diff --git a/.changeset/big-dogs-change.md b/.changeset/big-dogs-change.md new file mode 100644 index 000000000000..d068a7f60839 --- /dev/null +++ b/.changeset/big-dogs-change.md @@ -0,0 +1,26 @@ +--- +'@mastra/core': minor +'mastracode': patch +--- + +Renamed the AgentController interval API. `heartbeatHandlers` is now `intervalHandlers`, the `HeartbeatHandler` type is now `IntervalHandler`, and the `removeHeartbeat()`/`stopHeartbeats()` methods are now `removeInterval()`/`stopIntervals()`. This better reflects that these are fixed-interval background tasks, not liveness pings, and is distinct from the unrelated `mastra.heartbeats` scheduled-agent feature. + +**Before** + +```ts +const { controller } = await createMastraCode({ + heartbeatHandlers: [{ id: 'sync', intervalMs: 60_000, handler: async () => {} }], +}); +await controller.removeHeartbeat({ id: 'sync' }); +await controller.stopHeartbeats(); +``` + +**After** + +```ts +const { controller } = await createMastraCode({ + intervalHandlers: [{ id: 'sync', intervalMs: 60_000, handler: async () => {} }], +}); +await controller.removeInterval({ id: 'sync' }); +await controller.stopIntervals(); +``` diff --git a/.changeset/calm-spiders-give.md b/.changeset/calm-spiders-give.md new file mode 100644 index 000000000000..29d24252f22a --- /dev/null +++ b/.changeset/calm-spiders-give.md @@ -0,0 +1,5 @@ +--- +'@mastra/core': patch +--- + +Fixed thread metadata being lost when a processor or working memory writes to it during an agent run. The thread is re-saved when the run finishes, and it was using a stale in-memory snapshot that overwrote any metadata written mid-run via updateThread. The agent now re-reads the latest persisted thread before that save, so mid-run metadata is preserved. Affects all storage backends (Postgres, LibSQL, and others). Fixes #16216. diff --git a/.changeset/chatty-clowns-push.md b/.changeset/chatty-clowns-push.md new file mode 100644 index 000000000000..3e59837a96aa --- /dev/null +++ b/.changeset/chatty-clowns-push.md @@ -0,0 +1,5 @@ +--- +'@mastra/rag': minor +--- + +Added MongoDBConfig to DatabaseConfig, exposing numCandidates for MongoDB Atlas Vector Search queries via the RAG tool layer. diff --git a/.changeset/cold-tigers-move.md b/.changeset/cold-tigers-move.md new file mode 100644 index 000000000000..6abde2d7c596 --- /dev/null +++ b/.changeset/cold-tigers-move.md @@ -0,0 +1,27 @@ +--- +'@mastra/core': minor +--- + +Added `createCodingAgent` factory and a reusable `buildBasePrompt` so other projects can build a coding agent on top of the same defaults MastraCode uses. + +The factory wires sensible, portable defaults that you can override per field: + +- **Workspace** — a local filesystem + sandbox rooted at `process.cwd()` (set `basePath`, pass your own `workspace`, or pass `workspace: undefined` to opt out entirely). +- **Task signals** — `TaskSignalProvider` so a task list persists across turns. +- **Error handling** — retries on `ECONNRESET` and bad-request errors, plus prefill and provider-history compatibility processors. +- **Goal judging** — the default goal judge prompt. + +`buildBasePrompt` is parameterized with `productName`, `coAuthorName` (both default to "Mastra Code"), and `coAuthorEmail` (defaults to "noreply@mastra.ai"), so you can brand the system prompt and commit trailer without forking it. + +```ts +import { createCodingAgent } from '@mastra/core/coding-agent'; + +const agent = createCodingAgent({ + id: 'my-coding-agent', + name: 'My Coding Agent', + model: 'openai/gpt-5', + instructions: 'You help with my project.', + tools: {}, + basePath: '/path/to/repo', +}); +``` diff --git a/.changeset/crisp-geckos-do.md b/.changeset/crisp-geckos-do.md new file mode 100644 index 000000000000..c287a8a5c4ad --- /dev/null +++ b/.changeset/crisp-geckos-do.md @@ -0,0 +1,36 @@ +--- +'mastracode': minor +--- + +Improved push-to-talk voice input in the MastraCode TUI. Enable it with `/voice`, hold the spacebar to dictate, and release to finish — your speech streams into the input box in real time. + +**Choose how you dictate** + +`/voice` is now an interactive settings menu. You can toggle voice on/off, pick a transcription engine, and (for cloud transcription) pick a provider and model. Run `/voice status` to see the current engine, provider/model, and whether it's ready to use. The quick toggles `/voice on` and `/voice off` still work. + +**On-device transcription on macOS** + +On macOS, voice now defaults to a native on-device engine (Apple's `SFSpeechRecognizer`). It's free, works offline, and streams words into the input box with low latency — no API key required. + +When you turn voice on, the TUI checks the macOS Microphone and Speech Recognition permissions for you and guides you through whatever is needed: if access is blocked it offers to open the exact Privacy & Security pane (and tells you to enable "MastraCode Voice" there); if macOS simply hasn't asked yet, it explains that the first time you hold space it will prompt and you should click Allow. The same guidance appears in `/voice status` and, if dictation ever fails on a permission problem, alongside the error — so you're never left guessing what to do. + +**Multiple cloud providers, not just OpenAI** + +Cloud transcription is no longer locked to OpenAI Whisper. You can pick from several providers — OpenAI, Groq and other OpenAI-compatible Whisper hosts, plus Deepgram via its own SDK — and choose a model for each. Set the matching API key via the provider's environment variable or `/api-keys`; if a key is missing, `/voice` points you to `/api-keys`. + +You still need a local audio recorder on your `PATH` for the cloud engine — `rec`/`sox` (recommended) or `ffmpeg` on macOS, and `pw-record`/`parecord`/`arecord`/`sox` on Linux. Non-macOS systems default to the cloud engine. + +**Reliability fixes** + +macOS native engine: + +- It now triggers the permission prompt and works end-to-end. The on-device recognizer ships as a proper `.app` bundle — `Contents/MacOS/` plus a generated `Contents/Info.plist` with the Speech Recognition and Microphone usage descriptions — that is ad-hoc signed (`codesign -f -s -`) so the plist binds into the signature. +- The bundle is launched through macOS LaunchServices (`open`), the only launch path that makes macOS show the permission dialogs. A bare command-line executable — even one with an embedded, signed Info.plist — never fires `SFSpeechRecognizer.requestAuthorization`, so the Allow prompt never appeared and the recognizer was silently denied. +- Because a LaunchServices-launched app has no stdin/stdout pipe back to the CLI, the recognizer talks to the TUI over files: it appends newline-delimited JSON events to an events file the engine tails, and the engine asks it to flush a final result and quit by writing a stop sentinel file the recognizer polls for. The engine buffers partial lines so an event split across reads is never dropped, and it lets the recognizer see the stop sentinel before tearing the process down. +- `/voice status` does a real readiness check: it verifies the Swift toolchain and probes the actual Speech Recognition and Microphone permission state, naming exactly which one to enable in System Settings (and explaining the first-run prompt when permission hasn't been requested yet). The probe runs through the same `.app` bundle so it reads authorization under the bundle's TCC identity — probing the loose binary would query a different identity and wrongly report "not granted". +- If the recognizer exits before it can start (suppressed prompt or TCC kill), the engine surfaces a clear, actionable error with its captured output. A not-yet-determined permission state is no longer dressed up as the cause of a real crash, so you no longer see a misleading "macOS will prompt next time" message in red. The recognizer's Swift source and plist ship with the built CLI so the bundle can be built on first use. + +Cloud engine: + +- The live-partial loop is more robust: non-overlapping ticks and de-duplicated partials, and the terminal callback always fires on stop (final transcript, empty result, or error). +- First-dictation latency is reduced: the provider client is built once per dictation and reused across ticks so its HTTP connection stays warm (keep-alive), removing the DNS + TLS handshake cost that made the first dictation lag. The loop also polls at a short cadence until the recorder produces usable audio, so the opening partial fires as soon as there's something to transcribe. diff --git a/.changeset/durable-agent-parity-phase-1.md b/.changeset/durable-agent-parity-phase-1.md new file mode 100644 index 000000000000..5d4c5bee1f50 --- /dev/null +++ b/.changeset/durable-agent-parity-phase-1.md @@ -0,0 +1,9 @@ +--- +'@mastra/core': patch +--- + +`DurableAgent` now matches `Agent` behavior in three places where the durable loop previously diverged: + +- `isTaskComplete` scorers receive `requestContext` as `customContext`, so the same scorer code works on both agents. Only JSON-serializable entries from `requestContext` are forwarded; non-serializable values are dropped. Do not store secrets in `RequestContext` if you persist durable agent snapshots. +- Provider-defined tools (e.g. OpenAI `web_search`) resolve and execute when invoked by the model, instead of surfacing as `ToolNotFoundError`. +- Each iteration of a multi-step durable run produces a distinct assistant `messageId`, matching the non-durable loop and unblocking downstream consumers (signal drains, audit logs, replay) that key off message identity. diff --git a/.changeset/early-spoons-joke.md b/.changeset/early-spoons-joke.md new file mode 100644 index 000000000000..809e7fe3a9e2 --- /dev/null +++ b/.changeset/early-spoons-joke.md @@ -0,0 +1,18 @@ +--- +'@mastra/inngest': minor +--- + +Added support for the fine-grained authorization (FGA) `actor` signal on the Inngest execution engine. + +Workflows running on the Inngest engine can now pass a trusted `actor` through `run.start()`, `startAsync()`, `resume()`, `stream()`, and `timeTravel()`. The signal is re-threaded across durable step and nested-workflow boundaries, so every nested agent, tool, and memory FGA check sees the same actor. Previously `actor` was only threaded through the default engine, so trusted background workflows on Inngest lost the membership bypass at each step re-entry. + +**Usage** + +```ts +const run = await workflow.createRun(); +await run.start({ + inputData, + requestContext, // includes organizationId / tenant scope + actor: { actorKind: 'system', sourceWorkflow: 'nightly-sync' }, +}); +``` diff --git a/.changeset/eighty-points-sneeze.md b/.changeset/eighty-points-sneeze.md new file mode 100644 index 000000000000..9c1796bd4e4f --- /dev/null +++ b/.changeset/eighty-points-sneeze.md @@ -0,0 +1,5 @@ +--- +'@mastra/core': patch +--- + +add agent reference to processor execution context diff --git a/.changeset/few-planets-invite.md b/.changeset/few-planets-invite.md new file mode 100644 index 000000000000..cccbbfa8781d --- /dev/null +++ b/.changeset/few-planets-invite.md @@ -0,0 +1,18 @@ +--- +'@mastra/mesa': minor +--- + +Added a Mesa filesystem provider for Mastra workspaces. + +```ts +import { Workspace } from '@mastra/core/workspace'; +import { MesaFilesystem } from '@mastra/mesa'; + +const workspace = new Workspace({ + filesystem: new MesaFilesystem({ + apiKey: process.env.MESA_API_KEY, + org: 'acme', + repos: [{ name: 'docs', bookmark: 'main' }], + }), +}); +``` diff --git a/.changeset/fix-client-js-logs-pagination-zero.md b/.changeset/fix-client-js-logs-pagination-zero.md new file mode 100644 index 000000000000..9b30d551c274 --- /dev/null +++ b/.changeset/fix-client-js-logs-pagination-zero.md @@ -0,0 +1,5 @@ +--- +'@mastra/client-js': patch +--- + +Fix `listLogs` and `getLogForRun` dropping the `page` and `perPage` query parameters when they are `0`. Requesting the first page with `page: 0` (or `perPage: 0`) now sends those values instead of falling back to the server defaults. Closes #18631. diff --git a/.changeset/fix-deployer-env-dollar-corruption.md b/.changeset/fix-deployer-env-dollar-corruption.md new file mode 100644 index 000000000000..9bcf6c7fb577 --- /dev/null +++ b/.changeset/fix-deployer-env-dollar-corruption.md @@ -0,0 +1,5 @@ +--- +'@mastra/deployer': patch +--- + +Fix `FileEnvService.setEnvValue` corrupting env values that contain `$` when updating an existing key. Values such as database URLs and passwords that include `$&`, `$$`, or `$1` are now written exactly as provided instead of being mangled by `String.prototype.replace` special patterns. Closes #18633. diff --git a/.changeset/fix-fs-agents-dev-entry-write-order.md b/.changeset/fix-fs-agents-dev-entry-write-order.md new file mode 100644 index 000000000000..0f0952686f63 --- /dev/null +++ b/.changeset/fix-fs-agents-dev-entry-write-order.md @@ -0,0 +1,12 @@ +--- +'@mastra/deployer': patch +'mastra': patch +--- + +Fix `ENOENT: .mastra-fs-agents-entry.mjs` when running `mastra dev`/`mastra build` in a project that uses file-based agents. The generated fs-agents wrapper entry was written before `bundler.prepare()` emptied the output directory, so it was wiped before the bundler could read it. Wrapper generation is now split: `prepareFsAgentsEntry` returns the generated source without writing, and the new `writeFsAgentsEntry` writes it after `prepare()` runs. + +```ts +const fsAgents = await prepareFsAgentsEntry({ entryFile, mastraDir, outputDirectory }); +await bundler.prepare(outputDirectory); // empties output dir +await writeFsAgentsEntry(fsAgents); // wrapper now survives for the bundler +``` diff --git a/.changeset/fix-inmemory-getworkflowrunbyid-optional-name.md b/.changeset/fix-inmemory-getworkflowrunbyid-optional-name.md new file mode 100644 index 000000000000..4ec649c4a2fb --- /dev/null +++ b/.changeset/fix-inmemory-getworkflowrunbyid-optional-name.md @@ -0,0 +1,5 @@ +--- +'@mastra/core': patch +--- + +Fix in-memory workflow storage `getWorkflowRunById` returning `null` when `workflowName` is omitted. `workflowName` is optional in the storage contract and the pg/libsql adapters match by `runId` alone when it is not provided, but the in-memory store always compared `workflow_name === workflowName`, which never matched for an undefined name. It now matches by `runId`, only filters by `workflowName` when provided, and returns the most recent run for parity with the persistent adapters. Closes #18585. diff --git a/.changeset/fix-inmemory-observability-exclusive-bounds.md b/.changeset/fix-inmemory-observability-exclusive-bounds.md new file mode 100644 index 000000000000..2a44e58853d1 --- /dev/null +++ b/.changeset/fix-inmemory-observability-exclusive-bounds.md @@ -0,0 +1,5 @@ +--- +'@mastra/core': patch +--- + +Fix in-memory observability `listTraces` ignoring the `startExclusive` and `endExclusive` flags on `startedAt`/`endedAt` filters. Exclusive date-range bounds now drop a trace that sits exactly on the boundary, matching the pg/libsql adapters (and the in-memory log/metric filters). Closes #18635. diff --git a/.changeset/fix-inmemory-scores-scorerid-sort.md b/.changeset/fix-inmemory-scores-scorerid-sort.md new file mode 100644 index 000000000000..88472641a7f2 --- /dev/null +++ b/.changeset/fix-inmemory-scores-scorerid-sort.md @@ -0,0 +1,5 @@ +--- +'@mastra/core': patch +--- + +Fix in-memory scores store `listScoresByScorerId` returning scores in insertion order instead of newest first. The pg and libsql adapters order by `createdAt DESC`, and the sibling `listScoresBySpan` already does, so the in-memory store now sorts the same way before paginating. Closes #18618. diff --git a/.changeset/fix-schema-compat-v4-string-format-drop.md b/.changeset/fix-schema-compat-v4-string-format-drop.md new file mode 100644 index 000000000000..3e69931cd534 --- /dev/null +++ b/.changeset/fix-schema-compat-v4-string-format-drop.md @@ -0,0 +1,5 @@ +--- +'@mastra/schema-compat': patch +--- + +Fix the Zod v4 string handler silently dropping unrecognized `string_format` checks. Formats without a textual description (such as `ipv4`, `ipv6`, `datetime`, `date`, `time`, `base64`, `cuid2`, `ulid`, `nanoid`, `jwt`) are now preserved as validation instead of being removed, so schemas using them keep rejecting invalid input. Closes #18634. diff --git a/.changeset/fix-zod-v4-date-constraint-descriptions.md b/.changeset/fix-zod-v4-date-constraint-descriptions.md new file mode 100644 index 000000000000..4f8dfe35560c --- /dev/null +++ b/.changeset/fix-zod-v4-date-constraint-descriptions.md @@ -0,0 +1,5 @@ +--- +'@mastra/schema-compat': patch +--- + +Fix inverted date constraint descriptions in the Zod v4 schema handler. `z.date().min()` and `z.date().max()` were described with their bounds swapped (a lower bound was labelled "older than" and an upper bound "newer than"), so the schema sent to the model stated the opposite and impossible constraint. The handler now matches Zod semantics and the existing v3 handler. Closes #18581. diff --git a/.changeset/floppy-towns-notice.md b/.changeset/floppy-towns-notice.md new file mode 100644 index 000000000000..b9380174060d --- /dev/null +++ b/.changeset/floppy-towns-notice.md @@ -0,0 +1,5 @@ +--- +'@mastra/railway': patch +--- + +Fixed Railway sandbox templates so they are built once during sandbox creation. diff --git a/.changeset/forty-carpets-unite.md b/.changeset/forty-carpets-unite.md new file mode 100644 index 000000000000..3d23f459505d --- /dev/null +++ b/.changeset/forty-carpets-unite.md @@ -0,0 +1,13 @@ +--- +'@mastra/memory': minor +'@mastra/core': patch +--- + +add observational memory extractors + +Introduces a public Extractor API for Observational Memory +with inline XML extraction and structured follow-up modes. +Includes built-in extractors for current task, suggested +response, and thread title. Persists extracted values into +thread OM metadata with key-level merging and carry-forward +into future observer/reflector prompts. diff --git a/.changeset/fs-agents-cli.md b/.changeset/fs-agents-cli.md new file mode 100644 index 000000000000..1ba87148362e --- /dev/null +++ b/.changeset/fs-agents-cli.md @@ -0,0 +1,16 @@ +--- +'mastra': minor +--- + +`mastra dev` and `mastra build` now pick up file-based agents defined under `src/mastra/agents//`. Agents created this way appear in Studio and respond just like agents registered in code, and the two styles can be mixed in one project. Files committed under `agents//workspace/` are mirrored into the agent's workspace so it starts with them on disk. Agents can also declare subagents under `agents//subagents//`, which the agent can delegate to as a tool named after the directory. + +```text +src/mastra/agents/weather/ + config.ts # export default agentConfig({ model: 'openai/gpt-4o' }) + instructions.md + tools/get_weather.ts +``` + +```bash +mastra dev # discovers and registers src/mastra/agents/weather automatically +``` diff --git a/.changeset/fs-agents-deployer.md b/.changeset/fs-agents-deployer.md new file mode 100644 index 000000000000..36ab0fef3af6 --- /dev/null +++ b/.changeset/fs-agents-deployer.md @@ -0,0 +1,14 @@ +--- +'@mastra/deployer': minor +--- + +You can now define agents by file convention instead of registering each one in code: drop a directory under `src/mastra/agents//`, run a Mastra build/dev, and the agent is bundled and registered onto your Mastra instance automatically. A directory becomes an agent when it has a `config.ts` or `instructions.md`; `tools/*.ts` add tools, `skills/` add skills (a `createSkill()` module, a packaged `SKILL.md` with its `references/`, or a flat `.md`), a `memory.ts` default export supplies the agent's `memory`, and `subagents//` (one level deep) add delegatable subagents. Each agent gets a default workspace unless `workspace.ts` / `config.workspace` overrides it, and files committed under `agents//workspace/` are mirrored into the bundle to seed that workspace at runtime. Projects with no file-based agents are unaffected — the original entry is used unchanged. + +```text +src/mastra/agents/weather/ + config.ts # export default agentConfig({ model: 'openai/gpt-4o' }) + instructions.md + memory.ts # export default new Memory() + tools/get_weather.ts + workspace/cities.json # mirrored into the agent's workspace +``` diff --git a/.changeset/fs-agents-server-source.md b/.changeset/fs-agents-server-source.md new file mode 100644 index 000000000000..a3638a695cfd --- /dev/null +++ b/.changeset/fs-agents-server-source.md @@ -0,0 +1,12 @@ +--- +'@mastra/server': patch +--- + +Allow `'fs'` as an agent/scorer definition source in the server handlers and response schemas. File-based agents are registered with `source: 'fs'`, and the scorer/agent list endpoints now surface and validate that value instead of failing schema validation. + +```ts +// GET /api/agents now returns file-based agents alongside code/stored ones: +{ + "weather": { "name": "weather", "source": "fs" /* was rejected before */ } +} +``` diff --git a/.changeset/full-numbers-occur.md b/.changeset/full-numbers-occur.md new file mode 100644 index 000000000000..7d34a09cd5d8 --- /dev/null +++ b/.changeset/full-numbers-occur.md @@ -0,0 +1,5 @@ +--- +'@mastra/server': patch +--- + +Fixed inline skills (created via createSkill()) not appearing in the Dev Portal. The server now uses agent.listSkills() and agent.getSkill() which return both inline and workspace skills, instead of only querying workspace skills. diff --git a/.changeset/gateway-redos-fix.md b/.changeset/gateway-redos-fix.md new file mode 100644 index 000000000000..d72770b4b4e3 --- /dev/null +++ b/.changeset/gateway-redos-fix.md @@ -0,0 +1,5 @@ +--- +'@mastra/core': patch +--- + +Fix a polynomial ReDoS in the model gateway error matcher. The `Missing .+ environment variable` pattern used to classify expected missing-auth errors could backtrack catastrophically on adversarial error messages; it now uses `Missing [^ ]+ environment variable`, which matches the same real messages without the ambiguous overlap. diff --git a/.changeset/heartbeats-on-accepted.md b/.changeset/heartbeats-on-accepted.md new file mode 100644 index 000000000000..082a2efdd609 --- /dev/null +++ b/.changeset/heartbeats-on-accepted.md @@ -0,0 +1,84 @@ +--- +'@mastra/core': minor +'@mastra/server': minor +'@mastra/client-js': minor +--- + +**Added** heartbeats: schedule an agent to run on a recurring cron, either inside an existing conversation thread or on its own. + +A heartbeat fires a prompt to an agent on a schedule. When it has a thread, the run is delivered into that thread as a normal agent signal, so anything watching the thread sees it like any other message; without a thread, the agent just runs in isolation. Each heartbeat has its own id and an optional `name`, so one agent or thread can have several heartbeats with different schedules and prompts. The id is generated for you, or you can pass your own `id` to `create` for a stable handle (it's normalized to `hb_`). Heartbeats are persisted, so they keep firing across process restarts with no extra setup. + +```ts +const hb = await mastra.heartbeats.create({ + agentId: 'chef', + name: 'morning-checkin', + threadId, + resourceId, + cron: '*/5 * * * *', + prompt: 'Check in on the user', + ifActive: { behavior: 'discard' }, // skip if the user is mid-conversation + ifIdle: { behavior: 'wake' }, // wake the agent if the thread is idle +}); + +// Threadless: run the agent on a cron with no conversation. +await mastra.heartbeats.create({ + agentId: 'chef', + cron: '0 * * * *', + prompt: 'Run the hourly summary', +}); + +await mastra.heartbeats.list({ agentId: 'chef' }); +await mastra.heartbeats.get(hb.id); +await mastra.heartbeats.update(hb.id, { prompt: 'check in gently' }); +await mastra.heartbeats.pause(hb.id); +await mastra.heartbeats.resume(hb.id); +await mastra.heartbeats.run(hb.id); // fire once now +await mastra.heartbeats.delete(hb.id); +``` + +The same CRUD is available over HTTP through `@mastra/server` (under `/api/heartbeats`) and as top-level methods on the `@mastra/client-js` client (`client.createHeartbeat`, `client.getHeartbeat`, `client.listHeartbeats`, etc.). + +**Lifecycle hooks** + +React to heartbeat runs via `heartbeat` on the `Mastra` constructor. It's a single hook bundle that runs for every agent's heartbeats; each hook receives the firing `agentId` so you can branch on it. `prepare` resolves fire-time parameters (for example, creating a fresh thread per fire), and `onFinish` / `onError` / `onAbort` mirror `agent.stream`. + +```ts +new Mastra({ + // ... + heartbeat: { + // Return overrides, `null` to skip this fire, or `undefined` to use defaults. + prepare: async ({ agentId, heartbeat }) => { + if (agentId === 'chef' && heartbeat.name === 'daily-digest') { + return { threadId: await createDailyThread(), resourceId: 'slack:U095PUH0FKL' }; + } + }, + onFinish: ({ agentId, outcome, result, heartbeat }) => { + metrics.record({ agentId, heartbeat: heartbeat.name, outcome }); + }, + onError: ({ agentId, error, phase, heartbeat }) => { + alerts.send(`heartbeat ${agentId}/${heartbeat.name} failed in ${phase}: ${error.message}`); + }, + }, +}); +``` + +**Signal shaping** + +A heartbeat fire surfaces to the agent as a signal. By default it uses the `notification` type and renders as ``; override `signalType` and `tagName` to change either. `ifActive` and `ifIdle` mirror the `agent.sendSignal` options shape (`{ behavior, attributes }`, plus `streamOptions` on `ifIdle`) and stay JSON-serializable so they persist with the schedule. `ifIdle.streamOptions` currently accepts `requestContext`, which is rehydrated onto the woken run. Top-level `attributes` are rendered on the signal tag, and top-level `providerOptions` are merged into the signal payload on every fire. + +```ts +await mastra.heartbeats.create({ + agentId: 'chef', + threadId, + resourceId, + cron: '*/5 * * * *', + prompt: 'Check in on the user', + tagName: 'check-in', // renders as + attributes: { source: 'cron' }, + providerOptions: { openai: { store: false } }, + ifIdle: { + behavior: 'wake', + streamOptions: { requestContext: { locale: 'en-US' } }, + }, +}); +``` diff --git a/.changeset/honest-llamas-dress.md b/.changeset/honest-llamas-dress.md new file mode 100644 index 000000000000..cd8d393e6248 --- /dev/null +++ b/.changeset/honest-llamas-dress.md @@ -0,0 +1,22 @@ +--- +'@mastra/memory': minor +'@mastra/core': minor +--- + +add OM-managed working memory + +Adds `observationalMemory.observation.manageWorkingMemory` so the Observer can update working memory automatically instead of requiring the main agent to call the working memory tool. + +```ts +new Memory({ + options: { + workingMemory: { enabled: true }, + observationalMemory: { + enabled: true, + observation: { manageWorkingMemory: true }, + }, + }, +}) +``` + +This option adds `WorkingMemoryExtractor`, defaults `workingMemory.agentManaged` to `false`, and defaults `workingMemory.useStateSignals` to `true` when working memory is enabled. Set `workingMemory.agentManaged: true` to keep the main agent's working memory tool and instructions enabled. diff --git a/.changeset/inngest-durable-agent-parity.md b/.changeset/inngest-durable-agent-parity.md new file mode 100644 index 000000000000..299e56d582d3 --- /dev/null +++ b/.changeset/inngest-durable-agent-parity.md @@ -0,0 +1,29 @@ +--- +'@mastra/inngest': minor +'@mastra/core': patch +--- + +Bring `InngestAgent` (Inngest-backed durable agent) to parity with `DurableAgent` for per-call execution options, abort handling, idle-aware resume, and `generate()`. + +`InngestAgent.stream()` and `resume()` now accept the same execution-option surface as `DurableAgent`, including `stopWhen`, `activeTools`, `structuredOutput`, `versions`, `system`, `disableBackgroundTasks`, `tracingOptions`, `actor`, `transform`, `prepareStep`, `isTaskComplete`, `delegation`, function-form `requireToolApproval`, and the lifecycle callbacks `onAbort` / `onIterationComplete`. Closure-shaped options (`prepareStep`, `transform`, function-form `isTaskComplete` / `requireToolApproval`, `stopWhen` callbacks) continue to work in-process; they degrade after a worker hop the same way they do for in-memory `DurableAgent`. + +```ts +const result = await inngestAgent.stream(messages, { + runId: 'run-1', + abortSignal: controller.signal, + stopWhen: stepCountIs(5), + onIterationComplete: ({ iteration }) => console.log('done', iteration), +}); + +// Cancel a live run from the caller +result.abort(); + +// Resume and drive the run to completion in a single call +await inngestAgent.resume({ runId: 'run-1', resumeData, untilIdle: true }); + +// Durable equivalents of Agent.generate / resumeGenerate +const out = await inngestAgent.generate(messages, { runId: 'run-2' }); +const resumed = await inngestAgent.resumeGenerate({ runId: 'run-2', resumeData }); +``` + +`@mastra/core` re-exports `globalRunRegistry` and `runResumeDurableStreamUntilIdle` from `@mastra/core/agent/durable` so durable-agent integrations can share the same registry and idle-wrapper plumbing. diff --git a/.changeset/jolly-results-send.md b/.changeset/jolly-results-send.md new file mode 100644 index 000000000000..b818b96bb414 --- /dev/null +++ b/.changeset/jolly-results-send.md @@ -0,0 +1,49 @@ +--- +'@mastra/vercel': minor +--- + +**Breaking change:** Renamed the Vercel sandbox exports to make the MicroVM and serverless implementations explicit. `VercelSandbox` now refers to the MicroVM-backed Vercel Sandbox product. The serverless implementation is now exported as `VercelServerlessSandbox`. + +- If you have been using `VercelSandbox` in your code, you should update your imports to use `VercelServerlessSandbox` instead. + + ```diff + -import { VercelSandbox } from '@mastra/vercel'; + -import type { VercelSandboxOptions } from '@mastra/vercel'; + +import { VercelServerlessSandbox } from '@mastra/vercel'; + +import type { VercelServerlessSandboxOptions } from '@mastra/vercel'; + + -const sandbox = new VercelSandbox({ + +const sandbox = new VercelServerlessSandbox({ + token: process.env.VERCEL_TOKEN, + }); + + -const options: VercelSandboxOptions = { + +const options: VercelServerlessSandboxOptions = { + token: process.env.VERCEL_TOKEN, + }; + ``` + +- If you have been using `VercelMicroVMSandbox` in your code, you should update your imports to use `VercelSandbox` instead. + + ```diff + -import { VercelMicroVMSandbox } from '@mastra/vercel'; + +import { VercelSandbox } from '@mastra/vercel'; + -import type { VercelMicroVMSandboxOptions } from '@mastra/vercel'; + +import type { VercelSandboxOptions } from '@mastra/vercel'; + + -const sandbox = new VercelMicroVMSandbox(); + +const sandbox = new VercelSandbox(); + + -const options: VercelMicroVMSandboxOptions = { + +const options: VercelSandboxOptions = { + runtime: 'node24', + }; + ``` + +- Provider descriptors are also split by runtime: + + ```ts + import { vercelSandboxProvider, vercelServerlessSandboxProvider } from '@mastra/vercel'; + ``` + + Use `vercelSandboxProvider` for MicroVM-backed Vercel Sandbox instances and `vercelServerlessSandboxProvider` for Vercel Functions-backed serverless instances. diff --git a/.changeset/light-mails-guess.md b/.changeset/light-mails-guess.md new file mode 100644 index 000000000000..c630cd1ef658 --- /dev/null +++ b/.changeset/light-mails-guess.md @@ -0,0 +1,5 @@ +--- +'@mastra/ai-sdk': patch +--- + +Fixed `chatRoute` and `handleChatStream` ignoring agent instructions and tools edited through the Agent Editor. When an editor is configured, the chat endpoint now applies the agent's stored overrides just like Studio does, instead of running the bare code-defined agent. Previously, instructions edited in the editor were silently dropped and the agent answered as if it had none. diff --git a/.changeset/long-worlds-wish.md b/.changeset/long-worlds-wish.md new file mode 100644 index 000000000000..2669aaeb6ef6 --- /dev/null +++ b/.changeset/long-worlds-wish.md @@ -0,0 +1,27 @@ +--- +'mastracode': minor +--- + +Reworked headless mode into a real programmatic API. You can now run MastraCode from Node/CI code with `runMC({ controller, session, prompt })`, which returns a handle that streams live events as an async-iterable and resolves to a typed result with status, text, usage, tool calls, and an exit code — it never calls `process.exit` or writes to global streams. The CLI is now a thin adapter over the same runner. + +```ts +import { createMastraCode, runMC } from 'mastracode'; + +const { controller, session } = await createMastraCode({ settingsPath }); + +const run = runMC({ controller, session, prompt: 'Fix the failing test' }); +for await (const event of run) { + // optional: react to live progress events +} +const result = await run.result; +process.exitCode = result.exitCode; // 0 success, 1 error/aborted/max-turns, 2 timeout +``` + +Approvals and suspensions are resolved by a pluggable policy (default keeps the previous auto-approve behavior); a built-in `denyPolicy` plus `permissionModeToPolicy` are exported, and a new `--permission-mode {auto|deny}` flag selects between them. A new `--max-turns` flag (and `maxTurns` option) caps assistant turns and reports a `max_turns` status with exit code 1 when the cap is hit mid-task. + +Breaking changes / migration: + +- Output flags consolidated: the `--format` and `--output-format` flags are replaced by a single `--output` flag with values `human`, `json`, or `jsonl`. + - Before: `mastracode --prompt "..." --output-format json` + - After: `mastracode --prompt "..." --output json` +- Programmatic entry point changed from the old `runHeadless`/`headlessMain` to `runMC` (pure runner) and `runMCCli` (CLI adapter). `runMC` takes an already-built `controller` + `session` from `createMastraCode` and returns a result object instead of only an exit code. diff --git a/.changeset/many-worlds-switch.md b/.changeset/many-worlds-switch.md new file mode 100644 index 000000000000..0c7784f89578 --- /dev/null +++ b/.changeset/many-worlds-switch.md @@ -0,0 +1,6 @@ +--- +'@mastra/core': patch +'mastracode': patch +--- + +Amazon Bedrock models now appear under their own `amazon-bedrock/` provider in the model picker instead of the `mastracode/amazon-bedrock/` namespace. Bedrock is resolved through a dedicated Amazon Bedrock gateway that authenticates with the AWS credential chain (SigV4) and surfaces models from the public models.dev catalog. Saved model selections using the previous `mastracode/amazon-bedrock/...` IDs are still resolved at runtime, so existing config keeps working. diff --git a/.changeset/mastracode-amazon-bedrock.md b/.changeset/mastracode-amazon-bedrock.md new file mode 100644 index 000000000000..dfeb49ab37be --- /dev/null +++ b/.changeset/mastracode-amazon-bedrock.md @@ -0,0 +1,15 @@ +--- +'mastracode': minor +--- + +Added Amazon Bedrock as a model provider in Mastra Code. Bedrock models surfaced by models.dev are now selectable via `/models` and usable as build/plan/fast or subagent models with the `amazon-bedrock/` form. Models are only offered when AWS credentials are detected. + +Bedrock authenticates with AWS SigV4 through the standard AWS credential chain (`fromNodeProviderChain`), so environment variables, shared `~/.aws` profiles, SSO, and container/instance roles all work without extra configuration. Set `AWS_REGION` (defaults to `us-east-1`) to target a region, or `AWS_BEARER_TOKEN_BEDROCK` to use Bedrock API-key auth instead. + +```sh +# Pick a Bedrock model from the picker +/models + +# Or set it directly via the build/plan/fast slots +/build amazon-bedrock/ +``` diff --git a/.changeset/mastracode-coding-agent-factory.md b/.changeset/mastracode-coding-agent-factory.md new file mode 100644 index 000000000000..ba3fe81aa516 --- /dev/null +++ b/.changeset/mastracode-coding-agent-factory.md @@ -0,0 +1,5 @@ +--- +'mastracode': patch +--- + +MastraCode now builds its code agent through the new `createCodingAgent` factory from `@mastra/core/coding-agent` instead of constructing the `Agent` inline. No user-facing behavior changes — the system prompt and agent configuration are unchanged. diff --git a/.changeset/mastracode-web-cloud.md b/.changeset/mastracode-web-cloud.md new file mode 100644 index 000000000000..0cc79542db54 --- /dev/null +++ b/.changeset/mastracode-web-cloud.md @@ -0,0 +1,42 @@ +--- +'mastracode': minor +--- + +Turned MastraCode Web into a multi-org cloud coding service. When WorkOS auth and a GitHub App are configured, a team can sign in, connect their repos, and run coding agents that branch, commit, push, and open pull requests — all from isolated cloud sandboxes. When the relevant environment variables are absent, the server and UI behave exactly as before (local-path projects, single shared store, no auth UI), so this is fully opt-in. + +**Authentication (WorkOS AuthKit).** Setting `WORKOS_API_KEY` and `WORKOS_CLIENT_ID` protects every route: unauthenticated visitors are redirected to the WorkOS hosted login, signed-in users get an encrypted session, expired sessions bounce back to login, and the sidebar shows the signed-in email with a Sign out button. Users with no WorkOS organization get a personal org bootstrapped on first authenticated use (idempotent, with recovery from partial creations), so personal accounts can use org-scoped features without hand-creating an org. + +**Org-owned GitHub projects.** With the GitHub App env vars set (`GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_ID`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_SLUG`, `APP_DATABASE_URL`), users install/connect the app, pick repos they can access, and turn each into a project. The installation and connected repos belong to the WorkOS organization; the same repo can be connected independently by different orgs with no cross-visibility. Each repo is materialized into an isolated cloud sandbox on open — cloned (or pulled) inside the sandbox with a short-lived installation token that never reaches the browser and is scrubbed from the remote afterward. + +**Cloud coding-agent write-back.** From a connected repo, each user gets their own sandbox, git worktrees, and feature branches. The agent runs against the selected worktree (file edits and commands bind to its path) and can commit, push, and open pull requests via the in-sandbox `gh` CLI, authenticated with short-lived per-operation installation tokens. The sidebar shows a nested project → worktree → conversations tree with a "+ New worktree" affordance; conversations scope per worktree. + +**Sandbox providers.** A provider is selected automatically: Railway when `RAILWAY_API_TOKEN` is set, otherwise a local provider that runs git directly on the host (single-user local dev only — no tenant isolation). `MASTRACODE_SANDBOX_PROVIDER` overrides explicitly. Idle sandboxes are torn down and re-provisioned on the next open; a per-replica live-sandbox cap (`MASTRACODE_MAX_SANDBOXES`) and a per-user teardown route bound resource use. + +**Per-(org,user) state isolation.** Agent state (threads, messages, memory, recall vectors) is isolated by the `(organization, user)` pair, each backed by a dedicated libSQL database whose location is derived server-side from a hash of `(orgId, userId)` — never a client-supplied path. By default each tenant gets local libSQL files under `MASTRACODE_TENANT_DB_ROOT`; for hosted deployments, point each tenant at a remote libSQL/Turso database via `MASTRACODE_TENANT_DB_URL_TEMPLATE` (plus optional vector template and auth tokens). + +**Sandbox isolation hardening.** Commands run in the local sandbox receive only a sanitized allow-list of environment variables (PATH/HOME/locale/git config), so server secrets such as `GITHUB_APP_PRIVATE_KEY`, `WORKOS_API_KEY`, and `APP_DATABASE_URL` are never exposed to code running against an untrusted checkout. Sandbox filesystem write operations (write/append/copy/move/mkdir) now verify the destination's real path — including a symlinked parent directory — stays within the workspace root, preventing a malicious repo's symlink from redirecting writes outside the sandbox. + +**Multi-replica deployment hardening.** Per-(project,user) git writes are serialized across replicas with Postgres advisory locks (`MASTRACODE_DISTRIBUTED_LOCK`, on by default; requires `APP_DATABASE_URL`). OAuth/install state signing requires a replica-stable secret in multi-replica setups, in-memory tenant stacks are evicted by idle timeout and an LRU cap (`MASTRACODE_TENANT_IDLE_MINUTES`, `MASTRACODE_TENANT_MAX_APPS`), and `MASTRACODE_REQUIRE_REMOTE_TENANT_DB=1` fails startup when no shared remote tenant DB is configured. + +```bash +# Auth + GitHub App (opt-in) +WORKOS_API_KEY=sk_xxxxxxxx +WORKOS_CLIENT_ID=client_xxxxxxxx +GITHUB_APP_ID=123456 +GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" +GITHUB_APP_CLIENT_ID=Iv1.xxxxxxxx +GITHUB_APP_CLIENT_SECRET=xxxxxxxx +GITHUB_APP_SLUG=your-app-slug +APP_DATABASE_URL=postgres://user:pass@host:5432/mastracode_web + +# Multi-replica hosted deployment +GITHUB_APP_WEBHOOK_SECRET=... # replica-stable state signing +MASTRACODE_DISTRIBUTED_LOCK=1 # cross-replica git write locks +MASTRACODE_REQUIRE_REMOTE_TENANT_DB=1 # require shared remote tenant DBs +MASTRACODE_TENANT_DB_URL_TEMPLATE=libsql://{id}-org.turso.io +MASTRACODE_TENANT_IDLE_MINUTES=30 +MASTRACODE_TENANT_MAX_APPS=100 +MASTRACODE_MAX_SANDBOXES=50 +``` + +Still deferred: collaboration within a project (multiple users sharing one worktree/sandbox/branch), org admin/roles and membership management, and org-level project deletion. diff --git a/.changeset/mcp-http-url-env-expansion.md b/.changeset/mcp-http-url-env-expansion.md new file mode 100644 index 000000000000..2e6c426026e7 --- /dev/null +++ b/.changeset/mcp-http-url-env-expansion.md @@ -0,0 +1,5 @@ +--- +'mastracode': patch +--- + +Fixed MCP HTTP server URLs so `${VAR}` references resolve from the environment, the same way header values already do. A server configured with `"url": "${MCP_SERVER_URL}"` is now connected instead of being silently skipped as an invalid URL. diff --git a/.changeset/mcp-stdio-env-expansion.md b/.changeset/mcp-stdio-env-expansion.md new file mode 100644 index 000000000000..023775d83604 --- /dev/null +++ b/.changeset/mcp-stdio-env-expansion.md @@ -0,0 +1,17 @@ +--- +'mastracode': patch +--- + +MCP stdio servers now resolve `${VAR}` references in their `env` values from the host environment, matching the existing behavior for HTTP server headers. You can reference secrets from the environment instead of hardcoding them in `mcp.json`: + +```json +{ + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" } + } + } +} +``` diff --git a/.changeset/om-observe-hooks-provider-metadata.md b/.changeset/om-observe-hooks-provider-metadata.md new file mode 100644 index 000000000000..f0b346719871 --- /dev/null +++ b/.changeset/om-observe-hooks-provider-metadata.md @@ -0,0 +1,21 @@ +--- +"@mastra/memory": patch +--- + +Expose `providerMetadata` on Observational Memory `ObserveHooks` results + +`onObservationEnd` and `onReflectionEnd` now receive the OM model call's `providerMetadata` alongside `usage`, so you can read per-call provider details — for example the AI Gateway's cost and generation id — straight from the hook instead of wrapping the observer/reflector models in a model-stream middleware: + +```ts +const hooks: ObserveHooks = { + onObservationEnd: ({ usage, providerMetadata }) => { + const gateway = providerMetadata?.gateway; + recordCost({ tokens: usage?.totalTokens, cost: gateway?.cost, generationId: gateway?.generationId }); + }, + onReflectionEnd: ({ usage, providerMetadata }) => { + recordCost({ tokens: usage?.totalTokens, cost: providerMetadata?.gateway?.cost }); + }, +}; +``` + +The field is additive and optional, and is omitted entirely when the provider emits no metadata, so existing hook consumers are unaffected. For batched observations and multi-attempt reflections it reflects the last batch/attempt that emitted provider metadata. diff --git a/.changeset/open-plums-greet.md b/.changeset/open-plums-greet.md new file mode 100644 index 000000000000..8fe0975b46cf --- /dev/null +++ b/.changeset/open-plums-greet.md @@ -0,0 +1,9 @@ +--- +'@mastra/mongodb': patch +--- + +Made MongoDB store writes safe against partial failures, preventing orphaned records when an operation fails partway through. + +**Atomic multi-collection writes.** Creates, deletes, and updates across the agents, mcp-clients, mcp-servers, prompt-blocks, scorer-definitions, skills, workspaces, schedules, and datasets domains now run in a transaction on replica sets, so a failed write leaves no half-written state. On standalone servers (which can't run transactions) these degrade to sequential best-effort, matching the previous behavior. + +**Scalable cascade deletes.** Deleting a thread (with its messages) or a dataset (with its items) is deliberately *not* wrapped in a transaction, because those children are unbounded and a transactional delete is capped by MongoDB's 60-second transaction limit — a large thread or dataset would abort and become permanently undeletable. Instead the children are removed first and the parent record last, so a failure mid-delete leaves the parent in place and re-running the delete safely finishes the job. diff --git a/.changeset/petite-plants-play.md b/.changeset/petite-plants-play.md new file mode 100644 index 000000000000..68e300679c8e --- /dev/null +++ b/.changeset/petite-plants-play.md @@ -0,0 +1,5 @@ +--- +'@mastra/mcp': patch +--- + +Fixed @mastra/mcp crashing Cloudflare Workers at module initialization. MCPClient can now be safely imported on workerd without the Worker failing to start. diff --git a/.changeset/plenty-loops-create.md b/.changeset/plenty-loops-create.md new file mode 100644 index 000000000000..6f31fc4ff93a --- /dev/null +++ b/.changeset/plenty-loops-create.md @@ -0,0 +1,5 @@ +--- +'@mastra/core': patch +--- + +Fixed gs:// and s3:// file/image references being downloaded and corrupted into data: URIs during durable agent execution. The durable LLM step now forwards the model's supportedUrls (matching standard execution), so URLs a provider fetches natively (e.g. Vertex gs://) pass through as references instead of failing with "Failed to download asset" or being base64-wrapped. diff --git a/.changeset/pre.json b/.changeset/pre.json index ef2ee29fbd9e..72af8b170971 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -165,16 +165,64 @@ }, "changesets": [ "automated-provider-registry-20260627-011213", + "big-dogs-change", "busy-lions-boil", + "calm-spiders-give", + "chatty-clowns-push", "clear-cougars-decide", + "cold-tigers-move", "cool-singers-peel", "cozy-bottles-melt", + "crisp-geckos-do", + "durable-agent-parity-phase-1", + "early-spoons-joke", + "eighty-points-sneeze", + "fix-client-js-logs-pagination-zero", + "fix-deployer-env-dollar-corruption", + "fix-fs-agents-dev-entry-write-order", + "fix-inmemory-getworkflowrunbyid-optional-name", + "fix-inmemory-observability-exclusive-bounds", + "fix-inmemory-scores-scorerid-sort", "fix-qdrant-ts-expect-error-18572", + "fix-schema-compat-v4-string-format-drop", + "fix-zod-v4-date-constraint-descriptions", + "floppy-towns-notice", + "fs-agents-cli", + "fs-agents-deployer", + "fs-agents-server-source", + "full-numbers-occur", + "gateway-redos-fix", "green-birds-knock", + "heartbeats-on-accepted", "huge-maps-kick", + "inngest-durable-agent-parity", "inngest-observe-replay", + "jolly-results-send", "legal-dots-kick", - "solid-bees-shave", - "subagent-schema-cache" + "light-mails-guess", + "long-worlds-wish", + "many-worlds-switch", + "mastracode-amazon-bedrock", + "mastracode-coding-agent-factory", + "mastracode-web-cloud", + "mcp-http-url-env-expansion", + "mcp-stdio-env-expansion", + "om-observe-hooks-provider-metadata", + "open-plums-greet", + "petite-plants-play", + "plenty-loops-create", + "proud-zoos-enter", + "quick-dingos-eat", + "quick-walls-worry", + "quiet-cycles-repair", + "scorer-helper-signal-user-message", + "shaky-dingos-prove", + "shy-radios-slide", + "spicy-masks-matter", + "studio-metrics-vnext-gate", + "subagent-schema-cache", + "thirty-candies-invite", + "turso-tenant-bootstrap-mastracode", + "two-dancers-jam" ] } diff --git a/.changeset/proud-zoos-enter.md b/.changeset/proud-zoos-enter.md new file mode 100644 index 000000000000..470affe503ca --- /dev/null +++ b/.changeset/proud-zoos-enter.md @@ -0,0 +1,43 @@ +--- +'@mastra/core': minor +--- + +Added file-based agents: define an agent by file convention under `src/mastra/agents//` alongside agents created with `new Agent()`. + +A directory becomes an agent when it has a `config.ts` or `instructions.md`. The directory name is the agent name. `instructions.md` supplies the instructions, `tools/*.ts` supply tools, `skills/` supplies skills (a `createSkill()` module, a packaged `SKILL.md` directory, or a flat `.md`), and a `memory.ts` default export supplies the agent's `memory` (`config.memory` wins if both are set). Each file-based agent also gets a workspace by default (contained filesystem + shell sandbox rooted at a per-agent `workspace/` dir); customize it with a `workspace.ts` default export or `config.workspace`. Both styles register into the same Mastra instance and show up together in Studio, the server, and the bundler. + +**Before** + +```ts +import { Agent } from '@mastra/core/agent'; + +export const weather = new Agent({ + id: 'weather', + name: 'weather', + instructions: 'You are a weather assistant.', + model: 'openai/gpt-4o', +}); +``` + +**After (file-based, optional)** + +```ts +// src/mastra/agents/weather/config.ts +import { agentConfig } from '@mastra/core/agent'; + +export default agentConfig({ + model: 'openai/gpt-4o', + // instructions taken from instructions.md, tools from tools/*.ts +}); + +// src/mastra/agents/weather/memory.ts +import { Memory } from '@mastra/memory'; + +export default new Memory(); // wired in as the agent's memory +``` + +A file-based agent can also declare **subagents** under `agents//subagents//`, using the same directory layout as an agent (`config.ts`, `instructions.md`, `tools/`, `skills/`, `workspace.ts` / `workspace/`). Each subagent is assembled independently and wired into the parent's `agents` map, so the loop exposes it as a delegation tool named after the directory. A subagent's `config.ts` must set a non-empty `description` (build error otherwise), subagents inherit nothing from the parent, and they are one level deep (a nested `subagents/` directory is ignored with a warning). A subagent id colliding with a parent tool key or another subagent id is a build error; an id also present in `config.agents` keeps the `config.agents` entry with a warning. + +Code-registered agents win on name collisions, and a `config.ts` that exports `new Agent()` is used as-is (its sibling `instructions.md`, `tools/`, and `subagents/` are ignored with a warning), so existing projects are unaffected. + +The core API surface is `agentConfig()` plus the `assembleAgentFromFsEntry()` / `Mastra.__registerFsAgents()` helpers that turn a discovered directory into a registered agent. Directory discovery itself is performed by the build pipeline; importing the `mastra` instance directly as a library does not scan `agents//` directories, so register those agents in code if you need them outside the build pipeline. diff --git a/.changeset/quick-dingos-eat.md b/.changeset/quick-dingos-eat.md new file mode 100644 index 000000000000..9be189ad5e23 --- /dev/null +++ b/.changeset/quick-dingos-eat.md @@ -0,0 +1,19 @@ +--- +'@mastra/core': minor +--- + +support inline JSON prompt injection + +Added `structuredOutput.jsonPromptInjection: 'inline'` to +append JSON schema instructions to the latest user message +instead of the system prompt. This helps keep the system +prompt stable on providers that cache prompt prefixes. + +```ts +await agent.generate('Summarize this text', { + structuredOutput: { + schema, + jsonPromptInjection: 'inline', + }, +}); +``` diff --git a/.changeset/quick-walls-worry.md b/.changeset/quick-walls-worry.md new file mode 100644 index 000000000000..d8ca393938c3 --- /dev/null +++ b/.changeset/quick-walls-worry.md @@ -0,0 +1,10 @@ +--- +'@mastra/playground-ui': minor +--- + +Added public subpath entrypoints for shared Playground UI domain components, hooks, resize helpers, primitives, and the playground store. Applications can now import focused APIs such as `TracesLayout` and `usePlaygroundStore` directly from those subpaths. + +```ts +import { TracesLayout } from '@mastra/playground-ui/domains/traces/components/traces-layout'; +import { usePlaygroundStore } from '@mastra/playground-ui/store/playground-store'; +``` diff --git a/.changeset/quiet-cycles-repair.md b/.changeset/quiet-cycles-repair.md new file mode 100644 index 000000000000..4364c4b9ab00 --- /dev/null +++ b/.changeset/quiet-cycles-repair.md @@ -0,0 +1,5 @@ +--- +'@mastra/core': patch +--- + +Fixed background task execution metadata updates so they no longer rewrite the model-visible tool invocation state. diff --git a/.changeset/scorer-helper-signal-user-message.md b/.changeset/scorer-helper-signal-user-message.md new file mode 100644 index 000000000000..9e5035d09172 --- /dev/null +++ b/.changeset/scorer-helper-signal-user-message.md @@ -0,0 +1,5 @@ +--- +'@mastra/evals': patch +--- + +Eval scorers now receive the original user message for runs started through the agent subscription / `sendMessage` API. Previously `getUserMessageFromRunInput` returned an empty value for these runs, so scorers could not see what the user said (only `agent.stream` and `agent.generate` worked). diff --git a/.changeset/shaky-dingos-prove.md b/.changeset/shaky-dingos-prove.md new file mode 100644 index 000000000000..b9af2dc2a1a7 --- /dev/null +++ b/.changeset/shaky-dingos-prove.md @@ -0,0 +1,18 @@ +--- +'@mastra/editor': patch +--- + +Agent Builder agents now default observational memory to `__GATEWAY_OPENAI_MODEL_MINI__` instead of `__GATEWAY_GOOGLE_MODEL__`. Set `OPENAI_API_KEY` in any environment where Builder agents run. Core (non-builder) agents are unaffected and keep the framework default. Admins can still override the model: + +```typescript +new MastraEditor({ + builder: { + enabled: true, + configuration: { + agent: { + memory: { observationalMemory: { model: '__GATEWAY_OPENAI_MODEL_MINI__' } }, + }, + }, + }, +}); +``` diff --git a/.changeset/shy-radios-slide.md b/.changeset/shy-radios-slide.md new file mode 100644 index 000000000000..ee988e0cf4cb --- /dev/null +++ b/.changeset/shy-radios-slide.md @@ -0,0 +1,6 @@ +--- +'@mastra/schema-compat': patch +'@mastra/core': patch +--- + +Fixed 'Type instantiation is excessively deep' (TS2589) errors that occurred when defining workflows with Zod schemas. Workflow and step type inference is now significantly faster and no longer causes TypeScript to crash or report depth errors. diff --git a/.changeset/solid-bees-shave.md b/.changeset/solid-bees-shave.md deleted file mode 100644 index 47fe36a4c392..000000000000 --- a/.changeset/solid-bees-shave.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@mastra/core': patch ---- - -Fixed notification signal delivery to idle threads not including ifIdle with streamOptions. When GitHub notifications or heartbeats wake an idle agent thread, the request context (containing model selection) was missing, causing 'No model selected' errors. Added getNotificationStreamOptions callback to AgentNotificationConfig so the notification dispatcher can resolve stream options for deferred notifications. diff --git a/.changeset/spicy-masks-matter.md b/.changeset/spicy-masks-matter.md new file mode 100644 index 000000000000..02dfc0eee174 --- /dev/null +++ b/.changeset/spicy-masks-matter.md @@ -0,0 +1,24 @@ +--- +'@mastra/core': minor +'@mastra/server': minor +'@mastra/client-js': minor +'mastra': patch +--- + +Added storage-backed discovery of suspended agent runs, so human-in-the-loop approval UIs can recover a pending run after a page refresh or server restart. + +`agent.listSuspendedRuns()` lists runs waiting on a tool-call approval or on a tool that called `suspend()`. Unlike the in-memory `getActiveThreadRunId()`, it reads from storage, so it works after a restart and across multiple server instances: + +```ts +const { runs, total } = await agent.listSuspendedRuns({ threadId, resourceId }); +if (runs[0]) { + // runs[0].toolCalls -> [{ toolCallId, toolName, args, requiresApproval }] + await agent.approveToolCall({ runId: runs[0].runId, toolCallId: runs[0].toolCalls[0].toolCallId }); +} +``` + +Supports `threadId`/`resourceId`/date filters and pagination, mirroring `listWorkflowRuns()`. The same surface is exposed over HTTP as `GET /agents/:agentId/suspended-runs` and on the client SDK as `agent.listSuspendedRuns()`; server-enforced request-context values take precedence over client query parameters, so clients cannot list runs outside their scope. + +`sendToolApproval()` now falls back to this storage-backed discovery when no active run is found in memory for the thread, so approvals keep working after a restart. If several suspended runs match, it throws an error asking for a `toolCallId` to disambiguate. + +**Why:** approval UIs previously had no public way to recover a suspended run after a refresh or restart, forcing apps to parse internal workflow snapshots. diff --git a/.changeset/studio-metrics-vnext-gate.md b/.changeset/studio-metrics-vnext-gate.md new file mode 100644 index 000000000000..54013d3771b8 --- /dev/null +++ b/.changeset/studio-metrics-vnext-gate.md @@ -0,0 +1,5 @@ +--- +'@internal/playground': patch +--- + +Fixed Studio Metrics tab not rendering for PostgresStoreVNext users. The dashboard now appears when the observability store is Postgres v-next, with an advisory banner recommending time-range filters for best performance. diff --git a/.changeset/thick-readers-arrive.md b/.changeset/thick-readers-arrive.md new file mode 100644 index 000000000000..0b7916823e1a --- /dev/null +++ b/.changeset/thick-readers-arrive.md @@ -0,0 +1,5 @@ +--- +'mastracode': patch +--- + +Improved the Mastra Code status area to show active work time, completed work duration, and idle time. diff --git a/.changeset/thirty-candies-invite.md b/.changeset/thirty-candies-invite.md new file mode 100644 index 000000000000..37381de6b81d --- /dev/null +++ b/.changeset/thirty-candies-invite.md @@ -0,0 +1,5 @@ +--- +'mastracode': patch +--- + +Improved MastraCode web chat so hydrated and streaming messages render consistently, including tool cards, reasoning, and failed-tool states. diff --git a/.changeset/true-clowns-divide.md b/.changeset/true-clowns-divide.md new file mode 100644 index 000000000000..0a955aec5feb --- /dev/null +++ b/.changeset/true-clowns-divide.md @@ -0,0 +1,28 @@ +--- +'mastracode': patch +--- + +Added Mastra Code plugin support: + +- Install, scaffold, configure, block, and auto-update plugins with local-change backups. +- Load plugin tools in all modes, including streaming progress and subagent-style rendering. +- Load bundled plugin commands, skills, and plugin-provided system instructions. + +Example: + +```ts +import { createTool, defineMastraCodePlugin, z } from 'mastracode/plugin'; + +export default defineMastraCodePlugin({ + id: 'acme.tools', + tools: { + echo: { + tool: createTool({ + id: 'echo', + inputSchema: z.object({ message: z.string() }), + execute: async ({ message }) => ({ message }), + }), + }, + }, +}); +``` diff --git a/.changeset/turso-tenant-bootstrap-mastracode.md b/.changeset/turso-tenant-bootstrap-mastracode.md new file mode 100644 index 000000000000..d31b3cd0a3c7 --- /dev/null +++ b/.changeset/turso-tenant-bootstrap-mastracode.md @@ -0,0 +1,18 @@ +--- +'mastracode': minor +--- + +Auto-provision a per-tenant Turso database in deployed MastraCode Web environments. + +Previously, hosting per-`(org, user)` agent state on Turso required each tenant's database to already exist at the URL produced by `MASTRACODE_TENANT_DB_URL_TEMPLATE`. There was no way to create those databases on demand, so the only zero-setup option was server-local libSQL files — which are ephemeral and not shared across replicas. + +Setting `MASTRACODE_TURSO_PLATFORM_TOKEN` and `MASTRACODE_TURSO_ORG` now enables a third tenant-storage mode: the first time a tenant is seen, its own Turso database is created via the Turso Platform API (idempotent — an "already exists" race recovers the hostname via `databases.get`), a scoped auth token is minted, and the stable database-name/hostname mapping is persisted in the app Postgres (`tenant_databases` table, requires `APP_DATABASE_URL`). All replicas converge on the same database and cold starts never re-create it. Only the durable mapping is stored; the auth token is minted fresh per resolution, so no long-lived credential is persisted. + +Resolution priority is: explicit `MASTRACODE_TENANT_DB_URL_TEMPLATE` → Turso auto-provisioning → local libSQL files. Turso provisioning also satisfies `MASTRACODE_REQUIRE_REMOTE_TENANT_DB=1`. The `@tursodatabase/api` client is loaded dynamically, so deployments that don't use Turso never pull it in at runtime. + +```bash +MASTRACODE_TURSO_PLATFORM_TOKEN=... # Turso Platform API token +MASTRACODE_TURSO_ORG=my-org # org that owns provisioned databases +MASTRACODE_TURSO_GROUP=default # optional group (default "default") +APP_DATABASE_URL=postgres://... # required for the mapping table +``` diff --git a/.changeset/two-dancers-jam.md b/.changeset/two-dancers-jam.md new file mode 100644 index 000000000000..183bbc98f88f --- /dev/null +++ b/.changeset/two-dancers-jam.md @@ -0,0 +1,5 @@ +--- +'@mastra/core': patch +--- + +Fixed custom model gateways being overridden by default gateways. GatewayManager now deduplicates gateways by ID (first-wins) so custom gateways take precedence over defaults. Narrowed the auth-availability check to only swallow expected missing-credential errors instead of all errors, so real gateway failures surface during debugging. diff --git a/.claude/skills/e2e-tests-studio/SKILL.md b/.claude/skills/e2e-tests-studio/SKILL.md index 3085564c0a9a..80e4405b6789 100644 --- a/.claude/skills/e2e-tests-studio/SKILL.md +++ b/.claude/skills/e2e-tests-studio/SKILL.md @@ -30,6 +30,52 @@ model: claude-opus-4-5 - ✅ "Chat messages stream correctly and maintain conversation context" - ✅ "Workflow execution triggers tools in the correct order" +## BDD Structure (REQUIRED) + +**Every E2E spec MUST follow the same BDD shape as the MSW tests.** In `packages/playground`, `e2e-bdd/test-needs-when-describe` enforces this shape. + +The structure has exactly three levels: + +1. **Outer `test.describe`** = the unit under test (one page or feature per file). +2. **Inner `test.describe('when …')`** = exactly ONE precondition. The title MUST start with `when`. +3. **Each `test`** = exactly ONE observable outcome. + +```ts +import { test, expect } from '@playwright/test'; +import { resetStorage } from '../__utils__/reset-storage'; + +test.describe('Tools list page', () => { + // the unit + test.afterEach(async () => { + await resetStorage(); + }); + + test.describe('when a registered tool is clicked', () => { + // ONE precondition (starts with "when") + test('navigates to that tool detail page', async ({ page }) => { + // ONE outcome + await page.goto('/tools'); + await page.locator('text=Get current weather for a location').click(); + await expect(page).toHaveURL(/\/tools\/weatherInfo$/); + }); + + test('shows the tool name as the page heading', async ({ page }) => { + // ONE outcome + await page.goto('/tools'); + await page.locator('text=Get current weather for a location').click(); + await expect(page.locator('h2')).toHaveText('weatherInfo'); + }); + }); +}); +``` + +Rules: + +- One outer `test.describe` per file naming the unit. +- Every leaf `test` lives inside a `test.describe('when …')` precondition group. **No top-level flat `test()`.** +- Split a multi-assertion `test()` only where assertions represent **distinct outcomes**; keep tightly-coupled assertions that prove a single outcome together. Never drop an assertion. +- Place `beforeEach`/`afterEach` in the narrowest `describe` scope that needs them. + ## Prerequisites Requires Playwright MCP server. If the `browser_navigate` tool is unavailable, instruct the user to add it: @@ -102,15 +148,17 @@ test.describe('[Feature Name] - Behavior Tests', () => { await resetStorage(page); }); - test('should [verb describing behavior] when [trigger condition]', async () => { - // ARRANGE: Set up preconditions - // - Navigate to the feature - // - Configure any required state - // ACT: Perform the user action that triggers the behavior - // ASSERT: Verify the OUTCOME, not the UI state - // - Check data persistence - // - Verify downstream effects - // - Confirm API calls made correctly + test.describe('when [the single precondition for these outcomes]', () => { + test('[verb describing the single observable outcome]', async () => { + // ARRANGE: Set up preconditions + // - Navigate to the feature + // - Configure any required state + // ACT: Perform the user action that triggers the behavior + // ASSERT: Verify the OUTCOME, not the UI state + // - Check data persistence + // - Verify downstream effects + // - Confirm API calls made correctly + }); }); }); ``` @@ -120,149 +168,161 @@ test.describe('[Feature Name] - Behavior Tests', () => { #### Pattern 1: Configuration Affects Behavior ```ts -test('selecting LLM provider should use that provider for agent responses', async () => { - // ARRANGE - await page.goto('/agents/my-agent/chat'); - - // Intercept API to verify provider - let capturedProvider: string | null = null; - await page.route('**/api/chat', route => { - const body = JSON.parse(route.request().postData() || '{}'); - capturedProvider = body.provider; - route.continue(); +test.describe('when a different LLM provider is selected', () => { + test('uses that provider for agent responses', async () => { + // ARRANGE + await page.goto('/agents/my-agent/chat'); + + // Intercept API to verify provider + let capturedProvider: string | null = null; + await page.route('**/api/chat', route => { + const body = JSON.parse(route.request().postData() || '{}'); + capturedProvider = body.provider; + route.continue(); + }); + + // ACT: Select a different provider + await page.getByTestId('provider-selector').click(); + await page.getByRole('option', { name: 'OpenAI' }).click(); + + // Send a message to trigger the agent + await page.getByTestId('chat-input').fill('Hello'); + await page.getByTestId('send-button').click(); + + // ASSERT: Verify the selected provider was used + await expect.poll(() => capturedProvider).toBe('openai'); }); - - // ACT: Select a different provider - await page.getByTestId('provider-selector').click(); - await page.getByRole('option', { name: 'OpenAI' }).click(); - - // Send a message to trigger the agent - await page.getByTestId('chat-input').fill('Hello'); - await page.getByTestId('send-button').click(); - - // ASSERT: Verify the selected provider was used - await expect.poll(() => capturedProvider).toBe('openai'); }); ``` #### Pattern 2: Data Persistence ```ts -test('created agent should persist after page reload', async () => { - // ARRANGE - await page.goto('/agents'); - const agentName = `Test Agent ${nanoid()}`; - - // ACT: Create new agent - await page.getByTestId('create-agent-button').click(); - await page.getByTestId('agent-name-input').fill(agentName); - await page.getByTestId('save-agent-button').click(); - - // Wait for creation to complete - await expect(page.getByText(agentName)).toBeVisible(); - - // ASSERT: Verify persistence - await page.reload(); - await expect(page.getByText(agentName)).toBeVisible({ timeout: 10000 }); +test.describe('when a new agent is created', () => { + test('persists after page reload', async () => { + // ARRANGE + await page.goto('/agents'); + const agentName = `Test Agent ${nanoid()}`; + + // ACT: Create new agent + await page.getByTestId('create-agent-button').click(); + await page.getByTestId('agent-name-input').fill(agentName); + await page.getByTestId('save-agent-button').click(); + + // Wait for creation to complete + await expect(page.getByText(agentName)).toBeVisible(); + + // ASSERT: Verify persistence + await page.reload(); + await expect(page.getByText(agentName)).toBeVisible({ timeout: 10000 }); + }); }); ``` #### Pattern 3: Tool Execution Produces Correct Output ```ts -test('weather tool should return formatted weather data', async () => { - // ARRANGE - await selectFixture(page, 'weather-success'); - await page.goto('/tools/weather-tool'); - - // ACT: Execute tool with parameters - await page.getByTestId('param-city').fill('San Francisco'); - await page.getByTestId('execute-tool-button').click(); - - // ASSERT: Verify OUTPUT content, not just that output appears - const output = page.getByTestId('tool-output'); - await expect(output).toContainText('temperature'); - await expect(output).toContainText('San Francisco'); - - // Verify structured data if applicable - const outputText = await output.textContent(); - const outputData = JSON.parse(outputText || '{}'); - expect(outputData).toHaveProperty('temperature'); - expect(outputData).toHaveProperty('conditions'); +test.describe('when the weather tool is executed with a city', () => { + test('returns formatted weather data for that city', async () => { + // ARRANGE + await selectFixture(page, 'weather-success'); + await page.goto('/tools/weather-tool'); + + // ACT: Execute tool with parameters + await page.getByTestId('param-city').fill('San Francisco'); + await page.getByTestId('execute-tool-button').click(); + + // ASSERT: Verify OUTPUT content, not just that output appears + const output = page.getByTestId('tool-output'); + await expect(output).toContainText('temperature'); + await expect(output).toContainText('San Francisco'); + + // Verify structured data if applicable + const outputText = await output.textContent(); + const outputData = JSON.parse(outputText || '{}'); + expect(outputData).toHaveProperty('temperature'); + expect(outputData).toHaveProperty('conditions'); + }); }); ``` #### Pattern 4: Workflow Step Chaining ```ts -test('workflow should pass data between steps correctly', async () => { - // ARRANGE - await selectFixture(page, 'workflow-multi-step'); - const sessionId = nanoid(); - await page.goto(`/workflows/data-pipeline?session=${sessionId}`); - - // ACT: Trigger workflow execution - await page.getByTestId('workflow-input').fill('test input data'); - await page.getByTestId('run-workflow-button').click(); - - // ASSERT: Verify each step received correct input from previous step - // Wait for completion - await expect(page.getByTestId('workflow-status')).toHaveText('completed', { timeout: 30000 }); - - // Check step outputs show data transformation chain - const step1Output = await page.getByTestId('step-1-output').textContent(); - const step2Output = await page.getByTestId('step-2-output').textContent(); - - // Verify step 2 received step 1's output as input - expect(step2Output).toContain(step1Output); +test.describe('when a multi-step workflow is run', () => { + test('passes data between steps correctly', async () => { + // ARRANGE + await selectFixture(page, 'workflow-multi-step'); + const sessionId = nanoid(); + await page.goto(`/workflows/data-pipeline?session=${sessionId}`); + + // ACT: Trigger workflow execution + await page.getByTestId('workflow-input').fill('test input data'); + await page.getByTestId('run-workflow-button').click(); + + // ASSERT: Verify each step received correct input from previous step + // Wait for completion + await expect(page.getByTestId('workflow-status')).toHaveText('completed', { timeout: 30000 }); + + // Check step outputs show data transformation chain + const step1Output = await page.getByTestId('step-1-output').textContent(); + const step2Output = await page.getByTestId('step-2-output').textContent(); + + // Verify step 2 received step 1's output as input + expect(step2Output).toContain(step1Output); + }); }); ``` #### Pattern 5: Streaming Chat with Context ```ts -test('chat should maintain conversation context across messages', async () => { - // ARRANGE - await selectFixture(page, 'contextual-chat'); - const chatId = nanoid(); - await page.goto(`/agents/assistant/chat/${chatId}`); - - // ACT: Multi-turn conversation - await page.getByTestId('chat-input').fill('My name is Alice'); - await page.getByTestId('send-button').click(); - await expect(page.getByTestId('assistant-message').last()).toBeVisible({ timeout: 20000 }); - - await page.getByTestId('chat-input').fill('What is my name?'); - await page.getByTestId('send-button').click(); - - // ASSERT: Verify context was maintained - const response = page.getByTestId('assistant-message').last(); - await expect(response).toContainText('Alice', { timeout: 20000 }); +test.describe('when a multi-turn conversation is held', () => { + test('maintains conversation context across messages', async () => { + // ARRANGE + await selectFixture(page, 'contextual-chat'); + const chatId = nanoid(); + await page.goto(`/agents/assistant/chat/${chatId}`); + + // ACT: Multi-turn conversation + await page.getByTestId('chat-input').fill('My name is Alice'); + await page.getByTestId('send-button').click(); + await expect(page.getByTestId('assistant-message').last()).toBeVisible({ timeout: 20000 }); + + await page.getByTestId('chat-input').fill('What is my name?'); + await page.getByTestId('send-button').click(); + + // ASSERT: Verify context was maintained + const response = page.getByTestId('assistant-message').last(); + await expect(response).toContainText('Alice', { timeout: 20000 }); + }); }); ``` #### Pattern 6: Error Recovery ```ts -test('should show actionable error and allow retry when API fails', async () => { - // ARRANGE: Set up failure fixture - await selectFixture(page, 'api-failure'); - await page.goto('/tools/flaky-tool'); - - // ACT: Trigger the error - await page.getByTestId('execute-tool-button').click(); - - // ASSERT: Error is shown with recovery option - await expect(page.getByTestId('error-message')).toContainText('failed'); - await expect(page.getByTestId('retry-button')).toBeVisible(); - - // Switch to success fixture and retry - await selectFixture(page, 'api-success'); - await page.getByTestId('retry-button').click(); - - // Verify recovery worked - await expect(page.getByTestId('tool-output')).toBeVisible({ timeout: 10000 }); - await expect(page.getByTestId('error-message')).not.toBeVisible(); +test.describe('when the API fails during tool execution', () => { + test('shows an actionable error and allows a successful retry', async () => { + // ARRANGE: Set up failure fixture + await selectFixture(page, 'api-failure'); + await page.goto('/tools/flaky-tool'); + + // ACT: Trigger the error + await page.getByTestId('execute-tool-button').click(); + + // ASSERT: Error is shown with recovery option + await expect(page.getByTestId('error-message')).toContainText('failed'); + await expect(page.getByTestId('retry-button')).toBeVisible(); + + // Switch to success fixture and retry + await selectFixture(page, 'api-success'); + await page.getByTestId('retry-button').click(); + + // Verify recovery worked + await expect(page.getByTestId('tool-output')).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId('error-message')).not.toBeVisible(); + }); }); ``` @@ -285,20 +345,22 @@ test('dropdown opens when clicked', async () => { }); ``` -**AFTER (Behavior-focused):** +**AFTER (Behavior-focused + BDD nesting):** ```ts -test('selecting model from dropdown updates agent configuration', async () => { - // Open dropdown and select model - await page.getByTestId('model-dropdown').click(); - await page.getByRole('option', { name: 'GPT-4' }).click(); - - // Verify the selection persists and affects behavior - await page.reload(); - await expect(page.getByTestId('model-dropdown')).toHaveText('GPT-4'); - - // Optionally: verify the model is used in actual requests - // (via request interception or checking response metadata) +test.describe('when a model is selected from the dropdown', () => { + test('updates and persists the agent configuration', async () => { + // Open dropdown and select model + await page.getByTestId('model-dropdown').click(); + await page.getByRole('option', { name: 'GPT-4' }).click(); + + // Verify the selection persists and affects behavior + await page.reload(); + await expect(page.getByTestId('model-dropdown')).toHaveText('GPT-4'); + + // Optionally: verify the model is used in actual requests + // (via request interception or checking response metadata) + }); }); ``` @@ -359,6 +421,9 @@ cd packages/playground && pnpm test:e2e Before considering tests complete, verify: - [ ] Each test has a clear user story comment +- [ ] One outer `test.describe` names the unit under test +- [ ] Every `test` is nested in a `test.describe('when …')` precondition block (no flat top-level `test()`) +- [ ] Each `test` asserts exactly ONE observable outcome - [ ] Tests verify OUTCOMES, not intermediate UI states - [ ] Tests would FAIL if the feature broke (not just if UI changed) - [ ] Persistence is verified via `page.reload()` where applicable @@ -380,12 +445,14 @@ Before considering tests complete, verify: ## Anti-Patterns to Avoid -| ❌ Don't | ✅ Do Instead | -| ---------------------------------- | ------------------------------------------------------------ | -| Test that modal opens | Test that modal action completes and persists | -| Test that button is clickable | Test that clicking button produces expected result | -| Test loading spinner appears | Test that loaded data is correct | -| Test form validation message shows | Test that invalid form cannot submit AND valid form succeeds | -| Test dropdown has options | Test that selecting option changes system behavior | -| Test sidebar navigation works | Test that navigated page has correct data/functionality | -| Assert element is visible | Assert element contains expected data/state | +| ❌ Don't | ✅ Do Instead | +| ----------------------------------------------------- | ------------------------------------------------------------ | +| Test that modal opens | Test that modal action completes and persists | +| Test that button is clickable | Test that clicking button produces expected result | +| Test loading spinner appears | Test that loaded data is correct | +| Test form validation message shows | Test that invalid form cannot submit AND valid form succeeds | +| Test dropdown has options | Test that selecting option changes system behavior | +| Test sidebar navigation works | Test that navigated page has correct data/functionality | +| Assert element is visible | Assert element contains expected data/state | +| Top-level flat `test()` with no precondition describe | Nest every `test` in a `test.describe('when …')` block | +| One `test()` asserting several unrelated outcomes | One `test()` per observable outcome | diff --git a/.claude/skills/mastra-docs/SKILL.md b/.claude/skills/mastra-docs/SKILL.md index 1a13f39dac41..c95a4f2a9368 100644 --- a/.claude/skills/mastra-docs/SKILL.md +++ b/.claude/skills/mastra-docs/SKILL.md @@ -5,7 +5,7 @@ description: Documentation guidelines for Mastra. This skill should be used when # Mastra Documentation Guidelines -Use this skill when you create or update Mastra docs. Keep the docs clear and consistent. Follow the most specific AGENTS.md for the area you change. +Use this skill when you create or update Mastra docs. Keep the docs clear and consistent. Follow the most specific AGENTS.md for the area you change. After making your changes to the docs and sidebars, run the linters to check your work. ## Styleguides @@ -32,3 +32,4 @@ Run these commands in docs/: - npm run format - Format files with Prettier - npm run lint:remark - Check markdown with Remark - npm run lint:vale:ai - Check prose with Vale using the error alert level +- npm run validate - Check frontmatter values and if all sidebars are valid diff --git a/.claude/skills/mastra-docs/references/STYLEGUIDE.md b/.claude/skills/mastra-docs/references/STYLEGUIDE.md index 82c4bbb7a5b1..78330fd2dde7 100644 --- a/.claude/skills/mastra-docs/references/STYLEGUIDE.md +++ b/.claude/skills/mastra-docs/references/STYLEGUIDE.md @@ -13,8 +13,7 @@ Use this file as the default writing guide for Mastra's documentation. ## Keep docs current -- Use current model names for providers such as OpenAI and Claude. -- Check `packages/core/src/llm/model/provider-registry.json` for the latest models supported by Mastra. +- When adding a model name or ID to docs, use a placeholder token from docs/src/plugins/remark-model-tokens/models.ts (remark replaces them at docs build time) ## Scope diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index c0553be42c74..0240b85cbc14 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -216,7 +216,7 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2, 3] + shard: [1, 2, 3, 4] permissions: contents: read env: @@ -234,7 +234,17 @@ jobs: uses: ./.github/actions/setup-pnpm-node - name: Build - run: pnpm build + run: >- + pnpm turbo + --filter "@mastra/core" + --filter "mastra" + --filter "@mastra/editor" + --filter "@mastra/memory" + --filter "@mastra/libsql" + --filter "@mastra/loggers" + --filter "@mastra/mcp" + --filter "@internal/playground" + build - name: Setup e2e project dependencies working-directory: ./packages/playground diff --git a/AGENTS.md b/AGENTS.md index c6d3eb3fc628..b23dc3c15a34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ For work in packages read package local packages//AGENTS.md first turborepo pnpm workspace packages use strict TypeScript vitest tests are colocated with source -When adding a model name or ID to docs, changesets, or comments, use a placeholder token from docs/src/plugins/remark-model-tokens/models.ts (remark replaces them at docs build time); in executable tests use a real provider/model value, not a bare token +When adding a model name or ID to changesets or comments, use a literal value from docs/src/plugins/remark-model-tokens/models.ts (do not use placeholder tokens, remark does not replace them in changesets/comments) Prefer narrowest build test lint typecheck for packages when package splits unit integration or E2E coverage run narrowest suite first diff --git a/client-sdks/ai-sdk/CHANGELOG.md b/client-sdks/ai-sdk/CHANGELOG.md index 01ac3fa7e25c..e6bca3e00efb 100644 --- a/client-sdks/ai-sdk/CHANGELOG.md +++ b/client-sdks/ai-sdk/CHANGELOG.md @@ -1,5 +1,14 @@ # @mastra/ai-sdk +## 1.6.1-alpha.0 + +### Patch Changes + +- Fixed `chatRoute` and `handleChatStream` ignoring agent instructions and tools edited through the Agent Editor. When an editor is configured, the chat endpoint now applies the agent's stored overrides just like Studio does, instead of running the bare code-defined agent. Previously, instructions edited in the editor were silently dropped and the agent answered as if it had none. ([#18592](https://github.com/mastra-ai/mastra/pull/18592)) + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + ## 1.6.0 ### Minor Changes diff --git a/client-sdks/ai-sdk/package.json b/client-sdks/ai-sdk/package.json index 1fa97360560b..c53938006347 100644 --- a/client-sdks/ai-sdk/package.json +++ b/client-sdks/ai-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/ai-sdk", - "version": "1.6.0", + "version": "1.6.1-alpha.0", "description": "Adds custom API routes to be compatible with the AI SDK UI parts", "type": "module", "main": "dist/index.js", diff --git a/client-sdks/ai-sdk/src/__tests__/editor-stored-overrides.test.ts b/client-sdks/ai-sdk/src/__tests__/editor-stored-overrides.test.ts new file mode 100644 index 000000000000..9d6658634d28 --- /dev/null +++ b/client-sdks/ai-sdk/src/__tests__/editor-stored-overrides.test.ts @@ -0,0 +1,169 @@ +/** + * Regression test for issue #18574. + * + * When `@mastra/editor` is configured, an agent's runtime config (instructions, + * tools, model, ...) can live in stored config instead of the code definition. + * Studio resolves these stored overrides before every run, but `chatRoute` / + * `handleChatStream` from `@mastra/ai-sdk` resolved the agent with a plain + * `mastra.getAgentById(agentId)` and never applied the stored overrides. The + * agent therefore executed with the (empty) code-defined instructions and the + * endpoint behaved differently from Studio. + * + * The fix routes `handleChatStream` through the editor's `applyStoredOverrides` + * (defaulting to the published version, matching the built-in agent handlers) + * so the endpoint serves the same instructions Studio does. + */ +import type { UIMessage } from '@internal/ai-sdk-v5'; +import { convertArrayToReadableStream, MockLanguageModelV2 } from '@internal/ai-sdk-v5/test'; +import { Agent } from '@mastra/core/agent'; +import { Mastra } from '@mastra/core/mastra'; +import { RequestContext } from '@mastra/core/request-context'; +import { describe, expect, it, vi } from 'vitest'; + +import { handleChatStream } from '../chat-route'; + +const STORED_INSTRUCTIONS = 'The secret phrase is "banana". You are a helpful weather assistant.'; +const CODE_INSTRUCTIONS = 'You are a code-defined assistant.'; + +const messages: UIMessage[] = [ + { id: 'user-1', role: 'user', parts: [{ type: 'text', text: 'What is the secret phrase?' }] }, +]; + +/** + * Mock model that records the system instructions it actually receives, so a + * test can assert which instructions reached the LLM. + */ +function createCapturingModel(capture: { systemPrompt: string | undefined }) { + return new MockLanguageModelV2({ + doStream: async ({ prompt }) => { + const systemMessage = (prompt as Array<{ role: string; content: unknown }>).find(m => m.role === 'system'); + capture.systemPrompt = typeof systemMessage?.content === 'string' ? systemMessage.content : undefined; + + return { + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: 'msg-1', modelId: 'mock-model', timestamp: new Date() }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'ok' }, + { type: 'text-end', id: 'text-1' }, + { type: 'finish', finishReason: 'stop', usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } }, + ] as any), + rawCall: { rawPrompt: [], rawSettings: {} }, + warnings: [], + }; + }, + }); +} + +function createCodeAgent(capture: { systemPrompt: string | undefined }, instructions: string) { + return new Agent({ + id: 'weather-agent', + name: 'Weather Agent', + instructions, + model: createCapturingModel(capture), + }); +} + +/** + * Minimal editor stub mirroring the `@mastra/editor` contract that + * `handleChatStream` relies on. `applyStoredOverrides` forks the code agent and + * swaps in the stored instructions — exactly what the real editor does when a + * stored config exists for the requested version. The spy lets tests assert the + * version/requestContext that `handleChatStream` resolved with. + */ +function createEditorStub(storedInstructions: string | null) { + const applyStoredOverrides = vi.fn( + async (agent: Agent, _options?: unknown, _requestContext?: RequestContext): Promise => { + if (storedInstructions === null) return agent; + const fork = (agent as any).__fork() as Agent; + (fork as any).__updateInstructions(storedInstructions); + return fork; + }, + ); + return { registerWithMastra() {}, agent: { applyStoredOverrides } }; +} + +function createMastra(agent: Agent, editor?: ReturnType) { + return new Mastra({ + agents: { weatherAgent: agent }, + ...(editor ? { editor: editor as any } : {}), + }); +} + +async function drainStream(stream: ReadableStream) { + const reader = stream.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } +} + +describe('handleChatStream editor stored overrides (issue #18574)', () => { + it('runs with stored instructions from the editor instead of the empty code definition', async () => { + const capture: { systemPrompt: string | undefined } = { systemPrompt: undefined }; + const editor = createEditorStub(STORED_INSTRUCTIONS); + const mastra = createMastra(createCodeAgent(capture, ''), editor); + + const stream = await handleChatStream({ mastra, agentId: 'weatherAgent', params: { messages } }); + await drainStream(stream); + + expect(capture.systemPrompt).toContain(STORED_INSTRUCTIONS); + // Overrides resolved exactly once, defaulting to the published version. + expect(editor.agent.applyStoredOverrides).toHaveBeenCalledTimes(1); + expect(editor.agent.applyStoredOverrides.mock.calls[0]![1]).toEqual({ status: 'published' }); + }); + + it('forwards an explicit agentVersion to the editor instead of the published default', async () => { + const capture: { systemPrompt: string | undefined } = { systemPrompt: undefined }; + const editor = createEditorStub(STORED_INSTRUCTIONS); + const mastra = createMastra(createCodeAgent(capture, ''), editor); + + const stream = await handleChatStream({ + mastra, + agentId: 'weatherAgent', + agentVersion: { versionId: 'v-123' }, + params: { messages }, + }); + await drainStream(stream); + + expect(capture.systemPrompt).toContain(STORED_INSTRUCTIONS); + expect(editor.agent.applyStoredOverrides).toHaveBeenCalledTimes(1); + expect(editor.agent.applyStoredOverrides.mock.calls[0]![1]).toEqual({ versionId: 'v-123' }); + }); + + it('threads requestContext through to the editor override resolution', async () => { + const capture: { systemPrompt: string | undefined } = { systemPrompt: undefined }; + const editor = createEditorStub(STORED_INSTRUCTIONS); + const mastra = createMastra(createCodeAgent(capture, ''), editor); + + const requestContext = new RequestContext([['tenant', 'acme']]); + const stream = await handleChatStream({ mastra, agentId: 'weatherAgent', params: { messages, requestContext } }); + await drainStream(stream); + + expect(editor.agent.applyStoredOverrides).toHaveBeenCalledTimes(1); + expect(editor.agent.applyStoredOverrides.mock.calls[0]![2]).toBe(requestContext); + }); + + it('keeps the code instructions when the editor has no stored config', async () => { + const capture: { systemPrompt: string | undefined } = { systemPrompt: undefined }; + // storedInstructions: null → editor returns the code agent unchanged. + const editor = createEditorStub(null); + const mastra = createMastra(createCodeAgent(capture, CODE_INSTRUCTIONS), editor); + + const stream = await handleChatStream({ mastra, agentId: 'weatherAgent', params: { messages } }); + await drainStream(stream); + + expect(capture.systemPrompt).toContain(CODE_INSTRUCTIONS); + expect(editor.agent.applyStoredOverrides).toHaveBeenCalledTimes(1); + }); + + it('leaves the code instructions untouched when no editor is configured', async () => { + const capture: { systemPrompt: string | undefined } = { systemPrompt: undefined }; + const mastra = createMastra(createCodeAgent(capture, CODE_INSTRUCTIONS)); + + const stream = await handleChatStream({ mastra, agentId: 'weatherAgent', params: { messages } }); + await drainStream(stream); + + expect(capture.systemPrompt).toContain(CODE_INSTRUCTIONS); + }); +}); diff --git a/client-sdks/ai-sdk/src/chat-route.ts b/client-sdks/ai-sdk/src/chat-route.ts index adcc8957b6cb..d8a278da71eb 100644 --- a/client-sdks/ai-sdk/src/chat-route.ts +++ b/client-sdks/ai-sdk/src/chat-route.ts @@ -167,11 +167,31 @@ export async function handleChatStream({ throw new Error('runId is required when resumeData is provided'); } - const agentObj = agentVersion ? await mastra.getAgentById(agentId, agentVersion) : mastra.getAgentById(agentId); - if (!agentObj) { + const baseAgent = mastra.getAgentById(agentId); + if (!baseAgent) { throw new Error(`Agent ${agentId} not found`); } + // When an editor is configured, an agent's runtime config (instructions, tools, + // model, ...) can live in stored config rather than the code definition. Studio + // resolves these stored overrides before every run, so this endpoint must do the + // same or it would execute a stale/empty code-defined agent (issue #18574). An + // explicit agentVersion (from query params or route options) wins; otherwise we + // default to the published version, matching the built-in agent handlers. + let agentObj = baseAgent; + const editorAgent = mastra.getEditor?.()?.agent; + if (editorAgent) { + agentObj = await editorAgent.applyStoredOverrides( + baseAgent, + agentVersion ?? { status: 'published' }, + requestContext as RequestContext | undefined, + ); + } else if (agentVersion) { + // No editor configured: preserve the prior behavior of surfacing the + // "editor required for versioned agent lookup" error for explicit versions. + agentObj = await mastra.getAgentById(agentId, agentVersion); + } + if (!Array.isArray(messages)) { throw new Error('Messages must be an array of UIMessage objects'); } diff --git a/client-sdks/client-js/CHANGELOG.md b/client-sdks/client-js/CHANGELOG.md index 9c71537cdcc9..6d8a1db71cfb 100644 --- a/client-sdks/client-js/CHANGELOG.md +++ b/client-sdks/client-js/CHANGELOG.md @@ -1,5 +1,157 @@ # @mastra/client-js +## 1.29.0-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + +## 1.29.0-alpha.8 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + +## 1.29.0-alpha.7 + +### Patch Changes + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + +## 1.29.0-alpha.6 + +### Patch Changes + +- Fix `listLogs` and `getLogForRun` dropping the `page` and `perPage` query parameters when they are `0`. Requesting the first page with `page: 0` (or `perPage: 0`) now sends those values instead of falling back to the server defaults. Closes #18631. ([#18632](https://github.com/mastra-ai/mastra/pull/18632)) + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`d0702ee`](https://github.com/mastra-ai/mastra/commit/d0702eedc1594cb2d0d83476440cfc2ec8820adb), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/schema-compat@1.3.2-alpha.1 + +## 1.29.0-alpha.5 + +### Minor Changes + +- **Added** heartbeats: schedule an agent to run on a recurring cron, either inside an existing conversation thread or on its own. ([#18184](https://github.com/mastra-ai/mastra/pull/18184)) + + A heartbeat fires a prompt to an agent on a schedule. When it has a thread, the run is delivered into that thread as a normal agent signal, so anything watching the thread sees it like any other message; without a thread, the agent just runs in isolation. Each heartbeat has its own id and an optional `name`, so one agent or thread can have several heartbeats with different schedules and prompts. The id is generated for you, or you can pass your own `id` to `create` for a stable handle (it's normalized to `hb_`). Heartbeats are persisted, so they keep firing across process restarts with no extra setup. + + ```ts + const hb = await mastra.heartbeats.create({ + agentId: 'chef', + name: 'morning-checkin', + threadId, + resourceId, + cron: '*/5 * * * *', + prompt: 'Check in on the user', + ifActive: { behavior: 'discard' }, // skip if the user is mid-conversation + ifIdle: { behavior: 'wake' }, // wake the agent if the thread is idle + }); + + // Threadless: run the agent on a cron with no conversation. + await mastra.heartbeats.create({ + agentId: 'chef', + cron: '0 * * * *', + prompt: 'Run the hourly summary', + }); + + await mastra.heartbeats.list({ agentId: 'chef' }); + await mastra.heartbeats.get(hb.id); + await mastra.heartbeats.update(hb.id, { prompt: 'check in gently' }); + await mastra.heartbeats.pause(hb.id); + await mastra.heartbeats.resume(hb.id); + await mastra.heartbeats.run(hb.id); // fire once now + await mastra.heartbeats.delete(hb.id); + ``` + + The same CRUD is available over HTTP through `@mastra/server` (under `/api/heartbeats`) and as top-level methods on the `@mastra/client-js` client (`client.createHeartbeat`, `client.getHeartbeat`, `client.listHeartbeats`, etc.). + + **Lifecycle hooks** + + React to heartbeat runs via `heartbeat` on the `Mastra` constructor. It's a single hook bundle that runs for every agent's heartbeats; each hook receives the firing `agentId` so you can branch on it. `prepare` resolves fire-time parameters (for example, creating a fresh thread per fire), and `onFinish` / `onError` / `onAbort` mirror `agent.stream`. + + ```ts + new Mastra({ + // ... + heartbeat: { + // Return overrides, `null` to skip this fire, or `undefined` to use defaults. + prepare: async ({ agentId, heartbeat }) => { + if (agentId === 'chef' && heartbeat.name === 'daily-digest') { + return { threadId: await createDailyThread(), resourceId: 'slack:U095PUH0FKL' }; + } + }, + onFinish: ({ agentId, outcome, result, heartbeat }) => { + metrics.record({ agentId, heartbeat: heartbeat.name, outcome }); + }, + onError: ({ agentId, error, phase, heartbeat }) => { + alerts.send(`heartbeat ${agentId}/${heartbeat.name} failed in ${phase}: ${error.message}`); + }, + }, + }); + ``` + + **Signal shaping** + + A heartbeat fire surfaces to the agent as a signal. By default it uses the `notification` type and renders as ``; override `signalType` and `tagName` to change either. `ifActive` and `ifIdle` mirror the `agent.sendSignal` options shape (`{ behavior, attributes }`, plus `streamOptions` on `ifIdle`) and stay JSON-serializable so they persist with the schedule. `ifIdle.streamOptions` currently accepts `requestContext`, which is rehydrated onto the woken run. Top-level `attributes` are rendered on the signal tag, and top-level `providerOptions` are merged into the signal payload on every fire. + + ```ts + await mastra.heartbeats.create({ + agentId: 'chef', + threadId, + resourceId, + cron: '*/5 * * * *', + prompt: 'Check in on the user', + tagName: 'check-in', // renders as + attributes: { source: 'cron' }, + providerOptions: { openai: { store: false } }, + ifIdle: { + behavior: 'wake', + streamOptions: { requestContext: { locale: 'en-US' } }, + }, + }); + ``` + +- Added storage-backed discovery of suspended agent runs, so human-in-the-loop approval UIs can recover a pending run after a page refresh or server restart. ([#17898](https://github.com/mastra-ai/mastra/pull/17898)) + + `agent.listSuspendedRuns()` lists runs waiting on a tool-call approval or on a tool that called `suspend()`. Unlike the in-memory `getActiveThreadRunId()`, it reads from storage, so it works after a restart and across multiple server instances: + + ```ts + const { runs, total } = await agent.listSuspendedRuns({ threadId, resourceId }); + if (runs[0]) { + // runs[0].toolCalls -> [{ toolCallId, toolName, args, requiresApproval }] + await agent.approveToolCall({ runId: runs[0].runId, toolCallId: runs[0].toolCalls[0].toolCallId }); + } + ``` + + Supports `threadId`/`resourceId`/date filters and pagination, mirroring `listWorkflowRuns()`. The same surface is exposed over HTTP as `GET /agents/:agentId/suspended-runs` and on the client SDK as `agent.listSuspendedRuns()`; server-enforced request-context values take precedence over client query parameters, so clients cannot list runs outside their scope. + + `sendToolApproval()` now falls back to this storage-backed discovery when no active run is found in memory for the thread, so approvals keep working after a restart. If several suspended runs match, it throws an error asking for a `toolCallId` to disambiguate. + + **Why:** approval UIs previously had no public way to recover a suspended run after a refresh or restart, forcing apps to parse internal workflow snapshots. + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + +## 1.28.1-alpha.4 + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + +## 1.28.1-alpha.3 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`9feeaa0`](https://github.com/mastra-ai/mastra/commit/9feeaa0f9a1af07039e5b4f22b932b0cb18617e8), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/schema-compat@1.3.2-alpha.0 + ## 1.28.1-alpha.2 ### Patch Changes diff --git a/client-sdks/client-js/package.json b/client-sdks/client-js/package.json index 81c2fcc209f3..693fecb9406a 100644 --- a/client-sdks/client-js/package.json +++ b/client-sdks/client-js/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/client-js", - "version": "1.28.1-alpha.2", + "version": "1.29.0-alpha.9", "description": "The official TypeScript library for the Mastra Client API", "author": "", "type": "module", diff --git a/client-sdks/client-js/src/client-logs-pagination.test.ts b/client-sdks/client-js/src/client-logs-pagination.test.ts new file mode 100644 index 000000000000..15967fdeef4a --- /dev/null +++ b/client-sdks/client-js/src/client-logs-pagination.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, beforeEach, it, vi } from 'vitest'; +import { MastraClient } from './client'; + +global.fetch = vi.fn(); + +function mockJsonResponse() { + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({}), + }); +} + +function lastFetchUrl(): string { + const calls = (global.fetch as any).mock.calls; + return calls[calls.length - 1][0] as string; +} + +describe('MastraClient logs pagination params', () => { + let client: MastraClient; + + beforeEach(() => { + (global.fetch as any).mockClear(); + client = new MastraClient({ baseUrl: 'http://localhost:3000' }); + }); + + it('listLogs sends page and perPage even when they are 0', async () => { + mockJsonResponse(); + await client.listLogs({ transportId: 't', page: 0, perPage: 0 }); + + const url = lastFetchUrl(); + expect(url).toContain('page=0'); + expect(url).toContain('perPage=0'); + }); + + it('getLogForRun sends page and perPage even when they are 0', async () => { + mockJsonResponse(); + await client.getLogForRun({ runId: 'r', transportId: 't', page: 0, perPage: 0 }); + + const url = lastFetchUrl(); + expect(url).toContain('page=0'); + expect(url).toContain('perPage=0'); + }); +}); diff --git a/client-sdks/client-js/src/client.ts b/client-sdks/client-js/src/client.ts index cc38aacd851d..5e04c27765c6 100644 --- a/client-sdks/client-js/src/client.ts +++ b/client-sdks/client-js/src/client.ts @@ -198,6 +198,11 @@ import type { ScheduleResponse, ListScheduleTriggersParams, ListScheduleTriggersResponse, + Heartbeat, + ListHeartbeatsParams, + CreateHeartbeatInput, + UpdateHeartbeatOptions, + RunHeartbeatResponse, } from './types'; import { base64RequestContext, parseClientRequestContext, requestContextQueryString } from './utils'; @@ -622,10 +627,10 @@ export class MastraClient extends BaseResource { if (logLevel) { searchParams.set('logLevel', logLevel); } - if (page) { + if (page !== undefined) { searchParams.set('page', String(page)); } - if (perPage) { + if (perPage !== undefined) { searchParams.set('perPage', String(perPage)); } if (_filters) { @@ -670,10 +675,10 @@ export class MastraClient extends BaseResource { if (logLevel) { searchParams.set('logLevel', logLevel); } - if (page) { + if (page !== undefined) { searchParams.set('page', String(page)); } - if (perPage) { + if (perPage !== undefined) { searchParams.set('perPage', String(perPage)); } @@ -2209,12 +2214,14 @@ export class MastraClient extends BaseResource { } /** - * Lists workflow schedules with optional filtering by workflowId or status. + * Lists schedules with optional filtering by workflowId, status, ownerType, or ownerId. */ public listSchedules(params: ListSchedulesParams = {}): Promise { const searchParams = new URLSearchParams(); if (params.workflowId) searchParams.set('workflowId', params.workflowId); if (params.status) searchParams.set('status', params.status); + if (params.ownerType) searchParams.set('ownerType', params.ownerType); + if (params.ownerId) searchParams.set('ownerId', params.ownerId); const qs = searchParams.toString(); return this.request(`/schedules${qs ? `?${qs}` : ''}`); } @@ -2258,4 +2265,96 @@ export class MastraClient extends BaseResource { public resumeSchedule(scheduleId: string): Promise { return this.request(`/schedules/${encodeURIComponent(scheduleId)}/resume`, { method: 'POST' }); } + + /** + * Lists heartbeats across all agents. Pass `agentId` to scope the list to + * a single agent. Filter further by `threadId`, `resourceId`, or `name`. + */ + public listHeartbeats(params: ListHeartbeatsParams = {}): Promise { + const searchParams = new URLSearchParams(); + if (params.agentId) searchParams.set('agentId', params.agentId); + if (params.threadId) searchParams.set('threadId', params.threadId); + if (params.resourceId) searchParams.set('resourceId', params.resourceId); + if (params.name) searchParams.set('name', params.name); + const qs = searchParams.toString(); + return this.request<{ heartbeats: Heartbeat[] }>(`/heartbeats${qs ? `?${qs}` : ''}`).then( + response => response.heartbeats, + ); + } + + /** + * Gets a single heartbeat by id. + */ + public getHeartbeat(heartbeatId: string): Promise { + return this.request(`/heartbeats/${encodeURIComponent(heartbeatId)}`); + } + + /** + * Creates a heartbeat for the agent named by `agentId`. By default each call + * creates a new heartbeat with a random `hb_` id — multiple heartbeats + * per agent/thread are supported. Use `name` to label distinct heartbeats. + * Pass `id` to choose a stable id (normalized to `hb_`); creating one + * with an id that already exists throws. + * + * Trigger (fire) history is read through the generic schedules surface: + * `listScheduleTriggers(heartbeat.id)`. + */ + public createHeartbeat(options: CreateHeartbeatInput): Promise { + return this.request(`/heartbeats`, { + method: 'POST', + body: options, + }); + } + + /** + * Patches an existing heartbeat. `threadId` / `resourceId` are immutable — + * to retarget, delete and recreate. + */ + public updateHeartbeat(heartbeatId: string, patch: UpdateHeartbeatOptions): Promise { + return this.request(`/heartbeats/${encodeURIComponent(heartbeatId)}`, { + method: 'PATCH', + body: patch, + }); + } + + /** + * Deletes a heartbeat. + */ + public deleteHeartbeat(heartbeatId: string): Promise<{ message: string }> { + return this.request(`/heartbeats/${encodeURIComponent(heartbeatId)}`, { + method: 'DELETE', + }); + } + + /** + * Pauses a heartbeat. Idempotent — pausing an already-paused heartbeat + * returns the current state unchanged. + */ + public pauseHeartbeat(heartbeatId: string): Promise { + return this.request(`/heartbeats/${encodeURIComponent(heartbeatId)}/pause`, { + method: 'POST', + }); + } + + /** + * Resumes a paused heartbeat. Recomputes nextFireAt from "now" so a + * long-paused heartbeat does not fire a backlog. Idempotent. + */ + public resumeHeartbeat(heartbeatId: string): Promise { + return this.request(`/heartbeats/${encodeURIComponent(heartbeatId)}/resume`, { + method: 'POST', + }); + } + + /** + * Fires a heartbeat manually, out-of-band from the cron schedule. Behaves + * like a scheduled fire (honoring `ifActive` / `ifIdle`) but + * does not advance `nextFireAt`. The returned `claimId` is the trigger row's + * runId — look it up via `listScheduleTriggers(heartbeatId)`. + */ + public runHeartbeat(heartbeatId: string): Promise { + return this.request(`/heartbeats/${encodeURIComponent(heartbeatId)}/run`, { + method: 'POST', + }); + } } diff --git a/client-sdks/client-js/src/resources/agent.test.ts b/client-sdks/client-js/src/resources/agent.test.ts index eb89ed91c3fc..a7e2c0ce3595 100644 --- a/client-sdks/client-js/src/resources/agent.test.ts +++ b/client-sdks/client-js/src/resources/agent.test.ts @@ -1800,6 +1800,59 @@ describe('Agent Voice Resource', () => { expect(versionedAgent).toBeInstanceOf(Agent); }); + it('should list suspended runs with suspendedAt as an ISO string', async () => { + const suspendedAt = new Date('2026-06-12T10:00:00.000Z'); + mockFetchResponse({ + runs: [ + { + runId: 'run-123', + status: 'suspended', + threadId: 'thread-123', + resourceId: 'resource-123', + suspendedAt: suspendedAt.toISOString(), + toolCalls: [ + { toolCallId: 'tool-call-123', toolName: 'findUserTool', args: { name: 'Dero' }, requiresApproval: true }, + ], + }, + ], + total: 1, + }); + + const result = await agent.listSuspendedRuns(); + + expect(global.fetch).toHaveBeenCalledWith( + `${clientOptions.baseUrl}/api/agents/test-agent/suspended-runs`, + expect.objectContaining({ + headers: expect.objectContaining(clientOptions.headers), + }), + ); + expect(result.total).toBe(1); + expect(result.runs[0]!.runId).toBe('run-123'); + expect(result.runs[0]!.suspendedAt).toBe(suspendedAt.toISOString()); + expect(result.runs[0]!.toolCalls[0]!.requiresApproval).toBe(true); + }); + + it('should pass suspended-run filters as query params', async () => { + mockFetchResponse({ runs: [], total: 0 }); + + const fromDate = new Date('2026-01-01T00:00:00.000Z'); + await agent.listSuspendedRuns({ + threadId: 'thread-123', + resourceId: 'resource-123', + fromDate, + perPage: 5, + page: 1, + }); + + const requestedUrl = new URL((global.fetch as any).mock.calls[0][0]); + expect(requestedUrl.pathname).toBe('/api/agents/test-agent/suspended-runs'); + expect(requestedUrl.searchParams.get('threadId')).toBe('thread-123'); + expect(requestedUrl.searchParams.get('resourceId')).toBe('resource-123'); + expect(requestedUrl.searchParams.get('fromDate')).toBe(fromDate.toISOString()); + expect(requestedUrl.searchParams.get('perPage')).toBe('5'); + expect(requestedUrl.searchParams.get('page')).toBe('1'); + }); + it('should get available speakers', async () => { const mockResponse = [{ voiceId: 'speaker1' }]; mockFetchResponse(mockResponse); diff --git a/client-sdks/client-js/src/resources/agent.ts b/client-sdks/client-js/src/resources/agent.ts index 2a29bedf25fd..896d6819ef7e 100644 --- a/client-sdks/client-js/src/resources/agent.ts +++ b/client-sdks/client-js/src/resources/agent.ts @@ -44,6 +44,8 @@ import type { SendAgentSignalParams, QueueAgentMessageParams, SubscribeAgentThreadParams, + ListAgentSuspendedRunsParams, + ListAgentSuspendedRunsResponse, ProcessAgentThreadStreamOptions, CreateCodeAgentVersionParams, ActivateAgentVersionResponse, @@ -2742,6 +2744,31 @@ export class Agent extends BaseResource { return streamResponse; } + /** + * Lists suspended runs for this agent from storage — runs waiting on a + * tool-call approval or on a tool that suspended. Backed by storage, so it + * works after a server restart and across server instances. Pass the + * returned runId to approveToolCall(), declineToolCall(), or resumeStream(). + * @param params - Optional filters (threadId, resourceId, fromDate, toDate) and pagination (perPage, page) + * @param requestContext - Optional request context + * @returns Promise containing the matching runs and the total count before pagination + */ + async listSuspendedRuns( + params?: ListAgentSuspendedRunsParams, + requestContext?: RequestContext | Record, + ): Promise { + const searchParams = new URLSearchParams(requestContextQueryString(requestContext).slice(1)); + if (params?.threadId) searchParams.set('threadId', params.threadId); + if (params?.resourceId) searchParams.set('resourceId', params.resourceId); + if (params?.fromDate) searchParams.set('fromDate', params.fromDate.toISOString()); + if (params?.toDate) searchParams.set('toDate', params.toDate.toISOString()); + if (params?.perPage !== undefined) searchParams.set('perPage', String(params.perPage)); + if (params?.page !== undefined) searchParams.set('page', String(params.page)); + + const query = searchParams.size ? `?${searchParams}` : ''; + return this.request(`/agents/${this.agentId}/suspended-runs${query}`); + } + async approveToolCall(params: { runId: string; toolCallId: string; diff --git a/client-sdks/client-js/src/route-types.generated.ts b/client-sdks/client-js/src/route-types.generated.ts index 0edf3cce33df..a0d54ab1d7dd 100644 --- a/client-sdks/client-js/src/route-types.generated.ts +++ b/client-sdks/client-js/src/route-types.generated.ts @@ -135,7 +135,7 @@ export type GetAgents_Response = { [key: string]: any; } | undefined; - source?: ('code' | 'stored') | undefined; + source?: ('code' | 'stored' | 'fs') | undefined; status?: ('draft' | 'published' | 'archived') | undefined; activeVersionId?: string | undefined; hasDraft?: boolean | undefined; @@ -343,7 +343,7 @@ export type GetAgentsAgentId_Response = { [key: string]: any; } | undefined; - source?: ('code' | 'stored') | undefined; + source?: ('code' | 'stored' | 'fs') | undefined; status?: ('draft' | 'published' | 'archived') | undefined; activeVersionId?: string | undefined; hasDraft?: boolean | undefined; @@ -7070,6 +7070,60 @@ export interface PostAgentsAgentIdSendToolApproval_RouteContract { responseType: 'json'; } +// ============================================================================ +// Route: GET /agents/:agentId/suspended-runs +// ============================================================================ +export type GetAgentsAgentIdSuspendedRuns_PathParams = { + /** Unique identifier for the agent */ + agentId: string; +}; + +export type GetAgentsAgentIdSuspendedRuns_QueryParams = { + threadId?: string | undefined; + resourceId?: string | undefined; + fromDate?: Date | undefined; + toDate?: Date | undefined; + perPage?: number | undefined; + page?: number | undefined; +}; + +export type GetAgentsAgentIdSuspendedRuns_Response = { + runs: { + runId: string; + status: 'suspended'; + threadId?: string | undefined; + resourceId?: string | undefined; + suspendedAt: Date; + toolCalls: { + toolCallId?: string | undefined; + toolName?: string | undefined; + args?: unknown | undefined; + requiresApproval: boolean; + suspendPayload?: unknown | undefined; + }[]; + }[]; + total: number; +}; + +export type GetAgentsAgentIdSuspendedRuns_Request = Simplify< + (GetAgentsAgentIdSuspendedRuns_PathParams extends never ? {} : { params: GetAgentsAgentIdSuspendedRuns_PathParams }) & + (GetAgentsAgentIdSuspendedRuns_QueryParams extends never + ? {} + : {} extends GetAgentsAgentIdSuspendedRuns_QueryParams + ? { query?: GetAgentsAgentIdSuspendedRuns_QueryParams } + : { query: GetAgentsAgentIdSuspendedRuns_QueryParams }) & + (never extends never ? {} : {} extends never ? { body?: never } : { body: never }) +>; + +export interface GetAgentsAgentIdSuspendedRuns_RouteContract { + pathParams: GetAgentsAgentIdSuspendedRuns_PathParams; + queryParams: GetAgentsAgentIdSuspendedRuns_QueryParams; + body: never; + request: GetAgentsAgentIdSuspendedRuns_Request; + response: GetAgentsAgentIdSuspendedRuns_Response; + responseType: 'json'; +} + // ============================================================================ // Route: POST /agents/:agentId/decline-tool-call // ============================================================================ @@ -12884,7 +12938,7 @@ export type GetScoresScorers_Response = { agentNames: string[]; workflowIds: string[]; isRegistered: boolean; - source: 'code' | 'stored'; + source: 'code' | 'stored' | 'fs'; }; }; @@ -12926,7 +12980,7 @@ export type GetScoresScorersScorerId_Response = { agentNames: string[]; workflowIds: string[]; isRegistered: boolean; - source: 'code' | 'stored'; + source: 'code' | 'stored' | 'fs'; } | null; export type GetScoresScorersScorerId_Request = Simplify< @@ -89665,17 +89719,71 @@ export type GetSchedules_QueryParams = { export type GetSchedules_Response = { schedules: { id: string; - target: { - type: 'workflow'; - workflowId: string; - inputData?: unknown | undefined; - initialState?: unknown | undefined; - requestContext?: - | { - [key: string]: unknown; - } - | undefined; - }; + target: + | { + type: 'workflow'; + workflowId: string; + inputData?: unknown | undefined; + initialState?: unknown | undefined; + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + } + | { + type: 'heartbeat'; + agentId: string; + prompt: string; + threadId?: string | undefined; + resourceId?: string | undefined; + signalType?: string | undefined; + tagName?: string | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + ifActive?: + | { + behavior?: ('deliver' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + } + | undefined; + ifIdle?: + | { + behavior?: ('wake' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + streamOptions?: + | { + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + } + | undefined; + } + | undefined; + providerOptions?: + | { + [key: string]: unknown; + } + | undefined; + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + }; cron: string; timezone?: string | undefined; status: 'active' | 'paused'; @@ -89742,172 +89850,285 @@ export type GetSchedulesScheduleId_PathParams = { export type GetSchedulesScheduleId_Response = { id: string; - target: { - type: 'workflow'; - workflowId: string; - inputData?: unknown | undefined; - initialState?: unknown | undefined; - requestContext?: - | { - [key: string]: unknown; - } - | undefined; - }; - cron: string; - timezone?: string | undefined; - status: 'active' | 'paused'; - nextFireAt: number; - lastFireAt?: number | undefined; - lastRunId?: string | undefined; - lastRun?: + target: | { - status: - | 'running' - | 'success' - | 'failed' - | 'tripwire' - | 'suspended' - | 'waiting' - | 'pending' - | 'canceled' - | 'bailed' - | 'paused' - | 'skipped'; - startedAt?: number | undefined; - completedAt?: number | undefined; - durationMs?: number | undefined; - error?: string | undefined; + type: 'workflow'; + workflowId: string; + inputData?: unknown | undefined; + initialState?: unknown | undefined; + requestContext?: + | { + [key: string]: unknown; + } + | undefined; } - | undefined; - metadata?: | { - [key: string]: unknown; - } - | undefined; - ownerType?: string | undefined; - ownerId?: string | undefined; - createdAt: number; - updatedAt: number; -}; - -export type GetSchedulesScheduleId_Request = Simplify< - (GetSchedulesScheduleId_PathParams extends never ? {} : { params: GetSchedulesScheduleId_PathParams }) & - (never extends never ? {} : {} extends never ? { query?: never } : { query: never }) & - (never extends never ? {} : {} extends never ? { body?: never } : { body: never }) ->; - -export interface GetSchedulesScheduleId_RouteContract { - pathParams: GetSchedulesScheduleId_PathParams; - queryParams: never; - body: never; - request: GetSchedulesScheduleId_Request; - response: GetSchedulesScheduleId_Response; - responseType: 'json'; -} - -// ============================================================================ -// Route: GET /schedules/:scheduleId/triggers -// ============================================================================ -export type GetSchedulesScheduleIdTriggers_PathParams = { - scheduleId: string; -}; - -export type GetSchedulesScheduleIdTriggers_QueryParams = { - limit?: number | undefined; - fromActualFireAt?: number | undefined; - toActualFireAt?: number | undefined; -}; - -export type GetSchedulesScheduleIdTriggers_Response = { - triggers: { - id?: string | undefined; - scheduleId: string; - runId: string | null; - scheduledFireAt: number; - actualFireAt: number; - outcome: - | 'published' - | 'failed' - | 'skipped' - | 'acked' - | 'alerted' - | 'deferred' - | 'appended-from-queue' - | 'dropped-stale' - | 'dropped-superseded' - | 'dropped-busy'; - error?: string | undefined; - triggerKind?: ('schedule-fire' | 'queue-drain') | undefined; - parentTriggerId?: string | undefined; - metadata?: - | { - [key: string]: unknown; - } - | undefined; - run?: - | { - status: - | 'running' - | 'success' - | 'failed' - | 'tripwire' - | 'suspended' - | 'waiting' - | 'pending' - | 'canceled' - | 'bailed' - | 'paused' - | 'skipped'; - startedAt?: number | undefined; - completedAt?: number | undefined; - durationMs?: number | undefined; - error?: string | undefined; - } - | undefined; - }[]; -}; - -export type GetSchedulesScheduleIdTriggers_Request = Simplify< - (GetSchedulesScheduleIdTriggers_PathParams extends never - ? {} - : { params: GetSchedulesScheduleIdTriggers_PathParams }) & - (GetSchedulesScheduleIdTriggers_QueryParams extends never - ? {} - : {} extends GetSchedulesScheduleIdTriggers_QueryParams - ? { query?: GetSchedulesScheduleIdTriggers_QueryParams } - : { query: GetSchedulesScheduleIdTriggers_QueryParams }) & - (never extends never ? {} : {} extends never ? { body?: never } : { body: never }) ->; - -export interface GetSchedulesScheduleIdTriggers_RouteContract { - pathParams: GetSchedulesScheduleIdTriggers_PathParams; - queryParams: GetSchedulesScheduleIdTriggers_QueryParams; - body: never; - request: GetSchedulesScheduleIdTriggers_Request; - response: GetSchedulesScheduleIdTriggers_Response; - responseType: 'json'; -} - -// ============================================================================ -// Route: POST /schedules/:scheduleId/pause -// ============================================================================ -export type PostSchedulesScheduleIdPause_PathParams = { - scheduleId: string; -}; - -export type PostSchedulesScheduleIdPause_Response = { - id: string; - target: { - type: 'workflow'; - workflowId: string; - inputData?: unknown | undefined; - initialState?: unknown | undefined; - requestContext?: - | { - [key: string]: unknown; - } - | undefined; - }; + type: 'heartbeat'; + agentId: string; + prompt: string; + threadId?: string | undefined; + resourceId?: string | undefined; + signalType?: string | undefined; + tagName?: string | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + ifActive?: + | { + behavior?: ('deliver' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + } + | undefined; + ifIdle?: + | { + behavior?: ('wake' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + streamOptions?: + | { + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + } + | undefined; + } + | undefined; + providerOptions?: + | { + [key: string]: unknown; + } + | undefined; + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + }; + cron: string; + timezone?: string | undefined; + status: 'active' | 'paused'; + nextFireAt: number; + lastFireAt?: number | undefined; + lastRunId?: string | undefined; + lastRun?: + | { + status: + | 'running' + | 'success' + | 'failed' + | 'tripwire' + | 'suspended' + | 'waiting' + | 'pending' + | 'canceled' + | 'bailed' + | 'paused' + | 'skipped'; + startedAt?: number | undefined; + completedAt?: number | undefined; + durationMs?: number | undefined; + error?: string | undefined; + } + | undefined; + metadata?: + | { + [key: string]: unknown; + } + | undefined; + ownerType?: string | undefined; + ownerId?: string | undefined; + createdAt: number; + updatedAt: number; +}; + +export type GetSchedulesScheduleId_Request = Simplify< + (GetSchedulesScheduleId_PathParams extends never ? {} : { params: GetSchedulesScheduleId_PathParams }) & + (never extends never ? {} : {} extends never ? { query?: never } : { query: never }) & + (never extends never ? {} : {} extends never ? { body?: never } : { body: never }) +>; + +export interface GetSchedulesScheduleId_RouteContract { + pathParams: GetSchedulesScheduleId_PathParams; + queryParams: never; + body: never; + request: GetSchedulesScheduleId_Request; + response: GetSchedulesScheduleId_Response; + responseType: 'json'; +} + +// ============================================================================ +// Route: GET /schedules/:scheduleId/triggers +// ============================================================================ +export type GetSchedulesScheduleIdTriggers_PathParams = { + scheduleId: string; +}; + +export type GetSchedulesScheduleIdTriggers_QueryParams = { + limit?: number | undefined; + fromActualFireAt?: number | undefined; + toActualFireAt?: number | undefined; +}; + +export type GetSchedulesScheduleIdTriggers_Response = { + triggers: { + id?: string | undefined; + scheduleId: string; + runId: string | null; + scheduledFireAt: number; + actualFireAt: number; + outcome: + | 'published' + | 'succeeded' + | 'delivered' + | 'persisted' + | 'discarded' + | 'skipped' + | 'aborted' + | 'failed' + | 'acked' + | 'alerted' + | 'deferred' + | 'appended-from-queue' + | 'dropped-stale' + | 'dropped-superseded' + | 'dropped-busy'; + error?: string | undefined; + triggerKind?: ('schedule-fire' | 'queue-drain' | 'manual') | undefined; + parentTriggerId?: string | undefined; + metadata?: + | { + [key: string]: unknown; + } + | undefined; + run?: + | { + status: + | 'running' + | 'success' + | 'failed' + | 'tripwire' + | 'suspended' + | 'waiting' + | 'pending' + | 'canceled' + | 'bailed' + | 'paused' + | 'skipped'; + startedAt?: number | undefined; + completedAt?: number | undefined; + durationMs?: number | undefined; + error?: string | undefined; + } + | undefined; + }[]; +}; + +export type GetSchedulesScheduleIdTriggers_Request = Simplify< + (GetSchedulesScheduleIdTriggers_PathParams extends never + ? {} + : { params: GetSchedulesScheduleIdTriggers_PathParams }) & + (GetSchedulesScheduleIdTriggers_QueryParams extends never + ? {} + : {} extends GetSchedulesScheduleIdTriggers_QueryParams + ? { query?: GetSchedulesScheduleIdTriggers_QueryParams } + : { query: GetSchedulesScheduleIdTriggers_QueryParams }) & + (never extends never ? {} : {} extends never ? { body?: never } : { body: never }) +>; + +export interface GetSchedulesScheduleIdTriggers_RouteContract { + pathParams: GetSchedulesScheduleIdTriggers_PathParams; + queryParams: GetSchedulesScheduleIdTriggers_QueryParams; + body: never; + request: GetSchedulesScheduleIdTriggers_Request; + response: GetSchedulesScheduleIdTriggers_Response; + responseType: 'json'; +} + +// ============================================================================ +// Route: POST /schedules/:scheduleId/pause +// ============================================================================ +export type PostSchedulesScheduleIdPause_PathParams = { + scheduleId: string; +}; + +export type PostSchedulesScheduleIdPause_Response = { + id: string; + target: + | { + type: 'workflow'; + workflowId: string; + inputData?: unknown | undefined; + initialState?: unknown | undefined; + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + } + | { + type: 'heartbeat'; + agentId: string; + prompt: string; + threadId?: string | undefined; + resourceId?: string | undefined; + signalType?: string | undefined; + tagName?: string | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + ifActive?: + | { + behavior?: ('deliver' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + } + | undefined; + ifIdle?: + | { + behavior?: ('wake' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + streamOptions?: + | { + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + } + | undefined; + } + | undefined; + providerOptions?: + | { + [key: string]: unknown; + } + | undefined; + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + }; cron: string; timezone?: string | undefined; status: 'active' | 'paused'; @@ -89969,17 +90190,71 @@ export type PostSchedulesScheduleIdResume_PathParams = { export type PostSchedulesScheduleIdResume_Response = { id: string; - target: { - type: 'workflow'; - workflowId: string; - inputData?: unknown | undefined; - initialState?: unknown | undefined; - requestContext?: - | { - [key: string]: unknown; - } - | undefined; - }; + target: + | { + type: 'workflow'; + workflowId: string; + inputData?: unknown | undefined; + initialState?: unknown | undefined; + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + } + | { + type: 'heartbeat'; + agentId: string; + prompt: string; + threadId?: string | undefined; + resourceId?: string | undefined; + signalType?: string | undefined; + tagName?: string | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + ifActive?: + | { + behavior?: ('deliver' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + } + | undefined; + ifIdle?: + | { + behavior?: ('wake' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + streamOptions?: + | { + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + } + | undefined; + } + | undefined; + providerOptions?: + | { + [key: string]: unknown; + } + | undefined; + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + }; cron: string; timezone?: string | undefined; status: 'active' | 'paused'; @@ -90032,6 +90307,817 @@ export interface PostSchedulesScheduleIdResume_RouteContract { responseType: 'json'; } +// ============================================================================ +// Route: GET /heartbeats +// ============================================================================ +export type GetHeartbeats_QueryParams = { + agentId?: string | undefined; + threadId?: string | undefined; + resourceId?: string | undefined; + name?: string | undefined; +}; + +export type GetHeartbeats_Response = { + heartbeats: { + id: string; + agentId: string; + name?: string | undefined; + threadId?: string | undefined; + resourceId?: string | undefined; + prompt: string; + cron: string; + timezone?: string | undefined; + status: 'active' | 'paused'; + nextFireAt: number; + lastFireAt?: number | undefined; + lastRunId?: string | undefined; + lastRun?: + | { + status: + | 'running' + | 'success' + | 'failed' + | 'tripwire' + | 'suspended' + | 'waiting' + | 'pending' + | 'canceled' + | 'bailed' + | 'paused' + | 'skipped'; + startedAt?: number | undefined; + completedAt?: number | undefined; + durationMs?: number | undefined; + error?: string | undefined; + } + | undefined; + signalType?: string | undefined; + tagName?: string | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + ifActive?: + | { + behavior?: ('deliver' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + } + | undefined; + ifIdle?: + | { + behavior?: ('wake' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + streamOptions?: + | { + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + } + | undefined; + } + | undefined; + providerOptions?: + | { + [key: string]: unknown; + } + | undefined; + metadata?: + | { + [key: string]: unknown; + } + | undefined; + createdAt: number; + updatedAt: number; + }[]; +}; + +export type GetHeartbeats_Request = Simplify< + (never extends never ? {} : { params: never }) & + (GetHeartbeats_QueryParams extends never + ? {} + : {} extends GetHeartbeats_QueryParams + ? { query?: GetHeartbeats_QueryParams } + : { query: GetHeartbeats_QueryParams }) & + (never extends never ? {} : {} extends never ? { body?: never } : { body: never }) +>; + +export interface GetHeartbeats_RouteContract { + pathParams: never; + queryParams: GetHeartbeats_QueryParams; + body: never; + request: GetHeartbeats_Request; + response: GetHeartbeats_Response; + responseType: 'json'; +} + +// ============================================================================ +// Route: GET /heartbeats/:heartbeatId +// ============================================================================ +export type GetHeartbeatsHeartbeatId_PathParams = { + heartbeatId: string; +}; + +export type GetHeartbeatsHeartbeatId_Response = { + id: string; + agentId: string; + name?: string | undefined; + threadId?: string | undefined; + resourceId?: string | undefined; + prompt: string; + cron: string; + timezone?: string | undefined; + status: 'active' | 'paused'; + nextFireAt: number; + lastFireAt?: number | undefined; + lastRunId?: string | undefined; + lastRun?: + | { + status: + | 'running' + | 'success' + | 'failed' + | 'tripwire' + | 'suspended' + | 'waiting' + | 'pending' + | 'canceled' + | 'bailed' + | 'paused' + | 'skipped'; + startedAt?: number | undefined; + completedAt?: number | undefined; + durationMs?: number | undefined; + error?: string | undefined; + } + | undefined; + signalType?: string | undefined; + tagName?: string | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + ifActive?: + | { + behavior?: ('deliver' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + } + | undefined; + ifIdle?: + | { + behavior?: ('wake' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + streamOptions?: + | { + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + } + | undefined; + } + | undefined; + providerOptions?: + | { + [key: string]: unknown; + } + | undefined; + metadata?: + | { + [key: string]: unknown; + } + | undefined; + createdAt: number; + updatedAt: number; +}; + +export type GetHeartbeatsHeartbeatId_Request = Simplify< + (GetHeartbeatsHeartbeatId_PathParams extends never ? {} : { params: GetHeartbeatsHeartbeatId_PathParams }) & + (never extends never ? {} : {} extends never ? { query?: never } : { query: never }) & + (never extends never ? {} : {} extends never ? { body?: never } : { body: never }) +>; + +export interface GetHeartbeatsHeartbeatId_RouteContract { + pathParams: GetHeartbeatsHeartbeatId_PathParams; + queryParams: never; + body: never; + request: GetHeartbeatsHeartbeatId_Request; + response: GetHeartbeatsHeartbeatId_Response; + responseType: 'json'; +} + +// ============================================================================ +// Route: POST /heartbeats +// ============================================================================ +export type PostHeartbeats_Body = { + id?: string | undefined; + agentId: string; + cron: string; + timezone?: string | undefined; + prompt: string; + name?: string | undefined; + threadId?: string | undefined; + resourceId?: string | undefined; + signalType?: string | undefined; + tagName?: string | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + ifActive?: + | { + behavior?: ('deliver' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + } + | undefined; + ifIdle?: + | { + behavior?: ('wake' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + streamOptions?: + | { + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + } + | undefined; + } + | undefined; + providerOptions?: + | { + [key: string]: unknown; + } + | undefined; + metadata?: + | { + [key: string]: unknown; + } + | undefined; +}; + +export type PostHeartbeats_Response = { + id: string; + agentId: string; + name?: string | undefined; + threadId?: string | undefined; + resourceId?: string | undefined; + prompt: string; + cron: string; + timezone?: string | undefined; + status: 'active' | 'paused'; + nextFireAt: number; + lastFireAt?: number | undefined; + lastRunId?: string | undefined; + lastRun?: + | { + status: + | 'running' + | 'success' + | 'failed' + | 'tripwire' + | 'suspended' + | 'waiting' + | 'pending' + | 'canceled' + | 'bailed' + | 'paused' + | 'skipped'; + startedAt?: number | undefined; + completedAt?: number | undefined; + durationMs?: number | undefined; + error?: string | undefined; + } + | undefined; + signalType?: string | undefined; + tagName?: string | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + ifActive?: + | { + behavior?: ('deliver' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + } + | undefined; + ifIdle?: + | { + behavior?: ('wake' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + streamOptions?: + | { + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + } + | undefined; + } + | undefined; + providerOptions?: + | { + [key: string]: unknown; + } + | undefined; + metadata?: + | { + [key: string]: unknown; + } + | undefined; + createdAt: number; + updatedAt: number; +}; + +export type PostHeartbeats_Request = Simplify< + (never extends never ? {} : { params: never }) & + (never extends never ? {} : {} extends never ? { query?: never } : { query: never }) & + (PostHeartbeats_Body extends never + ? {} + : {} extends PostHeartbeats_Body + ? { body?: PostHeartbeats_Body } + : { body: PostHeartbeats_Body }) +>; + +export interface PostHeartbeats_RouteContract { + pathParams: never; + queryParams: never; + body: PostHeartbeats_Body; + request: PostHeartbeats_Request; + response: PostHeartbeats_Response; + responseType: 'json'; +} + +// ============================================================================ +// Route: PATCH /heartbeats/:heartbeatId +// ============================================================================ +export type PatchHeartbeatsHeartbeatId_PathParams = { + heartbeatId: string; +}; + +export type PatchHeartbeatsHeartbeatId_Body = { + cron?: string | undefined; + timezone?: string | undefined; + prompt?: string | undefined; + name?: string | undefined; + signalType?: string | undefined; + tagName?: string | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + ifActive?: + | { + behavior?: ('deliver' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + } + | undefined; + ifIdle?: + | { + behavior?: ('wake' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + streamOptions?: + | { + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + } + | undefined; + } + | undefined; + providerOptions?: + | { + [key: string]: unknown; + } + | undefined; + metadata?: + | { + [key: string]: unknown; + } + | undefined; +}; + +export type PatchHeartbeatsHeartbeatId_Response = { + id: string; + agentId: string; + name?: string | undefined; + threadId?: string | undefined; + resourceId?: string | undefined; + prompt: string; + cron: string; + timezone?: string | undefined; + status: 'active' | 'paused'; + nextFireAt: number; + lastFireAt?: number | undefined; + lastRunId?: string | undefined; + lastRun?: + | { + status: + | 'running' + | 'success' + | 'failed' + | 'tripwire' + | 'suspended' + | 'waiting' + | 'pending' + | 'canceled' + | 'bailed' + | 'paused' + | 'skipped'; + startedAt?: number | undefined; + completedAt?: number | undefined; + durationMs?: number | undefined; + error?: string | undefined; + } + | undefined; + signalType?: string | undefined; + tagName?: string | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + ifActive?: + | { + behavior?: ('deliver' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + } + | undefined; + ifIdle?: + | { + behavior?: ('wake' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + streamOptions?: + | { + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + } + | undefined; + } + | undefined; + providerOptions?: + | { + [key: string]: unknown; + } + | undefined; + metadata?: + | { + [key: string]: unknown; + } + | undefined; + createdAt: number; + updatedAt: number; +}; + +export type PatchHeartbeatsHeartbeatId_Request = Simplify< + (PatchHeartbeatsHeartbeatId_PathParams extends never ? {} : { params: PatchHeartbeatsHeartbeatId_PathParams }) & + (never extends never ? {} : {} extends never ? { query?: never } : { query: never }) & + (PatchHeartbeatsHeartbeatId_Body extends never + ? {} + : {} extends PatchHeartbeatsHeartbeatId_Body + ? { body?: PatchHeartbeatsHeartbeatId_Body } + : { body: PatchHeartbeatsHeartbeatId_Body }) +>; + +export interface PatchHeartbeatsHeartbeatId_RouteContract { + pathParams: PatchHeartbeatsHeartbeatId_PathParams; + queryParams: never; + body: PatchHeartbeatsHeartbeatId_Body; + request: PatchHeartbeatsHeartbeatId_Request; + response: PatchHeartbeatsHeartbeatId_Response; + responseType: 'json'; +} + +// ============================================================================ +// Route: DELETE /heartbeats/:heartbeatId +// ============================================================================ +export type DeleteHeartbeatsHeartbeatId_PathParams = { + heartbeatId: string; +}; + +export type DeleteHeartbeatsHeartbeatId_Response = { + message: string; +}; + +export type DeleteHeartbeatsHeartbeatId_Request = Simplify< + (DeleteHeartbeatsHeartbeatId_PathParams extends never ? {} : { params: DeleteHeartbeatsHeartbeatId_PathParams }) & + (never extends never ? {} : {} extends never ? { query?: never } : { query: never }) & + (never extends never ? {} : {} extends never ? { body?: never } : { body: never }) +>; + +export interface DeleteHeartbeatsHeartbeatId_RouteContract { + pathParams: DeleteHeartbeatsHeartbeatId_PathParams; + queryParams: never; + body: never; + request: DeleteHeartbeatsHeartbeatId_Request; + response: DeleteHeartbeatsHeartbeatId_Response; + responseType: 'json'; +} + +// ============================================================================ +// Route: POST /heartbeats/:heartbeatId/pause +// ============================================================================ +export type PostHeartbeatsHeartbeatIdPause_PathParams = { + heartbeatId: string; +}; + +export type PostHeartbeatsHeartbeatIdPause_Response = { + id: string; + agentId: string; + name?: string | undefined; + threadId?: string | undefined; + resourceId?: string | undefined; + prompt: string; + cron: string; + timezone?: string | undefined; + status: 'active' | 'paused'; + nextFireAt: number; + lastFireAt?: number | undefined; + lastRunId?: string | undefined; + lastRun?: + | { + status: + | 'running' + | 'success' + | 'failed' + | 'tripwire' + | 'suspended' + | 'waiting' + | 'pending' + | 'canceled' + | 'bailed' + | 'paused' + | 'skipped'; + startedAt?: number | undefined; + completedAt?: number | undefined; + durationMs?: number | undefined; + error?: string | undefined; + } + | undefined; + signalType?: string | undefined; + tagName?: string | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + ifActive?: + | { + behavior?: ('deliver' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + } + | undefined; + ifIdle?: + | { + behavior?: ('wake' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + streamOptions?: + | { + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + } + | undefined; + } + | undefined; + providerOptions?: + | { + [key: string]: unknown; + } + | undefined; + metadata?: + | { + [key: string]: unknown; + } + | undefined; + createdAt: number; + updatedAt: number; +}; + +export type PostHeartbeatsHeartbeatIdPause_Request = Simplify< + (PostHeartbeatsHeartbeatIdPause_PathParams extends never + ? {} + : { params: PostHeartbeatsHeartbeatIdPause_PathParams }) & + (never extends never ? {} : {} extends never ? { query?: never } : { query: never }) & + (never extends never ? {} : {} extends never ? { body?: never } : { body: never }) +>; + +export interface PostHeartbeatsHeartbeatIdPause_RouteContract { + pathParams: PostHeartbeatsHeartbeatIdPause_PathParams; + queryParams: never; + body: never; + request: PostHeartbeatsHeartbeatIdPause_Request; + response: PostHeartbeatsHeartbeatIdPause_Response; + responseType: 'json'; +} + +// ============================================================================ +// Route: POST /heartbeats/:heartbeatId/resume +// ============================================================================ +export type PostHeartbeatsHeartbeatIdResume_PathParams = { + heartbeatId: string; +}; + +export type PostHeartbeatsHeartbeatIdResume_Response = { + id: string; + agentId: string; + name?: string | undefined; + threadId?: string | undefined; + resourceId?: string | undefined; + prompt: string; + cron: string; + timezone?: string | undefined; + status: 'active' | 'paused'; + nextFireAt: number; + lastFireAt?: number | undefined; + lastRunId?: string | undefined; + lastRun?: + | { + status: + | 'running' + | 'success' + | 'failed' + | 'tripwire' + | 'suspended' + | 'waiting' + | 'pending' + | 'canceled' + | 'bailed' + | 'paused' + | 'skipped'; + startedAt?: number | undefined; + completedAt?: number | undefined; + durationMs?: number | undefined; + error?: string | undefined; + } + | undefined; + signalType?: string | undefined; + tagName?: string | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + ifActive?: + | { + behavior?: ('deliver' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + } + | undefined; + ifIdle?: + | { + behavior?: ('wake' | 'persist' | 'discard') | undefined; + attributes?: + | { + [key: string]: (string | number | boolean | null) | undefined; + } + | undefined; + streamOptions?: + | { + requestContext?: + | { + [key: string]: unknown; + } + | undefined; + } + | undefined; + } + | undefined; + providerOptions?: + | { + [key: string]: unknown; + } + | undefined; + metadata?: + | { + [key: string]: unknown; + } + | undefined; + createdAt: number; + updatedAt: number; +}; + +export type PostHeartbeatsHeartbeatIdResume_Request = Simplify< + (PostHeartbeatsHeartbeatIdResume_PathParams extends never + ? {} + : { params: PostHeartbeatsHeartbeatIdResume_PathParams }) & + (never extends never ? {} : {} extends never ? { query?: never } : { query: never }) & + (never extends never ? {} : {} extends never ? { body?: never } : { body: never }) +>; + +export interface PostHeartbeatsHeartbeatIdResume_RouteContract { + pathParams: PostHeartbeatsHeartbeatIdResume_PathParams; + queryParams: never; + body: never; + request: PostHeartbeatsHeartbeatIdResume_Request; + response: PostHeartbeatsHeartbeatIdResume_Response; + responseType: 'json'; +} + +// ============================================================================ +// Route: POST /heartbeats/:heartbeatId/run +// ============================================================================ +export type PostHeartbeatsHeartbeatIdRun_PathParams = { + heartbeatId: string; +}; + +export type PostHeartbeatsHeartbeatIdRun_Response = { + scheduleId: string; + claimId: string; + scheduledFireAt: number; +}; + +export type PostHeartbeatsHeartbeatIdRun_Request = Simplify< + (PostHeartbeatsHeartbeatIdRun_PathParams extends never ? {} : { params: PostHeartbeatsHeartbeatIdRun_PathParams }) & + (never extends never ? {} : {} extends never ? { query?: never } : { query: never }) & + (never extends never ? {} : {} extends never ? { body?: never } : { body: never }) +>; + +export interface PostHeartbeatsHeartbeatIdRun_RouteContract { + pathParams: PostHeartbeatsHeartbeatIdRun_PathParams; + queryParams: never; + body: never; + request: PostHeartbeatsHeartbeatIdRun_Request; + response: PostHeartbeatsHeartbeatIdRun_Response; + responseType: 'json'; +} + // ============================================================================ // Route: GET /channels/platforms // ============================================================================ @@ -91524,6 +92610,7 @@ export interface RouteTypes { 'POST /agents/:agentId/tools/:toolId/execute': PostAgentsAgentIdToolsToolIdExecute_RouteContract; 'POST /agents/:agentId/approve-tool-call': PostAgentsAgentIdApproveToolCall_RouteContract; 'POST /agents/:agentId/send-tool-approval': PostAgentsAgentIdSendToolApproval_RouteContract; + 'GET /agents/:agentId/suspended-runs': GetAgentsAgentIdSuspendedRuns_RouteContract; 'POST /agents/:agentId/decline-tool-call': PostAgentsAgentIdDeclineToolCall_RouteContract; 'POST /agents/:agentId/resume-stream': PostAgentsAgentIdResumeStream_RouteContract; 'POST /agents/:agentId/approve-tool-call-generate': PostAgentsAgentIdApproveToolCallGenerate_RouteContract; @@ -91851,6 +92938,14 @@ export interface RouteTypes { 'GET /schedules/:scheduleId/triggers': GetSchedulesScheduleIdTriggers_RouteContract; 'POST /schedules/:scheduleId/pause': PostSchedulesScheduleIdPause_RouteContract; 'POST /schedules/:scheduleId/resume': PostSchedulesScheduleIdResume_RouteContract; + 'GET /heartbeats': GetHeartbeats_RouteContract; + 'GET /heartbeats/:heartbeatId': GetHeartbeatsHeartbeatId_RouteContract; + 'POST /heartbeats': PostHeartbeats_RouteContract; + 'PATCH /heartbeats/:heartbeatId': PatchHeartbeatsHeartbeatId_RouteContract; + 'DELETE /heartbeats/:heartbeatId': DeleteHeartbeatsHeartbeatId_RouteContract; + 'POST /heartbeats/:heartbeatId/pause': PostHeartbeatsHeartbeatIdPause_RouteContract; + 'POST /heartbeats/:heartbeatId/resume': PostHeartbeatsHeartbeatIdResume_RouteContract; + 'POST /heartbeats/:heartbeatId/run': PostHeartbeatsHeartbeatIdRun_RouteContract; 'GET /channels/platforms': GetChannelsPlatforms_RouteContract; 'GET /channels/:platform/installations': GetChannelsPlatformInstallations_RouteContract; 'POST /channels/:platform/connect': PostChannelsPlatformConnect_RouteContract; @@ -92157,6 +93252,9 @@ export interface Client { '/agents/:agentId/streamVNext': { POST: PostAgentsAgentIdStreamVNext_RouteContract; }; + '/agents/:agentId/suspended-runs': { + GET: GetAgentsAgentIdSuspendedRuns_RouteContract; + }; '/agents/:agentId/threads/abort': { POST: PostAgentsAgentIdThreadsAbort_RouteContract; }; @@ -92321,6 +93419,24 @@ export interface Client { '/experiments/review-summary': { GET: GetExperimentsReviewSummary_RouteContract; }; + '/heartbeats': { + GET: GetHeartbeats_RouteContract; + POST: PostHeartbeats_RouteContract; + }; + '/heartbeats/:heartbeatId': { + DELETE: DeleteHeartbeatsHeartbeatId_RouteContract; + GET: GetHeartbeatsHeartbeatId_RouteContract; + PATCH: PatchHeartbeatsHeartbeatId_RouteContract; + }; + '/heartbeats/:heartbeatId/pause': { + POST: PostHeartbeatsHeartbeatIdPause_RouteContract; + }; + '/heartbeats/:heartbeatId/resume': { + POST: PostHeartbeatsHeartbeatIdResume_RouteContract; + }; + '/heartbeats/:heartbeatId/run': { + POST: PostHeartbeatsHeartbeatIdRun_RouteContract; + }; '/logs': { GET: GetLogs_RouteContract; }; diff --git a/client-sdks/client-js/src/types.ts b/client-sdks/client-js/src/types.ts index 68d7a23ac3a4..6e4874aa22b6 100644 --- a/client-sdks/client-js/src/types.ts +++ b/client-sdks/client-js/src/types.ts @@ -144,6 +144,19 @@ export interface SubscribeAgentThreadParams { threadId: string; } +export type ListAgentSuspendedRunsParams = GeneratedRequest>; + +/** + * Listed suspended runs as returned by `agent.listSuspendedRuns()`. + * Date fields (e.g. `suspendedAt`) are ISO strings over the wire, matching + * the rest of the client SDK. + */ +export type ListAgentSuspendedRunsResponse = GeneratedResponse<'GET /agents/:agentId/suspended-runs'>; + +export type AgentSuspendedRun = ListAgentSuspendedRunsResponse['runs'][number]; + +export type AgentSuspendedRunToolCall = AgentSuspendedRun['toolCalls'][number]; + /** * @experimental Agent signals are experimental and may change in a future release. */ @@ -3023,17 +3036,15 @@ export interface ScheduleResponse { export type ScheduleTriggerOutcome = | 'published' - | 'failed' + | 'succeeded' + | 'delivered' + | 'persisted' + | 'discarded' | 'skipped' - | 'acked' - | 'alerted' - | 'deferred' - | 'appended-from-queue' - | 'dropped-stale' - | 'dropped-superseded' - | 'dropped-busy'; + | 'aborted' + | 'failed'; -export type ScheduleTriggerKind = 'schedule-fire' | 'queue-drain'; +export type ScheduleTriggerKind = 'schedule-fire' | 'queue-drain' | 'manual'; export interface ScheduleTriggerResponse { id?: string; @@ -3068,6 +3079,128 @@ export interface ListScheduleTriggersResponse { triggers: ScheduleTriggerResponse[]; } +// --------------------------------------------------------------------------- +// Heartbeats +// +// A Heartbeat is the user-facing view of a scheduled agent self-message. The +// underlying storage is a Schedule + built-in workflow, but callers of the +// SDK never see that — the server flattens the schedule's `inputData` onto +// the top level so the SDK contract stays stable across implementations. +// --------------------------------------------------------------------------- + +/** Attributes rendered onto the signal's XML tag. */ +export type HeartbeatSignalAttributes = Record; + +/** Behavior applied when the thread is already streaming. */ +export interface HeartbeatIfActive { + behavior?: 'deliver' | 'persist' | 'discard'; + attributes?: HeartbeatSignalAttributes; +} + +/** + * Behavior applied when the thread is idle, plus a serializable subset of + * stream options forwarded to the woken run. + */ +export interface HeartbeatIfIdle { + behavior?: 'wake' | 'persist' | 'discard'; + attributes?: HeartbeatSignalAttributes; + streamOptions?: { + requestContext?: Record; + }; +} + +export interface Heartbeat { + id: string; + agentId: string; + name?: string; + threadId?: string; + resourceId?: string; + prompt: string; + cron: string; + timezone?: string; + status: 'active' | 'paused'; + nextFireAt: number; + lastFireAt?: number; + lastRunId?: string; + signalType?: string; + tagName?: string; + attributes?: HeartbeatSignalAttributes; + ifActive?: HeartbeatIfActive; + ifIdle?: HeartbeatIfIdle; + providerOptions?: Record; + metadata?: Record; + lastRun?: ScheduleRunSummary; + createdAt: number; + updatedAt: number; +} + +/** + * Body for `client.createHeartbeat(...)`. Mirrors the public + * `CreateHeartbeatInput` shape on the core Heartbeats service. `agentId` + * names the agent the heartbeat fires as. + */ +export interface CreateHeartbeatInput { + /** Optional stable id; normalized to `hb_`. A random id is generated when omitted. */ + id?: string; + agentId: string; + cron: string; + prompt: string; + name?: string; + timezone?: string; + threadId?: string; + resourceId?: string; + signalType?: string; + tagName?: string; + attributes?: HeartbeatSignalAttributes; + ifActive?: HeartbeatIfActive; + ifIdle?: HeartbeatIfIdle; + providerOptions?: Record; + metadata?: Record; +} + +/** + * Patch body for `client.updateHeartbeat(...)`. `threadId` / `resourceId` are + * part of the heartbeat identity and cannot be changed — to retarget, + * delete and recreate. + */ +export interface UpdateHeartbeatOptions { + cron?: string; + prompt?: string; + name?: string; + timezone?: string; + signalType?: string; + tagName?: string; + attributes?: HeartbeatSignalAttributes; + ifActive?: HeartbeatIfActive; + ifIdle?: HeartbeatIfIdle; + providerOptions?: Record; + metadata?: Record; +} + +export interface ListHeartbeatsParams { + agentId?: string; + threadId?: string; + resourceId?: string; + name?: string; +} + +export interface ListHeartbeatsResponse { + heartbeats: Heartbeat[]; +} + +/** + * Response for POST /heartbeats/:heartbeatId/run. + * + * The run runs asynchronously through the same HeartbeatWorker pipeline as + * scheduled fires. `claimId` is the trigger row's `runId` (used to look up + * the resulting trigger row). + */ +export interface RunHeartbeatResponse { + scheduleId: string; + claimId: string; + scheduledFireAt: number; +} + export interface ExperimentReviewCounts { experimentId: string; total: number; diff --git a/client-sdks/react/CHANGELOG.md b/client-sdks/react/CHANGELOG.md index 2db64aadae48..d26eab92ac5d 100644 --- a/client-sdks/react/CHANGELOG.md +++ b/client-sdks/react/CHANGELOG.md @@ -1,5 +1,61 @@ # @mastra/react +## 1.2.1-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + - @mastra/client-js@1.29.0-alpha.9 + +## 1.2.1-alpha.8 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + - @mastra/client-js@1.29.0-alpha.8 + +## 1.2.1-alpha.7 + +### Patch Changes + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + - @mastra/client-js@1.29.0-alpha.7 + +## 1.2.1-alpha.6 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`6a4a466`](https://github.com/mastra-ai/mastra/commit/6a4a466495279c2add2b0fd0afe6989fe7ae352a), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/client-js@1.29.0-alpha.6 + +## 1.2.1-alpha.5 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/client-js@1.29.0-alpha.5 + +## 1.2.1-alpha.4 + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + - @mastra/client-js@1.28.1-alpha.4 + +## 1.2.1-alpha.3 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/client-js@1.28.1-alpha.3 + ## 1.2.1-alpha.2 ### Patch Changes diff --git a/client-sdks/react/package.json b/client-sdks/react/package.json index c227f5cecf88..46a8cbada224 100644 --- a/client-sdks/react/package.json +++ b/client-sdks/react/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/react", - "version": "1.2.1-alpha.2", + "version": "1.2.1-alpha.9", "repository": { "type": "git", "url": "git+https://github.com/mastra-ai/mastra.git", diff --git a/deployers/cloud/CHANGELOG.md b/deployers/cloud/CHANGELOG.md index 2398c4ddb61a..b9324469e530 100644 --- a/deployers/cloud/CHANGELOG.md +++ b/deployers/cloud/CHANGELOG.md @@ -1,5 +1,61 @@ # @mastra/deployer-cloud +## 1.48.0-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + - @mastra/deployer@1.48.0-alpha.9 + +## 1.48.0-alpha.8 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + - @mastra/deployer@1.48.0-alpha.8 + +## 1.48.0-alpha.7 + +### Patch Changes + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`7331245`](https://github.com/mastra-ai/mastra/commit/733124501b4504578648cf15ab6d64330e8778c7), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + - @mastra/deployer@1.48.0-alpha.7 + +## 1.48.0-alpha.6 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`9e76ed9`](https://github.com/mastra-ai/mastra/commit/9e76ed9f9d92619ccf5b77978d8cdea76bcae61e), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/deployer@1.48.0-alpha.6 + +## 1.48.0-alpha.5 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/deployer@1.48.0-alpha.5 + +## 1.48.0-alpha.4 + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + - @mastra/deployer@1.48.0-alpha.4 + +## 1.48.0-alpha.3 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/deployer@1.48.0-alpha.3 + ## 1.48.0-alpha.2 ### Patch Changes diff --git a/deployers/cloud/package.json b/deployers/cloud/package.json index 6a73d11c2341..4b7e2ca11ae3 100644 --- a/deployers/cloud/package.json +++ b/deployers/cloud/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/deployer-cloud", - "version": "1.48.0-alpha.2", + "version": "1.48.0-alpha.9", "description": "", "type": "module", "files": [ diff --git a/docs/AGENTS.md b/docs/AGENTS.md index fe6d0326e0dc..0bf18ec87029 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -2,6 +2,7 @@ Use @styleguides/STYLEGUIDE.md first. @styleguides/ also includes guides for doc When working check src/content/en/docs/ and src/content/en/reference/ update existing docs or create new docs @CONTRIBUTING.md for setup, local development, and components / frontmatter +When adding a model name or ID to docs, use a placeholder token from src/plugins/remark-model-tokens/models.ts (remark replaces them at docs build time) main documentation src/content/en/docs/ step by step guides src/content/en/guides/ @@ -26,4 +27,8 @@ pnpm test:smoke # Smoke tests only desktop pnpm test:og # OG image meta tag tests only desktop pnpm test:navigation # Navigation tests desktop + tablet + mobile +Linting +pnpm validate # Check frontmatter values and if all sidebars are valid +pnpm lint:prose # Check prose with Vale and Remark + Tests live in tests/ helpers in tests/helpers/ and playwright.config.ts starts pnpm serve diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 35928b77f9f7..caac26b6e1be 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -56,10 +56,10 @@ Before submitting a PR, make sure to: 4. **Verify code examples** - If you've added code examples, test them if possible to ensure they work. -5. **Run linters** to check for style issues: +5. **Run linters**: ```shell - pnpm run lint:prose + pnpm run lint:prose && pnpm run validate ``` ## Documentation structure diff --git a/docs/package.json b/docs/package.json index fcf7d0282503..96380e282423 100644 --- a/docs/package.json +++ b/docs/package.json @@ -27,7 +27,7 @@ "lint:vale": "scripts/vale/bin/vale src/content/en/docs src/content/en/guides src/content/en/reference src/learn/content", "lint:vale:ai": "pnpm lint:vale --minAlertLevel=error --output=line", "lint:remark": "remark --no-stdout --frail --quiet --ext mdx src/content/en/docs src/content/en/guides src/content/en/reference src/learn/content", - "lint:prose": "npm-run-all --parallel lint:vale lint:remark", + "lint:prose": "npm-run-all --parallel lint:vale:ai lint:remark", "test": "vitest run", "test:watch": "vitest watch", "test:e2e": "pnpm exec playwright test", diff --git a/docs/src/content/en/docs/agent-builder/memory.mdx b/docs/src/content/en/docs/agent-builder/memory.mdx index cfc81f554469..2da969fc0271 100644 --- a/docs/src/content/en/docs/agent-builder/memory.mdx +++ b/docs/src/content/en/docs/agent-builder/memory.mdx @@ -35,9 +35,13 @@ Observational memory lets the agent learn long-lived facts from past conversatio ## Observational memory model -Observational memory runs an Observer and Reflector model on top of every conversation. The default model is `__GATEWAY_GOOGLE_MODEL__`, which requires a `GOOGLE_API_KEY` environment variable in any environment where the Builder agent will run. Mastra also falls back to `GOOGLE_GENERATIVE_AI_API_KEY`. +Observational memory runs an Observer and Reflector model on top of every conversation. For Agent Builder agents, the default model is `__GATEWAY_OPENAI_MODEL_MINI__`, which requires an `OPENAI_API_KEY` environment variable in any environment where the Builder agent will run. -To use a different model, set `observationalMemory.model` to any model ID supported by the Mastra model router (and provide the matching provider credentials): +:::note +This default applies only to agents created through the Agent Builder. Core (non-builder) agents configured with `observationalMemory: true` keep the framework default `__GATEWAY_GOOGLE_MODEL__` (which uses `GOOGLE_API_KEY`, falling back to `GOOGLE_GENERATIVE_AI_API_KEY`). +::: + +To use a different model, set `observationalMemory.model` to any model ID supported by the Mastra model router (and provide the matching provider credentials). An explicit model always wins over the Builder default: ```typescript title="src/mastra/index.ts" new MastraEditor({ diff --git a/docs/src/content/en/docs/agent-controller/overview.mdx b/docs/src/content/en/docs/agent-controller/overview.mdx index d442f31360f2..74b1e598da95 100644 --- a/docs/src/content/en/docs/agent-controller/overview.mdx +++ b/docs/src/content/en/docs/agent-controller/overview.mdx @@ -29,7 +29,7 @@ The AgentController gives you the runtime pieces to ship interactive agent appli - **Let users pick the right model for each step.** Per-mode [model management](/reference/agent-controller/agent-controller-class) switches models at runtime and tracks usage, which powers copilot UIs where users trade speed for capability. - **Delegate focused work to child agents.** [Subagents](/docs/agent-controller/subagents) run subtasks with constrained tools and can fork the parent conversation, so a research mode can spin off web search or code review without polluting the main thread. - **Drive a live UI from agent activity.** The [event system](/docs/agent-controller/session) emits typed events and coalesced display snapshots, so your TUI or web app reflects message updates, mode changes, and pending approvals in real time. -- **Run long-lived autonomous agents.** Structured task lists, heartbeat handlers, and observational memory keep background task runners on track and let them learn across threads. +- **Run long-lived autonomous agents.** Structured task lists, interval handlers, and observational memory keep background task runners on track and let them learn across threads. ## When to use the AgentController diff --git a/docs/src/content/en/docs/agents/agent-approval.mdx b/docs/src/content/en/docs/agents/agent-approval.mdx index de972a79c9df..91733f9ee5d1 100644 --- a/docs/src/content/en/docs/agents/agent-approval.mdx +++ b/docs/src/content/en/docs/agents/agent-approval.mdx @@ -380,6 +380,48 @@ For automatic tool resumption to work: Both approaches work with the same tool definitions. Automatic resumption triggers only when suspended tools exist in the message history and the user sends a new message on the same thread. +## Resuming after a restart + +The examples above hold on to `stream.runId` between suspension and approval. That works while the process stays alive, but in production the approval often arrives later — after a page refresh, a server restart, or on a different server instance behind a load balancer. + +Use [`listSuspendedRuns()`](/reference/agents/listSuspendedRuns) to rediscover the pending run for a conversation from storage: + +```typescript +// In the request handler that receives the user's decision +const { runs } = await agent.listSuspendedRuns({ + threadId: 'thread-123', + resourceId: 'user-456', +}) + +const run = runs[0] +const toolCall = run?.toolCalls[0] + +if (run && toolCall) { + let stream + if (toolCall.requiresApproval) { + // Suspended by requireApproval — approve or decline the tool call + stream = await agent.approveToolCall({ runId: run.runId, toolCallId: toolCall.toolCallId }) + } else { + // Suspended by suspend() — resume with the data the tool asked for + console.log('Tool asked:', toolCall.suspendPayload) + stream = await agent.resumeStream({ name: 'San Francisco' }, { runId: run.runId }) + } + for await (const chunk of stream.textStream) process.stdout.write(chunk) +} +``` + +Each returned run includes the suspended tool calls (`toolCallId`, `toolName`, `args`, and `requiresApproval`). Approval suspensions (`requiresApproval: true`) are answered with `approveToolCall()` / `declineToolCall()`, while `suspend()`-based suspensions carry their `suspendPayload` and expect `resumeStream()` with resume data — so you can rebuild the right UI for either flow without keeping any state in memory. + +`sendToolApproval()` uses the same storage-backed discovery automatically: when no active run is found in memory for the thread, it looks up the suspended run in storage before failing. If several suspended runs match the thread, pass a `toolCallId` to disambiguate. + +The same discovery is available over HTTP as `GET /agents/:agentId/suspended-runs` and in the client SDK as [`agent.listSuspendedRuns()`](/reference/client-js/agents#listsuspendedruns), so browser-based approval UIs can rediscover pending runs directly. + +:::note + +Suspended runs only survive restarts when your Mastra instance is configured with a persistent [storage provider](/docs/memory/storage). The default in-memory store loses snapshots when the process exits. + +::: + ## Tool approval: Supervisor agents A [supervisor agent](/docs/agents/supervisor-agents) coordinates multiple subagents using `.stream()` or `.generate()`. When a subagent calls a tool that requires approval, the request propagates up through the delegation chain and surfaces at the supervisor level: diff --git a/docs/src/content/en/docs/agents/file-based-agents.mdx b/docs/src/content/en/docs/agents/file-based-agents.mdx new file mode 100644 index 000000000000..3bbaf2779295 --- /dev/null +++ b/docs/src/content/en/docs/agents/file-based-agents.mdx @@ -0,0 +1,279 @@ +--- +title: "File-based agents | Agents" +description: Define agents by file convention under src/mastra/agents, alongside agents created in code. +packages: + - "@mastra/core" +--- + +# File-based agents + +**Added in:** `@mastra/core@1.48.0` + +:::experimental + +This feature is in beta. Breaking changes may occur without a major version bump until the API is stable. + +::: + +You can define an agent by file convention instead of constructing it in code. Mastra discovers a directory under `src/mastra/agents//`, assembles an [`Agent`](/reference/agents/agent), and registers it on your [`Mastra`](/reference/core/mastra-class) instance. + +## When to use file-based agents + +Use file-based agents when you want one directory per agent, with configuration, instructions, tools, skills, workspace files, and subagents grouped together. + +Keep using [`new Agent()`](/reference/agents/agent) in code when you need dynamic configuration, programmatic registration, or a shared agent instance across modules. Both approaches can coexist: code-registered agents are always present, and the bundler adds file-based agents when you run through the Mastra CLI. + +## Quickstart + +Create a directory for the agent and add a `config.ts`. Use `agentConfig` so the partial config is typed while sibling files supply the rest. + +```typescript title="src/mastra/agents/weather/config.ts" +import { agentConfig } from '@mastra/core/agent' + +export default agentConfig({ + model: '__GATEWAY_OPENAI_MODEL__', + // instructions omitted -> taken from instructions.md + // tools omitted -> taken from tools/*.ts +}) +``` + +Add the agent instructions: + +```markdown title="src/mastra/agents/weather/instructions.md" +You are a helpful weather assistant. Answer questions about current conditions and forecasts. +``` + +Add a tool. The filename becomes the tool key, so this file is exposed as `get_weather`. + +```typescript title="src/mastra/agents/weather/tools/get_weather.ts" +import { createTool } from '@mastra/core/tools' +import { z } from 'zod' + +export default createTool({ + id: 'get_weather', + description: 'Get the current weather for a city', + inputSchema: z.object({ city: z.string() }), + execute: async ({ context }) => ({ city: context.city, tempC: 21 }), +}) +``` + +Your `src/mastra/index.ts` stays the same. The discovered agent is registered automatically when you run the app through `mastra dev` or `mastra build`. + +```typescript title="src/mastra/index.ts" +import { Mastra } from '@mastra/core' +import { Agent } from '@mastra/core/agent' + +const supportAgent = new Agent({ + id: 'support', + name: 'support', + instructions: 'You are a support agent.', + model: '__GATEWAY_OPENAI_MODEL__', +}) + +// `support` is registered in code; `weather` is discovered from the filesystem. +export const mastra = new Mastra({ + agents: { support: supportAgent }, +}) +``` + +Start the app through the Mastra CLI: + +```bash npm2yarn +npx mastra dev +``` + +## Folder structure + +The following table shows the supported file-based agent surface: + +| File / directory | Maps to | +| - | - | +| `agents//config.ts` | Default export merged into the agent config. `id` and `name` default to ``. | +| `agents//instructions.md` | The agent `instructions`. The file contents are inlined into generated code. | +| `agents//tools/*.ts` | Each default-exported [`createTool()`](/reference/tools/create-tool). The tool key defaults to the filename. | +| `agents//skills/*.ts` | Each default-exported [`createSkill()`](/reference/agents/createSkill), added to the agent `skills`. | +| `agents//skills//SKILL.md` | A packaged skill. Frontmatter supplies `name` and `description`, the body is the instructions, and files under `references/` are inlined. | +| `agents//skills/.md` | A flat skill. The filename is the skill name and the body is the instructions. | +| `agents//memory.ts` | Default export: a [`Memory`](/reference/memory/memory-class) instance used as the agent `memory`. | +| `agents//workspace.ts` | Default export: a [`Workspace`](/reference/workspace/workspace-class) for the agent. | +| `agents//workspace/` | Seed files mirrored into the agent's default workspace at build time. | +| `agents//subagents//` | A declared subagent with the same layout as an agent directory. Wired into the parent as a delegation tool named ``. | + +Test files named `*.test.ts`, `*.spec.ts`, `*.test.js`, and `*.spec.js` are ignored during tool and skill discovery. + +## Add skills + +Add skills to a file-based agent by placing them under `agents//skills/`. Three layouts are supported, and they're inlined into the bundle at build time so the deployed agent doesn't read them from disk at runtime. + +- A `.ts` file can default-export `createSkill()`: + + ```typescript title="src/mastra/agents/weather/skills/forecasting.ts" + import { createSkill } from '@mastra/core/skills' + + export default createSkill({ + name: 'forecasting', + description: 'Use when the user asks about multi-day forecasts.', + instructions: 'Summarize the forecast day by day and call out precipitation.', + }) + ``` + + :::note + Visit [`createSkill()` reference](/reference/agents/createSkill) for the full API. + ::: + +- A packaged `SKILL.md` directory uses frontmatter for the name and description. Files under `references/` are inlined with the skill. + + ```markdown title="src/mastra/agents/weather/skills/severe-weather/SKILL.md" + --- + name: severe-weather + description: Use when conditions include storms, flooding, or other hazards. + --- + + Lead with the active alert, then give safety guidance. + ``` + +- A flat `.md` file uses the filename as the skill name: + + ```markdown title="src/mastra/agents/weather/skills/units.md" + Always report temperatures in both Celsius and Fahrenheit. + ``` + +Discovered skills merge with any `skills` in `config.ts`. On a name collision, `config.skills` wins and a warning is logged. If `config.skills` is a function, discovered skills are ignored with a warning because they can't be statically merged. + +## Add memory + +Give a file-based agent [memory](/docs/memory/overview) by adding a `memory.ts` that default-exports a [`Memory`](/reference/memory/memory-class) instance: + +```typescript title="src/mastra/agents/weather/memory.ts" +import { Memory } from '@mastra/memory' + +export default new Memory() +``` + +The exported instance becomes the agent's `memory`. You can also set `memory` directly in `config.ts`. `config.memory` wins over `memory.ts`, and a warning is logged when both are present. If neither is present, the agent has no memory, which is the default. + +## Add a workspace + +When a file-based agent is discovered through `mastra dev` or `mastra build`, it gets a default workspace unless `config.workspace` or `workspace.ts` supplies one. The default workspace uses a contained filesystem and shell sandbox rooted at a per-agent `workspace/` directory in the bundle. + +To customize it, add a `workspace.ts` that default-exports a [`Workspace`](/reference/workspace/workspace-class): + +```typescript title="src/mastra/agents/weather/workspace.ts" +import { Workspace, LocalFilesystem, LocalSandbox } from '@mastra/core/workspace' + +export default new Workspace({ + name: 'weather-workspace', + filesystem: new LocalFilesystem({ basePath: './data/weather' }), + sandbox: new LocalSandbox({ workingDirectory: './data/weather' }), +}) +``` + +You can also set `workspace` directly in `config.ts`. `config.workspace` wins over `workspace.ts`, and `workspace.ts` wins over the default workspace. + +### Seed files + +Add a `workspace/` directory to ship files with the agent. Mastra mirrors every file under it into the agent's default workspace at build time, so the agent starts with those files on disk: + +```text +src/mastra/agents/weather/ + config.ts + workspace/ + README.md + data/cities.json +``` + +Seed files are copied into the bundle next to the running server. Commit the starting files alongside the agent that uses them. + +## Add subagents + +A file-based agent can declare **subagents**, specialist child agents it can delegate to. Add a `subagents/` directory under the agent, with one directory per subagent. Each subagent directory has the same layout as a top-level agent: `config.ts`, `instructions.md`, `tools/`, `skills/`, `memory.ts`, `workspace.ts`, and `workspace/`. + +```text +src/mastra/agents/ + supervisor/ + config.ts + instructions.md + subagents/ + researcher/ + config.ts + instructions.md + tools/ + search.ts +``` + +Each subagent is assembled as its own agent and wired into the parent's `agents` map. The agent loop lowers it into a model-visible delegation tool. The tool name is the bare directory name, so the example above exposes `researcher` to the supervisor. + +A subagent's `config.ts` must set a non-empty `description`. The description is what the model sees when deciding whether to delegate, so the build fails if it's missing. + +```typescript title="src/mastra/agents/supervisor/subagents/researcher/config.ts" +import { agentConfig } from '@mastra/core/agent' + +export default agentConfig({ + model: '__GATEWAY_OPENAI_MODEL__', + description: 'Researches a topic and returns cited findings.', +}) +``` + +Subagents are isolated. A subagent inherits nothing from its parent. Its tools, skills, and workspace come only from its own directory, and any absent slot falls back to the same framework defaults as a top-level agent. + +Subagents are one level deep. A `subagents/` directory nested inside a subagent is ignored with a warning. + +Naming rules: + +- A subagent id that collides with one of the parent's tool keys is a build error. +- A duplicate subagent id under the same parent is a build error. +- If a subagent id also exists in the parent's `config.agents`, the `config.agents` entry wins and logs a warning. +- If `config.agents` is a function, discovered subagents are ignored with a warning because they can't be statically merged. + +## Precedence rules + +File-based and code-based agents coexist with deterministic rules: + +- **Code wins on name collisions:** If an agent name exists in both code and the filesystem, the code-registered agent is kept and a warning is logged. +- **A folder can hold a code agent:** If `config.ts` exports `new Agent({...})`, that instance is used as-is. Sibling `instructions.md`, `tools/`, `skills/`, `memory.ts`, `workspace.ts`, and `subagents/` entries are ignored with warnings. +- **Instructions:** Dynamic function instructions in `config.ts` win over `instructions.md`. Otherwise, `instructions.md` wins over a static `instructions` string. If neither is present, the build fails for that agent. +- **Model:** A missing `model` fails the build and names the agent directory. +- **Tools:** Tools from `tools/*.ts` merge with `config.tools`. On a key collision, `config.tools` wins and a warning is logged. If `config.tools` is a function, discovered tools are ignored with a warning. +- **Skills:** Skills from `skills/` merge with `config.skills`. On a name collision, `config.skills` wins and a warning is logged. If `config.skills` is a function, discovered skills are ignored with a warning. +- **Memory:** `config.memory` wins over `memory.ts`, and a warning is logged when both are present. If neither is set, the agent has no memory. +- **Workspace:** `config.workspace` wins over `workspace.ts`, which wins over the default workspace. +- **Subagents:** Subagents from `subagents/` merge with `config.agents`. On an id collision, `config.agents` wins and a warning is logged. A subagent id that collides with a tool key, or a duplicate subagent id, is a build error. + +## What happens at build time + +File-based agents are discovered by the Mastra **bundler**, the step that runs under `mastra dev` and `mastra build`. + +File-based agents are registered only when your app runs through the Mastra CLI. If you import your `mastra` instance directly, `agents//` directories aren't discovered. + +When you consume Mastra as a library, register those agents in code instead of relying on file discovery: + +```typescript title="src/mastra/index.ts" +import { Mastra } from '@mastra/core' +import { Agent } from '@mastra/core/agent' + +const weather = new Agent({ + id: 'weather', + name: 'weather', + instructions: 'You are a helpful weather assistant.', + model: '__GATEWAY_OPENAI_MODEL__', +}) + +export const mastra = new Mastra({ + agents: { weather }, +}) +``` + +## Related + +- [Agents overview](/docs/agents/overview) +- [Tools](/docs/agents/using-tools) +- [Skills](/docs/agents/skills) +- [Memory](/docs/memory/overview) +- [Supervisor agents](/docs/agents/supervisor-agents) +- [Workspace](/docs/workspace/overview) +- [Studio overview](/docs/studio/overview) +- [`Agent` reference](/reference/agents/agent) +- [`createTool()` reference](/reference/tools/create-tool) +- [`createSkill()` reference](/reference/agents/createSkill) +- [`Memory` reference](/reference/memory/memory-class) +- [`Workspace` reference](/reference/workspace/workspace-class) diff --git a/docs/src/content/en/docs/agents/heartbeats.mdx b/docs/src/content/en/docs/agents/heartbeats.mdx new file mode 100644 index 000000000000..18a6cf378ae3 --- /dev/null +++ b/docs/src/content/en/docs/agents/heartbeats.mdx @@ -0,0 +1,223 @@ +--- +title: "Heartbeats | Agents" +description: "Run an agent on a cron schedule to deliver a recurring prompt, with optional thread delivery and lifecycle hooks." +packages: + - "@mastra/core" + - "@mastra/client-js" +--- + +# Heartbeats + +**Added in:** `@mastra/core@1.46.0` + +:::experimental + +This feature is in alpha. Breaking changes may occur without a major version bump until the API is stable. + +::: + +A heartbeat runs an agent on a cron schedule. On each fire, Mastra sends a prompt to the agent, either as a [signal](/docs/agents/signals) into a thread or as a threadless `agent.generate()` run. Use heartbeats for recurring agent work such as daily summaries, periodic checks, or scheduled nudges into a conversation. + +Heartbeats are persisted, so they survive restarts and redeploys. Manage them at runtime through `mastra.heartbeats`, the canonical create, read, update, and delete (CRUD) surface. + +## Prerequisites + +Heartbeats require a [storage](/docs/memory/storage) adapter that implements the schedules domain, for example `@mastra/libsql`. Without one, `mastra.heartbeats.create()` throws. + +## Quickstart + +The following heartbeat runs the `pinger` agent every hour. It has no thread, so each fire is an isolated `agent.generate()` run. + +```typescript title="src/mastra/heartbeats.ts" +import { Mastra } from '@mastra/core' +import { Agent } from '@mastra/core/agent' +import { LibSQLStore } from '@mastra/libsql' + +const pinger = new Agent({ + id: 'pinger', + name: 'Pinger', + instructions: 'Report the current system status in one sentence.', + model: '__GATEWAY_OPENAI_MODEL__', +}) + +const mastra = new Mastra({ + agents: { pinger }, + storage: new LibSQLStore({ url: 'file:./mastra.db' }), +}) + +await mastra.heartbeats.create({ + agentId: 'pinger', + cron: '0 * * * *', + prompt: 'Give me a status update.', +}) +``` + +Mastra starts the scheduler the first time a heartbeat is created, then fires the agent on the cron you specify. + +## Cadence + +Heartbeats fire on a cron expression. The `cron` field accepts a standard 5-, 6-, or 7-part cron expression, and it's validated when you create or update the heartbeat. + +Croner nicknames also work, for example `@hourly`, `@daily`, `@weekly`, `@monthly`, and `@midnight`. For day-and-time combinations, write the cron field directly: + +```typescript +// Every weekday at 9am +await mastra.heartbeats.create({ + agentId: 'pinger', + cron: '0 9 * * 1-5', + prompt: 'Start-of-day check.', +}) +``` + +Set `timezone` to an IANA timezone, for example `America/New_York`, so fire times don't depend on the host's locale. When omitted, the cron resolves against the host's local timezone. + +For more readable cron construction, you can use a userland builder such as [`cron-time-generator`](https://www.npmjs.com/package/cron-time-generator) and pass its output to `cron`. + +## Threadless and threaded heartbeats + +A heartbeat fires in one of two modes, decided by whether you pass a `threadId`. + +### Threadless + +Without a `threadId`, each fire is an isolated `agent.generate()` run. Nothing is written to a conversation thread. This is the simplest mode and suits status checks, reports, and other work that doesn't need conversation context. + +### Threaded + +With a `threadId`, the heartbeat sends a [signal](/docs/agents/signals) into that thread, so the prompt joins the agent's conversation. Threaded heartbeats require a `resourceId` alongside the `threadId`. + +```typescript title="src/mastra/heartbeats.ts" +await mastra.heartbeats.create({ + agentId: 'pinger', + cron: '0 9 * * *', + prompt: 'Summarize anything new since yesterday.', + threadId: 'thread-123', + resourceId: 'user-456', +}) +``` + +Threaded heartbeats accept extra fields that control how the signal behaves. They mirror the options [`agent.sendSignal`](/docs/agents/signals) accepts and stay JSON-serializable so they persist with the schedule. + +- `signalType`: the [signal type](/docs/agents/signals) to send, for example `notification` or `system-reminder`. Defaults to `notification`. +- `tagName`: the XML tag the signal renders as. Defaults to `heartbeat`, so a fire surfaces to the agent as ``. +- `attributes`: values rendered onto the signal's XML tag. +- `ifActive`: behavior when the thread is already streaming, as `{ behavior, attributes }`. `behavior` is one of `deliver`, `persist`, or `discard`. +- `ifIdle`: behavior when the thread is idle, as `{ behavior, attributes, streamOptions }`. `behavior` is one of `wake`, `persist`, or `discard`. `streamOptions.requestContext` is applied to the woken run. + +These fields require a `threadId`. Passing them on a threadless heartbeat throws. + +```typescript +await mastra.heartbeats.create({ + agentId: 'pinger', + cron: '0 9 * * *', + prompt: 'Summarize anything new since yesterday.', + threadId: 'thread-123', + resourceId: 'user-456', + tagName: 'check-in', // renders as + attributes: { source: 'cron' }, + ifActive: { behavior: 'discard' }, // skip if the thread is mid-stream + ifIdle: { + behavior: 'wake', // wake the agent if the thread is idle + streamOptions: { requestContext: { locale: 'en-US' } }, + }, +}) +``` + +`providerOptions` are merged into the signal payload on every fire and apply to both threaded and threadless heartbeats. + +## Managing heartbeats + +Use `mastra.heartbeats` for all heartbeat operations. To scope to a single agent, pass `agentId` to `create` or `list`. + +```typescript +// Create +const hb = await mastra.heartbeats.create({ + agentId: 'pinger', + cron: '0 * * * *', + prompt: 'Status check.', +}) + +// Read +await mastra.heartbeats.get(hb.id) +await mastra.heartbeats.list({ agentId: 'pinger' }) + +// Update — changing cron or timezone recomputes the next fire time +await mastra.heartbeats.update(hb.id, { cron: '*/30 * * * *' }) + +// Pause and resume +await mastra.heartbeats.pause(hb.id) +await mastra.heartbeats.resume(hb.id) + +// Fire once now, off-schedule +await mastra.heartbeats.run(hb.id) + +// Delete +await mastra.heartbeats.delete(hb.id) +``` + +A few rules worth knowing: + +- `pause` and `resume` are durable and idempotent. A paused heartbeat survives restarts, and `resume` recomputes the next fire time from now rather than firing backlogged runs. +- `run` fires the heartbeat once immediately without affecting its schedule. +- `list` filters by `agentId`, `threadId`, `resourceId`, and `name`. + +### Custom IDs + +By default `create` generates a random `hb_` id. Pass `id` to choose a stable one, for example when you want a predictable handle to look up, update, or delete later: + +```typescript +await mastra.heartbeats.create({ + id: 'nightly-summary', + agentId: 'pinger', + cron: '0 9 * * *', + prompt: 'Summarize anything new since yesterday.', +}) +// stored as `hb_nightly-summary` +``` + +The id is normalized to `hb_`: the `hb_` prefix is added if missing and the rest is slugified. Creating a heartbeat with an id that already exists throws, so use `update` to change an existing one. + +### From the client + +The same operations are available from `@mastra/client-js` over the server routes, so you can manage heartbeats from a separate process or a UI. + +## Lifecycle hooks + +Hooks let you run code at key points in a heartbeat's lifecycle, for example to compute fire-time parameters or react to the outcome. Configure them on the `Mastra` constructor under `heartbeat`. The hooks are a single flat bundle that runs for every agent's heartbeats; each hook context carries the firing `agentId`, so branch on it when you need per-agent behavior. Hooks live at the `Mastra` level so they apply to both code-defined and stored agents. + +```typescript title="src/mastra/index.ts" +const mastra = new Mastra({ + agents: { pinger }, + storage: new LibSQLStore({ url: 'file:./mastra.db' }), + heartbeat: { + prepare: async ({ agentId, heartbeat, trigger }) => { + // Return overrides, null to skip this fire, or undefined for defaults + return { prompt: `Status as of ${trigger.firedAt.toISOString()}` } + }, + onFinish: async ({ agentId, outcome, runId }) => { + // Runs on any non-error, non-abort outcome + }, + onError: async ({ agentId, phase, error }) => { + // Runs when prepare, the signal, or the agent run threw + }, + onAbort: async ({ agentId, runId }) => { + // Runs when the run was aborted mid-stream + }, + }, +}) +``` + +The hooks are: + +- `prepare`: runs before the fire. Return an object to override fire-time parameters such as `prompt` or `threadId`, `null` to skip the fire, or `undefined` to use the stored defaults. +- `onFinish`: runs once per trigger that reached a non-error, non-abort terminal state. +- `onError`: runs when `prepare`, the signal, or the agent run threw. +- `onAbort`: runs when the run was aborted mid-stream. + +Every hook context includes `agentId` (the agent the heartbeat fired for) alongside `heartbeat` and `trigger`. + +Hook exceptions are caught and logged. They never re-route the worker or trigger another hook. + +## Related + +- [Signals](/docs/agents/signals): the delivery mechanism behind threaded heartbeats. +- [Scheduled workflows](/docs/workflows/scheduled-workflows): run a workflow, rather than an agent, on a cron schedule. diff --git a/docs/src/content/en/docs/getting-started/project-structure.mdx b/docs/src/content/en/docs/getting-started/project-structure.mdx index 622e64dc208e..93e3ec24be52 100644 --- a/docs/src/content/en/docs/getting-started/project-structure.mdx +++ b/docs/src/content/en/docs/getting-started/project-structure.mdx @@ -7,13 +7,13 @@ description: Guide on organizing folders and files in Mastra, including best pra Your new Mastra project, created with the `create mastra` command, comes with a predefined set of files and folders to help you get started. -Mastra is a framework, but it's **unopinionated** about how you organize or colocate your files. The CLI provides a sensible default structure that works well for most projects, but you're free to adapt it to your workflow or team conventions. You could even build your entire project in a single file if you wanted! Whatever structure you choose, keep it consistent to ensure your code stays maintainable and straightforward to navigate. +Mastra is a framework, but it's mostly **unopinionated** about how you organize or colocate your files. The CLI provides a sensible default structure that works well for most projects, but you're free to adapt it to your workflow or team conventions. You could even build your entire project in a single file if you wanted! Whatever structure you choose, keep it consistent to ensure your code stays maintainable and straightforward to navigate. ## Default project structure A project created with the `create mastra` command looks like this: -``` +```bash src/ ├── mastra/ │ ├── agents/ @@ -30,13 +30,11 @@ src/ └── tsconfig.json ``` -:::tip Use the predefined files as templates. Duplicate and adapt them to quickly create your own agents, tools, workflows, etc. -::: ### Folders -Folders organize your agent's resources, like agents, tools, and workflows. +Mastra recommends organizing your code into the following folders: | Folder | Description | | - | - | @@ -44,9 +42,13 @@ Folders organize your agent's resources, like agents, tools, and workflows. | `src/mastra/agents` | Define and configure your agents - their behavior, goals, and tools. | | `src/mastra/workflows` | Define multi-step workflows that orchestrate agents and tools together. | | `src/mastra/tools` | Create reusable tools that your agents can call | -| `src/mastra/mcp` | (Optional) Implement custom MCP servers to share your tools with external agents | -| `src/mastra/scorers` | (Optional) Define scorers for evaluating agent performance over time | -| `src/mastra/public` | (Optional) Contents are copied into the `.build/output` directory during the build process, making them available for serving at runtime | +| `src/mastra/mcp` | Implement custom MCP servers to share your tools with external agents | +| `src/mastra/scorers` | Define scorers for evaluating agent performance over time | + +There are two special folder conventions in Mastra: + +- `src/mastra/agents/`: You can define an agent by file convention instead of constructing it in code. Learn more in the [file-based agents](/docs/agents/file-based-agents) guide. +- `src/mastra/public`: Contents are copied into the `.build/output` directory during the build process, making them available for serving at runtime. ### Top-level files diff --git a/docs/src/content/en/docs/memory/observational-memory.mdx b/docs/src/content/en/docs/memory/observational-memory.mdx index 7e66e9a81c04..b4eb7fc1978e 100644 --- a/docs/src/content/en/docs/memory/observational-memory.mdx +++ b/docs/src/content/en/docs/memory/observational-memory.mdx @@ -179,6 +179,120 @@ OM uses fast local token estimation for this thresholding work. Text is estimate The Observer can also see attachments in the history it reviews. OM keeps readable placeholders like `[Image #1: reference-board.png]` or `[File #1: floorplan.pdf]` in the transcript for readability, and forwards the actual attachment parts alongside the text. Image-like `file` parts are upgraded to image inputs for the Observer when possible, while non-image attachments are forwarded as file parts with normalized token counting. This applies to both normal thread observation and batched resource-scope observation. +### Extractors + +Use extractors when you want OM to persist specific values alongside observations. Built-in values such as **current task**, **suggested response**, and **thread title** use the same extraction pipeline as custom values. + +The following example extracts a compact user profile from observations: + +```typescript title="src/mastra/agents/agent.ts" +import { Agent } from '@mastra/core/agent' +import { Extractor, Memory } from '@mastra/memory' +import { z } from 'zod' + +const memory = new Memory({ + options: { + observationalMemory: { + model: '__GATEWAY_OPENAI_MODEL_MINI__', + observation: { + extract: [ + new Extractor({ + name: 'User profile', + instructions: 'Extract stable user profile facts that should be remembered.', + schema: z.object({ + preferredName: z.string().optional(), + timezone: z.string().optional(), + tools: z.array(z.string()).optional(), + }), + }), + ], + }, + }, + }, +}) + +export const agent = new Agent({ + name: 'assistant', + instructions: 'You are a helpful assistant.', + model: '__GATEWAY_OPENAI_MODEL_MINI__', + memory, +}) +``` + +Adding a `schema` makes the extractor run as a follow-up structured output request. Schema-less extractors are inline string extractors emitted directly in the Observer or Reflector response. + +```typescript title="src/mastra/agents/agent.ts" +new Extractor({ + name: 'Mood', + instructions: 'Extract the user mood as a short phrase.', +}) +``` + +By default, OM shows the last extracted value to the extractor on later runs. Set `includePreviousExtraction: false` when the Observer should not see the previous value. + +```typescript title="src/mastra/agents/agent.ts" +new Extractor({ + name: 'Latest blocker', + instructions: 'Extract any blockers the agent is running into.', + includePreviousExtraction: false, +}) +``` + +Use dynamic `instructions` or `schema` functions when an extractor needs runtime context, such as the active memory instance or request context: + +```typescript title="src/mastra/agents/agent.ts" +new Extractor({ + name: 'Workspace summary', + instructions: ({ memory }) => + memory ? 'Extract workspace facts for this memory instance.' : 'Extract workspace facts.', +}) +``` + +### Working memory updates + +Use `observationalMemory.observation.manageWorkingMemory` to let the Observer manage working memory automatically. The main agent no longer needs to call the working memory tool while it handles the user request, so working memory updates don't depend on the agent remembering to make them. + +This also keeps working memory prompt-cache friendly. Working memory normally lives in the system prompt, so updates can invalidate the prompt cache. OM-managed working memory defaults `workingMemory.useStateSignals` to `true`, which moves working memory into state signals instead. + +```typescript title="src/mastra/agents/agent.ts" +import { Memory } from '@mastra/memory' + +const memory = new Memory({ + options: { + workingMemory: { + enabled: true, + }, + observationalMemory: { + enabled: true, + observation: { + manageWorkingMemory: true, + }, + }, + }, +}) +``` + +This setting adds `WorkingMemoryExtractor`, defaults `workingMemory.agentManaged` to `false`, and defaults `workingMemory.useStateSignals` to `true`. Set `workingMemory.agentManaged: true` if the main agent should still receive working memory tool and instruction injection. + +Use `onExtracted` to normalize or react to custom extracted values before they are persisted: + +```typescript title="src/mastra/agents/agent.ts" +new Extractor({ + name: 'Project status', + instructions: 'Extract the current project status.', + schema: z.string(), + async onExtracted({ current, sendSignal }) { + await sendSignal?.({ + type: 'user-message', + contents: `Project status extracted: ${current}`, + }) + return current.trim().toLowerCase() + }, +}) +``` + +Extractor failures are reported in OM markers and do not block other successful extractor values. See [the API reference](/reference/memory/observational-memory#extractor-api) for the full `Extractor` shape. + If your Observer model is text-only or its API rejects multimodal input, set `observation.observeAttachments` to `false` to drop attachments before they reach the Observer. The readable placeholders (`[Image #1: ...]`, `[File #1: ...]`) are kept in the transcript so the Observer can still reason about what was shared without receiving the binary payload. The same filter applies to tool results that contain image or file parts: ```typescript diff --git a/docs/src/content/en/docs/memory/working-memory.mdx b/docs/src/content/en/docs/memory/working-memory.mdx index b4a5edaeb1bd..920d9a4cc66e 100644 --- a/docs/src/content/en/docs/memory/working-memory.mdx +++ b/docs/src/content/en/docs/memory/working-memory.mdx @@ -20,6 +20,8 @@ Think of it as the agent's active thoughts or scratchpad – the key information This is useful for maintaining ongoing state that's always relevant and should always be available to the agent. +If you use [Observational Memory](/docs/memory/observational-memory), `observationalMemory.observation.manageWorkingMemory` lets OM update working memory for the agent. + Working memory can persist at two different scopes: - **Resource-scoped** (default): Memory persists across all conversation threads for the same user diff --git a/docs/src/content/en/docs/observability/integrations/exporters/otel.mdx b/docs/src/content/en/docs/observability/integrations/exporters/otel.mdx index 86e72eca6b97..085683d24623 100644 --- a/docs/src/content/en/docs/observability/integrations/exporters/otel.mdx +++ b/docs/src/content/en/docs/observability/integrations/exporters/otel.mdx @@ -8,7 +8,7 @@ packages: # OpenTelemetry exporter -The OpenTelemetry (OTEL) exporter sends your traces and logs to any OTEL-compatible observability platform using standardized [OpenTelemetry Semantic Conventions for GenAI](https://opentelemetry.io/docs/specs/semconv/gen-ai/). This ensures broad compatibility with platforms like Datadog, New Relic, SigNoz, MLflow, Dash0, Traceloop, Laminar, and more. +The OpenTelemetry (OTEL) exporter sends your traces and logs to any OTEL-compatible observability platform using standardized [OpenTelemetry Semantic Conventions for GenAI](https://opentelemetry.io/docs/specs/semconv/gen-ai/). This ensures broad compatibility with platforms like Datadog, New Relic, SigNoz, MLflow, Latitude, Dash0, Traceloop, Laminar, and more. :::info Looking for bidirectional OTEL integration? @@ -20,7 +20,7 @@ If you have existing OpenTelemetry instrumentation and want Mastra traces to inh Each provider requires specific protocol packages. Install the base exporter plus the protocol package for your provider: -### For HTTP/Protobuf Providers (SigNoz, New Relic, Laminar, MLflow) +### For HTTP/Protobuf Providers (SigNoz, New Relic, Laminar, MLflow, Latitude) ```bash npm2yarn npm install @mastra/otel-exporter@latest @opentelemetry/exporter-trace-otlp-proto @@ -70,6 +70,27 @@ new OtelExporter({ }) ``` +### Latitude + +[Latitude](https://latitude.so) is an open-source LLM observability and evaluation platform that ingests OTLP traces. Use the `custom` provider with HTTP/Protobuf, pointing at Latitude's ingestion endpoint and authenticating with your API key and project slug: + +```typescript title="src/mastra/index.ts" +new OtelExporter({ + provider: { + custom: { + endpoint: 'https://ingest.latitude.so/v1/traces', + protocol: 'http/protobuf', + headers: { + Authorization: `Bearer ${process.env.LATITUDE_API_KEY}`, + 'X-Latitude-Project': process.env.LATITUDE_PROJECT, + }, + }, + }, +}) +``` + +Sign up at [console.latitude.so](https://console.latitude.so/login), or self-host and point the endpoint at your own ingestion host. + ### Dash0 [Dash0](https://www.dash0.com/) provides real-time observability with automatic insights. diff --git a/docs/src/content/en/docs/observability/metrics/overview.mdx b/docs/src/content/en/docs/observability/metrics/overview.mdx index aece3728d5fb..fee39a45bb4d 100644 --- a/docs/src/content/en/docs/observability/metrics/overview.mdx +++ b/docs/src/content/en/docs/observability/metrics/overview.mdx @@ -17,9 +17,9 @@ Three categories of metrics are emitted automatically: :::note -Metrics require an OLAP-capable store for observability. Relational databases like PostgreSQL, LibSQL, etc. aren't supported for metrics. In-memory storage resets on restart. +Metrics require an analytics-capable store for observability. Most relational databases (LibSQL, MSSQL) aren't supported for metrics. In-memory storage resets on restart. -For local development, use [DuckDB](https://duckdb.org/) through `@mastra/duckdb`. For production, use [ClickHouse](https://clickhouse.com/) through `@mastra/clickhouse`. +For local development, use [DuckDB](https://duckdb.org/) through `@mastra/duckdb`. For production, use [ClickHouse](https://clickhouse.com/) through `@mastra/clickhouse`. `PostgresStoreVNext` with the observability domain enabled also supports metrics, but always provide a time range to avoid full partition scans. Google Cloud Spanner supports metrics, but it's not recommended for heavy metrics workloads. The Spanner adapter disables metrics by default because metrics are write-heavy and scan-heavy. Set `disableMetrics: false` only for light workloads, or route metrics to an OLAP store. diff --git a/docs/src/content/en/docs/sidebars.js b/docs/src/content/en/docs/sidebars.js index 446a35b7a236..60d4049037dc 100644 --- a/docs/src/content/en/docs/sidebars.js +++ b/docs/src/content/en/docs/sidebars.js @@ -112,6 +112,14 @@ const sidebars = { type: 'html', value: 'Memory', }, + { + type: 'doc', + id: 'agents/file-based-agents', + label: 'File-based Agents', + customProps: { + tags: ['beta'], + }, + }, { type: 'doc', id: 'agents/structured-output', @@ -210,6 +218,14 @@ const sidebars = { tags: ['beta'], }, }, + { + type: 'doc', + id: 'agents/heartbeats', + label: 'Heartbeats', + customProps: { + tags: ['alpha'], + }, + }, { type: 'doc', id: 'agents/networks', diff --git a/docs/src/content/en/docs/workspace/filesystem.mdx b/docs/src/content/en/docs/workspace/filesystem.mdx index 1d140961d96b..e2a82e9c1c1f 100644 --- a/docs/src/content/en/docs/workspace/filesystem.mdx +++ b/docs/src/content/en/docs/workspace/filesystem.mdx @@ -32,10 +32,11 @@ Available providers: - [`AzureBlobFilesystem`](/reference/workspace/azure-blob-filesystem): Stores files in Azure Blob Storage - [`FilesSDKFilesystem`](/reference/workspace/files-sdk-filesystem): Stores files in any [FilesSDK](https://files-sdk.dev) adapter (S3, R2, GCS, Azure Blob, Vercel Blob, local filesystem, and more) — useful when you want one provider that can target multiple backends - [`AgentFSFilesystem`](/reference/workspace/agentfs-filesystem): Stores files in a Turso/SQLite database via AgentFS +- [`MesaFilesystem`](/reference/workspace/mesa-filesystem): Stores files in versioned Mesa repos :::tip -`LocalFilesystem` is the simplest way to get started as it requires no external services. For cloud storage, use `S3Filesystem`, `GCSFilesystem`, or `AzureBlobFilesystem`. For database-backed storage without external services, use `AgentFSFilesystem`. +`LocalFilesystem` is the simplest way to get started as it requires no external services. For cloud storage, use `S3Filesystem`, `GCSFilesystem`, or `AzureBlobFilesystem`. For versioned storage, use `MesaFilesystem`. For database-backed storage without external services, use `AgentFSFilesystem`. ::: @@ -248,5 +249,6 @@ When you configure a filesystem on a workspace, agents receive tools for reading - [AzureBlobFilesystem reference](/reference/workspace/azure-blob-filesystem) - [FilesSDKFilesystem reference](/reference/workspace/files-sdk-filesystem) - [AgentFSFilesystem reference](/reference/workspace/agentfs-filesystem) +- [MesaFilesystem reference](/reference/workspace/mesa-filesystem) - [Workspace overview](/docs/workspace/overview) - [Sandbox](/docs/workspace/sandbox) diff --git a/docs/src/content/en/models/gateways/index.mdx b/docs/src/content/en/models/gateways/index.mdx index 0ee802df5c64..43f90f2351d6 100644 --- a/docs/src/content/en/models/gateways/index.mdx +++ b/docs/src/content/en/models/gateways/index.mdx @@ -33,13 +33,13 @@ Create custom gateways for private LLM deployments or specialized provider integ /> } /> Netlify -Netlify AI Gateway provides unified access to multiple providers with built-in caching and observability. Access 64 models through Mastra's model router. +Netlify AI Gateway provides unified access to multiple providers with built-in caching and observability. Access 66 models through Mastra's model router. Learn more in the [Netlify documentation](https://docs.netlify.com/build/ai-gateway/overview/). @@ -61,6 +61,7 @@ ANTHROPIC_API_KEY=ant-... | `anthropic/claude-sonnet-4-5` | | `anthropic/claude-sonnet-4-5-20250929` | | `anthropic/claude-sonnet-4-6` | +| `anthropic/claude-sonnet-5` | | `gemini/gemini-2.5-flash` | | `gemini/gemini-2.5-flash-image` | | `gemini/gemini-2.5-flash-lite` | @@ -69,6 +70,7 @@ ANTHROPIC_API_KEY=ant-... | `gemini/gemini-3-pro-image` | | `gemini/gemini-3.1-flash-image` | | `gemini/gemini-3.1-flash-lite` | +| `gemini/gemini-3.1-flash-lite-image` | | `gemini/gemini-3.1-pro-preview` | | `gemini/gemini-3.1-pro-preview-customtools` | | `gemini/gemini-3.5-flash` | diff --git a/docs/src/content/en/models/gateways/openrouter.mdx b/docs/src/content/en/models/gateways/openrouter.mdx index 2ac332a2215e..38560a9d995f 100644 --- a/docs/src/content/en/models/gateways/openrouter.mdx +++ b/docs/src/content/en/models/gateways/openrouter.mdx @@ -9,7 +9,7 @@ description: "Use AI models through OpenRouter." # OpenRouter logoOpenRouter -OpenRouter aggregates models from multiple providers with enhanced features like rate limiting and failover. Access 337 models through Mastra's model router. +OpenRouter aggregates models from multiple providers with enhanced features like rate limiting and failover. Access 335 models through Mastra's model router. Learn more in the [OpenRouter documentation](https://openrouter.ai/models). @@ -75,7 +75,6 @@ ANTHROPIC_API_KEY=ant-... | `anthropic/claude-opus-4.1` | | `anthropic/claude-opus-4.5` | | `anthropic/claude-opus-4.6` | -| `anthropic/claude-opus-4.6-fast` | | `anthropic/claude-opus-4.7` | | `anthropic/claude-opus-4.7-fast` | | `anthropic/claude-opus-4.8` | @@ -285,7 +284,6 @@ ANTHROPIC_API_KEY=ant-... | `openrouter/bodybuilder` | | `openrouter/free` | | `openrouter/fusion` | -| `openrouter/owl-alpha` | | `openrouter/pareto-code` | | `perceptron/perceptron-mk1` | | `perplexity/sonar` | diff --git a/docs/src/content/en/models/index.mdx b/docs/src/content/en/models/index.mdx index 90fd667aa522..c798320d904d 100644 --- a/docs/src/content/en/models/index.mdx +++ b/docs/src/content/en/models/index.mdx @@ -1,6 +1,6 @@ --- title: "Models" -description: "Access 134+ AI providers and 4474+ models through Mastra's model router." +description: "Access 136+ AI providers and 4488+ models through Mastra's model router." --- {/* This file is auto-generated by generate-model-docs.ts - DO NOT EDIT MANUALLY */} @@ -12,7 +12,7 @@ import { NetlifyLogo } from "@site/src/components/logos/NetlifyLogo"; # Model Providers -Mastra provides a unified interface for working with LLMs across multiple providers, giving you access to 4474 models from 134 providers through a single API. +Mastra provides a unified interface for working with LLMs across multiple providers, giving you access to 4488 models from 136 providers through a single API. ## Features @@ -149,7 +149,7 @@ Browse the directory of available models using the navigation on the left, or ex Google -
+ 126 more
+
+ 128 more
diff --git a/docs/src/content/en/models/providers/anthropic.mdx b/docs/src/content/en/models/providers/anthropic.mdx index 55c143f74611..faaaf4ed2b05 100644 --- a/docs/src/content/en/models/providers/anthropic.mdx +++ b/docs/src/content/en/models/providers/anthropic.mdx @@ -1,13 +1,13 @@ --- title: "Anthropic | Models" -description: "Use Anthropic models with Mastra. 17 models available." +description: "Use Anthropic models with Mastra. 18 models available." --- {/* This file is auto-generated by generate-model-docs.ts - DO NOT EDIT MANUALLY */} # Anthropic logoAnthropic -Access 17 Anthropic models through Mastra's model router. Authentication is handled automatically using the `ANTHROPIC_API_KEY` environment variable. +Access 18 Anthropic models through Mastra's model router. Authentication is handled automatically using the `ANTHROPIC_API_KEY` environment variable. Learn more in the [Anthropic documentation](https://docs.anthropic.com/en/docs/about-claude/models). @@ -242,6 +242,18 @@ for await (const chunk of stream) { "maxOutput": 64000, "inputCost": 3, "outputCost": 15 + }, + { + "model": "anthropic/claude-sonnet-5", + "imageInput": true, + "audioInput": false, + "videoInput": false, + "toolUsage": true, + "reasoning": true, + "contextWindow": 1000000, + "maxOutput": 128000, + "inputCost": 2, + "outputCost": 10 } ]} /> @@ -273,7 +285,7 @@ const agent = new Agent({ model: ({ requestContext }) => { const useAdvanced = requestContext.task === "complex"; return useAdvanced - ? "anthropic/claude-sonnet-4-6" + ? "anthropic/claude-sonnet-5" : "anthropic/claude-fable-5"; } }); diff --git a/docs/src/content/en/models/providers/deepinfra.mdx b/docs/src/content/en/models/providers/deepinfra.mdx index 51e4f6327009..46038b09e62f 100644 --- a/docs/src/content/en/models/providers/deepinfra.mdx +++ b/docs/src/content/en/models/providers/deepinfra.mdx @@ -1,13 +1,13 @@ --- title: "Deep Infra | Models" -description: "Use Deep Infra models with Mastra. 26 models available." +description: "Use Deep Infra models with Mastra. 27 models available." --- {/* This file is auto-generated by generate-model-docs.ts - DO NOT EDIT MANUALLY */} # Deep Infra logoDeep Infra -Access 26 Deep Infra models through Mastra's model router. Authentication is handled automatically using the `DEEPINFRA_API_KEY` environment variable. +Access 27 Deep Infra models through Mastra's model router. Authentication is handled automatically using the `DEEPINFRA_API_KEY` environment variable. Learn more in the [Deep Infra documentation](https://deepinfra.com/models). @@ -183,6 +183,18 @@ for await (const chunk of stream) { "inputCost": 0.75, "outputCost": 3.5 }, + { + "model": "deepinfra/moonshotai/Kimi-K2.7-Code", + "imageInput": true, + "audioInput": false, + "videoInput": true, + "toolUsage": true, + "reasoning": true, + "contextWindow": 262144, + "maxOutput": 262144, + "inputCost": 0.74, + "outputCost": 3.5 + }, { "model": "deepinfra/openai/gpt-oss-120b", "imageInput": false, diff --git a/docs/src/content/en/models/providers/gmicloud.mdx b/docs/src/content/en/models/providers/gmicloud.mdx index 279a9791c1d3..aeee689d31b8 100644 --- a/docs/src/content/en/models/providers/gmicloud.mdx +++ b/docs/src/content/en/models/providers/gmicloud.mdx @@ -1,13 +1,13 @@ --- title: "GMI Cloud | Models" -description: "Use GMI Cloud models with Mastra. 8 models available." +description: "Use GMI Cloud models with Mastra. 11 models available." --- {/* This file is auto-generated by generate-model-docs.ts - DO NOT EDIT MANUALLY */} # GMI Cloud logoGMI Cloud -Access 8 GMI Cloud models through Mastra's model router. Authentication is handled automatically using the `GMICLOUD_API_KEY` environment variable. +Access 11 GMI Cloud models through Mastra's model router. Authentication is handled automatically using the `GMICLOUD_API_KEY` environment variable. Learn more in the [GMI Cloud documentation](https://docs.gmicloud.ai). @@ -22,7 +22,7 @@ const agent = new Agent({ id: "my-agent", name: "My Agent", instructions: "You are a helpful assistant", - model: "gmicloud/anthropic/claude-opus-4.6" + model: "gmicloud/Qwen/Qwen3.7-Max" }); // Generate a response @@ -117,6 +117,30 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "inputCost": 0.855, "outputCost": 3.6 }, + { + "model": "gmicloud/moonshotai/kimi-k2.7-code-highspeed", + "imageInput": false, + "audioInput": false, + "videoInput": false, + "toolUsage": true, + "reasoning": true, + "contextWindow": 262144, + "maxOutput": 262144, + "inputCost": 1.9, + "outputCost": 8 + }, + { + "model": "gmicloud/Qwen/Qwen3.7-Max", + "imageInput": false, + "audioInput": false, + "videoInput": false, + "toolUsage": true, + "reasoning": true, + "contextWindow": 1000000, + "maxOutput": 65536, + "inputCost": 2.5, + "outputCost": 7.5 + }, { "model": "gmicloud/zai-org/GLM-5-FP8", "imageInput": false, @@ -140,6 +164,18 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "maxOutput": 131072, "inputCost": 0.98, "outputCost": 3.08 + }, + { + "model": "gmicloud/zai-org/GLM-5.2-FP8", + "imageInput": false, + "audioInput": false, + "videoInput": false, + "toolUsage": true, + "reasoning": true, + "contextWindow": 1000000, + "maxOutput": 131072, + "inputCost": 0.979, + "outputCost": 3.08 } ]} /> @@ -154,7 +190,7 @@ const agent = new Agent({ name: "custom-agent", model: { url: "https://api.gmi-serving.com/v1", - id: "gmicloud/anthropic/claude-opus-4.6", + id: "gmicloud/Qwen/Qwen3.7-Max", apiKey: process.env.GMICLOUD_API_KEY, headers: { "X-Custom-Header": "value" @@ -172,8 +208,8 @@ const agent = new Agent({ model: ({ requestContext }) => { const useAdvanced = requestContext.task === "complex"; return useAdvanced - ? "gmicloud/zai-org/GLM-5.1-FP8" - : "gmicloud/anthropic/claude-opus-4.6"; + ? "gmicloud/zai-org/GLM-5.2-FP8" + : "gmicloud/Qwen/Qwen3.7-Max"; } }); ``` diff --git a/docs/src/content/en/models/providers/inceptron.mdx b/docs/src/content/en/models/providers/inceptron.mdx index c58ec6d129cd..a4337935b550 100644 --- a/docs/src/content/en/models/providers/inceptron.mdx +++ b/docs/src/content/en/models/providers/inceptron.mdx @@ -1,13 +1,13 @@ --- title: "Inceptron | Models" -description: "Use Inceptron models with Mastra. 4 models available." +description: "Use Inceptron models with Mastra. 6 models available." --- {/* This file is auto-generated by generate-model-docs.ts - DO NOT EDIT MANUALLY */} # Inceptron logoInceptron -Access 4 Inceptron models through Mastra's model router. Authentication is handled automatically using the `INCEPTRON_API_KEY` environment variable. +Access 6 Inceptron models through Mastra's model router. Authentication is handled automatically using the `INCEPTRON_API_KEY` environment variable. Learn more in the [Inceptron documentation](https://docs.inceptron.io). @@ -54,7 +54,7 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "reasoning": true, "contextWindow": 196608, "maxOutput": 196608, - "inputCost": 0.24, + "inputCost": 0.15, "outputCost": 0.9 }, { @@ -66,20 +66,32 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "reasoning": true, "contextWindow": 262144, "maxOutput": 262144, - "inputCost": 0.78, + "inputCost": 0.66, "outputCost": 3.5 }, { - "model": "inceptron/nvidia/llama-3.3-70b-instruct-fp8", - "imageInput": false, + "model": "inceptron/moonshotai/Kimi-K2.6-Fast", + "imageInput": true, "audioInput": false, "videoInput": false, "toolUsage": true, - "reasoning": false, - "contextWindow": 131072, - "maxOutput": 131072, - "inputCost": 0.12, - "outputCost": 0.38 + "reasoning": true, + "contextWindow": 262144, + "maxOutput": 262144, + "inputCost": 1.32, + "outputCost": 7 + }, + { + "model": "inceptron/moonshotai/Kimi-K2.7-Code", + "imageInput": true, + "audioInput": false, + "videoInput": false, + "toolUsage": true, + "reasoning": true, + "contextWindow": 262144, + "maxOutput": 262144, + "inputCost": 0.75, + "outputCost": 3.5 }, { "model": "inceptron/zai-org/GLM-5.1-FP8", @@ -92,6 +104,18 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "maxOutput": 202752, "inputCost": 1.4, "outputCost": 4.4 + }, + { + "model": "inceptron/zai-org/GLM-5.2", + "imageInput": false, + "audioInput": false, + "videoInput": false, + "toolUsage": true, + "reasoning": true, + "contextWindow": 1000000, + "maxOutput": 131072, + "inputCost": 1.2, + "outputCost": 4.2 } ]} /> @@ -124,7 +148,7 @@ const agent = new Agent({ model: ({ requestContext }) => { const useAdvanced = requestContext.task === "complex"; return useAdvanced - ? "inceptron/zai-org/GLM-5.1-FP8" + ? "inceptron/zai-org/GLM-5.2" : "inceptron/MiniMaxAI/MiniMax-M2.5"; } }); diff --git a/docs/src/content/en/models/providers/index.mdx b/docs/src/content/en/models/providers/index.mdx index 7bcf63dcfcd6..31f193522022 100644 --- a/docs/src/content/en/models/providers/index.mdx +++ b/docs/src/content/en/models/providers/index.mdx @@ -19,7 +19,7 @@ Direct access to individual AI model providers. Each provider offers unique mode logo="https://models.dev/logos/openai.svg" /> LLM Gateway -Access 181 LLM Gateway models through Mastra's model router. Authentication is handled automatically using the `LLMGATEWAY_API_KEY` environment variable. +Access 178 LLM Gateway models through Mastra's model router. Authentication is handled automatically using the `LLMGATEWAY_API_KEY` environment variable. Learn more in the [LLM Gateway documentation](https://llmgateway.io/docs). @@ -117,6 +117,18 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "inputCost": 1, "outputCost": 5 }, + { + "model": "llmgateway/claude-haiku-4-5-free", + "imageInput": true, + "audioInput": false, + "videoInput": false, + "toolUsage": true, + "reasoning": false, + "contextWindow": 200000, + "maxOutput": 200000, + "inputCost": null, + "outputCost": null + }, { "model": "llmgateway/claude-opus-4-1-20250805", "imageInput": true, @@ -477,18 +489,6 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "inputCost": 1.1, "outputCost": 4.5 }, - { - "model": "llmgateway/glm-4.5-flash", - "imageInput": false, - "audioInput": false, - "videoInput": false, - "toolUsage": true, - "reasoning": true, - "contextWindow": 128000, - "maxOutput": 98304, - "inputCost": null, - "outputCost": null - }, { "model": "llmgateway/glm-4.5-x", "imageInput": false, @@ -537,18 +537,6 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "inputCost": 0.3, "outputCost": 0.9 }, - { - "model": "llmgateway/glm-4.6v-flash", - "imageInput": true, - "audioInput": false, - "videoInput": false, - "toolUsage": true, - "reasoning": true, - "contextWindow": 128000, - "maxOutput": 16000, - "inputCost": null, - "outputCost": null - }, { "model": "llmgateway/glm-4.6v-flashx", "imageInput": true, @@ -585,18 +573,6 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "inputCost": 0.06, "outputCost": 0.4 }, - { - "model": "llmgateway/glm-4.7-flash-free", - "imageInput": false, - "audioInput": false, - "videoInput": false, - "toolUsage": true, - "reasoning": true, - "contextWindow": 200000, - "maxOutput": 200000, - "inputCost": null, - "outputCost": null - }, { "model": "llmgateway/glm-4.7-flashx", "imageInput": false, @@ -1098,8 +1074,8 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "reasoning": false, "contextWindow": 2000000, "maxOutput": 30000, - "inputCost": 2, - "outputCost": 6 + "inputCost": 1.25, + "outputCost": 2.5 }, { "model": "llmgateway/grok-4-20-reasoning", @@ -1110,8 +1086,8 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "reasoning": true, "contextWindow": 2000000, "maxOutput": 30000, - "inputCost": 2, - "outputCost": 6 + "inputCost": 1.25, + "outputCost": 2.5 }, { "model": "llmgateway/grok-4-3", @@ -1161,18 +1137,6 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "inputCost": 0.574, "outputCost": 2.294 }, - { - "model": "llmgateway/kimi-k2-thinking-turbo", - "imageInput": false, - "audioInput": false, - "videoInput": false, - "toolUsage": true, - "reasoning": true, - "contextWindow": 262144, - "maxOutput": 262144, - "inputCost": 1.15, - "outputCost": 8 - }, { "model": "llmgateway/kimi-k2.5", "imageInput": true, @@ -1194,8 +1158,8 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "reasoning": true, "contextWindow": 262144, "maxOutput": 262144, - "inputCost": 0.5, - "outputCost": 2.8 + "inputCost": 0.4, + "outputCost": 2.2 }, { "model": "llmgateway/kimi-k2.7-code", @@ -1792,7 +1756,7 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "videoInput": false, "toolUsage": true, "reasoning": false, - "contextWindow": 262000, + "contextWindow": 262144, "maxOutput": 8192, "inputCost": 0.09, "outputCost": 0.58 @@ -1939,7 +1903,7 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "contextWindow": 131072, "maxOutput": 32768, "inputCost": 0.15, - "outputCost": 1.5 + "outputCost": 1.2 }, { "model": "llmgateway/qwen3-next-80b-a3b-thinking", diff --git a/docs/src/content/en/models/providers/neuralwatt.mdx b/docs/src/content/en/models/providers/neuralwatt.mdx index ff2abeebe9ff..31a57e2c8d14 100644 --- a/docs/src/content/en/models/providers/neuralwatt.mdx +++ b/docs/src/content/en/models/providers/neuralwatt.mdx @@ -1,13 +1,13 @@ --- title: "Neuralwatt | Models" -description: "Use Neuralwatt models with Mastra. 14 models available." +description: "Use Neuralwatt models with Mastra. 18 models available." --- {/* This file is auto-generated by generate-model-docs.ts - DO NOT EDIT MANUALLY */} # Neuralwatt logoNeuralwatt -Access 14 Neuralwatt models through Mastra's model router. Authentication is handled automatically using the `NEURALWATT_API_KEY` environment variable. +Access 18 Neuralwatt models through Mastra's model router. Authentication is handled automatically using the `NEURALWATT_API_KEY` environment variable. Learn more in the [Neuralwatt documentation](https://portal.neuralwatt.com/docs). @@ -46,31 +46,31 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp @@ -244,7 +292,7 @@ const agent = new Agent({ model: ({ requestContext }) => { const useAdvanced = requestContext.task === "complex"; return useAdvanced - ? "neuralwatt/zai-org/GLM-5.1-FP8" + ? "neuralwatt/qwen3.6-35b-fast" : "neuralwatt/Qwen/Qwen3.5-397B-A17B-FP8"; } }); diff --git a/docs/src/content/en/models/providers/novita-ai.mdx b/docs/src/content/en/models/providers/novita-ai.mdx index 07c95d9ac6b7..a53e577a101e 100644 --- a/docs/src/content/en/models/providers/novita-ai.mdx +++ b/docs/src/content/en/models/providers/novita-ai.mdx @@ -402,8 +402,8 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "reasoning": false, "contextWindow": 262144, "maxOutput": 32768, - "inputCost": null, - "outputCost": null + "inputCost": 0.3, + "outputCost": 2.5 }, { "model": "novita-ai/inclusionai/ling-2.6-flash", diff --git a/docs/src/content/en/models/providers/opencode-go.mdx b/docs/src/content/en/models/providers/opencode-go.mdx index f5f219120f37..6603c466fba1 100644 --- a/docs/src/content/en/models/providers/opencode-go.mdx +++ b/docs/src/content/en/models/providers/opencode-go.mdx @@ -162,8 +162,8 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "reasoning": true, "contextWindow": 1000000, "maxOutput": 131072, - "inputCost": 0.1, - "outputCost": 0.4 + "inputCost": 0.3, + "outputCost": 1.2 }, { "model": "opencode-go/qwen3.6-plus", diff --git a/docs/src/content/en/models/providers/sakana.mdx b/docs/src/content/en/models/providers/sakana.mdx new file mode 100644 index 000000000000..f39688469e8b --- /dev/null +++ b/docs/src/content/en/models/providers/sakana.mdx @@ -0,0 +1,121 @@ +--- +title: "Sakana AI | Models" +description: "Use Sakana AI models with Mastra. 3 models available." +--- + +{/* This file is auto-generated by generate-model-docs.ts - DO NOT EDIT MANUALLY */} + +# Sakana AI logoSakana AI + +Access 3 Sakana AI models through Mastra's model router. Authentication is handled automatically using the `SAKANA_API_KEY` environment variable. + +Learn more in the [Sakana AI documentation](https://console.sakana.ai/models). + +```bash title=".env" +SAKANA_API_KEY=your-api-key +``` + +```typescript title="src/mastra/agents/my-agent.ts" {7} +import { Agent } from "@mastra/core/agent"; + +const agent = new Agent({ + id: "my-agent", + name: "My Agent", + instructions: "You are a helpful assistant", + model: "sakana/fugu" +}); + +// Generate a response +const response = await agent.generate("Hello!"); + +// Stream a response +const stream = await agent.stream("Tell me a story"); +for await (const chunk of stream) { + console.log(chunk); +} +``` + +:::info + +Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-specific features may not be available. Check the [Sakana AI documentation](https://console.sakana.ai/models) for details. + +::: + +## Models + + + +## Advanced configuration + +### Custom headers + +```typescript title="src/mastra/agents/my-agent.ts" +const agent = new Agent({ + id: "custom-agent", + name: "custom-agent", + model: { + url: "https://api.sakana.ai/v1", + id: "sakana/fugu", + apiKey: process.env.SAKANA_API_KEY, + headers: { + "X-Custom-Header": "value" + } + } +}); +``` + +### Dynamic model selection + +```typescript title="src/mastra/agents/my-agent.ts" +const agent = new Agent({ + id: "dynamic-agent", + name: "Dynamic Agent", + model: ({ requestContext }) => { + const useAdvanced = requestContext.task === "complex"; + return useAdvanced + ? "sakana/fugu-ultra-20260615" + : "sakana/fugu"; + } +}); +``` + + diff --git a/docs/src/content/en/models/providers/stepfun.mdx b/docs/src/content/en/models/providers/stepfun.mdx index 80f1d8137161..04528dbc090b 100644 --- a/docs/src/content/en/models/providers/stepfun.mdx +++ b/docs/src/content/en/models/providers/stepfun.mdx @@ -102,8 +102,8 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "reasoning": true, "contextWindow": 256000, "maxOutput": 256000, - "inputCost": 0.19, - "outputCost": 1.13 + "inputCost": 0.2, + "outputCost": 1.15 } ]} /> diff --git a/docs/src/content/en/models/providers/subconscious.mdx b/docs/src/content/en/models/providers/subconscious.mdx new file mode 100644 index 000000000000..a7dddf77ec54 --- /dev/null +++ b/docs/src/content/en/models/providers/subconscious.mdx @@ -0,0 +1,97 @@ +--- +title: "Subconscious | Models" +description: "Use Subconscious models with Mastra. 1 model available." +--- + +{/* This file is auto-generated by generate-model-docs.ts - DO NOT EDIT MANUALLY */} + +# Subconscious logoSubconscious + +Access 1 Subconscious model through Mastra's model router. Authentication is handled automatically using the `SUBCONSCIOUS_API_KEY` environment variable. + +Learn more in the [Subconscious documentation](https://docs.subconscious.dev). + +```bash title=".env" +SUBCONSCIOUS_API_KEY=your-api-key +``` + +```typescript title="src/mastra/agents/my-agent.ts" {7} +import { Agent } from "@mastra/core/agent"; + +const agent = new Agent({ + id: "my-agent", + name: "My Agent", + instructions: "You are a helpful assistant", + model: "subconscious/subconscious/tim-qwen3.6-27b" +}); + +// Generate a response +const response = await agent.generate("Hello!"); + +// Stream a response +const stream = await agent.stream("Tell me a story"); +for await (const chunk of stream) { + console.log(chunk); +} +``` + +:::info + +Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-specific features may not be available. Check the [Subconscious documentation](https://docs.subconscious.dev) for details. + +::: + +## Models + + + +## Advanced configuration + +### Custom headers + +```typescript title="src/mastra/agents/my-agent.ts" +const agent = new Agent({ + id: "custom-agent", + name: "custom-agent", + model: { + url: "https://api.subconscious.dev/v1", + id: "subconscious/subconscious/tim-qwen3.6-27b", + apiKey: process.env.SUBCONSCIOUS_API_KEY, + headers: { + "X-Custom-Header": "value" + } + } +}); +``` + +### Dynamic model selection + +```typescript title="src/mastra/agents/my-agent.ts" +const agent = new Agent({ + id: "dynamic-agent", + name: "Dynamic Agent", + model: ({ requestContext }) => { + const useAdvanced = requestContext.task === "complex"; + return useAdvanced + ? "subconscious/subconscious/tim-qwen3.6-27b" + : "subconscious/subconscious/tim-qwen3.6-27b"; + } +}); +``` + + diff --git a/docs/src/content/en/models/providers/synthetic.mdx b/docs/src/content/en/models/providers/synthetic.mdx index 6be5a4defab2..3cd6f643b437 100644 --- a/docs/src/content/en/models/providers/synthetic.mdx +++ b/docs/src/content/en/models/providers/synthetic.mdx @@ -1,13 +1,13 @@ --- title: "Synthetic | Models" -description: "Use Synthetic models with Mastra. 8 models available." +description: "Use Synthetic models with Mastra. 10 models available." --- {/* This file is auto-generated by generate-model-docs.ts - DO NOT EDIT MANUALLY */} # Synthetic logoSynthetic -Access 8 Synthetic models through Mastra's model router. Authentication is handled automatically using the `SYNTHETIC_API_KEY` environment variable. +Access 10 Synthetic models through Mastra's model router. Authentication is handled automatically using the `SYNTHETIC_API_KEY` environment variable. Learn more in the [Synthetic documentation](https://synthetic.new/pricing). @@ -88,7 +88,7 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "videoInput": false, "toolUsage": true, "reasoning": true, - "contextWindow": 128000, + "contextWindow": 131072, "maxOutput": 32768, "inputCost": 0.1, "outputCost": 0.1 @@ -103,7 +103,19 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "contextWindow": 262144, "maxOutput": 65536, "inputCost": 0.6, - "outputCost": 3 + "outputCost": 3.6 + }, + { + "model": "synthetic/hf:Qwen/Qwen3.6-27B", + "imageInput": true, + "audioInput": false, + "videoInput": false, + "toolUsage": true, + "reasoning": true, + "contextWindow": 262144, + "maxOutput": 65536, + "inputCost": 0.45, + "outputCost": 3.6 }, { "model": "synthetic/hf:zai-org/GLM-4.7", @@ -112,9 +124,9 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "videoInput": false, "toolUsage": true, "reasoning": true, - "contextWindow": 200000, - "maxOutput": 64000, - "inputCost": 0.55, + "contextWindow": 202752, + "maxOutput": 65536, + "inputCost": 0.45, "outputCost": 2.19 }, { @@ -126,8 +138,8 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "reasoning": true, "contextWindow": 196608, "maxOutput": 65536, - "inputCost": 0.06, - "outputCost": 0.4 + "inputCost": 0.1, + "outputCost": 0.5 }, { "model": "synthetic/hf:zai-org/GLM-5.1", @@ -140,6 +152,18 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "maxOutput": 65536, "inputCost": 1, "outputCost": 3 + }, + { + "model": "synthetic/hf:zai-org/GLM-5.2", + "imageInput": false, + "audioInput": false, + "videoInput": false, + "toolUsage": true, + "reasoning": true, + "contextWindow": 524288, + "maxOutput": 65536, + "inputCost": 1.4, + "outputCost": 4.4 } ]} /> @@ -172,7 +196,7 @@ const agent = new Agent({ model: ({ requestContext }) => { const useAdvanced = requestContext.task === "complex"; return useAdvanced - ? "synthetic/hf:zai-org/GLM-5.1" + ? "synthetic/hf:zai-org/GLM-5.2" : "synthetic/hf:MiniMaxAI/MiniMax-M3"; } }); diff --git a/docs/src/content/en/models/providers/xiaomi.mdx b/docs/src/content/en/models/providers/xiaomi.mdx index 489a80404aea..526273435917 100644 --- a/docs/src/content/en/models/providers/xiaomi.mdx +++ b/docs/src/content/en/models/providers/xiaomi.mdx @@ -54,8 +54,8 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "reasoning": true, "contextWindow": 1048576, "maxOutput": 131072, - "inputCost": 0.4, - "outputCost": 2 + "inputCost": 0.14, + "outputCost": 0.28 }, { "model": "xiaomi/mimo-v2.5-pro", @@ -66,8 +66,8 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp "reasoning": true, "contextWindow": 1048576, "maxOutput": 131072, - "inputCost": 1, - "outputCost": 3 + "inputCost": 0.435, + "outputCost": 0.87 }, { "model": "xiaomi/mimo-v2.5-pro-ultraspeed", diff --git a/docs/src/content/en/models/providers/zeldoc.mdx b/docs/src/content/en/models/providers/zeldoc.mdx index fff79fd3aed0..15a6c2db8ea8 100644 --- a/docs/src/content/en/models/providers/zeldoc.mdx +++ b/docs/src/content/en/models/providers/zeldoc.mdx @@ -47,13 +47,13 @@ Mastra uses the OpenAI-compatible `/chat/completions` endpoint. Some provider-sp models={[ { "model": "zeldoc/z-code", - "imageInput": true, + "imageInput": false, "audioInput": false, - "videoInput": true, + "videoInput": false, "toolUsage": true, "reasoning": true, - "contextWindow": 262144, - "maxOutput": 262144, + "contextWindow": 1000000, + "maxOutput": 131072, "inputCost": null, "outputCost": null } diff --git a/docs/src/content/en/models/sidebars.js b/docs/src/content/en/models/sidebars.js index 94e8d41137a6..18193456f22a 100644 --- a/docs/src/content/en/models/sidebars.js +++ b/docs/src/content/en/models/sidebars.js @@ -576,6 +576,11 @@ const sidebars = { id: 'providers/routing-run', label: 'routing.run', }, + { + type: 'doc', + id: 'providers/sakana', + label: 'Sakana AI', + }, { type: 'doc', id: 'providers/sarvam', @@ -616,6 +621,11 @@ const sidebars = { id: 'providers/stepfun-ai', label: 'StepFun AI', }, + { + type: 'doc', + id: 'providers/subconscious', + label: 'Subconscious', + }, { type: 'doc', id: 'providers/submodel', diff --git a/docs/src/content/en/reference/agent-controller/agent-controller-class.mdx b/docs/src/content/en/reference/agent-controller/agent-controller-class.mdx index fba1c867c784..9a8788755415 100644 --- a/docs/src/content/en/reference/agent-controller/agent-controller-class.mdx +++ b/docs/src/content/en/reference/agent-controller/agent-controller-class.mdx @@ -331,8 +331,8 @@ await agentController.sendMessage({ content: 'Hello!' }) isOptional: true, }, { - name: 'heartbeatHandlers', - type: 'HeartbeatHandler[]', + name: 'intervalHandlers', + type: 'IntervalHandler[]', description: 'Periodic background tasks started during `init()`. Use for gateway sync, cache refresh, and similar tasks.', isOptional: true, @@ -432,7 +432,7 @@ await agentController.sendMessage({ content: 'Hello!' }) #### `init()` -Initialize the agentController. Loads storage, initializes a static workspace (dynamic factory workspaces are resolved per-session during `createSession`), propagates memory and workspace to mode agents, and starts heartbeat handlers. Call this before using the agentController. +Initialize the agentController. Loads storage, initializes a static workspace (dynamic factory workspaces are resolved per-session during `createSession`), propagates memory and workspace to mode agents, and starts interval handlers. Call this before using the agentController. ```typescript await agentController.init() @@ -490,26 +490,26 @@ const thread = await agentController.selectOrCreateThread() #### `destroy()` -Stop all heartbeat handlers and clean up resources. +Stop all interval handlers and clean up resources. ```typescript await agentController.destroy() ``` -#### `removeHeartbeat({ id })` +#### `removeInterval({ id })` -Remove a specific heartbeat handler by ID. Calls the handler's `shutdown()` callback if defined. +Remove a specific interval handler by ID. Calls the handler's `shutdown()` callback if defined. ```typescript -await agentController.removeHeartbeat({ id: 'gateway-sync' }) +await agentController.removeInterval({ id: 'gateway-sync' }) ``` -#### `stopHeartbeats()` +#### `stopIntervals()` -Stop and remove all heartbeat handlers. +Stop and remove all interval handlers. ```typescript -await agentController.stopHeartbeats() +await agentController.stopIntervals() ``` #### `getCurrentAgent()` diff --git a/docs/src/content/en/reference/agents/listSuspendedRuns.mdx b/docs/src/content/en/reference/agents/listSuspendedRuns.mdx new file mode 100644 index 000000000000..45bf8c6b7a6b --- /dev/null +++ b/docs/src/content/en/reference/agents/listSuspendedRuns.mdx @@ -0,0 +1,148 @@ +--- +title: "Reference: Agent.listSuspendedRuns() | Agents" +description: "API reference for Agent.listSuspendedRuns(), which discovers suspended agent runs from storage so approvals survive restarts." +packages: + - "@mastra/core" +--- + +# Agent.listSuspendedRuns() + +**Added in:** `@mastra/core@1.43.0` + +The `.listSuspendedRuns()` method lists suspended agent runs from workflow snapshot storage: runs waiting on a tool call requiring [approval](/docs/agents/agent-approval), or on a tool that called `suspend()`. Because discovery is backed by storage rather than in-memory state, it works after a server restart and across multiple server instances. + +Pass the returned `runId` to [`resumeStream()`](/docs/agents/agent-approval#runtime-suspension-with-suspend), `approveToolCall()`, or `declineToolCall()` to continue the run. + +The filter contract mirrors the workflow run listing APIs (`listWorkflowRuns`), plus the agent-level `threadId` filter. + +## Usage example + +Discover the pending run for a conversation and continue it. Check `requiresApproval` to pick the right continuation — `approveToolCall()` / `declineToolCall()` for approval suspensions, `resumeStream()` with resume data for `suspend()`-based suspensions: + +```typescript +const { runs } = await agent.listSuspendedRuns({ + threadId: 'thread-123', + resourceId: 'user-456', +}) + +const run = runs[0] +const toolCall = run?.toolCalls[0] + +if (run && toolCall) { + const stream = toolCall.requiresApproval + ? await agent.approveToolCall({ runId: run.runId, toolCallId: toolCall.toolCallId }) + : await agent.resumeStream({ name: 'San Francisco' }, { runId: run.runId }) +} +``` + +## Parameters + + + +## Returns + +', + description: 'A promise that resolves to the matching runs and the total count before pagination.', + }, +]} +/> + +```typescript +interface AgentListSuspendedRunsResult { + runs: AgentRun[] + /** Total number of matching runs, before pagination */ + total: number +} + +interface AgentRun { + /** Run ID accepted by resumeStream(), approveToolCall(), and declineToolCall() */ + runId: string + status: 'suspended' + threadId?: string + resourceId?: string + /** When the run suspended */ + suspendedAt: Date + /** Suspended tool calls awaiting approval or resume data */ + toolCalls: AgentRunToolCall[] +} + +interface AgentRunToolCall { + toolCallId?: string + toolName?: string + /** Arguments the model supplied (approval suspensions only) */ + args?: unknown + /** True when the run is waiting on a tool-call approval */ + requiresApproval: boolean + /** The tool-defined suspend payload when the tool called suspend() */ + suspendPayload?: unknown +} +``` + +## Discovery scope + +Results are scoped to runs started by the agent you call `listSuspendedRuns()` on: snapshots persist the owning agent's id, so runs started by other agents on the same Mastra instance are not returned. In [supervisor setups](/docs/agents/agent-approval#tool-approval-supervisor-agents) the supervisor sees its outer run — the one to resume — while a subagent's inner run is only visible from the subagent itself. Filter by `threadId` and `resourceId` to scope results to one conversation. + +Run snapshots are only persisted while a run is waiting on input and are deleted when it finishes, so suspended runs are the only runs discoverable from storage. Suspended runs only survive restarts when the Mastra instance has a persistent [storage provider](/docs/memory/storage) configured. With the default in-memory store, snapshots are lost on restart. + +## Related + +- [Agent approval](/docs/agents/agent-approval) +- [Storage](/docs/memory/storage) diff --git a/docs/src/content/en/reference/client-js/agents.mdx b/docs/src/content/en/reference/client-js/agents.mdx index 2ee64c887696..04541d18d887 100644 --- a/docs/src/content/en/reference/client-js/agents.mdx +++ b/docs/src/content/en/reference/client-js/agents.mdx @@ -429,6 +429,27 @@ response.processDataStream({ }) ``` +### `listSuspendedRuns()` + +List suspended runs for the agent from storage — runs waiting on a tool-call approval or on a tool that suspended. Discovery is backed by storage, so it works after a server restart and across server instances. Pass the returned `runId` to `approveToolCall()`, `declineToolCall()`, or `resumeStream()`. + +```typescript +const { runs, total } = await agent.listSuspendedRuns({ + threadId: 'thread-456', + resourceId: 'user-123', +}) + +if (runs[0]) { + console.log(runs[0].toolCalls) // [{ toolCallId, toolName, args, requiresApproval }] + await agent.approveToolCall({ + runId: runs[0].runId, + toolCallId: runs[0].toolCalls[0].toolCallId, + }) +} +``` + +Accepts optional filters (`threadId`, `resourceId`, `fromDate`, `toDate`) and pagination (`perPage`, `page`). Returns `{ runs, total }`, where `total` is the number of matching runs before pagination. See [`Agent.listSuspendedRuns()`](/reference/agents/listSuspendedRuns) for details on the returned run shape. + ### `approveToolCall()` Approve a pending tool call and return a continuation stream. Use this when you are rendering the resumed chunks from the approval response. diff --git a/docs/src/content/en/reference/coding-agent/build-base-prompt.mdx b/docs/src/content/en/reference/coding-agent/build-base-prompt.mdx new file mode 100644 index 000000000000..60db75739e27 --- /dev/null +++ b/docs/src/content/en/reference/coding-agent/build-base-prompt.mdx @@ -0,0 +1,144 @@ +--- +title: "Reference: buildBasePrompt() | Coding Agent" +description: "API reference for buildBasePrompt(), which builds the shared base system prompt for a coding agent from a PromptContext." +packages: + - "@mastra/core" +--- + +# buildBasePrompt() + +`buildBasePrompt()` builds the shared base system prompt for a coding agent — the behavioral instructions that make the agent a good coding assistant. It takes a `PromptContext` describing the current environment (project, platform, git branch, mode, model) and returns the prompt as a string. + +Product-specific strings are parameterized through `productName`, `coAuthorName`, and `coAuthorEmail`, so you can rebrand the prompt and commit trailer without forking it. They default to `"Mastra Code"` / `"noreply@mastra.ai"`, so existing callers keep identical output. + +Use this with [`createCodingAgent()`](/reference/coding-agent/create-coding-agent) when you build dynamic instructions for the agent. + +## Usage example + +Build the base prompt from a `PromptContext`: + +```typescript title="src/mastra/prompt.ts" +import { buildBasePrompt } from '@mastra/core/coding-agent' + +const prompt = buildBasePrompt({ + projectPath: process.cwd(), + projectName: 'my-app', + gitBranch: 'main', + platform: process.platform, + date: new Date().toDateString(), + mode: 'build', + modelId: '__GATEWAY_OPENAI_MODEL_BASE__', + toolGuidance: '', +}) +``` + +To rebrand the prompt, pass the product and co-author fields: + +```typescript +const prompt = buildBasePrompt({ + // ...environment fields + productName: 'Acme Coder', + coAuthorName: 'Acme Bot', + coAuthorEmail: 'bot@acme.dev', +}) +``` + +## Parameters + +`buildBasePrompt()` takes a single `PromptContext` object. + + + +## Returns + + + +## Related + +- [`createCodingAgent()`](/reference/coding-agent/create-coding-agent) diff --git a/docs/src/content/en/reference/coding-agent/create-coding-agent.mdx b/docs/src/content/en/reference/coding-agent/create-coding-agent.mdx new file mode 100644 index 000000000000..07c80668a86c --- /dev/null +++ b/docs/src/content/en/reference/coding-agent/create-coding-agent.mdx @@ -0,0 +1,151 @@ +--- +title: "Reference: createCodingAgent() | Coding Agent" +description: "API reference for createCodingAgent(), a factory that builds a coding agent with portable defaults for workspace, task signal, error retries, and goal judge." +packages: + - "@mastra/core" +--- + +# createCodingAgent() + +`createCodingAgent()` builds a coding [`Agent`](/reference/agents/agent) with portable defaults for the pieces a coding agent always needs: a local workspace, the task-list signal provider, network-retry error processors, and the goal judge prompt. Supply only `model`, `instructions`, and `tools` to get a working agent, or override any default. + +The returned value is a standard `Agent`, so it works anywhere an `Agent` does — including as the agent passed to an [`AgentController`](/reference/agent-controller/agent-controller-class). + +## Usage example + +Pass a model, instructions, and tools. The factory fills in the workspace, task signal, error processors, and goal prompt: + +```typescript title="src/mastra/coding-agent.ts" +import { createCodingAgent } from '@mastra/core/coding-agent' + +const agent = createCodingAgent({ + id: 'my-coding-agent', + name: 'My Coding Agent', + model: '__GATEWAY_OPENAI_MODEL_BASE__', + instructions: 'You are a helpful coding assistant.', + tools: {}, +}) +``` + +## Parameters + +`createCodingAgent()` accepts every field of [`AgentConfig`](/reference/agents/agent#constructor-parameters) plus the fields below. Fields you provide always take precedence over the factory defaults. + +', + description: 'The language model the agent uses. Passed straight through to Agent.', + }, + { + name: 'instructions', + type: 'string | DynamicArgument', + description: 'System instructions for the agent. Passed straight through to Agent.', + }, + { + name: 'tools', + type: 'ToolsInput | DynamicArgument', + description: 'Tools available to the agent. Passed straight through to Agent.', + isOptional: true, + }, + { + name: 'workspace', + type: 'AnyWorkspace | undefined', + description: + 'The workspace backing the agent. When the key is omitted, a default local workspace is built. When set explicitly to undefined, the factory builds no default — opt out when the workspace is wired elsewhere (for example at the AgentController level).', + isOptional: true, + }, + { + name: 'basePath', + type: 'string', + description: 'Base path for the default workspace built when workspace is omitted.', + isOptional: true, + defaultValue: 'process.cwd()', + }, + { + name: 'signals', + type: 'SignalProvider[]', + description: 'Signal providers for the agent. When omitted, defaults to a single TaskSignalProvider.', + isOptional: true, + }, + { + name: 'errorProcessors', + type: 'Processor[]', + description: + 'Error processors for the agent. When omitted, defaults to the ECONNRESET/bad-request retry stack plus PrefillErrorHandler and ProviderHistoryCompat.', + isOptional: true, + }, + { + name: 'goal', + type: 'AgentGoalConfig', + description: + 'Goal configuration. When provided without a prompt, the prompt defaults to DEFAULT_GOAL_JUDGE_PROMPT.', + isOptional: true, + }, +]} +/> + +## Returns + + + +## Defaults + +The factory only fills a default when you do not provide the corresponding field. Caller-provided values always win. + +| Field | Default when omitted | +| - | - | +| `workspace` | A [`Workspace`](/reference/workspace/workspace-class) backed by `LocalFilesystem` and `LocalSandbox` rooted at the base path. | +| `signals` | A single [`TaskSignalProvider`](/reference/signals/task-signal-provider). | +| `errorProcessors` | ECONNRESET and bad-request retry processors plus `PrefillErrorHandler` and `ProviderHistoryCompat`. | +| `goal.prompt` | `DEFAULT_GOAL_JUDGE_PROMPT` (only when a `goal` is configured). | + +### Workspace + +When the `workspace` key is omitted, the factory builds a local workspace rooted at `basePath` (default `process.cwd()`): + +```typescript +import { Workspace, LocalFilesystem, LocalSandbox } from '@mastra/core/workspace' + +new Workspace({ + filesystem: new LocalFilesystem({ basePath }), + sandbox: new LocalSandbox({ workingDirectory: basePath }), +}) +``` + +To opt out — for example when the workspace is injected at the [`AgentController`](/reference/agent-controller/agent-controller-class) level — pass `workspace: undefined` explicitly: + +```typescript +const agent = createCodingAgent({ + id: 'my-coding-agent', + name: 'My Coding Agent', + model: '__GATEWAY_OPENAI_MODEL_BASE__', + instructions: 'You are a helpful coding assistant.', + tools: {}, + workspace: undefined, // opt out of the default workspace +}) +``` + +### Error processors + +The default error processors apply a retry policy for transient failures: + +- Network resets (`ECONNRESET` / `socket hang up`) retry up to twice with exponential backoff (`1000ms * 2^retryCount`, capped at `30000ms`). +- Bad-request errors retry once after `2000ms`. + +`PrefillErrorHandler` and `ProviderHistoryCompat` are also included for provider compatibility. + +## Related + +- [`buildBasePrompt()`](/reference/coding-agent/build-base-prompt) +- [`Agent`](/reference/agents/agent) +- [`AgentController`](/reference/agent-controller/agent-controller-class) diff --git a/docs/src/content/en/reference/memory/observational-memory.mdx b/docs/src/content/en/reference/memory/observational-memory.mdx index c447456d838d..73dd178392e7 100644 --- a/docs/src/content/en/reference/memory/observational-memory.mdx +++ b/docs/src/content/en/reference/memory/observational-memory.mdx @@ -134,6 +134,13 @@ OM performs thresholding with fast local token estimation. Text uses `tokenx`, a isOptional: true, defaultValue: 'false', }, + { + name: 'extract', + type: 'Extractor[]', + description: + 'Custom values to extract after observation. Schema-less extractors are requested inline in the Observer output. Schema-backed extractors run as a follow-up structured output call and are stored in thread OM metadata.', + isOptional: true, + }, { name: 'observeAttachments', type: 'boolean | string[]', @@ -267,6 +274,13 @@ OM performs thresholding with fast local token estimation. Text uses `tokenx`, a "Custom instruction appended to the Reflector's system prompt. Use this to customize how the Reflector consolidates observations, such as prioritizing certain types of information.", isOptional: true, }, + { + name: 'extract', + type: 'Extractor[]', + description: + 'Custom values to extract after reflection. Schema-less extractors are requested inline in the Reflector output. Schema-backed extractors run as a follow-up structured output call and are stored in thread OM metadata.', + isOptional: true, + }, { name: 'observationTokens', type: 'number', @@ -351,8 +365,119 @@ OM persists token payload estimates so repeated counting can reuse prior token e - Per-message and per-conversation overhead are always recomputed at runtime and aren't cached. - `data-*` and `reasoning` parts are skipped and don't receive cache entries. +## Extractor API + +`Extractor` defines a value that OM should extract during observation or reflection. Built-in OM values such as `current-task`, `suggested-response`, and `thread-title` use the same extractor pipeline as custom values. + +```typescript title="src/mastra/agents/agent.ts" +import { Memory, Extractor } from '@mastra/memory' +import { z } from 'zod' + +const memory = new Memory({ + options: { + observationalMemory: { + model: '__GATEWAY_OPENAI_MODEL_MINI__', + observation: { + extract: [ + new Extractor({ + name: 'User profile', + instructions: 'Extract stable user profile facts that should be remembered.', + schema: z.object({ + name: z.string().optional(), + timezone: z.string().optional(), + }), + }), + ], + }, + }, + }, +}) +``` + + string', + description: + 'Instructions for what to extract and when to update the value. Use a function to derive instructions from runtime context.', + }, + { + name: 'schema', + type: 'ZodType | (context) => ZodType | undefined', + description: + 'Optional Zod schema for structured extraction. When provided, OM runs a follow-up structured output call after the main OM operation. When omitted, the extractor is an inline string extractor emitted in the Observer or Reflector response. Use a function to derive the schema from runtime context.', + isOptional: true, + }, + { + name: 'includePreviousExtraction', + type: 'boolean', + description: + 'Controls whether the previous extraction is shown to the extractor on future OM runs. Set to `false` for values that should only come from the current OM run.', + isOptional: true, + defaultValue: 'true', + }, + { + name: 'onExtracted', + type: '(context) => T | void | Promise', + description: + 'Optional hook called after a custom extractor returns a value and before metadata is persisted. Returning a value replaces the extracted value. Throwing records an extraction failure.', + isOptional: true, + }, +]} +/> + +### Extraction behavior + +- Extracted values are stored in thread OM metadata under `om.extracted`. +- Built-in extractor values are also mirrored to the compatibility metadata fields `currentTask`, `suggestedResponse`, and `threadTitle`. +- `thread-title` updates the thread title only when `observation.threadTitle` is enabled. +- `observation.extract` runs during observation. `reflection.extract` runs during reflection. +- Schema-backed extractors add a follow-up structured output request. +- Schema-less extractors are inline string extractors emitted directly in the Observer or Reflector output. +- Dynamic extractor functions receive runtime context, including `source`, `threadId`, `resourceId`, `mainAgent`, `memory`, and `requestContext` when available. +- `WorkingMemoryExtractor` uses the normal extractor pipeline to update working memory through the active `Memory` instance. It uses structured extraction when working memory has a JSON schema and skips OM metadata persistence, so the working memory payload isn't duplicated under OM extracted metadata. +- `observationalMemory.observation.manageWorkingMemory` adds `WorkingMemoryExtractor`, defaults `workingMemory.agentManaged` to `false`, and defaults `workingMemory.useStateSignals` to `true` when working memory is enabled. +- Extraction failures are reported in OM marker data and do not discard other successful extracted values. + ## Examples +### Working memory updates + +Use `observationalMemory.observation.manageWorkingMemory` when OM should update working memory. + +```typescript title="src/mastra/agents/agent.ts" +import { Memory } from '@mastra/memory' + +const memory = new Memory({ + options: { + workingMemory: { + enabled: true, + }, + observationalMemory: { + enabled: true, + observation: { + manageWorkingMemory: true, + }, + }, + }, +}) +``` + +Set `workingMemory.agentManaged: true` if the main agent should still receive working memory tool and instruction injection. + ### Resource scope with custom thresholds (experimental) ```typescript title="src/mastra/agents/agent.ts" @@ -688,6 +813,18 @@ Emitted when observation or reflection completes successfully. description: 'Suggested response extracted by the Observer.', isOptional: true, }, + { + name: 'extractedValues', + type: 'Record', + description: 'Values extracted during this OM operation, keyed by extractor slug.', + isOptional: true, + }, + { + name: 'extractionFailures', + type: 'Array<{ slug: string; error: string }>', + description: 'Extractor failures from this OM operation. Successful extractor values are still included.', + isOptional: true, + }, { name: 'recordId', type: 'string', description: 'The OM record ID.' }, { name: 'threadId', type: 'string', description: "This thread's ID." }, ]} @@ -745,6 +882,18 @@ Emitted when async buffering completes. The content is stored but not yet activa description: 'Observation tokens (output) after the Observer compressed them.', }, { name: 'observations', type: 'string', description: 'The buffered content.', isOptional: true }, + { + name: 'extractedValues', + type: 'Record', + description: 'Values extracted during this buffered OM operation, keyed by extractor slug.', + isOptional: true, + }, + { + name: 'extractionFailures', + type: 'Array<{ slug: string; error: string }>', + description: 'Extractor failures from this buffered OM operation. Successful extractor values are still included.', + isOptional: true, + }, { name: 'recordId', type: 'string', description: 'The OM record ID.' }, { name: 'threadId', type: 'string', description: "This thread's ID." }, ]} diff --git a/docs/src/content/en/reference/observability/metrics/automatic-metrics.mdx b/docs/src/content/en/reference/observability/metrics/automatic-metrics.mdx index d98f8db169cf..56f56030dad2 100644 --- a/docs/src/content/en/reference/observability/metrics/automatic-metrics.mdx +++ b/docs/src/content/en/reference/observability/metrics/automatic-metrics.mdx @@ -20,7 +20,7 @@ Metrics are extracted from spans when they end. The observability layer inspects Two conditions must be true for a metric to reach storage: 1. `MastraStorageExporter` is configured as an exporter. -1. The storage backend supports metrics (ClickHouse or DuckDB). +1. The storage backend supports metrics (ClickHouse, DuckDB, or Postgres v-next with the observability domain enabled). If metrics aren't available, see [troubleshooting](#troubleshooting). @@ -123,7 +123,7 @@ When you spot a spike in latency or token usage on the Metrics dashboard, correl - **Observability is configured**: Verify that your `Mastra` instance has an `observability` config with at least one exporter. - **`MastraStorageExporter` or `MastraPlatformExporter` is present**: Other exporters (Datadog, Langfuse, etc.) don't surface metrics in Mastra. `MastraStorageExporter` is required for the local Studio dashboard, and `MastraPlatformExporter` is required to view metrics in Mastra platform. -- **Storage supports metrics**: Metrics require an OLAP-capable store (ClickHouse or DuckDB). Row-oriented databases (PostgreSQL, LibSQL, MSSQL) and document stores (MongoDB) aren't supported for metrics. +- **Storage supports metrics**: Metrics require an analytics-capable store (ClickHouse, DuckDB, or Postgres v-next with the observability domain enabled). Other row-oriented databases (LibSQL, MSSQL) and document stores (MongoDB) aren't supported for metrics. - **Sampling isn't 0%**: If sampling probability is `0` or strategy is `never`, all spans become no-ops and no metrics are extracted. ### Duration metrics are missing diff --git a/docs/src/content/en/reference/sidebars.js b/docs/src/content/en/reference/sidebars.js index 67806a9ae403..37f524e789ac 100644 --- a/docs/src/content/en/reference/sidebars.js +++ b/docs/src/content/en/reference/sidebars.js @@ -72,6 +72,7 @@ const sidebars = { { type: 'doc', id: 'agents/listAgents', label: '.listAgents()' }, { type: 'doc', id: 'agents/listScorers', label: '.listScorers()' }, { type: 'doc', id: 'agents/listSkills', label: '.listSkills()' }, + { type: 'doc', id: 'agents/listSuspendedRuns', label: '.listSuspendedRuns()' }, { type: 'doc', id: 'agents/listTools', label: '.listTools()' }, { type: 'doc', id: 'agents/listWorkflows', label: '.listWorkflows()' }, { type: 'doc', id: 'agents/network', label: '.network()' }, @@ -165,6 +166,15 @@ const sidebars = { { type: 'doc', id: 'client-js/workflows', label: 'Workflows API' }, ], }, + { + type: 'category', + label: 'Coding Agent', + collapsed: true, + items: [ + { type: 'doc', id: 'coding-agent/build-base-prompt', label: 'buildBasePrompt()' }, + { type: 'doc', id: 'coding-agent/create-coding-agent', label: 'createCodingAgent()' }, + ], + }, { type: 'category', label: 'Core', diff --git a/docs/src/content/en/reference/workspace/mesa-filesystem.mdx b/docs/src/content/en/reference/workspace/mesa-filesystem.mdx new file mode 100644 index 000000000000..98af0406aee0 --- /dev/null +++ b/docs/src/content/en/reference/workspace/mesa-filesystem.mdx @@ -0,0 +1,367 @@ +--- +title: "Reference: MesaFilesystem | Workspace" +description: "API reference for the MesaFilesystem provider for versioned Mesa repos." +packages: + - "@mastra/mesa" +--- + +# MesaFilesystem + +Stores workspace files in [Mesa](https://docs.mesa.dev/content/getting-started/introduction) repos through the standard Mastra `WorkspaceFilesystem` interface. + +Use `MesaFilesystem` when agents need versioned file storage. For a local directory, use [`LocalFilesystem`](/reference/workspace/local-filesystem). For object storage, use [`S3Filesystem`](/reference/workspace/s3-filesystem), [`GCSFilesystem`](/reference/workspace/gcs-filesystem), or [`AzureBlobFilesystem`](/reference/workspace/azure-blob-filesystem). + +:::info + +`MesaFilesystem` runs in the Mastra process. + +[Mesa's POSIX mount](https://docs.mesa.dev/content/mesafs/posix-mount) (for using a Mesa filesystem within a sandbox) is not yet part of the `@mastra/mesa` package. Support is coming soon. + +::: + +## Installation + +```bash npm2yarn +npm install @mastra/mesa +``` + +## Usage example + +Mount one Mesa repo and pass the filesystem to a workspace: + +```typescript title="src/mastra/workspace.ts" +import { Agent } from '@mastra/core/agent' +import { Workspace } from '@mastra/core/workspace' +import { MesaFilesystem } from '@mastra/mesa' + +const workspace = new Workspace({ + filesystem: new MesaFilesystem({ + apiKey: process.env.MESA_API_KEY, + org: 'acme', + repos: [{ name: 'docs', bookmark: 'main' }], + }), +}) + +const agent = new Agent({ + name: 'file-agent', + model: '__GATEWAY_ANTHROPIC_MODEL_OPUS__', + workspace, +}) +``` + +`apiKey` falls back to `MESA_API_KEY` when omitted. + +## Constructor parameters + + + +## Properties + + + +## Methods + +`MesaFilesystem` implements the [WorkspaceFilesystem interface](/reference/workspace/filesystem). + +### File operations + +#### `readFile(path, options?)` + +Reads a file from Mesa. + +```typescript +const content = await filesystem.readFile('/acme/docs/README.md', { + encoding: 'utf-8', +}) +``` + +Returns: `Promise` + +#### `writeFile(path, content, options?)` + +Writes a file to Mesa. + +```typescript +await filesystem.writeFile('/acme/docs/report.md', '# Report') +``` + +Returns: `Promise` + +#### `appendFile(path, content)` + +Appends content to a file. + +```typescript +await filesystem.appendFile('/acme/docs/log.txt', 'new line\n') +``` + +Returns: `Promise` + +#### `deleteFile(path, options?)` + +Deletes a file. + +```typescript +await filesystem.deleteFile('/acme/docs/old-report.md') +``` + +Returns: `Promise` + +#### `copyFile(src, dest, options?)` + +Copies a file. + +```typescript +await filesystem.copyFile('/acme/docs/report.md', '/acme/docs/archive/report.md') +``` + +Returns: `Promise` + +#### `moveFile(src, dest, options?)` + +Moves or renames a file. + +```typescript +await filesystem.moveFile('/acme/docs/draft.md', '/acme/docs/final.md') +``` + +Returns: `Promise` + +### Directory operations + +#### `mkdir(path, options?)` + +Creates a directory. + +```typescript +await filesystem.mkdir('/acme/docs/reports', { recursive: true }) +``` + +Returns: `Promise` + +#### `rmdir(path, options?)` + +Removes a directory. + +```typescript +await filesystem.rmdir('/acme/docs/reports', { recursive: true }) +``` + +Returns: `Promise` + +#### `readdir(path, options?)` + +Lists directory entries. + +```typescript +const entries = await filesystem.readdir('/acme/docs', { + recursive: true, + extension: '.md', +}) +``` + +Returns: `Promise` + +### Path operations + +#### `exists(path)` + +Checks whether a path exists. + +```typescript +const exists = await filesystem.exists('/acme/docs/README.md') +``` + +Returns: `Promise` + +#### `stat(path)` + +Returns file or directory metadata. + +```typescript +const stat = await filesystem.stat('/acme/docs/README.md') +``` + +Returns: `Promise` + +#### `realpath(path)` + +Returns the canonical path from Mesa. + +```typescript +const realPath = await filesystem.realpath('/acme/docs/README.md') +``` + +Returns: `Promise` + +### Mesa operations + +#### `bash(options?)` + +Creates a Mesa-backed Bash runtime for this filesystem. + +```typescript +const bash = await filesystem.bash({ + cwd: '/acme/docs', +}) +``` + +Returns: `Promise` + +## Path semantics + +Methods expect absolute paths. For `MesaFilesystem`, paths are rooted at the Mesa mount and must include the org slug and repo name: + +```typescript +await filesystem.readFile('/acme/docs/README.md') +``` + +Do not omit the leading slash: + +```typescript +await filesystem.readFile('acme/docs/README.md') // Incorrect +await filesystem.readFile('/acme/docs/README.md') // Correct +``` + +The org comes from `org` in the constructor, or from the Mesa SDK's default org inference when `org` is omitted. It still appears as the first path segment. + +When you mount multiple repos, each repo is available under the org segment: + +```typescript +const filesystem = new MesaFilesystem({ + org: 'acme', + repos: [ + { name: 'docs', bookmark: 'main' }, + { name: 'website', bookmark: 'main' }, + ], +}) + +await filesystem.readFile('/acme/docs/README.md') +await filesystem.readFile('/acme/website/package.json') +``` + +## Mesa versioning APIs + +Access the underlying Mesa filesystem for Mesa-specific change and bookmark operations: + +```typescript +await filesystem.writeFile('/acme/docs/draft.md', 'Draft') + +const current = await filesystem.change.current({ + repo: 'docs', +}) + +await filesystem.bookmark.move({ + repo: 'docs', + name: 'main', + changeId: current.changeId, +}) +``` + +More details on versioning semantics can be found in [Mesa's docs](https://docs.mesa.dev/content/concepts/versioning). + +## Read-only mode + +Set `readOnly: true` to block write operations through Mastra: + +```typescript +const filesystem = new MesaFilesystem({ + repos: [{ name: 'docs', bookmark: 'main' }], + readOnly: true, +}) +``` + +Read operations still work. Write operations throw `WorkspaceReadOnlyError`. + +## Concurrency + +`overwrite: false` and `expectedMtime` use preflight checks before writing. These checks are not atomic unless Mesa adds native conditional writes for app mounts. + +## Related + +- [WorkspaceFilesystem interface](/reference/workspace/filesystem) +- [Filesystem docs](/docs/workspace/filesystem) +- [Workspace overview](/docs/workspace/overview) +- [Mesa documentation](https://docs.mesa.dev/content/getting-started/introduction) diff --git a/docs/src/mastra-code/customization.mdx b/docs/src/mastra-code/customization.mdx index 3b4d2155ce19..b9111d4f17b3 100644 --- a/docs/src/mastra-code/customization.mdx +++ b/docs/src/mastra-code/customization.mdx @@ -16,26 +16,27 @@ Import `createMastraCode` to bootstrap Mastra Code in your own application: ```typescript title="src/custom-agent.ts" import { createMastraCode } from 'mastracode' -const { controller, mcpManager, authStorage } = await createMastraCode({ +const { controller, session, mcpManager, authStorage } = await createMastraCode({ cwd: '/path/to/project', initialState: { yolo: false, }, }) -await controller.init() -await controller.selectOrCreateThread() - -controller.subscribe(event => { +session.subscribe(event => { if (event.type === 'message_update') { - console.log(event.message.content) + const text = event.message.content + .filter(p => p.type === 'text') + .map(p => p.text) + .join('') + process.stdout.write(text) } }) -await controller.sendMessage({ content: 'Explain the auth module' }) +await session.sendMessage({ content: 'Explain the auth module' }) ``` -The `controller` object is a standard `AgentController` instance. You can subscribe to events, switch modes, manage threads, and send messages through its API. +`createMastraCode` boots the `controller` and returns a ready-to-use `session`. Subscribe to events and send messages on the `session`; use the `controller` to switch modes, configure tools, and manage shared resources. ## Custom modes @@ -148,15 +149,15 @@ const { controller } = await createMastraCode({ This is equivalent to setting `.mastracode/database.json` but allows configuration at the code level. -## Custom heartbeat handlers +## Custom interval handlers Replace or extend the default background tasks: -```typescript title="src/custom-heartbeats.ts" +```typescript title="src/custom-intervals.ts" import { createMastraCode } from 'mastracode' const { controller } = await createMastraCode({ - heartbeatHandlers: [ + intervalHandlers: [ { id: 'sync-config', intervalMs: 60_000, @@ -168,7 +169,7 @@ const { controller } = await createMastraCode({ }) ``` -Heartbeat handlers run on a fixed interval. They start when `controller.init()` is called and stop when `controller.destroy()` is called. +Interval handlers run on a fixed interval. They start when `controller.init()` is called and stop when `controller.destroy()` is called. ## Building a custom TUI @@ -178,7 +179,7 @@ Mastra Code exports the `MastraTUI` class from `mastracode/tui` for building cus import { createMastraCode } from 'mastracode' import { MastraTUI } from 'mastracode/tui' -const { controller, mcpManager, hookManager, authStorage } = createMastraCode() +const { controller, mcpManager, hookManager, authStorage } = await createMastraCode() const tui = new MastraTUI({ controller, diff --git a/docs/src/mastra-code/goals.mdx b/docs/src/mastra-code/goals.mdx index 20a5af971ff9..08a940eadf7e 100644 --- a/docs/src/mastra-code/goals.mdx +++ b/docs/src/mastra-code/goals.mdx @@ -29,13 +29,13 @@ The status line shows goal progress as `goal attempt N/M`. On narrow terminals, The first time you run `/goal`, Mastra Code asks which model should judge progress and how many attempts the goal can use. These defaults are saved for future goals. -Use `/judge` to change the defaults later: +Use `/goal judge` to change the defaults later: ```text -/judge +/goal judge ``` -Changing `/judge` while a goal is active updates that goal's judge model and attempt limit without resetting progress. +Changing `/goal judge` while a goal is active updates that goal's judge model and attempt limit without resetting progress. ## Manage a goal diff --git a/docs/src/mastra-code/headless.mdx b/docs/src/mastra-code/headless.mdx new file mode 100644 index 000000000000..3394bc1df36d --- /dev/null +++ b/docs/src/mastra-code/headless.mdx @@ -0,0 +1,211 @@ +--- +title: 'Headless mode' +description: 'Run Mastra Code non-interactively from your shell or programmatically from Node and CI with the runMC API.' +packages: + - 'mastracode' +--- + +# Headless mode + +Mastra Code can run non-interactively — both from your shell and programmatically from Node or CI. Both paths share one core runner, `runMC`. The CLI is a thin adapter over it. + +- **CLI**: `mastracode --prompt "..."` parses flags, bootstraps Mastra Code, runs the task, prints output, and exits with a status code. +- **Programmatic**: you bootstrap Mastra Code once with [`createMastraCode()`](/reference), then call `runMC()` to run a task as an async-iterable that also resolves to a typed result. + +## CLI usage + +Pass `--prompt` (or `-p`) to run a single task and exit: + +```sh +mastracode --prompt "Fix the bug in auth.ts" +``` + +Prompts can also be piped via stdin by passing `-` as the prompt: + +```sh +echo "Summarize the repo" | mastracode --prompt - +``` + +### Flags + +| Flag | Description | +| - | - | +| `--prompt`, `-p ` | The task to execute (required, or pipe via stdin) | +| `--continue`, `-c` | Resume the most recent thread instead of creating a new one | +| `--thread`, `-t ` | Resume a specific thread by ID | +| `--title ` | Set or rename the thread title | +| `--clone-thread` | Clone the current thread before running (work on a copy) | +| `--resource-id <id>` | Set the resource ID for thread scoping | +| `--timeout <seconds>` | Exit with code 2 if not complete within the timeout | +| `--max-turns <n>` | Abort after N agentic turns (exit code 1) | +| `--permission-mode <mode>` | How tool approvals/suspensions resolve: `auto` (default) approves everything; `deny` refuses approvals and aborts on suspension | +| `--output`, `-o <mode>` | Output mode: `human` (default), `json`, or `jsonl` | +| `--model`, `-m <id>` | Model override (e.g. a `provider/model` id) | +| `--mode <build\|plan\|fast>` | Execution mode — defaults to `build` if omitted | +| `--thinking-level <level>` | Thinking level: `off`, `low`, `medium`, `high`, `xhigh` | +| `--settings <path>` | Path to a `settings.json` file (default: global settings) | +| `--help`, `-h` | Show usage | + +`--continue` and `--thread` cannot be used together. + +### Output modes + +| Mode | Behavior | +| - | - | +| `human` | Streaming assistant text to stdout; tool activity and errors to stderr | +| `json` | A single final JSON object (text, usage, tool calls/results, threadId, status) | +| `jsonl` | Newline-delimited JSON: one line per event, then a final `{ "type": "result", ... }` line | + +### Exit codes + +| Code | Meaning | +| - | - | +| `0` | Agent completed successfully | +| `1` | Error, aborted, or max turns reached | +| `2` | Timeout | + +### Examples + +```sh +# JSON output with a timeout +mastracode --prompt "Add tests" --timeout 300 --output json + +# Event stream +mastracode --prompt "Refactor" --output jsonl + +# Gate a CI run: no unattended tool execution, bounded turns +mastracode --prompt "Review this PR" --permission-mode deny --max-turns 10 + +# Use a CI-specific settings file +mastracode --settings ./settings-ci.json --prompt "Run tests" + +# Resume the most recent thread +mastracode -c --prompt "Continue where you left off" +``` + +## Programmatic usage + +Bootstrap Mastra Code with [`createMastraCode()`](/reference), then pass the returned `controller` and `session` to `runMC()`. `createMastraCode()` is async; `runMC()` is synchronous and returns a run handle immediately. + +```typescript title="src/run.ts" +import { createMastraCode, runMC } from 'mastracode' + +const { controller, session } = await createMastraCode() + +const run = runMC({ + controller, + session, + prompt: 'Fix the failing test in auth.test.ts', +}) + +// Optional: stream live events as they happen. +for await (const event of run) { + console.log(event.type) +} + +// Resolves once the run completes, times out, errors, or is aborted. +const result = await run.result +console.log(result.status, result.text) +``` + +`runMC` never calls `process.exit` and never writes to global streams, so it is safe to embed in CI scripts and servers. + +### The run handle + +`runMC` returns an `MCRun`, which is both an async-iterable over controller events and a handle that resolves to a final result: + +```typescript +const run = runMC({ controller, session, prompt }) + +run.result // Promise<RunMCResult> +run.abort() // abort the in-flight run +for await (const event of run) { + /* ... */ +} +``` + +Both `for await (const event of run)` and `await run.result` work on the same run. Awaiting `result` without iterating still drains events internally. + +### `runMC` options + +| Option | Type | Description | +| - | - | - | +| `controller` | `AgentController` | Controller from `createMastraCode()` (required) | +| `session` | `Session` | Session from `createMastraCode()` (required) | +| `prompt` | `string` | The task to run (required) | +| `model` | `string` | Explicit model id override; takes precedence over `mode` | +| `mode` | `'build' \| 'plan' \| 'fast'` | Execution mode; resolves a model from `modeDefaults` when `model` is absent | +| `modeDefaults` | `Record<string, string>` | Per-mode default model ids | +| `thinkingLevel` | `'off' \| 'low' \| 'medium' \| 'high' \| 'xhigh'` | Thinking-effort level | +| `thread` | `{ id?: string; continueLatest?: boolean; clone?: boolean }` | Thread selection / mutation | +| `resourceId` | `string` | Resource id for thread scoping | +| `title` | `string` | Set or rename the thread title before running | +| `timeoutMs` | `number` | Abort with `status: 'timeout'` (exit code 2) if not complete in time | +| `maxTurns` | `number` | Abort with `status: 'max_turns'` (exit code 1) after N assistant turns | +| `policy` | `ResolutionPolicy` | How approvals/suspensions resolve (defaults to `autoApprovePolicy`) | +| `signal` | `AbortSignal` | External abort signal; aborting it aborts the run | + +### `RunMCResult` + +| Field | Type | Description | +| - | - | - | +| `status` | `'completed' \| 'error' \| 'aborted' \| 'timeout' \| 'max_turns'` | How the run ended | +| `text` | `string` | Aggregated assistant text across the run | +| `finishReason` | `string` | Underlying finish reason when the run finished normally | +| `usage` | `{ inputTokens?; outputTokens?; totalTokens? }` | Token usage | +| `toolCalls` | `Array<{ id; name; args }>` | Tool calls made during the run | +| `toolResults` | `Array<{ id; name; result; isError }>` | Tool results | +| `threadId` | `string` | The thread the run executed in | +| `error` | `{ name; message; stack? }` | Present when `status` is `error` | +| `exitCode` | `number` | `0` success, `1` error/aborted/max\_turns, `2` timeout | + +## Resolution policies + +A resolution policy decides how the run resumes `tool_approval_required` and `tool_suspended` events when there is no human in the loop. The default is `autoApprovePolicy`, which reproduces the historical headless behavior: approve every tool, auto-resolve sandbox and `submit_plan` suspensions, and answer any other suspension with a "use your best judgment" instruction. + +Two built-in policies and a resolver are exported: + +| Export | Behavior | +| - | - | +| `autoApprovePolicy` | Approve every tool; auto-resolve suspensions (default) | +| `denyPolicy` | Refuse every tool approval; abort on any suspension | +| `permissionModeToPolicy(mode)` | Resolve a `PermissionMode` (`'auto'` or `'deny'`) to a built-in policy | + +The CLI `--permission-mode` flag maps directly onto these via `permissionModeToPolicy`. + +### Custom policies + +Implement the `ResolutionPolicy` interface to make per-event decisions: + +```typescript title="src/strict-policy.ts" +import { createMastraCode, runMC, type ResolutionPolicy } from 'mastracode' + +const readOnlyPolicy: ResolutionPolicy = { + // Only approve read_file; deny everything else. + onToolApproval(event) { + return event.toolName === 'read_file' ? 'approve' : 'deny' + }, + // Abort rather than answer suspensions unattended. + onSuspension() { + return { abort: true } + }, +} + +const { controller, session } = await createMastraCode() + +const run = runMC({ + controller, + session, + prompt: 'Audit the codebase for secrets', + policy: readOnlyPolicy, +}) + +const result = await run.result +``` + +`onToolApproval` returns `'approve'` or `'deny'`. `onSuspension` returns either `{ resumeData }` to resume the suspended tool or `{ abort: true }` to abort the run. + +## Next steps + +- [Customization](/customization) — extend Mastra Code with custom modes, tools, and subagents. +- [API reference](/reference) — full `createMastraCode()` options and return values. diff --git a/docs/src/mastra-code/index.mdx b/docs/src/mastra-code/index.mdx index c28b8f09b794..bf8097880308 100644 --- a/docs/src/mastra-code/index.mdx +++ b/docs/src/mastra-code/index.mdx @@ -17,6 +17,7 @@ Mastra Code organizes its capabilities around these areas: - [**Configuration**](/configuration): Project-scoped threads, MCP servers, hooks, custom commands, skills, and database settings. - [**Terminal notifications**](/terminal-notifications): Show clickable notifications for Mastra Code running in an inactive terminal pane. - [**Customization**](/customization): Extend Mastra Code programmatically with custom modes, tools, subagents, and storage. +- [**Headless mode**](/headless): Run Mastra Code non-interactively from your shell or from Node and CI with the `runMC` API. In this demo, you'll see Mastra Code in action: @@ -94,20 +95,29 @@ Mastra Code provides built-in slash commands for managing sessions and settings: | `/name` | Rename the current thread | | `/models` | Switch model pack | | `/mode` | Switch or list modes | -| `/goal` | Start, inspect, pause, resume, or clear a persistent goal | -| `/judge` | Configure the judge model and attempt limit for goals | +| `/goal` | Start, inspect, pause, resume, or clear a persistent goal (use `/goal judge` to configure the judge model and attempt limit) | +| `/think` | Set the thinking level (off/low/medium/high/xhigh) | | `/permissions` | Configure tool approval permissions | +| `/yolo` | Toggle YOLO mode (auto-approve all tools) | | `/settings` | Open the settings panel | | `/om` | Configure Observational Memory | +| `/subagents` | Configure subagent model defaults | | `/skills` | List available skills | | `/skill/<name>` | Activate a specific skill | | `/cost` | Show token usage and costs | | `/diff` | Show modified files or git diff | +| `/resource` | Show or switch the resource ID (tag for sharing) | +| `/thread:tag-dir` | Tag the current thread with this directory | | `/sandbox` | Manage sandbox allowed paths | | `/review` | Review a GitHub pull request | +| `/github` | Subscribe, sync, or debug GitHub PR signals | | `/report-issue` | Open or browse mastracode issues | | `/login` | Authenticate with OAuth provider | | `/logout` | Log out from an OAuth provider | +| `/api-keys` | Manage API keys for model providers | +| `/custom-providers` | Manage custom providers and models | +| `/observability` | Configure cloud observability | +| `/voice` | Toggle push-to-talk voice input | | `/setup` | Run the setup wizard | | `/theme` | Switch color theme (auto/dark/light) | | `/browser` | Configure browser automation | @@ -149,4 +159,5 @@ Mastra Code is built on four layers: - [Tools](/tools) - [Configuration](/configuration) - [Customization](/customization) +- [Headless mode](/headless) - [API reference](/reference) diff --git a/docs/src/mastra-code/reference.mdx b/docs/src/mastra-code/reference.mdx index cfac93b46f89..911cdab17cb1 100644 --- a/docs/src/mastra-code/reference.mdx +++ b/docs/src/mastra-code/reference.mdx @@ -22,7 +22,7 @@ const { builtinPacks, builtinOmPacks, effectiveDefaults, -} = createMastraCode(options) +} = await createMastraCode(options) ``` ### Parameters @@ -36,27 +36,52 @@ const { | Property | Type | Description | | - | - | - | | `controller` | `AgentController` | The main orchestrator for modes, threads, messages, and tools | +| `session` | `Session<MastraCodeState>` | The wired local session. Pass this (with `controller`) to `runMC` | +| `sessionId` | `string` | Identity of the eager local session | +| `ownerId` | `string` | Owner ID for the local session | +| `storage` | `StorageConfig` | The resolved storage backend | +| `memory` | `Memory \| MemoryFactory` | The resolved memory instance or factory | +| `observability` | `object` | Observability handles | | `mcpManager` | `MCPManager` | Manager for MCP server connections | | `hookManager` | `HookManager` | Manager for lifecycle hooks | +| `signalsPubSub` | `PubSub` | PubSub used for signal routing | +| `githubSignals` | `object` | GitHub PR signal handles | | `authStorage` | `AuthStorage` | Storage for OAuth credentials | | `resolveModel` | `(modelId: string, options?: { thinkingLevel?: ThinkingLevel; remapForCodexOAuth?: boolean; requestContext?: RequestContext }) => ResolvedModel` | Model resolution function | | `storageWarning` | `string \| null` | Warning message if storage fallback occurred | +| `observabilityWarning` | `string \| null` | Warning message if observability setup fell back | | `builtinPacks` | `ModePack[]` | Built-in mode packs | | `builtinOmPacks` | `OmPack[]` | Built-in Observational Memory packs | | `effectiveDefaults` | `object` | Effective default settings after merging | +| `setActiveSession` | `(session: Session<MastraCodeState>) => void` | Publishes a session back into config closures | ### `CreateMastraCodeOptions` | Option | Type | Default | Description | | - | - | - | - | -| `cwd` | `string` | `process.cwd()` | Working directory for the agent | -| `modes` | `AgentControllerMode[]` | Build, Plan, Fast | Custom mode configurations | -| `extraTools` | `ToolsInput` | `{}` | Additional tools merged with built-in tools | -| `subagents` | `AgentControllerSubagent[]` | Explore, Plan, Execute | Custom subagent definitions | -| `storage` | `StorageConfig` | Local LibSQL | Database configuration | -| `initialState` | `Partial<MastraCodeState>` | Default state | Initial controller state values | -| `heartbeatHandlers` | `HeartbeatHandler[]` | Default handlers | Background task definitions | -| `resolveModel` | `(modelId: string, options?: { thinkingLevel?: ThinkingLevel; remapForCodexOAuth?: boolean; requestContext?: RequestContext }) => ResolvedModel` | Default resolver | Custom model resolution function | +| `cwd` | `string` | `process.cwd()` | Working directory for project detection | +| `homeDir` | `string` | `os.homedir()` | Home directory for global config discovery | +| `modes` | `AgentControllerMode[]` | Build, Plan, Fast | Override modes (model IDs, colors, which modes exist) | +| `subagents` | `AgentControllerSubagent[]` | Explore, Plan, Execute | Override or extend subagent definitions | +| `extraTools` | `Record<string, Tool> \| ((ctx) => Record<string, Tool>)` | `{}` | Extra tools merged into the dynamic tool set | +| `disabledTools` | `string[]` | `[]` | Tools removed from the dynamic tool set before exposure to the model | +| `storage` | `StorageConfig` | Local LibSQL | Custom storage config instead of auto-detected default | +| `omScope` | `'thread' \| 'resource'` | Auto-detected | Observational Memory scope | +| `settingsPath` | `string` | Global settings | Path to a custom `settings.json` file | +| `initialState` | `Partial<MastraCodeState>` | Default state | Initial state overrides (yolo, thinkingLevel, etc.) | +| `idGenerator` | `() => string` | Default | Override id generation for threads/messages (useful for deterministic tests) | +| `intervalHandlers` | `IntervalHandler[]` | gateway-sync | Override interval (background task) handlers | +| `resolveModel` | `(modelId: string, options?) => ResolvedModel` | Default resolver | Custom model resolution function | +| `workspace` | `Workspace` | Local FS + sandbox | Override the workspace | +| `configDir` | `string` | `.mastracode` | Override the config directory name | +| `mcpServers` | `Record<string, McpServerConfig>` | `{}` | Programmatic MCP server configs, merged with file-based configs | +| `disableMcp` | `boolean` | `false` | Disable MCP server discovery | +| `disableHooks` | `boolean` | `false` | Disable hooks | +| `memory` | `Memory \| MemoryFactory \| false` | Built-in gateway | Override the memory instance or factory | +| `browser` | `BrowserProvider` | — | Browser provider; when set the agent gains browser tools | +| `pubsub` | `PubSub` | — | PubSub for signal routing | +| `unixSocketPubSub` | `boolean` | `false` | Use the built-in Unix socket PubSub for local cross-process signal routing | +| `crossProcessPubSub` | `boolean` | `false` | Mark the configured PubSub as cross-process-safe (skips file thread locks) | #### `AgentControllerMode` @@ -118,17 +143,14 @@ Tool permission configuration. ```typescript interface PermissionRules { - categories: { - read: 'allow' | 'ask' | 'deny' - edit: 'allow' | 'ask' | 'deny' - execute: 'allow' | 'ask' | 'deny' - mcp: 'allow' | 'ask' | 'deny' - } + // Keyed by category (e.g. "read", "edit", "execute", "mcp") + categories: Record<string, 'allow' | 'ask' | 'deny'> + // Keyed by tool id, overriding the category policy tools: Record<string, 'allow' | 'ask' | 'deny'> } ``` -#### `HeartbeatHandler` +#### `IntervalHandler` Background task definition. @@ -179,25 +201,30 @@ tui.run() ### Basic usage +`createMastraCode` boots the controller and returns a ready-to-use `session`. Subscribe to events and send messages on the session: + ```typescript import { createMastraCode } from 'mastracode' -const { controller } = await createMastraCode({ +const { session } = await createMastraCode({ cwd: '/path/to/project', }) -await controller.init() -await controller.selectOrCreateThread() - -controller.subscribe(event => { +session.subscribe(event => { if (event.type === 'message_update') { - console.log(event.message.content) + const text = event.message.content + .filter(p => p.type === 'text') + .map(p => p.text) + .join('') + process.stdout.write(text) } }) -await controller.sendMessage({ content: 'Explain the auth module' }) +await session.sendMessage({ content: 'Explain the auth module' }) ``` +For one-shot/headless runs, prefer [`runMC`](/headless), which wraps this subscribe/send/aggregate loop and resolves to a typed result. + ### Custom mode ```typescript diff --git a/docs/src/plugins/remark-model-tokens/models.ts b/docs/src/plugins/remark-model-tokens/models.ts index 1c1a889ad192..e7f0bf1c6e7b 100644 --- a/docs/src/plugins/remark-model-tokens/models.ts +++ b/docs/src/plugins/remark-model-tokens/models.ts @@ -31,4 +31,12 @@ export const MODEL_TOKENS: Record<string, string> = { // Alibaba __GATEWAY_ALIBABA_MODEL__: 'alibaba/qwen-max', + + // Amazon Bedrock + __GATEWAY_BEDROCK_MODEL_OPUS__: 'amazon-bedrock/us.anthropic.claude-opus-4-6-v1', + __GATEWAY_BEDROCK_MODEL_SONNET__: 'amazon-bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0', + __BEDROCK_MODEL_OPUS_BARE__: 'us.anthropic.claude-opus-4-6-v1', + __BEDROCK_MODEL_SONNET_BARE__: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + __BEDROCK_MODEL_LLAMA_SCOUT_BARE__: 'us.meta.llama4-scout-17b-instruct-v1:0', + __BEDROCK_MODEL_HAIKU_BARE__: 'us.anthropic.claude-haiku-4-5-20251001-v1:0', } diff --git a/docs/vercel.json b/docs/vercel.json index 252ee55afb57..d6b82eb15133 100644 --- a/docs/vercel.json +++ b/docs/vercel.json @@ -35,21 +35,6 @@ "destination": "/docs", "permanent": true }, - { - "source": "/docs/harness/overview", - "destination": "/docs/agent-controller/overview", - "permanent": true - }, - { - "source": "/docs/harness/session", - "destination": "/docs/agent-controller/session", - "permanent": true - }, - { - "source": "/docs/harness/threads-and-state", - "destination": "/docs/agent-controller/threads-and-state", - "permanent": true - }, { "source": "/docs/v1/:path*", "destination": "/docs/:path*", @@ -690,6 +675,11 @@ "destination": "https://code.mastra.ai/customization", "permanent": true }, + { + "source": "/docs/mastra-code/headless", + "destination": "https://code.mastra.ai/headless", + "permanent": true + }, { "source": "/reference/mastra-code/createMastraCode", "destination": "https://code.mastra.ai/", @@ -809,6 +799,46 @@ "source": "/docs/streaming/background-task-streaming", "destination": "/docs/agents/background-tasks#subscribe-to-all-task-events", "permanent": true + }, + { + "source": "/docs/harness/modes", + "destination": "/docs/agent-controller/modes", + "permanent": true + }, + { + "source": "/docs/harness/overview", + "destination": "/docs/agent-controller/overview", + "permanent": true + }, + { + "source": "/docs/harness/session", + "destination": "/docs/agent-controller/session", + "permanent": true + }, + { + "source": "/docs/harness/subagents", + "destination": "/docs/agent-controller/subagents", + "permanent": true + }, + { + "source": "/docs/harness/threads-and-state", + "destination": "/docs/agent-controller/threads-and-state", + "permanent": true + }, + { + "source": "/docs/harness/tool-approvals", + "destination": "/docs/agent-controller/tool-approvals", + "permanent": true + }, + { + "source": "/reference/harness/harness-class", + "destination": "/reference/agent-controller/agent-controller-class", + "permanent": true + }, + { + "source": "/reference/harness/session", + "destination": "/reference/agent-controller/session", + "permanent": true } ] } diff --git a/examples/agent-builder/package.json b/examples/agent-builder/package.json index 2f91582334cb..61170849a7b3 100644 --- a/examples/agent-builder/package.json +++ b/examples/agent-builder/package.json @@ -12,7 +12,7 @@ "mastra:dev": "mastra dev" }, "dependencies": { - "zod": "^4.3.6", + "zod": "^4.4.3", "typescript": "^5.9.3", "@ai-sdk/openai": "^3.0.0", "@ai-sdk/provider": "^3.0.0", diff --git a/examples/agent-builder/pnpm-lock.yaml b/examples/agent-builder/pnpm-lock.yaml index 101228bc9aea..1bf364ba1adc 100644 --- a/examples/agent-builder/pnpm-lock.yaml +++ b/examples/agent-builder/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: dependencies: '@ai-sdk/openai': specifier: ^3.0.0 - version: 3.0.0(zod@4.3.6) + version: 3.0.0(zod@4.4.3) '@ai-sdk/provider': specifier: ^3.0.0 version: 3.0.0 @@ -82,7 +82,7 @@ importers: version: link:../../voice/openai ai: specifier: ^6.0.1 - version: 6.0.1(zod@4.3.6) + version: 6.0.1(zod@4.4.3) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -90,8 +90,8 @@ importers: specifier: ^8.18.0 version: 8.20.0 zod: - specifier: ^4.3.6 - version: 4.3.6 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: mastra: specifier: link:../../packages/cli @@ -381,30 +381,30 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} snapshots: - '@ai-sdk/gateway@3.0.0(zod@4.3.6)': + '@ai-sdk/gateway@3.0.0(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.0 - '@ai-sdk/provider-utils': 4.0.0(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.0(zod@4.4.3) '@vercel/oidc': 3.0.5 - zod: 4.3.6 + zod: 4.4.3 - '@ai-sdk/openai@3.0.0(zod@4.3.6)': + '@ai-sdk/openai@3.0.0(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.0 - '@ai-sdk/provider-utils': 4.0.0(zod@4.3.6) - zod: 4.3.6 + '@ai-sdk/provider-utils': 4.0.0(zod@4.4.3) + zod: 4.4.3 - '@ai-sdk/provider-utils@4.0.0(zod@4.3.6)': + '@ai-sdk/provider-utils@4.0.0(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.0 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.0.6 - zod: 4.3.6 + zod: 4.4.3 '@ai-sdk/provider@3.0.0': dependencies: @@ -471,13 +471,13 @@ snapshots: '@vercel/oidc@3.0.5': {} - ai@6.0.1(zod@4.3.6): + ai@6.0.1(zod@4.4.3): dependencies: - '@ai-sdk/gateway': 3.0.0(zod@4.3.6) + '@ai-sdk/gateway': 3.0.0(zod@4.4.3) '@ai-sdk/provider': 3.0.0 - '@ai-sdk/provider-utils': 4.0.0(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.0(zod@4.4.3) '@opentelemetry/api': 1.9.0 - zod: 4.3.6 + zod: 4.4.3 ansi-regex@5.0.1: {} @@ -624,4 +624,4 @@ snapshots: yoctocolors-cjs@2.1.3: {} - zod@4.3.6: {} + zod@4.4.3: {} diff --git a/examples/agent/package.json b/examples/agent/package.json index 539bf9ace176..6204857905db 100644 --- a/examples/agent/package.json +++ b/examples/agent/package.json @@ -34,7 +34,7 @@ "fetch-to-node": "^2.1.0", "mastra": "latest", "typescript": "^5.9.3", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "pnpm": { "overrides": { diff --git a/examples/agent/pnpm-lock.yaml b/examples/agent/pnpm-lock.yaml index 481f5fb2b0b3..9a67a7fda7a2 100644 --- a/examples/agent/pnpm-lock.yaml +++ b/examples/agent/pnpm-lock.yaml @@ -32,7 +32,7 @@ importers: dependencies: '@ai-sdk/openai': specifier: ^1.3.24 - version: 1.3.24(zod@4.3.6) + version: 1.3.24(zod@4.4.3) '@ai-sdk/provider': specifier: ^2.0.0 version: 2.0.1 @@ -95,13 +95,13 @@ importers: version: link:../../voice/openai '@modelcontextprotocol/sdk': specifier: ^1.29.0 - version: 1.29.0(zod@4.3.6) + version: 1.29.0(zod@4.4.3) ai: specifier: ^4.3.19 - version: 4.3.19(react@19.2.4)(zod@4.3.6) + version: 4.3.19(react@19.2.4)(zod@4.4.3) ai-v5: specifier: npm:ai@^5.0.93 - version: ai@5.0.126(zod@4.3.6) + version: ai@5.0.126(zod@4.4.3) better-auth: specifier: ^1.2.8 version: 1.5.3(react@19.2.4) @@ -115,8 +115,8 @@ importers: specifier: ^5.9.3 version: 5.9.3 zod: - specifier: ^4.3.6 - version: 4.3.6 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: concurrently: specifier: ^9.1.2 @@ -1166,37 +1166,37 @@ packages: peerDependencies: zod: ^3.25 || ^4 - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} snapshots: - '@ai-sdk/gateway@2.0.32(zod@4.3.6)': + '@ai-sdk/gateway@2.0.32(zod@4.4.3)': dependencies: '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.20(zod@4.3.6) + '@ai-sdk/provider-utils': 3.0.20(zod@4.4.3) '@vercel/oidc': 3.1.0 - zod: 4.3.6 + zod: 4.4.3 - '@ai-sdk/openai@1.3.24(zod@4.3.6)': + '@ai-sdk/openai@1.3.24(zod@4.4.3)': dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@4.3.6) - zod: 4.3.6 + '@ai-sdk/provider-utils': 2.2.8(zod@4.4.3) + zod: 4.4.3 - '@ai-sdk/provider-utils@2.2.8(zod@4.3.6)': + '@ai-sdk/provider-utils@2.2.8(zod@4.4.3)': dependencies: '@ai-sdk/provider': 1.1.3 nanoid: 3.3.11 secure-json-parse: 2.7.0 - zod: 4.3.6 + zod: 4.4.3 - '@ai-sdk/provider-utils@3.0.20(zod@4.3.6)': + '@ai-sdk/provider-utils@3.0.20(zod@4.4.3)': dependencies: '@ai-sdk/provider': 2.0.1 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.0.6 - zod: 4.3.6 + zod: 4.4.3 '@ai-sdk/provider@1.1.3': dependencies: @@ -1206,48 +1206,48 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/react@1.2.12(react@19.2.4)(zod@4.3.6)': + '@ai-sdk/react@1.2.12(react@19.2.4)(zod@4.4.3)': dependencies: - '@ai-sdk/provider-utils': 2.2.8(zod@4.3.6) - '@ai-sdk/ui-utils': 1.2.11(zod@4.3.6) + '@ai-sdk/provider-utils': 2.2.8(zod@4.4.3) + '@ai-sdk/ui-utils': 1.2.11(zod@4.4.3) react: 19.2.4 swr: 2.4.0(react@19.2.4) throttleit: 2.1.0 optionalDependencies: - zod: 4.3.6 + zod: 4.4.3 - '@ai-sdk/ui-utils@1.2.11(zod@4.3.6)': + '@ai-sdk/ui-utils@1.2.11(zod@4.4.3)': dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@4.3.6) - zod: 4.3.6 - zod-to-json-schema: 3.25.1(zod@4.3.6) + '@ai-sdk/provider-utils': 2.2.8(zod@4.4.3) + zod: 4.4.3 + zod-to-json-schema: 3.25.1(zod@4.4.3) - '@better-auth/core@1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)': + '@better-auth/core@1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.4.3))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)': dependencies: '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 '@standard-schema/spec': 1.1.0 - better-call: 1.3.2(zod@4.3.6) + better-call: 1.3.2(zod@4.4.3) jose: 6.2.0 kysely: 0.28.11 nanostores: 1.1.1 - zod: 4.3.6 + zod: 4.4.3 - '@better-auth/kysely-adapter@1.5.3(@better-auth/core@1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)': + '@better-auth/kysely-adapter@1.5.3(@better-auth/core@1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.4.3))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)': dependencies: - '@better-auth/core': 1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.4.3))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 kysely: 0.28.11 - '@better-auth/memory-adapter@1.5.3(@better-auth/core@1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': + '@better-auth/memory-adapter@1.5.3(@better-auth/core@1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.4.3))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': dependencies: - '@better-auth/core': 1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.4.3))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 - '@better-auth/telemetry@1.5.3(@better-auth/core@1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))': + '@better-auth/telemetry@1.5.3(@better-auth/core@1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.4.3))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))': dependencies: - '@better-auth/core': 1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.4.3))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 @@ -1359,7 +1359,7 @@ snapshots: '@inquirer/type@3.0.10': {} - '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.16) ajv: 8.20.0 @@ -1376,8 +1376,8 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.3.6 - zod-to-json-schema: 3.25.1(zod@4.3.6) + zod: 4.4.3 + zod-to-json-schema: 3.25.1(zod@4.4.3) transitivePeerDependencies: - supports-color @@ -1418,25 +1418,25 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - ai@4.3.19(react@19.2.4)(zod@4.3.6): + ai@4.3.19(react@19.2.4)(zod@4.4.3): dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@4.3.6) - '@ai-sdk/react': 1.2.12(react@19.2.4)(zod@4.3.6) - '@ai-sdk/ui-utils': 1.2.11(zod@4.3.6) + '@ai-sdk/provider-utils': 2.2.8(zod@4.4.3) + '@ai-sdk/react': 1.2.12(react@19.2.4)(zod@4.4.3) + '@ai-sdk/ui-utils': 1.2.11(zod@4.4.3) '@opentelemetry/api': 1.9.0 jsondiffpatch: 0.6.0 - zod: 4.3.6 + zod: 4.4.3 optionalDependencies: react: 19.2.4 - ai@5.0.126(zod@4.3.6): + ai@5.0.126(zod@4.4.3): dependencies: - '@ai-sdk/gateway': 2.0.32(zod@4.3.6) + '@ai-sdk/gateway': 2.0.32(zod@4.4.3) '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.20(zod@4.3.6) + '@ai-sdk/provider-utils': 3.0.20(zod@4.4.3) '@opentelemetry/api': 1.9.0 - zod: 4.3.6 + zod: 4.4.3 ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: @@ -1457,33 +1457,33 @@ snapshots: better-auth@1.5.3(react@19.2.4): dependencies: - '@better-auth/core': 1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) - '@better-auth/kysely-adapter': 1.5.3(@better-auth/core@1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11) - '@better-auth/memory-adapter': 1.5.3(@better-auth/core@1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1) - '@better-auth/telemetry': 1.5.3(@better-auth/core@1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)) + '@better-auth/core': 1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.4.3))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/kysely-adapter': 1.5.3(@better-auth/core@1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.4.3))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11) + '@better-auth/memory-adapter': 1.5.3(@better-auth/core@1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.4.3))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1) + '@better-auth/telemetry': 1.5.3(@better-auth/core@1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.4.3))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 '@noble/hashes': 2.0.1 - better-call: 1.3.2(zod@4.3.6) + better-call: 1.3.2(zod@4.4.3) defu: 6.1.4 jose: 6.2.0 kysely: 0.28.11 nanostores: 1.1.1 - zod: 4.3.6 + zod: 4.4.3 optionalDependencies: react: 19.2.4 transitivePeerDependencies: - '@cloudflare/workers-types' - better-call@1.3.2(zod@4.3.6): + better-call@1.3.2(zod@4.4.3): dependencies: '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 rou3: 0.7.12 set-cookie-parser: 3.0.1 optionalDependencies: - zod: 4.3.6 + zod: 4.4.3 body-parser@2.2.2: dependencies: @@ -2073,8 +2073,8 @@ snapshots: yoctocolors-cjs@2.1.3: {} - zod-to-json-schema@3.25.1(zod@4.3.6): + zod-to-json-schema@3.25.1(zod@4.4.3): dependencies: - zod: 4.3.6 + zod: 4.4.3 - zod@4.3.6: {} + zod@4.4.3: {} diff --git a/examples/agent/src/mastra/agents/weather-fs/README.md b/examples/agent/src/mastra/agents/weather-fs/README.md new file mode 100644 index 000000000000..e73ed5c609df --- /dev/null +++ b/examples/agent/src/mastra/agents/weather-fs/README.md @@ -0,0 +1,70 @@ +# File-based agent example (`weather-fs`) + +This directory is a **file-based agent**. Unlike the other agents in this +example (which are created with `new Agent()` and exported from +`agents/index.ts`), this agent is defined purely by file convention and is +**not** registered anywhere in code. `mastra dev` and `mastra build` discover it +automatically. + +It exercises every file-based capability: config, instructions, tools, skills, +memory, a default workspace with seed files, and a declared subagent. + +## Layout + +```text +weather-fs/ + config.ts # model + config overrides (uses agentConfig() for typing) + instructions.md # the agent instructions + memory.ts # default-exported Memory instance, wired in as the agent memory + tools/ + get_weather.ts # default-exported tool, keyed by filename -> "get_weather" + skills/ + units.md # flat skill: filename is the name, body is the instructions + severe-weather/ + SKILL.md # packaged skill: frontmatter name/description + body + references/ + thresholds.md # inlined and exposed to the skill at runtime + workspace/ # seed files mirrored into the agent's workspace + cities.json + README.md + subagents/ + forecaster/ # a declared subagent, same layout as an agent + config.ts # MUST set a description + instructions.md + tools/ + get_forecast.ts +``` + +## How it maps + +| File / dir | Becomes | +| -------------------------------- | ------------------------------------------------------------------------------- | +| `config.ts` | merged agent config; `id`/`name` default to `weather-fs`. | +| `instructions.md` | the agent `instructions`. | +| `memory.ts` | the agent `memory` (default export). | +| `tools/get_weather.ts` | a tool keyed `get_weather`. | +| `skills/units.md` | a flat skill named `units`. | +| `skills/severe-weather/SKILL.md` | a packaged skill; frontmatter supplies name/description, `references/` inlined. | +| `workspace/` | seed files copied into the agent's default workspace. | +| `subagents/forecaster/` | a subagent the parent can delegate to via a tool named `forecaster`. | + +Subagents are **one level deep** and each subagent's `config.ts` must set a +non-empty `description` — that is what the parent model sees when deciding +whether to delegate. + +## Try it + +From the repo root: + +```bash +pnpm --filter ./examples/agent mastra dev +``` + +Open Studio and you'll see **weather-fs** listed alongside the code-defined +agents. Try: + +- "what's the weather in Tokyo?" — calls `get_weather`, reports °C and °F. +- "give me a 5-day forecast for London" — delegates to the `forecaster` subagent. +- Ask about a storm — the `severe-weather` skill prepends a safety note. + +See the full docs at `/docs/agents/file-based-agents`. diff --git a/examples/agent/src/mastra/agents/weather-fs/config.ts b/examples/agent/src/mastra/agents/weather-fs/config.ts new file mode 100644 index 000000000000..09a77d87b75b --- /dev/null +++ b/examples/agent/src/mastra/agents/weather-fs/config.ts @@ -0,0 +1,26 @@ +import { agentConfig } from '@mastra/core/agent'; + +/** + * File-based agent example. + * + * This agent is defined entirely by file convention under + * `agents/weather-fs/` — there is NO `new Agent()` call and nothing is + * registered in `src/mastra/index.ts`. `mastra dev` / `mastra build` discover it + * automatically and register it onto the Mastra instance alongside the + * code-defined agents in this project. + * + * The pieces: + * - `config.ts` → this file (model + any config overrides) + * - `instructions.md` → the agent instructions + * - `tools/*.ts` → each default-exported tool, keyed by filename + * - `workspace/` → seed files mirrored into the agent's workspace + * + * `agentConfig` is an identity helper that just gives you typing — `model` and + * `instructions` are optional here because `instructions.md` supplies the + * instructions and the default workspace is created automatically. + */ +export default agentConfig({ + model: 'openai/gpt-5.4-mini', + // instructions omitted -> taken from instructions.md + // tools omitted -> taken from tools/*.ts +}); diff --git a/examples/agent/src/mastra/agents/weather-fs/instructions.md b/examples/agent/src/mastra/agents/weather-fs/instructions.md new file mode 100644 index 000000000000..e56fa0b1ed0a --- /dev/null +++ b/examples/agent/src/mastra/agents/weather-fs/instructions.md @@ -0,0 +1,15 @@ +You are a concise weather assistant. + +When asked about the current weather in a city, call the `get_weather` tool and +report the result in one short sentence. If the user does not name a city, ask +which city they mean. + +When the user asks for a multi-day forecast (or "this week", "next few days"), +delegate to the `forecaster` subagent instead of answering directly. + +You have a workspace with a `cities.json` file listing cities this assistant +commonly answers about — you can read it to suggest cities when the user is +unsure. + +Follow your skills: always give temperatures in both Celsius and Fahrenheit, and +lead with a safety note when conditions are hazardous. diff --git a/examples/agent/src/mastra/agents/weather-fs/memory.ts b/examples/agent/src/mastra/agents/weather-fs/memory.ts new file mode 100644 index 000000000000..4453900e23e9 --- /dev/null +++ b/examples/agent/src/mastra/agents/weather-fs/memory.ts @@ -0,0 +1,5 @@ +import { Memory } from '@mastra/memory'; + +// Default-exported `Memory` instance is wired in as the agent's `memory`. +// `config.memory` in config.ts would win over this file if both were set. +export default new Memory(); diff --git a/examples/agent/src/mastra/agents/weather-fs/skills/severe-weather/SKILL.md b/examples/agent/src/mastra/agents/weather-fs/skills/severe-weather/SKILL.md new file mode 100644 index 000000000000..87d51583f9fd --- /dev/null +++ b/examples/agent/src/mastra/agents/weather-fs/skills/severe-weather/SKILL.md @@ -0,0 +1,9 @@ +--- +name: severe-weather +description: Use when conditions include storms, flooding, heat waves, or other hazards. +--- + +When the weather involves a hazard (storms, flooding, extreme heat or cold, +high winds), lead with a one-line safety note before the forecast. + +Consult `references/thresholds.md` for the thresholds that count as hazardous. diff --git a/examples/agent/src/mastra/agents/weather-fs/skills/severe-weather/references/thresholds.md b/examples/agent/src/mastra/agents/weather-fs/skills/severe-weather/references/thresholds.md new file mode 100644 index 000000000000..35085424dc3c --- /dev/null +++ b/examples/agent/src/mastra/agents/weather-fs/skills/severe-weather/references/thresholds.md @@ -0,0 +1,6 @@ +# Hazard thresholds + +- Extreme heat: at or above 35°C (95°F) +- Extreme cold: at or below -10°C (14°F) +- High wind: sustained winds at or above 60 km/h +- Heavy rain: more than 50 mm in 24 hours diff --git a/examples/agent/src/mastra/agents/weather-fs/skills/units.md b/examples/agent/src/mastra/agents/weather-fs/skills/units.md new file mode 100644 index 000000000000..25ea0cd4cb3a --- /dev/null +++ b/examples/agent/src/mastra/agents/weather-fs/skills/units.md @@ -0,0 +1,8 @@ +--- +name: units +description: Use when reporting temperatures so values are shown in both Celsius and Fahrenheit. +--- + +Always report temperatures in both Celsius and Fahrenheit, with Celsius first. + +Convert with `F = C * 9/5 + 32` and round both values to whole numbers. diff --git a/examples/agent/src/mastra/agents/weather-fs/subagents/forecaster/config.ts b/examples/agent/src/mastra/agents/weather-fs/subagents/forecaster/config.ts new file mode 100644 index 000000000000..8c040121ce62 --- /dev/null +++ b/examples/agent/src/mastra/agents/weather-fs/subagents/forecaster/config.ts @@ -0,0 +1,20 @@ +import { agentConfig } from '@mastra/core/agent'; + +/** + * Declared subagent: `forecaster`. + * + * A subagent is just an agent directory nested under `subagents/`. It has the + * same layout as a top-level agent (`config.ts`, `instructions.md`, `tools/*`, + * and optionally `skills/`, `workspace/`). It is wired into the parent as a + * delegation tool the model can call by its directory name, `forecaster`. + * + * A subagent's `config.ts` MUST set a non-empty `description` — that text is + * what the parent model sees when deciding whether to delegate. The build fails + * if it is missing. + */ +export default agentConfig({ + model: 'openai/gpt-5.4-mini', + description: 'Produces a multi-day weather forecast for a city.', + // instructions omitted -> taken from instructions.md + // tools omitted -> taken from tools/*.ts +}); diff --git a/examples/agent/src/mastra/agents/weather-fs/subagents/forecaster/instructions.md b/examples/agent/src/mastra/agents/weather-fs/subagents/forecaster/instructions.md new file mode 100644 index 000000000000..9af5a325e28b --- /dev/null +++ b/examples/agent/src/mastra/agents/weather-fs/subagents/forecaster/instructions.md @@ -0,0 +1,5 @@ +You are a forecasting specialist. + +When asked for a forecast, call the `get_forecast` tool for the named city and +summarize the result day by day in a short list. Call out any precipitation. +If no city is named, ask which city. diff --git a/examples/agent/src/mastra/agents/weather-fs/subagents/forecaster/tools/get_forecast.ts b/examples/agent/src/mastra/agents/weather-fs/subagents/forecaster/tools/get_forecast.ts new file mode 100644 index 000000000000..a0a8f56047ce --- /dev/null +++ b/examples/agent/src/mastra/agents/weather-fs/subagents/forecaster/tools/get_forecast.ts @@ -0,0 +1,39 @@ +import { createTool } from '@mastra/core/tools'; +import { z } from 'zod'; + +/** + * File-based tool for the `forecaster` subagent. Discovered the same way as a + * top-level agent's tools — keyed by filename -> `get_forecast`. + */ +export default createTool({ + id: 'get-forecast', + description: 'Fetches a multi-day weather forecast for a given city', + inputSchema: z.object({ + city: z.string().describe('The city to forecast'), + days: z.number().int().min(1).max(7).default(3).describe('Number of days'), + }), + outputSchema: z.object({ + city: z.string(), + days: z.array( + z.object({ + day: z.number(), + conditions: z.string(), + highCelsius: z.number(), + lowCelsius: z.number(), + }), + ), + }), + execute: async ({ city, days }) => { + // Stubbed response — swap in a real API for production use. + const conditions = ['sunny', 'partly cloudy', 'rain', 'clear']; + return { + city, + days: Array.from({ length: days }, (_, i) => ({ + day: i + 1, + conditions: conditions[i % conditions.length]!, + highCelsius: 22 - i, + lowCelsius: 14 - i, + })), + }; + }, +}); diff --git a/examples/agent/src/mastra/agents/weather-fs/tools/get_weather.ts b/examples/agent/src/mastra/agents/weather-fs/tools/get_weather.ts new file mode 100644 index 000000000000..504ea5ad01c9 --- /dev/null +++ b/examples/agent/src/mastra/agents/weather-fs/tools/get_weather.ts @@ -0,0 +1,27 @@ +import { createTool } from '@mastra/core/tools'; +import { z } from 'zod'; + +/** + * File-based tool: the default export is registered on the agent with the tool + * key `get_weather` (the filename). No manual wiring needed. + */ +export default createTool({ + id: 'get-weather', + description: 'Fetches the current weather for a given city', + inputSchema: z.object({ + city: z.string().describe('The city to get weather for'), + }), + outputSchema: z.object({ + city: z.string(), + conditions: z.string(), + temperatureCelsius: z.number(), + }), + execute: async ({ city }) => { + // Stubbed response — swap in a real API for production use. + return { + city, + conditions: 'sunny', + temperatureCelsius: 21, + }; + }, +}); diff --git a/examples/agent/src/mastra/agents/weather-fs/workspace/README.md b/examples/agent/src/mastra/agents/weather-fs/workspace/README.md new file mode 100644 index 000000000000..d434c4ad7463 --- /dev/null +++ b/examples/agent/src/mastra/agents/weather-fs/workspace/README.md @@ -0,0 +1,6 @@ +# weather-fs workspace + +Files in this directory are **seed files**: at build/dev time they are mirrored +into this agent's workspace, so the running agent starts with them on disk. + +`cities.json` lists cities this assistant commonly answers about. diff --git a/examples/agent/src/mastra/agents/weather-fs/workspace/cities.json b/examples/agent/src/mastra/agents/weather-fs/workspace/cities.json new file mode 100644 index 000000000000..957caa35e464 --- /dev/null +++ b/examples/agent/src/mastra/agents/weather-fs/workspace/cities.json @@ -0,0 +1,3 @@ +{ + "cities": ["San Francisco", "New York", "London", "Tokyo", "Sydney"] +} diff --git a/explorations/longmemeval/CHANGELOG.md b/explorations/longmemeval/CHANGELOG.md index 30db0fafaf8a..341ef4ccdb50 100644 --- a/explorations/longmemeval/CHANGELOG.md +++ b/explorations/longmemeval/CHANGELOG.md @@ -1,5 +1,58 @@ # @mastra/longmemeval +## 1.1.3-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + +## 1.1.3-alpha.8 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + +## 1.1.3-alpha.7 + +### Patch Changes + +- Updated dependencies [[`1c3f396`](https://github.com/mastra-ai/mastra/commit/1c3f39617cf62169ee683cf881c102a9b34c7a05), [`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/rag@2.4.0-alpha.0 + - @mastra/core@1.48.0-alpha.7 + +## 1.1.3-alpha.6 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/memory@1.21.3-alpha.2 + +## 1.1.3-alpha.5 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`c607ece`](https://github.com/mastra-ai/mastra/commit/c607eceeda028a80b24d00ee7dae376db73df526), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/memory@1.21.3-alpha.1 + +## 1.1.3-alpha.4 + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + +## 1.1.3-alpha.3 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/memory@1.21.3-alpha.0 + ## 1.1.3-alpha.2 ### Patch Changes diff --git a/explorations/longmemeval/package.json b/explorations/longmemeval/package.json index ed4ff4473392..39ad479601c7 100644 --- a/explorations/longmemeval/package.json +++ b/explorations/longmemeval/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/longmemeval", - "version": "1.1.3-alpha.2", + "version": "1.1.3-alpha.9", "description": "LongMemEval benchmark implementation for Mastra Memory", "scripts": { "test": "vitest", diff --git a/integrations/opencode/CHANGELOG.md b/integrations/opencode/CHANGELOG.md index f5567a1aca3c..d965e1b636c8 100644 --- a/integrations/opencode/CHANGELOG.md +++ b/integrations/opencode/CHANGELOG.md @@ -1,5 +1,57 @@ # @mastra/opencode +## 0.1.3-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + +## 0.1.3-alpha.8 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + +## 0.1.3-alpha.7 + +### Patch Changes + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + +## 0.1.3-alpha.6 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/memory@1.21.3-alpha.2 + +## 0.1.3-alpha.5 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`c607ece`](https://github.com/mastra-ai/mastra/commit/c607eceeda028a80b24d00ee7dae376db73df526), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/memory@1.21.3-alpha.1 + +## 0.1.3-alpha.4 + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + +## 0.1.3-alpha.3 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/memory@1.21.3-alpha.0 + ## 0.1.3-alpha.2 ### Patch Changes diff --git a/integrations/opencode/package.json b/integrations/opencode/package.json index 434441b7deb2..7fd3004447ff 100644 --- a/integrations/opencode/package.json +++ b/integrations/opencode/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/opencode", - "version": "0.1.3-alpha.2", + "version": "0.1.3-alpha.9", "description": "OpenCode plugin for Mastra Observational Memory", "type": "module", "main": "./dist/index.js", diff --git a/mastracode/CHANGELOG.md b/mastracode/CHANGELOG.md index 93e98856f8a4..be1af578f273 100644 --- a/mastracode/CHANGELOG.md +++ b/mastracode/CHANGELOG.md @@ -1,5 +1,220 @@ # mastracode +## 0.27.0-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + - @mastra/react@1.2.1-alpha.9 + - @mastra/server@1.48.0-alpha.9 + - @mastra/hono@1.5.3-alpha.9 + +## 0.27.0-alpha.8 + +### Patch Changes + +- MastraCode now builds its code agent through the new `createCodingAgent` factory from `@mastra/core/coding-agent` instead of constructing the `Agent` inline. No user-facing behavior changes — the system prompt and agent configuration are unchanged. ([#18695](https://github.com/mastra-ai/mastra/pull/18695)) + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + - @mastra/react@1.2.1-alpha.8 + - @mastra/server@1.48.0-alpha.8 + - @mastra/hono@1.5.3-alpha.8 + +## 0.27.0-alpha.7 + +### Minor Changes + +- Reworked headless mode into a real programmatic API. You can now run MastraCode from Node/CI code with `runMC({ controller, session, prompt })`, which returns a handle that streams live events as an async-iterable and resolves to a typed result with status, text, usage, tool calls, and an exit code — it never calls `process.exit` or writes to global streams. The CLI is now a thin adapter over the same runner. ([#18680](https://github.com/mastra-ai/mastra/pull/18680)) + + ```ts + import { createMastraCode, runMC } from 'mastracode'; + + const { controller, session } = await createMastraCode({ settingsPath }); + + const run = runMC({ controller, session, prompt: 'Fix the failing test' }); + for await (const event of run) { + // optional: react to live progress events + } + const result = await run.result; + process.exitCode = result.exitCode; // 0 success, 1 error/aborted/max-turns, 2 timeout + ``` + + Approvals and suspensions are resolved by a pluggable policy (default keeps the previous auto-approve behavior); a built-in `denyPolicy` plus `permissionModeToPolicy` are exported, and a new `--permission-mode {auto|deny}` flag selects between them. A new `--max-turns` flag (and `maxTurns` option) caps assistant turns and reports a `max_turns` status with exit code 1 when the cap is hit mid-task. + + Breaking changes / migration: + - Output flags consolidated: the `--format` and `--output-format` flags are replaced by a single `--output` flag with values `human`, `json`, or `jsonl`. + - Before: `mastracode --prompt "..." --output-format json` + - After: `mastracode --prompt "..." --output json` + - Programmatic entry point changed from the old `runHeadless`/`headlessMain` to `runMC` (pure runner) and `runMCCli` (CLI adapter). `runMC` takes an already-built `controller` + `session` from `createMastraCode` and returns a result object instead of only an exit code. + +- Turned MastraCode Web into a multi-org cloud coding service. When WorkOS auth and a GitHub App are configured, a team can sign in, connect their repos, and run coding agents that branch, commit, push, and open pull requests — all from isolated cloud sandboxes. When the relevant environment variables are absent, the server and UI behave exactly as before (local-path projects, single shared store, no auth UI), so this is fully opt-in. ([#18567](https://github.com/mastra-ai/mastra/pull/18567)) + + **Authentication (WorkOS AuthKit).** Setting `WORKOS_API_KEY` and `WORKOS_CLIENT_ID` protects every route: unauthenticated visitors are redirected to the WorkOS hosted login, signed-in users get an encrypted session, expired sessions bounce back to login, and the sidebar shows the signed-in email with a Sign out button. Users with no WorkOS organization get a personal org bootstrapped on first authenticated use (idempotent, with recovery from partial creations), so personal accounts can use org-scoped features without hand-creating an org. + + **Org-owned GitHub projects.** With the GitHub App env vars set (`GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_ID`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_SLUG`, `APP_DATABASE_URL`), users install/connect the app, pick repos they can access, and turn each into a project. The installation and connected repos belong to the WorkOS organization; the same repo can be connected independently by different orgs with no cross-visibility. Each repo is materialized into an isolated cloud sandbox on open — cloned (or pulled) inside the sandbox with a short-lived installation token that never reaches the browser and is scrubbed from the remote afterward. + + **Cloud coding-agent write-back.** From a connected repo, each user gets their own sandbox, git worktrees, and feature branches. The agent runs against the selected worktree (file edits and commands bind to its path) and can commit, push, and open pull requests via the in-sandbox `gh` CLI, authenticated with short-lived per-operation installation tokens. The sidebar shows a nested project → worktree → conversations tree with a "+ New worktree" affordance; conversations scope per worktree. + + **Sandbox providers.** A provider is selected automatically: Railway when `RAILWAY_API_TOKEN` is set, otherwise a local provider that runs git directly on the host (single-user local dev only — no tenant isolation). `MASTRACODE_SANDBOX_PROVIDER` overrides explicitly. Idle sandboxes are torn down and re-provisioned on the next open; a per-replica live-sandbox cap (`MASTRACODE_MAX_SANDBOXES`) and a per-user teardown route bound resource use. + + **Per-(org,user) state isolation.** Agent state (threads, messages, memory, recall vectors) is isolated by the `(organization, user)` pair, each backed by a dedicated libSQL database whose location is derived server-side from a hash of `(orgId, userId)` — never a client-supplied path. By default each tenant gets local libSQL files under `MASTRACODE_TENANT_DB_ROOT`; for hosted deployments, point each tenant at a remote libSQL/Turso database via `MASTRACODE_TENANT_DB_URL_TEMPLATE` (plus optional vector template and auth tokens). + + **Sandbox isolation hardening.** Commands run in the local sandbox receive only a sanitized allow-list of environment variables (PATH/HOME/locale/git config), so server secrets such as `GITHUB_APP_PRIVATE_KEY`, `WORKOS_API_KEY`, and `APP_DATABASE_URL` are never exposed to code running against an untrusted checkout. Sandbox filesystem write operations (write/append/copy/move/mkdir) now verify the destination's real path — including a symlinked parent directory — stays within the workspace root, preventing a malicious repo's symlink from redirecting writes outside the sandbox. + + **Multi-replica deployment hardening.** Per-(project,user) git writes are serialized across replicas with Postgres advisory locks (`MASTRACODE_DISTRIBUTED_LOCK`, on by default; requires `APP_DATABASE_URL`). OAuth/install state signing requires a replica-stable secret in multi-replica setups, in-memory tenant stacks are evicted by idle timeout and an LRU cap (`MASTRACODE_TENANT_IDLE_MINUTES`, `MASTRACODE_TENANT_MAX_APPS`), and `MASTRACODE_REQUIRE_REMOTE_TENANT_DB=1` fails startup when no shared remote tenant DB is configured. + + ```bash + # Auth + GitHub App (opt-in) + WORKOS_API_KEY=sk_xxxxxxxx + WORKOS_CLIENT_ID=client_xxxxxxxx + GITHUB_APP_ID=123456 + GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" + GITHUB_APP_CLIENT_ID=Iv1.xxxxxxxx + GITHUB_APP_CLIENT_SECRET=xxxxxxxx + GITHUB_APP_SLUG=your-app-slug + APP_DATABASE_URL=postgres://user:pass@host:5432/mastracode_web + + # Multi-replica hosted deployment + GITHUB_APP_WEBHOOK_SECRET=... # replica-stable state signing + MASTRACODE_DISTRIBUTED_LOCK=1 # cross-replica git write locks + MASTRACODE_REQUIRE_REMOTE_TENANT_DB=1 # require shared remote tenant DBs + MASTRACODE_TENANT_DB_URL_TEMPLATE=libsql://{id}-org.turso.io + MASTRACODE_TENANT_IDLE_MINUTES=30 + MASTRACODE_TENANT_MAX_APPS=100 + MASTRACODE_MAX_SANDBOXES=50 + ``` + + Still deferred: collaboration within a project (multiple users sharing one worktree/sandbox/branch), org admin/roles and membership management, and org-level project deletion. + +- Auto-provision a per-tenant Turso database in deployed MastraCode Web environments. ([#18690](https://github.com/mastra-ai/mastra/pull/18690)) + + Previously, hosting per-`(org, user)` agent state on Turso required each tenant's database to already exist at the URL produced by `MASTRACODE_TENANT_DB_URL_TEMPLATE`. There was no way to create those databases on demand, so the only zero-setup option was server-local libSQL files — which are ephemeral and not shared across replicas. + + Setting `MASTRACODE_TURSO_PLATFORM_TOKEN` and `MASTRACODE_TURSO_ORG` now enables a third tenant-storage mode: the first time a tenant is seen, its own Turso database is created via the Turso Platform API (idempotent — an "already exists" race recovers the hostname via `databases.get`), a scoped auth token is minted, and the stable database-name/hostname mapping is persisted in the app Postgres (`tenant_databases` table, requires `APP_DATABASE_URL`). All replicas converge on the same database and cold starts never re-create it. Only the durable mapping is stored; the auth token is minted fresh per resolution, so no long-lived credential is persisted. + + Resolution priority is: explicit `MASTRACODE_TENANT_DB_URL_TEMPLATE` → Turso auto-provisioning → local libSQL files. Turso provisioning also satisfies `MASTRACODE_REQUIRE_REMOTE_TENANT_DB=1`. The `@tursodatabase/api` client is loaded dynamically, so deployments that don't use Turso never pull it in at runtime. + + ```bash + MASTRACODE_TURSO_PLATFORM_TOKEN=... # Turso Platform API token + MASTRACODE_TURSO_ORG=my-org # org that owns provisioned databases + MASTRACODE_TURSO_GROUP=default # optional group (default "default") + APP_DATABASE_URL=postgres://... # required for the mapping table + ``` + +### Patch Changes + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + - @mastra/server@1.48.0-alpha.7 + - @mastra/hono@1.5.3-alpha.7 + - @mastra/react@1.2.1-alpha.7 + +## 0.27.0-alpha.6 + +### Patch Changes + +- Renamed the AgentController interval API. `heartbeatHandlers` is now `intervalHandlers`, the `HeartbeatHandler` type is now `IntervalHandler`, and the `removeHeartbeat()`/`stopHeartbeats()` methods are now `removeInterval()`/`stopIntervals()`. This better reflects that these are fixed-interval background tasks, not liveness pings, and is distinct from the unrelated `mastra.heartbeats` scheduled-agent feature. ([#18665](https://github.com/mastra-ai/mastra/pull/18665)) + + **Before** + + ```ts + const { controller } = await createMastraCode({ + heartbeatHandlers: [{ id: 'sync', intervalMs: 60_000, handler: async () => {} }], + }); + await controller.removeHeartbeat({ id: 'sync' }); + await controller.stopHeartbeats(); + ``` + + **After** + + ```ts + const { controller } = await createMastraCode({ + intervalHandlers: [{ id: 'sync', intervalMs: 60_000, handler: async () => {} }], + }); + await controller.removeInterval({ id: 'sync' }); + await controller.stopIntervals(); + ``` + +- Improved MastraCode web chat so hydrated and streaming messages render consistently, including tool cards, reasoning, and failed-tool states. ([#18620](https://github.com/mastra-ai/mastra/pull/18620)) + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`d0702ee`](https://github.com/mastra-ai/mastra/commit/d0702eedc1594cb2d0d83476440cfc2ec8820adb), [`65a66db`](https://github.com/mastra-ai/mastra/commit/65a66dbe249a0d92d828c605b955e73a983cf3b0), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/schema-compat@1.3.2-alpha.1 + - @mastra/mcp@1.12.1-alpha.0 + - @mastra/react@1.2.1-alpha.6 + - @mastra/memory@1.21.3-alpha.2 + - @mastra/server@1.48.0-alpha.6 + - @mastra/hono@1.5.3-alpha.6 + +## 0.27.0-alpha.5 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`c607ece`](https://github.com/mastra-ai/mastra/commit/c607eceeda028a80b24d00ee7dae376db73df526), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/server@1.48.0-alpha.5 + - @mastra/memory@1.21.3-alpha.1 + - @mastra/hono@1.5.3-alpha.5 + +## 0.27.0-alpha.4 + +### Minor Changes + +- Added voice input to the MastraCode TUI. Enable it with /voice, then hold the spacebar to dictate a prompt and release to finish. Your speech streams into the input in real time as you talk and is transcribed with OpenAI Whisper. The setting persists across restarts. ([#18624](https://github.com/mastra-ai/mastra/pull/18624)) + + Requires an OpenAI API key (set `OPENAI_API_KEY` or configure it via `/api-keys`) and a local audio recorder on your `PATH` — `rec`/`sox` (recommended for live streaming) or `ffmpeg` on macOS, and `pw-record`/`parecord`/`arecord`/`sox` on Linux. + +- Added Amazon Bedrock as a model provider in Mastra Code. Bedrock models surfaced by models.dev are now selectable via `/models` and usable as build/plan/fast or subagent models with the `amazon-bedrock/<modelId>` form. Models are only offered when AWS credentials are detected. ([#17937](https://github.com/mastra-ai/mastra/pull/17937)) + + Bedrock authenticates with AWS SigV4 through the standard AWS credential chain (`fromNodeProviderChain`), so environment variables, shared `~/.aws` profiles, SSO, and container/instance roles all work without extra configuration. Set `AWS_REGION` (defaults to `us-east-1`) to target a region, or `AWS_BEARER_TOKEN_BEDROCK` to use Bedrock API-key auth instead. + + ```sh + # Pick a Bedrock model from the picker + /models + + # Or set it directly via the build/plan/fast slots + /build amazon-bedrock/<modelId> + ``` + +### Patch Changes + +- Amazon Bedrock models now appear under their own `amazon-bedrock/<model>` provider in the model picker instead of the `mastracode/amazon-bedrock/<model>` namespace. Bedrock is resolved through a dedicated Amazon Bedrock gateway that authenticates with the AWS credential chain (SigV4) and surfaces models from the public models.dev catalog. Saved model selections using the previous `mastracode/amazon-bedrock/...` IDs are still resolved at runtime, so existing config keeps working. ([#17937](https://github.com/mastra-ai/mastra/pull/17937)) + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + - @mastra/server@1.48.0-alpha.4 + - @mastra/hono@1.5.3-alpha.4 + +## 0.27.0-alpha.3 + +### Patch Changes + +- Fixed MCP HTTP server URLs so `${VAR}` references resolve from the environment, the same way header values already do. A server configured with `"url": "${MCP_SERVER_URL}"` is now connected instead of being silently skipped as an invalid URL. ([#18579](https://github.com/mastra-ai/mastra/pull/18579)) + +- MCP stdio servers now resolve `${VAR}` references in their `env` values from the host environment, matching the existing behavior for HTTP server headers. You can reference secrets from the environment instead of hardcoding them in `mcp.json`: ([#18529](https://github.com/mastra-ai/mastra/pull/18529)) + + ```json + { + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" } + } + } + } + ``` + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`9feeaa0`](https://github.com/mastra-ai/mastra/commit/9feeaa0f9a1af07039e5b4f22b932b0cb18617e8), [`e05dade`](https://github.com/mastra-ai/mastra/commit/e05dade21c3e2551893ad939e6028cdafd84a1b2), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/schema-compat@1.3.2-alpha.0 + - @mastra/server@1.48.0-alpha.3 + - @mastra/mcp@1.12.0 + - @mastra/memory@1.21.3-alpha.0 + - @mastra/hono@1.5.3-alpha.3 + ## 0.27.0-alpha.2 ### Patch Changes diff --git a/mastracode/README.md b/mastracode/README.md index c5589df813c2..a0f05a980712 100644 --- a/mastracode/README.md +++ b/mastracode/README.md @@ -101,6 +101,7 @@ Select a suggestion with arrow keys and press Tab to insert it. | `/mcp` | Show/reload MCP server connections | | `/sandbox` | Manage allowed paths (add/remove dirs) | | `/permissions` | View/manage tool approval permissions | +| `/plugins` | Install and manage trusted Mastra Code plugins | | `/settings` | General settings (notifications, YOLO, etc.) | | `/yolo` | Toggle YOLO mode (auto-approve all tools) | | `/resource` | Show/switch resource ID (tag for sharing) | @@ -111,6 +112,10 @@ Select a suggestion with arrow keys and press Tab to insert it. | `/help` | Show available commands | | `/exit` | Exit the TUI | +### Plugins + +Use `/plugins` to install and manage trusted local or GitHub plugins. Plugins can add tools, commands, skills, and system instructions. Because plugins execute code inside Mastra Code and their instructions are appended to the agent prompt, only install plugins from sources you trust. + ### Goals Use `/goal <objective>` to have Mastra Code keep working toward an objective across turns. Goals use a judge model to decide whether the goal is complete, should continue, or should wait for an explicit user checkpoint. Configure defaults with `/judge`. @@ -215,6 +220,8 @@ When both are available, Claude Max OAuth takes priority. For **other providers** (OpenAI, Google, etc.), set the corresponding environment variable (e.g., `OPENAI_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY`) or use OAuth where supported. +For **Amazon Bedrock**, mastracode authenticates with AWS SigV4 through the standard AWS credential chain — environment variables (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`), a shared `~/.aws` profile (`AWS_PROFILE`, including SSO), or a container/instance role all work, the same resolution order as the AWS CLI. Set `AWS_REGION` (defaults to `us-east-1`) to choose a region. Select Bedrock models with the `amazon-bedrock/<modelId>` form, where `<modelId>` is any Bedrock model ID surfaced via `/models`. To use Bedrock API-key auth instead of SigV4, set `AWS_BEARER_TOKEN_BEDROCK`. + Credentials are stored alongside the database in `auth.json`. ### Custom providers and models @@ -256,6 +263,129 @@ To save plans to a project-local directory instead, set the `MASTRA_PLANS_DIR` e export MASTRA_PLANS_DIR=.mastracode/plans ``` +### Web UI: optional auth & GitHub projects + +The web UI (`mastracode web`) supports optional WorkOS authentication and a GitHub App +integration. Both are off by default — when their environment variables are absent the web UI +behaves exactly as before. + +**WorkOS auth** — when `WORKOS_API_KEY` and `WORKOS_CLIENT_ID` are set, every route requires a +signed-in user (hosted login + encrypted session): + +```bash +export WORKOS_API_KEY=... +export WORKOS_CLIENT_ID=... +export WORKOS_REDIRECT_URI=https://your-host/auth/callback # optional +export WORKOS_COOKIE_PASSWORD=... # optional (recommended in prod) +``` + +On first authenticated use, a user with no WorkOS organization is automatically given a personal +org (the org is created and the user added as a member), so org-scoped features work without +hand-creating an org in the WorkOS dashboard. The WorkOS API key must be allowed to create +organizations and memberships; if it isn't, bootstrap fails soft (logged) and the user keeps the +`organization_required` response. + +**GitHub projects** — when the GitHub App variables are set _and_ WorkOS auth is enabled, +signed-in users can install the GitHub App, pick repositories, and turn each repo into a project. +The tenant boundary is the **WorkOS organization**: the GitHub App installation and the connected +project (repo) are owned by the org, while each user inside the org gets their own isolated +sandbox, worktrees, branches, and PRs against that repo. The **same repo can be connected +independently by different orgs** without ever seeing each other's projects, sandboxes, or state. +Personal accounts are bootstrapped into a personal org on first use (see above), so they can +connect GitHub projects too; users always get isolated agent state regardless. Repo and project +metadata persist in a separate application Postgres (`APP_DATABASE_URL`): + +```bash +export GITHUB_APP_ID=... +export GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" +export GITHUB_APP_CLIENT_ID=... +export GITHUB_APP_CLIENT_SECRET=... +export GITHUB_APP_SLUG=your-app-slug +export APP_DATABASE_URL=postgres://user:pass@host:5432/db +export GITHUB_APP_REDIRECT_URI=https://your-host/auth/github/callback # optional +``` + +GitHub-backed projects are cloned into an isolated cloud sandbox on open, which requires a +sandbox provider. Railway is the first supported backend: + +```bash +export RAILWAY_API_TOKEN=... +export RAILWAY_ENVIRONMENT_ID=... +export MASTRACODE_SANDBOX_PROVIDER=railway # optional (default when a token is set) +export MASTRACODE_SANDBOX_WORKDIR=/workspace # optional (path inside the sandbox) +export MASTRACODE_SANDBOX_IDLE_MINUTES=30 # optional (idle teardown window; default 30) +``` + +The sandbox template must have `git` and `gh` (the GitHub CLI) installed and outbound network +access to `github.com`. `gh` is only required to open pull requests; clone/open work without it. +Idle sandboxes are stopped by the provider after `MASTRACODE_SANDBOX_IDLE_MINUTES`; the next open +detects the stopped VM and re-provisions automatically. +Without a sandbox provider, users can still connect GitHub and pick repos, but opening a repo +project shows a clear "sandbox not configured" error. + +### Per-(org,user) storage isolation + +When WorkOS web auth is enabled, the tenant boundary is the **(organization, user)** pair: each +user in each org operates against their own dedicated libSQL database for all agent state (threads, +messages, memory, observational memory, recall vectors) — no tenant can read another tenant's data +at the storage layer. Two users in the same org are isolated, and the same user across two orgs is +also isolated. The database location is derived server-side from a hash of `(orgId, userId)` (no +client-supplied paths); users without an org fall back to a user-only key. + +```bash +# Local files (default): one isolated DB dir per tenant under this root +export MASTRACODE_TENANT_DB_ROOT=~/.mastracode/web/tenants # optional + +# Or remote libSQL/Turso per tenant ({id} = hashed (orgId, userId)). This mode +# assumes each tenant DB already exists at the templated URL. +export MASTRACODE_TENANT_DB_URL_TEMPLATE=libsql://{id}-org.turso.io # optional +export MASTRACODE_TENANT_VECTOR_URL_TEMPLATE=libsql://{id}-vec-org.turso.io # optional +export MASTRACODE_TENANT_DB_AUTH_TOKEN=... # optional +export MASTRACODE_TENANT_VECTOR_AUTH_TOKEN=... # optional + +# Or auto-provision a Turso database per tenant on first access via the Turso +# Platform API (no pre-created DBs needed). Requires APP_DATABASE_URL: the +# stable db-name/hostname mapping is persisted there so replicas converge on +# one DB and cold starts don't re-create it. A scoped token is minted fresh per +# resolution, so no long-lived credential is stored. +export MASTRACODE_TURSO_PLATFORM_TOKEN=... # optional +export MASTRACODE_TURSO_ORG=my-org # optional +export MASTRACODE_TURSO_GROUP=default # optional (default "default") +``` + +Resolution priority: explicit `MASTRACODE_TENANT_DB_URL_TEMPLATE` → Turso +auto-provisioning (when the platform token + org are set) → local libSQL files. + +When web auth is disabled the server uses a single shared store, exactly as before. + +### Multi-replica deployment + +The web server keeps each tenant's Mastra stack in an in-memory cache and serializes per-user git +write operations. For hosted, multi-replica deployments a few settings make this safe and bounded: + +```bash +# Replica-stable state signing — REQUIRED across replicas. Without an explicit +# GITHUB_APP_WEBHOOK_SECRET (or WORKOS_COOKIE_PASSWORD) the OAuth/install state +# is signed with a per-process random key and callbacks fail on other replicas. +export GITHUB_APP_WEBHOOK_SECRET=... + +# Cross-replica serialization of per-(project,user) git writes via Postgres +# advisory locks (default on, requires APP_DATABASE_URL). Set 0 for local dev. +export MASTRACODE_DISTRIBUTED_LOCK=1 + +# Persist/share tenant DBs across replicas — fail/warn at startup if no remote +# tenant DB backend (URL template OR Turso auto-provisioning) is configured +# (local-file DBs don't survive restarts or sharing). +export MASTRACODE_REQUIRE_REMOTE_TENANT_DB=1 + +# Bound in-memory tenant caches as the team grows. +export MASTRACODE_TENANT_IDLE_MINUTES=30 # idle eviction window (0 disables) +export MASTRACODE_TENANT_MAX_APPS=100 # LRU cap on cached stacks (0 disables) + +# Per-replica cap on concurrently live sandboxes (0 / unset = unlimited). +export MASTRACODE_MAX_SANDBOXES=50 +``` + ## Architecture ``` diff --git a/mastracode/docker-compose.yml b/mastracode/docker-compose.yml new file mode 100644 index 000000000000..50ad3867ae83 --- /dev/null +++ b/mastracode/docker-compose.yml @@ -0,0 +1,32 @@ +# Local-dev Postgres for the MastraCode Web GitHub App feature. +# +# This is the separate "application database" referenced by APP_DATABASE_URL +# (distinct from Mastra's own storage). The web server runs the GitHub schema +# migrations automatically on boot when the feature is enabled. +# +# Usage: +# docker compose up -d +# export APP_DATABASE_URL=postgres://user:pass@localhost:54329/mastracode_web +# +# The default credentials below match the APP_DATABASE_URL example in +# src/web/.env.example. +services: + app-db: + image: postgres:16 + container_name: mastracode-web-db + ports: + - '54329:5432' + environment: + POSTGRES_USER: ${POSTGRES_USER:-user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pass} + POSTGRES_DB: ${POSTGRES_DB:-mastracode_web} + volumes: + - mastracode-web-pgdata:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-user} -d ${POSTGRES_DB:-mastracode_web}'] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + mastracode-web-pgdata: diff --git a/mastracode/e2e/fixtures/plugins-assets-loading.json b/mastracode/e2e/fixtures/plugins-assets-loading.json new file mode 100644 index 000000000000..82d536d38c31 --- /dev/null +++ b/mastracode/e2e/fixtures/plugins-assets-loading.json @@ -0,0 +1,24 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "E2E plugin bundled command executed.\n\nARGUMENTS:", + "model": "gpt-5.4-mini", + "endpoint": "chat" + }, + "response": { + "content": "MC plugin bundled command response" + } + }, + { + "match": { + "userMessage": "E2E plugin bundled skill instructions.", + "model": "gpt-5.4-mini", + "endpoint": "chat" + }, + "response": { + "content": "MC plugin bundled skill response" + } + } + ] +} diff --git a/mastracode/e2e/fixtures/plugins-github-poll-update.json b/mastracode/e2e/fixtures/plugins-github-poll-update.json new file mode 100644 index 000000000000..3e6b36a56c6d --- /dev/null +++ b/mastracode/e2e/fixtures/plugins-github-poll-update.json @@ -0,0 +1,62 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "Call the GitHub plugin before update.", + "model": "gpt-5.4-mini", + "endpoint": "chat", + "hasToolResult": false + }, + "response": { + "toolCalls": [ + { + "id": "call_github_poll_before", + "name": "e2e_plugin_lookup", + "arguments": { + "query": "before" + } + } + ] + } + }, + { + "match": { + "hasToolResult": true, + "toolCallId": "call_github_poll_before", + "model": "gpt-5.4-mini", + "endpoint": "chat" + }, + "response": { + "content": "GitHub poll plugin follow-up complete." + } + }, + { + "match": { + "model": "gpt-5.4-mini", + "endpoint": "chat" + }, + "response": { + "toolCalls": [ + { + "id": "call_github_poll_after", + "name": "e2e_plugin_lookup", + "arguments": { + "query": "after" + } + } + ] + } + }, + { + "match": { + "hasToolResult": true, + "toolCallId": "call_github_poll_after", + "model": "gpt-5.4-mini", + "endpoint": "chat" + }, + "response": { + "content": "GitHub poll plugin follow-up complete." + } + } + ] +} diff --git a/mastracode/e2e/fixtures/plugins-local-hot-reload.json b/mastracode/e2e/fixtures/plugins-local-hot-reload.json new file mode 100644 index 000000000000..ee66cddb0078 --- /dev/null +++ b/mastracode/e2e/fixtures/plugins-local-hot-reload.json @@ -0,0 +1,62 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "Call the hot reload plugin before edit.", + "model": "gpt-5.4-mini", + "endpoint": "chat", + "hasToolResult": false + }, + "response": { + "toolCalls": [ + { + "id": "call_hot_reload_before", + "name": "e2e_plugin_lookup", + "arguments": { + "query": "before" + } + } + ] + } + }, + { + "match": { + "hasToolResult": true, + "toolCallId": "call_hot_reload_before", + "model": "gpt-5.4-mini", + "endpoint": "chat" + }, + "response": { + "content": "Hot reload plugin returned version-one." + } + }, + { + "match": { + "model": "gpt-5.4-mini", + "endpoint": "chat" + }, + "response": { + "toolCalls": [ + { + "id": "call_hot_reload_after", + "name": "e2e_plugin_lookup", + "arguments": { + "query": "after" + } + } + ] + } + }, + { + "match": { + "hasToolResult": true, + "toolCallId": "call_hot_reload_after", + "model": "gpt-5.4-mini", + "endpoint": "chat" + }, + "response": { + "content": "Hot reload plugin follow-up complete." + } + } + ] +} diff --git a/mastracode/e2e/fixtures/plugins-local-tool.json b/mastracode/e2e/fixtures/plugins-local-tool.json new file mode 100644 index 000000000000..921061b5e37c --- /dev/null +++ b/mastracode/e2e/fixtures/plugins-local-tool.json @@ -0,0 +1,14 @@ +{ + "fixtures": [ + { + "match": { + "endpoint": "chat", + "model": "gpt-5.4-mini", + "userMessage": "Use the local plugin tool availability check." + }, + "response": { + "content": "Plugin tool availability verified." + } + } + ] +} diff --git a/mastracode/e2e/fixtures/plugins-scaffold-install-tool.json b/mastracode/e2e/fixtures/plugins-scaffold-install-tool.json new file mode 100644 index 000000000000..c55ebfb8ec2a --- /dev/null +++ b/mastracode/e2e/fixtures/plugins-scaffold-install-tool.json @@ -0,0 +1,34 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "Use the scaffolded example plugin tool.", + "model": "gpt-5.4-mini", + "endpoint": "chat", + "hasToolResult": false + }, + "response": { + "toolCalls": [ + { + "id": "call_scaffolded_example_tool", + "name": "example_tool", + "arguments": { + "message": "hello from scaffold" + } + } + ] + } + }, + { + "match": { + "hasToolResult": true, + "toolCallId": "call_scaffolded_example_tool", + "model": "gpt-5.4-mini", + "endpoint": "chat" + }, + "response": { + "content": "Scaffolded example tool returned hello from scaffold." + } + } + ] +} diff --git a/mastracode/e2e/fixtures/plugins-streaming-tool-output.json b/mastracode/e2e/fixtures/plugins-streaming-tool-output.json new file mode 100644 index 000000000000..fc00d43d843b --- /dev/null +++ b/mastracode/e2e/fixtures/plugins-streaming-tool-output.json @@ -0,0 +1,34 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "Use the streaming plugin tool.", + "model": "gpt-5.4-mini", + "endpoint": "chat", + "hasToolResult": false + }, + "response": { + "toolCalls": [ + { + "id": "call_streaming_plugin_tool", + "name": "e2e_plugin_lookup", + "arguments": { + "query": "stream progress" + } + } + ] + } + }, + { + "match": { + "hasToolResult": true, + "toolCallId": "call_streaming_plugin_tool", + "model": "gpt-5.4-mini", + "endpoint": "chat" + }, + "response": { + "content": "Streaming plugin tool completed." + } + } + ] +} diff --git a/mastracode/e2e/fixtures/work-idle-status.json b/mastracode/e2e/fixtures/work-idle-status.json new file mode 100644 index 000000000000..986bc27e222f --- /dev/null +++ b/mastracode/e2e/fixtures/work-idle-status.json @@ -0,0 +1,19 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "Run a slow work idle status check.", + "model": "gpt-5.4-mini", + "endpoint": "chat" + }, + "chunkSize": 4, + "streamingProfile": { + "ttft": 500, + "tps": 4 + }, + "response": { + "content": "Work idle status response complete." + } + } + ] +} diff --git a/mastracode/e2e/terminal-backend.ts b/mastracode/e2e/terminal-backend.ts index 8e1834a2a0f3..f388f494665e 100644 --- a/mastracode/e2e/terminal-backend.ts +++ b/mastracode/e2e/terminal-backend.ts @@ -371,6 +371,7 @@ async function startMastraCodeApp( hookManager: result.hookManager, authStorage: result.authStorage, mcpManager: result.mcpManager, + pluginManager: result.pluginManager, appName: 'Mastra Code', version: process.env.npm_package_version ?? 'mc-e2e-terminal', inlineQuestions: true, @@ -378,6 +379,7 @@ async function startMastraCodeApp( terminal, ...(options?.tui ?? {}), }); + await options?.onTuiCreated?.(tui); void tui.run().catch(error => { process.stderr.write(`[mc-e2e:terminal] TUI run failed: ${error instanceof Error ? error.stack : String(error)}\n`); @@ -398,7 +400,7 @@ async function startMastraCodeApp( await Promise.allSettled([ result.mcpManager?.disconnect(), result.controller.getMastra()?.stopWorkers(), - result.controller.stopHeartbeats(), + result.controller.stopIntervals(), closeSignalsPubSub?.(), ]); }, diff --git a/mastracode/e2e/tui/headless-mcp-tool-availability.ts b/mastracode/e2e/tui/headless-mcp-tool-availability.ts index 92c1133dad72..f1d225c36803 100644 --- a/mastracode/e2e/tui/headless-mcp-tool-availability.ts +++ b/mastracode/e2e/tui/headless-mcp-tool-availability.ts @@ -47,8 +47,8 @@ async function runHeadlessInProcess(terminal: { write: (text: string) => void }) 'headless-mcp-e2e', '--prompt', 'Use the delayed headless MCP lookup tool and report its payload.', - '--output-format', - 'text', + '--output', + 'human', '--timeout', '30', ]; @@ -78,8 +78,8 @@ async function runHeadlessInProcess(terminal: { write: (text: string) => void }) }) as typeof process.exit; try { - const { headlessMain } = await import('../../src/headless.js'); - await headlessMain(); + const { runMCCli } = await import('../../src/headless/index.js'); + await runMCCli(); } catch (error) { if (!(error instanceof Error) || !error.message.startsWith('MC_E2E_HEADLESS_EXIT:0')) throw error; } finally { diff --git a/mastracode/e2e/tui/index.ts b/mastracode/e2e/tui/index.ts index 1ec9169d0972..b4e3e9fadcc9 100644 --- a/mastracode/e2e/tui/index.ts +++ b/mastracode/e2e/tui/index.ts @@ -73,6 +73,16 @@ import { persistentGoalReloadScenario } from './persistent-goal-reload.js'; import { planApprovalGoalHandoffScenario } from './plan-approval-goal-handoff.js'; import { planApprovalHandoffScenario } from './plan-approval-handoff.js'; import { planApprovalRequestChangesScenario } from './plan-approval-request-changes.js'; +import { + pluginsAssetsLoadingScenario, + pluginsBlockedConfigScenario, + pluginsCommandUiScenario, + pluginsGithubPollUpdateScenario, + pluginsLocalHotReloadScenario, + pluginsLocalToolScenario, + pluginsScaffoldInstallToolScenario, + pluginsStreamingToolOutputScenario, +} from './plugins.js'; import { processShortcutsScenario } from './process-shortcuts.js'; import { promptContextInstructionsScenario } from './prompt-context-instructions.js'; import { promptQueueInterleaveScenario } from './prompt-queue-interleave.js'; @@ -122,6 +132,7 @@ import { updateCommandPromptScenario } from './update-command-prompt.js'; import { updateStartupPromptScenario } from './update-startup-prompt.js'; import { visibleCommandsScenario } from './visible-commands.js'; import { webSearchRenderingScenario } from './web-search-rendering.js'; +import { workIdleStatusScenario } from './work-idle-status.js'; import { workspaceCommandsScenario } from './workspace-commands.js'; import { workspacePlanModeToolsScenario } from './workspace-plan-mode-tools.js'; import { workspaceToolNamesScenario } from './workspace-tool-names.js'; @@ -209,6 +220,14 @@ export const scenarios: Record<ScenarioName, McE2eScenario> = { 'plan-approval-goal-handoff': planApprovalGoalHandoffScenario, 'plan-approval-handoff': planApprovalHandoffScenario, 'plan-approval-request-changes': planApprovalRequestChangesScenario, + 'plugins-local-tool': pluginsLocalToolScenario, + 'plugins-local-hot-reload': pluginsLocalHotReloadScenario, + 'plugins-github-poll-update': pluginsGithubPollUpdateScenario, + 'plugins-blocked-config': pluginsBlockedConfigScenario, + 'plugins-scaffold-install-tool': pluginsScaffoldInstallToolScenario, + 'plugins-streaming-tool-output': pluginsStreamingToolOutputScenario, + 'plugins-assets-loading': pluginsAssetsLoadingScenario, + 'plugins-command-ui': pluginsCommandUiScenario, 'process-shortcuts': processShortcutsScenario, 'provider-history-compat': providerHistoryCompatScenario, 'provider-history-rejection-retry': providerHistoryRejectionRetryScenario, @@ -257,6 +276,7 @@ export const scenarios: Record<ScenarioName, McE2eScenario> = { 'workspace-plan-mode-tools': workspacePlanModeToolsScenario, 'workspace-tool-names': workspaceToolNamesScenario, 'workspace-tool-output-rendering': workspaceToolOutputRenderingScenario, + 'work-idle-status': workIdleStatusScenario, 'resourceid-drift-prompt-accept': resourceidDriftPromptAcceptScenario, 'resourceid-drift-prompt-decline': resourceidDriftPromptDeclineScenario, 'worktree-cross-thread-resume': worktreeCrossThreadResumeScenario, diff --git a/mastracode/e2e/tui/plugins.ts b/mastracode/e2e/tui/plugins.ts new file mode 100644 index 000000000000..0e089d2d5a18 --- /dev/null +++ b/mastracode/e2e/tui/plugins.ts @@ -0,0 +1,654 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { scaffoldPlugin } from '../../src/plugins/scaffold.js'; +import type { McE2ePrepareContext, McE2eScenario } from './types.js'; +import { typeTextSlowly } from './typing-utils.js'; + +const MASTRACODE_PACKAGE_DIR = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); + +const PLUGIN_ID = 'e2e.local-plugin'; +const PLUGIN_NAME = 'E2E Local Plugin'; +const TOOL_NAME = 'e2e_plugin_lookup'; +const PROMPT = 'Use the local plugin tool availability check.'; +const RESPONSE = 'Plugin tool availability verified.'; + +let currentTui: unknown; +let hotReloadPluginDir: string | undefined; +let githubPollSourceDir: string | undefined; +let githubPollManager: { pollGithubSourcesForUpdates: () => Promise<boolean> } | undefined; + +function resetPluginScenarioState(): void { + currentTui = undefined; + hotReloadPluginDir = undefined; + githubPollSourceDir = undefined; + githubPollManager = undefined; +} + +function writeLocalPlugin({ projectDir }: Pick<McE2ePrepareContext, 'projectDir'>): string { + const pluginDir = join(projectDir, 'fixtures', 'plugins', 'local-plugin'); + const pluginSrcDir = join(pluginDir, 'src'); + mkdirSync(pluginSrcDir, { recursive: true }); + writePluginPackageLink(pluginDir); + + writeFileSync( + join(pluginSrcDir, 'index.ts'), + `import { createTool, defineMastraCodePlugin, z } from 'mastracode/plugin'; + +export default defineMastraCodePlugin({ + id: '${PLUGIN_ID}', + name: '${PLUGIN_NAME}', + description: 'Plugin used by Mastra Code E2E tests.', + tools: { + ${TOOL_NAME}: { + tool: createTool({ + id: '${TOOL_NAME}', + description: 'Return an E2E plugin lookup result.', + inputSchema: z.object({ query: z.string() }), + execute: async ({ context }) => ({ query: context.query, source: '${PLUGIN_ID}' }), + }), + }, + }, +}); +`, + ); + + return pluginDir; +} + +function writeAssetPlugin({ projectDir }: Pick<McE2ePrepareContext, 'projectDir'>): string { + const pluginDir = join(projectDir, 'fixtures', 'plugins', 'asset-plugin'); + const pluginSrcDir = join(pluginDir, 'src'); + const commandsDir = join(pluginDir, 'commands'); + const skillDir = join(pluginDir, 'skills', 'e2e-plugin-asset-skill'); + mkdirSync(pluginSrcDir, { recursive: true }); + mkdirSync(commandsDir, { recursive: true }); + mkdirSync(skillDir, { recursive: true }); + writePluginPackageLink(pluginDir); + + writeFileSync( + join(pluginSrcDir, 'index.ts'), + `import { defineMastraCodePlugin } from 'mastracode/plugin'; + +export default defineMastraCodePlugin({ + id: '${PLUGIN_ID}', + name: '${PLUGIN_NAME}', + description: 'Plugin used by Mastra Code E2E asset loading tests.', + tools: {}, +}); +`, + ); + writeFileSync( + join(commandsDir, 'e2e-plugin-assets.md'), + `---\ndescription: E2E plugin bundled command autocomplete description\n---\nE2E plugin bundled command executed.\n\nARGUMENTS: $ARGUMENTS\n`, + ); + writeFileSync( + join(skillDir, 'SKILL.md'), + `---\nname: e2e-plugin-asset-skill\ndescription: E2E plugin bundled skill autocomplete description\nuser-invocable: true\n---\nE2E plugin bundled skill instructions.\n`, + ); + + return pluginDir; +} + +function writeStreamingPlugin({ projectDir }: Pick<McE2ePrepareContext, 'projectDir'>): string { + const pluginDir = join(projectDir, 'fixtures', 'plugins', 'streaming-plugin'); + const pluginSrcDir = join(pluginDir, 'src'); + mkdirSync(pluginSrcDir, { recursive: true }); + writePluginPackageLink(pluginDir); + + writeFileSync( + join(pluginSrcDir, 'index.ts'), + `import { createTool, defineMastraCodePlugin, writeToolProgress, z } from 'mastracode/plugin'; + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + +export default defineMastraCodePlugin({ + id: '${PLUGIN_ID}', + name: '${PLUGIN_NAME}', + description: 'Plugin used by Mastra Code E2E tests.', + tools: { + ${TOOL_NAME}: { + tool: createTool({ + id: '${TOOL_NAME}', + description: 'Stream progress before returning an E2E plugin lookup result.', + inputSchema: z.object({ query: z.string() }), + execute: async (input, toolContext) => { + await writeToolProgress(toolContext, { event: 'text', text: 'E2E plugin progress visible before completion' }); + await sleep(1500); + return { query: input.query, source: '${PLUGIN_ID}', done: true }; + }, + }), + render: { type: 'subagent', agentType: 'e2e-plugin' }, + }, + }, +}); +`, + ); + + return pluginDir; +} + +function writeHotReloadPlugin({ projectDir }: Pick<McE2ePrepareContext, 'projectDir'>, result: string): string { + const pluginDir = join(projectDir, 'fixtures', 'plugins', 'hot-reload-plugin'); + writeHotReloadPluginSource(pluginDir, result); + return pluginDir; +} + +function writeHotReloadPluginSource(pluginDir: string, result: string): void { + const pluginSrcDir = join(pluginDir, 'src'); + mkdirSync(pluginSrcDir, { recursive: true }); + writePluginPackageLink(pluginDir); + + writeFileSync( + join(pluginSrcDir, 'index.ts'), + `import { createTool, defineMastraCodePlugin, z } from 'mastracode/plugin'; + +export default defineMastraCodePlugin({ + id: '${PLUGIN_ID}', + name: '${PLUGIN_NAME}', + description: 'Plugin used by Mastra Code E2E hot reload tests.', + tools: { + ${TOOL_NAME}: { + tool: createTool({ + id: '${TOOL_NAME}', + description: 'Return the current hot reload plugin result.', + inputSchema: z.object({ query: z.string() }), + execute: async input => ({ query: input.query, result: '${result}' }), + }), + }, + }, +}); +`, + ); +} + +function writePluginPackageLink(pluginDir: string): void { + const nodeModulesDir = join(pluginDir, 'node_modules'); + mkdirSync(nodeModulesDir, { recursive: true }); + try { + symlinkSync(MASTRACODE_PACKAGE_DIR, join(nodeModulesDir, 'mastracode'), 'dir'); + } catch (error) { + if (!error || typeof error !== 'object' || (error as { code?: string }).code !== 'EEXIST') { + throw error; + } + } +} + +function writePluginRegistry( + projectDir: string, + pluginDir: string, + enabled = true, + disabledPlugins: string[] = [], +): void { + const registryDir = join(projectDir, '.mastracode', 'plugins'); + mkdirSync(registryDir, { recursive: true }); + writeFileSync( + join(registryDir, 'plugins.json'), + JSON.stringify( + { + disabledPlugins, + plugins: { + [PLUGIN_ID]: { + enabled, + source: 'local', + specifier: pluginDir, + path: pluginDir, + entry: 'src/index.ts', + }, + }, + }, + null, + 2, + ), + ); +} + +function writeGithubPluginRegistry(projectDir: string, checkoutName: string): void { + const registryDir = join(projectDir, '.mastracode', 'plugins'); + mkdirSync(registryDir, { recursive: true }); + writeFileSync( + join(registryDir, 'plugins.json'), + JSON.stringify( + { + plugins: { + [PLUGIN_ID]: { + enabled: true, + source: 'github', + specifier: 'https://github.com/acme/github-poll-plugin', + path: `sources/github/${checkoutName}`, + entry: 'src/index.ts', + }, + }, + }, + null, + 2, + ), + ); +} + +function git(cwd: string, args: string[]): void { + execFileSync('git', args, { cwd, stdio: 'ignore' }); +} + +function prepareGithubPollPlugin(projectDir: string): string { + const sourceDir = join(projectDir, 'fixtures', 'plugins', 'github-poll-source'); + const remoteDir = join(projectDir, 'fixtures', 'plugins', 'github-poll-remote.git'); + const checkoutName = 'acme-github-poll-plugin'; + const checkoutDir = join(projectDir, '.mastracode', 'plugins', 'sources', 'github', checkoutName); + + writeHotReloadPluginSource(sourceDir, 'version-one'); + git(sourceDir, ['init', '-b', 'main']); + git(sourceDir, ['config', 'user.email', 'e2e@example.com']); + git(sourceDir, ['config', 'user.name', 'Mastra Code E2E']); + git(sourceDir, ['add', '.']); + git(sourceDir, ['commit', '-m', 'initial plugin']); + git(projectDir, ['init', '--bare', remoteDir]); + git(sourceDir, ['remote', 'add', 'origin', remoteDir]); + git(sourceDir, ['push', '-u', 'origin', 'main']); + mkdirSync(dirname(checkoutDir), { recursive: true }); + git(projectDir, ['clone', '-b', 'main', remoteDir, checkoutDir]); + writeGithubPluginRegistry(projectDir, checkoutName); + + return sourceDir; +} + +function pushGithubPollPluginUpdate(sourceDir: string): void { + writeHotReloadPluginSource(sourceDir, 'version-two'); + git(sourceDir, ['add', '.']); + git(sourceDir, ['commit', '-m', 'update plugin result']); + git(sourceDir, ['push']); +} + +function getToolNames(requests: unknown[]): string[] { + const names = new Set<string>(); + for (const request of requests as Array<{ body?: { tools?: unknown[] } }>) { + for (const tool of request.body?.tools ?? []) { + const name = + (tool as { function?: { name?: unknown }; name?: unknown }).function?.name ?? (tool as { name?: unknown }).name; + if (typeof name === 'string') names.add(name); + } + } + return [...names].sort(); +} + +export const pluginsLocalToolScenario: McE2eScenario = { + name: 'plugins-local-tool', + description: 'Loads a project-local TypeScript plugin and advertises its Mastra tool to the model request.', + testName: 'advertises local plugin tools to model requests', + useOpenAIModel: true, + aimockFixture: 'plugins-local-tool.json', + prepare({ projectDir }) { + resetPluginScenarioState(); + const pluginDir = writeLocalPlugin({ projectDir }); + writePluginRegistry(projectDir, pluginDir); + }, + async inProcessApp({ homeDir, projectDir, startMastraCodeApp }) { + const { PluginManager } = await import('../../src/plugins/manager.js'); + return startMastraCodeApp({ + config: { + pluginManager: new PluginManager({ projectRoot: projectDir, configDir: '.mastracode', homeDir }), + }, + onTuiCreated: tui => { + currentTui = tui; + }, + }); + }, + async run({ terminal, runtime }) { + runtime.startLiveOutput(terminal); + await runtime.waitForScreenText(/Resource ID:/i, terminal); + + terminal.submit(PROMPT); + await runtime.waitForScreenText(new RegExp(RESPONSE), terminal); + + terminal.keyCtrlC(); + runtime.printScreen('after Ctrl-C', terminal); + }, + verifyAimockRequests(requests) { + const names = getToolNames(requests); + if (!names.includes(TOOL_NAME)) { + throw new Error(`Expected provider request to expose plugin tool ${TOOL_NAME}. Names: ${names.join(', ')}`); + } + }, +}; + +export const pluginsStreamingToolOutputScenario: McE2eScenario = { + name: 'plugins-streaming-tool-output', + description: 'Streams progress from an installed plugin tool into the TUI before the tool completes.', + testName: 'streams installed plugin tool progress before final result', + useOpenAIModel: true, + aimockFixture: 'plugins-streaming-tool-output.json', + prepare({ projectDir }) { + resetPluginScenarioState(); + const pluginDir = writeStreamingPlugin({ projectDir }); + writePluginRegistry(projectDir, pluginDir); + }, + async inProcessApp({ homeDir, projectDir, startMastraCodeApp }) { + const { PluginManager } = await import('../../src/plugins/manager.js'); + return startMastraCodeApp({ + config: { + pluginManager: new PluginManager({ projectRoot: projectDir, configDir: '.mastracode', homeDir }), + }, + }); + }, + async run({ terminal, runtime }) { + runtime.startLiveOutput(terminal); + await runtime.waitForScreenText(/Resource ID:/i, terminal); + + terminal.submit('Use the streaming plugin tool.'); + await runtime.waitForScreenText(/E2E plugin progress visible before completion/i, terminal, 10_000); + await runtime.waitForScreenText(/Streaming plugin tool completed/i, terminal, 10_000); + + terminal.keyCtrlC(); + }, + verifyAimockRequests(requests) { + const names = getToolNames(requests); + if (!names.includes(TOOL_NAME)) { + throw new Error( + `Expected provider request to expose streaming plugin tool ${TOOL_NAME}. Names: ${names.join(', ')}`, + ); + } + }, +}; + +export const pluginsScaffoldInstallToolScenario: McE2eScenario = { + name: 'plugins-scaffold-install-tool', + description: 'Scaffolds a plugin, installs it through /plugins, and executes its example tool.', + testName: 'scaffolds installs and executes a plugin tool through the TUI', + useOpenAIModel: true, + aimockFixture: 'plugins-scaffold-install-tool.json', + prepare({ projectDir }) { + resetPluginScenarioState(); + scaffoldPlugin('scaffolded-e2e-plugin', { + projectRoot: projectDir, + id: 'e2e.scaffolded-plugin', + name: 'E2E Scaffolded Plugin', + }); + }, + async inProcessApp({ homeDir, projectDir, startMastraCodeApp }) { + const { PluginManager } = await import('../../src/plugins/manager.js'); + return startMastraCodeApp({ + config: { + pluginManager: new PluginManager({ projectRoot: projectDir, configDir: '.mastracode', homeDir }), + }, + }); + }, + async run({ terminal, runtime }) { + runtime.startLiveOutput(terminal); + await runtime.waitForScreenText(/Resource ID:/i, terminal); + + terminal.submit('/plugins'); + await runtime.waitForScreenText(/Install new plugin/i, terminal, 8_000); + terminal.write('\r'); + await runtime.waitForScreenText(/Install plugin from:/i, terminal, 8_000); + terminal.write('\r'); + await runtime.waitForScreenText(/Local plugin path or discovered plugin:/i, terminal, 8_000); + terminal.write('\r'); + await runtime.waitForScreenText(/Install scope:/i, terminal, 8_000); + terminal.write('\r'); + await runtime.waitForScreenText(/Plugins run code inside Mastra Code/i, terminal, 8_000); + terminal.write('\r'); + await runtime.waitForScreenText(/E2E Scaffolded Plugin/i, terminal, 8_000); + await runtime.waitForScreenText(/active/i, terminal, 8_000); + terminal.write('\x1b'); + + terminal.submit('Use the scaffolded example plugin tool.'); + await runtime.waitForScreenText(/Scaffolded example tool returned hello from scaffold/i, terminal, 10_000); + + terminal.keyCtrlC(); + }, + verifyAimockRequests(requests) { + const names = getToolNames(requests); + if (!names.includes('example_tool')) { + throw new Error(`Expected provider request to expose scaffolded example_tool. Names: ${names.join(', ')}`); + } + }, +}; + +export const pluginsLocalHotReloadScenario: McE2eScenario = { + name: 'plugins-local-hot-reload', + description: 'Reloads an installed local plugin after source edits without restarting Mastra Code.', + testName: 'hot reloads a local plugin in the same TUI session', + useOpenAIModel: true, + aimockFixture: 'plugins-local-hot-reload.json', + prepare({ projectDir }) { + resetPluginScenarioState(); + hotReloadPluginDir = writeHotReloadPlugin({ projectDir }, 'version-one'); + writePluginRegistry(projectDir, hotReloadPluginDir); + }, + async inProcessApp({ homeDir, projectDir, startMastraCodeApp }) { + const { PluginManager } = await import('../../src/plugins/manager.js'); + return startMastraCodeApp({ + config: { + pluginManager: new PluginManager({ projectRoot: projectDir, configDir: '.mastracode', homeDir }), + }, + }); + }, + async run({ terminal, runtime }) { + runtime.startLiveOutput(terminal); + await runtime.waitForScreenText(/Resource ID:/i, terminal); + + terminal.submit('Call the hot reload plugin before edit.'); + await runtime.waitForScreenText(/version-one/i, terminal, 10_000); + + if (!hotReloadPluginDir) throw new Error('Hot reload plugin directory was not prepared'); + writeHotReloadPluginSource(hotReloadPluginDir, 'version-two'); + + terminal.submit('Call the hot reload plugin after edit.'); + await runtime.waitForScreenText(/version-two/i, terminal, 10_000); + + terminal.keyCtrlC(); + }, + verifyAimockRequests(requests) { + const names = getToolNames(requests); + if (!names.includes(TOOL_NAME)) { + throw new Error( + `Expected provider request to expose hot reload plugin tool ${TOOL_NAME}. Names: ${names.join(', ')}`, + ); + } + // The run() screen assertions wait for version-one and version-two, while the second AIMock follow-up response + // deliberately avoids version-two. That ensures the visible updated version comes from the plugin tool result. + }, +}; + +export const pluginsGithubPollUpdateScenario: McE2eScenario = { + name: 'plugins-github-poll-update', + description: 'Polls a GitHub-installed plugin checkout and reloads updated tool code in the same TUI session.', + testName: 'polls GitHub plugin updates in the same TUI session', + useOpenAIModel: true, + aimockFixture: 'plugins-github-poll-update.json', + prepare({ projectDir }) { + resetPluginScenarioState(); + githubPollSourceDir = prepareGithubPollPlugin(projectDir); + }, + async inProcessApp({ homeDir, projectDir, startMastraCodeApp }) { + const { PluginManager } = await import('../../src/plugins/manager.js'); + const manager = new PluginManager({ projectRoot: projectDir, configDir: '.mastracode', homeDir }); + githubPollManager = manager; + return startMastraCodeApp({ + config: { + pluginManager: manager, + }, + }); + }, + async run({ terminal, runtime }) { + runtime.startLiveOutput(terminal); + await runtime.waitForScreenText(/Resource ID:/i, terminal); + + terminal.submit('Call the GitHub plugin before update.'); + await runtime.waitForScreenText(/version-one/i, terminal, 10_000); + + if (!githubPollSourceDir) throw new Error('GitHub poll plugin source directory was not prepared'); + if (!githubPollManager) throw new Error('GitHub poll plugin manager was not initialized'); + pushGithubPollPluginUpdate(githubPollSourceDir); + const changed = await githubPollManager.pollGithubSourcesForUpdates(); + if (!changed) throw new Error('Expected GitHub plugin poll to detect an update'); + + terminal.submit('Call the GitHub plugin after update.'); + await runtime.waitForScreenText(/version-two/i, terminal, 10_000); + + terminal.keyCtrlC(); + }, + verifyAimockRequests(requests) { + const names = getToolNames(requests); + if (!names.includes(TOOL_NAME)) { + throw new Error( + `Expected provider request to expose GitHub poll plugin tool ${TOOL_NAME}. Names: ${names.join(', ')}`, + ); + } + // The run() screen assertions wait for version-one and version-two, while the AIMock fixture responses deliberately + // avoid those strings. That ensures the visible text comes from the plugin tool results, not mocked model prose. + }, +}; + +export const pluginsBlockedConfigScenario: McE2eScenario = { + name: 'plugins-blocked-config', + description: 'Blocks an installed plugin through plugins.json disabledPlugins.', + testName: 'shows configured plugin blocks and hides blocked tools', + prepare({ projectDir }) { + resetPluginScenarioState(); + const pluginDir = writeLocalPlugin({ projectDir }); + writePluginRegistry(projectDir, pluginDir, true, [PLUGIN_ID]); + }, + async inProcessApp({ homeDir, projectDir, startMastraCodeApp }) { + const { PluginManager } = await import('../../src/plugins/manager.js'); + return startMastraCodeApp({ + config: { + pluginManager: new PluginManager({ projectRoot: projectDir, configDir: '.mastracode', homeDir }), + }, + }); + }, + async run({ terminal, runtime }) { + runtime.startLiveOutput(terminal); + await runtime.waitForScreenText(/Resource ID:/i, terminal); + + terminal.submit('/plugins'); + await runtime.waitForScreenText(new RegExp(PLUGIN_ID), terminal, 8_000); + await runtime.waitForScreenText(/blocked/i, terminal, 8_000); + terminal.write('\x1b'); + await runtime.waitForScreenText(/│ ›/i, terminal, 8_000); + + terminal.submit(`/plugins ${PLUGIN_ID}`); + await runtime.waitForScreenText(/blocked by plugins\.json disabledPlugins/i, terminal, 8_000); + await runtime.waitForScreenText(/tools:\s*\(none\)/i, terminal, 8_000); + + terminal.keyCtrlC(); + }, +}; + +export const pluginsAssetsLoadingScenario: McE2eScenario = { + name: 'plugins-assets-loading', + description: 'Loads commands and skills bundled by an installed plugin and exposes them through slash autocomplete.', + testName: 'loads bundled plugin commands and skills with autocomplete entries', + projectFixture: 'long-branch', + useOpenAIModel: true, + aimockFixture: 'plugins-assets-loading.json', + prepare({ projectDir }) { + resetPluginScenarioState(); + const pluginDir = writeAssetPlugin({ projectDir }); + writePluginRegistry(projectDir, pluginDir); + }, + async inProcessApp({ homeDir, projectDir, startMastraCodeApp }) { + const { PluginManager } = await import('../../src/plugins/manager.js'); + return startMastraCodeApp({ + config: { + pluginManager: new PluginManager({ projectRoot: projectDir, configDir: '.mastracode', homeDir }), + }, + }); + }, + async run({ terminal, runtime }) { + runtime.startLiveOutput(terminal); + await runtime.waitForScreenText(/Project: project/i, terminal, 15_000); + await terminal.flushInput?.(); + await runtime.waitForScreenText(/│ ›/i, terminal, 10_000); + + terminal.submit('/help'); + await runtime.waitForScreenText(/Custom Commands/i, terminal, 8_000); + await runtime.waitForScreenText(/\/\/e2e-plugin-assets/i, terminal, 8_000); + await runtime.waitForScreenText(/E2E plugin bundled command autocomplete description/i, terminal, 8_000); + terminal.write('\x1b'); + await runtime.sleep(100); + + terminal.submit('/skills'); + await runtime.waitForScreenText(/e2e-plugin-asset-skill/i, terminal, 10_000); + await runtime.waitForScreenText(/E2E plugin bundled skill autocomplete description/i, terminal, 10_000); + + await typeTextSlowly(terminal, '/e2e-plugin-a'); + await runtime.waitForScreenText(/E2E plugin bundled command autocomplete description/i, terminal, 20_000); + runtime.printScreen('plugin command autocomplete', terminal); + terminal.write('\r'); + await runtime.waitForScreenText(/E2E plugin bundled command executed\./i, terminal, 15_000); + await runtime.waitForScreenText(/MC plugin bundled command response/i, terminal, 15_000); + + await runtime.waitForScreenText(/│ ›/i, terminal, 10_000); + await typeTextSlowly(terminal, '/skill/e2e-plugin'); + await runtime.waitForScreenText(/E2E plugin bundled skill autocomplete description/i, terminal, 20_000); + runtime.printScreen('plugin skill autocomplete', terminal); + terminal.write('\r'); + await runtime.waitForScreenText(/MC plugin bundled skill response/i, terminal, 15_000); + + terminal.keyCtrlC(); + }, + verifyAimockRequests(requests) { + const body = JSON.stringify(requests); + if (!body.includes('E2E plugin bundled command executed.')) { + throw new Error(`Expected plugin bundled command template in AIMock requests: ${body.slice(0, 2000)}`); + } + if (!body.includes('E2E plugin bundled skill instructions.')) { + throw new Error(`Expected plugin bundled skill instructions in AIMock requests: ${body.slice(0, 2000)}`); + } + }, +}; + +export const pluginsCommandUiScenario: McE2eScenario = { + name: 'plugins-command-ui', + description: 'Shows installed plugins and plugin details in the /plugins TUI command.', + testName: 'renders plugin list and detail screens', + prepare({ projectDir }) { + resetPluginScenarioState(); + const pluginDir = writeLocalPlugin({ projectDir }); + writePluginRegistry(projectDir, pluginDir); + }, + async inProcessApp({ homeDir, projectDir, startMastraCodeApp }) { + const { PluginManager } = await import('../../src/plugins/manager.js'); + return startMastraCodeApp({ + config: { + pluginManager: new PluginManager({ projectRoot: projectDir, configDir: '.mastracode', homeDir }), + }, + onTuiCreated: tui => { + currentTui = tui; + }, + }); + }, + async run({ terminal, runtime }) { + runtime.startLiveOutput(terminal); + await runtime.waitForScreenText(/Resource ID:/i, terminal); + + terminal.submit('/plugins'); + await runtime.waitForScreenText(/Install new plugin/i, terminal, 8_000); + await runtime.waitForScreenText(new RegExp(PLUGIN_NAME), terminal, 8_000); + await runtime.waitForScreenText(new RegExp(PLUGIN_ID), terminal, 8_000); + await runtime.waitForScreenText(/project/i, terminal, 8_000); + await runtime.waitForScreenText(/active/i, terminal, 8_000); + + terminal.write('\r'); + await runtime.waitForScreenText(/Install plugin from:/i, terminal, 8_000); + await runtime.waitForScreenText(/Local path/i, terminal, 8_000); + terminal.write('\x1b'); + await runtime.sleep(100); + + ( + currentTui as { state?: { ui?: { hideOverlay?: () => void; requestRender?: () => void } } } | undefined + )?.state?.ui?.hideOverlay?.(); + (currentTui as { state?: { ui?: { requestRender?: () => void } } } | undefined)?.state?.ui?.requestRender?.(); + await runtime.sleep(100); + terminal.submit(`/plugins ${PLUGIN_ID}`); + await runtime.waitForScreenText(new RegExp(`tools:.*${TOOL_NAME}`), terminal, 8_000); + await runtime.waitForScreenText(/Deactivate/i, terminal, 8_000); + await runtime.waitForScreenText(/Uninstall/i, terminal, 8_000); + + terminal.write('\x1b'); + terminal.keyCtrlC(); + }, +}; diff --git a/mastracode/e2e/tui/state-signal-browser-processor.ts b/mastracode/e2e/tui/state-signal-browser-processor.ts index cef1e29c0536..f04b462fd38a 100644 --- a/mastracode/e2e/tui/state-signal-browser-processor.ts +++ b/mastracode/e2e/tui/state-signal-browser-processor.ts @@ -61,6 +61,8 @@ class BrowserProcessorFixture extends MastraBrowser { } } +let browserProcessorStatePath: string | undefined; + function getRequestBody(request: unknown): unknown { if (request && typeof request === 'object' && 'body' in request) { return request.body; @@ -78,9 +80,9 @@ export const stateSignalBrowserProcessorScenario = { aimockFixture: 'state-signal-browser-processor.json', prepare({ appDataDir, projectDir }) { mkdirSync(projectDir, { recursive: true }); - const statePath = join(appDataDir, 'browser-state-processor.json'); + browserProcessorStatePath = join(appDataDir, 'browser-state-processor.json'); writeFileSync( - statePath, + browserProcessorStatePath, JSON.stringify({ tabs: [{ url: 'https://example.test/browser-snapshot', title: 'Browser Snapshot E2E' }], activeTabIndex: 0, @@ -109,11 +111,17 @@ export const stateSignalBrowserProcessorScenario = { await runtime.waitForScreenText(/Active tab URL: https:\/\/example\.test\/browser-snapshot/i, terminal, 10_000); await runtime.waitForScreenText(/Browser processor snapshot captured/i, terminal, 10_000); - terminal.submit( - `!node -e 'const fs=require("fs"); fs.writeFileSync(process.env.MASTRA_APP_DATA_DIR+"/browser-state-processor.json", JSON.stringify({tabs:[{url:"https://example.test/browser-delta",title:"Browser Delta E2E"},{url:"https://example.test/second-tab",title:"Second Tab"}],activeTabIndex:0})); console.log("BROWSER_PROCESSOR_STATE=delta-ready");'`, + if (!browserProcessorStatePath) throw new Error('Browser processor state path was not initialized'); + writeFileSync( + browserProcessorStatePath, + JSON.stringify({ + tabs: [ + { url: 'https://example.test/browser-delta', title: 'Browser Delta E2E' }, + { url: 'https://example.test/second-tab', title: 'Second Tab' }, + ], + activeTabIndex: 0, + }), ); - await runtime.waitForScreenText(/BROWSER_PROCESSOR_STATE=delta-ready/i, terminal, 8_000); - await runtime.waitForScreenText(/BROWSER_PROCESSOR_STATE=delta-ready[\s\S]*✓/i, terminal, 8_000); terminal.submit('Capture browser processor delta.'); await runtime.waitForScreenText(/State delta: browser/i, terminal, 10_000); diff --git a/mastracode/e2e/tui/types.ts b/mastracode/e2e/tui/types.ts index a15be45f181c..de2d8cc4a4a9 100644 --- a/mastracode/e2e/tui/types.ts +++ b/mastracode/e2e/tui/types.ts @@ -57,6 +57,13 @@ export type ScenarioName = | 'persistent-goal-commands' | 'persistent-goal-judge-decision' | 'persistent-goal-reload' + | 'plugins-local-tool' + | 'plugins-local-hot-reload' + | 'plugins-github-poll-update' + | 'plugins-blocked-config' + | 'plugins-scaffold-install-tool' + | 'plugins-assets-loading' + | 'plugins-command-ui' | 'process-shortcuts' | 'provider-history-compat' | 'provider-history-rejection-retry' @@ -120,6 +127,7 @@ export type ScenarioName = | 'task-prompt-context-next-turn' | 'thread-history' | 'tool-history-reload' + | 'plugins-streaming-tool-output' | 'tool-schema-compat' | 'tool-suspension-same-run-resume' | 'update-command-prompt' @@ -129,6 +137,7 @@ export type ScenarioName = | 'workspace-plan-mode-tools' | 'workspace-tool-names' | 'workspace-tool-output-rendering' + | 'work-idle-status' | 'worktree-cross-thread-resume' | 'worktree-thread-scoping' | 'resourceid-drift-prompt-accept' @@ -168,6 +177,7 @@ export type McE2eMastraCodeAppResult = Awaited<ReturnType<typeof createMastraCod export type McE2eStartMastraCodeAppOptions = { config?: MastraCodeConfig; onCreated?: (result: McE2eMastraCodeAppResult) => Promise<void> | void; + onTuiCreated?: (tui: unknown) => Promise<void> | void; setupDebugLogging?: boolean; startupWarnings?: string[]; tui?: Partial<Pick<MastraTUIOptions, 'appName' | 'initialMessage' | 'inlineQuestions' | 'verbose'>>; diff --git a/mastracode/e2e/tui/work-idle-status.ts b/mastracode/e2e/tui/work-idle-status.ts new file mode 100644 index 000000000000..dfd105f07f22 --- /dev/null +++ b/mastracode/e2e/tui/work-idle-status.ts @@ -0,0 +1,60 @@ +import { updateStatusLine } from '../../src/tui/status-line.js'; +import { expect } from './expect.js'; +import type { McE2eScenario } from './types.js'; + +let tuiRef: any; + +export const workIdleStatusScenario: McE2eScenario = { + name: 'work-idle-status', + description: 'Verifies the TUI active timer, completed status-line timing, and delayed idle line.', + testName: 'keeps completed timing beside the model and shows delayed idle above the editor', + useOpenAIModel: true, + aimockFixture: 'work-idle-status.json', + async inProcessApp({ startMastraCodeApp }) { + const app = await startMastraCodeApp({ + onTuiCreated(tui) { + tuiRef = tui; + }, + }); + return { + stop() { + tuiRef = undefined; + return app.stop?.(); + }, + }; + }, + async run({ terminal, runtime }) { + runtime.startLiveOutput(terminal); + + await ( + expect(terminal.getByText(/Mastra Code|Build|Plan|Fast|Type|Press|>/gi, { full: true, strict: false })) as any + ).toBeVisible(); + + terminal.submit('Run a slow work idle status check.'); + await runtime.waitForScreenText(/\b1s\b/i, terminal, 10_000); + await runtime.waitForScreenText(/Work idle status response complete\./i, terminal); + + let state = tuiRef?.state; + for (let i = 0; i < 20 && (!state?.lastAgentRunEndedAt || !state.idleCounter); i++) { + await runtime.sleep(100); + state = tuiRef?.state; + } + if (!state?.lastAgentRunEndedAt || !state.idleCounter) { + throw new Error('Expected TUI timing state to be available after agent run'); + } + state.lastAgentRunDurationMs = 61_000; + state.lastAgentRunEndReason = 'done'; + updateStatusLine(state); + state.idleCounter.setTimingState(state); + state.ui.requestRender?.(); + await runtime.waitForScreenText(/\d+m\d+s\s+✓/i, terminal); + + state.lastAgentRunEndedAt = Date.now() - 60_000; + state.idleCounter.setTimingState(state); + state.ui.requestRender?.(); + + await runtime.waitForScreenText(/1m idle/i, terminal, 5_000); + + terminal.keyCtrlC(); + }, +}; diff --git a/mastracode/e2e/web/driver.ts b/mastracode/e2e/web/driver.ts index a281fab14935..8ca52103d59e 100644 --- a/mastracode/e2e/web/driver.ts +++ b/mastracode/e2e/web/driver.ts @@ -216,34 +216,38 @@ export async function createDriver(opts: { function entryText(entry: TimelineEntry): string { switch (entry.kind) { - case 'user': - return entry.text; - case 'assistant': { - // Flatten the ordered segments to text, interleaving tool name/output in - // execution order — exactly how they render. + case 'message': { const parts: string[] = []; - for (const seg of entry.segments) { - if (seg.kind === 'text' || seg.kind === 'thinking') { - parts.push(seg.text); - } else { - const tool = entry.toolsById[seg.toolCallId]; - if (tool) parts.push(tool.toolName, tool.output); + for (const part of entry.message.content.parts) { + if (part.type === 'text') { + parts.push(part.text); + } else if (part.type === 'reasoning') { + parts.push(part.reasoning); + } else if (part.type === 'tool-invocation') { + const invocation = part.toolInvocation; + const runtimeTool = entry.runtimeTools?.[invocation.toolCallId]; + parts.push( + runtimeTool?.toolName ?? invocation.toolName, + runtimeTool?.output ?? '', + runtimeTool?.result === undefined ? '' : String(runtimeTool.result), + invocation.state === 'result' && invocation.result !== undefined ? String(invocation.result) : '', + ); } } - return parts.join(' '); + return parts.filter(Boolean).join(' '); } case 'notice': return entry.text; case 'approval': - return `approve ${(entry as ApprovalPrompt).toolName}`; + return `approve ${entry.toolName}`; case 'suspension': - return `suspend ${(entry as SuspensionPrompt).toolName}`; + return `suspend ${entry.toolName}`; case 'notification': - return `notification ${(entry as NotificationEntry).message}`; + return `notification ${entry.message}`; case 'notification_summary': - return `notification_summary ${(entry as { message: string }).message}`; + return `notification_summary ${entry.message}`; case 'subagent': - return `subagent ${(entry as SubagentEntry).agentType} ${(entry as SubagentEntry).task}`; + return `subagent ${entry.agentType} ${entry.task}`; default: return ''; } diff --git a/mastracode/e2e/web/fixtures/plan-approval.json b/mastracode/e2e/web/fixtures/plan-approval.json index 0ef3343a2c46..a4e5238d4027 100644 --- a/mastracode/e2e/web/fixtures/plan-approval.json +++ b/mastracode/e2e/web/fixtures/plan-approval.json @@ -13,8 +13,7 @@ "id": "call_submit_plan", "name": "submit_plan", "arguments": { - "title": "Add a README", - "plan": "1. Create README.md\n2. Describe the project" + "path": ".mastracode/plans/add-readme.md" } } ] diff --git a/mastracode/e2e/web/notification.scenario.test.ts b/mastracode/e2e/web/notification.scenario.test.ts index af62f1f4b829..73c003808c6f 100644 --- a/mastracode/e2e/web/notification.scenario.test.ts +++ b/mastracode/e2e/web/notification.scenario.test.ts @@ -38,7 +38,7 @@ describe('web scenario: notification', () => { // Verify transcript has both the notification-driven response and // the transcript state reflects the notification was processed. const state = driver.state(); - const assistantEntries = state.entries.filter(e => e.kind === 'assistant'); + const assistantEntries = state.entries.filter(e => e.kind === 'message' && e.message.role === 'assistant'); expect(assistantEntries.length).toBeGreaterThan(0); }, }); @@ -62,7 +62,7 @@ describe('web scenario: notification', () => { await driver.waitForText('received the notification', 20_000); const state = driver.state(); - expect(state.entries.some(e => e.kind === 'assistant')).toBe(true); + expect(state.entries.some(e => e.kind === 'message' && e.message.role === 'assistant')).toBe(true); }, }); }); diff --git a/mastracode/e2e/web/sse-reconnect.scenario.test.ts b/mastracode/e2e/web/sse-reconnect.scenario.test.ts index 862c523d366f..5174c4e19471 100644 --- a/mastracode/e2e/web/sse-reconnect.scenario.test.ts +++ b/mastracode/e2e/web/sse-reconnect.scenario.test.ts @@ -46,13 +46,19 @@ describe('web scenario: sse-reconnect', () => { // Send first message and wait for response await session.sendMessage('before disconnect'); - // Flatten transcript entries to text (assistant entries hold ordered - // segments rather than a single text field). + // Flatten transcript entries to text. Message entries hold ordered + // content parts; extract text/reasoning parts in order. const flatten = () => transcript.entries .map(e => { - if (e.kind === 'assistant') { - return e.segments.map(s => (s.kind === 'text' || s.kind === 'thinking' ? s.text : '')).join(''); + if (e.kind === 'message') { + return e.message.content.parts + .map(part => { + if (part.type === 'text') return part.text; + if (part.type === 'reasoning') return part.reasoning; + return ''; + }) + .join(''); } if (e.kind === 'notice') return e.text; return ''; diff --git a/mastracode/e2e/web/streaming-text.scenario.test.ts b/mastracode/e2e/web/streaming-text.scenario.test.ts index 406249da84be..b9eda561f943 100644 --- a/mastracode/e2e/web/streaming-text.scenario.test.ts +++ b/mastracode/e2e/web/streaming-text.scenario.test.ts @@ -17,13 +17,15 @@ const scenario: WebScenario = { // After message_end the streaming flag should be false. const state = driver.state(); - const assistantEntries = state.entries.filter(e => e.kind === 'assistant'); + const assistantEntries = state.entries.filter(e => e.kind === 'message' && e.message.role === 'assistant'); const last = assistantEntries[assistantEntries.length - 1]; - if (!last || last.kind !== 'assistant') throw new Error('No assistant entry found'); - if (last.streaming) throw new Error('Expected streaming=false after message_end, got true'); - const assistantText = last.segments - .filter(s => s.kind === 'text') - .map(s => (s.kind === 'text' ? s.text : '')) + if (!last || last.kind !== 'message') throw new Error('No assistant entry found'); + if (last.streaming !== false) { + throw new Error(`Expected streaming=false after message_end, got ${String(last.streaming)}`); + } + const assistantText = last.message.content.parts + .filter(part => part.type === 'text') + .map(part => (part.type === 'text' ? part.text : '')) .join(''); if (!assistantText.includes('Streaming test response')) { throw new Error(`Unexpected text: ${assistantText}`); diff --git a/mastracode/e2e/web/transcript-hydrate.scenario.test.ts b/mastracode/e2e/web/transcript-hydrate.scenario.test.ts index 0f2dd31d44e2..bdc6d27e1956 100644 --- a/mastracode/e2e/web/transcript-hydrate.scenario.test.ts +++ b/mastracode/e2e/web/transcript-hydrate.scenario.test.ts @@ -2,12 +2,22 @@ import type { AgentControllerMessage } from '@mastra/client-js'; import { describe, it, expect } from 'vitest'; import { initialTranscript, transcriptReducer } from '../../src/web/ui/transcript.js'; -import type { TimelineEntry } from '../../src/web/ui/transcript.js'; +import type { MessageEntry, TimelineEntry } from '../../src/web/ui/transcript.js'; -/** Flatten an assistant entry's ordered text/thinking segments to a string. */ -function assistantText(entry: TimelineEntry): string { - if (entry.kind !== 'assistant') return ''; - return entry.segments.map(s => (s.kind === 'text' || s.kind === 'thinking' ? s.text : '')).join(''); +/** Flatten a message entry's ordered text/reasoning parts to a string. */ +function messageText(entry: TimelineEntry): string { + if (entry.kind !== 'message') return ''; + return entry.message.content.parts + .map(part => { + if (part.type === 'text') return part.text; + if (part.type === 'reasoning') return part.reasoning; + return ''; + }) + .join(''); +} + +function toolParts(entry: MessageEntry) { + return entry.message.content.parts.filter(part => part.type === 'tool-invocation'); } /** @@ -41,15 +51,22 @@ describe('transcript hydrate (thread history rendering)', () => { expect(state.modeId).toBe('build'); expect(state.modelId).toBe('openai/gpt-5.4-mini'); expect(state.entries).toHaveLength(2); - expect(state.entries[0]).toMatchObject({ kind: 'user', id: 'u1', text: 'hello there' }); - expect(state.entries[1]).toMatchObject({ kind: 'assistant', id: 'a1', streaming: false }); - expect(assistantText(state.entries[1])).toBe('hi, how can I help?'); + expect(state.entries[0]).toMatchObject({ kind: 'message', id: 'u1', message: { role: 'user' } }); + expect(messageText(state.entries[0])).toBe('hello there'); + expect(state.entries[1]).toMatchObject({ + kind: 'message', + id: 'a1', + message: { role: 'assistant' }, + streaming: false, + }); + expect(messageText(state.entries[1])).toBe('hi, how can I help?'); }); - it('omits system messages from the rendered transcript', () => { + it('keeps system messages in the hydrated message timeline', () => { const messages = [systemMsg('s1', 'you are a coding agent'), userMsg('u1', 'go')]; const state = transcriptReducer(initialTranscript, { type: 'hydrate', messages, threadId: 't' }); - expect(state.entries.map(e => e.kind)).toEqual(['user']); + expect(state.entries.map(e => (e.kind === 'message' ? e.message.role : e.kind))).toEqual(['system', 'user']); + expect(messageText(state.entries[0])).toBe('you are a coding agent'); }); it('replaces prior transcript contents (switching threads is a clean swap)', () => { @@ -69,7 +86,7 @@ describe('transcript hydrate (thread history rendering)', () => { }); expect(state.threadId).toBe('B'); expect(state.entries).toHaveLength(2); - const allText = state.entries.map(e => (e.kind === 'user' ? e.text : assistantText(e))).join('\n'); + const allText = state.entries.map(e => messageText(e)).join('\n'); expect(allText).toContain('thread B message'); expect(allText).not.toContain('thread A message'); }); @@ -91,17 +108,16 @@ describe('transcript hydrate (thread history rendering)', () => { threadId: 't', }); - const assistant = state.entries.find(e => e.kind === 'assistant'); + const assistant = state.entries.find(e => e.kind === 'message' && e.message.role === 'assistant'); expect(assistant).toBeDefined(); - if (assistant?.kind !== 'assistant') throw new Error('expected assistant entry'); - expect(assistantText(assistant)).toBe('Let me read that file.'); - const toolIds = Object.keys(assistant.toolsById); - expect(toolIds).toHaveLength(1); - expect(assistant.toolsById['tc-1']).toMatchObject({ + if (assistant?.kind !== 'message') throw new Error('expected assistant entry'); + expect(messageText(assistant)).toBe('Let me read that file.'); + const tools = toolParts(assistant); + expect(tools).toHaveLength(1); + expect(tools[0]?.toolInvocation).toMatchObject({ + state: 'result', toolCallId: 'tc-1', toolName: 'read_file', - args: { path: 'README.md' }, - status: 'done', result: 'file contents here', }); }); @@ -122,15 +138,13 @@ describe('transcript hydrate (thread history rendering)', () => { } as unknown as AgentControllerMessage; const state = transcriptReducer(initialTranscript, { type: 'hydrate', messages: [msg], threadId: 't' }); const assistant = state.entries[0]; - if (assistant.kind !== 'assistant') throw new Error('expected assistant entry'); - // The segment order must mirror content order, not bucket tools at the end. - expect(assistant.segments.map(s => (s.kind === 'tool' ? `tool:${s.toolCallId}` : s.kind))).toEqual([ - 'text', - 'tool:tc-1', - 'text', - 'tool:tc-2', - 'text', - ]); + if (assistant.kind !== 'message') throw new Error('expected assistant entry'); + // The part order must mirror content order, not bucket tools at the end. + expect( + assistant.message.content.parts.map(part => + part.type === 'tool-invocation' ? `tool:${part.toolInvocation.toolCallId}` : part.type, + ), + ).toEqual(['text', 'tool:tc-1', 'text', 'tool:tc-2', 'text']); }); it('marks a tool as errored when its result is an error', () => { @@ -144,8 +158,11 @@ describe('transcript hydrate (thread history rendering)', () => { } as unknown as AgentControllerMessage; const state = transcriptReducer(initialTranscript, { type: 'hydrate', messages: [msg], threadId: 't' }); const assistant = state.entries[0]; - if (assistant.kind !== 'assistant') throw new Error('expected assistant entry'); - expect(assistant.toolsById['tc-9'].status).toBe('error'); + if (assistant.kind !== 'message') throw new Error('expected assistant entry'); + const [tool] = toolParts(assistant); + expect(tool?.toolInvocation.state).toBe('output-error'); + expect(tool?.toolInvocation.result).toBe('command not found'); + expect(tool?.toolInvocation.errorText).toBe('command not found'); }); it('produces an empty transcript for a thread with no history', () => { diff --git a/mastracode/package.json b/mastracode/package.json index 9ab9dade76d1..2dc8b489b517 100644 --- a/mastracode/package.json +++ b/mastracode/package.json @@ -1,6 +1,6 @@ { "name": "mastracode", - "version": "0.27.0-alpha.2", + "version": "0.27.0-alpha.9", "description": "", "type": "module", "files": [ @@ -53,13 +53,36 @@ "default": "./dist/acp.cjs" } }, + "./headless": { + "import": { + "types": "./dist/headless/index.d.ts", + "default": "./dist/headless.js" + }, + "require": { + "types": "./dist/headless/index.d.ts", + "default": "./dist/headless.cjs" + } + }, + "./plugin": { + "import": { + "types": "./dist/plugin.d.ts", + "default": "./dist/plugin.js" + }, + "require": { + "types": "./dist/plugin.d.ts", + "default": "./dist/plugin.cjs" + } + }, "./package.json": "./package.json" }, "scripts": { "cli": "tsx src/main.ts", "check": "tsc --noEmit && pnpm check:ui", "check:ui": "tsc --noEmit -p src/web/ui/tsconfig.json", - "web:dev": "concurrently --kill-others-on-fail --names server,ui \"tsx src/web/server.ts\" \"vite --config src/web/vite.config.ts\"", + "db:up": "docker compose up -d --wait", + "db:down": "docker compose down", + "web:dev": "concurrently --kill-others-on-fail --names server,ui \"tsx --env-file-if-exists=src/web/.env src/web/server.ts\" \"vite --config src/web/vite.config.ts\"", + "web:dev:github": "pnpm db:up && pnpm web:dev", "web:ui:build": "vite --config src/web/vite.config.ts build", "web:test": "vitest run --config e2e/web/vitest.config.ts", "web:ui:test": "vitest run --config e2e/web-ui/vitest.config.ts", @@ -71,20 +94,23 @@ "e2e:list": "tsx e2e-list.ts", "e2e:smoke": "vitest run --config e2e/vitest.shards.config.ts", "e2e:test": "MC_E2E_VITEST_SCENARIOS=all vitest run --config e2e/vitest.shards.config.ts", - "web:start": "pnpm web:ui:build && tsx src/web/server.ts" + "web:start": "pnpm web:ui:build && tsx --env-file-if-exists=src/web/.env src/web/server.ts" }, "keywords": [], "author": "", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^0.21.0", + "@ai-sdk/amazon-bedrock": "^3.0.102", "@ai-sdk/anthropic": "^3.0.82", "@ai-sdk/openai": "^3.0.63", "@ai-sdk/openai-compatible": "^2.0.47", + "@aws-sdk/credential-providers": "^3.864.0", "@ast-grep/napi": "^0.42.0", "@earendil-works/pi-tui": "^0.79.4", "@hono/node-server": "^1.19.11", "@mastra/agent-browser": "workspace:*", + "@mastra/auth-workos": "workspace:*", "@mastra/core": "workspace:*", "@mastra/duckdb": "workspace:*", "@mastra/fastembed": "workspace:*", @@ -95,18 +121,27 @@ "@mastra/memory": "workspace:*", "@mastra/observability": "workspace:*", "@mastra/pg": "workspace:*", + "@mastra/railway": "workspace:*", + "@mastra/react": "workspace:*", "@mastra/schema-compat": "workspace:*", "@mastra/server": "workspace:*", "@mastra/stagehand": "workspace:*", "@mastra/tavily": "workspace:*", + "@mastra/voice-deepgram": "workspace:*", + "@mastra/voice-openai": "workspace:*", + "@octokit/auth-app": "^8.0.0", + "@octokit/rest": "^22.0.1", "@tanstack/react-query": "^5.90.21", + "@tursodatabase/api": "2.0.4", "ai": "^6.0.176", "chalk": "^5.5.0", "cli-highlight": "^2.1.11", + "drizzle-orm": "^0.45.0", "execa": "^9.6.1", "fastest-levenshtein": "^1.0.16", "hono": "^4.12.8", "partial-json": "^0.1.7", + "pg": "^8.21.0", "posthog-node": "^5.37.0", "strip-ansi": "^7.2.0", "tokenx": "^1.3.0", @@ -125,20 +160,22 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.5.2", "@types/node": "22.19.21", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", + "@types/pg": "^8.18.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "catalog:", "@vitest/ui": "catalog:", "@xterm/headless": "6.0.0", "concurrently": "^9.1.2", + "drizzle-kit": "^0.31.0", "eslint": "^10.4.1", "highlight.js": "^11.11.0", "jsdom": "^26.1.0", "marked": "^15.0.0", "msw": "^2.12.11", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", "tsup": "^8.5.1", "tsx": "catalog:", "typescript": "catalog:", diff --git a/mastracode/src/__tests__/index.test.ts b/mastracode/src/__tests__/index.test.ts index 67ecd365e437..699d11bd678a 100644 --- a/mastracode/src/__tests__/index.test.ts +++ b/mastracode/src/__tests__/index.test.ts @@ -15,6 +15,7 @@ const createMastraCodeModelCatalogProviderMock = vi.fn(() => mastraCodeCatalogPr const resolveModelMock = vi.fn(); vi.mock('@mastra/core/llm', () => ({ + MastraModelGateway: class {}, PROVIDER_REGISTRY: providerRegistryMock, })); @@ -27,13 +28,23 @@ vi.mock('@mastra/core/agent', () => ({ SignalProvider: class {}, })); +// The code agent is built via the core `createCodingAgent` factory. Forward the +// config mastracode passes to the same constructor spy the tests assert against, +// returning a mocked Agent instance. +vi.mock('@mastra/core/coding-agent', () => ({ + createCodingAgent: (config: unknown) => { + agentConstructorMock(config); + return {}; + }, +})); + const agentConstructorMock = vi.fn(); -const agentControllerConstructorMock = vi.fn(); +const controllerConstructorMock = vi.fn(); const loadSettingsMock = vi.fn(); const getAvailableModePacksMock = vi.fn(() => []); const getAvailableOmPacksMock = vi.fn(() => []); -const agentControllerSubscribeMock = vi.fn(); +const controllerSubscribeMock = vi.fn(); const detectProjectMock = vi.fn(() => ({ mode: 'none', rootPath: process.cwd(), @@ -42,16 +53,16 @@ const detectProjectMock = vi.fn(() => ({ hasGit: false, contextFiles: [], })); -const agentControllerGetCurrentThreadIdMock = vi.fn(); -const agentControllerListThreadsMock = vi.fn(); -const agentControllerSetStateMock = vi.fn(); -const agentControllerSetThreadSettingMock = vi.fn(); +const controllerGetCurrentThreadIdMock = vi.fn(); +const controllerListThreadsMock = vi.fn(); +const controllerSetStateMock = vi.fn(); +const controllerSetThreadSettingMock = vi.fn(); const createMcpManagerMock = vi.fn(); const hookManagerConstructorMock = vi.fn(); const getStorageConfigMock = vi.fn(() => ({ type: 'memory' })); const getResourceIdOverrideMock = vi.fn(() => undefined); const getDynamicWorkspaceMock = vi.fn(); -let agentControllerStateMock: Record<string, unknown> = { cavemanObservations: false }; +let controllerStateMock: Record<string, unknown> = { cavemanObservations: false }; function createMockSettings() { return { @@ -107,7 +118,7 @@ function createMockSettings() { vi.mock('@mastra/core/agent-controller', () => ({ AgentController: class { constructor(config: unknown) { - agentControllerConstructorMock(config); + controllerConstructorMock(config); } async init() {} getMastra() { @@ -115,33 +126,33 @@ vi.mock('@mastra/core/agent-controller', () => ({ } async createSession() { return { - subscribe: (eventHandler: unknown) => agentControllerSubscribeMock(eventHandler), + subscribe: (eventHandler: unknown) => controllerSubscribeMock(eventHandler), identity: { getResourceId: () => 'project-resource', }, thread: { - getId: () => agentControllerGetCurrentThreadIdMock(), - list: (options: unknown) => agentControllerListThreadsMock(options), - setSetting: (setting: unknown) => agentControllerSetThreadSettingMock(setting), + getId: () => controllerGetCurrentThreadIdMock(), + list: (options: unknown) => controllerListThreadsMock(options), + setSetting: (setting: unknown) => controllerSetThreadSettingMock(setting), }, mode: { get: () => 'build' }, model: { get: () => 'anthropic/claude-opus-4-6' }, state: { - get: () => agentControllerStateMock, - set: (state: unknown) => agentControllerSetStateMock(state), + get: () => controllerStateMock, + set: (state: unknown) => controllerSetStateMock(state), update: async (updater: any) => { - const result = await updater(agentControllerStateMock); - if (result?.updates) agentControllerSetStateMock(result.updates); + const result = await updater(controllerStateMock); + if (result?.updates) controllerSetStateMock(result.updates); return result?.result; }, }, }; } getState() { - return agentControllerStateMock; + return controllerStateMock; } setState(state: unknown) { - return agentControllerSetStateMock(state); + return controllerSetStateMock(state); } }, taskWriteTool: {}, @@ -330,15 +341,15 @@ describe('createMastraCode', () => { createVectorStoreMock.mockReturnValue({}); getDynamicMemoryMock.mockReset(); getDynamicMemoryMock.mockReturnValue(() => undefined); - agentControllerSubscribeMock.mockReset(); - agentControllerGetCurrentThreadIdMock.mockReset(); - agentControllerGetCurrentThreadIdMock.mockReturnValue(undefined); - agentControllerListThreadsMock.mockReset(); - agentControllerListThreadsMock.mockResolvedValue([]); - agentControllerSetStateMock.mockReset(); - agentControllerSetStateMock.mockResolvedValue(undefined); - agentControllerSetThreadSettingMock.mockReset(); - agentControllerSetThreadSettingMock.mockResolvedValue(undefined); + controllerSubscribeMock.mockReset(); + controllerGetCurrentThreadIdMock.mockReset(); + controllerGetCurrentThreadIdMock.mockReturnValue(undefined); + controllerListThreadsMock.mockReset(); + controllerListThreadsMock.mockResolvedValue([]); + controllerSetStateMock.mockReset(); + controllerSetStateMock.mockResolvedValue(undefined); + controllerSetThreadSettingMock.mockReset(); + controllerSetThreadSettingMock.mockResolvedValue(undefined); createMcpManagerMock.mockReset(); hookManagerConstructorMock.mockReset(); getStorageConfigMock.mockReset(); @@ -355,11 +366,11 @@ describe('createMastraCode', () => { hasGit: false, contextFiles: [], }); - agentControllerStateMock = { cavemanObservations: false }; + controllerStateMock = { cavemanObservations: false }; loadSettingsMock.mockReset(); loadSettingsMock.mockReturnValue(createMockSettings()); agentConstructorMock.mockReset(); - agentControllerConstructorMock.mockReset(); + controllerConstructorMock.mockReset(); streamErrorRetryProcessorConstructorMock.mockReset(); getAvailableModePacksMock.mockClear(); getAvailableOmPacksMock.mockClear(); @@ -384,13 +395,14 @@ describe('createMastraCode', () => { settingsPath: undefined, }); - const agentControllerConfig = agentControllerConstructorMock.mock.calls[0]?.[0] as + const agentControllerConfig = controllerConstructorMock.mock.calls[0]?.[0] as | { - gateways?: unknown[]; + gateways?: Array<{ id?: string }>; subagents?: unknown[]; } | undefined; - expect(agentControllerConfig?.gateways).toEqual([mastraCodeGatewayMock]); + expect(agentControllerConfig?.gateways?.[0]?.id).toBe('amazon-bedrock'); + expect(agentControllerConfig?.gateways?.[1]).toBe(mastraCodeGatewayMock); expect(agentControllerConfig?.subagents).toEqual([subagent]); }, 10_000); @@ -428,8 +440,8 @@ describe('createMastraCode', () => { await createMastraCode(); - expect(agentControllerConstructorMock).toHaveBeenCalled(); - const agentControllerConfig = agentControllerConstructorMock.mock.calls[0]?.[0] as { memory?: unknown } | undefined; + expect(controllerConstructorMock).toHaveBeenCalled(); + const agentControllerConfig = controllerConstructorMock.mock.calls[0]?.[0] as { memory?: unknown } | undefined; expect(typeof agentControllerConfig?.memory).toBe('function'); }); @@ -453,7 +465,7 @@ describe('createMastraCode', () => { initialState: { configDir: '.wrong-code' }, }); - const agentControllerConfig = agentControllerConstructorMock.mock.calls[0]?.[0] as + const agentControllerConfig = controllerConstructorMock.mock.calls[0]?.[0] as | { memory?: unknown; initialState?: Record<string, unknown> } | undefined; expect(agentControllerConfig?.memory).toBe(customMemory); @@ -470,9 +482,7 @@ describe('createMastraCode', () => { await createMastraCode({ workspace: customWorkspace as any }); - const agentControllerConfig = agentControllerConstructorMock.mock.calls[0]?.[0] as - | { workspace?: unknown } - | undefined; + const agentControllerConfig = controllerConstructorMock.mock.calls[0]?.[0] as { workspace?: unknown } | undefined; expect(agentControllerConfig?.workspace).toBe(customWorkspace); expect(getDynamicWorkspaceMock).not.toHaveBeenCalled(); }); @@ -482,13 +492,30 @@ describe('createMastraCode', () => { await createMastraCode(); - const agentControllerConfig = agentControllerConstructorMock.mock.calls[0]?.[0] as - | { workspace?: unknown } - | undefined; + const agentControllerConfig = controllerConstructorMock.mock.calls[0]?.[0] as { workspace?: unknown } | undefined; expect(typeof agentControllerConfig?.workspace).toBe('function'); expect(agentControllerConfig?.workspace).not.toEqual({ id: 'custom-workspace' }); }); + it('adds active plugin tool names to mode availableTools allowlists and seeds plugin instructions', async () => { + const { createMastraCode } = await import('../index.js'); + const pluginManager = { + reload: vi.fn(async () => [ + { id: 'acme.plugin', status: 'active', toolNames: ['plugin_tool'], instructions: 'Use plugin policy.' }, + ]), + getPluginTools: vi.fn(() => ({ plugin_tool: { id: 'plugin_tool' } })), + }; + + await createMastraCode({ pluginManager: pluginManager as any }); + + const agentControllerConfig = controllerConstructorMock.mock.calls[0]?.[0] as + | { modes?: Array<{ id: string; availableTools?: string[] }>; initialState?: Record<string, unknown> } + | undefined; + expect(agentControllerConfig?.modes?.find(mode => mode.id === 'plan')?.availableTools).toContain('plugin_tool'); + expect(agentControllerConfig?.modes?.find(mode => mode.id === 'fast')?.availableTools).toContain('plugin_tool'); + expect(agentControllerConfig?.initialState?.pluginInstructions).toEqual(['Use plugin policy.']); + }); + it('registers the TaskSignalProvider on the code agent so task tools persist via state signals', async () => { const { TaskSignalProvider } = await import('@mastra/core/signals'); const { createMastraCode } = await import('../index.js'); @@ -525,7 +552,7 @@ describe('createMastraCode', () => { ], }); - const agentControllerConfig = agentControllerConstructorMock.mock.calls[0]?.[0] as + const agentControllerConfig = controllerConstructorMock.mock.calls[0]?.[0] as | { modes?: { id: string; default?: boolean; defaultModelId: string }[] } | undefined; expect(agentControllerConfig?.modes).toEqual( @@ -550,7 +577,7 @@ describe('createMastraCode', () => { await createMastraCode({ cwd: projectPath }); - const agentControllerConfig = agentControllerConstructorMock.mock.calls[0]?.[0] as + const agentControllerConfig = controllerConstructorMock.mock.calls[0]?.[0] as | { initialState?: Record<string, unknown> } | undefined; expect(agentControllerConfig?.initialState?.projectPath).toBe(projectPath); @@ -578,7 +605,7 @@ describe('createMastraCode', () => { expect(getStorageConfigMock).toHaveBeenCalledWith(projectPath, expect.anything(), '.acme-code'); expect(createMcpManagerMock).toHaveBeenCalledWith(projectPath, '.acme-code', undefined); expect(hookManagerConstructorMock).toHaveBeenCalledWith(projectPath, 'session-init', '.acme-code', undefined); - const agentControllerConfig = agentControllerConstructorMock.mock.calls[0]?.[0] as + const agentControllerConfig = controllerConstructorMock.mock.calls[0]?.[0] as | { initialState?: Record<string, unknown> } | undefined; expect(agentControllerConfig?.initialState?.configDir).toBe('.acme-code'); @@ -626,7 +653,7 @@ describe('createMastraCode', () => { await createMastraCode({ pubsub, unixSocketPubSub: true }); - const agentControllerConfig = agentControllerConstructorMock.mock.calls.at(-1)?.[0] as + const agentControllerConfig = controllerConstructorMock.mock.calls.at(-1)?.[0] as | { pubsub?: unknown; threadLock?: unknown } | undefined; expect(agentControllerConfig?.pubsub).toBe(pubsub); @@ -639,7 +666,7 @@ describe('createMastraCode', () => { await createMastraCode({ pubsub, crossProcessPubSub: true }); - const agentControllerConfig = agentControllerConstructorMock.mock.calls.at(-1)?.[0] as + const agentControllerConfig = controllerConstructorMock.mock.calls.at(-1)?.[0] as | { pubsub?: unknown; threadLock?: unknown } | undefined; expect(agentControllerConfig?.pubsub).toBe(pubsub); @@ -647,28 +674,28 @@ describe('createMastraCode', () => { }); it('restores the current thread caveman observation setting at startup', async () => { - agentControllerGetCurrentThreadIdMock.mockReturnValue('thread-1'); - agentControllerListThreadsMock.mockResolvedValue([{ id: 'thread-1', metadata: { cavemanObservations: true } }]); + controllerGetCurrentThreadIdMock.mockReturnValue('thread-1'); + controllerListThreadsMock.mockResolvedValue([{ id: 'thread-1', metadata: { cavemanObservations: true } }]); const { createMastraCode } = await import('../index.js'); await createMastraCode(); - expect(agentControllerSubscribeMock).toHaveBeenCalled(); - expect(agentControllerListThreadsMock).toHaveBeenCalledWith({ allResources: true }); - expect(agentControllerSetStateMock).toHaveBeenCalledWith({ cavemanObservations: true }); + expect(controllerSubscribeMock).toHaveBeenCalled(); + expect(controllerListThreadsMock).toHaveBeenCalledWith({ allResources: true }); + expect(controllerSetStateMock).toHaveBeenCalledWith({ cavemanObservations: true }); }); it('restores an explicit false caveman observation setting at startup', async () => { - agentControllerStateMock = { cavemanObservations: true }; - agentControllerGetCurrentThreadIdMock.mockReturnValue('thread-1'); - agentControllerListThreadsMock.mockResolvedValue([{ id: 'thread-1', metadata: { cavemanObservations: false } }]); + controllerStateMock = { cavemanObservations: true }; + controllerGetCurrentThreadIdMock.mockReturnValue('thread-1'); + controllerListThreadsMock.mockResolvedValue([{ id: 'thread-1', metadata: { cavemanObservations: false } }]); const { createMastraCode } = await import('../index.js'); await createMastraCode(); - expect(agentControllerSubscribeMock).toHaveBeenCalled(); - expect(agentControllerListThreadsMock).toHaveBeenCalledWith({ allResources: true }); - expect(agentControllerSetStateMock).toHaveBeenCalledWith({ cavemanObservations: false }); + expect(controllerSubscribeMock).toHaveBeenCalled(); + expect(controllerListThreadsMock).toHaveBeenCalledWith({ allResources: true }); + expect(controllerSetStateMock).toHaveBeenCalledWith({ cavemanObservations: false }); }); it('seeds observeAttachments from persisted global setting at startup', async () => { @@ -679,7 +706,7 @@ describe('createMastraCode', () => { await createMastraCode(); - const agentControllerCall = agentControllerConstructorMock.mock.calls[0]?.[0] as + const agentControllerCall = controllerConstructorMock.mock.calls[0]?.[0] as | { initialState?: Record<string, unknown> } | undefined; expect(agentControllerCall?.initialState?.observeAttachments).toBe(false); @@ -690,23 +717,23 @@ describe('createMastraCode', () => { await createMastraCode(); - const agentControllerCall = agentControllerConstructorMock.mock.calls[0]?.[0] as + const agentControllerCall = controllerConstructorMock.mock.calls[0]?.[0] as | { initialState?: Record<string, unknown> } | undefined; expect(agentControllerCall?.initialState?.observeAttachments).toBe('auto'); }); it('restores observeAttachments metadata for the current thread at startup', async () => { - agentControllerStateMock = { observeAttachments: true }; - agentControllerGetCurrentThreadIdMock.mockReturnValue('thread-1'); - agentControllerListThreadsMock.mockResolvedValue([{ id: 'thread-1', metadata: { observeAttachments: 'auto' } }]); + controllerStateMock = { observeAttachments: true }; + controllerGetCurrentThreadIdMock.mockReturnValue('thread-1'); + controllerListThreadsMock.mockResolvedValue([{ id: 'thread-1', metadata: { observeAttachments: 'auto' } }]); const { createMastraCode } = await import('../index.js'); await createMastraCode(); - expect(agentControllerSubscribeMock).toHaveBeenCalled(); - expect(agentControllerListThreadsMock).toHaveBeenCalledWith({ allResources: true }); - expect(agentControllerSetStateMock).toHaveBeenCalledWith({ observeAttachments: 'auto' }); + expect(controllerSubscribeMock).toHaveBeenCalled(); + expect(controllerListThreadsMock).toHaveBeenCalledWith({ allResources: true }); + expect(controllerSetStateMock).toHaveBeenCalledWith({ observeAttachments: 'auto' }); }); it('runs stream error retries before provider-specific error recovery processors', async () => { @@ -776,8 +803,8 @@ describe('createMastraCode', () => { ...createMockSettings(), signals: { unixSocketPubSub: false, experimentalGithubSignals: true }, }); - agentControllerGetCurrentThreadIdMock.mockReturnValue('thread-1'); - agentControllerListThreadsMock.mockResolvedValue([{ id: 'thread-1', resourceId: 'thread-resource', metadata: {} }]); + controllerGetCurrentThreadIdMock.mockReturnValue('thread-1'); + controllerListThreadsMock.mockResolvedValue([{ id: 'thread-1', resourceId: 'thread-resource', metadata: {} }]); const { GithubSignals } = await import('@mastra/github-signals'); const startPollingForThread = vi.spyOn(GithubSignals.prototype, 'startPollingForThread').mockResolvedValue(true); const { createMastraCode } = await import('../index.js'); diff --git a/mastracode/src/acp/index.ts b/mastracode/src/acp/index.ts index 18e5962002de..632e878d3153 100644 --- a/mastracode/src/acp/index.ts +++ b/mastracode/src/acp/index.ts @@ -46,7 +46,7 @@ export async function acpMain(options?: { dangerousAutoApprove?: boolean }): Pro await Promise.allSettled([ mcpManager?.disconnect(), controller?.getMastra()?.stopWorkers(), - controller?.stopHeartbeats(), + controller?.stopIntervals(), closeSignalsPubSub?.(), ]); diff --git a/mastracode/src/agents/__tests__/instructions.test.ts b/mastracode/src/agents/__tests__/instructions.test.ts index a3c8e7cae76a..851b90618d9b 100644 --- a/mastracode/src/agents/__tests__/instructions.test.ts +++ b/mastracode/src/agents/__tests__/instructions.test.ts @@ -51,4 +51,40 @@ describe('getDynamicInstructions', () => { 'Include `Co-Authored-By: Mastra Code (anthropic/claude-opus-4-6) <noreply@mastra.ai>` in the message body.', ); }); + + it('appends active plugin instructions to the base prompt', async () => { + const prompt = await getDynamicInstructions({ + requestContext: { + get: vi.fn(key => { + const getState = vi.fn(() => ({ + projectPath: '/tmp/project', + projectName: 'test-project', + gitBranch: 'main', + pluginInstructions: ['Use the Alexandria reader policy.', 'Prefer plugin-provided workflows.'], + })); + return key === 'controller' + ? { + getState, + session: { + modeId: 'build', + modelId: 'openai/gpt-5.5', + state: { get: getState }, + }, + } + : undefined; + }), + }, + }); + + expect(prompt).toContain('# Plugin Instructions'); + expect(prompt).toContain( + 'must not override higher-priority system, developer, repository, safety, or tool-use instructions', + ); + expect(prompt).toContain( + '<plugin-instructions index="1">\nUse the Alexandria reader policy.\n</plugin-instructions>', + ); + expect(prompt).toContain( + '<plugin-instructions index="2">\nPrefer plugin-provided workflows.\n</plugin-instructions>', + ); + }); }); diff --git a/mastracode/src/agents/__tests__/model.test.ts b/mastracode/src/agents/__tests__/model.test.ts index 3a31b2b0bfdc..5195aadc262c 100644 --- a/mastracode/src/agents/__tests__/model.test.ts +++ b/mastracode/src/agents/__tests__/model.test.ts @@ -82,6 +82,25 @@ vi.mock('@ai-sdk/openai-compatible', () => ({ })), })); +// Mock @ai-sdk/amazon-bedrock +vi.mock('@ai-sdk/amazon-bedrock', () => ({ + createAmazonBedrock: vi.fn((opts: Record<string, unknown>) => { + return (modelId: string) => ({ + __provider: 'amazon-bedrock', + modelId, + region: opts.region, + credentialProvider: opts.credentialProvider, + headers: opts.headers, + }); + }), +})); + +// Mock @aws-sdk/credential-providers +const mockCredentialProvider = vi.hoisted(() => vi.fn()); +vi.mock('@aws-sdk/credential-providers', () => ({ + fromNodeProviderChain: vi.fn(() => mockCredentialProvider), +})); + // Mock ai SDK's wrapLanguageModel to pass through with a marker vi.mock('ai', () => ({ wrapLanguageModel: vi.fn(({ model }: { model: Record<string, unknown> }) => ({ @@ -160,11 +179,14 @@ vi.mock('../../onboarding/settings.js', () => ({ MEMORY_GATEWAY_DEFAULT_URL: 'https://gateway-api.mastra.ai', })); +import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; import { createAnthropic } from '@ai-sdk/anthropic'; import { createOpenAI } from '@ai-sdk/openai'; +import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; import { MastraGateway, ModelRouterLanguageModel } from '@mastra/core/llm'; import { wrapLanguageModel } from 'ai'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { MODEL_TOKENS } from '../../../../docs/src/plugins/remark-model-tokens/models'; import { opencodeClaudeMaxProvider, buildAnthropicOAuthFetch } from '../../providers/claude-max.js'; import { openaiCodexProvider, buildOpenAICodexOAuthFetch } from '../../providers/openai-codex.js'; import { @@ -526,6 +548,48 @@ describe('resolveModel', () => { }); }); + describe('amazon-bedrock/* models', () => { + it('resolves Bedrock models through the AWS SDK with the credential chain', () => { + process.env.AWS_REGION = 'us-west-2'; + + const result = resolveModel(MODEL_TOKENS.__GATEWAY_BEDROCK_MODEL_OPUS__) as Record<string, unknown>; + + expect(result.__provider).toBe('amazon-bedrock'); + expect(result.modelId).toBe(MODEL_TOKENS.__BEDROCK_MODEL_OPUS_BARE__); + expect(result.region).toBe('us-west-2'); + expect(result.credentialProvider).toBe(mockCredentialProvider); + expect(fromNodeProviderChain).toHaveBeenCalled(); + expect(createAmazonBedrock).toHaveBeenCalled(); + }); + + it('falls back to us-east-1 when no AWS region is configured', () => { + delete process.env.AWS_REGION; + delete process.env.AWS_DEFAULT_REGION; + + const result = resolveModel(MODEL_TOKENS.__GATEWAY_BEDROCK_MODEL_OPUS__) as Record<string, unknown>; + + expect(result.region).toBe('us-east-1'); + }); + + it('preserves a colon-bearing Bedrock model id', () => { + const result = resolveModel(MODEL_TOKENS.__GATEWAY_BEDROCK_MODEL_SONNET__) as Record<string, unknown>; + + expect(result.__provider).toBe('amazon-bedrock'); + expect(result.modelId).toBe(MODEL_TOKENS.__BEDROCK_MODEL_SONNET_BARE__); + }); + + it('passes harness headers to the Bedrock provider', () => { + const result = resolveModel(MODEL_TOKENS.__GATEWAY_BEDROCK_MODEL_OPUS__, { + requestContext: makeRequestContext({ threadId: 'thread-123', resourceId: 'resource-456' }), + }) as Record<string, unknown>; + + expect(result.headers).toEqual({ + 'x-thread-id': 'thread-123', + 'x-resource-id': 'resource-456', + }); + }); + }); + describe('memory gateway enabled (gateway API key stored)', () => { beforeEach(() => { mockAuthStorageInstance.getStoredApiKey.mockImplementation((providerId: string) => diff --git a/mastracode/src/agents/__tests__/workspace-env.test.ts b/mastracode/src/agents/__tests__/workspace-env.test.ts index 14ba023cfc45..27d228d39d76 100644 --- a/mastracode/src/agents/__tests__/workspace-env.test.ts +++ b/mastracode/src/agents/__tests__/workspace-env.test.ts @@ -56,7 +56,7 @@ describe('mastracode workspace sandbox environment', () => { try { process.env.MASTRACODE_TEST_ENV = 'works'; const { getDynamicWorkspace } = await import('../workspace.js'); - const workspace = getDynamicWorkspace({ requestContext: createRequestContext(tempDir) as any }); + const workspace = await getDynamicWorkspace({ requestContext: createRequestContext(tempDir) as any }); const result = await workspace.sandbox!.executeCommand!('node -e "console.log(process.env.MASTRACODE_TEST_ENV)"'); @@ -80,7 +80,7 @@ describe('mastracode workspace sandbox environment', () => { }; let callCount = 0; await fs.writeFile(path.join(tempDir, 'hooked.txt'), 'original'); - const workspace = getDynamicWorkspace({ requestContext }); + const workspace = await getDynamicWorkspace({ requestContext }); const agent = new Agent({ id: 'mc-workspace-hook-agent', name: 'MC Workspace Hook Agent', diff --git a/mastracode/src/agents/__tests__/workspace-skill-activation.test.ts b/mastracode/src/agents/__tests__/workspace-skill-activation.test.ts index 6f7080bca4dd..dd8fb94d66bf 100644 --- a/mastracode/src/agents/__tests__/workspace-skill-activation.test.ts +++ b/mastracode/src/agents/__tests__/workspace-skill-activation.test.ts @@ -63,7 +63,7 @@ describe('mastracode workspace skill activation', () => { }, }); - const workspace = getDynamicWorkspace({ requestContext }); + const workspace = await getDynamicWorkspace({ requestContext }); const agent = new Agent({ id: 'mc-symlink-skill-agent', diff --git a/mastracode/src/agents/__tests__/workspace-worktree-scenario.test.ts b/mastracode/src/agents/__tests__/workspace-worktree-scenario.test.ts new file mode 100644 index 000000000000..6a4bd3b52415 --- /dev/null +++ b/mastracode/src/agents/__tests__/workspace-worktree-scenario.test.ts @@ -0,0 +1,149 @@ +import { RequestContext } from '@mastra/core/request-context'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../onboarding/settings.js', () => ({ + loadSettings: () => ({}), +})); +vi.mock('../../onboarding/settings.js', () => ({ + loadSettings: () => ({}), +})); + +// Capture the workdir each SandboxFilesystem is constructed with so we can +// assert the workspace binds to the active worktree across re-opens. +const sandboxFsCalls: Array<{ workdir: string }> = []; +vi.mock('../../web/github/sandbox-filesystem.js', () => ({ + SandboxFilesystem: class { + workdir: string; + constructor(opts: { workdir: string }) { + this.workdir = opts.workdir; + sandboxFsCalls.push({ workdir: opts.workdir }); + } + }, +})); + +const reattachCalls: string[] = []; +vi.mock('../../web/github/sandbox.js', () => ({ + reattachProjectSandbox: vi.fn(async (sandboxId: string) => { + reattachCalls.push(sandboxId); + return { executeCommand: vi.fn(), getInfo: vi.fn() }; + }), +})); + +function createSandboxRequestContext(state: Record<string, unknown>) { + const requestContext = new RequestContext(); + const getState = () => state; + requestContext.set('controller', { + modeId: 'build', + getState, + session: { state: { get: getState } }, + }); + return requestContext; +} + +/** + * Minimal Mastra registry stand-in. `getDynamicWorkspace` reuses a workspace + * only when `mastra.getWorkspaceById(id)` returns one, so the test mirrors the + * real open/reopen lifecycle by registering each freshly-built workspace and + * serving it back on the next resolve with the same reuse key. + */ +function createWorkspaceRegistry() { + const registry = new Map<string, unknown>(); + return { + getWorkspaceById: (id: string) => registry.get(id), + register: (ws: { id: string }) => registry.set(ws.id, ws), + size: () => registry.size, + }; +} + +const baseState = { + githubProjectId: 'proj-1', + sandboxId: 'sbx-1', + sandboxWorkdir: '/workspace/hello', + sandboxAllowedPaths: [], +}; + +afterEach(() => { + sandboxFsCalls.length = 0; + reattachCalls.length = 0; + vi.resetModules(); +}); + +describe('S5 — worktree reattach round-trip through the workspace seam', () => { + it('binds, reuses on reopen, and rebuilds across a different worktree', async () => { + const { getDynamicWorkspace } = await import('../workspace.js'); + const reg = createWorkspaceRegistry(); + + // Resolve helper that mimics how the server registers a newly-built + // workspace before the next request can reuse it. + const resolve = async (state: Record<string, unknown>) => { + const ws = await getDynamicWorkspace({ + requestContext: createSandboxRequestContext(state) as any, + mastra: reg as any, + }); + reg.register(ws as { id: string }); + return ws; + }; + + // 1. First open on worktree feat-x: filesystem + sandbox bind to the + // worktree path, not the repo root. + const first = await resolve({ + ...baseState, + worktreePath: '/workspace/worktrees/feat-x', + branch: 'feat/x', + }); + expect(sandboxFsCalls.at(-1)?.workdir).toBe('/workspace/worktrees/feat-x'); + expect(first.id).toBe('mastra-code-workspace-gh-proj-1-sbx-1-/workspace/worktrees/feat-x'); + expect(reattachCalls).toHaveLength(1); + const fsCallsAfterFirst = sandboxFsCalls.length; + + // 2. Reopen with the SAME sandbox + worktree: the exact same Workspace + // instance is reused (reuse key honors the worktree). No new sandbox + // reattach and no new SandboxFilesystem are constructed. + const second = await resolve({ + ...baseState, + worktreePath: '/workspace/worktrees/feat-x', + branch: 'feat/x', + }); + expect(second).toBe(first); + expect(reattachCalls).toHaveLength(1); + expect(sandboxFsCalls.length).toBe(fsCallsAfterFirst); + + // 3. Reopen with the SAME sandbox but a DIFFERENT worktree: a brand new + // Workspace is built so no state leaks across feature branches. + const third = await resolve({ + ...baseState, + worktreePath: '/workspace/worktrees/feat-y', + branch: 'feat/y', + }); + expect(third).not.toBe(first); + expect(third.id).toBe('mastra-code-workspace-gh-proj-1-sbx-1-/workspace/worktrees/feat-y'); + expect(sandboxFsCalls.at(-1)?.workdir).toBe('/workspace/worktrees/feat-y'); + expect(reattachCalls).toHaveLength(2); + expect(reg.size()).toBe(2); + }); + + it('reuses a worktree workspace independently from the base-checkout workspace', async () => { + const { getDynamicWorkspace } = await import('../workspace.js'); + const reg = createWorkspaceRegistry(); + const resolve = async (state: Record<string, unknown>) => { + const ws = await getDynamicWorkspace({ + requestContext: createSandboxRequestContext(state) as any, + mastra: reg as any, + }); + reg.register(ws as { id: string }); + return ws; + }; + + // Base checkout (no worktree active) and a worktree both register distinct + // workspaces on the same sandbox, and each reopen reuses its own instance. + const base = await resolve({ ...baseState }); + const wt = await resolve({ ...baseState, worktreePath: '/workspace/worktrees/feat-x' }); + expect(base).not.toBe(wt); + + const baseAgain = await resolve({ ...baseState }); + const wtAgain = await resolve({ ...baseState, worktreePath: '/workspace/worktrees/feat-x' }); + expect(baseAgain).toBe(base); + expect(wtAgain).toBe(wt); + expect(reg.size()).toBe(2); + }); +}); diff --git a/mastracode/src/agents/__tests__/workspace-worktree.test.ts b/mastracode/src/agents/__tests__/workspace-worktree.test.ts new file mode 100644 index 000000000000..b29d768b969a --- /dev/null +++ b/mastracode/src/agents/__tests__/workspace-worktree.test.ts @@ -0,0 +1,98 @@ +import { RequestContext } from '@mastra/core/request-context'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../onboarding/settings.js', () => ({ + loadSettings: () => ({}), +})); +vi.mock('../../onboarding/settings.js', () => ({ + loadSettings: () => ({}), +})); + +// Capture the workdir the SandboxFilesystem is constructed with so we can assert +// the workspace binds to the worktree path rather than the repo root. +const sandboxFsCalls: Array<{ workdir: string }> = []; +vi.mock('../../web/github/sandbox-filesystem.js', () => ({ + SandboxFilesystem: class { + workdir: string; + constructor(opts: { workdir: string }) { + this.workdir = opts.workdir; + sandboxFsCalls.push({ workdir: opts.workdir }); + } + }, +})); + +vi.mock('../../web/github/sandbox.js', () => ({ + reattachProjectSandbox: vi.fn(async () => ({ + executeCommand: vi.fn(), + getInfo: vi.fn(), + })), +})); + +function createSandboxRequestContext(state: Record<string, unknown>) { + const requestContext = new RequestContext(); + const getState = () => state; + requestContext.set('controller', { + modeId: 'build', + getState, + session: { state: { get: getState } }, + }); + return requestContext; +} + +const baseState = { + githubProjectId: 'proj-1', + sandboxId: 'sbx-1', + sandboxWorkdir: '/workspace/hello', + sandboxAllowedPaths: [], +}; + +afterEach(() => { + sandboxFsCalls.length = 0; + vi.resetModules(); +}); + +describe('getDynamicWorkspace sandbox worktree binding', () => { + it('binds the workspace to the repo root when no worktree is active', async () => { + const { getDynamicWorkspace } = await import('../workspace.js'); + const workspace = await getDynamicWorkspace({ + requestContext: createSandboxRequestContext({ ...baseState }) as any, + }); + + expect(sandboxFsCalls.at(-1)?.workdir).toBe('/workspace/hello'); + // Reuse key embeds the bound workdir (repo root here). + expect(workspace.id).toBe('mastra-code-workspace-gh-proj-1-sbx-1-/workspace/hello'); + }); + + it('binds the workspace to the worktree path when one is active', async () => { + const { getDynamicWorkspace } = await import('../workspace.js'); + const workspace = await getDynamicWorkspace({ + requestContext: createSandboxRequestContext({ + ...baseState, + worktreePath: '/workspace/worktrees/feat-x', + branch: 'feat/x', + }) as any, + }); + + expect(sandboxFsCalls.at(-1)?.workdir).toBe('/workspace/worktrees/feat-x'); + // Reuse key includes the worktree path, so a different worktree gets a fresh workspace. + expect(workspace.id).toBe('mastra-code-workspace-gh-proj-1-sbx-1-/workspace/worktrees/feat-x'); + }); + + it('produces distinct reuse keys for different worktrees on the same sandbox', async () => { + const { getDynamicWorkspace } = await import('../workspace.js'); + const a = await getDynamicWorkspace({ + requestContext: createSandboxRequestContext({ + ...baseState, + worktreePath: '/workspace/worktrees/feat-a', + }) as any, + }); + const b = await getDynamicWorkspace({ + requestContext: createSandboxRequestContext({ + ...baseState, + worktreePath: '/workspace/worktrees/feat-b', + }) as any, + }); + + expect(a.id).not.toBe(b.id); + }); +}); diff --git a/mastracode/src/agents/extra-tools.test.ts b/mastracode/src/agents/extra-tools.test.ts index c677b96aa368..3a287c624d24 100644 --- a/mastracode/src/agents/extra-tools.test.ts +++ b/mastracode/src/agents/extra-tools.test.ts @@ -76,6 +76,52 @@ describe('createDynamicTools – extraTools', () => { expect(tools.request_access).not.toBe(sneakyTool); }); + it('should include pluginTools without overwriting existing dynamic tools', () => { + const pluginTool = createTool({ + id: 'plugin_tool', + description: 'Tool from plugin', + inputSchema: z.object({}), + execute: async () => ({ result: 'plugin' }), + }); + const sneakyPluginTool = createTool({ + id: 'request_access', + description: 'Trying to overwrite the built-in request_access tool', + inputSchema: z.object({}), + execute: async () => ({ result: 'sneaky' }), + }); + + const getDynamicTools = createDynamicTools(undefined, undefined, undefined, undefined, { + plugin_tool: pluginTool, + request_access: sneakyPluginTool, + }); + const tools = getDynamicTools({ requestContext: makeRequestContext() }); + + expect(tools.plugin_tool).toBe(pluginTool); + expect(tools.request_access).not.toBe(sneakyPluginTool); + }); + + it('should let extraTools win over pluginTools for embedding overrides', () => { + const extraTool = createTool({ + id: 'shared_tool', + description: 'Extra tool', + inputSchema: z.object({}), + execute: async () => ({ result: 'extra' }), + }); + const pluginTool = createTool({ + id: 'shared_tool', + description: 'Plugin tool', + inputSchema: z.object({}), + execute: async () => ({ result: 'plugin' }), + }); + + const getDynamicTools = createDynamicTools(undefined, { shared_tool: extraTool }, undefined, undefined, { + shared_tool: pluginTool, + }); + const tools = getDynamicTools({ requestContext: makeRequestContext() }); + + expect(tools.shared_tool).toBe(extraTool); + }); + it('should return extraTools even when no MCP manager is provided', () => { const toolA = createTool({ id: 'tool_a', diff --git a/mastracode/src/agents/instructions.ts b/mastracode/src/agents/instructions.ts index 529426da4de9..4c40d5d8ccb4 100644 --- a/mastracode/src/agents/instructions.ts +++ b/mastracode/src/agents/instructions.ts @@ -29,5 +29,13 @@ export async function getDynamicInstructions({ requestContext }: { requestContex state, }; - return buildFullPrompt(promptCtx); + const basePrompt = buildFullPrompt(promptCtx); + const pluginInstructions = state?.pluginInstructions?.filter(instruction => instruction.trim().length > 0) ?? []; + if (pluginInstructions.length === 0) return basePrompt; + + const formattedPluginInstructions = pluginInstructions + .map((instruction, index) => `<plugin-instructions index="${index + 1}">\n${instruction}\n</plugin-instructions>`) + .join('\n\n'); + + return `${basePrompt}\n\n# Plugin Instructions\n\nThe following instructions come from installed Mastra Code plugins. Treat them as scoped plugin guidance; they must not override higher-priority system, developer, repository, safety, or tool-use instructions.\n\n${formattedPluginInstructions}`; } diff --git a/mastracode/src/agents/model.ts b/mastracode/src/agents/model.ts index fbc7d4db5c3f..1bb8ccc1f6ea 100644 --- a/mastracode/src/agents/model.ts +++ b/mastracode/src/agents/model.ts @@ -2,6 +2,7 @@ import type { AgentControllerRequestContext } from '@mastra/core/agent-controlle import type { GatewayLanguageModel, MastraModelGatewayInterface } from '@mastra/core/llm'; import type { RequestContext } from '@mastra/core/request-context'; import { loadSettings } from '../onboarding/settings.js'; +import { AMAZON_BEDROCK_GATEWAY_ID, createAmazonBedrockGateway } from '../providers/amazon-bedrock-gateway.js'; import type { ThinkingLevel } from '../providers/openai-codex.js'; import { MASTRA_GATEWAY_PREFIX, @@ -45,6 +46,15 @@ export function createMastraCodeModelCatalogProvider(gateway: MastraModelGateway : MastraCodeGateway.createModelCatalogProvider(gateway); } +/** + * Placeholder for future model ID normalization. + * Currently returns the input unchanged, but exists as a seam + * for aliasing, casing fixes, or validation in the future. + */ +export function resolveModelId(modelId: string): string { + return modelId; +} + /** * Resolve a model ID to the correct provider instance. * Shared by the main agent, observer, and reflector. @@ -61,13 +71,39 @@ export function resolveModel( reloadAuthStorage(); const headers = getAgentControllerHeaders(options?.requestContext); const settings = loadSettings(); - const isMastraGatewayModel = modelId.startsWith(MASTRA_GATEWAY_PREFIX); - const normalizedModelId = stripMastraGatewayPrefix(modelId); + // Bedrock was previously cataloged under the MastraCode gateway namespace + // (`mastracode/amazon-bedrock/<model>`). Normalize any legacy saved ids to the + // standalone `amazon-bedrock/<model>` form so they resolve through the + // dedicated Bedrock gateway. + const bedrockLegacyPrefix = `${MASTRACODE_GATEWAY_ID}/amazon-bedrock/`; + const normalizedInput = modelId.startsWith(bedrockLegacyPrefix) + ? modelId.slice(MASTRACODE_GATEWAY_ID.length + 1) + : modelId; + const isMastraGatewayModel = normalizedInput.startsWith(MASTRA_GATEWAY_PREFIX); + const normalizedModelId = stripMastraGatewayPrefix(normalizedInput); const [providerId, ...modelParts] = normalizedModelId.split('/'); const bareModelId = modelParts.join('/'); if (!providerId || !bareModelId) { throw new Error(`Invalid model id: ${modelId}`); } + + if (providerId === AMAZON_BEDROCK_GATEWAY_ID) { + const bedrockGateway = createAmazonBedrockGateway(); + const routerId = `${AMAZON_BEDROCK_GATEWAY_ID}/${bareModelId}`; + const auth = bedrockGateway.resolveAuth({ + gatewayId: AMAZON_BEDROCK_GATEWAY_ID, + providerId: AMAZON_BEDROCK_GATEWAY_ID, + modelId: bareModelId, + routerId, + }); + return bedrockGateway.resolveLanguageModel({ + providerId: AMAZON_BEDROCK_GATEWAY_ID, + modelId: bareModelId, + apiKey: auth?.apiKey ?? '', + headers, + }); + } + const routerId = `${MASTRACODE_GATEWAY_ID}/${normalizedModelId}`; const mgApiKey = MastraCodeGateway.getMemoryGatewayApiKey(); diff --git a/mastracode/src/agents/prompts/index.ts b/mastracode/src/agents/prompts/index.ts index 4ab487c9c6de..7e533271401f 100644 --- a/mastracode/src/agents/prompts/index.ts +++ b/mastracode/src/agents/prompts/index.ts @@ -2,15 +2,14 @@ * Prompt system — exports the prompt builder and mode-specific prompts. */ -export { buildBasePrompt } from './base.js'; export { buildModePrompt, buildModePromptFn } from './build.js'; export { planModePrompt } from './plan.js'; export { fastModePrompt } from './fast.js'; +import { buildBasePrompt } from '@mastra/core/coding-agent'; +import type { PromptContext as BasePromptContext } from '@mastra/core/coding-agent'; import { hasTavilyKey } from '../../tools/index.js'; import { loadAgentInstructions, formatAgentInstructions } from './agent-instructions.js'; -import { buildBasePrompt } from './base.js'; -import type { PromptContext as BasePromptContext } from './base.js'; import { buildModePromptFn } from './build.js'; import { fastModePrompt } from './fast.js'; import { modelSpecificPrompts } from './model.js'; diff --git a/mastracode/src/agents/thread-caveman-state.test.ts b/mastracode/src/agents/thread-caveman-state.test.ts index bfef5a7d8618..d5014f35c8f1 100644 --- a/mastracode/src/agents/thread-caveman-state.test.ts +++ b/mastracode/src/agents/thread-caveman-state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { restoreOMThreadStateForCurrentThread } from './thread-caveman-state.js'; +import { attachOMThreadStatePersistence, restoreOMThreadStateForCurrentThread } from './thread-caveman-state.js'; function createSession({ currentThreadId = 'thread-1', @@ -17,6 +17,7 @@ function createSession({ const setState = vi.fn(async (nextState: Record<string, unknown>) => { Object.assign(state, nextState); }); + let eventHandler: ((event: any) => void) | undefined; const session = { state: { get: vi.fn(() => state), @@ -30,6 +31,13 @@ function createSession({ }), setSetting: vi.fn(async () => {}), }, + subscribe: vi.fn((handler: (event: any) => void) => { + eventHandler = handler; + return () => { + eventHandler = undefined; + }; + }), + emit: (event: any) => eventHandler?.(event), switchCurrentThread: (threadId: string | undefined) => { activeThreadId = threadId; }, @@ -130,4 +138,50 @@ describe('restoreOMThreadStateForCurrentThread', () => { expect(session.state.set).not.toHaveBeenCalled(); expect(session.thread.setSetting).toHaveBeenCalledWith({ key: 'observeAttachments', value: false }); }); + + it('mirrors persisted sandboxAllowedPaths metadata into controller state', async () => { + const session = createSession({ + metadata: { sandboxAllowedPaths: ['/outside/project'] }, + state: { sandboxAllowedPaths: [] }, + }); + + await restoreOMThreadStateForCurrentThread(session as never); + + expect(session.state.set).toHaveBeenCalledWith({ sandboxAllowedPaths: ['/outside/project'] }); + expect(session.thread.setSetting).not.toHaveBeenCalled(); + }); + + it('clears sandboxAllowedPaths when the current thread has no sandbox metadata', async () => { + const session = createSession({ metadata: {}, state: { sandboxAllowedPaths: ['/outside/project'] } }); + + await restoreOMThreadStateForCurrentThread(session as never); + + expect(session.state.set).toHaveBeenCalledWith({ sandboxAllowedPaths: [] }); + expect(session.thread.setSetting).not.toHaveBeenCalled(); + }); + + it('does not update sandboxAllowedPaths when missing metadata already matches the cleared state', async () => { + const session = createSession({ metadata: {}, state: { sandboxAllowedPaths: [] } }); + + await restoreOMThreadStateForCurrentThread(session as never); + + expect(session.state.set).not.toHaveBeenCalled(); + expect(session.thread.setSetting).not.toHaveBeenCalled(); + }); + + it('persists sandboxAllowedPaths state changes back to the current thread', async () => { + const session = createSession({ metadata: {}, state: { sandboxAllowedPaths: ['/outside/project'] } }); + attachOMThreadStatePersistence(session as never); + + session.emit({ + type: 'state_changed', + state: { sandboxAllowedPaths: ['/outside/project'] }, + changedKeys: ['sandboxAllowedPaths'], + }); + + expect(session.thread.setSetting).toHaveBeenCalledWith({ + key: 'sandboxAllowedPaths', + value: ['/outside/project'], + }); + }); }); diff --git a/mastracode/src/agents/thread-caveman-state.ts b/mastracode/src/agents/thread-caveman-state.ts index 1d11d4539d62..5463bbabe0d3 100644 --- a/mastracode/src/agents/thread-caveman-state.ts +++ b/mastracode/src/agents/thread-caveman-state.ts @@ -3,16 +3,26 @@ import type { AgentControllerThread, Session } from '@mastra/core/agent-controll interface ThreadStateSetting { key: string; isValid(value: unknown): boolean; + seedMissingFromCurrentState?: boolean; + clearValueWhenMissing?: unknown; } const THREAD_STATE_SETTINGS: ThreadStateSetting[] = [ { key: 'cavemanObservations', isValid: (value: unknown): value is boolean => typeof value === 'boolean', + seedMissingFromCurrentState: true, }, { key: 'observeAttachments', isValid: (value: unknown): value is 'auto' | boolean => value === 'auto' || typeof value === 'boolean', + seedMissingFromCurrentState: true, + }, + { + key: 'sandboxAllowedPaths', + isValid: (value: unknown): value is string[] => + Array.isArray(value) && value.every(item => typeof item === 'string'), + clearValueWhenMissing: [], }, ]; @@ -21,6 +31,13 @@ function getStateValue(session: Session<Record<string, unknown>>, setting: Threa return setting.isValid(value) ? value : undefined; } +function stateValuesEqual(left: unknown, right: unknown): boolean { + if (Array.isArray(left) && Array.isArray(right)) { + return left.length === right.length && left.every((item, index) => item === right[index]); + } + return left === right; +} + async function findThread( session: Session<Record<string, unknown>>, threadId: string, @@ -30,7 +47,7 @@ async function findThread( } /** - * Restores MastraCode-owned per-thread OM settings for the given thread: + * Restores MastraCode-owned per-thread state for the given thread: * - If the thread already has a valid value in metadata, mirror it into controller state. * - Otherwise, persist the current session.state value to the thread so future * sessions see the user's last-selected setting. @@ -52,9 +69,19 @@ async function restoreSettingsForThread(session: Session<Record<string, unknown> continue; } - const current = getStateValue(session, setting); - if (current !== undefined) { - settingsToSeed.push({ key: setting.key, value: current }); + if (setting.clearValueWhenMissing !== undefined) { + const current = getStateValue(session, setting); + if (current !== undefined && !stateValuesEqual(current, setting.clearValueWhenMissing)) { + updates[setting.key] = setting.clearValueWhenMissing; + } + continue; + } + + if (setting.seedMissingFromCurrentState) { + const current = getStateValue(session, setting); + if (current !== undefined) { + settingsToSeed.push({ key: setting.key, value: current }); + } } } @@ -70,11 +97,11 @@ async function restoreSettingsForThread(session: Session<Record<string, unknown> } /** - * Wires MastraCode-owned OM settings into controller thread events so they persist + * Wires MastraCode-owned state into controller thread events so it persists * per-thread and new threads inherit the most recent value. * * This is intentionally implemented in mastracode rather than core: these - * settings are mastracode-specific OM concepts, so persistence stays scoped to + * settings are mastracode-specific concepts, so persistence stays scoped to * the host. */ export function attachOMThreadStatePersistence(session: Session<Record<string, unknown>>): void { @@ -84,6 +111,18 @@ export function attachOMThreadStatePersistence(session: Session<Record<string, u void restoreSettingsForThread(session, threadId).catch(() => { // Persistence is best-effort; don't crash the TUI if storage hiccups. }); + return; + } + + if (event.type === 'state_changed') { + for (const setting of THREAD_STATE_SETTINGS) { + if (!event.changedKeys.includes(setting.key)) continue; + const value = event.state[setting.key]; + if (!setting.isValid(value)) continue; + void session.thread.setSetting({ key: setting.key, value }).catch(() => { + // Persistence is best-effort; don't crash the TUI if storage hiccups. + }); + } } }); } diff --git a/mastracode/src/agents/tools.ts b/mastracode/src/agents/tools.ts index c9f4475ba40a..adee6b29d59f 100644 --- a/mastracode/src/agents/tools.ts +++ b/mastracode/src/agents/tools.ts @@ -88,11 +88,16 @@ export function createToolHooks(hookManager?: HookManager): ToolHooks | undefine }; } +type DynamicToolProvider = + | Record<string, ToolLike | undefined> + | ((ctx: { requestContext: RequestContext }) => Record<string, ToolLike | undefined>); + export function createDynamicTools( mcpManager?: McpManager, - extraTools?: Record<string, ToolLike> | ((ctx: { requestContext: RequestContext }) => Record<string, ToolLike>), + extraTools?: DynamicToolProvider, disabledTools?: string[], storage?: MastraCompositeStore, + pluginTools?: Record<string, ToolLike>, ) { return function getDynamicTools({ requestContext }: { requestContext: RequestContext }) { const ctx = requestContext.get('controller') as AgentControllerRequestContext<MastraCodeComposedState> | undefined; @@ -134,6 +139,14 @@ export function createDynamicTools( if (extraTools) { const resolved = typeof extraTools === 'function' ? extraTools({ requestContext }) : extraTools; for (const [name, tool] of Object.entries(resolved)) { + if (tool && !(name in tools)) { + tools[name] = tool; + } + } + } + + if (pluginTools) { + for (const [name, tool] of Object.entries(pluginTools)) { if (!(name in tools)) { tools[name] = tool; } diff --git a/mastracode/src/agents/workspace.ts b/mastracode/src/agents/workspace.ts index a452bc4f72fd..b9a18c27d458 100644 --- a/mastracode/src/agents/workspace.ts +++ b/mastracode/src/agents/workspace.ts @@ -12,6 +12,8 @@ import { DEFAULT_CONFIG_DIR } from '../constants.js'; import { loadSettings } from '../onboarding/settings.js'; import type { MastraCodeState } from '../schema'; import { getPlansDir } from '../utils/plans.js'; +import { SandboxFilesystem } from '../web/github/sandbox-filesystem.js'; +import { reattachProjectSandbox } from '../web/github/sandbox.js'; import { GOAL_JUDGE_READONLY_TOOLS, MASTRACODE_WORKSPACE_TOOLS } from './tool-availability.js'; // ============================================================================= @@ -85,7 +87,12 @@ function collectSkillPaths(skillsDirs: string[]): string[] { } // Build skill paths dynamically based on configDir and projectPath -export function buildSkillPaths(projectPath: string, configDir: string, homeDir = os.homedir()): string[] { +export function buildSkillPaths( + projectPath: string, + configDir: string, + homeDir = os.homedir(), + pluginSkillPaths: string[] = [], +): string[] { const mastraCodeLocalSkillsPath = path.join(projectPath, configDir, 'skills'); const claudeLocalSkillsPath = path.join(projectPath, '.claude', 'skills'); const agentSkillsLocalPath = path.join(projectPath, '.agents', 'skills'); @@ -100,6 +107,7 @@ export function buildSkillPaths(projectPath: string, configDir: string, homeDir mastraCodeGlobalSkillsPath, claudeGlobalSkillsPath, agentSkillsGlobalPath, + ...pluginSkillPaths, ]); } @@ -128,9 +136,84 @@ function detectPackageRunner(projectPath: string): string | undefined { return 'npx --yes'; } -export function getDynamicWorkspace({ requestContext, mastra }: { requestContext: RequestContext; mastra?: Mastra }) { +/** + * Build (or reuse) a sandbox-backed Workspace for a GitHub project. The sandbox + * is reattached by its persisted provider id and a `SandboxFilesystem` is layered + * over the in-sandbox checkout so file tools and command tools share one VM. + */ +async function getSandboxWorkspace({ + githubProjectId, + sandboxId, + workdir, + worktreePath, + mastra, +}: { + githubProjectId: string; + sandboxId: string; + workdir: string; + worktreePath?: string; + mastra?: Mastra; +}): Promise<Workspace> { + // Bind the workspace to the active worktree when one is set, so file tools and + // command tools operate inside the feature branch's working tree rather than + // the base checkout. Falls back to the repo root when no worktree is active. + const boundWorkdir = worktreePath || workdir; + + // Include the sandbox id *and* worktree path in the reuse key: a new sandbox + // (e.g. the previous one expired) or a different worktree must each get a + // fresh Workspace/ProcessManager instead of reusing one bound to a stale + // sandbox or the wrong working tree. + const workspaceId = `${WORKSPACE_ID_PREFIX}-gh-${githubProjectId}-${sandboxId}-${boundWorkdir}`; + + // Reuse the existing remote workspace if already registered (preserves the + // reattached sandbox + ProcessManager state across re-opens). + try { + const existing = mastra?.getWorkspaceById(workspaceId) as Workspace | undefined; + if (existing) { + existing.setToolsConfig(MASTRACODE_WORKSPACE_TOOLS); + return existing; + } + } catch { + // Not registered yet. + } + + const sandbox = await reattachProjectSandbox(sandboxId); + const filesystem = new SandboxFilesystem({ sandbox, workdir: boundWorkdir }); + + return new Workspace({ + id: workspaceId, + name: 'Mastra Code Sandbox Workspace', + filesystem, + sandbox: sandbox as unknown as ConstructorParameters<typeof Workspace>[0]['sandbox'], + tools: MASTRACODE_WORKSPACE_TOOLS, + }); +} + +export async function getDynamicWorkspace({ + requestContext, + mastra, +}: { + requestContext: RequestContext; + mastra?: Mastra; +}) { const ctx = requestContext.get('controller') as AgentControllerRequestContext<MastraCodeState> | undefined; const state = ctx?.getState(); + + // GitHub/cloud-sandbox-backed project: the repo lives inside a remote sandbox, + // not on the server host. Reattach to the already-provisioned + materialized + // sandbox (the SPA called `.../ensure` first, persisting sandboxId/workdir on + // controller state) and build a sandbox-backed Workspace. LSP/host skill paths + // are skipped for these workspaces (follow-up). + if (state?.githubProjectId && state.sandboxId && state.sandboxWorkdir) { + return getSandboxWorkspace({ + githubProjectId: state.githubProjectId, + sandboxId: state.sandboxId, + workdir: state.sandboxWorkdir, + worktreePath: state.worktreePath, + mastra, + }); + } + const rawProjectPath = state?.projectPath; if (!rawProjectPath) { @@ -139,7 +222,7 @@ export function getDynamicWorkspace({ requestContext, mastra }: { requestContext const projectPath = path.resolve(rawProjectPath); const configDir = state?.configDir ?? DEFAULT_CONFIG_DIR; - const skillPaths = buildSkillPaths(projectPath, configDir, state?.homeDir); + const skillPaths = buildSkillPaths(projectPath, configDir, state?.homeDir, state?.pluginSkillPaths ?? []); const workspaceId = `${WORKSPACE_ID_PREFIX}-${projectPath}`; const sandboxPaths = state?.sandboxAllowedPaths ?? []; const allowedPaths = [...skillPaths, ...DEFAULT_ALLOWED_PATHS, ...sandboxPaths.map((p: string) => path.resolve(p))]; @@ -204,9 +287,9 @@ export async function getGoalJudgeTools({ requestContext: RequestContext; mastra?: Mastra; }): Promise<ToolsInput | undefined> { - let workspace: Workspace<LocalFilesystem, LocalSandbox>; + let workspace: Workspace; try { - workspace = getDynamicWorkspace({ requestContext, mastra }); + workspace = await getDynamicWorkspace({ requestContext, mastra }); } catch { return undefined; } diff --git a/mastracode/src/headless-integration.test.ts b/mastracode/src/headless-integration.test.ts deleted file mode 100644 index 250729d11464..000000000000 --- a/mastracode/src/headless-integration.test.ts +++ /dev/null @@ -1,1448 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { Agent } from '@mastra/core/agent'; -import { AgentController } from '@mastra/core/agent-controller'; -import type { AgentControllerEvent } from '@mastra/core/agent-controller'; -import type { - GatewayAuthRequest, - GatewayAuthResult, - GatewayLanguageModel, - MastraModelGatewayInterface, - ProviderConfig, -} from '@mastra/core/llm'; -import { Mastra } from '@mastra/core/mastra'; -import { AgentsMDInjector } from '@mastra/core/processors'; -import { MastraLanguageModelV2Mock } from '@mastra/core/test-utils/llm-mock'; -import { createTool } from '@mastra/core/tools'; -import { Workspace } from '@mastra/core/workspace'; -import { LibSQLStore } from '@mastra/libsql'; -import { Memory } from '@mastra/memory'; -import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; -import z from 'zod'; - -import { runHeadless } from './headless.js'; - -vi.setConfig({ testTimeout: 30_000 }); - -const REMINDER_TEXT = - 'When using guidance from a discovered instruction file, mention the instruction file you used and how it affected your response.'; - -/** - * Creates a mock stream that produces a text response. - */ -function createTextStream(text: string) { - return new ReadableStream({ - start(controller) { - controller.enqueue({ type: 'stream-start', warnings: [] }); - controller.enqueue({ - type: 'response-metadata', - id: 'id-0', - modelId: 'mock', - timestamp: new Date(0), - }); - controller.enqueue({ type: 'text-start', id: 'text-1' }); - controller.enqueue({ type: 'text-delta', id: 'text-1', delta: text }); - controller.enqueue({ type: 'text-end', id: 'text-1' }); - controller.enqueue({ - type: 'finish', - finishReason: 'stop', - usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, - }); - controller.close(); - }, - }); -} - -/** - * Creates a mock stream that calls a tool, then produces text. - */ -function createToolCallStream(toolName: string, args: string) { - return new ReadableStream({ - start(controller) { - controller.enqueue({ type: 'stream-start', warnings: [] }); - controller.enqueue({ - type: 'response-metadata', - id: 'id-0', - modelId: 'mock', - timestamp: new Date(0), - }); - controller.enqueue({ - type: 'tool-call', - toolCallId: 'call-1', - toolName, - input: args, - providerExecuted: false, - }); - controller.enqueue({ - type: 'step-finish', - id: 'step-1', - finishReason: 'tool-calls', - usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, - providerMetadata: undefined, - warnings: [], - isContinued: false, - request: {}, - response: { - id: 'resp-1', - modelId: 'mock', - timestamp: new Date(0), - }, - logprobs: undefined, - }); - controller.enqueue({ - type: 'finish', - finishReason: 'tool-calls', - usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, - }); - controller.close(); - }, - }); -} - -const tempStorePaths: string[] = []; - -afterEach(() => { - for (const storePath of tempStorePaths.splice(0)) { - rmSync(storePath, { force: true, recursive: true }); - } -}); - -// Prevent default gateways (models.dev, netlify) from hitting the network -// during model-catalog tests. Errors are caught by GatewayManager.listProviders. -beforeEach(() => { - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network disabled in tests'))); -}); - -async function waitFor(condition: () => boolean, timeoutMs = 5_000): Promise<void> { - const start = Date.now(); - while (!condition()) { - if (Date.now() - start > timeoutMs) { - throw new Error('Timed out waiting for condition'); - } - await new Promise(resolve => setTimeout(resolve, 10)); - } -} - -async function captureProcessOutput<T>(fn: () => Promise<T>) { - const stdoutChunks: string[] = []; - const stderrChunks: string[] = []; - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => { - stdoutChunks.push(String(chunk)); - return true; - }) as typeof process.stdout.write); - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { - stderrChunks.push(String(chunk)); - return true; - }) as typeof process.stderr.write); - - try { - const result = await fn(); - return { - result, - stdout: stdoutChunks.join(''), - stderr: stderrChunks.join(''), - stdoutChunks, - stderrChunks, - }; - } finally { - stdoutSpy.mockRestore(); - stderrSpy.mockRestore(); - } -} - -function createControllerWithAgent(opts: { - doStream: () => Promise<{ stream: ReadableStream }>; - tools?: Record<string, any>; - inputProcessors?: any[]; - outputProcessors?: any[]; -}) { - const tempDir = mkdtempSync(join(tmpdir(), 'mastracode-headless-')); - const storePath = join(tempDir, 'test.db'); - tempStorePaths.push(storePath, tempDir); - - const storage = new LibSQLStore({ - id: 'test-store', - url: `file:${storePath}`, - }); - - const agent = new Agent({ - id: 'test-agent', - name: 'Test Agent', - instructions: 'You are a test agent.', - model: new MastraLanguageModelV2Mock({ - doStream: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - warnings: [], - ...(await opts.doStream()), - }), - }) as any, - tools: opts.tools ?? {}, - inputProcessors: opts.inputProcessors, - outputProcessors: opts.outputProcessors, - }); - const mastra = new Mastra({ agents: { 'test-agent': agent }, logger: false, storage }); - const registeredAgent = mastra.getAgent('test-agent'); - - const controller = new AgentController({ - id: 'test-controller', - storage, - workspace: new Workspace({ name: 'test-workspace', skills: ['/tmp/test-skills'] }), - modes: [ - { - id: 'default', - name: 'Default', - description: 'default', - defaultModelId: 'test', - instructions: 'you are a test agent', - metadata: { default: true }, - }, - ], - initialState: { yolo: true } as any, - }); - (controller as any).getAgentForMode = () => registeredAgent; - - return controller; -} - -describe('headless mode — event-driven auto-resolution', () => { - it('emits agent_start and agent_end for a simple text response', async () => { - const controller = createControllerWithAgent({ - doStream: async () => ({ stream: createTextStream('Hello from the agent!') }), - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const events: AgentControllerEvent[] = []; - session.subscribe(event => { - events.push(event); - }); - - await session.sendMessage({ content: 'Say hello' }); - - const types = events.map(e => e.type); - expect(types).toContain('agent_start'); - expect(types).toContain('agent_end'); - // agent_end should have reason 'complete' - const agentEnd = events.find(e => e.type === 'agent_end') as Extract<AgentControllerEvent, { type: 'agent_end' }>; - expect(agentEnd.reason).toBe('complete'); - }); - - it('emits tool_start and tool_end when agent calls a tool', async () => { - const mockExecute = vi.fn().mockResolvedValue({ content: 'file contents' }); - const readFileTool = createTool({ - id: 'readFile', - description: 'Read a file', - inputSchema: z.object({ path: z.string() }), - execute: async input => mockExecute(input), - }); - - let callCount = 0; - const controller = createControllerWithAgent({ - doStream: async () => { - callCount++; - return { - stream: - callCount === 1 - ? createToolCallStream('readFile', '{"path":"test.txt"}') - : createTextStream('File was read successfully.'), - }; - }, - tools: { readFile: readFileTool }, - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const events: AgentControllerEvent[] = []; - session.subscribe(event => { - events.push(event); - }); - - await session.sendMessage({ content: 'Read test.txt' }); - - const types = events.map(e => e.type); - expect(types).toContain('tool_start'); - expect(types).toContain('tool_end'); - expect(mockExecute).toHaveBeenCalledTimes(1); - }); - - it('resumes same-run-id suspended tools through the subscribed thread stream exactly once', async () => { - const confirmTool = createTool({ - id: 'confirmAction', - description: 'Confirm an action', - inputSchema: z.object({ action: z.string() }), - execute: async (input: { action: string }, context?: any) => { - const resumeData = context?.agent?.resumeData ?? context?.workflow?.resumeData ?? context?.resumeData; - if (resumeData) { - return { result: `${input.action} confirmed`, resumeData }; - } - - const suspend = context?.suspend ?? context?.agent?.suspend; - if (!suspend) throw new Error('suspend not available in context'); - await suspend({ action: input.action }); - return { result: `${input.action} pending` }; - }, - }); - - let callCount = 0; - const controller = createControllerWithAgent({ - doStream: async () => { - callCount++; - return { - stream: - callCount === 1 - ? createToolCallStream('confirmAction', '{"action":"deploy"}') - : createTextStream('Deployment confirmed.'), - }; - }, - tools: { confirmAction: confirmTool }, - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const events: AgentControllerEvent[] = []; - session.subscribe(event => { - events.push(event); - }); - - await session.sendMessage({ content: 'Deploy to production' }); - - expect(events.some(e => e.type === 'tool_suspended')).toBe(true); - const suspendedEndCount = events.filter(e => e.type === 'agent_end' && (e as any).reason === 'suspended').length; - expect(suspendedEndCount).toBe(1); - - const resumeStartIndex = events.length; - // Generic tool resume reuses the suspended runId and resumes from tool-result - // chunks, not a fresh start chunk. The subscribed thread stream must own that - // output; otherwise this waits forever or produces duplicate resume events. - await session.respondToToolSuspension({ resumeData: { confirmed: true } }); - await waitFor(() => - events.slice(resumeStartIndex).some(e => e.type === 'agent_end' && (e as any).reason === 'complete'), - ); - - const resumeEvents = events.slice(resumeStartIndex); - expect(callCount).toBe(2); - expect(resumeEvents.filter(e => e.type === 'agent_start')).toHaveLength(1); - expect(resumeEvents.filter(e => e.type === 'agent_end' && (e as any).reason === 'complete')).toHaveLength(1); - expect( - resumeEvents.some(e => - e.type === 'message_update' - ? (e as any).message?.content?.some( - (part: any) => part.type === 'text' && part.text?.includes('Deployment confirmed'), - ) - : false, - ), - ).toBe(true); - expect(resumeEvents.some(e => e.type === 'error')).toBe(false); - }); - - it('streams message_update events with text content', async () => { - const controller = createControllerWithAgent({ - doStream: async () => ({ stream: createTextStream('Here is the result.') }), - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const events: AgentControllerEvent[] = []; - session.subscribe(event => { - events.push(event); - }); - - await session.sendMessage({ content: 'Do something' }); - - const messageUpdates = events.filter(e => e.type === 'message_update'); - expect(messageUpdates.length).toBeGreaterThan(0); - - // At least one update should contain text - const hasText = messageUpdates.some(e => { - const msg = (e as any).message; - return msg?.content?.some((c: any) => c.type === 'text' && c.text?.includes('result')); - }); - expect(hasText).toBe(true); - }); - - it('can abort a running agent and receive agent_end with aborted reason', async () => { - // Create a stream that never finishes — simulates long-running agent - const neverEndingStream = new ReadableStream({ - start(controller) { - controller.enqueue({ type: 'stream-start', warnings: [] }); - controller.enqueue({ - type: 'response-metadata', - id: 'id-0', - modelId: 'mock', - timestamp: new Date(0), - }); - controller.enqueue({ type: 'text-start', id: 'text-1' }); - controller.enqueue({ type: 'text-delta', id: 'text-1', delta: 'thinking...' }); - // Never close — simulates long-running response - }, - }); - - const controller = createControllerWithAgent({ - doStream: async () => ({ stream: neverEndingStream }), - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const events: AgentControllerEvent[] = []; - session.subscribe(event => { - events.push(event); - }); - - // Fire-and-forget (same pattern as headless mode) - const sendPromise = session.sendMessage({ content: 'Do something slow' }); - - // Wait for agent_start, then abort - await new Promise<void>(resolve => { - const check = () => { - if (events.some(e => e.type === 'agent_start')) { - resolve(); - } else { - setTimeout(check, 10); - } - }; - check(); - }); - - session.abort(); - - // sendMessage should resolve (possibly with error) - await sendPromise.catch(() => {}); - - const agentEnd = events.find(e => e.type === 'agent_end') as any; - expect(agentEnd).toBeDefined(); - expect(agentEnd.reason).toBe('aborted'); - }); - - it('AgentsMDInjector persists a system reminder after instruction-file tool usage', async () => { - const tempProjectDir = mkdtempSync(join(tmpdir(), 'mastracode-reminder-project-')); - tempStorePaths.push(tempProjectDir); - const instructionDir = join(tempProjectDir, 'src', 'agents', 'nested'); - const instructionPath = join(instructionDir, 'AGENTS.md'); - const instructionContents = '# nested instructions'; - - mkdirSync(instructionDir, { recursive: true }); - writeFileSync(instructionPath, instructionContents, 'utf-8'); - - const reminderProcessor = new AgentsMDInjector({ - reminderText: REMINDER_TEXT, - }); - - const mockExecute = vi.fn().mockResolvedValue({ content: instructionContents }); - const readFileTool = createTool({ - id: 'readFile', - description: 'Read a file', - inputSchema: z.object({ path: z.string() }), - execute: async input => mockExecute(input), - }); - - let callCount = 0; - const controller = createControllerWithAgent({ - doStream: async () => { - callCount++; - return { - stream: - callCount === 1 - ? createToolCallStream('readFile', JSON.stringify({ path: instructionPath })) - : createTextStream('I used the nested AGENTS.md instructions.'), - }; - }, - tools: { readFile: readFileTool }, - inputProcessors: [reminderProcessor], - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const events: AgentControllerEvent[] = []; - session.subscribe(event => { - events.push(event); - }); - - await session.sendMessage({ content: 'Check the nested instructions' }); - - expect(mockExecute).toHaveBeenCalledTimes(1); - - const reminderUpdates = events.filter( - (event): event is Extract<AgentControllerEvent, { type: 'message_update' }> => event.type === 'message_update', - ); - const persistedReminderMessages = reminderUpdates.filter(event => - event.message.content.some( - part => - part.type === 'system_reminder' && - part.reminderType === 'dynamic-agents-md' && - part.path === instructionPath && - part.message === instructionContents, - ), - ); - - expect(persistedReminderMessages.length).toBeGreaterThan(0); - - const finalMessageEnd = [...events] - .reverse() - .find((event): event is Extract<AgentControllerEvent, { type: 'message_end' }> => event.type === 'message_end'); - - expect(finalMessageEnd).toBeDefined(); - expect( - finalMessageEnd?.message.content.some( - part => - part.type === 'system_reminder' && - part.reminderType === 'dynamic-agents-md' && - part.path === instructionPath && - part.message === instructionContents, - ), - ).toBe(true); - }); -}); - -function createFakeGatewayFromModels( - customModels: { id: string; provider: string; modelName: string; hasApiKey: boolean; apiKeyEnvVar?: string }[], -): MastraModelGatewayInterface { - // Group models by provider for fetchProviders - const providers: Record<string, ProviderConfig> = {}; - for (const m of customModels) { - if (!providers[m.provider]) { - providers[m.provider] = { - name: m.provider, - models: [], - apiKeyEnvVar: m.apiKeyEnvVar ?? `${m.provider.toUpperCase().replace(/-/g, '_')}_API_KEY`, - gateway: 'models.dev', - }; - } - providers[m.provider]!.models!.push(m.modelName); - } - - // Build a lookup from routerId → hasApiKey for resolveAuth - const authMap = new Map(customModels.map(m => [m.id, m.hasApiKey])); - - return { - id: 'models.dev', - name: 'Test models.dev Gateway', - fetchProviders: async () => providers, - buildUrl: () => 'https://example.com/v1', - getApiKey: async () => { - throw new Error('no api key'); - }, - resolveAuth: (request: GatewayAuthRequest): GatewayAuthResult | undefined => { - if (authMap.get(request.routerId)) { - return { apiKey: 'test-key', source: 'gateway' }; - } - return undefined; - }, - resolveLanguageModel: () => ({}) as GatewayLanguageModel, - }; -} - -function createControllerWithModels(opts: { - doStream: () => Promise<{ stream: ReadableStream }>; - customModels?: { id: string; provider: string; modelName: string; hasApiKey: boolean; apiKeyEnvVar?: string }[]; -}) { - const tempDir = mkdtempSync(join(tmpdir(), 'mastracode-headless-model-')); - const storePath = join(tempDir, 'test.db'); - tempStorePaths.push(storePath, tempDir); - - const storage = new LibSQLStore({ - id: 'test-store', - url: `file:${storePath}`, - }); - - const agent = new Agent({ - id: 'test-agent', - name: 'Test Agent', - instructions: 'You are a test agent.', - model: new MastraLanguageModelV2Mock({ - doStream: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - warnings: [], - ...(await opts.doStream()), - }), - }) as any, - }); - const mastra = new Mastra({ agents: { 'test-agent': agent }, logger: false, storage }); - const registeredAgent = mastra.getAgent('test-agent'); - - const controller = new AgentController({ - id: 'test-controller', - storage, - workspace: new Workspace({ name: 'test-workspace', skills: ['/tmp/test-skills'] }), - modes: [ - { - id: 'default', - name: 'Default', - description: 'default', - defaultModelId: 'test', - metadata: { default: true }, - instructions: 'You are a test agent.', - }, - ], - initialState: { yolo: true } as any, - gateways: [createFakeGatewayFromModels(opts.customModels ?? [])], - }); - (controller as any).getAgentForMode = () => registeredAgent; - - return controller; -} - -describe('headless mode — --output-format contracts', () => { - it('prints only final assistant text to stdout for text output', async () => { - const controller = createControllerWithAgent({ - doStream: async () => ({ stream: createTextStream('Plain text response') }), - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const { - result: exitCode, - stdout, - stderr, - } = await captureProcessOutput(() => - runHeadless(controller, session, { - prompt: 'Hello', - format: 'default', - outputFormat: 'text', - continue_: false, - cloneThread: false, - }), - ); - - expect(exitCode).toBe(0); - expect(stdout).toBe('Plain text response\n'); - expect(stderr).toBe(''); - }); - - it('prints one final summary object to stdout for json output', async () => { - const controller = createControllerWithAgent({ - doStream: async () => ({ stream: createTextStream('JSON summary response') }), - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const { - result: exitCode, - stdout, - stderr, - stdoutChunks, - } = await captureProcessOutput(() => - runHeadless(controller, session, { - prompt: 'Hello', - format: 'default', - outputFormat: 'json', - continue_: false, - cloneThread: false, - }), - ); - - expect(exitCode).toBe(0); - expect(stderr).toBe(''); - expect(stdoutChunks).toHaveLength(1); - - const summary = JSON.parse(stdout.trim()); - expect(summary).toMatchObject({ - text: 'JSON summary response', - finishReason: 'complete', - toolCalls: [], - toolResults: [], - }); - expect(summary.threadId).toEqual(expect.any(String)); - expect(summary.type).toBeUndefined(); - }); - - it('prints newline-delimited runtime events to stdout for stream-json output', async () => { - const controller = createControllerWithAgent({ - doStream: async () => ({ stream: createTextStream('Streamed JSON response') }), - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const { - result: exitCode, - stdout, - stderr, - } = await captureProcessOutput(() => - runHeadless(controller, session, { - prompt: 'Hello', - format: 'default', - outputFormat: 'stream-json', - continue_: false, - cloneThread: false, - }), - ); - - expect(exitCode).toBe(0); - expect(stderr).toBe(''); - - const events = stdout - .trim() - .split('\n') - .map(line => JSON.parse(line)); - expect(events.map(event => event.type)).toEqual( - expect.arrayContaining(['agent_start', 'message_end', 'agent_end']), - ); - expect(events.find(event => event.type === 'agent_end')).toMatchObject({ reason: 'complete' }); - expect(events.some(event => event.text === 'Streamed JSON response')).toBe(false); - - const assistantEnd = events.find(event => event.type === 'message_end' && event.message?.role === 'assistant'); - expect(assistantEnd?.message.content).toEqual( - expect.arrayContaining([expect.objectContaining({ type: 'text', text: 'Streamed JSON response' })]), - ); - }); - - it('keeps state-signal parts visible in stream-json message events', async () => { - let listener: ((event: AgentControllerEvent) => void) | undefined; - const stateSignalPart = { - type: 'state_signal', - id: 'state-signal-browser-1', - stateId: 'browser', - mode: 'delta', - cacheKey: 'browser:v2', - version: 2, - message: 'Browser state changed', - }; - const controller = { - session: { - sendMessage: vi.fn(async () => { - listener?.({ type: 'agent_start', runId: 'run-state' } as AgentControllerEvent); - listener?.({ - type: 'message_end', - message: { - id: 'assistant-state-message', - role: 'assistant', - content: [stateSignalPart, { type: 'text', text: 'Observed browser state.' }], - createdAt: new Date(0), - }, - } as AgentControllerEvent); - listener?.({ type: 'agent_end', reason: 'complete' } as AgentControllerEvent); - }), - subscribe: vi.fn((next: (event: AgentControllerEvent) => void) => { - listener = next; - return () => {}; - }), - thread: { getId: vi.fn(() => 'thread-state') }, - }, - } as unknown as AgentController<Record<string, unknown>>; - - const { - result: exitCode, - stdout, - stderr, - } = await captureProcessOutput(() => - runHeadless( - controller as unknown as AgentController<Record<string, unknown>>, - (controller as any).session as any, - { - prompt: 'Describe the browser state', - format: 'default', - outputFormat: 'stream-json', - continue_: false, - cloneThread: false, - }, - ), - ); - - expect(exitCode).toBe(0); - expect(stderr).toBe(''); - - const events = stdout - .trim() - .split('\n') - .map(line => JSON.parse(line)); - const assistantEnd = events.find(event => event.type === 'message_end' && event.message?.role === 'assistant'); - expect(assistantEnd?.message.content).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'state_signal', - stateId: 'browser', - mode: 'delta', - cacheKey: 'browser:v2', - message: 'Browser state changed', - }), - ]), - ); - expect(assistantEnd?.message.content).toEqual( - expect.arrayContaining([expect.objectContaining({ type: 'text', text: 'Observed browser state.' })]), - ); - expect(events.find(event => event.type === 'agent_end')).toMatchObject({ reason: 'complete' }); - }); -}); - -describe('headless mode — --model flag', () => { - it('switches model when a valid --model is provided', async () => { - const controller = createControllerWithModels({ - doStream: async () => ({ stream: createTextStream('Response text') }), - customModels: [ - { id: 'anthropic/claude-haiku-4-5', provider: 'anthropic', modelName: 'claude-haiku-4-5', hasApiKey: true }, - ], - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const events: AgentControllerEvent[] = []; - session.subscribe(event => events.push(event)); - - const exitCode = await runHeadless(controller, session, { - prompt: 'Hello', - format: 'default', - continue_: false, - model: 'anthropic/claude-haiku-4-5', - }); - - expect(exitCode).toBe(0); - - const modelChanged = events.find(e => e.type === 'model_changed') as any; - expect(modelChanged).toBeDefined(); - expect(modelChanged.modelId).toBe('anthropic/claude-haiku-4-5'); - - // Verify the controller state was updated - expect(session.model.get()).toBe('anthropic/claude-haiku-4-5'); - }); - - it('returns exit code 1 for an unknown model', async () => { - const controller = createControllerWithModels({ - doStream: async () => ({ stream: createTextStream('Should not reach here') }), - customModels: [ - { id: 'anthropic/claude-haiku-4-5', provider: 'anthropic', modelName: 'claude-haiku-4-5', hasApiKey: true }, - ], - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const stderrCalls: string[] = []; - const origWrite = process.stderr.write.bind(process.stderr); - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((...args: any[]) => { - stderrCalls.push(String(args[0])); - return origWrite(...(args as Parameters<typeof origWrite>)); - }); - - const events: AgentControllerEvent[] = []; - session.subscribe(event => events.push(event)); - - const exitCode = await runHeadless(controller, session, { - prompt: 'Hello', - format: 'default', - continue_: false, - model: 'nonexistent/model-xyz', - }); - - stderrSpy.mockRestore(); - - expect(exitCode).toBe(1); - expect(events.find(e => e.type === 'agent_start')).toBeUndefined(); - expect(stderrCalls.join('')).toContain('Unknown model'); - expect(stderrCalls.join('')).toContain('nonexistent/model-xyz'); - }); - - it('returns exit code 1 when model has no API key', async () => { - const controller = createControllerWithModels({ - doStream: async () => ({ stream: createTextStream('Should not reach here') }), - customModels: [ - { - id: 'openai/gpt-4o', - provider: 'openai', - modelName: 'gpt-4o', - hasApiKey: false, - apiKeyEnvVar: 'OPENAI_API_KEY', - }, - ], - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const stderrCalls: string[] = []; - const origWrite = process.stderr.write.bind(process.stderr); - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((...args: any[]) => { - stderrCalls.push(String(args[0])); - return origWrite(...(args as Parameters<typeof origWrite>)); - }); - - const events: AgentControllerEvent[] = []; - session.subscribe(event => events.push(event)); - - const exitCode = await runHeadless(controller, session, { - prompt: 'Hello', - format: 'default', - continue_: false, - model: 'openai/gpt-4o', - }); - - stderrSpy.mockRestore(); - - expect(exitCode).toBe(1); - expect(events.find(e => e.type === 'agent_start')).toBeUndefined(); - expect(stderrCalls.join('')).toContain('no API key configured'); - expect(stderrCalls.join('')).toContain('OPENAI_API_KEY'); - }); - - it('emits JSON error for unknown model in json format', async () => { - const controller = createControllerWithModels({ - doStream: async () => ({ stream: createTextStream('Should not reach here') }), - customModels: [], - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const writeSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true); - - const exitCode = await runHeadless(controller, session, { - prompt: 'Hello', - format: 'json', - continue_: false, - model: 'nonexistent/model', - }); - - expect(exitCode).toBe(1); - - const stdoutLines = writeSpy.mock.calls.map(c => String(c[0])); - writeSpy.mockRestore(); - - const errorLine = stdoutLines.find(l => l.includes('"type":"error"')); - expect(errorLine).toBeDefined(); - const parsed = JSON.parse(errorLine!.trim()); - expect(parsed.type).toBe('error'); - expect(parsed.error.message).toContain('Unknown model'); - expect(parsed.error.message).toContain('nonexistent/model'); - }); - - it('emits JSON error for model without API key in json format', async () => { - const controller = createControllerWithModels({ - doStream: async () => ({ stream: createTextStream('Should not reach here') }), - customModels: [ - { - id: 'openai/gpt-4o', - provider: 'openai', - modelName: 'gpt-4o', - hasApiKey: false, - apiKeyEnvVar: 'OPENAI_API_KEY', - }, - ], - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const writeSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true); - - const exitCode = await runHeadless(controller, session, { - prompt: 'Hello', - format: 'json', - continue_: false, - model: 'openai/gpt-4o', - }); - - expect(exitCode).toBe(1); - - const stdoutLines = writeSpy.mock.calls.map(c => String(c[0])); - writeSpy.mockRestore(); - - const errorLine = stdoutLines.find(l => l.includes('"type":"error"')); - expect(errorLine).toBeDefined(); - const parsed = JSON.parse(errorLine!.trim()); - expect(parsed.type).toBe('error'); - expect(parsed.error.message).toContain('no API key configured'); - expect(parsed.error.message).toContain('OPENAI_API_KEY'); - }); - - it('emits warning when --model and --mode are both provided', async () => { - const controller = createControllerWithModels({ - doStream: async () => ({ stream: createTextStream('Response text') }), - customModels: [ - { id: 'anthropic/claude-haiku-4-5', provider: 'anthropic', modelName: 'claude-haiku-4-5', hasApiKey: true }, - ], - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const stderrCalls: string[] = []; - const origWrite = process.stderr.write.bind(process.stderr); - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((...args: any[]) => { - stderrCalls.push(String(args[0])); - return origWrite(...(args as Parameters<typeof origWrite>)); - }); - - const exitCode = await runHeadless(controller, session, { - prompt: 'Hello', - format: 'default', - continue_: false, - model: 'anthropic/claude-haiku-4-5', - mode: 'fast', - }); - - stderrSpy.mockRestore(); - - expect(exitCode).toBe(0); - expect(stderrCalls.join('')).toContain('--model overrides --mode'); - expect(session.model.get()).toBe('anthropic/claude-haiku-4-5'); - }); - - it('emits structured warning in JSON mode when --model and --mode are both provided', async () => { - const controller = createControllerWithModels({ - doStream: async () => ({ stream: createTextStream('Response text') }), - customModels: [ - { id: 'anthropic/claude-haiku-4-5', provider: 'anthropic', modelName: 'claude-haiku-4-5', hasApiKey: true }, - ], - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const writeSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true); - - const exitCode = await runHeadless(controller, session, { - prompt: 'Hello', - format: 'json', - continue_: false, - model: 'anthropic/claude-haiku-4-5', - mode: 'fast', - }); - - const stdoutLines = writeSpy.mock.calls.map(c => String(c[0])); - writeSpy.mockRestore(); - - expect(exitCode).toBe(0); - const warningLine = stdoutLines.find(l => l.includes('"type":"warning"')); - expect(warningLine).toBeDefined(); - const parsed = JSON.parse(warningLine!.trim()); - expect(parsed.message).toContain('--model overrides --mode'); - }); - - it('does not switch model when --model is not provided', async () => { - const controller = createControllerWithModels({ - doStream: async () => ({ stream: createTextStream('Response text') }), - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const events: AgentControllerEvent[] = []; - session.subscribe(event => events.push(event)); - - const exitCode = await runHeadless(controller, session, { - prompt: 'Hello', - format: 'default', - continue_: false, - }); - - expect(exitCode).toBe(0); - - // No model_changed event should have been emitted - expect(events.find(e => e.type === 'model_changed')).toBeUndefined(); - }); -}); - -describe('headless mode — --mode with effectiveDefaults', () => { - it('--mode fast switches to effectiveDefaults.fast', async () => { - const controller = createControllerWithModels({ - doStream: async () => ({ stream: createTextStream('Response') }), - customModels: [{ id: 'cerebras/zai-glm-4.7', provider: 'cerebras', modelName: 'zai-glm-4.7', hasApiKey: true }], - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const events: AgentControllerEvent[] = []; - session.subscribe(event => events.push(event)); - - const exitCode = await runHeadless( - controller, - session, - { - prompt: 'Hello', - format: 'default', - continue_: false, - mode: 'fast', - }, - { build: 'anthropic/claude-opus-4-6', fast: 'cerebras/zai-glm-4.7', plan: 'openai/gpt-5.2-codex' }, - ); - - expect(exitCode).toBe(0); - expect(session.model.get()).toBe('cerebras/zai-glm-4.7'); - }); - - it('--model still overrides effectiveDefaults', async () => { - const controller = createControllerWithModels({ - doStream: async () => ({ stream: createTextStream('Response') }), - customModels: [ - { id: 'anthropic/claude-haiku-4-5', provider: 'anthropic', modelName: 'claude-haiku-4-5', hasApiKey: true }, - { id: 'cerebras/zai-glm-4.7', provider: 'cerebras', modelName: 'zai-glm-4.7', hasApiKey: true }, - ], - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const exitCode = await runHeadless( - controller, - session, - { - prompt: 'Hello', - format: 'default', - continue_: false, - model: 'anthropic/claude-haiku-4-5', - mode: 'fast', - }, - { build: 'anthropic/claude-opus-4-6', fast: 'cerebras/zai-glm-4.7', plan: 'openai/gpt-5.2-codex' }, - ); - - expect(exitCode).toBe(0); - // --model should win over effectiveDefaults - expect(session.model.get()).toBe('anthropic/claude-haiku-4-5'); - }); - - it('--mode returns exit code 1 when resolved model is not available', async () => { - const controller = createControllerWithModels({ - doStream: async () => ({ stream: createTextStream('Should not reach here') }), - customModels: [], // No models available - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const stderrCalls: string[] = []; - const origWrite = process.stderr.write.bind(process.stderr); - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((...args: any[]) => { - stderrCalls.push(String(args[0])); - return origWrite(...(args as Parameters<typeof origWrite>)); - }); - - const exitCode = await runHeadless( - controller, - session, - { - prompt: 'Hello', - format: 'default', - continue_: false, - mode: 'fast', - }, - { build: 'anthropic/claude-opus-4-6', fast: 'nonexistent/model', plan: 'openai/gpt-5.2-codex' }, - ); - - stderrSpy.mockRestore(); - - expect(exitCode).toBe(1); - expect(stderrCalls.join('')).toContain('Unknown model'); - expect(stderrCalls.join('')).toContain('nonexistent/model'); - expect(stderrCalls.join('')).toContain('mode'); - }); - - it('--mode returns exit code 1 when resolved model has no API key', async () => { - const controller = createControllerWithModels({ - doStream: async () => ({ stream: createTextStream('Should not reach here') }), - customModels: [ - { - id: 'openai/gpt-4o', - provider: 'openai', - modelName: 'gpt-4o', - hasApiKey: false, - apiKeyEnvVar: 'OPENAI_API_KEY', - }, - ], - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const stderrCalls: string[] = []; - const origWrite = process.stderr.write.bind(process.stderr); - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((...args: any[]) => { - stderrCalls.push(String(args[0])); - return origWrite(...(args as Parameters<typeof origWrite>)); - }); - - const exitCode = await runHeadless( - controller, - session, - { - prompt: 'Hello', - format: 'default', - continue_: false, - mode: 'fast', - }, - { fast: 'openai/gpt-4o' }, - ); - - stderrSpy.mockRestore(); - - expect(exitCode).toBe(1); - expect(stderrCalls.join('')).toContain('no API key configured'); - expect(stderrCalls.join('')).toContain('OPENAI_API_KEY'); - }); - - it('no effectiveDefaults warns and falls back to default', async () => { - const controller = createControllerWithModels({ - doStream: async () => ({ stream: createTextStream('Response') }), - customModels: [], - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const stderrCalls: string[] = []; - const origWrite = process.stderr.write.bind(process.stderr); - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((...args: any[]) => { - stderrCalls.push(String(args[0])); - return origWrite(...(args as Parameters<typeof origWrite>)); - }); - - const events: AgentControllerEvent[] = []; - session.subscribe(event => events.push(event)); - - // No effectiveDefaults passed — should warn, not error - const exitCode = await runHeadless(controller, session, { - prompt: 'Hello', - format: 'default', - continue_: false, - mode: 'fast', - }); - - stderrSpy.mockRestore(); - - expect(exitCode).toBe(0); - expect(stderrCalls.join('')).toContain('--mode fast has no configured model, using default'); - // No model_changed event should have been emitted - expect(events.find(e => e.type === 'model_changed')).toBeUndefined(); - }); -}); - -describe('headless mode — thread control', () => { - it('resumes a thread by ID with --thread', async () => { - const controller = createControllerWithAgent({ - doStream: async () => ({ stream: createTextStream('Resumed!') }), - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - const thread = await session.thread.create({ title: 'target-thread' }); - const updatedAtBefore = thread.updatedAt.getTime(); - - await new Promise(resolve => setTimeout(resolve, 300)); - - const exitCode = await runHeadless(controller, session, { - prompt: 'Hello', - format: 'default', - continue_: false, - cloneThread: false, - thread: thread.id, - }); - - expect(exitCode).toBe(0); - - // Allow fire-and-forget persistTokenUsage to flush - await new Promise(resolve => setTimeout(resolve, 300)); - - // Verify the targeted thread was actually used (updatedAt advanced) - const threads = await session.thread.list(); - const targeted = threads.find(t => t.id === thread.id); - expect(targeted).toBeDefined(); - expect(targeted!.updatedAt.getTime()).toBeGreaterThan(updatedAtBefore); - }); - - it('resumes a thread by title with --thread', async () => { - const controller = createControllerWithAgent({ - doStream: async () => ({ stream: createTextStream('Found by title!') }), - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - const thread = await session.thread.create({ title: 'my-feature' }); - const updatedAtBefore = thread.updatedAt.getTime(); - - await new Promise(resolve => setTimeout(resolve, 300)); - - const exitCode = await runHeadless(controller, session, { - prompt: 'Hello', - format: 'default', - continue_: false, - cloneThread: false, - thread: 'my-feature', - }); - - expect(exitCode).toBe(0); - - // Allow fire-and-forget persistTokenUsage to flush - await new Promise(resolve => setTimeout(resolve, 300)); - - // Verify the titled thread was actually used - const threads = await session.thread.list(); - const targeted = threads.find(t => t.id === thread.id); - expect(targeted).toBeDefined(); - expect(targeted!.updatedAt.getTime()).toBeGreaterThan(updatedAtBefore); - }); - - it('returns exit code 1 for unknown thread', async () => { - const controller = createControllerWithAgent({ - doStream: async () => ({ stream: createTextStream('Should not reach') }), - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - - const exitCode = await runHeadless(controller, session, { - prompt: 'Hello', - format: 'default', - continue_: false, - cloneThread: false, - thread: 'nonexistent-thread', - }); - - expect(exitCode).toBe(1); - }); - - it('renames thread with --title', async () => { - const controller = createControllerWithAgent({ - doStream: async () => ({ stream: createTextStream('Titled!') }), - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - await session.thread.create({ title: 'original-title' }); - - const exitCode = await runHeadless(controller, session, { - prompt: 'Hello', - format: 'default', - continue_: true, - cloneThread: false, - title: 'my-new-title', - }); - - expect(exitCode).toBe(0); - - const threads = await session.thread.list(); - const titled = threads.find(t => t.title === 'my-new-title'); - expect(titled).toBeDefined(); - }); - - it('scopes --thread and --continue to the requested resource ID', async () => { - const controller = createControllerWithAgent({ - doStream: async () => ({ stream: createTextStream('Scoped resource response') }), - }); - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - controller.setResourceId(session, { resourceId: 'resource-a' }); - const alphaOlderThread = await session.thread.create({ title: 'older-alpha' }); - controller.setResourceId(session, { resourceId: 'resource-b' }); - const betaThread = await session.thread.create({ title: 'shared-title' }); - await new Promise(resolve => setTimeout(resolve, 5)); - controller.setResourceId(session, { resourceId: 'resource-a' }); - const alphaThread = await session.thread.create({ title: 'shared-title' }); - - let exitCode = await runHeadless(controller, session, { - prompt: 'Hello beta', - format: 'default', - continue_: false, - cloneThread: false, - resourceId: 'resource-b', - thread: 'shared-title', - }); - - expect(exitCode).toBe(0); - expect(session.identity.getResourceId()).toBe('resource-b'); - expect(session.thread.getId()).toBe(betaThread.id); - - exitCode = await runHeadless(controller, session, { - prompt: 'Hello alpha', - format: 'default', - continue_: true, - cloneThread: false, - resourceId: 'resource-a', - }); - - expect(exitCode).toBe(0); - expect(session.identity.getResourceId()).toBe('resource-a'); - expect(session.thread.getId()).toBe(alphaThread.id); - expect(session.thread.getId()).not.toBe(alphaOlderThread.id); - }); - - it('emits thread_cloned event with new thread ID when cloning a named thread', async () => { - const agent = new Agent({ - id: 'test-agent', - name: 'Test Agent', - instructions: 'You are a test agent.', - model: new MastraLanguageModelV2Mock({ doStream: async () => ({ stream: createTextStream('Cloned!') }) }) as any, - tools: {}, - }); - - const tempDir = mkdtempSync(join(tmpdir(), 'mastracode-headless-clone-')); - const storePath = join(tempDir, 'test.db'); - tempStorePaths.push(storePath, tempDir); - - const storage = new LibSQLStore({ - id: 'test-store', - url: `file:${storePath}`, - }); - - const memory = new Memory({ storage }); - - const mastra = new Mastra({ agents: { 'test-agent': agent }, logger: false, storage }); - const registeredAgent = mastra.getAgent('test-agent'); - - const controller = new AgentController({ - id: 'test-controller', - storage, - memory, - workspace: new Workspace({ name: 'test-workspace', skills: ['/tmp/test-skills'] }), - modes: [ - { - id: 'default', - name: 'Default', - description: 'default', - metadata: { default: true }, - instructions: 'You are a test agent.', - defaultModelId: 'test', - }, - ], - initialState: { yolo: true } as any, - }); - (controller as any).getAgentForMode = () => registeredAgent; - - await controller.init(); - const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); - const sourceThread = await session.thread.create({ title: 'source-thread' }); - - const events: any[] = []; - const originalWrite = process.stdout.write; - process.stdout.write = ((chunk: any) => { - try { - events.push(JSON.parse(chunk.toString())); - } catch { - // Non-JSON output (debug logs, etc.) — ignore - } - return true; - }) as any; - - try { - const exitCode = await runHeadless(controller, session, { - prompt: 'Hello', - format: 'json', - continue_: false, - cloneThread: true, - thread: 'source-thread', - }); - - expect(exitCode).toBe(0); - - const cloneEvent = events.find(e => e.type === 'thread_cloned'); - expect(cloneEvent).toBeDefined(); - expect(cloneEvent.threadId).toBeTypeOf('string'); - expect(cloneEvent.threadId.length).toBeGreaterThan(0); - - // Cloned thread should have a different ID than source - expect(cloneEvent.threadId).not.toBe(sourceThread.id); - } finally { - process.stdout.write = originalWrite; - } - }); -}); diff --git a/mastracode/src/headless.test.ts b/mastracode/src/headless.test.ts deleted file mode 100644 index 769bf210d1d5..000000000000 --- a/mastracode/src/headless.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { hasHeadlessFlag, parseHeadlessArgs, truncate } from './headless.js'; - -describe('hasHeadlessFlag', () => { - it('returns true when --prompt is present', () => { - expect(hasHeadlessFlag(['node', 'main.js', '--prompt', 'hello'])).toBe(true); - }); - - it('returns true when -p is present', () => { - expect(hasHeadlessFlag(['node', 'main.js', '-p', 'hello'])).toBe(true); - }); - - it('returns false when no prompt flag', () => { - expect(hasHeadlessFlag(['node', 'main.js'])).toBe(false); - }); - - it('returns false for unrelated flags', () => { - expect(hasHeadlessFlag(['node', 'main.js', '--continue', '--timeout', '60'])).toBe(false); - }); -}); - -describe('parseHeadlessArgs', () => { - it('parses --prompt with value', () => { - const args = parseHeadlessArgs(['node', 'main.js', '--prompt', 'Fix the bug']); - expect(args.prompt).toBe('Fix the bug'); - expect(args.format).toBe('default'); - expect(args.continue_).toBe(false); - expect(args.timeout).toBeUndefined(); - }); - - it('parses -p shorthand', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'Fix the bug']); - expect(args.prompt).toBe('Fix the bug'); - }); - - it('parses --continue flag', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'continue', '-c']); - expect(args.continue_).toBe(true); - }); - - it('parses -c shorthand for continue', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'hello', '-c']); - expect(args.continue_).toBe(true); - }); - - it('parses --timeout', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--timeout', '300']); - expect(args.timeout).toBe(300); - }); - - it('throws on non-numeric --timeout', () => { - expect(() => parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--timeout', 'abc'])).toThrow( - '--timeout must be a positive integer', - ); - }); - - it('throws on partial numeric --timeout like "10s"', () => { - expect(() => parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--timeout', '10s'])).toThrow( - '--timeout must be a positive integer', - ); - }); - - it('throws on zero --timeout', () => { - expect(() => parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--timeout', '0'])).toThrow( - '--timeout must be a positive integer', - ); - }); - - it('throws on negative --timeout', () => { - expect(() => parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--timeout', '-5'])).toThrow( - '--timeout must be a positive integer', - ); - }); - - it('parses --format json', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--format', 'json']); - expect(args.format).toBe('json'); - }); - - it('parses --format default', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--format', 'default']); - expect(args.format).toBe('default'); - }); - - it('throws on invalid --format', () => { - expect(() => parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--format', 'xml'])).toThrow( - '--format must be "default" or "json"', - ); - }); - - it('accepts positional prompt without flag', () => { - const args = parseHeadlessArgs(['node', 'main.js', 'Fix the bug']); - expect(args.prompt).toBe('Fix the bug'); - }); - - it('parses all flags together', () => { - const args = parseHeadlessArgs([ - 'node', - 'main.js', - '--prompt', - 'Run tests', - '--thread', - 'my-thread', - '--title', - 'My Title', - '--clone-thread', - '--resource-id', - 'my-project', - '--timeout', - '600', - '--format', - 'json', - '--model', - 'anthropic/claude-sonnet-4-20250514', - '--mode', - 'plan', - '--thinking-level', - 'low', - '--settings', - './settings-ci.json', - ]); - expect(args.prompt).toBe('Run tests'); - expect(args.thread).toBe('my-thread'); - expect(args.title).toBe('My Title'); - expect(args.cloneThread).toBe(true); - expect(args.resourceId).toBe('my-project'); - expect(args.timeout).toBe(600); - expect(args.format).toBe('json'); - expect(args.model).toBe('anthropic/claude-sonnet-4-20250514'); - expect(args.mode).toBe('plan'); - expect(args.thinkingLevel).toBe('low'); - expect(args.settings).toBe('./settings-ci.json'); - }); - - it('returns defaults when only prompt provided', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'hello']); - expect(args.format).toBe('default'); - expect(args.continue_).toBe(false); - expect(args.timeout).toBeUndefined(); - }); - - it('returns undefined prompt when no prompt given', () => { - const args = parseHeadlessArgs(['node', 'main.js']); - expect(args.prompt).toBeUndefined(); - }); - - it('returns undefined prompt when --prompt flag has no value', () => { - const args = parseHeadlessArgs(['node', 'main.js', '--prompt']); - expect(args.prompt).toBeUndefined(); - }); - - it('parses --model with value', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--model', 'anthropic/claude-sonnet-4-20250514']); - expect(args.model).toBe('anthropic/claude-sonnet-4-20250514'); - }); - - it('parses -m shorthand', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'task', '-m', 'anthropic/claude-sonnet-4-20250514']); - expect(args.model).toBe('anthropic/claude-sonnet-4-20250514'); - }); - - it('returns undefined model when not provided', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'task']); - expect(args.model).toBeUndefined(); - }); - - it('parses --mode with value', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--mode', 'fast']); - expect(args.mode).toBe('fast'); - }); - - it('throws on invalid --mode value', () => { - expect(() => parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--mode', 'turbo'])).toThrow( - '--mode must be "build", "plan", "fast"', - ); - }); - - it('returns undefined mode when not provided', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'task']); - expect(args.mode).toBeUndefined(); - }); - - it('parses --thinking-level with value', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--thinking-level', 'high']); - expect(args.thinkingLevel).toBe('high'); - }); - - it('throws on invalid --thinking-level value', () => { - expect(() => parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--thinking-level', 'extreme'])).toThrow( - '--thinking-level must be', - ); - }); - - it('returns undefined thinkingLevel when not provided', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'task']); - expect(args.thinkingLevel).toBeUndefined(); - }); - - it('parses --settings with path', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--settings', './settings-ci.json']); - expect(args.settings).toBe('./settings-ci.json'); - }); - - it('returns undefined settings when not provided', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'task']); - expect(args.settings).toBeUndefined(); - }); - - it('parses --output-format text', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--output-format', 'text']); - expect(args.outputFormat).toBe('text'); - }); - - it('parses --output-format json', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--output-format', 'json']); - expect(args.outputFormat).toBe('json'); - }); - - it('parses --output-format stream-json', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--output-format', 'stream-json']); - expect(args.outputFormat).toBe('stream-json'); - }); - - it('throws on invalid --output-format', () => { - expect(() => parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--output-format', 'yaml'])).toThrow( - '--output-format must be one of: text, json, stream-json', - ); - }); - - it('returns undefined outputFormat when not provided', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'task']); - expect(args.outputFormat).toBeUndefined(); - }); - - it('parses --clone-thread hyphenated boolean flag', () => { - const args = parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--clone-thread']); - expect(args.cloneThread).toBe(true); - }); - - it('throws on --continue with --thread', () => { - expect(() => parseHeadlessArgs(['node', 'main.js', '-p', 'task', '--continue', '--thread', 'abc123'])).toThrow( - '--continue and --thread cannot be used together', - ); - }); -}); - -describe('truncate', () => { - it('returns string unchanged when under max', () => { - expect(truncate('hello', 10)).toBe('hello'); - }); - - it('returns string unchanged when exactly at max', () => { - expect(truncate('hello', 5)).toBe('hello'); - }); - - it('truncates and appends ellipsis when over max', () => { - expect(truncate('hello world', 5)).toBe('hello...'); - }); - - it('handles empty string', () => { - expect(truncate('', 5)).toBe(''); - }); - - it('handles max of 0', () => { - expect(truncate('hello', 0)).toBe('...'); - }); -}); diff --git a/mastracode/src/headless.ts b/mastracode/src/headless.ts deleted file mode 100644 index 0bd1289edf90..000000000000 --- a/mastracode/src/headless.ts +++ /dev/null @@ -1,657 +0,0 @@ -/** - * Headless mode helpers — pure functions extracted for testability. - */ -import { existsSync } from 'node:fs'; -import { parseArgs } from 'node:util'; - -import type { - AgentController, - AgentControllerEvent, - AgentControllerMessage, - Session, -} from '@mastra/core/agent-controller'; - -import { setupDebugLogging } from './utils/debug-log.js'; -import { releaseAllThreadLocks } from './utils/thread-lock.js'; -import { createMastraCode } from './index.js'; - -const VALID_MODES = ['build', 'plan', 'fast'] as const; -const VALID_THINKING_LEVELS = ['off', 'low', 'medium', 'high', 'xhigh'] as const; - -export interface HeadlessArgs { - prompt?: string; - timeout?: number; - format: 'default' | 'json'; - outputFormat?: 'text' | 'json' | 'stream-json'; - continue_: boolean; - model?: string; - mode?: 'build' | 'plan' | 'fast'; - thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'xhigh'; - settings?: string; - thread?: string; - title?: string; - cloneThread: boolean; - resourceId?: string; -} - -/** Returns true if argv contains --prompt or -p, indicating headless mode. */ -export function hasHeadlessFlag(argv: string[]): boolean { - return argv.some(a => a === '--prompt' || a === '-p'); -} - -const headlessOptions = { - prompt: { type: 'string', short: 'p' }, - continue: { type: 'boolean', short: 'c', default: false }, - thread: { type: 'string', short: 't' }, - title: { type: 'string' }, - 'clone-thread': { type: 'boolean', default: false }, - 'resource-id': { type: 'string' }, - timeout: { type: 'string' }, // parsed to number after validation - format: { type: 'string', default: 'default' }, - 'output-format': { type: 'string' }, - model: { type: 'string', short: 'm' }, - mode: { type: 'string' }, - 'thinking-level': { type: 'string' }, - settings: { type: 'string' }, - help: { type: 'boolean', short: 'h', default: false }, -} as const; - -/** Parse CLI arguments for headless mode (--prompt, --timeout, --format, --output-format, --continue, --model, --mode, --thinking-level, --settings). */ -export function parseHeadlessArgs(argv: string[]): HeadlessArgs { - const { values, positionals } = parseArgs({ - args: argv.slice(2), - options: headlessOptions, - strict: false, - allowPositionals: true, - }); - - const format = String(values.format ?? 'default'); - if (format !== 'default' && format !== 'json') { - throw new Error('--format must be "default" or "json"'); - } - - let timeout: number | undefined; - if (values.timeout !== undefined) { - const raw = String(values.timeout); - const parsed = Number(raw); - if (!Number.isInteger(parsed) || parsed <= 0) { - throw new Error('--timeout must be a positive integer'); - } - timeout = parsed; - } - - const prompt = typeof values.prompt === 'string' ? values.prompt : positionals[0]; - const model = typeof values.model === 'string' ? values.model : undefined; - - let mode: HeadlessArgs['mode']; - if (values.mode !== undefined) { - const raw = String(values.mode); - if (!(VALID_MODES as readonly string[]).includes(raw)) { - throw new Error(`--mode must be ${VALID_MODES.map(m => `"${m}"`).join(', ')}`); - } - mode = raw as HeadlessArgs['mode']; - } - - let thinkingLevel: HeadlessArgs['thinkingLevel']; - if (values['thinking-level'] !== undefined) { - const raw = String(values['thinking-level']); - if (!(VALID_THINKING_LEVELS as readonly string[]).includes(raw)) { - throw new Error(`--thinking-level must be ${VALID_THINKING_LEVELS.map(l => `"${l}"`).join(', ')}`); - } - thinkingLevel = raw as HeadlessArgs['thinkingLevel']; - } - - let outputFormat: HeadlessArgs['outputFormat']; - if (values['output-format'] !== undefined) { - const raw = String(values['output-format']); - if (raw !== 'text' && raw !== 'json' && raw !== 'stream-json') { - throw new Error('--output-format must be one of: text, json, stream-json'); - } - outputFormat = raw; - } - - const settings = typeof values.settings === 'string' ? values.settings : undefined; - const thread = typeof values.thread === 'string' ? values.thread : undefined; - const title = typeof values.title === 'string' ? values.title : undefined; - const cloneThread = Boolean(values['clone-thread']); - const resourceId = typeof values['resource-id'] === 'string' ? values['resource-id'] : undefined; - - if (values.continue && thread) { - throw new Error('--continue and --thread cannot be used together'); - } - - return { - prompt, - timeout, - format: format as 'default' | 'json', - outputFormat, - continue_: Boolean(values.continue), - model, - mode, - thinkingLevel, - settings, - thread, - title, - cloneThread, - resourceId, - }; -} - -/** Truncate a string to `max` characters, appending "..." if truncated. */ -export function truncate(s: string, max: number): string { - return s.length > max ? s.slice(0, max) + '...' : s; -} - -export function printHeadlessUsage(): void { - process.stdout.write(` -Usage: mastracode --prompt <text> [options] - -Headless (non-interactive) mode options: - --prompt, -p <text> The task to execute (required, or pipe via stdin) - --continue, -c Resume the most recent thread instead of creating a new one - --thread, -t <id|title> Resume a specific thread by ID or title - --title <title> Set or rename the thread title - --clone-thread Clone the current thread before running (work on a copy) - --resource-id <id> Set the resource ID for thread scoping - --timeout <seconds> Exit with code 2 if not complete within timeout - --format <type> Output format: "default" or "json" (default: "default") - --output-format <type> Automation output: "text", "json", or "stream-json" - --model, -m <id> Model override (e.g., "anthropic/claude-sonnet-4-5") - --mode {build|plan|fast} Execution mode — defaults to "build" if omitted - --thinking-level <level> Thinking level: off, low, medium, high, xhigh - --settings <path> Path to settings.json file (default: global settings) - -Thread behavior: - By default, a new thread is created for each run. - Use --continue to resume the most recent thread, or --thread to target a specific one. - Use --clone-thread to branch off a copy before running. - -Settings file: - Uses the same settings.json as the interactive TUI. Pass --settings to use - a custom settings file (e.g., settings-ci.json for CI). All model, pack, - subagent, and OM configuration is resolved from settings at startup. - -Exit codes: - 0 Agent completed successfully - 1 Error or aborted - 2 Timeout - -Examples: - mastracode --prompt "Fix the bug in auth.ts" - mastracode --prompt "Add tests" --timeout 300 - mastracode --prompt "Fix the bug" --mode fast --thinking-level high - mastracode --settings ./settings-ci.json --prompt "Run tests" - mastracode -c --prompt "Continue where you left off" - mastracode -t "feature-auth" --prompt "Keep working on this" - mastracode --thread abc123 --clone-thread --prompt "Try a different approach" - mastracode --prompt "Refactor utils" --title "utils-refactor" - mastracode --prompt "Refactor utils" --format json - mastracode --prompt "Run tests and summarize pass/fail counts" --output-format json - mastracode --prompt "Find all TODO comments" --output-format stream-json - mastracode --resource-id my-project --prompt "Fix the bug" - echo "task description" | mastracode --prompt - - -Piping without --prompt launches the interactive TUI with piped content -as the first message: - cat file.txt | mastracode - git diff | mastracode - npm test 2>&1 | mastracode - -Run without --prompt for the interactive TUI. -`); -} - -function resolveExitCode(reason?: string): number { - return reason === 'error' || reason === 'aborted' ? 1 : 0; -} - -function autoResolve<TState extends Record<string, unknown>>( - session: Session<TState>, - event: AgentControllerEvent, -): { resolved: true; label: string; json: Record<string, unknown> } | { resolved: false } { - switch (event.type) { - case 'tool_approval_required': { - session.respondToToolApproval({ decision: 'approve' }); - return { resolved: true, label: `[auto-approved] ${event.toolName}`, json: { ...event, autoApproved: true } }; - } - case 'tool_suspended': { - const payload = (event.suspendPayload ?? {}) as Record<string, unknown>; - if (event.toolName === 'request_access' || payload.kind === 'sandbox_access_request') { - void session.respondToToolSuspension({ toolCallId: event.toolCallId, resumeData: 'Yes' }); - return { - resolved: true, - label: `[auto-approved sandbox] ${String(payload.path ?? '')}`, - json: { ...event, autoApproved: true }, - }; - } - if (event.toolName === 'submit_plan') { - void session.respondToToolSuspension({ toolCallId: event.toolCallId, resumeData: { action: 'approved' } }); - return { - resolved: true, - label: `[auto-approved plan] ${String(payload.title ?? '')}`, - json: { ...event, autoApproved: true }, - }; - } - void session.respondToToolSuspension({ - toolCallId: event.toolCallId, - resumeData: 'Proceed with your best judgment. Do not ask further questions.', - }); - return { - resolved: true, - label: `[auto-answered] ${truncate(String(payload.question ?? ''), 100)}`, - json: { ...event, autoAnswered: true }, - }; - } - default: - return { resolved: false }; - } -} - -function formatDefault(event: AgentControllerEvent, ctx: { lastTextLength: number }): void { - switch (event.type) { - case 'agent_start': - ctx.lastTextLength = 0; - break; - case 'message_update': { - const fullText = event.message.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map(p => p.text) - .join(''); - if (fullText.length > ctx.lastTextLength) { - process.stdout.write(fullText.slice(ctx.lastTextLength)); - ctx.lastTextLength = fullText.length; - } - break; - } - case 'message_end': - ctx.lastTextLength = 0; - process.stdout.write('\n'); - break; - case 'tool_start': - process.stderr.write(`[tool] ${event.toolName}\n`); - break; - case 'tool_end': - if (event.isError) process.stderr.write(`[tool error] ${truncate(String(event.result), 200)}\n`); - break; - case 'shell_output': - process.stderr.write(event.output); - break; - case 'subagent_start': - process.stderr.write( - `[subagent:${event.forked ? 'forked:' : ''}${event.agentType}] ${truncate(event.task, 100)}\n`, - ); - break; - case 'subagent_end': - if (event.isError) process.stderr.write(`[subagent error] ${truncate(event.result, 200)}\n`); - break; - case 'error': - process.stderr.write(`[error] ${event.error.message}\n`); - break; - } -} - -interface HeadlessSummary { - text: string; - finishReason?: string; - usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; - toolCalls: Array<{ id: string; name: string; args: unknown }>; - toolResults: Array<{ id: string; name: string; result: unknown; isError: boolean }>; - error?: { name: string; message: string; stack?: string }; - threadId?: string; -} - -function createEmptySummary(): HeadlessSummary { - return { text: '', toolCalls: [], toolResults: [] }; -} - -function extractAssistantText(message: AgentControllerMessage): string { - return message.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map(c => c.text) - .join(''); -} - -function aggregateIntoSummary(event: AgentControllerEvent, summary: HeadlessSummary): void { - switch (event.type) { - case 'message_end': - if (event.message.role === 'assistant') { - summary.text += extractAssistantText(event.message); - } - break; - case 'tool_start': - summary.toolCalls.push({ id: event.toolCallId, name: event.toolName, args: event.args }); - break; - case 'tool_end': { - const matching = summary.toolCalls.find(c => c.id === event.toolCallId); - summary.toolResults.push({ - id: event.toolCallId, - name: matching?.name ?? '', - result: event.result, - isError: event.isError, - }); - break; - } - case 'usage_update': - summary.usage = { - inputTokens: event.usage.promptTokens, - outputTokens: event.usage.completionTokens, - totalTokens: event.usage.totalTokens, - }; - break; - case 'error': - summary.error = { - name: event.error.name, - message: event.error.message, - stack: event.error.stack, - }; - break; - } -} - -function finalizeSummary<TState extends Record<string, unknown>>( - summary: HeadlessSummary, - endEvent: Extract<AgentControllerEvent, { type: 'agent_end' }>, - session: Session<TState>, -): void { - summary.finishReason = endEvent.reason; - summary.threadId = session.thread.getId() ?? undefined; -} - -/** Resolve a thread by ID or title. Tries exact ID match first, then title. */ -async function resolveThread<TState extends Record<string, unknown>>( - session: Session<TState>, - threadIdOrTitle: string, -): Promise<{ threadId: string; matchType: 'id' | 'title' } | { error: string }> { - const threads = await session.thread.list(); - - const byId = threads.find(t => t.id === threadIdOrTitle); - if (byId) return { threadId: byId.id, matchType: 'id' }; - - const byTitle = threads - .filter(t => t.title === threadIdOrTitle) - .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); - if (byTitle.length > 0) return { threadId: byTitle[0]!.id, matchType: 'title' }; - - return { error: `No thread found matching "${threadIdOrTitle}"` }; -} - -/** - * Run headless mode: subscribe to controller events with auto-approval, - * optionally resume a thread, send the prompt, and wait for completion. - * - * Returns the exit code (0 = success, 1 = error/aborted, 2 = timeout). - */ -export async function runHeadless<TState extends Record<string, unknown>>( - controller: AgentController<TState>, - session: Session<TState>, - args: HeadlessArgs & { prompt: string }, - effectiveDefaults?: Record<string, string>, -): Promise<number> { - const outputFormat = args.outputFormat; - const emit = - outputFormat === 'stream-json' || (!outputFormat && args.format === 'json') - ? (data: Record<string, unknown>) => process.stdout.write(JSON.stringify(data) + '\n') - : null; - const summary = outputFormat === 'json' ? createEmptySummary() : null; - let textBuffer: string | null = outputFormat === 'text' ? '' : null; - - let timeoutId: ReturnType<typeof setTimeout> | undefined; - let timedOut = false; - if (args.timeout) { - timeoutId = setTimeout(() => { - timedOut = true; - if (emit) { - emit({ type: 'timeout', seconds: args.timeout }); - } else { - process.stderr.write(`\nTimeout: ${args.timeout}s elapsed. Aborting.\n`); - } - session.abort(); - }, args.timeout * 1000); - } - - function failEarly(msg: string): 1 { - if (emit) emit({ type: 'error', error: { message: msg } }); - else process.stderr.write(`Error: ${msg}\n`); - if (timeoutId) clearTimeout(timeoutId); - return 1; - } - - // --- Pre-flight checks (before subscribing to events) --- - - // --- Resolve model --- - if (args.model && args.mode) { - if (emit) { - emit({ type: 'warning', message: '--model overrides --mode, ignoring --mode' }); - } else { - process.stderr.write('Warning: --model overrides --mode, ignoring --mode\n'); - } - } - - if (args.model) { - // Highest priority: explicit --model flag - const available = await controller.listAvailableModels(); - const match = available.find(m => m.id === args.model); - if (!match) { - return failEarly(`Unknown model: "${args.model}"`); - } - if (!match.hasApiKey) { - const keyHint = match.apiKeyEnvVar ? ` Set ${match.apiKeyEnvVar} to use this model.` : ''; - return failEarly(`Model "${args.model}" has no API key configured.${keyHint}`); - } - await session.model.switch({ modelId: args.model }); - if (!emit) process.stderr.write(`[model] ${args.model}\n`); - } else if (args.mode) { - // --mode flag: look up model from effectiveDefaults (resolved from settings at startup) - const modelId = effectiveDefaults?.[args.mode]; - if (modelId) { - const available = await controller.listAvailableModels(); - const match = available.find(m => m.id === modelId); - if (!match) { - return failEarly(`Unknown model "${modelId}" configured for mode "${args.mode}"`); - } - if (!match.hasApiKey) { - const keyHint = match.apiKeyEnvVar ? ` Set ${match.apiKeyEnvVar} to use this model.` : ''; - return failEarly(`Model "${modelId}" (mode: ${args.mode}) has no API key configured.${keyHint}`); - } - await session.model.switch({ modelId }); - if (!emit) process.stderr.write(`[model] ${modelId} (mode: ${args.mode})\n`); - } else { - const warnMsg = `--mode ${args.mode} has no configured model, using default`; - if (emit) emit({ type: 'warning', message: warnMsg }); - else process.stderr.write(`Warning: ${warnMsg}\n`); - } - } - - // --- Resolve thinking level --- - if (args.thinkingLevel) { - await session.state.set({ thinkingLevel: args.thinkingLevel } as unknown as Partial<TState>); - if (!emit) process.stderr.write(`[thinking] ${args.thinkingLevel}\n`); - } - - // --- Subscribe and send --- - // Subscription is set up after preflight checks (model switching, thinking level) so that - // early-exit failures don't leave a dangling subscriber. The subscriber only handles - // runtime events (auto-resolution, streaming, agent_end). - - const streamCtx = { lastTextLength: 0 }; - - const done = new Promise<number>(resolve => { - const unsubscribe = session.subscribe(event => { - const result = autoResolve(session, event); - if (result.resolved) { - if (emit) emit(result.json); - else if (!outputFormat) process.stderr.write(result.label + '\n'); - return; - } - - // Aggregate into accumulators for text / json modes - if (summary) aggregateIntoSummary(event, summary); - if (textBuffer !== null && event.type === 'message_end' && event.message.role === 'assistant') { - textBuffer += extractAssistantText(event.message); - } - - if (event.type === 'agent_end') { - if (summary) { - finalizeSummary(summary, event, session); - process.stdout.write(JSON.stringify(summary) + '\n'); - } else if (textBuffer !== null) { - process.stdout.write(textBuffer); - if (!textBuffer.endsWith('\n')) process.stdout.write('\n'); - } else if (emit) { - emit({ ...event }); - } - unsubscribe(); - resolve(resolveExitCode(event.reason)); - return; - } - - if (emit) { - emit({ ...event }); - } else if (!outputFormat) { - formatDefault(event, streamCtx); - } - }); - }); - - // --- Resource ID --- - if (args.resourceId) { - await controller.setResourceId(session, { resourceId: args.resourceId }); - if (!emit) process.stderr.write(`[resource] ${args.resourceId}\n`); - } - - // --- Thread selection --- - try { - if (args.thread) { - const result = await resolveThread(session, args.thread); - if ('error' in result) { - const msg = result.error; - if (emit) emit({ type: 'error', error: { message: msg } }); - else process.stderr.write(`Error: ${msg}\n`); - if (timeoutId) clearTimeout(timeoutId); - return 1; - } - await session.thread.switch({ threadId: result.threadId }); - if (!emit) process.stderr.write(`[thread] resumed ${result.threadId} (matched by ${result.matchType})\n`); - } else if (args.continue_) { - const threads = await session.thread.list(); - if (threads.length > 0) { - const sorted = [...threads].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); - await session.thread.switch({ threadId: sorted[0]!.id }); - if (!emit) process.stderr.write(`[continued] thread ${sorted[0]!.id}\n`); - } else if (!emit) { - process.stderr.write(`[info] No existing threads found, starting new thread\n`); - } - } - // else: no thread selection — sendMessage will auto-create a new thread - } catch (err) { - const msg = `Failed to select thread: ${(err as Error).message}`; - if (emit) emit({ type: 'error', error: { message: msg } }); - else process.stderr.write(`Error: ${msg}\n`); - if (timeoutId) clearTimeout(timeoutId); - return 1; - } - - // --- Clone --- - if (args.cloneThread) { - try { - const cloned = await session.thread.clone(); - if (emit) emit({ type: 'thread_cloned', threadId: cloned.id }); - else process.stderr.write(`[cloned] thread ${cloned.id}\n`); - } catch (err) { - const msg = `Failed to clone thread: ${(err as Error).message}`; - if (emit) emit({ type: 'error', error: { message: msg } }); - else process.stderr.write(`Error: ${msg}\n`); - if (timeoutId) clearTimeout(timeoutId); - return 1; - } - } - - // --- Title --- - if (args.title) { - try { - await session.thread.rename({ title: args.title }); - if (!emit) process.stderr.write(`[title] "${args.title}"\n`); - } catch (err) { - const msg = `Failed to set thread title: ${(err as Error).message}`; - if (emit) emit({ type: 'error', error: { message: msg } }); - else process.stderr.write(`Error: ${msg}\n`); - if (timeoutId) clearTimeout(timeoutId); - return 1; - } - } - - await session.sendMessage({ content: args.prompt }); - - const exitCode = await done; - if (timeoutId) clearTimeout(timeoutId); - return timedOut ? 2 : exitCode; -} - -/** - * Headless mode main entry point: parse arguments, read stdin, initialize - * MastraCode, and run headless mode. - */ -export async function headlessMain(predrainedInput?: string | null): Promise<never> { - if (process.argv.includes('--help') || process.argv.includes('-h')) { - printHeadlessUsage(); - process.exit(0); - } - - let args; - try { - args = parseHeadlessArgs(process.argv); - } catch (e) { - process.stderr.write(`Error: ${(e as Error).message}\n`); - process.exit(1); - } - - let prompt = args.prompt; - if (predrainedInput !== undefined) { - // Stdin was already drained by the caller (e.g. TTY reopen failed after pipe drain) - prompt = predrainedInput ?? ''; - } else if (prompt === '-' || (!prompt && !process.stdin.isTTY)) { - const chunks: Buffer[] = []; - for await (const chunk of process.stdin) { - chunks.push(chunk as Buffer); - } - prompt = Buffer.concat(chunks).toString('utf-8').trim(); - } - - if (!prompt) { - printHeadlessUsage(); - process.stderr.write('Error: --prompt is required (or pipe via stdin)\n'); - process.exit(1); - } - - if (args.settings && !existsSync(args.settings)) { - process.stderr.write(`Error: Settings file not found: ${args.settings}\n`); - process.exit(1); - } - - const result = await createMastraCode({ settingsPath: args.settings }); - const { controller, session, mcpManager, effectiveDefaults } = result; - - if (mcpManager?.hasServers()) { - try { - await mcpManager.initInBackground(); - } catch (err) { - process.stderr.write(`Warning: MCP server initialization failed: ${(err as Error).message ?? err}\n`); - } - } - - setupDebugLogging(); - - const exitCode = await runHeadless(controller, session, { ...args, prompt }, effectiveDefaults); - - // Cleanup - releaseAllThreadLocks(); - const closeSignalsPubSub = (result.signalsPubSub as { close?: () => Promise<void> | void } | undefined)?.close; - await Promise.allSettled([ - mcpManager?.disconnect(), - controller.getMastra()?.stopWorkers(), - controller?.stopHeartbeats(), - closeSignalsPubSub?.(), - ]); - - process.exit(exitCode); -} diff --git a/mastracode/src/headless/cli.test.ts b/mastracode/src/headless/cli.test.ts new file mode 100644 index 000000000000..a0d13e837719 --- /dev/null +++ b/mastracode/src/headless/cli.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect } from 'vitest'; + +import { hasHeadlessFlag, parseHeadlessArgs } from './cli.js'; +import { buildParseArgsOptions, FLAGS, renderFlagUsage } from './flags.js'; + +function argv(...rest: string[]): string[] { + return ['node', 'main.js', ...rest]; +} + +describe('hasHeadlessFlag', () => { + it('detects --prompt', () => { + expect(hasHeadlessFlag(argv('--prompt', 'do it'))).toBe(true); + }); + + it('detects -p', () => { + expect(hasHeadlessFlag(argv('-p', 'do it'))).toBe(true); + }); + + it('returns false without a prompt flag', () => { + expect(hasHeadlessFlag(argv('--continue'))).toBe(false); + }); +}); + +describe('parseHeadlessArgs', () => { + it('parses --prompt and applies defaults', () => { + const args = parseHeadlessArgs(argv('--prompt', 'Fix the bug')); + expect(args.prompt).toBe('Fix the bug'); + expect(args.output).toBe('human'); + expect(args.continue_).toBe(false); + expect(args.cloneThread).toBe(false); + }); + + it('parses the -p short flag', () => { + expect(parseHeadlessArgs(argv('-p', 'Fix the bug')).prompt).toBe('Fix the bug'); + }); + + it('reads a positional prompt when no flag is given', () => { + expect(parseHeadlessArgs(argv('Fix the bug')).prompt).toBe('Fix the bug'); + }); + + it('parses the consolidated --output modes', () => { + expect(parseHeadlessArgs(argv('-p', 'x', '--output', 'json')).output).toBe('json'); + expect(parseHeadlessArgs(argv('-p', 'x', '-o', 'jsonl')).output).toBe('jsonl'); + expect(parseHeadlessArgs(argv('-p', 'x', '--output', 'human')).output).toBe('human'); + }); + + it('rejects an invalid --output value', () => { + expect(() => parseHeadlessArgs(argv('-p', 'x', '--output', 'xml'))).toThrow(/--output must be one of/); + }); + + it('parses --timeout as a positive integer', () => { + expect(parseHeadlessArgs(argv('-p', 'x', '--timeout', '300')).timeout).toBe(300); + }); + + it('rejects a non-positive or non-integer --timeout', () => { + expect(() => parseHeadlessArgs(argv('-p', 'x', '--timeout', '0'))).toThrow(/--timeout/); + expect(() => parseHeadlessArgs(argv('-p', 'x', '--timeout', '1.5'))).toThrow(/--timeout/); + expect(() => parseHeadlessArgs(argv('-p', 'x', '--timeout', 'soon'))).toThrow(/--timeout/); + }); + + it('validates --mode', () => { + expect(parseHeadlessArgs(argv('-p', 'x', '--mode', 'plan')).mode).toBe('plan'); + expect(() => parseHeadlessArgs(argv('-p', 'x', '--mode', 'turbo'))).toThrow(/--mode/); + }); + + it('validates --thinking-level', () => { + expect(parseHeadlessArgs(argv('-p', 'x', '--thinking-level', 'high')).thinkingLevel).toBe('high'); + expect(() => parseHeadlessArgs(argv('-p', 'x', '--thinking-level', 'extreme'))).toThrow(/--thinking-level/); + }); + + it('parses thread flags', () => { + const args = parseHeadlessArgs(argv('-p', 'x', '--thread', 't-1', '--title', 'My run', '--clone-thread')); + expect(args.thread).toBe('t-1'); + expect(args.title).toBe('My run'); + expect(args.cloneThread).toBe(true); + }); + + it('parses --continue', () => { + expect(parseHeadlessArgs(argv('-p', 'x', '--continue')).continue_).toBe(true); + expect(parseHeadlessArgs(argv('-p', 'x', '-c')).continue_).toBe(true); + }); + + it('rejects --continue together with --thread', () => { + expect(() => parseHeadlessArgs(argv('-p', 'x', '--continue', '--thread', 't-1'))).toThrow( + /--continue and --thread/, + ); + }); + + it('parses --model, --resource-id, and --settings', () => { + const args = parseHeadlessArgs( + argv('-p', 'x', '--model', 'openai/gpt-4o', '--resource-id', 'r-1', '--settings', './s.json'), + ); + expect(args.model).toBe('openai/gpt-4o'); + expect(args.resourceId).toBe('r-1'); + expect(args.settings).toBe('./s.json'); + }); + + it('parses --max-turns as a positive integer', () => { + expect(parseHeadlessArgs(argv('-p', 'x', '--max-turns', '5')).maxTurns).toBe(5); + }); + + it('leaves maxTurns undefined when --max-turns is absent', () => { + expect(parseHeadlessArgs(argv('-p', 'x')).maxTurns).toBeUndefined(); + }); + + it('rejects a non-positive or non-integer --max-turns', () => { + expect(() => parseHeadlessArgs(argv('-p', 'x', '--max-turns', '0'))).toThrow(/--max-turns/); + expect(() => parseHeadlessArgs(argv('-p', 'x', '--max-turns', '-3'))).toThrow(/--max-turns/); + expect(() => parseHeadlessArgs(argv('-p', 'x', '--max-turns', '2.5'))).toThrow(/--max-turns/); + expect(() => parseHeadlessArgs(argv('-p', 'x', '--max-turns', 'lots'))).toThrow(/--max-turns/); + }); + + it('validates --permission-mode', () => { + expect(parseHeadlessArgs(argv('-p', 'x', '--permission-mode', 'auto')).permissionMode).toBe('auto'); + expect(parseHeadlessArgs(argv('-p', 'x', '--permission-mode', 'deny')).permissionMode).toBe('deny'); + }); + + it('leaves permissionMode undefined when absent', () => { + expect(parseHeadlessArgs(argv('-p', 'x')).permissionMode).toBeUndefined(); + }); + + it('rejects an invalid --permission-mode value', () => { + expect(() => parseHeadlessArgs(argv('-p', 'x', '--permission-mode', 'yolo'))).toThrow(/--permission-mode must be/); + }); + + it('parses every flag together', () => { + const args = parseHeadlessArgs( + argv( + '--prompt', + 'Do everything', + '--output', + 'json', + '--model', + 'openai/gpt-4o', + '--mode', + 'plan', + '--thinking-level', + 'high', + '--timeout', + '120', + '--max-turns', + '8', + '--permission-mode', + 'deny', + '--thread', + 't-9', + '--title', + 'Full run', + '--clone-thread', + '--resource-id', + 'r-9', + '--settings', + './ci.json', + ), + ); + expect(args).toEqual({ + prompt: 'Do everything', + output: 'json', + model: 'openai/gpt-4o', + mode: 'plan', + thinkingLevel: 'high', + timeout: 120, + maxTurns: 8, + permissionMode: 'deny', + continue_: false, + thread: 't-9', + title: 'Full run', + cloneThread: true, + resourceId: 'r-9', + settings: './ci.json', + }); + }); +}); + +describe('flag spec', () => { + it('derives parseArgs options from the flag table', () => { + const options = buildParseArgsOptions(); + // Every declared flag is present with the right kind + short alias. + for (const flag of FLAGS) { + expect(options[flag.key]).toMatchObject({ type: flag.type }); + if (flag.short) expect(options[flag.key]!.short).toBe(flag.short); + } + // Booleans default to false so they're always defined. + expect(options.continue).toMatchObject({ type: 'boolean', default: false }); + }); + + it('renders one usage entry per flag, aligned', () => { + const usage = renderFlagUsage(); + for (const flag of FLAGS) { + expect(usage).toContain(`--${flag.key}`); + } + // First help line of a multi-line flag stays on the same row as the flag. + expect(usage).toMatch(/--permission-mode <mode>\s+How tool approvals/); + }); + + it('reports unknown enum values uniformly', () => { + expect(() => parseHeadlessArgs(argv('-p', 'x', '--mode', 'turbo'))).toThrow( + '--mode must be one of: build, plan, fast', + ); + expect(() => parseHeadlessArgs(argv('-p', 'x', '--permission-mode', 'nope'))).toThrow( + '--permission-mode must be one of: auto, deny', + ); + }); +}); diff --git a/mastracode/src/headless/cli.ts b/mastracode/src/headless/cli.ts new file mode 100644 index 000000000000..399669c4f77c --- /dev/null +++ b/mastracode/src/headless/cli.ts @@ -0,0 +1,262 @@ +/** + * CLI adapter for headless MastraCode runs. + * + * This is the only headless layer that touches the process: it parses argv, + * reads stdin, bootstraps MastraCode via `createMastraCode`, drives `runMC`, + * renders events/results to stdout/stderr through the pure formatters, maps the + * result to an exit code, and owns teardown + `process.exit`. + */ +import { existsSync } from 'node:fs'; +import { parseArgs } from 'node:util'; + +import { createMastraCode } from '../index.js'; +import { setupDebugLogging } from '../utils/debug-log.js'; +import { releaseAllThreadLocks } from '../utils/thread-lock.js'; + +import { buildParseArgsOptions, FLAGS, renderFlagUsage } from './flags.js'; +import { createHumanFormatState, formatHuman, formatJsonl, renderJsonResult } from './format.js'; +import { permissionModeToPolicy } from './policy.js'; +import { runMC } from './run-mc.js'; +import type { PermissionMode, RunMode, ThinkingLevel } from './types.js'; + +/** Consolidated output mode (replaces the old `--format` + `--output-format`). */ +export type OutputMode = 'human' | 'json' | 'jsonl'; + +export interface HeadlessArgs { + prompt?: string; + /** Timeout in seconds (CLI surface); converted to ms before `runMC`. */ + timeout?: number; + output: OutputMode; + continue_: boolean; + model?: string; + mode?: RunMode; + thinkingLevel?: ThinkingLevel; + settings?: string; + thread?: string; + title?: string; + cloneThread: boolean; + resourceId?: string; + /** Max agentic turns before the run aborts with exit code 1. */ + maxTurns?: number; + /** Named permission mode resolving to a built-in policy. Defaults to `auto`. */ + permissionMode?: PermissionMode; +} + +const parseArgsOptions = buildParseArgsOptions(); + +/** + * Returns true if `argv` selects headless mode. This must agree with what + * {@link parseHeadlessArgs} (and `runMCCli`) accept as a prompt: `--prompt`/`-p` + * or a bare positional prompt (e.g. `mastracode "Fix the bug"`). Note that a + * prompt piped via stdin without a flag is handled separately by the caller. + */ +export function hasHeadlessFlag(argv: string[]): boolean { + if (argv.some(a => a === '--prompt' || a === '-p')) return true; + try { + const { values, positionals } = parseArgs({ + args: argv.slice(2), + options: parseArgsOptions, + strict: false, + allowPositionals: true, + }); + // A positional prompt only counts when not asking for help. + return positionals.length > 0 && !values.help; + } catch { + return false; + } +} + +/** + * Parse CLI arguments for headless mode. The flag table in `flags.ts` is the + * single source of truth: each flag carries its own coercion/validation, so this + * function just walks {@link FLAGS} and assembles the typed {@link HeadlessArgs}. + */ +export function parseHeadlessArgs(argv: string[]): HeadlessArgs { + const { values, positionals } = parseArgs({ + args: argv.slice(2), + options: parseArgsOptions, + strict: false, + allowPositionals: true, + }); + + // Seed defaults; per-flag values below override these. + const args: HeadlessArgs = { + output: 'human', + continue_: false, + cloneThread: false, + }; + const sink = args as unknown as Record<string, unknown>; + + for (const flag of FLAGS) { + if (!flag.field) continue; // e.g. --help, handled by the caller + const raw = values[flag.key]; + if (raw === undefined) continue; + + if (flag.type === 'boolean') { + sink[flag.field] = Boolean(raw); + } else if (typeof raw === 'string') { + sink[flag.field] = flag.coerce ? flag.coerce(raw) : raw; + } + } + + // A bare positional acts as the prompt when --prompt/-p is absent. + if (args.prompt === undefined && positionals[0] !== undefined) { + args.prompt = positionals[0]; + } + + if (args.continue_ && args.thread) { + throw new Error('--continue and --thread cannot be used together'); + } + + return args; +} + +export function printHeadlessUsage(): void { + process.stdout.write(` +Usage: mastracode --prompt <text> [options] + +Headless (non-interactive) mode options: +${renderFlagUsage()} + +Thread behavior: + By default, a new thread is created for each run. + Use --continue to resume the most recent thread, or --thread to target a specific one. + Use --clone-thread to branch off a copy before running. + +Settings file: + Uses the same settings.json as the interactive TUI. Pass --settings to use + a custom settings file (e.g., settings-ci.json for CI). All model, pack, + subagent, and OM configuration is resolved from settings at startup. + +Exit codes: + 0 Agent completed successfully + 1 Error, aborted, or max turns reached + 2 Timeout + +Examples: + mastracode --prompt "Fix the bug in auth.ts" + mastracode --prompt "Add tests" --timeout 300 --output json + mastracode --prompt "Refactor" --output jsonl + mastracode --prompt "Review this PR" --permission-mode deny --max-turns 10 + mastracode --settings ./settings-ci.json --prompt "Run tests" + mastracode -c --prompt "Continue where you left off" + echo "Summarize the repo" | mastracode --prompt - +`); +} + +/** + * Headless CLI entry point: parse arguments, read stdin, initialize MastraCode, + * run via `runMC`, render output, and exit with the mapped code. + */ +export async function runMCCli(predrainedInput?: string | null): Promise<never> { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + printHeadlessUsage(); + process.exit(0); + } + + let args: HeadlessArgs; + try { + args = parseHeadlessArgs(process.argv); + } catch (e) { + process.stderr.write(`Error: ${(e as Error).message}\n`); + process.exit(1); + } + + let prompt = args.prompt; + if (predrainedInput !== undefined) { + prompt = predrainedInput ?? ''; + } else if (prompt === '-' || (!prompt && !process.stdin.isTTY)) { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + prompt = Buffer.concat(chunks).toString('utf-8').trim(); + } + + if (!prompt) { + printHeadlessUsage(); + process.stderr.write('Error: --prompt is required (or pipe via stdin)\n'); + process.exit(1); + } + + if (args.settings && !existsSync(args.settings)) { + process.stderr.write(`Error: Settings file not found: ${args.settings}\n`); + process.exit(1); + } + + const boot = await createMastraCode({ settingsPath: args.settings }); + const { controller, session, mcpManager, effectiveDefaults } = boot; + + if (mcpManager?.hasServers()) { + try { + await mcpManager.initInBackground(); + } catch (err) { + process.stderr.write(`Warning: MCP server initialization failed: ${(err as Error).message ?? err}\n`); + } + } + + setupDebugLogging(); + + // Default to a non-zero exit so an unexpected throw before the run resolves + // still surfaces as a failure to the caller / CI. + let exitCode = 1; + try { + const humanState = createHumanFormatState(); + const run = runMC({ + controller, + session, + prompt, + model: args.model, + mode: args.mode, + modeDefaults: effectiveDefaults, + thinkingLevel: args.thinkingLevel, + thread: { id: args.thread, continueLatest: args.continue_, clone: args.cloneThread }, + resourceId: args.resourceId, + title: args.title, + timeoutMs: args.timeout ? args.timeout * 1000 : undefined, + maxTurns: args.maxTurns, + policy: args.permissionMode ? permissionModeToPolicy(args.permissionMode) : undefined, + }); + + // Stream live events for human + jsonl modes. (json mode prints only the final object.) + for await (const event of run) { + if (args.output === 'human') { + const out = formatHuman(event, humanState); + if (out.stdout) process.stdout.write(out.stdout); + if (out.stderr) process.stderr.write(out.stderr); + } else if (args.output === 'jsonl') { + process.stdout.write(JSON.stringify(formatJsonl(event)) + '\n'); + } + } + + const result = await run.result; + exitCode = result.exitCode; + + if (args.output === 'json') { + process.stdout.write(renderJsonResult(result)); + } else if (args.output === 'jsonl') { + process.stdout.write(JSON.stringify({ type: 'result', ...result }) + '\n'); + } + + if (result.status === 'timeout') { + process.stderr.write(`\nTimeout elapsed. Aborted.\n`); + } else if (result.error && args.output === 'human') { + process.stderr.write(`Error: ${result.error.message}\n`); + } + } catch (err) { + process.stderr.write(`Error: ${(err as Error).message ?? err}\n`); + exitCode = 1; + } finally { + // --- Teardown (always runs, even on a thrown error) --- + releaseAllThreadLocks(); + const closeSignalsPubSub = (boot.signalsPubSub as { close?: () => Promise<void> | void } | undefined)?.close; + await Promise.allSettled([ + mcpManager?.disconnect(), + controller.getMastra()?.stopWorkers(), + controller?.stopIntervals(), + closeSignalsPubSub?.(), + ]); + } + + process.exit(exitCode); +} diff --git a/mastracode/src/headless/flags.ts b/mastracode/src/headless/flags.ts new file mode 100644 index 000000000000..7accda37c70e --- /dev/null +++ b/mastracode/src/headless/flags.ts @@ -0,0 +1,230 @@ +/** + * Declarative flag specification for the headless CLI. + * + * A single table is the source of truth for three things that previously drifted + * apart in `cli.ts`: + * 1. the `node:util` `parseArgs` option config, + * 2. per-flag value coercion + validation, and + * 3. the `--help` usage text. + * + * Adding a flag means adding one row here; parsing, validation, and the usage + * listing all follow automatically. + */ +import type { OutputMode } from './cli.js'; +import type { PermissionMode, RunMode, ThinkingLevel } from './types.js'; +import { VALID_MODES, VALID_PERMISSION_MODES, VALID_THINKING_LEVELS } from './types.js'; + +export const VALID_OUTPUTS = ['human', 'json', 'jsonl'] as const; + +/** Reusable validators. Each throws a descriptive Error or returns the typed value. */ +const validate = { + /** Restrict to a fixed set of string literals. */ + enum<T extends string>(flag: string, allowed: readonly T[]) { + return (raw: string): T => { + if (!(allowed as readonly string[]).includes(raw)) { + throw new Error(`${flag} must be one of: ${allowed.join(', ')}`); + } + return raw as T; + }; + }, + /** Require a positive (>0) integer. */ + positiveInt(flag: string) { + return (raw: string): number => { + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${flag} must be a positive integer`); + } + return parsed; + }; + }, + /** Pass a string through unchanged. */ + string(raw: string): string { + return raw; + }, +}; + +/** + * One CLI flag. `key` is the long name (e.g. `output`); `field` is the + * {@link import('./cli.js').HeadlessArgs} property it populates. `coerce` + * converts the raw string and validates it. Boolean flags omit `coerce` and set + * `field` to `true` when present. + */ +export interface FlagSpec { + /** Long flag name without the leading `--`. */ + key: string; + /** Single-character alias (without the leading `-`), if any. */ + short?: string; + /** Whether the flag takes a value (`string`) or is a switch (`boolean`). */ + type: 'string' | 'boolean'; + /** Target field on `HeadlessArgs`. Omit for flags handled out-of-band (e.g. help). */ + field?: string; + /** Coerce/validate a string value. Required for `type: 'string'` flags that map to a field. */ + coerce?: (raw: string) => unknown; + /** `<...>` value placeholder shown in usage, e.g. `<text>`. */ + placeholder?: string; + /** One-line (or multi-line) help description shown in usage. */ + help: string | string[]; +} + +export const FLAGS: FlagSpec[] = [ + { + key: 'prompt', + short: 'p', + type: 'string', + field: 'prompt', + coerce: validate.string, + placeholder: '<text>', + help: 'The task to execute (required, or pipe via stdin)', + }, + { + key: 'continue', + short: 'c', + type: 'boolean', + field: 'continue_', + help: 'Resume the most recent thread instead of creating a new one', + }, + { + key: 'thread', + short: 't', + type: 'string', + field: 'thread', + coerce: validate.string, + placeholder: '<id>', + help: 'Resume a specific thread by ID', + }, + { + key: 'title', + type: 'string', + field: 'title', + coerce: validate.string, + placeholder: '<title>', + help: 'Set or rename the thread title', + }, + { + key: 'clone-thread', + type: 'boolean', + field: 'cloneThread', + help: 'Clone the current thread before running (work on a copy)', + }, + { + key: 'resource-id', + type: 'string', + field: 'resourceId', + coerce: validate.string, + placeholder: '<id>', + help: 'Set the resource ID for thread scoping', + }, + { + key: 'timeout', + type: 'string', + field: 'timeout', + coerce: validate.positiveInt('--timeout'), + placeholder: '<seconds>', + help: 'Exit with code 2 if not complete within timeout', + }, + { + key: 'max-turns', + type: 'string', + field: 'maxTurns', + coerce: validate.positiveInt('--max-turns'), + placeholder: '<n>', + help: 'Abort after N agentic turns (exit code 1)', + }, + { + key: 'permission-mode', + type: 'string', + field: 'permissionMode', + coerce: validate.enum<PermissionMode>('--permission-mode', VALID_PERMISSION_MODES), + placeholder: '<mode>', + help: [ + 'How tool approvals/suspensions resolve:', + ' auto approve everything (default)', + ' deny refuse approvals, abort on suspension', + ], + }, + { + key: 'output', + short: 'o', + type: 'string', + field: 'output', + coerce: validate.enum<OutputMode>('--output', VALID_OUTPUTS), + placeholder: '<mode>', + help: [ + 'Output mode: "human" (default), "json", or "jsonl"', + ' human streaming text to stdout, activity to stderr', + ' json single final JSON object (text, usage, tools)', + ' jsonl newline-delimited JSON event stream', + ], + }, + { + key: 'model', + short: 'm', + type: 'string', + field: 'model', + coerce: validate.string, + placeholder: '<id>', + help: 'Model override (e.g., a provider/model id)', + }, + { + key: 'mode', + type: 'string', + field: 'mode', + coerce: validate.enum<RunMode>('--mode', VALID_MODES), + placeholder: '{build|plan|fast}', + help: 'Execution mode — defaults to "build" if omitted', + }, + { + key: 'thinking-level', + type: 'string', + field: 'thinkingLevel', + coerce: validate.enum<ThinkingLevel>('--thinking-level', VALID_THINKING_LEVELS), + placeholder: '<level>', + help: 'Thinking level: off, low, medium, high, xhigh', + }, + { + key: 'settings', + type: 'string', + field: 'settings', + coerce: validate.string, + placeholder: '<path>', + help: 'Path to settings.json file (default: global settings)', + }, + { + key: 'help', + short: 'h', + type: 'boolean', + help: 'Show this help and exit', + }, +]; + +/** `parseArgs` option config derived from {@link FLAGS}. */ +export function buildParseArgsOptions() { + const options: Record<string, { type: 'string' | 'boolean'; short?: string; default?: boolean }> = {}; + for (const flag of FLAGS) { + options[flag.key] = flag.type === 'boolean' ? { type: 'boolean', default: false } : { type: 'string' }; + if (flag.short) options[flag.key]!.short = flag.short; + } + return options; +} + +/** Render the aligned `--flag description` block of the usage text from {@link FLAGS}. */ +export function renderFlagUsage(): string { + const left = (flag: FlagSpec): string => { + const long = `--${flag.key}`; + const short = flag.short ? `, -${flag.short}` : ''; + const value = flag.placeholder ? ` ${flag.placeholder}` : ''; + return ` ${long}${short}${value}`; + }; + + const rows = FLAGS.map(flag => ({ flag, label: left(flag) })); + const width = Math.max(...rows.map(r => r.label.length)) + 2; + + return rows + .map(({ flag, label }) => { + const lines = Array.isArray(flag.help) ? flag.help : [flag.help]; + const first = `${label.padEnd(width)}${lines[0]}`; + const rest = lines.slice(1).map(line => `${' '.repeat(width)}${line}`); + return [first, ...rest].join('\n'); + }) + .join('\n'); +} diff --git a/mastracode/src/headless/format.test.ts b/mastracode/src/headless/format.test.ts new file mode 100644 index 000000000000..5361e95f683f --- /dev/null +++ b/mastracode/src/headless/format.test.ts @@ -0,0 +1,128 @@ +import type { AgentControllerEvent } from '@mastra/core/agent-controller'; +import { describe, it, expect } from 'vitest'; + +import { + createHumanFormatState, + formatHuman, + formatJsonl, + renderJsonResult, + renderTextResult, + truncate, +} from './format.js'; +import type { RunMCResult } from './types.js'; + +function textMessage(text: string) { + return { role: 'assistant' as const, content: [{ type: 'text' as const, text }] }; +} + +describe('truncate', () => { + it('returns the string unchanged when under the limit', () => { + expect(truncate('hello', 10)).toBe('hello'); + }); + + it('appends "..." when over the limit', () => { + expect(truncate('hello world', 5)).toBe('hello...'); + }); +}); + +describe('formatHuman', () => { + it('streams only newly-appended assistant text via the state cursor', () => { + const state = createHumanFormatState(); + const first = formatHuman({ type: 'message_update', message: textMessage('Hello') } as AgentControllerEvent, state); + expect(first).toEqual({ stdout: 'Hello' }); + + const second = formatHuman( + { type: 'message_update', message: textMessage('Hello world') } as AgentControllerEvent, + state, + ); + expect(second).toEqual({ stdout: ' world' }); + }); + + it('emits nothing when the text has not grown', () => { + const state = createHumanFormatState(); + formatHuman({ type: 'message_update', message: textMessage('Hi') } as AgentControllerEvent, state); + const repeat = formatHuman({ type: 'message_update', message: textMessage('Hi') } as AgentControllerEvent, state); + expect(repeat).toEqual({}); + }); + + it('resets the cursor and emits a trailing newline on message_end', () => { + const state = createHumanFormatState(); + formatHuman({ type: 'message_update', message: textMessage('Hi') } as AgentControllerEvent, state); + expect(formatHuman({ type: 'message_end', message: textMessage('Hi') } as AgentControllerEvent, state)).toEqual({ + stdout: '\n', + }); + expect(state.lastTextLength).toBe(0); + }); + + it('ignores non-assistant message_end (e.g. echoed user prompt) so it never reaches stdout', () => { + const state = createHumanFormatState(); + const userEcho = { + type: 'message_end' as const, + message: { role: 'user' as const, content: [{ type: 'text' as const, text: 'Do the thing.' }] }, + }; + expect(formatHuman(userEcho as AgentControllerEvent, state)).toEqual({}); + // The cursor must remain untouched so a subsequent assistant turn streams correctly. + expect(state.lastTextLength).toBe(0); + }); + + it('flushes trailing assistant text on message_end when message_update never streamed it', () => { + const state = createHumanFormatState(); + const out = formatHuman( + { type: 'message_end', message: textMessage('Final answer') } as AgentControllerEvent, + state, + ); + expect(out).toEqual({ stdout: 'Final answer\n' }); + expect(state.lastTextLength).toBe(0); + }); + + it('routes tool start activity to stderr', () => { + const state = createHumanFormatState(); + const out = formatHuman({ type: 'tool_start', toolName: 'shell', toolCallId: 'c1' } as AgentControllerEvent, state); + expect(out).toEqual({ stderr: '[tool] shell\n' }); + }); + + it('routes errors to stderr', () => { + const state = createHumanFormatState(); + const out = formatHuman( + { type: 'error', error: { name: 'Error', message: 'boom' } } as AgentControllerEvent, + state, + ); + expect(out).toEqual({ stderr: '[error] boom\n' }); + }); +}); + +describe('formatJsonl', () => { + it('returns a plain object copy of the event', () => { + const event = { type: 'tool_start', toolName: 'shell', toolCallId: 'c1' } as AgentControllerEvent; + expect(formatJsonl(event)).toEqual({ type: 'tool_start', toolName: 'shell', toolCallId: 'c1' }); + }); +}); + +describe('result renderers', () => { + const result: RunMCResult = { + status: 'completed', + text: 'The answer is 4.', + finishReason: 'stop', + usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, + toolCalls: [], + toolResults: [], + threadId: 'thread-1', + exitCode: 0, + }; + + it('renderTextResult terminates with a single newline', () => { + expect(renderTextResult(result)).toBe('The answer is 4.\n'); + expect(renderTextResult({ ...result, text: 'x\n' })).toBe('x\n'); + }); + + it('renderJsonResult emits a JSON object with the expected fields', () => { + const parsed = JSON.parse(renderJsonResult(result)); + expect(parsed).toMatchObject({ + text: 'The answer is 4.', + finishReason: 'stop', + usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, + threadId: 'thread-1', + }); + expect(renderJsonResult(result).endsWith('\n')).toBe(true); + }); +}); diff --git a/mastracode/src/headless/format.ts b/mastracode/src/headless/format.ts new file mode 100644 index 000000000000..1b611b9c820c --- /dev/null +++ b/mastracode/src/headless/format.ts @@ -0,0 +1,127 @@ +/** + * Output formatters for headless runs. All functions here are pure — they take + * an event (or a final result) and return strings / plain objects. They never + * touch `process.*`; the CLI adapter owns the sinks. + */ +import type { AgentControllerEvent } from '@mastra/core/agent-controller'; + +import type { RunMCResult } from './types.js'; + +/** Truncate a string to `max` characters, appending "..." if truncated. */ +export function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max) + '...' : s; +} + +/** Mutable per-stream cursor used by {@link formatHuman} to stream assistant text. */ +export interface HumanFormatState { + lastTextLength: number; +} + +export function createHumanFormatState(): HumanFormatState { + return { lastTextLength: 0 }; +} + +/** A chunk of formatted output destined for stdout and/or stderr. */ +export interface FormattedOutput { + stdout?: string; + stderr?: string; +} + +/** + * Human-readable streaming formatter (the historical default). Assistant text + * streams to stdout; tool/subagent/shell/error activity goes to stderr. The + * `state` cursor is mutated so repeated `message_update` events only emit the + * newly-appended text. + */ +export function formatHuman(event: AgentControllerEvent, state: HumanFormatState): FormattedOutput { + switch (event.type) { + case 'agent_start': + state.lastTextLength = 0; + return {}; + case 'message_update': { + const fullText = event.message.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map(p => p.text) + .join(''); + if (fullText.length > state.lastTextLength) { + const delta = fullText.slice(state.lastTextLength); + state.lastTextLength = fullText.length; + return { stdout: delta }; + } + return {}; + } + case 'message_end': { + // Only assistant messages produce stdout. The controller also emits + // message_end for the echoed user prompt (and system messages); emitting + // those here would duplicate the prompt to stdout and corrupt the stream. + if (event.message.role !== 'assistant') return {}; + // Emit any assistant text the message_update stream didn't already cover + // (e.g. a run that only delivered the final text on message_end), then + // terminate the line. + const fullText = event.message.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map(p => p.text) + .join(''); + const delta = fullText.length > state.lastTextLength ? fullText.slice(state.lastTextLength) : ''; + state.lastTextLength = 0; + return { stdout: delta + '\n' }; + } + case 'tool_start': + return { stderr: `[tool] ${event.toolName}\n` }; + case 'tool_end': + return event.isError ? { stderr: `[tool error] ${truncate(String(event.result), 200)}\n` } : {}; + case 'shell_output': + return { stderr: event.output }; + case 'subagent_start': + return { + stderr: `[subagent:${event.forked ? 'forked:' : ''}${event.agentType}] ${truncate(event.task, 100)}\n`, + }; + case 'subagent_end': + return event.isError ? { stderr: `[subagent error] ${truncate(event.result, 200)}\n` } : {}; + case 'error': + return { stderr: `[error] ${event.error.message}\n` }; + default: + return {}; + } +} + +/** Convert an `Error` instance into a JSON-serializable plain object. */ +function serializeError(err: Error): { name: string; message: string; stack?: string } { + return { name: err.name, message: err.message, stack: err.stack }; +} + +/** + * JSONL (stream-json) formatter — returns a plain object to be `JSON.stringify`'d + * as one line per event by the sink. `Error` instances are normalized so their + * `name`/`message`/`stack` survive serialization (`JSON.stringify` turns a raw + * `Error` into `{}`). + */ +export function formatJsonl(event: AgentControllerEvent): Record<string, unknown> { + const out: Record<string, unknown> = { ...event }; + if ('error' in event && event.error instanceof Error) { + out.error = serializeError(event.error); + } + return out; +} + +/** Render the final result for `--output text`: assistant text, newline-terminated. */ +export function renderTextResult(result: RunMCResult): string { + return result.text.endsWith('\n') ? result.text : result.text + '\n'; +} + +/** Render the final result for `--output json`: one JSON object. */ +export function renderJsonResult(result: RunMCResult): string { + return ( + JSON.stringify({ + status: result.status, + text: result.text, + finishReason: result.finishReason, + usage: result.usage, + toolCalls: result.toolCalls, + toolResults: result.toolResults, + error: result.error, + threadId: result.threadId, + exitCode: result.exitCode, + }) + '\n' + ); +} diff --git a/mastracode/src/headless/index.ts b/mastracode/src/headless/index.ts new file mode 100644 index 000000000000..10d7c4954860 --- /dev/null +++ b/mastracode/src/headless/index.ts @@ -0,0 +1,55 @@ +/** + * Public headless / programmatic API for MastraCode. + * + * Programmatic (CI / Node) usage: + * ```ts + * import { createMastraCode } from 'mastracode'; + * import { runMC } from 'mastracode/headless'; + * + * const { controller, session } = await createMastraCode({ settingsPath }); + * const run = runMC({ controller, session, prompt: 'Fix the bug' }); + * for await (const event of run) { ... } // optional live events + * const result = await run.result; // typed RunMCResult + * ``` + * + * The CLI adapter (`runMCCli`) wraps the same `createMastraCode` → `runMC` flow. + */ + +// Core runner +export { runMC } from './run-mc.js'; + +// Resolution policy +export { autoApprovePolicy, denyPolicy, permissionModeToPolicy } from './policy.js'; + +// Formatters (pure, sink-agnostic) +export { + formatHuman, + formatJsonl, + renderTextResult, + renderJsonResult, + createHumanFormatState, + truncate, +} from './format.js'; +export type { FormattedOutput, HumanFormatState } from './format.js'; + +// CLI adapter +export { runMCCli, hasHeadlessFlag, parseHeadlessArgs, printHeadlessUsage } from './cli.js'; +export type { HeadlessArgs, OutputMode } from './cli.js'; + +// Shared types +export type { + RunMCOptions, + RunMCResult, + RunMCStatus, + RunMCUsage, + RunMCToolCall, + RunMCToolResult, + RunMCError, + RunMCThreadOptions, + MCRun, + ResolutionPolicy, + RunMode, + ThinkingLevel, + PermissionMode, +} from './types.js'; +export { VALID_MODES, VALID_THINKING_LEVELS, VALID_PERMISSION_MODES } from './types.js'; diff --git a/mastracode/src/headless/policy.test.ts b/mastracode/src/headless/policy.test.ts new file mode 100644 index 000000000000..56000bb9447e --- /dev/null +++ b/mastracode/src/headless/policy.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; + +import { autoApprovePolicy, denyPolicy, permissionModeToPolicy } from './policy.js'; + +type ApprovalEvent = Parameters<typeof autoApprovePolicy.onToolApproval>[0]; +type SuspensionEvent = Parameters<typeof autoApprovePolicy.onSuspension>[0]; + +function approval(overrides: Partial<ApprovalEvent> = {}): ApprovalEvent { + return { type: 'tool_approval_required', toolCallId: 'call-1', toolName: 'shell', ...overrides } as ApprovalEvent; +} + +function suspension(overrides: Partial<SuspensionEvent> = {}): SuspensionEvent { + return { type: 'tool_suspended', toolCallId: 'call-1', toolName: 'ask_user', ...overrides } as SuspensionEvent; +} + +describe('autoApprovePolicy', () => { + it('approves every tool approval request', () => { + expect(autoApprovePolicy.onToolApproval(approval())).toBe('approve'); + expect(autoApprovePolicy.onToolApproval(approval({ toolName: 'write_file' }))).toBe('approve'); + }); + + it('auto-approves request_access suspensions with "Yes"', () => { + expect(autoApprovePolicy.onSuspension(suspension({ toolName: 'request_access' }))).toEqual({ resumeData: 'Yes' }); + }); + + it('auto-approves sandbox_access_request suspensions by payload kind', () => { + const event = suspension({ toolName: 'something', suspendPayload: { kind: 'sandbox_access_request' } }); + expect(autoApprovePolicy.onSuspension(event)).toEqual({ resumeData: 'Yes' }); + }); + + it('auto-approves submit_plan suspensions', () => { + expect(autoApprovePolicy.onSuspension(suspension({ toolName: 'submit_plan' }))).toEqual({ + resumeData: { action: 'approved' }, + }); + }); + + it('answers other suspensions with a best-judgment instruction', () => { + const outcome = autoApprovePolicy.onSuspension(suspension({ toolName: 'ask_user' })); + expect(outcome).toEqual({ resumeData: 'Proceed with your best judgment. Do not ask further questions.' }); + }); + + it('tolerates a missing suspendPayload', () => { + const outcome = autoApprovePolicy.onSuspension(suspension({ toolName: 'ask_user', suspendPayload: undefined })); + expect(outcome).toEqual({ resumeData: 'Proceed with your best judgment. Do not ask further questions.' }); + }); +}); + +describe('denyPolicy', () => { + it('denies every tool approval request', () => { + expect(denyPolicy.onToolApproval(approval())).toBe('deny'); + expect(denyPolicy.onToolApproval(approval({ toolName: 'read_file' }))).toBe('deny'); + }); + + it('aborts on any suspension', () => { + expect(denyPolicy.onSuspension(suspension({ toolName: 'request_access' }))).toEqual({ abort: true }); + expect(denyPolicy.onSuspension(suspension({ toolName: 'submit_plan' }))).toEqual({ abort: true }); + expect(denyPolicy.onSuspension(suspension({ toolName: 'ask_user' }))).toEqual({ abort: true }); + }); +}); + +describe('permissionModeToPolicy', () => { + it('maps "auto" to autoApprovePolicy', () => { + expect(permissionModeToPolicy('auto')).toBe(autoApprovePolicy); + }); + + it('maps "deny" to denyPolicy', () => { + expect(permissionModeToPolicy('deny')).toBe(denyPolicy); + }); +}); diff --git a/mastracode/src/headless/policy.ts b/mastracode/src/headless/policy.ts new file mode 100644 index 000000000000..7619d8e3d4e8 --- /dev/null +++ b/mastracode/src/headless/policy.ts @@ -0,0 +1,68 @@ +/** + * Resolution policies decide how `runMC` resumes interactive events + * (`tool_approval_required`, `tool_suspended`) without a human in the loop. + * + * Policies are pure decision objects: they inspect the event and return a + * decision. The runner is responsible for applying the decision to the session + * and for any side effects (emitting labels, etc.). This keeps policies testable + * and lets CI swap in stricter behavior without touching the runner. + */ +import type { AgentControllerEvent } from '@mastra/core/agent-controller'; + +import type { PermissionMode, ResolutionPolicy } from './types.js'; + +/** + * Default policy — reproduces the historical headless behavior: + * - approve every tool approval request, + * - auto-approve `request_access` / sandbox access suspensions ("Yes"), + * - auto-approve `submit_plan` suspensions (`{ action: 'approved' }`), + * - answer any other suspension with a "use your best judgment" instruction. + */ +export const autoApprovePolicy: ResolutionPolicy = { + onToolApproval(_event: Extract<AgentControllerEvent, { type: 'tool_approval_required' }>): 'approve' | 'deny' { + return 'approve'; + }, + + onSuspension( + event: Extract<AgentControllerEvent, { type: 'tool_suspended' }>, + ): { resumeData: unknown } | { abort: true } { + const payload = (event.suspendPayload ?? {}) as Record<string, unknown>; + + if (event.toolName === 'request_access' || payload.kind === 'sandbox_access_request') { + return { resumeData: 'Yes' }; + } + + if (event.toolName === 'submit_plan') { + return { resumeData: { action: 'approved' } }; + } + + return { resumeData: 'Proceed with your best judgment. Do not ask further questions.' }; + }, +}; + +/** + * Strict policy — refuses every tool approval and aborts on any suspension. + * Useful for CI gating where unattended tool execution must not happen. + */ +export const denyPolicy: ResolutionPolicy = { + onToolApproval(_event: Extract<AgentControllerEvent, { type: 'tool_approval_required' }>): 'approve' | 'deny' { + return 'deny'; + }, + + onSuspension( + _event: Extract<AgentControllerEvent, { type: 'tool_suspended' }>, + ): { resumeData: unknown } | { abort: true } { + return { abort: true }; + }, +}; + +/** Resolve a named {@link PermissionMode} to its built-in {@link ResolutionPolicy}. */ +export function permissionModeToPolicy(mode: PermissionMode): ResolutionPolicy { + switch (mode) { + case 'deny': + return denyPolicy; + case 'auto': + default: + return autoApprovePolicy; + } +} diff --git a/mastracode/src/headless/run-mc.test.ts b/mastracode/src/headless/run-mc.test.ts new file mode 100644 index 000000000000..b639b6a24fb3 --- /dev/null +++ b/mastracode/src/headless/run-mc.test.ts @@ -0,0 +1,284 @@ +import { Agent } from '@mastra/core/agent'; +import { AgentController } from '@mastra/core/agent-controller'; +import { Mastra } from '@mastra/core/mastra'; +import { MastraLanguageModelV2Mock } from '@mastra/core/test-utils/llm-mock'; +import { createTool } from '@mastra/core/tools'; +import { Workspace } from '@mastra/core/workspace'; +import { LibSQLStore } from '@mastra/libsql'; +import { describe, it, expect, vi } from 'vitest'; +import z from 'zod'; + +import { runMC } from './run-mc.js'; +import type { ResolutionPolicy } from './types.js'; + +vi.setConfig({ testTimeout: 30_000 }); + +function textStream(text: string, finishReason: 'stop' | 'tool-calls' = 'stop') { + return new ReadableStream({ + start(controller) { + controller.enqueue({ type: 'stream-start', warnings: [] }); + controller.enqueue({ type: 'response-metadata', id: 'id-1', modelId: 'mock', timestamp: new Date(0) }); + controller.enqueue({ type: 'text-start', id: 'text-1' }); + controller.enqueue({ type: 'text-delta', id: 'text-1', delta: text }); + controller.enqueue({ type: 'text-end', id: 'text-1' }); + controller.enqueue({ + type: 'finish', + finishReason, + usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, + }); + controller.close(); + }, + }); +} + +function toolCallStream() { + return new ReadableStream({ + start(controller) { + controller.enqueue({ type: 'stream-start', warnings: [] }); + controller.enqueue({ type: 'response-metadata', id: 'id-0', modelId: 'mock', timestamp: new Date(0) }); + controller.enqueue({ + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'readFile', + input: '{"path":"test.txt"}', + providerExecuted: false, + }); + controller.enqueue({ + type: 'finish', + finishReason: 'tool-calls', + usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, + }); + controller.close(); + }, + }); +} + +interface HarnessOptions { + doStream: () => Promise<{ stream: ReadableStream }>; + withReadFileTool?: boolean; + readFileNeedsApproval?: boolean; +} + +async function makeHarness(opts: HarnessOptions) { + const storage = new LibSQLStore({ id: 'test-store', url: 'file::memory:?cache=shared' }); + + const tools: Record<string, ReturnType<typeof createTool>> = {}; + if (opts.withReadFileTool) { + tools.readFile = createTool({ + id: 'readFile', + description: 'Read a file', + inputSchema: z.object({ path: z.string() }), + ...(opts.readFileNeedsApproval ? { requireApproval: true } : {}), + execute: async () => ({ content: 'file contents' }), + }); + } + + const agent = new Agent({ + id: 'test-agent', + name: 'Test Agent', + instructions: 'You answer questions.', + model: new MastraLanguageModelV2Mock({ doStream: opts.doStream }) as any, + tools, + }); + + const mastra = new Mastra({ agents: { 'test-agent': agent }, logger: false, storage }); + const registeredAgent = mastra.getAgent('test-agent'); + + const controller = new AgentController({ + id: 'test-controller', + storage, + workspace: new Workspace({ name: 'test-workspace', skills: ['/tmp/test-skills'] }), + modes: [ + { + id: 'default', + name: 'Default', + description: 'default', + defaultModelId: 'test', + metadata: { default: true }, + instructions: 'You answer questions.', + }, + ], + initialState: { yolo: false }, + }); + (controller as any).getAgentForMode = () => registeredAgent; + + await controller.init(); + const session = await controller.createSession({ id: `s-${Math.random()}`, ownerId: 'test-owner' }); + await session.thread.create(); + + return { controller, session }; +} + +describe('runMC', () => { + it('resolves the final result when awaited without iterating', async () => { + const { controller, session } = await makeHarness({ + doStream: async () => ({ stream: textStream('The answer is 4.') }), + }); + + const run = runMC({ controller, session, prompt: 'What is 2+2?' }); + const result = await run.result; + + expect(result.status).toBe('completed'); + expect(result.exitCode).toBe(0); + expect(result.text).toBe('The answer is 4.'); + expect(result.usage).toEqual({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }); + expect(result.threadId).toBeTruthy(); + }); + + it('yields controller events while iterating, then resolves', async () => { + const { controller, session } = await makeHarness({ doStream: async () => ({ stream: textStream('Hi there') }) }); + + const run = runMC({ controller, session, prompt: 'Greet me' }); + const types: string[] = []; + for await (const event of run) { + types.push(event.type); + } + const result = await run.result; + + expect(types).toContain('agent_start'); + expect(types).toContain('agent_end'); + expect(result.status).toBe('completed'); + expect(result.text).toBe('Hi there'); + }); + + it('applies a tool approval policy and records tool calls', async () => { + let call = 0; + const { controller, session } = await makeHarness({ + withReadFileTool: true, + readFileNeedsApproval: true, + doStream: async () => { + call++; + return { stream: call === 1 ? toolCallStream() : textStream('Done reading') }; + }, + }); + + const approvals: string[] = []; + const policy: ResolutionPolicy = { + onToolApproval: event => { + approvals.push(event.toolName); + return 'approve'; + }, + onSuspension: () => ({ resumeData: 'Yes' }), + }; + + const run = runMC({ controller, session, prompt: 'Read test.txt', policy }); + const result = await run.result; + + expect(result.status).toBe('completed'); + expect(approvals).toContain('readFile'); + expect(result.toolCalls.map(c => c.name)).toContain('readFile'); + }); + + it('returns status "aborted" with exit code 1 when aborted', async () => { + const { controller, session } = await makeHarness({ + doStream: async () => { + await new Promise(r => setTimeout(r, 500)); + return { stream: textStream('too late') }; + }, + }); + + const run = runMC({ controller, session, prompt: 'Slow task' }); + run.abort(); + const result = await run.result; + + expect(result.status).toBe('aborted'); + expect(result.exitCode).toBe(1); + }); + + it('returns status "aborted" when an external signal is already aborted', async () => { + const { controller, session } = await makeHarness({ + doStream: async () => { + await new Promise(r => setTimeout(r, 500)); + return { stream: textStream('too late') }; + }, + }); + + const result = await runMC({ controller, session, prompt: 'Slow', signal: AbortSignal.abort() }).result; + expect(result.status).toBe('aborted'); + }); + + it('returns status "timeout" with exit code 2 when the timeout elapses', async () => { + const { controller, session } = await makeHarness({ + doStream: async () => { + await new Promise(r => setTimeout(r, 1000)); + return { stream: textStream('too late') }; + }, + }); + + const run = runMC({ controller, session, prompt: 'Slow task', timeoutMs: 50 }); + const result = await run.result; + + expect(result.status).toBe('timeout'); + expect(result.exitCode).toBe(2); + }); + + it('returns a structured error result for an unknown model (no throw)', async () => { + const { controller, session } = await makeHarness({ doStream: async () => ({ stream: textStream('unused') }) }); + + const result = await runMC({ controller, session, prompt: 'x', model: 'does-not-exist/model' }).result; + + expect(result.status).toBe('error'); + expect(result.exitCode).toBe(1); + expect(result.error?.message).toMatch(/Unknown model/); + }); + + it('returns a structured error result when thread resolution fails', async () => { + const { controller, session } = await makeHarness({ doStream: async () => ({ stream: textStream('unused') }) }); + + const result = await runMC({ + controller, + session, + prompt: 'x', + thread: { id: 'no-such-thread-or-title' }, + }).result; + + expect(result.status).toBe('error'); + expect(result.error?.message).toMatch(/No thread found/); + }); + + it('returns status "max_turns" with exit code 1 when the turn cap is hit mid-task', async () => { + // First turn is a tool call, so the agent still has work to do when the + // single-turn cap forces an abort. Later turns would produce text, but the + // cap should stop the run before then. + let call = 0; + const { controller, session } = await makeHarness({ + withReadFileTool: true, + doStream: async () => { + call++; + return { stream: call === 1 ? toolCallStream() : textStream('summary') }; + }, + }); + + const run = runMC({ controller, session, prompt: 'Read then summarize', maxTurns: 1 }); + const result = await run.result; + + expect(result.status).toBe('max_turns'); + expect(result.exitCode).toBe(1); + }); + + it('completes normally when the run finishes within the turn cap', async () => { + const { controller, session } = await makeHarness({ + doStream: async () => ({ stream: textStream('All done') }), + }); + + // Generous cap the single-turn run never reaches. + const result = await runMC({ controller, session, prompt: 'One shot', maxTurns: 5 }).result; + + expect(result.status).toBe('completed'); + expect(result.exitCode).toBe(0); + expect(result.text).toBe('All done'); + }); + + it('does not call process.exit', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + try { + const { controller, session } = await makeHarness({ doStream: async () => ({ stream: textStream('ok') }) }); + + await runMC({ controller, session, prompt: 'hi' }).result; + + expect(exitSpy).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + } + }); +}); diff --git a/mastracode/src/headless/run-mc.ts b/mastracode/src/headless/run-mc.ts new file mode 100644 index 000000000000..9860297d2eea --- /dev/null +++ b/mastracode/src/headless/run-mc.ts @@ -0,0 +1,386 @@ +/** + * Core programmatic runner for MastraCode headless runs. + * + * `runMC` is a pure async runner: it takes an already-built `controller` + + * `session` (from `createMastraCode(...)`), resolves run config, subscribes to + * the session, applies a {@link ResolutionPolicy} for approvals/suspensions, + * sends the prompt, and aggregates events into a {@link RunMCResult}. + * + * It never touches `process.*` and never calls `process.exit`. The returned + * {@link MCRun} is async-iterable over controller events and also resolves to a + * final result via `result`. + */ +import type { AgentControllerEvent, AgentControllerMessage, Session } from '@mastra/core/agent-controller'; + +import { autoApprovePolicy } from './policy.js'; +import type { MCRun, ResolutionPolicy, RunMCOptions, RunMCResult, RunMCStatus } from './types.js'; + +function extractAssistantText(message: AgentControllerMessage): string { + return message.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map(c => c.text) + .join(''); +} + +function exitCodeForStatus(status: RunMCStatus): number { + switch (status) { + case 'completed': + return 0; + case 'timeout': + return 2; + default: + return 1; + } +} + +interface MutableResult { + text: string; + finishReason?: string; + usage?: RunMCResult['usage']; + toolCalls: RunMCResult['toolCalls']; + toolResults: RunMCResult['toolResults']; + error?: RunMCResult['error']; + threadId?: string; +} + +function aggregate(event: AgentControllerEvent, acc: MutableResult): void { + switch (event.type) { + case 'message_end': + if (event.message.role === 'assistant') { + acc.text += extractAssistantText(event.message); + } + break; + case 'tool_start': + acc.toolCalls.push({ id: event.toolCallId, name: event.toolName, args: event.args }); + break; + case 'tool_end': { + const matching = acc.toolCalls.find(c => c.id === event.toolCallId); + acc.toolResults.push({ + id: event.toolCallId, + name: matching?.name ?? '', + result: event.result, + isError: event.isError, + }); + break; + } + case 'usage_update': + acc.usage = { + inputTokens: event.usage.promptTokens, + outputTokens: event.usage.completionTokens, + totalTokens: event.usage.totalTokens, + }; + break; + case 'error': + acc.error = { + name: event.error.name, + message: event.error.message, + stack: event.error.stack, + }; + break; + } +} + +/** Resolve a thread by its exact ID. Titles are not unique, so we don't match on them. */ +async function resolveThread<TState extends Record<string, unknown>>( + session: Session<TState>, + threadId: string, +): Promise<{ threadId: string } | { error: string }> { + const threads = await session.thread.list(); + + const byId = threads.find(t => t.id === threadId); + if (byId) return { threadId: byId.id }; + + return { error: `No thread found with ID "${threadId}"` }; +} + +/** + * A simple back-pressured async queue. Events are pushed in; consumers pull via + * the async iterator. `close()` ends iteration after draining buffered events. + * + * Buffered events are bounded by `maxBuffer`: if a consumer never iterates (the + * result-only path, `await run.result` without `for await (...)`) — or simply + * falls far behind — the oldest buffered events are dropped instead of growing + * without limit. The aggregated {@link RunMCResult} is built independently of + * this queue, so dropping buffered events never affects the final result. + */ +class EventQueue<T> { + #buffer: T[] = []; + #resolvers: Array<(r: IteratorResult<T>) => void> = []; + #closed = false; + readonly #maxBuffer: number; + + constructor(maxBuffer = 10_000) { + this.#maxBuffer = maxBuffer; + } + + push(value: T): void { + if (this.#closed) return; + const resolve = this.#resolvers.shift(); + if (resolve) { + resolve({ value, done: false }); + return; + } + this.#buffer.push(value); + if (this.#buffer.length > this.#maxBuffer) { + // Bound memory: drop the oldest event once over the cap. + this.#buffer.shift(); + } + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + for (const resolve of this.#resolvers.splice(0)) { + resolve({ value: undefined as unknown as T, done: true }); + } + } + + [Symbol.asyncIterator](): AsyncIterator<T> { + return { + next: (): Promise<IteratorResult<T>> => { + if (this.#buffer.length > 0) { + return Promise.resolve({ value: this.#buffer.shift()!, done: false }); + } + if (this.#closed) { + return Promise.resolve({ value: undefined as unknown as T, done: true }); + } + return new Promise<IteratorResult<T>>(resolve => this.#resolvers.push(resolve)); + }, + }; + } +} + +/** + * Run a headless MastraCode turn. Returns an {@link MCRun} handle that is + * async-iterable over controller events and resolves to a {@link RunMCResult}. + */ +export function runMC<TState extends Record<string, unknown>>(options: RunMCOptions<TState>): MCRun { + const { controller, session, prompt } = options; + const policy: ResolutionPolicy = options.policy ?? autoApprovePolicy; + + const queue = new EventQueue<AgentControllerEvent>(); + const acc: MutableResult = { text: '', toolCalls: [], toolResults: [] }; + + let timeoutId: ReturnType<typeof setTimeout> | undefined; + let timedOut = false; + let aborted = false; + let maxTurnsExceeded = false; + let assistantTurns = 0; + let settled = false; + let unsubscribe: (() => void) | undefined; + let resolveResult!: (r: RunMCResult) => void; + + const result = new Promise<RunMCResult>(resolve => { + resolveResult = resolve; + }); + + function finish(status: RunMCStatus): void { + if (settled) return; + settled = true; + if (timeoutId) clearTimeout(timeoutId); + unsubscribe?.(); + queue.close(); + resolveResult({ + status, + text: acc.text, + finishReason: acc.finishReason, + usage: acc.usage, + toolCalls: acc.toolCalls, + toolResults: acc.toolResults, + threadId: acc.threadId ?? session.thread.getId() ?? undefined, + error: acc.error, + exitCode: exitCodeForStatus(status), + }); + } + + function fail(message: string, name = 'Error'): void { + if (!acc.error) acc.error = { name, message }; + finish('error'); + } + + function abort(): void { + if (settled) return; + aborted = true; + session.abort(); + } + + if (options.signal) { + if (options.signal.aborted) { + // Defer so the caller can attach iteration / result handlers first. + queueMicrotask(() => abort()); + } else { + options.signal.addEventListener('abort', () => abort(), { once: true }); + } + } + + // Kick off the run asynchronously so the MCRun handle is returned synchronously. + void (async () => { + if (options.timeoutMs) { + timeoutId = setTimeout(() => { + timedOut = true; + session.abort(); + }, options.timeoutMs); + } + + // --- Config resolution (model / mode / thinking) --- + try { + if (options.model) { + const available = await controller.listAvailableModels(); + const match = available.find(m => m.id === options.model); + if (!match) return fail(`Unknown model: "${options.model}"`); + if (!match.hasApiKey) { + const keyHint = match.apiKeyEnvVar ? ` Set ${match.apiKeyEnvVar} to use this model.` : ''; + return fail(`Model "${options.model}" has no API key configured.${keyHint}`); + } + await session.model.switch({ modelId: options.model }); + } else if (options.mode) { + const modelId = options.modeDefaults?.[options.mode]; + if (modelId) { + const available = await controller.listAvailableModels(); + const match = available.find(m => m.id === modelId); + if (!match) return fail(`Unknown model "${modelId}" configured for mode "${options.mode}"`); + if (!match.hasApiKey) { + const keyHint = match.apiKeyEnvVar ? ` Set ${match.apiKeyEnvVar} to use this model.` : ''; + return fail(`Model "${modelId}" (mode: ${options.mode}) has no API key configured.${keyHint}`); + } + await session.model.switch({ modelId }); + } + // No configured model for mode → fall through to default (no failure). + } + + if (options.thinkingLevel) { + await session.state.set({ thinkingLevel: options.thinkingLevel } as unknown as Partial<TState>); + } + } catch (err) { + return fail(`Failed to resolve run config: ${(err as Error).message}`); + } + + // --- Subscribe --- + unsubscribe = session.subscribe(event => { + if (settled) return; + + if (event.type === 'tool_approval_required') { + let decision: 'approve' | 'deny'; + try { + decision = policy.onToolApproval(event); + } catch (err) { + fail(`Resolution policy failed: ${(err as Error).message}`); + return; + } + session.respondToToolApproval({ + decision: decision === 'approve' ? 'approve' : 'decline', + toolCallId: event.toolCallId, + }); + queue.push(event); + return; + } + + if (event.type === 'tool_suspended') { + let outcome: ReturnType<ResolutionPolicy['onSuspension']>; + try { + outcome = policy.onSuspension(event); + } catch (err) { + fail(`Resolution policy failed: ${(err as Error).message}`); + return; + } + if ('abort' in outcome) { + queue.push(event); + abort(); + return; + } + void session.respondToToolSuspension({ toolCallId: event.toolCallId, resumeData: outcome.resumeData }); + queue.push(event); + return; + } + + aggregate(event, acc); + queue.push(event); + + // Count agentic turns (one assistant response = one turn). When the cap is + // reached, abort the run; agent_end then resolves as 'max_turns'. + if (event.type === 'message_end' && event.message.role === 'assistant' && options.maxTurns !== undefined) { + assistantTurns += 1; + if (assistantTurns >= options.maxTurns && !maxTurnsExceeded) { + maxTurnsExceeded = true; + abort(); + } + } + + if (event.type === 'agent_end') { + acc.finishReason = event.reason; + if (timedOut) { + finish('timeout'); + } else if (event.reason === 'error') { + finish('error'); + } else if (maxTurnsExceeded && event.reason !== 'complete') { + // The cap forced an abort while the agent still had work to do. + finish('max_turns'); + } else if ((event.reason === 'aborted' || aborted) && !maxTurnsExceeded) { + finish('aborted'); + } else { + finish('completed'); + } + } + }); + + // --- Resource id --- + try { + if (options.resourceId) { + await controller.setResourceId(session, { resourceId: options.resourceId }); + } + } catch (err) { + return fail(`Failed to set resource id: ${(err as Error).message}`); + } + + // --- Thread selection --- + try { + const thread = options.thread; + if (thread?.id) { + const resolved = await resolveThread(session, thread.id); + if ('error' in resolved) return fail(resolved.error); + await session.thread.switch({ threadId: resolved.threadId }); + } else if (thread?.continueLatest) { + const threads = await session.thread.list(); + if (threads.length > 0) { + const sorted = [...threads].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); + await session.thread.switch({ threadId: sorted[0]!.id }); + } + } + } catch (err) { + return fail(`Failed to select thread: ${(err as Error).message}`); + } + + // --- Clone --- + if (options.thread?.clone) { + try { + await session.thread.clone(); + } catch (err) { + return fail(`Failed to clone thread: ${(err as Error).message}`); + } + } + + // --- Title --- + if (options.title) { + try { + await session.thread.rename({ title: options.title }); + } catch (err) { + return fail(`Failed to set thread title: ${(err as Error).message}`); + } + } + + // --- Send --- + try { + await session.sendMessage({ content: prompt }); + } catch (err) { + return fail(`Failed to send message: ${(err as Error).message}`); + } + })(); + + return { + result, + abort, + [Symbol.asyncIterator](): AsyncIterator<AgentControllerEvent> { + return queue[Symbol.asyncIterator](); + }, + }; +} diff --git a/mastracode/src/headless/scenarios.test.ts b/mastracode/src/headless/scenarios.test.ts new file mode 100644 index 000000000000..2c97221be246 --- /dev/null +++ b/mastracode/src/headless/scenarios.test.ts @@ -0,0 +1,292 @@ +/** + * End-to-end scenario tests for the headless API. + * + * Unlike `run-mc.test.ts` (which exercises one runMC behavior per case), these + * tests walk through realistic multi-step user journeys that compose the whole + * pipeline — `runMC` + a {@link ResolutionPolicy} + the pure formatters — the + * way a CLI session or a CI consumer actually would. + */ +import { Agent } from '@mastra/core/agent'; +import { AgentController } from '@mastra/core/agent-controller'; +import type { AgentControllerEvent } from '@mastra/core/agent-controller'; +import { Mastra } from '@mastra/core/mastra'; +import { MastraLanguageModelV2Mock } from '@mastra/core/test-utils/llm-mock'; +import { createTool } from '@mastra/core/tools'; +import { Workspace } from '@mastra/core/workspace'; +import { LibSQLStore } from '@mastra/libsql'; +import { describe, it, expect, vi } from 'vitest'; +import z from 'zod'; + +import { createHumanFormatState, formatHuman, formatJsonl, renderJsonResult, renderTextResult } from './format.js'; +import { runMC } from './run-mc.js'; +import type { ResolutionPolicy } from './types.js'; + +vi.setConfig({ testTimeout: 30_000 }); + +function textStream(text: string, finishReason: 'stop' | 'tool-calls' = 'stop') { + return new ReadableStream({ + start(controller) { + controller.enqueue({ type: 'stream-start', warnings: [] }); + controller.enqueue({ type: 'response-metadata', id: 'id-1', modelId: 'mock', timestamp: new Date(0) }); + controller.enqueue({ type: 'text-start', id: 'text-1' }); + controller.enqueue({ type: 'text-delta', id: 'text-1', delta: text }); + controller.enqueue({ type: 'text-end', id: 'text-1' }); + controller.enqueue({ + type: 'finish', + finishReason, + usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, + }); + controller.close(); + }, + }); +} + +function toolCallStream(toolName: string, input: string) { + return new ReadableStream({ + start(controller) { + controller.enqueue({ type: 'stream-start', warnings: [] }); + controller.enqueue({ type: 'response-metadata', id: 'id-0', modelId: 'mock', timestamp: new Date(0) }); + controller.enqueue({ type: 'tool-call', toolCallId: 'call-1', toolName, input, providerExecuted: false }); + controller.enqueue({ + type: 'finish', + finishReason: 'tool-calls', + usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, + }); + controller.close(); + }, + }); +} + +interface HarnessOptions { + doStream: () => Promise<{ stream: ReadableStream }>; + withReadFileTool?: boolean; + readFileNeedsApproval?: boolean; +} + +async function makeHarness(opts: HarnessOptions) { + const storage = new LibSQLStore({ id: 'test-store', url: 'file::memory:?cache=shared' }); + + const tools: Record<string, ReturnType<typeof createTool>> = {}; + if (opts.withReadFileTool) { + tools.readFile = createTool({ + id: 'readFile', + description: 'Read a file', + inputSchema: z.object({ path: z.string() }), + ...(opts.readFileNeedsApproval ? { requireApproval: true } : {}), + execute: async () => ({ content: 'file contents' }), + }); + } + + const agent = new Agent({ + id: 'test-agent', + name: 'Test Agent', + instructions: 'You answer questions.', + model: new MastraLanguageModelV2Mock({ doStream: opts.doStream }) as any, + tools, + }); + + const mastra = new Mastra({ agents: { 'test-agent': agent }, logger: false, storage }); + const registeredAgent = mastra.getAgent('test-agent'); + + const controller = new AgentController({ + id: 'test-controller', + storage, + workspace: new Workspace({ name: 'test-workspace', skills: ['/tmp/test-skills'] }), + modes: [ + { + id: 'default', + name: 'Default', + description: 'default', + defaultModelId: 'test', + metadata: { default: true }, + instructions: 'You answer questions.', + }, + ], + initialState: { yolo: false }, + }); + (controller as any).getAgentForMode = () => registeredAgent; + + await controller.init(); + const session = await controller.createSession({ id: `s-${Math.random()}`, ownerId: 'test-owner' }); + await session.thread.create(); + + return { controller, session }; +} + +describe('headless scenarios', () => { + it('streams a turn to human stdout/stderr and renders a final text result', async () => { + // A CLI user runs `mastracode --prompt ... --output human`: assistant text + // streams to stdout as it arrives, then the final result is rendered. + const { controller, session } = await makeHarness({ + doStream: async () => ({ stream: textStream('The answer is 4.') }), + }); + + const run = runMC({ controller, session, prompt: 'What is 2+2?' }); + + const state = createHumanFormatState(); + let stdout = ''; + let stderr = ''; + for await (const event of run) { + const out = formatHuman(event, state); + if (out.stdout) stdout += out.stdout; + if (out.stderr) stderr += out.stderr; + } + const result = await run.result; + + expect(result.status).toBe('completed'); + expect(stdout).toContain('The answer is 4.'); + expect(stderr).toBe(''); + expect(renderTextResult(result)).toBe('The answer is 4.\n'); + }); + + it('runs a tool, approves it, and produces a machine-readable JSON result', async () => { + // A CI consumer runs a tool-using task and reads the structured JSON result. + let call = 0; + const { controller, session } = await makeHarness({ + withReadFileTool: true, + readFileNeedsApproval: true, + doStream: async () => { + call++; + return { + stream: call === 1 ? toolCallStream('readFile', '{"path":"notes.txt"}') : textStream('Read the notes.'), + }; + }, + }); + + const run = runMC({ controller, session, prompt: 'Read notes.txt' }); + const result = await run.result; + + expect(result.status).toBe('completed'); + expect(result.toolCalls.map(c => c.name)).toContain('readFile'); + expect(result.toolResults.length).toBeGreaterThan(0); + + const json = JSON.parse(renderJsonResult(result)); + expect(json.text).toBe('Read the notes.'); + expect(json.toolCalls[0].name).toBe('readFile'); + expect(json.usage).toEqual({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }); + expect(json.threadId).toBeTruthy(); + }); + + it('emits one JSONL object per controller event for a streaming consumer', async () => { + // `--output jsonl`: every event becomes a newline-delimited JSON object. + const { controller, session } = await makeHarness({ + doStream: async () => ({ stream: textStream('Hello') }), + }); + + const run = runMC({ controller, session, prompt: 'Greet me' }); + const lines: Record<string, unknown>[] = []; + for await (const event of run) { + lines.push(formatJsonl(event)); + } + await run.result; + + // Each line is a faithful, serializable copy of the event. + expect(lines.length).toBeGreaterThan(0); + expect(lines.map(l => l.type)).toContain('agent_start'); + expect(lines.map(l => l.type)).toContain('agent_end'); + for (const line of lines) { + expect(() => JSON.parse(JSON.stringify(line))).not.toThrow(); + } + }); + + it('honors a strict CI policy that denies approvals', async () => { + // A CI consumer supplies a policy that refuses every tool approval. The run + // still completes (the agent moves on) but the policy is consulted. + let call = 0; + const { controller, session } = await makeHarness({ + withReadFileTool: true, + readFileNeedsApproval: true, + doStream: async () => { + call++; + return { + stream: call === 1 ? toolCallStream('readFile', '{"path":"secret.txt"}') : textStream('Cannot read it.'), + }; + }, + }); + + const denied: string[] = []; + const denyPolicy: ResolutionPolicy = { + onToolApproval: event => { + denied.push(event.toolName); + return 'deny'; + }, + onSuspension: () => ({ abort: true }), + }; + + const run = runMC({ controller, session, prompt: 'Read secret.txt', policy: denyPolicy }); + const result = await run.result; + + expect(denied).toContain('readFile'); + expect(result.exitCode).toBe(0); + expect(result.status).toBe('completed'); + }); + + it('continues an existing thread across two runs and preserves the thread id', async () => { + // First run creates/uses a thread; a second run targets the same thread by + // id and keeps writing to it — the typical `--continue` / `--thread` flow. + let call = 0; + const { controller, session } = await makeHarness({ + doStream: async () => { + call++; + return { stream: textStream(call === 1 ? 'First answer.' : 'Second answer.') }; + }, + }); + + const first = await runMC({ controller, session, prompt: 'First question' }).result; + expect(first.status).toBe('completed'); + const threadId = first.threadId!; + expect(threadId).toBeTruthy(); + + const second = await runMC({ + controller, + session, + prompt: 'Follow-up question', + thread: { id: threadId }, + }).result; + + expect(second.status).toBe('completed'); + expect(second.text).toBe('Second answer.'); + expect(second.threadId).toBe(threadId); + }); + + it('reports a timeout as a non-zero exit without throwing, for CI gating', async () => { + // A CI job sets a timeout; a slow run must surface as a clean exit code 2 + // rather than a thrown error or a process exit. + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + try { + const { controller, session } = await makeHarness({ + doStream: async () => { + await new Promise(r => setTimeout(r, 1000)); + return { stream: textStream('too late') }; + }, + }); + + const result = await runMC({ controller, session, prompt: 'Slow task', timeoutMs: 50 }).result; + + expect(result.status).toBe('timeout'); + expect(result.exitCode).toBe(2); + expect(exitSpy).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + } + }); + + it('collects events and the final result on the same run handle', async () => { + // Both iteration and awaiting must observe the same single run. + const { controller, session } = await makeHarness({ + doStream: async () => ({ stream: textStream('Done.') }), + }); + + const run = runMC({ controller, session, prompt: 'Do it' }); + const events: AgentControllerEvent[] = []; + for await (const event of run) { + events.push(event); + } + const result = await run.result; + + expect(events.length).toBeGreaterThan(0); + expect(events.at(-1)?.type).toBe('agent_end'); + expect(result.text).toBe('Done.'); + expect(result.status).toBe('completed'); + }); +}); diff --git a/mastracode/src/headless/types.ts b/mastracode/src/headless/types.ts new file mode 100644 index 000000000000..961ccf7ebdda --- /dev/null +++ b/mastracode/src/headless/types.ts @@ -0,0 +1,141 @@ +/** + * Shared types for the headless / programmatic MastraCode API. + * + * These types are consumed by the core runner (`runMC`), the resolution policy, + * the output formatters, and the CLI adapter. They are intentionally free of any + * `process.*` access so the core API is usable from CI / Node code. + */ +import type { AgentController, AgentControllerEvent, Session } from '@mastra/core/agent-controller'; + +export type RunMode = 'build' | 'plan' | 'fast'; +export type ThinkingLevel = 'off' | 'low' | 'medium' | 'high' | 'xhigh'; + +export const VALID_MODES = ['build', 'plan', 'fast'] as const; +export const VALID_THINKING_LEVELS = ['off', 'low', 'medium', 'high', 'xhigh'] as const; + +/** + * Named permission modes for non-interactive runs. Maps to a built-in + * {@link ResolutionPolicy}: + * - `auto` — approve every tool and auto-resolve suspensions (the default). + * - `deny` — refuse every tool approval and abort on any suspension. + */ +export type PermissionMode = 'auto' | 'deny'; +export const VALID_PERMISSION_MODES = ['auto', 'deny'] as const; + +/** How `runMC` should resume `tool_approval_required` and `tool_suspended` events. */ +export interface ResolutionPolicy { + /** + * Called for every `tool_approval_required` event. Return the decision; the + * runner forwards it to `session.respondToToolApproval`. + */ + onToolApproval(event: Extract<AgentControllerEvent, { type: 'tool_approval_required' }>): 'approve' | 'deny'; + /** + * Called for every `tool_suspended` event. Return `resumeData` to resume the + * tool, or `{ abort: true }` to abort the run. + */ + onSuspension( + event: Extract<AgentControllerEvent, { type: 'tool_suspended' }>, + ): { resumeData: unknown } | { abort: true }; +} + +/** Thread selection / mutation options resolved up front by `runMC`. */ +export interface RunMCThreadOptions { + /** Resume a specific thread by its exact id. */ + id?: string; + /** Resume the most recently updated thread instead of creating a new one. */ + continueLatest?: boolean; + /** Clone the resolved (or current) thread before running — work on a copy. */ + clone?: boolean; +} + +export interface RunMCOptions<TState extends Record<string, unknown> = Record<string, unknown>> { + /** Controller built via `createMastraCode(...)`. */ + controller: AgentController<TState>; + /** Session built via `createMastraCode(...)`. */ + session: Session<TState>; + /** The task to run. */ + prompt: string; + + /** Explicit model id override. Takes precedence over `mode`. */ + model?: string; + /** Execution mode; resolves a model from `modeDefaults` when `model` is absent. */ + mode?: RunMode; + /** Per-mode default model ids (resolved from settings at startup). */ + modeDefaults?: Record<string, string>; + /** Thinking-effort level. */ + thinkingLevel?: ThinkingLevel; + /** Thread selection / mutation. */ + thread?: RunMCThreadOptions; + /** Resource id for thread scoping. */ + resourceId?: string; + /** Set or rename the thread title before running. */ + title?: string; + /** Abort with `status: 'timeout'` (exit code 2) if not complete within this many ms. */ + timeoutMs?: number; + /** + * Maximum number of agentic turns (assistant responses). When the limit is + * reached the run aborts with `status: 'max_turns'` (exit code 1). No limit + * by default. + */ + maxTurns?: number; + /** How approvals / suspensions are resolved. Defaults to {@link autoApprovePolicy}. */ + policy?: ResolutionPolicy; + /** External abort signal; aborting it aborts the run. */ + signal?: AbortSignal; +} + +export type RunMCStatus = 'completed' | 'error' | 'aborted' | 'timeout' | 'max_turns'; + +export interface RunMCUsage { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; +} + +export interface RunMCToolCall { + id: string; + name: string; + args: unknown; +} + +export interface RunMCToolResult { + id: string; + name: string; + result: unknown; + isError: boolean; +} + +export interface RunMCError { + name: string; + message: string; + stack?: string; +} + +export interface RunMCResult { + status: RunMCStatus; + /** Aggregated assistant text across all message_end events. */ + text: string; + /** Underlying finish reason from `agent_end`, when the run finished normally. */ + finishReason?: string; + usage?: RunMCUsage; + toolCalls: RunMCToolCall[]; + toolResults: RunMCToolResult[]; + threadId?: string; + error?: RunMCError; + /** 0 success, 1 error/aborted/max_turns, 2 timeout. */ + exitCode: number; +} + +/** + * A handle to an in-flight `runMC` run. It is async-iterable over controller + * events and also resolves to a final {@link RunMCResult} via `result`. + * + * Both `for await (const e of run)` and `await run.result` work on the same run; + * awaiting `result` without iterating still drains events internally. + */ +export interface MCRun extends AsyncIterable<AgentControllerEvent> { + /** Resolves once the run completes, times out, errors, or is aborted. */ + result: Promise<RunMCResult>; + /** Abort the in-flight run. */ + abort(): void; +} diff --git a/mastracode/src/index.test.ts b/mastracode/src/index.test.ts index a457ef29f348..f64b9be71411 100644 --- a/mastracode/src/index.test.ts +++ b/mastracode/src/index.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; const createSessionCalls = vi.hoisted<Array<{ id?: string; ownerId?: string; resourceId?: string }>>(() => []); vi.mock('@mastra/core/llm', () => ({ + MastraModelGateway: class {}, GatewayRegistry: { getInstance: vi.fn(() => ({ syncGateways: vi.fn(), @@ -23,10 +24,10 @@ vi.mock('@mastra/core/agent-controller', () => ({ AgentController: class { constructor(config: { resourceId?: string; - heartbeatHandlers?: Array<{ immediate?: boolean; handler: () => unknown }>; + intervalHandlers?: Array<{ immediate?: boolean; handler: () => unknown }>; }) { - for (const heartbeat of config.heartbeatHandlers ?? []) { - if (heartbeat.immediate !== false) void heartbeat.handler(); + for (const interval of config.intervalHandlers ?? []) { + if (interval.immediate !== false) void interval.handler(); } } diff --git a/mastracode/src/index.ts b/mastracode/src/index.ts index 9c265dcc9342..cb871bd0b32c 100644 --- a/mastracode/src/index.ts +++ b/mastracode/src/index.ts @@ -2,10 +2,10 @@ import { createHash } from 'node:crypto'; import { hostname } from 'node:os'; import path from 'node:path'; -import { Agent } from '@mastra/core/agent'; +import type { Agent } from '@mastra/core/agent'; import { AgentController } from '@mastra/core/agent-controller'; import type { - HeartbeatHandler, + IntervalHandler, AgentControllerConfig, AgentControllerEvent, AgentControllerMode, @@ -13,6 +13,7 @@ import type { AgentControllerRequestContext, Session, } from '@mastra/core/agent-controller'; +import { createCodingAgent } from '@mastra/core/coding-agent'; import type { PubSub } from '@mastra/core/events'; import { PROVIDER_REGISTRY } from '@mastra/core/llm'; import type { ProviderConfig } from '@mastra/core/llm'; @@ -70,7 +71,9 @@ import { saveSettings, } from './onboarding/settings.js'; import { getToolCategory } from './permissions.js'; +import { PluginManager } from './plugins/manager.js'; import { PlanRejectionAbortProcessor } from './processors/plan-rejection-abort.js'; +import { createAmazonBedrockGateway } from './providers/amazon-bedrock-gateway.js'; import { setAuthStorage } from './providers/claude-max.js'; import { setAuthStorage as setGitHubCopilotAuthStorage } from './providers/github-copilot.js'; import { setAuthStorage as setOpenAIAuthStorage } from './providers/openai-codex.js'; @@ -142,6 +145,20 @@ function applyEffectiveDefaultsToModes( }); } +function addPluginToolsToModeAllowlists( + modes: AgentControllerMode[], + pluginToolNames: string[], +): AgentControllerMode[] { + if (pluginToolNames.length === 0) return modes; + return modes.map(mode => { + if (!mode.availableTools) return mode; + return { + ...mode, + availableTools: Array.from(new Set([...mode.availableTools, ...pluginToolNames])), + }; + }); +} + export interface MastraCodeConfig { /** Working directory for project detection. Default: process.cwd() */ cwd?: string; @@ -175,8 +192,8 @@ export interface MastraCodeConfig { initialState?: Partial<MastraCodeState>; /** Override id generation for threads/messages. Primarily useful for deterministic tests. */ idGenerator?: AgentControllerConfig<MastraCodeState>['idGenerator']; - /** Override heartbeat handlers. Default: gateway-sync */ - heartbeatHandlers?: HeartbeatHandler[]; + /** Override interval handlers. Default: gateway-sync */ + intervalHandlers?: IntervalHandler[]; /** Override the workspace. Default: local filesystem + local sandbox based on detected project */ workspace?: AgentControllerConfig<MastraCodeState>['workspace']; /** Override the config directory name. Default: '.mastracode'. Replaces '.mastracode' in all project-level and global config paths (MCP, hooks, commands, database, skills, agent instructions). */ @@ -187,6 +204,10 @@ export interface MastraCodeConfig { disableMcp?: boolean; /** Disable hooks. Default: false */ disableHooks?: boolean; + /** Disable plugin discovery/loading. Default: false */ + disablePlugins?: boolean; + /** Override the plugin manager. Primarily useful for tests or embedding. */ + pluginManager?: PluginManager; /** * Override the memory instance (or dynamic factory) passed to the AgentController. * When provided, this replaces the default `getDynamicMemory(storage, vectorStore)` which @@ -315,6 +336,7 @@ export async function createMastraCodeAgentController(config?: MastraCodeConfig) routeThroughMastraGateway: false, settingsPath: config?.settingsPath, }); + const amazonBedrockGateway = createAmazonBedrockGateway(); // Project detection const project = detectProject(cwd); @@ -452,6 +474,12 @@ export async function createMastraCodeAgentController(config?: MastraCodeConfig) ? undefined : new HookManager(project.rootPath, 'session-init', configDir, homeDir); + const pluginManager = config?.disablePlugins + ? undefined + : (config?.pluginManager ?? new PluginManager({ projectRoot: project.rootPath, configDir, homeDir })); + const loadedPlugins = pluginManager ? await pluginManager.reload() : []; + const pluginTools = pluginManager?.getPluginTools() ?? {}; + // Scorers (live evaluation with sampling) const outcomeScorer = createOutcomeScorer(); const efficiencyScorer = createEfficiencyScorer(); @@ -516,12 +544,17 @@ export async function createMastraCodeAgentController(config?: MastraCodeConfig) }, }) : undefined; - const codeAgent: Agent = new Agent({ + const codeAgent: Agent = createCodingAgent({ id: CODE_AGENT_ID, name: 'Code Agent', + // Workspace is wired per-request at the AgentController level (see + // `config.workspace` below), so opt out of the factory's default local + // workspace. An explicit `undefined` is required: the factory only builds a + // default when the `workspace` key is absent. + workspace: undefined, instructions: getDynamicInstructions, model: getDynamicModel, - tools: createDynamicTools(mcpManager, config?.extraTools, config?.disabledTools, storage), + tools: createDynamicTools(mcpManager, config?.extraTools, config?.disabledTools, storage, pluginTools), hooks: createToolHooks(hookManager), scorers: { outcome: { @@ -620,7 +653,7 @@ export async function createMastraCodeAgentController(config?: MastraCodeConfig) }, ]; - const defaultHeartbeatHandlers: HeartbeatHandler[] = [ + const defaultIntervalHandlers: IntervalHandler[] = [ { id: 'gateway-sync', intervalMs: 5 * 60 * 1000, @@ -628,7 +661,7 @@ export async function createMastraCodeAgentController(config?: MastraCodeConfig) handler: () => syncGateways(), }, ]; - const heartbeatHandlers = config?.heartbeatHandlers ?? defaultHeartbeatHandlers; + const intervalHandlers = config?.intervalHandlers ?? defaultIntervalHandlers; // Build lightweight provider access for resolving built-in packs at startup. // Anthropic/OpenAI use AuthStorage; other providers use env API keys. @@ -684,7 +717,10 @@ export async function createMastraCodeAgentController(config?: MastraCodeConfig) const effectiveCavemanObservations = globalSettings.models.omCavemanObservations ?? undefined; const effectiveObserveAttachments = globalSettings.models.omObserveAttachments ?? 'auto'; - const modes = applyEffectiveDefaultsToModes(config?.modes ? config.modes : defaultModes, effectiveDefaults); + const modes = addPluginToolsToModeAllowlists( + applyEffectiveDefaultsToModes(config?.modes ? config.modes : defaultModes, effectiveDefaults), + Object.keys(pluginTools), + ); const defaultModeId = modes.find(mode => mode.metadata?.default === true)?.id ?? modes.find(mode => mode.id === 'build')?.id ?? @@ -746,7 +782,7 @@ export async function createMastraCodeAgentController(config?: MastraCodeConfig) stateSchema: typedStateSchema, agent: codeAgent, subagents: config?.subagents ?? [], - gateways: [mastraCodeGateway], + gateways: [amazonBedrockGateway, mastraCodeGateway], workspace: config?.workspace ?? (args => getDynamicWorkspace(args)), browser: config?.browser, idGenerator: config?.idGenerator, @@ -755,6 +791,13 @@ export async function createMastraCodeAgentController(config?: MastraCodeConfig) projectPath: project.rootPath, projectName: project.name, gitBranch: project.gitBranch, + pluginSkillPaths: loadedPlugins.flatMap(plugin => (plugin.status === 'active' ? (plugin.skillPaths ?? []) : [])), + pluginCommandPaths: loadedPlugins.flatMap(plugin => + plugin.status === 'active' ? (plugin.commandPaths ?? []) : [], + ), + pluginInstructions: loadedPlugins.flatMap(plugin => + plugin.status === 'active' && plugin.instructions ? [plugin.instructions] : [], + ), yolo: true, ...globalInitialState, ...config?.initialState, @@ -763,7 +806,7 @@ export async function createMastraCodeAgentController(config?: MastraCodeConfig) configDir, }, modes, - heartbeatHandlers, + intervalHandlers, modelUseCountProvider: () => loadSettings().modelUseCounts, modelUseCountTracker: modelId => { try { @@ -802,6 +845,9 @@ export async function createMastraCodeAgentController(config?: MastraCodeConfig) memory, mcpManager, hookManager, + pluginManager, + loadedPlugins, + pluginTools, signalsPubSub, authStorage, resolveModel, @@ -959,3 +1005,34 @@ export async function mountAgentControllerOnMastra( * case: `bootLocalAgentController` (local) or {@link mountAgentControllerOnMastra} (server). */ export const createMastraCode = bootLocalAgentController; + +/** + * Programmatic headless API. `runMC` runs an already-built controller/session + * (from {@link createMastraCode}) as an async-iterable run that also resolves to + * a typed result. Also available via the `mastracode/headless` subpath. + */ +export { + runMC, + runMCCli, + hasHeadlessFlag, + autoApprovePolicy, + denyPolicy, + permissionModeToPolicy, + formatHuman, + formatJsonl, + renderTextResult, + renderJsonResult, +} from './headless/index.js'; +export type { + RunMCOptions, + RunMCResult, + RunMCStatus, + RunMCUsage, + RunMCToolCall, + RunMCToolResult, + RunMCError, + RunMCThreadOptions, + MCRun, + ResolutionPolicy, + PermissionMode, +} from './headless/index.js'; diff --git a/mastracode/src/main.ts b/mastracode/src/main.ts index c9098d808e55..5105724521e8 100644 --- a/mastracode/src/main.ts +++ b/mastracode/src/main.ts @@ -6,8 +6,9 @@ import fs from 'node:fs'; import { createMastraCodeAnalytics } from './analytics.js'; import { isStreamDestroyedError } from './error-classification.js'; -import { hasHeadlessFlag, headlessMain } from './headless.js'; +import { hasHeadlessFlag, runMCCli } from './headless/index.js'; import { createBrowserFromSettings, loadSettings } from './onboarding/settings.js'; +import { formatScaffoldSuccess, scaffoldPlugin } from './plugins/scaffold.js'; import { detectTerminalTheme } from './tui/detect-theme.js'; import { MastraTUI } from './tui/index.js'; import { applyThemeMode, restoreTerminalForeground } from './tui/theme.js'; @@ -79,7 +80,7 @@ async function tuiMain(pipedInput?: string | null) { // MCP connection is deferred to TUI.init() (after ui.start()) so that // status messages use showInfo() instead of console.info(), which would - // corrupt the terminal. Headless mode still inits from headless.ts. + // corrupt the terminal. Headless mode still inits from headless/cli.ts. setupDebugLogging(); @@ -123,6 +124,7 @@ async function tuiMain(pipedInput?: string | null) { analytics, authStorage, mcpManager, + pluginManager: result.pluginManager, appName: 'Mastra Code', version: getCurrentVersion(), inlineQuestions: true, @@ -150,7 +152,7 @@ const asyncCleanup = async () => { await Promise.allSettled([ mcpManager?.disconnect(), controller?.getMastra()?.stopWorkers(), - controller?.stopHeartbeats(), + controller?.stopIntervals(), closeSignalsPubSub?.(), analytics?.shutdown(), ]); @@ -180,6 +182,34 @@ function hasEconnrefused(err: unknown, depth = 0): boolean { return false; } +function pluginMain(args: string[]): void { + if (args[0] !== 'scaffold') { + process.stderr.write('Usage: mastracode plugin scaffold <dir> [--id acme.foo] [--name "Foo Tools"]\n'); + process.exit(1); + } + + const dir = args[1]; + if (!dir) { + process.stderr.write('Usage: mastracode plugin scaffold <dir> [--id acme.foo] [--name "Foo Tools"]\n'); + process.exit(1); + } + + const id = readFlag(args, '--id'); + const name = readFlag(args, '--name'); + const targetDir = scaffoldPlugin(dir, { ...(id ? { id } : {}), ...(name ? { name } : {}) }); + process.stdout.write(`${formatScaffoldSuccess(targetDir)}\n`); +} + +function readFlag(args: string[], flag: string): string | undefined { + const index = args.indexOf(flag); + if (index === -1) return undefined; + const value = args[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for ${flag}`); + } + return value; +} + function handleFatalError(error: unknown): never { // Always write to real stderr, even if console.error was overridden const write = (msg: string) => process.stderr.write(msg + '\n'); @@ -211,8 +241,12 @@ function handleFatalError(error: unknown): never { } async function main() { + if (process.argv[2] === 'plugin') { + return pluginMain(process.argv.slice(3)); + } + if (hasHeadlessFlag(process.argv) || process.argv.includes('--help') || process.argv.includes('-h')) { - return headlessMain(); + return runMCCli(); } if (process.argv.includes('--acp')) { @@ -233,7 +267,7 @@ async function main() { const reopenedStdin = reopenStdinFromTTY(); if (!reopenedStdin) { process.stderr.write('No TTY available — falling back to headless mode.\n'); - return headlessMain(pipedInput); + return runMCCli(pipedInput); } } diff --git a/mastracode/src/mcp/__tests__/config.test.ts b/mastracode/src/mcp/__tests__/config.test.ts index 15681c751a07..93c9528f35c3 100644 --- a/mastracode/src/mcp/__tests__/config.test.ts +++ b/mastracode/src/mcp/__tests__/config.test.ts @@ -43,6 +43,17 @@ describe('classifyServerEntry', () => { expect(classifyServerEntry({ url: 'http://localhost:8080/sse' }).kind).toBe('http'); expect(classifyServerEntry({ url: 'https://mcp.example.com/mcp' }).kind).toBe('http'); }); + + it('accepts http entry whose url uses ${VAR} that resolves to a valid URL', () => { + const previous = process.env.MC_TEST_MCP_URL; + process.env.MC_TEST_MCP_URL = 'https://mcp.example.com/mcp'; + try { + expect(classifyServerEntry({ url: '${MC_TEST_MCP_URL}' }).kind).toBe('http'); + } finally { + if (previous === undefined) delete process.env.MC_TEST_MCP_URL; + else process.env.MC_TEST_MCP_URL = previous; + } + }); }); describe('validateConfig', () => { @@ -109,6 +120,45 @@ describe('validateConfig', () => { } }); + it('expands ${VAR} references in the http url from the environment', () => { + const previous = process.env.MC_TEST_MCP_URL; + process.env.MC_TEST_MCP_URL = 'https://api.example.com/mcp'; + try { + const result = validateConfig({ + mcpServers: { + remote: { url: '${MC_TEST_MCP_URL}' }, + }, + }); + expect(result.mcpServers!['remote']).toEqual({ + url: 'https://api.example.com/mcp', + headers: undefined, + }); + } finally { + if (previous === undefined) delete process.env.MC_TEST_MCP_URL; + else process.env.MC_TEST_MCP_URL = previous; + } + }); + + it('expands ${VAR} references in stdio env values from the environment', () => { + const previous = process.env.MC_TEST_MCP_KEY; + process.env.MC_TEST_MCP_KEY = 'secret-123'; + try { + const result = validateConfig({ + mcpServers: { + fs: { command: 'npx', args: ['-y', 'mcp-fs'], env: { API_KEY: '${MC_TEST_MCP_KEY}' } }, + }, + }); + expect(result.mcpServers!['fs']).toEqual({ + command: 'npx', + args: ['-y', 'mcp-fs'], + env: { API_KEY: 'secret-123' }, + }); + } finally { + if (previous === undefined) delete process.env.MC_TEST_MCP_KEY; + else process.env.MC_TEST_MCP_KEY = previous; + } + }); + it('accepts http server entry with OAuth config', () => { const result = validateConfig({ mcpServers: { diff --git a/mastracode/src/mcp/config.ts b/mastracode/src/mcp/config.ts index bb5ce7306ffc..b60a823c4ea5 100644 --- a/mastracode/src/mcp/config.ts +++ b/mastracode/src/mcp/config.ts @@ -99,6 +99,19 @@ function expandHeaderEnvVars(headers: Record<string, unknown>): Record<string, s return expanded; } +/** + * Expand `${VAR}` and `$VAR` references in every string-valued environment + * variable passed to a stdio server, so that secrets can be referenced from the + * host environment instead of being hardcoded in `mcp.json`. + */ +function expandEnvValues(env: Record<string, unknown>): Record<string, string> { + const expanded: Record<string, string> = {}; + for (const [key, value] of Object.entries(env)) { + if (typeof value === 'string') expanded[key] = expandEnvVars(value); + } + return expanded; +} + /** * Classify a raw server entry as stdio, http, or skip (with reason). */ @@ -121,7 +134,7 @@ export function classifyServerEntry(raw: unknown): { kind: 'stdio' | 'http' | 's if (hasUrl) { try { - new URL(obj.url as string); + new URL(expandEnvVars(obj.url as string)); } catch { return { kind: 'skip', reason: `Invalid URL: "${obj.url}"` }; } @@ -149,7 +162,8 @@ export function validateConfig(raw: unknown): McpConfig { servers[name] = { command: e.command as string, args: Array.isArray(e.args) ? (e.args as string[]) : undefined, - env: typeof e.env === 'object' && e.env !== null ? (e.env as Record<string, string>) : undefined, + env: + typeof e.env === 'object' && e.env !== null ? expandEnvValues(e.env as Record<string, unknown>) : undefined, }; } else if (classification.kind === 'http') { const e = entry as Record<string, unknown>; @@ -159,7 +173,7 @@ export function validateConfig(raw: unknown): McpConfig { continue; } servers[name] = { - url: e.url as string, + url: expandEnvVars(e.url as string), headers: typeof e.headers === 'object' && e.headers !== null ? expandHeaderEnvVars(e.headers as Record<string, unknown>) diff --git a/mastracode/src/onboarding/__tests__/settings.test.ts b/mastracode/src/onboarding/__tests__/settings.test.ts index 5504d5ee182e..077287216fdb 100644 --- a/mastracode/src/onboarding/__tests__/settings.test.ts +++ b/mastracode/src/onboarding/__tests__/settings.test.ts @@ -67,6 +67,7 @@ function createSettings(overrides?: Partial<GlobalSettings>): GlobalSettings { stagehand: { env: 'LOCAL' }, }, shellPassthrough: { mode: 'default' }, + voice: { enabled: false, engine: 'cloud', provider: 'openai', model: 'whisper-1' }, signals: { unixSocketPubSub: false, experimentalGithubSignals: false }, observability: { resources: {}, localTracing: false }, ...overrides, @@ -102,6 +103,69 @@ function withTempSettingsFile(run: (filePath: string) => void): void { } } +describe('voice settings parsing', () => { + it('back-compat: old { enabled }-only file gets engine + provider defaults', () => { + withTempSettingsFile(filePath => { + writeFileSync(filePath, JSON.stringify({ voice: { enabled: true } }), 'utf-8'); + + const { voice } = loadSettings(filePath); + + expect(voice.enabled).toBe(true); + expect(voice.engine).toMatch(/^(macos-native|cloud)$/); + expect(voice.provider).toBe('openai'); + expect(voice.model).toBe('whisper-1'); + }); + }); + + it('keeps a valid provider/model pair', () => { + withTempSettingsFile(filePath => { + writeFileSync( + filePath, + JSON.stringify({ voice: { enabled: true, engine: 'cloud', provider: 'groq', model: 'whisper-large-v3' } }), + 'utf-8', + ); + + const { voice } = loadSettings(filePath); + + expect(voice.engine).toBe('cloud'); + expect(voice.provider).toBe('groq'); + expect(voice.model).toBe('whisper-large-v3'); + }); + }); + + it('falls back to the provider default when the model is unknown', () => { + withTempSettingsFile(filePath => { + writeFileSync(filePath, JSON.stringify({ voice: { provider: 'groq', model: 'does-not-exist' } }), 'utf-8'); + + const { voice } = loadSettings(filePath); + + expect(voice.provider).toBe('groq'); + expect(voice.model).toBe('whisper-large-v3-turbo'); + }); + }); + + it('falls back to the global default for an unknown provider', () => { + withTempSettingsFile(filePath => { + writeFileSync(filePath, JSON.stringify({ voice: { provider: 'nope' } }), 'utf-8'); + + const { voice } = loadSettings(filePath); + + expect(voice.provider).toBe('openai'); + expect(voice.model).toBe('whisper-1'); + }); + }); + + it('rejects an invalid engine value', () => { + withTempSettingsFile(filePath => { + writeFileSync(filePath, JSON.stringify({ voice: { engine: 'bogus' } }), 'utf-8'); + + const { voice } = loadSettings(filePath); + + expect(voice.engine).toMatch(/^(macos-native|cloud)$/); + }); + }); +}); + describe('customProviders parsing/persistence', () => { it('returns defaults with empty customProviders when missing from settings file', () => { withTempSettingsFile(filePath => { diff --git a/mastracode/src/onboarding/settings.ts b/mastracode/src/onboarding/settings.ts index a506a0179725..7cc2de7277c9 100644 --- a/mastracode/src/onboarding/settings.ts +++ b/mastracode/src/onboarding/settings.ts @@ -10,6 +10,7 @@ import type { MastraBrowser } from '@mastra/core/browser'; import type { LSPConfig } from '@mastra/core/workspace'; import { AuthStorage } from '../auth/storage.js'; import { buildCodexStagehandFetch, createCodexMiddleware } from '../providers/openai-codex.js'; +import { DEFAULT_STT_PROVIDER, resolveSTTModel } from '../tui/voice/stt-registry.js'; import { getAppDataDir } from '../utils/project.js'; /** A saved custom pack — user-defined model selections for each mode. */ @@ -84,6 +85,21 @@ export interface ShellPassthroughSettings { family?: ShellPassthroughSettingsFamily | string; } +/** STT engine: on-device macOS recognizer or a cloud provider. */ +export type VoiceEngine = 'macos-native' | 'cloud'; + +/** Voice (hold-space dictation) configuration persisted in global settings. */ +export interface VoiceSettings { + /** Whether hold-space voice input is enabled. */ + enabled: boolean; + /** Which STT engine to use. Defaults to macOS native on darwin, else cloud. */ + engine: VoiceEngine; + /** Cloud provider id (matches an STT registry entry). Cloud engine only. */ + provider: string; + /** Cloud model id; defaults to the provider's first registry model. */ + model?: string; +} + /** Stagehand environment type. */ export type StagehandEnv = 'LOCAL' | 'BROWSERBASE'; @@ -224,6 +240,8 @@ export interface GlobalSettings { browser: BrowserSettings; // Direct TUI `!` shell passthrough configuration shellPassthrough: ShellPassthroughSettings; + // Hold-space voice input configuration + voice: VoiceSettings; // Signal routing configuration signals: SignalSettings; // Cloud observability configuration (per-resource project IDs; tokens stored in auth.json) @@ -260,6 +278,11 @@ export const STORAGE_DEFAULTS: StorageSettings = { pg: {}, }; +/** Default STT engine: on-device macOS recognizer where available, else cloud. */ +export function defaultVoiceEngine(): VoiceEngine { + return process.platform === 'darwin' ? 'macos-native' : 'cloud'; +} + const DEFAULTS: GlobalSettings = { onboarding: { completedAt: null, @@ -306,6 +329,7 @@ const DEFAULTS: GlobalSettings = { stagehand: { env: 'LOCAL' }, }, shellPassthrough: { mode: 'default' }, + voice: { enabled: false, engine: defaultVoiceEngine(), provider: DEFAULT_STT_PROVIDER }, signals: { unixSocketPubSub: false, experimentalGithubSignals: false }, observability: { resources: {}, localTracing: false }, }; @@ -522,6 +546,24 @@ function parseShellPassthroughSettings(rawShellPassthrough: unknown): ShellPasst }; } +function parseVoiceSettings(rawVoice: unknown): VoiceSettings { + const raw = rawVoice && typeof rawVoice === 'object' ? (rawVoice as Record<string, unknown>) : {}; + const enabled = typeof raw.enabled === 'boolean' ? raw.enabled : DEFAULTS.voice.enabled; + const engine: VoiceEngine = + raw.engine === 'macos-native' || raw.engine === 'cloud' ? raw.engine : defaultVoiceEngine(); + // Validate provider/model against the registry, falling back to a usable entry + // so old `{ enabled }`-only files and unknown values still resolve cleanly. + const provider = typeof raw.provider === 'string' ? raw.provider : DEFAULTS.voice.provider; + const model = typeof raw.model === 'string' ? raw.model : undefined; + const resolved = resolveSTTModel(provider, model); + return { + enabled, + engine, + provider: resolved.provider, + model: resolved.model, + }; +} + const VALID_PROJECT_ID = /^[a-zA-Z0-9_-]+$/; function parseObservabilitySettings(raw: unknown): ObservabilitySettings { @@ -588,6 +630,7 @@ function migrateFromAuth(settingsPath: string): boolean { lsp: raw.lsp && typeof raw.lsp === 'object' ? (raw.lsp as LSPConfig) : undefined, browser: parseBrowserSettings(raw.browser), shellPassthrough: parseShellPassthroughSettings(raw.shellPassthrough), + voice: parseVoiceSettings(raw.voice), signals: parseSignalSettings(raw.signals), observability: parseObservabilitySettings(raw.observability), }; @@ -710,6 +753,7 @@ export function loadSettings(filePath: string = getSettingsPath()): GlobalSettin lsp: raw.lsp && typeof raw.lsp === 'object' ? (raw.lsp as LSPConfig) : undefined, browser: parseBrowserSettings(raw.browser), shellPassthrough: parseShellPassthroughSettings(raw.shellPassthrough), + voice: parseVoiceSettings(raw.voice), signals: parseSignalSettings(raw.signals), observability: parseObservabilitySettings(raw.observability), }; diff --git a/mastracode/src/plugin.test.ts b/mastracode/src/plugin.test.ts new file mode 100644 index 000000000000..8dc5d374511c --- /dev/null +++ b/mastracode/src/plugin.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { writeToolProgress } from './plugin'; + +describe('writeToolProgress', () => { + it('writes transient Mastra Code progress chunks with the current tool call id', async () => { + const writer = { custom: vi.fn().mockResolvedValue(undefined) }; + + await writeToolProgress( + { + writer: writer as any, + agent: { toolCallId: 'call-123' } as any, + }, + { status: 'thinking', detail: 'Agent is answering…' }, + ); + + expect(writer.custom).toHaveBeenCalledWith({ + type: 'data-mastracode-tool-progress', + data: { + toolCallId: 'call-123', + progress: { status: 'thinking', detail: 'Agent is answering…' }, + }, + transient: true, + }); + }); + + it('does nothing when the tool is not running in an agent context', async () => { + const writer = { custom: vi.fn().mockResolvedValue(undefined) }; + + await writeToolProgress({ writer: writer as any }, 'starting'); + + expect(writer.custom).not.toHaveBeenCalled(); + }); +}); diff --git a/mastracode/src/plugin.ts b/mastracode/src/plugin.ts new file mode 100644 index 000000000000..45464294652b --- /dev/null +++ b/mastracode/src/plugin.ts @@ -0,0 +1,123 @@ +import type { Tool, ToolAction, ToolExecutionContext } from '@mastra/core/tools'; + +export { createTool } from '@mastra/core/tools'; +export type { Tool, ToolAction, ToolExecutionContext } from '@mastra/core/tools'; +export { z } from 'zod'; + +export type MastraCodeToolRenderConfig = { + type: 'subagent'; + agentType?: string; + modelId?: string; + forked?: boolean; + label?: string; + maxActivityLines?: number; + collapsedLines?: number; + colors?: { + border?: string; + label?: string; + agentType?: string; + icon?: string; + }; + icons?: { + running?: string; + success?: string; + error?: string; + }; +}; + +export type MastraCodeSubagentProgress = + | { + event: 'text'; + text: string; + } + | { + event: 'tool_start'; + toolName: string; + args?: unknown; + } + | { + event: 'tool_end'; + toolName: string; + result?: unknown; + isError?: boolean; + } + | { + event: 'finish'; + isError?: boolean; + durationMs?: number; + result?: string; + }; + +export type MastraCodeToolProgress = string | { status?: string; detail?: string } | MastraCodeSubagentProgress; + +export async function writeToolProgress( + context: Pick<ToolExecutionContext, 'writer' | 'agent'> | undefined, + progress: MastraCodeToolProgress, +): Promise<void> { + const toolCallId = context?.agent?.toolCallId; + if (!toolCallId) return; + + const chunk = { + type: 'data-mastracode-tool-progress', + data: { + toolCallId, + progress, + }, + transient: true, + } as const; + + const outputWriter = (context.agent as { outputWriter?: (chunk: unknown) => Promise<void> } | undefined) + ?.outputWriter; + if (outputWriter) { + await outputWriter(chunk); + return; + } + + await context.writer?.custom(chunk); +} + +export type MastraCodePluginConfigValue = string | boolean | undefined; + +export type MastraCodePluginConfigOption = { + type: 'model' | 'boolean' | 'string'; + label?: string; + description?: string; + default?: string | boolean; +}; + +export type MastraCodePluginConfigSchema = Record<string, MastraCodePluginConfigOption>; +export type MastraCodePluginConfigValues = Record<string, MastraCodePluginConfigValue>; + +export type MastraCodePluginContext = { + cwd: string; + scope: 'global' | 'project'; + pluginDir: string; + config: MastraCodePluginConfigValues; +}; + +export type MastraCodePluginTool = Tool | ToolAction<any, any, any, any, any, any, any>; + +export type MastraCodePluginToolEntry = { + tool: MastraCodePluginTool; + render?: MastraCodeToolRenderConfig; +}; + +export type MastraCodePluginTools = Record<string, MastraCodePluginTool>; +export type MastraCodePluginToolEntries = Record<string, MastraCodePluginToolEntry>; +export type MastraCodePluginInstructions = string | ((context: MastraCodePluginContext) => string | Promise<string>); + +export type MastraCodePlugin = { + id: string; + name?: string; + version?: string; + description?: string; + config?: MastraCodePluginConfigSchema; + instructions?: MastraCodePluginInstructions; + tools?: + | MastraCodePluginToolEntries + | ((context: MastraCodePluginContext) => MastraCodePluginToolEntries | Promise<MastraCodePluginToolEntries>); +}; + +export function defineMastraCodePlugin<TPlugin extends MastraCodePlugin>(plugin: TPlugin): TPlugin { + return plugin; +} diff --git a/mastracode/src/plugins/__tests__/install.test.ts b/mastracode/src/plugins/__tests__/install.test.ts new file mode 100644 index 000000000000..33fc1824fd3e --- /dev/null +++ b/mastracode/src/plugins/__tests__/install.test.ts @@ -0,0 +1,214 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const execaMock = vi.hoisted(() => vi.fn()); + +vi.mock('execa', () => ({ execa: execaMock })); + +import { detectEntry, discoverLocalPlugins, installGithubPlugin, installLocalPlugin } from '../install.js'; +import { loadPluginRegistry } from '../registry.js'; + +const mastracodePackageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); + +let tempDir: string | undefined; + +afterEach(() => { + vi.clearAllMocks(); + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } +}); + +function writePlugin(pluginDir: string, id: string): void { + fs.mkdirSync(path.join(pluginDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'src/index.ts'), + `import { defineMastraCodePlugin } from 'mastracode/plugin'; + +export default defineMastraCodePlugin({ id: '${id}', version: '1.2.3', tools: { installed_tool: { tool: { id: 'installed_tool' } } } });`, + ); +} + +describe('detectEntry', () => { + it('detects TypeScript entry candidates', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-install-')); + tempDir = dir; + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'src/index.ts'), 'export default {}'); + + expect(detectEntry(dir)).toBe('src/index.ts'); + }); + + it('rejects non-TypeScript explicit entries', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-install-')); + tempDir = dir; + fs.writeFileSync(path.join(dir, 'index.js'), 'export default {}'); + + expect(() => detectEntry(dir, 'index.js')).toThrow('Plugin entry must be a .ts file'); + }); + + it('accepts an explicit entry directory and detects its TypeScript entry', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-install-')); + tempDir = dir; + writePlugin(path.join(dir, '.mastracode', 'plugins', 'sources', 'local', 'alexandria'), 'alexandria'); + + expect(detectEntry(dir, '.mastracode/plugins/sources/local/alexandria')).toBe( + '.mastracode/plugins/sources/local/alexandria/src/index.ts', + ); + }); + + it('uses .mastracode-plugin.json when present', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-install-')); + tempDir = dir; + writePlugin(path.join(dir, '.mastracode', 'plugins', 'sources', 'local', 'alexandria'), 'alexandria'); + fs.writeFileSync( + path.join(dir, '.mastracode-plugin.json'), + JSON.stringify({ + plugins: [ + { + id: 'alexandria', + entry: '.mastracode/plugins/sources/local/alexandria/src/index.ts', + }, + ], + }), + ); + + expect(detectEntry(dir)).toBe('.mastracode/plugins/sources/local/alexandria/src/index.ts'); + }); +}); + +describe('discoverLocalPlugins', () => { + it('finds scaffolded plugins under project local sources', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-install-')); + const projectRoot = path.join(tempDir, 'project'); + const firstPluginDir = path.join(projectRoot, '.mastracode', 'plugins', 'sources', 'local', 'first-plugin'); + const secondPluginDir = path.join(projectRoot, '.mastracode', 'plugins', 'sources', 'local', 'second-plugin'); + writePlugin(firstPluginDir, 'acme.first'); + writePlugin(secondPluginDir, 'acme.second'); + fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'src/index.ts'), 'export default {}'); + + expect(discoverLocalPlugins('.', { projectRoot })).toEqual([ + { name: 'first-plugin', path: firstPluginDir, entry: 'src/index.ts' }, + { name: 'second-plugin', path: secondPluginDir, entry: 'src/index.ts' }, + ]); + }); + + it('finds installable plugins under another project path', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-install-')); + const projectRoot = path.join(tempDir, 'project'); + const otherProject = path.join(tempDir, 'other-project'); + const pluginDir = path.join(otherProject, '.mastracode', 'plugins', 'sources', 'local', 'nested-plugin'); + writePlugin(pluginDir, 'acme.nested'); + + expect(discoverLocalPlugins(otherProject, { projectRoot })).toEqual([ + { name: 'nested-plugin', path: pluginDir, entry: 'src/index.ts' }, + ]); + }); +}); + +describe('installLocalPlugin', () => { + it('loads the local plugin and writes the scoped registry record', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-install-')); + const projectRoot = path.join(tempDir, 'project'); + const homeDir = path.join(tempDir, 'home'); + const pluginDir = path.join(tempDir, 'local-plugin'); + writePlugin(pluginDir, 'acme.local'); + + await expect(installLocalPlugin(pluginDir, 'project', { projectRoot, homeDir })).resolves.toBe('acme.local'); + + expect(fs.realpathSync(path.join(pluginDir, 'node_modules', 'mastracode'))).toBe( + fs.realpathSync(mastracodePackageRoot), + ); + expect(loadPluginRegistry(path.join(projectRoot, '.mastracode/plugins/plugins.json'))).toEqual({ + disabledPlugins: [], + plugins: { + 'acme.local': { + enabled: true, + source: 'local', + specifier: pluginDir, + path: pluginDir, + entry: 'src/index.ts', + version: '1.2.3', + }, + }, + }); + }); +}); + +describe('installGithubPlugin', () => { + it('clones with argv and writes a relative checkout path', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-install-')); + const projectRoot = path.join(tempDir, 'project'); + const homeDir = path.join(tempDir, 'home'); + execaMock.mockImplementation(async (_cmd: string, args: string[]) => { + if (args[0] === 'clone') { + const checkoutDir = args[2]; + if (!checkoutDir) throw new Error('missing checkout dir'); + writePlugin(checkoutDir, 'acme.github'); + } + return { stdout: '' }; + }); + + await expect( + installGithubPlugin('https://github.com/acme/mastracode-plugin#main', 'global', { projectRoot, homeDir }), + ).resolves.toBe('acme.github'); + + expect(execaMock).toHaveBeenNthCalledWith(1, 'git', [ + 'clone', + 'https://github.com/acme/mastracode-plugin.git', + path.join(homeDir, '.mastracode/plugins/sources/github/acme-mastracode-plugin'), + ]); + expect(execaMock).toHaveBeenNthCalledWith(2, 'git', ['checkout', 'main'], { + cwd: path.join(homeDir, '.mastracode/plugins/sources/github/acme-mastracode-plugin'), + }); + expect( + loadPluginRegistry(path.join(homeDir, '.mastracode/plugins/plugins.json')).plugins['acme.github'], + ).toMatchObject({ + source: 'github', + path: 'sources/github/acme-mastracode-plugin', + ref: 'main', + }); + }); + + it('uses a repository plugin manifest for nested scaffolded GitHub plugins', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-install-')); + const projectRoot = path.join(tempDir, 'project'); + const homeDir = path.join(tempDir, 'home'); + execaMock.mockImplementation(async (_cmd: string, args: string[]) => { + if (args[0] === 'clone') { + const checkoutDir = args[2]; + if (!checkoutDir) throw new Error('missing checkout dir'); + writePlugin(path.join(checkoutDir, '.mastracode', 'plugins', 'sources', 'local', 'alexandria'), 'alexandria'); + fs.writeFileSync( + path.join(checkoutDir, '.mastracode-plugin.json'), + JSON.stringify({ + plugins: [ + { + id: 'alexandria', + entry: '.mastracode/plugins/sources/local/alexandria/src/index.ts', + }, + ], + }), + ); + } + return { stdout: '' }; + }); + + await expect( + installGithubPlugin('https://github.com/acme/alexandria', 'project', { projectRoot, homeDir }), + ).resolves.toBe('alexandria'); + + expect( + loadPluginRegistry(path.join(projectRoot, '.mastracode/plugins/plugins.json')).plugins.alexandria, + ).toMatchObject({ + source: 'github', + path: 'sources/github/acme-alexandria', + entry: '.mastracode/plugins/sources/local/alexandria/src/index.ts', + }); + }); +}); diff --git a/mastracode/src/plugins/__tests__/loader.test.ts b/mastracode/src/plugins/__tests__/loader.test.ts new file mode 100644 index 000000000000..b4dd1b3dcedd --- /dev/null +++ b/mastracode/src/plugins/__tests__/loader.test.ts @@ -0,0 +1,234 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { collectActivePluginTools, loadPluginFromEntry, loadPlugins } from '../loader.js'; +import type { PluginRegistry } from '../types.js'; + +let tempDir: string | undefined; + +afterEach(() => { + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } +}); + +function writePlugin(filePath: string, source: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, source); +} + +describe('plugin loader', () => { + it('loads default exported TypeScript plugins and resolves tools functions', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-loader-')); + const entryPath = path.join(tempDir, 'plugin.ts'); + writePlugin( + entryPath, + `export default { + id: 'acme.loader', + name: 'Loader Plugin', + version: '1.0.0', + tools: context => ({ echo_tool: { tool: { id: 'echo_tool', description: context.cwd } } }) + };`, + ); + + await expect(loadPluginFromEntry(entryPath)).resolves.toMatchObject({ id: 'acme.loader', name: 'Loader Plugin' }); + }); + + it('loads enabled registry records and marks disabled records inactive', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-loader-')); + const projectRoot = path.join(tempDir, 'project'); + const pluginDir = path.join(projectRoot, '.mastracode', 'plugins', 'plugin'); + writePlugin( + path.join(pluginDir, 'src/index.ts'), + `export const plugin = { + id: 'acme.enabled', + tools: { enabled_tool: { tool: { id: 'enabled_tool', description: 'enabled' } } } + };`, + ); + + const projectRegistry: PluginRegistry = { + plugins: { + 'acme.enabled': { + enabled: true, + source: 'local', + specifier: '../plugin', + path: pluginDir, + entry: 'src/index.ts', + }, + 'acme.disabled': { + enabled: false, + source: 'local', + specifier: '../disabled', + path: path.join(projectRoot, '.mastracode', 'plugins', 'disabled'), + entry: 'src/index.ts', + }, + }, + }; + + const loaded = await loadPlugins({ + projectRoot, + homeDir: path.join(tempDir, 'home'), + projectRegistry, + globalRegistry: { plugins: {} }, + }); + + expect(loaded.map(plugin => [plugin.id, plugin.status])).toEqual([ + ['acme.disabled', 'inactive'], + ['acme.enabled', 'active'], + ]); + expect(loaded.find(plugin => plugin.id === 'acme.enabled')?.toolNames).toEqual(['enabled_tool']); + }); + + it('passes configured plugin option values into tools functions', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-loader-')); + const projectRoot = path.join(tempDir, 'project'); + const pluginDir = path.join(projectRoot, '.mastracode', 'plugins', 'plugin'); + writePlugin( + path.join(pluginDir, 'src/index.ts'), + `export default { + id: 'acme.config', + config: { + answerModel: { type: 'model', default: 'default-model' }, + enabled: { type: 'boolean', default: true }, + prompt: { type: 'string', default: 'default prompt' } + }, + tools: context => ({ configured_tool: { tool: { id: 'configured_tool', description: JSON.stringify(context.config) } } }) + };`, + ); + + const loaded = await loadPlugins({ + projectRoot, + homeDir: path.join(tempDir, 'home'), + projectRegistry: { + plugins: { + 'acme.config': { + enabled: true, + source: 'local', + specifier: '../plugin', + path: pluginDir, + entry: 'src/index.ts', + config: { answerModel: 'chosen-model', enabled: false }, + }, + }, + }, + globalRegistry: { plugins: {} }, + }); + + expect(loaded[0]).toMatchObject({ + id: 'acme.config', + status: 'active', + configValues: { answerModel: 'chosen-model', enabled: false, prompt: 'default prompt' }, + }); + expect(loaded[0]?.tools.configured_tool?.description).toContain('chosen-model'); + }); + + it('normalizes first-class tool render entries and discovers bundled assets and instructions', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-loader-')); + const projectRoot = path.join(tempDir, 'project'); + const pluginDir = path.join(projectRoot, '.mastracode', 'plugins', 'plugin'); + fs.mkdirSync(path.join(pluginDir, 'skills', 'helper'), { recursive: true }); + fs.writeFileSync(path.join(pluginDir, 'skills', 'helper', 'SKILL.md'), '# Helper'); + fs.mkdirSync(path.join(pluginDir, 'commands'), { recursive: true }); + fs.writeFileSync(path.join(pluginDir, 'commands', 'ask.md'), 'Ask template'); + writePlugin( + path.join(pluginDir, 'src/index.ts'), + `export default { + id: 'acme.assets', + instructions: context => ` + + '`Plugin instruction for ${context.cwd}`' + + `, + tools: { + rendered_tool: { + tool: { id: 'rendered_tool', description: 'rendered' }, + render: { type: 'subagent', agentType: 'assets' } + } + } + };`, + ); + + const loaded = await loadPlugins({ + projectRoot, + homeDir: path.join(tempDir, 'home'), + projectRegistry: { + plugins: { + 'acme.assets': { + enabled: true, + source: 'local', + specifier: '../plugin', + path: pluginDir, + entry: 'src/index.ts', + }, + }, + }, + globalRegistry: { plugins: {} }, + }); + + expect(loaded[0]?.renderConfigs?.rendered_tool).toEqual({ type: 'subagent', agentType: 'assets' }); + expect(loaded[0]?.instructions).toBe(`Plugin instruction for ${projectRoot}`); + expect(loaded[0]?.skillPaths).toEqual([path.join(pluginDir, 'skills')]); + expect(loaded[0]?.commandPaths).toEqual([path.join(pluginDir, 'commands')]); + }); + + it('surfaces load failures without throwing', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-loader-')); + const projectRegistry: PluginRegistry = { + plugins: { + broken: { + enabled: true, + source: 'local', + specifier: '../broken', + path: path.join(tempDir, 'project', '.mastracode', 'plugins', 'broken'), + entry: 'index.ts', + }, + }, + }; + + const loaded = await loadPlugins({ + projectRoot: path.join(tempDir, 'project'), + homeDir: path.join(tempDir, 'home'), + projectRegistry, + globalRegistry: { plugins: {} }, + }); + + expect(loaded[0]).toMatchObject({ id: 'broken', status: 'load failed' }); + expect(loaded[0]?.error).toBeTruthy(); + }); + + it('marks later duplicate tool names conflicted', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-loader-')); + const projectRoot = path.join(tempDir, 'project'); + const firstDir = path.join(tempDir, 'first'); + const secondDir = path.join(tempDir, 'second'); + writePlugin( + path.join(firstDir, 'index.ts'), + `export default { id: 'a.first', tools: { same: { tool: { id: 'same' } } } };`, + ); + writePlugin( + path.join(secondDir, 'index.ts'), + `export default { id: 'b.second', tools: { same: { tool: { id: 'same' } } } };`, + ); + + const loaded = await loadPlugins({ + projectRoot, + homeDir: path.join(tempDir, 'home'), + projectRegistry: { + plugins: { + 'a.first': { enabled: true, source: 'local', specifier: 'first', path: firstDir, entry: 'index.ts' }, + 'b.second': { enabled: true, source: 'local', specifier: 'second', path: secondDir, entry: 'index.ts' }, + }, + }, + globalRegistry: { plugins: {} }, + }); + + expect(loaded.map(plugin => [plugin.id, plugin.status])).toEqual([ + ['a.first', 'active'], + ['b.second', 'conflicted'], + ]); + expect(loaded[1]?.conflicts).toEqual(['same']); + expect(Object.keys(collectActivePluginTools(loaded))).toEqual(['same']); + expect(collectActivePluginTools(loaded).same).toBe(loaded[0]?.tools.same); + }); +}); diff --git a/mastracode/src/plugins/__tests__/manager.test.ts b/mastracode/src/plugins/__tests__/manager.test.ts new file mode 100644 index 000000000000..264ed0b01066 --- /dev/null +++ b/mastracode/src/plugins/__tests__/manager.test.ts @@ -0,0 +1,338 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const execaMock = vi.hoisted(() => vi.fn()); + +vi.mock('execa', () => ({ execa: execaMock })); + +import { PluginManager } from '../manager.js'; +import { loadPluginRegistry } from '../registry.js'; + +let tempDir: string | undefined; + +afterEach(() => { + vi.clearAllMocks(); + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } +}); + +function writePlugin(pluginDir: string, id: string, toolName: string, description = 'tool'): void { + fs.mkdirSync(path.join(pluginDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'src/index.ts'), + `export default { id: '${id}', name: '${id}', tools: { ${toolName}: { tool: { id: '${toolName}', description: '${description}' } } } };`, + ); +} + +async function waitUntil(assertion: () => boolean, timeoutMs = 3000): Promise<void> { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (assertion()) return; + await new Promise(resolve => setTimeout(resolve, 50)); + } + expect(assertion()).toBe(true); +} + +describe('PluginManager', () => { + it('installs, lists, disables, enables, and uninstalls local plugins', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-manager-')); + const projectRoot = path.join(tempDir, 'project'); + const homeDir = path.join(tempDir, 'home'); + const pluginDir = path.join(tempDir, 'plugin'); + writePlugin(pluginDir, 'acme.manager', 'manager_tool'); + const manager = new PluginManager({ projectRoot, homeDir }); + const pluginTools = manager.getPluginTools(); + + await expect(manager.installLocal(pluginDir, 'project')).resolves.toBe('acme.manager'); + expect(await manager.listPlugins()).toMatchObject([ + { id: 'acme.manager', scope: 'project', status: 'active', toolNames: ['manager_tool'] }, + ]); + expect(manager.getPluginTools()).toBe(pluginTools); + expect(Object.keys(pluginTools)).toEqual(['manager_tool']); + + await manager.setEnabled('acme.manager', 'project', false); + expect(manager.getPluginTools()).toBe(pluginTools); + expect(Object.keys(pluginTools)).toEqual([]); + expect((await manager.listPlugins())[0]?.status).toBe('inactive'); + expect( + loadPluginRegistry(path.join(projectRoot, '.mastracode/plugins/plugins.json')).plugins['acme.manager']?.enabled, + ).toBe(false); + + await manager.setEnabled('acme.manager', 'project', true); + expect(manager.getPluginTools()).toBe(pluginTools); + expect(Object.keys(pluginTools)).toEqual(['manager_tool']); + expect((await manager.listPlugins())[0]?.status).toBe('active'); + + await manager.uninstall('acme.manager', 'project'); + expect(await manager.listPlugins()).toEqual([]); + expect(fs.existsSync(pluginDir)).toBe(true); + }); + + it('persists plugin config values and reloads plugin context', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-manager-')); + const projectRoot = path.join(tempDir, 'project'); + const homeDir = path.join(tempDir, 'home'); + const pluginDir = path.join(tempDir, 'plugin'); + fs.mkdirSync(path.join(pluginDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'src/index.ts'), + `export default { + id: 'acme.config', + config: { answerModel: { type: 'model', default: 'default-model' } }, + tools: context => ({ config_tool: { tool: { id: 'config_tool', description: context.config.answerModel } } }) + };`, + ); + const manager = new PluginManager({ projectRoot, homeDir }); + + await manager.installLocal(pluginDir, 'project'); + expect(manager.getPluginTools().config_tool?.description).toBe('default-model'); + + await manager.setConfigValue('acme.config', 'project', 'answerModel', 'chosen-model'); + + expect(manager.getPluginTools().config_tool?.description).toBe('chosen-model'); + expect( + loadPluginRegistry(path.join(projectRoot, '.mastracode/plugins/plugins.json')).plugins['acme.config']?.config, + ).toEqual({ answerModel: 'chosen-model' }); + + await manager.setConfigValue('acme.config', 'project', 'answerModel', ''); + + expect(manager.getPluginTools().config_tool?.description).toBe('default-model'); + expect( + loadPluginRegistry(path.join(projectRoot, '.mastracode/plugins/plugins.json')).plugins['acme.config']?.config, + ).toBeUndefined(); + }); + + it('hot reloads local plugin source changes into the stable tools object', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-manager-')); + const projectRoot = path.join(tempDir, 'project'); + const homeDir = path.join(tempDir, 'home'); + const pluginDir = path.join(tempDir, 'plugin'); + writePlugin(pluginDir, 'acme.hot', 'hot_tool', 'first'); + const manager = new PluginManager({ projectRoot, homeDir }); + const pluginTools = manager.getPluginTools(); + + await manager.installLocal(pluginDir, 'project'); + expect(pluginTools.hot_tool?.description).toBe('first'); + + await new Promise(resolve => setTimeout(resolve, 20)); + writePlugin(pluginDir, 'acme.hot', 'hot_tool', 'second'); + + await waitUntil(() => pluginTools.hot_tool?.description === 'second'); + expect(manager.getPluginTools()).toBe(pluginTools); + }); + + it('does not expose tools for plugins blocked by project config', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-manager-')); + const projectRoot = path.join(tempDir, 'project'); + const homeDir = path.join(tempDir, 'home'); + const pluginDir = path.join(tempDir, 'plugin'); + writePlugin(pluginDir, 'alexandria', 'mastra_expert'); + const manager = new PluginManager({ projectRoot, homeDir }); + + await manager.installLocal(pluginDir, 'global'); + fs.mkdirSync(path.join(projectRoot, '.mastracode/plugins'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, '.mastracode/plugins/plugins.json'), + JSON.stringify({ plugins: {}, disabledPlugins: ['alexandria'] }), + ); + + await manager.reload(); + + expect(await manager.listPlugins()).toMatchObject([{ id: 'alexandria', scope: 'global', status: 'blocked' }]); + expect(Object.keys(manager.getPluginTools())).toEqual([]); + }); + + it('polls GitHub plugin checkouts and reloads changed tools', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-manager-')); + const projectRoot = path.join(tempDir, 'project'); + const homeDir = path.join(tempDir, 'home'); + const checkoutDir = path.join(projectRoot, '.mastracode/plugins/sources/github/acme-plugin'); + writePlugin(checkoutDir, 'acme.github', 'github_tool', 'first'); + fs.mkdirSync(path.join(checkoutDir, '.git'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, '.mastracode/plugins'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, '.mastracode/plugins/plugins.json'), + JSON.stringify({ + plugins: { + 'acme.github': { + enabled: true, + source: 'github', + specifier: 'https://github.com/acme/plugin', + path: 'sources/github/acme-plugin', + entry: 'src/index.ts', + }, + }, + }), + ); + execaMock.mockImplementation(async (_cmd: string, args: string[], options: { cwd?: string } = {}) => { + expect(options.cwd).toBe(checkoutDir); + if (args[0] === 'rev-parse' && args[1] === 'HEAD') { + return { + stdout: + execaMock.mock.calls.filter(call => call[1][0] === 'rev-parse' && call[1][1] === 'HEAD').length === 1 + ? 'old' + : 'new', + }; + } + if (args[0] === 'rev-parse') return { stdout: 'origin/main' }; + if (args[0] === 'rev-list') return { stdout: '0\t1' }; + if (args[0] === 'status') return { stdout: '' }; + if (args[0] === 'reset') { + writePlugin(checkoutDir, 'acme.github', 'github_tool', 'second'); + } + return { stdout: '' }; + }); + + const manager = new PluginManager({ projectRoot, homeDir }); + const pluginTools = manager.getPluginTools(); + await manager.reload(); + expect(pluginTools.github_tool?.description).toBe('first'); + + await expect(manager.pollGithubSourcesForUpdates()).resolves.toBe(true); + + expect(pluginTools.github_tool?.description).toBe('second'); + expect(execaMock).toHaveBeenCalledWith('git', ['fetch', 'origin'], { cwd: checkoutDir }); + expect(execaMock).toHaveBeenCalledWith('git', ['reset', '--hard', 'origin/main'], { cwd: checkoutDir }); + }); + + it('backs up divergent GitHub plugin checkouts before forcing them to origin', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-manager-')); + const projectRoot = path.join(tempDir, 'project'); + const homeDir = path.join(tempDir, 'home'); + const checkoutDir = path.join(projectRoot, '.mastracode/plugins/sources/github/acme-plugin'); + writePlugin(checkoutDir, 'acme.github', 'github_tool', 'first'); + fs.mkdirSync(path.join(checkoutDir, '.git'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, '.mastracode/plugins'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, '.mastracode/plugins/plugins.json'), + JSON.stringify({ + plugins: { + 'acme.github': { + enabled: true, + source: 'github', + specifier: 'https://github.com/acme/plugin', + path: 'sources/github/acme-plugin', + entry: 'src/index.ts', + }, + }, + }), + ); + execaMock.mockImplementation(async (_cmd: string, args: string[], options: { cwd?: string } = {}) => { + expect(options.cwd).toBe(checkoutDir); + if (args[0] === 'rev-parse' && args[1] === 'HEAD') { + return { stdout: 'abc1234567890' }; + } + if (args[0] === 'rev-parse') return { stdout: 'origin/main' }; + if (args[0] === 'rev-list') return { stdout: '1\t1' }; + if (args[0] === 'status') return { stdout: '' }; + if (args[0] === 'reset') { + writePlugin(checkoutDir, 'acme.github', 'github_tool', 'second'); + return { stdout: '' }; + } + return { stdout: '' }; + }); + + const manager = new PluginManager({ projectRoot, homeDir }); + await manager.reload(); + + await expect(manager.pollGithubSourcesForUpdates()).resolves.toBe(true); + + const branchCall = execaMock.mock.calls.find(call => call[1][0] === 'branch'); + expect(branchCall?.[1][1]).toMatch(/^mastracode\/plugin-backup\/.*-abc12345$/); + expect(branchCall?.[1][2]).toBe('HEAD'); + expect(execaMock).toHaveBeenCalledWith('git', ['reset', '--hard', 'origin/main'], { cwd: checkoutDir }); + expect(manager.getPluginTools().github_tool?.description).toBe('second'); + }); + + it('commits dirty GitHub plugin checkout changes on the backup branch before reset', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-manager-')); + const projectRoot = path.join(tempDir, 'project'); + const homeDir = path.join(tempDir, 'home'); + const checkoutDir = path.join(projectRoot, '.mastracode/plugins/sources/github/acme-plugin'); + writePlugin(checkoutDir, 'acme.github', 'github_tool', 'first'); + fs.mkdirSync(path.join(checkoutDir, '.git'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, '.mastracode/plugins'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, '.mastracode/plugins/plugins.json'), + JSON.stringify({ + plugins: { + 'acme.github': { + enabled: true, + source: 'github', + specifier: 'https://github.com/acme/plugin', + path: 'sources/github/acme-plugin', + entry: 'src/index.ts', + }, + }, + }), + ); + execaMock.mockImplementation(async (_cmd: string, args: string[], options: { cwd?: string } = {}) => { + expect(options.cwd).toBe(checkoutDir); + if (args[0] === 'rev-parse' && args[1] === 'HEAD') return { stdout: 'abc1234567890' }; + if (args[0] === 'rev-parse') return { stdout: 'origin/main' }; + if (args[0] === 'rev-list') return { stdout: '0\t1' }; + if (args[0] === 'status') return { stdout: ' M src/index.ts' }; + if (args[0] === 'branch') return { stdout: 'main' }; + if (args[0] === 'diff') throw new Error('staged changes'); + return { stdout: '' }; + }); + + const manager = new PluginManager({ projectRoot, homeDir }); + await manager.reload(); + + await expect(manager.pollGithubSourcesForUpdates()).resolves.toBe(true); + + expect(execaMock.mock.calls.map(call => call[1][0])).toEqual([ + 'rev-parse', + 'fetch', + 'rev-parse', + 'rev-list', + 'status', + 'branch', + 'switch', + 'add', + 'diff', + '-c', + 'switch', + 'reset', + 'rev-parse', + ]); + expect(execaMock.mock.calls.find(call => call[1][0] === 'switch')?.[1][2]).toMatch( + /^mastracode\/plugin-backup\/.*-abc12345$/, + ); + expect(execaMock).toHaveBeenCalledWith('git', ['switch', 'main'], { cwd: checkoutDir }); + expect(execaMock).toHaveBeenCalledWith('git', ['reset', '--hard', 'origin/main'], { cwd: checkoutDir }); + }); + + it('removes GitHub checkout directories when uninstalling GitHub plugins', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-manager-')); + const projectRoot = path.join(tempDir, 'project'); + const homeDir = path.join(tempDir, 'home'); + const checkoutDir = path.join(projectRoot, '.mastracode/plugins/sources/github/acme-plugin'); + writePlugin(checkoutDir, 'acme.github', 'github_tool'); + fs.mkdirSync(path.join(projectRoot, '.mastracode/plugins'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, '.mastracode/plugins/plugins.json'), + JSON.stringify({ + plugins: { + 'acme.github': { + enabled: true, + source: 'github', + specifier: 'https://github.com/acme/plugin', + path: 'sources/github/acme-plugin', + entry: 'src/index.ts', + }, + }, + }), + ); + + const manager = new PluginManager({ projectRoot, homeDir }); + await manager.uninstall('acme.github', 'project'); + + expect(fs.existsSync(checkoutDir)).toBe(false); + }); +}); diff --git a/mastracode/src/plugins/__tests__/registry.test.ts b/mastracode/src/plugins/__tests__/registry.test.ts new file mode 100644 index 000000000000..edb294cc2919 --- /dev/null +++ b/mastracode/src/plugins/__tests__/registry.test.ts @@ -0,0 +1,165 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { getPluginRegistryPath, getPluginRoot, getPluginScopePaths } from '../paths.js'; +import { + loadPluginRegistry, + mergePluginRegistries, + removePluginRecord, + savePluginRegistry, + setPluginRecord, +} from '../registry.js'; + +let tempDir: string | undefined; + +afterEach(() => { + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } +}); + +describe('plugin paths', () => { + it('computes project and global plugin roots using the configured configDir', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugins-')); + const projectRoot = path.join(tempDir, 'project'); + const homeDir = path.join(tempDir, 'home'); + + expect(getPluginRoot('project', { projectRoot, homeDir, configDir: '.acme-code' })).toBe( + path.join(projectRoot, '.acme-code', 'plugins'), + ); + expect(getPluginRegistryPath('global', { projectRoot, homeDir, configDir: '.acme-code' })).toBe( + path.join(homeDir, '.acme-code', 'plugins', 'plugins.json'), + ); + expect(getPluginScopePaths('project', { projectRoot, homeDir }).sourcesPath).toBe( + path.join(projectRoot, '.mastracode', 'plugins', 'sources'), + ); + }); +}); + +describe('plugin registry', () => { + it('loads, validates, and saves plugin registry files', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugins-')); + const registryPath = path.join(tempDir, 'plugins.json'); + + fs.writeFileSync( + registryPath, + JSON.stringify({ + disabledPlugins: ['valid', 12, 'valid'], + plugins: { + valid: { + enabled: true, + source: 'local', + specifier: '../plugin', + path: '../plugin', + entry: 'src/index.ts', + ref: 12, + }, + invalid: { enabled: true, source: 'npm' }, + }, + }), + ); + + const loaded = loadPluginRegistry(registryPath); + + expect(loaded).toEqual({ + disabledPlugins: ['valid'], + plugins: { + valid: { + enabled: true, + source: 'local', + specifier: '../plugin', + path: '../plugin', + entry: 'src/index.ts', + }, + }, + }); + + savePluginRegistry( + registryPath, + setPluginRecord(loaded, 'github.plugin', { + enabled: false, + source: 'github', + specifier: 'https://github.com/acme/plugin', + path: 'sources/github/acme-plugin', + entry: 'src/index.ts', + ref: 'main', + }), + ); + + expect(loadPluginRegistry(registryPath).plugins['github.plugin']?.ref).toBe('main'); + }); + + it('returns empty registries when files are missing or invalid', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugins-')); + const missingPath = path.join(tempDir, 'missing.json'); + const invalidPath = path.join(tempDir, 'invalid.json'); + fs.writeFileSync(invalidPath, '{invalid'); + + expect(loadPluginRegistry(missingPath)).toEqual({ plugins: {}, disabledPlugins: [] }); + expect(loadPluginRegistry(invalidPath)).toEqual({ plugins: {}, disabledPlugins: [] }); + }); + + it('merges global and project registries with project plugins taking precedence', () => { + const globalRegistry = setPluginRecord({ plugins: {} }, 'acme.plugin', { + enabled: true, + source: 'github', + specifier: 'https://github.com/acme/global', + path: 'sources/github/global', + entry: 'src/index.ts', + }); + const projectRegistry = setPluginRecord({ plugins: {} }, 'acme.plugin', { + enabled: false, + source: 'local', + specifier: '../project', + path: '../project', + entry: 'index.ts', + }); + + expect(mergePluginRegistries(globalRegistry, projectRegistry)).toEqual([ + { + id: 'acme.plugin', + scope: 'project', + enabled: false, + source: 'local', + specifier: '../project', + path: '../project', + entry: 'index.ts', + }, + ]); + }); + + it('marks merged plugins blocked when their id is listed in disabledPlugins', () => { + const globalRegistry = setPluginRecord({ plugins: {} }, 'alexandria', { + enabled: true, + source: 'github', + specifier: 'https://github.com/acme/alexandria', + path: 'sources/github/alexandria', + entry: 'src/index.ts', + }); + const projectRegistry = { plugins: {}, disabledPlugins: ['alexandria'] }; + + expect(mergePluginRegistries(globalRegistry, projectRegistry)).toMatchObject([ + { + id: 'alexandria', + scope: 'global', + blocked: true, + }, + ]); + }); + + it('removes plugin records immutably', () => { + const registry = setPluginRecord({ plugins: {} }, 'acme.plugin', { + enabled: true, + source: 'local', + specifier: '../plugin', + path: '../plugin', + entry: 'index.ts', + }); + + expect(removePluginRecord(registry, 'acme.plugin')).toEqual({ plugins: {}, disabledPlugins: [] }); + expect(registry.plugins).toHaveProperty('acme.plugin'); + }); +}); diff --git a/mastracode/src/plugins/__tests__/scaffold.test.ts b/mastracode/src/plugins/__tests__/scaffold.test.ts new file mode 100644 index 000000000000..1dc8acd71bcb --- /dev/null +++ b/mastracode/src/plugins/__tests__/scaffold.test.ts @@ -0,0 +1,89 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { formatScaffoldSuccess, resolveScaffoldTarget, scaffoldPlugin } from '../scaffold.js'; + +let tempDir: string | undefined; + +afterEach(() => { + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } +}); + +describe('scaffoldPlugin', () => { + it('resolves bare plugin names under project plugin local sources', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-scaffold-')); + + expect(resolveScaffoldTarget('my-plugin', { projectRoot: tempDir })).toBe( + path.join(tempDir, '.mastracode', 'plugins', 'sources', 'local', 'my-plugin'), + ); + expect(resolveScaffoldTarget('./my-plugin', { projectRoot: tempDir })).toBe(path.join(tempDir, 'my-plugin')); + expect(resolveScaffoldTarget('plugins/my-plugin', { projectRoot: tempDir })).toBe( + path.join(tempDir, 'plugins', 'my-plugin'), + ); + }); + + it('creates a TypeScript-only ESM plugin scaffold', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-scaffold-')); + const target = path.join(tempDir, 'my-plugin'); + + const createdDir = scaffoldPlugin(target, { id: 'acme.foo', name: 'Foo Tools' }); + + expect(createdDir).toBe(target); + + expect(JSON.parse(fs.readFileSync(path.join(target, 'package.json'), 'utf-8'))).toMatchObject({ + name: 'my-plugin', + type: 'module', + exports: './src/index.ts', + peerDependencies: { mastracode: '*' }, + }); + expect(JSON.parse(fs.readFileSync(path.join(target, 'tsconfig.json'), 'utf-8')).compilerOptions).toMatchObject({ + verbatimModuleSyntax: true, + erasableSyntaxOnly: true, + }); + const indexSource = fs.readFileSync(path.join(target, 'src/index.ts'), 'utf-8'); + expect(indexSource).toContain("import { createTool, defineMastraCodePlugin, z } from 'mastracode/plugin';"); + expect(indexSource).toContain('execute: async context =>'); + expect(indexSource).toContain('id: "acme.foo"'); + expect(JSON.parse(fs.readFileSync(path.join(target, '.mastracode-plugin.json'), 'utf-8'))).toEqual({ + plugins: [{ id: 'acme.foo', name: 'Foo Tools', entry: 'src/index.ts' }], + }); + expect(fs.existsSync(path.join(target, 'node_modules', 'mastracode'))).toBe(true); + }); + + it('scaffolds bare plugin names into project plugin local sources', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-scaffold-')); + + const createdDir = scaffoldPlugin('my-plugin', { projectRoot: tempDir }); + + expect(createdDir).toBe(path.join(tempDir, '.mastracode', 'plugins', 'sources', 'local', 'my-plugin')); + expect(JSON.parse(fs.readFileSync(path.join(createdDir, 'package.json'), 'utf-8'))).toMatchObject({ + name: 'my-plugin', + }); + expect(JSON.parse(fs.readFileSync(path.join(tempDir, '.mastracode-plugin.json'), 'utf-8'))).toEqual({ + plugins: [ + { + id: 'my-plugin', + name: 'My Plugin', + entry: '.mastracode/plugins/sources/local/my-plugin/src/index.ts', + }, + ], + }); + }); + + it('refuses to overwrite non-empty directories', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-plugin-scaffold-')); + tempDir = dir; + fs.writeFileSync(path.join(dir, 'existing.txt'), 'content'); + + expect(() => scaffoldPlugin(dir)).toThrow('Directory already exists and is not empty'); + }); + + it('prints next steps', () => { + expect(formatScaffoldSuccess('/tmp/plugin')).toContain('/plugins'); + }); +}); diff --git a/mastracode/src/plugins/install.ts b/mastracode/src/plugins/install.ts new file mode 100644 index 000000000000..0d1475dbe7ce --- /dev/null +++ b/mastracode/src/plugins/install.ts @@ -0,0 +1,203 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { execa } from 'execa'; + +import { DEFAULT_CONFIG_DIR } from '../constants.js'; +import { loadPluginFromEntry } from './loader.js'; +import { getSingleManifestPlugin } from './manifest.js'; +import { ensureMastraCodePackageLink } from './package-link.js'; +import { getPluginRoot, getPluginScopePaths } from './paths.js'; +import type { PluginPathOptions } from './paths.js'; +import { loadPluginRegistry, removePluginRecord, savePluginRegistry, setPluginRecord } from './registry.js'; +import type { InstalledPluginRecord, PluginScope } from './types.js'; + +export type InstallPluginOptions = PluginPathOptions & { + entry?: string; + ref?: string; +}; + +export type DiscoveredLocalPlugin = { + name: string; + path: string; + entry: string; +}; + +const ENTRY_CANDIDATES = ['src/index.ts', 'index.ts']; + +export async function installLocalPlugin( + localPath: string, + scope: PluginScope, + options: InstallPluginOptions, +): Promise<string> { + const sourcePath = path.resolve(options.projectRoot, localPath); + if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isDirectory()) { + throw new Error(`Local plugin path does not exist or is not a directory: ${localPath}`); + } + + const entry = detectEntry(sourcePath, options.entry); + ensureMastraCodePackageLink(sourcePath); + const plugin = await loadPluginFromEntry(path.join(sourcePath, entry)); + const registryPath = getPluginScopePaths(scope, options).registryPath; + const registry = removePluginRecord(loadPluginRegistry(registryPath), plugin.id); + const record: InstalledPluginRecord = { + enabled: true, + source: 'local', + specifier: localPath, + path: sourcePath, + entry, + ...(plugin.version ? { version: plugin.version } : {}), + }; + + savePluginRegistry(registryPath, setPluginRecord(registry, plugin.id, record)); + return plugin.id; +} + +export async function installGithubPlugin( + url: string, + scope: PluginScope, + options: InstallPluginOptions, +): Promise<string> { + const parsed = parseGithubUrl(url); + const paths = getPluginScopePaths(scope, options); + const checkoutDir = path.join(paths.sourcesPath, 'github', `${parsed.owner}-${parsed.repo}`); + + fs.rmSync(checkoutDir, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(checkoutDir), { recursive: true }); + await execa('git', ['clone', parsed.cloneUrl, checkoutDir]); + const ref = options.ref ?? parsed.ref; + if (ref) { + await execa('git', ['checkout', ref], { cwd: checkoutDir }); + } + + const entry = detectEntry(checkoutDir, options.entry); + ensureMastraCodePackageLink(checkoutDir); + const plugin = await loadPluginFromEntry(path.join(checkoutDir, entry)); + const registry = removePluginRecord(loadPluginRegistry(paths.registryPath), plugin.id); + const relativePath = path.relative(getPluginRoot(scope, options), checkoutDir); + const record: InstalledPluginRecord = { + enabled: true, + source: 'github', + specifier: url, + path: relativePath, + entry, + ...(ref ? { ref } : {}), + ...(plugin.version ? { version: plugin.version } : {}), + }; + + savePluginRegistry(paths.registryPath, setPluginRecord(registry, plugin.id, record)); + return plugin.id; +} + +export function discoverLocalPlugins(searchRoot: string, options: PluginPathOptions): DiscoveredLocalPlugin[] { + const root = path.resolve(options.projectRoot, searchRoot); + const localSourcesRoot = path.join(root, options.configDir ?? DEFAULT_CONFIG_DIR, 'plugins', 'sources', 'local'); + const scanRoot = isLocalSourcesDir(root) ? root : localSourcesRoot; + + const seen = new Set<string>(); + return discoverPluginDirs(scanRoot) + .filter(candidate => { + if (seen.has(candidate.path)) return false; + seen.add(candidate.path); + return true; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +function isLocalSourcesDir(dir: string): boolean { + const normalized = dir.split(path.sep).join('/'); + return normalized.endsWith('/plugins/sources/local'); +} + +function discoverPluginDirs(root: string): DiscoveredLocalPlugin[] { + if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) return []; + return fs + .readdirSync(root, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .flatMap(entry => { + const pluginDir = path.join(root, entry.name); + const detectedEntry = tryDetectEntry(pluginDir); + return detectedEntry ? [{ name: entry.name, path: pluginDir, entry: detectedEntry }] : []; + }); +} + +function tryDetectEntry(pluginDir: string): string | undefined { + try { + return detectEntry(pluginDir); + } catch { + return undefined; + } +} + +export function detectEntry(pluginDir: string, explicitEntry?: string): string { + const root = path.resolve(pluginDir); + if (explicitEntry) { + const entryPath = path.resolve(pluginDir, explicitEntry); + if (!isInsideDirectory(entryPath, root)) { + throw new Error('Plugin entry must be inside the plugin directory'); + } + if (fs.existsSync(entryPath) && fs.statSync(entryPath).isDirectory()) { + const nestedEntry = detectEntry(entryPath); + return path.relative(root, path.join(entryPath, nestedEntry)); + } + if (path.extname(entryPath) !== '.ts') { + throw new Error('Plugin entry must be a .ts file'); + } + if (!fs.existsSync(entryPath) || !fs.statSync(entryPath).isFile()) { + throw new Error(`Plugin entry file does not exist: ${explicitEntry}`); + } + return path.relative(root, entryPath); + } + + const manifestPlugin = getSingleManifestPlugin(pluginDir); + if (manifestPlugin) { + return detectEntry(pluginDir, manifestPlugin.entry); + } + + for (const candidate of ENTRY_CANDIDATES) { + const entryPath = path.join(pluginDir, candidate); + if (fs.existsSync(entryPath) && fs.statSync(entryPath).isFile()) { + return candidate; + } + } + + throw new Error(`Could not find a plugin entry file. Tried: ${ENTRY_CANDIDATES.join(', ')}`); +} + +function isInsideDirectory(targetPath: string, root: string): boolean { + return targetPath === root || targetPath.startsWith(root + path.sep); +} + +function parseGithubUrl(specifier: string): { owner: string; repo: string; cloneUrl: string; ref?: string } { + const [urlPart, ref] = specifier.split('#', 2); + if (!urlPart) { + throw new Error(`Invalid GitHub URL: ${specifier}`); + } + let url: URL; + try { + url = new URL(urlPart); + } catch { + throw new Error(`Invalid GitHub URL: ${specifier}`); + } + + if (url.hostname !== 'github.com') { + throw new Error('Only github.com plugin URLs are supported'); + } + + const [owner, rawRepo, ...rest] = url.pathname.split('/').filter(Boolean); + if (!owner || !rawRepo || rest.length > 0) { + throw new Error('GitHub plugin URL must be in the form https://github.com/owner/repo'); + } + + const repo = rawRepo.replace(/\.git$/, ''); + if (!/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) { + throw new Error('GitHub owner and repo may only contain letters, numbers, dots, underscores, and dashes'); + } + + return { + owner, + repo, + cloneUrl: `https://github.com/${owner}/${repo}.git`, + ...(ref ? { ref } : {}), + }; +} diff --git a/mastracode/src/plugins/loader.ts b/mastracode/src/plugins/loader.ts new file mode 100644 index 000000000000..0d9a1c484d86 --- /dev/null +++ b/mastracode/src/plugins/loader.ts @@ -0,0 +1,272 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type { + MastraCodePlugin, + MastraCodePluginConfigSchema, + MastraCodePluginConfigValues, + MastraCodePluginContext, + MastraCodePluginToolEntries, + MastraCodePluginTools, + MastraCodeToolRenderConfig, +} from '../plugin.js'; +import { getPluginRoot } from './paths.js'; +import type { PluginPathOptions } from './paths.js'; +import { loadPluginRegistry, mergePluginRegistries } from './registry.js'; +import type { LoadedPlugin, PluginRegistry, ScopedInstalledPluginRecord } from './types.js'; + +export type LoadPluginsOptions = PluginPathOptions & { + globalRegistry?: PluginRegistry; + projectRegistry?: PluginRegistry; +}; + +export async function loadPlugins(options: LoadPluginsOptions): Promise<LoadedPlugin[]> { + const globalRegistry = + options.globalRegistry ?? loadPluginRegistry(path.join(getPluginRoot('global', options), 'plugins.json')); + const projectRegistry = + options.projectRegistry ?? loadPluginRegistry(path.join(getPluginRoot('project', options), 'plugins.json')); + const records = mergePluginRegistries(globalRegistry, projectRegistry); + const loaded: LoadedPlugin[] = []; + + for (const record of records) { + if (record.blocked) { + loaded.push({ ...record, status: 'blocked', tools: {}, toolNames: [] }); + continue; + } + if (!record.enabled) { + loaded.push({ ...record, status: 'inactive', tools: {}, toolNames: [] }); + continue; + } + + loaded.push(await loadPluginRecord(record, options)); + } + + return markToolConflicts(loaded); +} + +export async function loadPluginRecord( + record: ScopedInstalledPluginRecord, + options: PluginPathOptions, +): Promise<LoadedPlugin> { + try { + const entryPath = resolvePluginEntryPath(record, options); + const plugin = await importPluginModule(entryPath); + if (plugin.id !== record.id) { + throw new Error(`Plugin id mismatch: registry has "${record.id}" but module exports "${plugin.id}"`); + } + + const configSchema = validatePluginConfigSchema(plugin.config); + const configValues = resolvePluginConfigValues(configSchema, record.config); + const pluginDir = path.dirname(entryPath); + const pluginRoot = resolvePluginRoot(record, options); + const context: MastraCodePluginContext = { + cwd: options.projectRoot, + scope: record.scope, + pluginDir, + config: configValues, + }; + const { tools, renderConfigs } = await resolvePluginTools(plugin, context); + const instructions = await resolvePluginInstructions(plugin, context); + + return { + ...record, + name: plugin.name, + version: plugin.version ?? record.version, + description: plugin.description, + instructions, + status: 'active', + tools, + renderConfigs, + toolNames: Object.keys(tools).sort(), + skillPaths: resolveExistingAssetDirs(pluginRoot, 'skills'), + commandPaths: resolveExistingAssetDirs(pluginRoot, 'commands'), + configSchema, + configValues, + }; + } catch (error) { + return { + ...record, + status: 'load failed', + error: error instanceof Error ? error.message : String(error), + tools: {}, + toolNames: [], + }; + } +} + +export async function loadPluginFromEntry(entryPath: string): Promise<MastraCodePlugin> { + return validatePluginExport(await importPluginModule(entryPath)); +} + +export function resolvePluginRoot(record: ScopedInstalledPluginRecord, options: PluginPathOptions): string { + const scopeRoot = path.resolve(getPluginRoot(record.scope, options)); + const pluginRoot = path.resolve(path.isAbsolute(record.path) ? record.path : path.join(scopeRoot, record.path)); + if (record.source === 'github' && !isInsideDirectory(pluginRoot, scopeRoot)) { + throw new Error(`Plugin path for "${record.id}" must be inside the ${record.scope} plugin directory`); + } + return pluginRoot; +} + +export function resolvePluginEntryPath(record: ScopedInstalledPluginRecord, options: PluginPathOptions): string { + const pluginRoot = resolvePluginRoot(record, options); + const entryPath = path.resolve(pluginRoot, record.entry); + if (!isInsideDirectory(entryPath, pluginRoot)) { + throw new Error(`Plugin entry for "${record.id}" must be inside the plugin directory`); + } + return entryPath; +} + +export function isInsideDirectory(targetPath: string, root: string): boolean { + const resolvedTarget = path.resolve(targetPath); + const resolvedRoot = path.resolve(root); + return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(resolvedRoot + path.sep); +} + +function resolveExistingAssetDirs(pluginRoot: string, dirname: 'skills' | 'commands'): string[] { + const dir = path.join(pluginRoot, dirname); + try { + return fs.statSync(dir).isDirectory() ? [dir] : []; + } catch { + return []; + } +} + +async function importPluginModule(entryPath: string): Promise<MastraCodePlugin> { + if (path.extname(entryPath) !== '.ts') { + throw new Error( + `Unsupported plugin entry extension "${path.extname(entryPath)}". V1 plugins must use .ts entries.`, + ); + } + + const url = pathToFileURL(entryPath); + const stat = fs.statSync(entryPath, { bigint: true }); + url.searchParams.set('mtimeNs', stat.mtimeNs.toString()); + url.searchParams.set('size', stat.size.toString()); + const mod = (await import(url.href)) as { default?: unknown; plugin?: unknown }; + return validatePluginExport(mod.default ?? mod.plugin); +} + +function validatePluginExport(value: unknown): MastraCodePlugin { + if (!value || typeof value !== 'object') { + throw new Error('Plugin module must export a plugin object as default or named "plugin" export'); + } + + const plugin = value as MastraCodePlugin; + if (typeof plugin.id !== 'string' || plugin.id.trim().length === 0) { + throw new Error('Plugin id must be a non-empty string'); + } + + if (plugin.tools !== undefined && typeof plugin.tools !== 'object' && typeof plugin.tools !== 'function') { + throw new Error('Plugin tools must be an object or function'); + } + + return plugin; +} + +async function resolvePluginTools( + plugin: MastraCodePlugin, + context: MastraCodePluginContext, +): Promise<{ tools: MastraCodePluginTools; renderConfigs: Record<string, MastraCodeToolRenderConfig> }> { + if (!plugin.tools) return { tools: {}, renderConfigs: {} }; + const entries = typeof plugin.tools === 'function' ? await plugin.tools(context) : plugin.tools; + if (!entries || typeof entries !== 'object' || Array.isArray(entries)) { + throw new Error('Plugin tools function must return an object'); + } + return normalizePluginToolEntries(entries); +} + +async function resolvePluginInstructions( + plugin: MastraCodePlugin, + context: MastraCodePluginContext, +): Promise<string | undefined> { + if (plugin.instructions === undefined) return undefined; + const instructions = + typeof plugin.instructions === 'function' ? await plugin.instructions(context) : plugin.instructions; + if (typeof instructions !== 'string') { + throw new Error('Plugin instructions must be a string'); + } + const trimmed = instructions.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizePluginToolEntries(entries: MastraCodePluginToolEntries): { + tools: MastraCodePluginTools; + renderConfigs: Record<string, MastraCodeToolRenderConfig>; +} { + const tools: MastraCodePluginTools = {}; + const renderConfigs: Record<string, MastraCodeToolRenderConfig> = {}; + for (const [name, entry] of Object.entries(entries)) { + if (!isToolEntryObject(entry)) { + throw new Error(`Plugin tool "${name}" must be an object with a tool property`); + } + tools[name] = entry.tool; + if (entry.render) renderConfigs[name] = entry.render; + } + return { tools, renderConfigs }; +} + +function isToolEntryObject(entry: MastraCodePluginToolEntries[string]): entry is MastraCodePluginToolEntries[string] { + if (!entry || typeof entry !== 'object' || !('tool' in entry)) return false; + const tool = (entry as { tool?: unknown }).tool; + return !!tool && typeof tool === 'object' && !Array.isArray(tool); +} + +function validatePluginConfigSchema(schema: unknown): MastraCodePluginConfigSchema | undefined { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return undefined; + const validated: MastraCodePluginConfigSchema = {}; + for (const [key, option] of Object.entries(schema)) { + if (!option || typeof option !== 'object' || Array.isArray(option)) continue; + const record = option as Record<string, unknown>; + if (record.type !== 'model' && record.type !== 'boolean' && record.type !== 'string') continue; + validated[key] = { + type: record.type, + ...(typeof record.label === 'string' ? { label: record.label } : {}), + ...(typeof record.description === 'string' ? { description: record.description } : {}), + ...(typeof record.default === 'string' || typeof record.default === 'boolean' ? { default: record.default } : {}), + }; + } + return Object.keys(validated).length > 0 ? validated : undefined; +} + +function resolvePluginConfigValues( + schema: MastraCodePluginConfigSchema | undefined, + recordValues: Record<string, unknown> | undefined, +): MastraCodePluginConfigValues { + const values: MastraCodePluginConfigValues = {}; + if (!schema) return values; + for (const [key, option] of Object.entries(schema)) { + const value = recordValues?.[key]; + if (option.type === 'boolean') { + values[key] = typeof value === 'boolean' ? value : typeof option.default === 'boolean' ? option.default : false; + continue; + } + values[key] = typeof value === 'string' ? value : typeof option.default === 'string' ? option.default : undefined; + } + return values; +} + +export function collectActivePluginTools(plugins: LoadedPlugin[]): MastraCodePluginTools { + const tools: MastraCodePluginTools = {}; + for (const plugin of plugins) { + if (plugin.status !== 'active') continue; + for (const [name, tool] of Object.entries(plugin.tools)) { + if (!(name in tools)) { + tools[name] = tool; + } + } + } + return tools; +} + +function markToolConflicts(plugins: LoadedPlugin[]): LoadedPlugin[] { + const seen = new Map<string, string>(); + return plugins.map(plugin => { + if (plugin.status !== 'active') return plugin; + const conflicts = plugin.toolNames.filter(toolName => seen.has(toolName)); + for (const toolName of plugin.toolNames) { + if (!seen.has(toolName)) seen.set(toolName, plugin.id); + } + return conflicts.length > 0 ? { ...plugin, status: 'conflicted', conflicts } : plugin; + }); +} diff --git a/mastracode/src/plugins/manager.ts b/mastracode/src/plugins/manager.ts new file mode 100644 index 000000000000..a3352f1dc833 --- /dev/null +++ b/mastracode/src/plugins/manager.ts @@ -0,0 +1,431 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { execa } from 'execa'; + +import type { MastraCodePluginConfigValue } from '../plugin.js'; +import { discoverLocalPlugins, installGithubPlugin, installLocalPlugin } from './install.js'; +import type { InstallPluginOptions } from './install.js'; +import { collectActivePluginTools, isInsideDirectory, loadPlugins, resolvePluginEntryPath } from './loader.js'; +import { getPluginScopePaths } from './paths.js'; +import type { PluginPathOptions } from './paths.js'; +import { loadPluginRegistry, removePluginRecord, savePluginRegistry, setPluginRecord } from './registry.js'; +import type { LoadedPlugin, PluginScope } from './types.js'; + +const GITHUB_PLUGIN_POLL_INTERVAL_MS = 60_000; + +function getEntryVersion(entryPath: string): string { + const stat = fs.statSync(entryPath, { bigint: true }); + return `${stat.mtimeNs}:${stat.size}`; +} + +export class PluginManager { + private loadedPlugins: LoadedPlugin[] = []; + private readonly pluginTools: ReturnType<typeof collectActivePluginTools> = {}; + private readonly rawPluginTools: ReturnType<typeof collectActivePluginTools> = {}; + private readonly toolRenderConfigs = new Map<string, NonNullable<LoadedPlugin['renderConfigs']>[string]>(); + private readonly watchedLocalEntries = new Set<string>(); + private readonly localEntryVersions = new Map<string, string>(); + private githubPollTimer: ReturnType<typeof setInterval> | undefined; + private githubPollInFlight: Promise<boolean> | undefined; + private reloadInFlight: Promise<LoadedPlugin[]> | undefined; + private readonly reloadListeners = new Set<(plugins: LoadedPlugin[]) => void | Promise<void>>(); + + constructor(private readonly options: PluginPathOptions) {} + + onReload(listener: (plugins: LoadedPlugin[]) => void | Promise<void>): () => void { + this.reloadListeners.add(listener); + return () => this.reloadListeners.delete(listener); + } + + async reload(): Promise<LoadedPlugin[]> { + if (this.reloadInFlight) return this.reloadInFlight; + + this.reloadInFlight = (async () => { + this.loadedPlugins = await loadPlugins(this.options); + this.updateLocalEntryWatchers(this.loadedPlugins); + this.updateGithubPoller(this.loadedPlugins); + this.updatePluginRenderConfigs(this.loadedPlugins); + this.updatePluginTools(collectActivePluginTools(this.loadedPlugins)); + await this.notifyReloadListeners(this.loadedPlugins); + return this.loadedPlugins; + })().finally(() => { + this.reloadInFlight = undefined; + }); + + return this.reloadInFlight; + } + + async listPlugins(): Promise<LoadedPlugin[]> { + if (this.loadedPlugins.length === 0) { + await this.reload(); + } + return this.loadedPlugins; + } + + getLoadedPlugins(): LoadedPlugin[] { + return this.loadedPlugins; + } + + getPluginTools() { + return this.pluginTools; + } + + getToolRenderConfig(toolName: string) { + return this.toolRenderConfigs.get(toolName); + } + + getPluginSkillPaths(): string[] { + return this.loadedPlugins.flatMap(plugin => (plugin.status === 'active' ? (plugin.skillPaths ?? []) : [])); + } + + getPluginCommandPaths(): string[] { + return this.loadedPlugins.flatMap(plugin => (plugin.status === 'active' ? (plugin.commandPaths ?? []) : [])); + } + + getPluginInstructions(): string[] { + return this.loadedPlugins.flatMap(plugin => + plugin.status === 'active' && plugin.instructions ? [plugin.instructions] : [], + ); + } + + private async notifyReloadListeners(plugins: LoadedPlugin[]): Promise<void> { + await Promise.all([...this.reloadListeners].map(listener => Promise.resolve(listener(plugins)))); + } + + private updatePluginRenderConfigs(plugins: LoadedPlugin[]): void { + this.toolRenderConfigs.clear(); + for (const plugin of plugins) { + if (plugin.status !== 'active') continue; + for (const [toolName, renderConfig] of Object.entries(plugin.renderConfigs ?? {})) { + if (!this.toolRenderConfigs.has(toolName)) { + this.toolRenderConfigs.set(toolName, renderConfig); + } + } + } + } + + private updatePluginTools(nextTools: ReturnType<typeof collectActivePluginTools>): void { + for (const name of Object.keys(this.rawPluginTools)) { + if (!(name in nextTools)) { + delete this.rawPluginTools[name]; + delete this.pluginTools[name]; + } + } + + for (const [name, tool] of Object.entries(nextTools)) { + this.rawPluginTools[name] = tool; + if (!this.pluginTools[name]) { + this.pluginTools[name] = this.createLiveToolProxy(name); + } + this.syncLiveToolProxy(name, tool); + } + } + + private createLiveToolProxy(toolName: string) { + return { + execute: async (...args: any[]) => { + await this.reloadChangedLocalPlugins(); + const latestTool = this.rawPluginTools[toolName]; + if (!latestTool?.execute) { + throw new Error(`Plugin tool "${toolName}" is no longer available`); + } + return (latestTool.execute as (...args: any[]) => unknown)(...args); + }, + } as LoadedPlugin['tools'][string]; + } + + private syncLiveToolProxy(toolName: string, tool: LoadedPlugin['tools'][string]): void { + const proxy = this.pluginTools[toolName]; + if (!proxy) return; + const mutableProxy = proxy as unknown as Record<string, unknown>; + for (const key of Object.keys(mutableProxy)) { + delete mutableProxy[key]; + } + Object.assign(proxy, tool); + proxy.execute = this.createLiveToolProxy(toolName).execute; + } + + private async reloadChangedLocalPlugins(): Promise<void> { + for (const plugin of this.loadedPlugins) { + if (plugin.source !== 'local' || plugin.status !== 'active') continue; + const entryPath = resolvePluginEntryPath(plugin, this.options); + const currentVersion = getEntryVersion(entryPath); + if (this.localEntryVersions.get(entryPath) !== currentVersion) { + await this.reload(); + return; + } + } + } + + private updateLocalEntryWatchers(plugins: LoadedPlugin[]): void { + const nextEntries = new Set<string>(); + for (const plugin of plugins) { + if (plugin.source !== 'local' || plugin.status !== 'active') continue; + let entryPath: string; + let entryVersion: string; + try { + entryPath = resolvePluginEntryPath(plugin, this.options); + entryVersion = getEntryVersion(entryPath); + } catch { + continue; + } + nextEntries.add(entryPath); + this.localEntryVersions.set(entryPath, entryVersion); + if (this.watchedLocalEntries.has(entryPath)) continue; + + const watcher = fs.watchFile(entryPath, { interval: 500 }, (current, previous) => { + if (current.mtimeMs === previous.mtimeMs) return; + void this.reload().catch(() => undefined); + }); + watcher.unref?.(); + this.watchedLocalEntries.add(entryPath); + } + + for (const entryPath of this.watchedLocalEntries) { + if (nextEntries.has(entryPath)) continue; + fs.unwatchFile(entryPath); + this.watchedLocalEntries.delete(entryPath); + this.localEntryVersions.delete(entryPath); + } + } + + private updateGithubPoller(plugins: LoadedPlugin[]): void { + const hasGithubPlugin = plugins.some( + plugin => plugin.source === 'github' && plugin.status !== 'inactive' && plugin.status !== 'blocked', + ); + if (hasGithubPlugin && !this.githubPollTimer) { + this.githubPollTimer = setInterval(() => { + void this.pollGithubSourcesForUpdates().catch(() => undefined); + }, GITHUB_PLUGIN_POLL_INTERVAL_MS); + this.githubPollTimer.unref?.(); + } + if (!hasGithubPlugin && this.githubPollTimer) { + clearInterval(this.githubPollTimer); + this.githubPollTimer = undefined; + } + } + + async pollGithubSourcesForUpdates(): Promise<boolean> { + if (this.githubPollInFlight) return this.githubPollInFlight; + this.githubPollInFlight = this.pollGithubSourcesForUpdatesOnce().finally(() => { + this.githubPollInFlight = undefined; + }); + return this.githubPollInFlight; + } + + private async pollGithubSourcesForUpdatesOnce(): Promise<boolean> { + let changed = false; + const seen = new Set<string>(); + for (const plugin of this.loadedPlugins) { + if (plugin.source !== 'github' || plugin.status === 'inactive' || plugin.status === 'blocked') continue; + const checkoutPath = this.resolvePluginSourcePath(plugin); + if (seen.has(checkoutPath) || !fs.existsSync(path.join(checkoutPath, '.git'))) continue; + seen.add(checkoutPath); + + const before = await this.readGitHead(checkoutPath); + const checkoutChanged = await this.refreshGithubCheckout(plugin, checkoutPath, before); + const after = await this.readGitHead(checkoutPath); + if (checkoutChanged || before !== after) changed = true; + } + + if (changed) { + await this.reload(); + } + return changed; + } + + private async refreshGithubCheckout( + plugin: LoadedPlugin, + checkoutPath: string, + currentHead: string, + ): Promise<boolean> { + await execa('git', ['fetch', 'origin'], { cwd: checkoutPath }); + const upstream = await this.resolveGitUpstream(checkoutPath, plugin.ref); + if (!upstream) return false; + const [localOnly, remoteOnly] = await this.readGitAheadBehind(checkoutPath, upstream); + const hasLocalChanges = await this.hasGitWorkingTreeChanges(checkoutPath); + + if (localOnly > 0 || hasLocalChanges) { + await this.backupGitCheckout(checkoutPath, currentHead, hasLocalChanges); + } + + if (remoteOnly > 0 || localOnly > 0 || hasLocalChanges) { + await execa('git', ['reset', '--hard', upstream], { cwd: checkoutPath }); + return true; + } + + return false; + } + + private async backupGitCheckout( + checkoutPath: string, + currentHead: string, + includeWorkingTree: boolean, + ): Promise<void> { + const backupBranch = this.createGitBackupBranchName(currentHead); + + if (includeWorkingTree) { + const currentBranch = await this.readGitCurrentBranch(checkoutPath); + await execa('git', ['switch', '-c', backupBranch], { cwd: checkoutPath }); + await execa('git', ['add', '-A'], { cwd: checkoutPath }); + const hasStagedChanges = await this.hasGitStagedChanges(checkoutPath); + if (hasStagedChanges) { + await execa( + 'git', + [ + '-c', + 'user.name=Mastra Code', + '-c', + 'user.email=noreply@mastra.ai', + 'commit', + '-m', + 'chore: backup local plugin checkout changes', + ], + { cwd: checkoutPath }, + ); + } + await this.restoreGitCheckout(checkoutPath, currentBranch, currentHead); + return; + } + + await execa('git', ['branch', backupBranch, 'HEAD'], { cwd: checkoutPath }); + } + + private async resolveGitUpstream(cwd: string, installedRef?: string): Promise<string | undefined> { + try { + const { stdout } = await execa('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { cwd }); + return stdout.trim(); + } catch { + return installedRef ? undefined : 'origin/main'; + } + } + + private async readGitAheadBehind(cwd: string, upstream: string): Promise<[number, number]> { + const { stdout } = await execa('git', ['rev-list', '--left-right', '--count', `HEAD...${upstream}`], { cwd }); + const [ahead = '0', behind = '0'] = stdout.trim().split(/\s+/); + return [Number(ahead) || 0, Number(behind) || 0]; + } + + private async hasGitWorkingTreeChanges(cwd: string): Promise<boolean> { + const { stdout } = await execa('git', ['status', '--porcelain'], { cwd }); + return stdout.trim().length > 0; + } + + private async hasGitStagedChanges(cwd: string): Promise<boolean> { + try { + await execa('git', ['diff', '--cached', '--quiet'], { cwd }); + return false; + } catch { + return true; + } + } + + private async restoreGitCheckout(cwd: string, branch: string | undefined, fallbackHead: string): Promise<void> { + if (branch) { + await execa('git', ['switch', branch], { cwd }); + return; + } + await execa('git', ['checkout', fallbackHead], { cwd }); + } + + private async readGitCurrentBranch(cwd: string): Promise<string | undefined> { + const { stdout } = await execa('git', ['branch', '--show-current'], { cwd }); + const branch = stdout.trim(); + return branch.length > 0 ? branch : undefined; + } + + private createGitBackupBranchName(currentHead: string): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `mastracode/plugin-backup/${timestamp}-${currentHead.slice(0, 8)}`; + } + + private resolvePluginSourcePath(plugin: LoadedPlugin): string { + const paths = getPluginScopePaths(plugin.scope, this.options); + return path.isAbsolute(plugin.path) ? plugin.path : path.join(paths.root, plugin.path); + } + + private async readGitHead(cwd: string): Promise<string> { + const { stdout } = await execa('git', ['rev-parse', 'HEAD'], { cwd }); + return stdout.trim(); + } + + discoverLocal(searchRoot = '.'): ReturnType<typeof discoverLocalPlugins> { + return discoverLocalPlugins(searchRoot, this.options); + } + + async installLocal( + localPath: string, + scope: PluginScope, + options: Pick<InstallPluginOptions, 'entry'> = {}, + ): Promise<string> { + const id = await installLocalPlugin(localPath, scope, { ...this.options, ...options }); + await this.reload(); + return id; + } + + async installGithub( + url: string, + scope: PluginScope, + options: Pick<InstallPluginOptions, 'entry' | 'ref'> = {}, + ): Promise<string> { + const id = await installGithubPlugin(url, scope, { ...this.options, ...options }); + await this.reload(); + return id; + } + + async setEnabled(pluginId: string, scope: PluginScope, enabled: boolean): Promise<void> { + const paths = getPluginScopePaths(scope, this.options); + const registry = loadPluginRegistry(paths.registryPath); + const record = registry.plugins[pluginId]; + if (!record) { + throw new Error(`Plugin "${pluginId}" is not installed in ${scope} scope`); + } + savePluginRegistry(paths.registryPath, setPluginRecord(registry, pluginId, { ...record, enabled })); + await this.reload(); + } + + async setConfigValue( + pluginId: string, + scope: PluginScope, + key: string, + value: MastraCodePluginConfigValue, + ): Promise<void> { + const paths = getPluginScopePaths(scope, this.options); + const registry = loadPluginRegistry(paths.registryPath); + const record = registry.plugins[pluginId]; + if (!record) { + throw new Error(`Plugin "${pluginId}" is not installed in ${scope} scope`); + } + const config = { ...(record.config ?? {}) }; + if (value === undefined || value === '') { + delete config[key]; + } else { + config[key] = value; + } + const nextRecord = { ...record, config: Object.keys(config).length > 0 ? config : undefined }; + savePluginRegistry(paths.registryPath, setPluginRecord(registry, pluginId, nextRecord)); + await this.reload(); + } + + async uninstall(pluginId: string, scope: PluginScope): Promise<void> { + const paths = getPluginScopePaths(scope, this.options); + const registry = loadPluginRegistry(paths.registryPath); + const record = registry.plugins[pluginId]; + if (!record) { + throw new Error(`Plugin "${pluginId}" is not installed in ${scope} scope`); + } + + savePluginRegistry(paths.registryPath, removePluginRecord(registry, pluginId)); + if (record.source === 'github') { + const checkoutPath = path.resolve( + path.isAbsolute(record.path) ? record.path : path.join(paths.root, record.path), + ); + const githubSourcesPath = path.resolve(paths.sourcesPath, 'github'); + if (isInsideDirectory(checkoutPath, githubSourcesPath)) { + fs.rmSync(checkoutPath, { recursive: true, force: true }); + } + } + await this.reload(); + } +} diff --git a/mastracode/src/plugins/manifest.ts b/mastracode/src/plugins/manifest.ts new file mode 100644 index 000000000000..0d7aa9b9d80c --- /dev/null +++ b/mastracode/src/plugins/manifest.ts @@ -0,0 +1,87 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const PLUGIN_MANIFEST_FILE = '.mastracode-plugin.json'; + +export type PluginManifestEntry = { + id: string; + name?: string; + entry: string; +}; + +export type PluginManifest = { + plugins: PluginManifestEntry[]; +}; + +export function loadPluginManifest(rootDir: string): PluginManifest | undefined { + const manifestPath = path.join(rootDir, PLUGIN_MANIFEST_FILE); + if (!fs.existsSync(manifestPath)) return undefined; + + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch (error) { + throw new Error( + `Could not parse ${PLUGIN_MANIFEST_FILE}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (!parsed || typeof parsed !== 'object' || !Array.isArray((parsed as { plugins?: unknown }).plugins)) { + throw new Error(`${PLUGIN_MANIFEST_FILE} must contain a plugins array`); + } + + return { + plugins: (parsed as { plugins: unknown[] }).plugins.map((entry, index) => validateManifestEntry(entry, index)), + }; +} + +export function savePluginManifest(rootDir: string, manifest: PluginManifest): void { + fs.writeFileSync(path.join(rootDir, PLUGIN_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`); +} + +export function upsertPluginManifestEntry(rootDir: string, entry: PluginManifestEntry): void { + const manifest = loadPluginManifest(rootDir) ?? { plugins: [] }; + const validatedEntry = validateManifestEntry(entry, manifest.plugins.length); + const existingIndex = manifest.plugins.findIndex(plugin => plugin.id === validatedEntry.id); + if (existingIndex >= 0) { + manifest.plugins[existingIndex] = validateManifestEntry(validatedEntry, existingIndex); + } else { + manifest.plugins.push(validatedEntry); + } + savePluginManifest(rootDir, manifest); +} + +export function getSingleManifestPlugin(rootDir: string): PluginManifestEntry | undefined { + const manifest = loadPluginManifest(rootDir); + if (!manifest) return undefined; + if (manifest.plugins.length === 0) return undefined; + if (manifest.plugins.length > 1) { + throw new Error( + `${PLUGIN_MANIFEST_FILE} contains multiple plugins. Provide an entry path for one of: ${manifest.plugins + .map(plugin => `${plugin.id} (${plugin.entry})`) + .join(', ')}`, + ); + } + return manifest.plugins[0]; +} + +function validateManifestEntry(entry: unknown, index: number): PluginManifestEntry { + if (!entry || typeof entry !== 'object') { + throw new Error(`${PLUGIN_MANIFEST_FILE} plugin at index ${index} must be an object`); + } + const candidate = entry as { id?: unknown; name?: unknown; entry?: unknown }; + if (typeof candidate.id !== 'string' || candidate.id.length === 0) { + throw new Error(`${PLUGIN_MANIFEST_FILE} plugin at index ${index} must include an id`); + } + if (typeof candidate.entry !== 'string' || candidate.entry.length === 0) { + throw new Error(`${PLUGIN_MANIFEST_FILE} plugin ${candidate.id} must include an entry`); + } + if (candidate.name !== undefined && typeof candidate.name !== 'string') { + throw new Error(`${PLUGIN_MANIFEST_FILE} plugin ${candidate.id} name must be a string`); + } + return { + id: candidate.id, + entry: candidate.entry, + ...(candidate.name ? { name: candidate.name } : {}), + }; +} diff --git a/mastracode/src/plugins/package-link.ts b/mastracode/src/plugins/package-link.ts new file mode 100644 index 000000000000..5d9aa9a98fb1 --- /dev/null +++ b/mastracode/src/plugins/package-link.ts @@ -0,0 +1,19 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const MASTRACODE_PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); + +export function ensureMastraCodePackageLink(pluginDir: string): void { + const nodeModulesDir = path.join(pluginDir, 'node_modules'); + const linkPath = path.join(nodeModulesDir, 'mastracode'); + try { + fs.lstatSync(linkPath); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.symlinkSync(MASTRACODE_PACKAGE_ROOT, linkPath, 'dir'); +} diff --git a/mastracode/src/plugins/paths.ts b/mastracode/src/plugins/paths.ts new file mode 100644 index 000000000000..a17fc100d9c3 --- /dev/null +++ b/mastracode/src/plugins/paths.ts @@ -0,0 +1,31 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { DEFAULT_CONFIG_DIR } from '../constants.js'; +import type { PluginScope, PluginScopePaths } from './types.js'; + +export type PluginPathOptions = { + projectRoot: string; + configDir?: string; + homeDir?: string; +}; + +export function getPluginRoot(scope: PluginScope, options: PluginPathOptions): string { + const configDir = options.configDir ?? DEFAULT_CONFIG_DIR; + const baseDir = scope === 'project' ? options.projectRoot : (options.homeDir ?? os.homedir()); + return path.join(baseDir, configDir, 'plugins'); +} + +export function getPluginRegistryPath(scope: PluginScope, options: PluginPathOptions): string { + return path.join(getPluginRoot(scope, options), 'plugins.json'); +} + +export function getPluginScopePaths(scope: PluginScope, options: PluginPathOptions): PluginScopePaths { + const root = getPluginRoot(scope, options); + return { + scope, + root, + registryPath: path.join(root, 'plugins.json'), + sourcesPath: path.join(root, 'sources'), + }; +} diff --git a/mastracode/src/plugins/registry.ts b/mastracode/src/plugins/registry.ts new file mode 100644 index 000000000000..ba48fbfb007a --- /dev/null +++ b/mastracode/src/plugins/registry.ts @@ -0,0 +1,115 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { InstalledPluginRecord, PluginRegistry, ScopedInstalledPluginRecord } from './types.js'; + +export const EMPTY_PLUGIN_REGISTRY: PluginRegistry = { plugins: {}, disabledPlugins: [] }; + +export function loadPluginRegistry(registryPath: string): PluginRegistry { + try { + if (!fs.existsSync(registryPath)) return { plugins: {}, disabledPlugins: [] }; + const raw = JSON.parse(fs.readFileSync(registryPath, 'utf-8')) as unknown; + return validatePluginRegistry(raw); + } catch { + return { plugins: {}, disabledPlugins: [] }; + } +} + +export function savePluginRegistry(registryPath: string, registry: PluginRegistry): void { + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + fs.writeFileSync(registryPath, `${JSON.stringify(validatePluginRegistry(registry), null, 2)}\n`); +} + +export function mergePluginRegistries( + globalRegistry: PluginRegistry, + projectRegistry: PluginRegistry, +): ScopedInstalledPluginRecord[] { + const merged = new Map<string, ScopedInstalledPluginRecord>(); + const disabledPlugins = new Set([ + ...(globalRegistry.disabledPlugins ?? []), + ...(projectRegistry.disabledPlugins ?? []), + ]); + + for (const [id, record] of Object.entries(globalRegistry.plugins)) { + merged.set(id, { id, scope: 'global', ...record }); + } + + for (const [id, record] of Object.entries(projectRegistry.plugins)) { + merged.set(id, { id, scope: 'project', ...record }); + } + + return [...merged.values()] + .map(record => (disabledPlugins.has(record.id) ? { ...record, blocked: true } : record)) + .sort((a, b) => { + if (a.scope !== b.scope) return a.scope === 'project' ? -1 : 1; + return a.id.localeCompare(b.id); + }); +} + +export function setPluginRecord( + registry: PluginRegistry, + pluginId: string, + record: InstalledPluginRecord, +): PluginRegistry { + return { + plugins: { + ...registry.plugins, + [pluginId]: record, + }, + disabledPlugins: registry.disabledPlugins ?? [], + }; +} + +export function removePluginRecord(registry: PluginRegistry, pluginId: string): PluginRegistry { + const plugins = { ...registry.plugins }; + delete plugins[pluginId]; + return { plugins, disabledPlugins: registry.disabledPlugins ?? [] }; +} + +function validatePluginRegistry(raw: unknown): PluginRegistry { + if (!raw || typeof raw !== 'object') return { plugins: {}, disabledPlugins: [] }; + const plugins = (raw as { plugins?: unknown }).plugins; + if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return { plugins: {}, disabledPlugins: [] }; + + const disabledPlugins = (raw as { disabledPlugins?: unknown }).disabledPlugins; + const validated: PluginRegistry = { + plugins: {}, + disabledPlugins: Array.isArray(disabledPlugins) + ? [...new Set(disabledPlugins.filter((pluginId): pluginId is string => typeof pluginId === 'string'))].sort() + : [], + }; + for (const [id, value] of Object.entries(plugins)) { + if (typeof id !== 'string' || id.trim().length === 0) continue; + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; + const record = value as Record<string, unknown>; + if (typeof record.enabled !== 'boolean') continue; + if (record.source !== 'local' && record.source !== 'github') continue; + if (typeof record.specifier !== 'string') continue; + if (typeof record.path !== 'string') continue; + if (typeof record.entry !== 'string') continue; + + validated.plugins[id] = { + enabled: record.enabled, + source: record.source, + specifier: record.specifier, + path: record.path, + entry: record.entry, + ...(typeof record.ref === 'string' ? { ref: record.ref } : {}), + ...(typeof record.version === 'string' ? { version: record.version } : {}), + ...(record.config && typeof record.config === 'object' && !Array.isArray(record.config) + ? { config: validatePluginConfigValues(record.config) } + : {}), + }; + } + + return validated; +} + +function validatePluginConfigValues(raw: object): Record<string, string | boolean> { + const values: Record<string, string | boolean> = {}; + for (const [key, value] of Object.entries(raw)) { + if (typeof key !== 'string' || key.trim().length === 0) continue; + if (typeof value === 'string' || typeof value === 'boolean') values[key] = value; + } + return values; +} diff --git a/mastracode/src/plugins/scaffold.ts b/mastracode/src/plugins/scaffold.ts new file mode 100644 index 000000000000..bb2a1c0f70bc --- /dev/null +++ b/mastracode/src/plugins/scaffold.ts @@ -0,0 +1,184 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { DEFAULT_CONFIG_DIR } from '../constants.js'; +import { upsertPluginManifestEntry } from './manifest.js'; +import { ensureMastraCodePackageLink } from './package-link.js'; + +export type ScaffoldPluginOptions = { + id?: string; + name?: string; + projectRoot?: string; + configDir?: string; +}; + +export function resolveScaffoldTarget( + target: string, + options: Pick<ScaffoldPluginOptions, 'projectRoot' | 'configDir'> = {}, +): string { + if (isBarePluginName(target)) { + return path.join( + options.projectRoot ?? process.cwd(), + options.configDir ?? DEFAULT_CONFIG_DIR, + 'plugins', + 'sources', + 'local', + target, + ); + } + return path.resolve(options.projectRoot ?? process.cwd(), target); +} + +export function scaffoldPlugin(targetDir: string, options: ScaffoldPluginOptions = {}): string { + const dir = resolveScaffoldTarget(targetDir, options); + if (fs.existsSync(dir) && fs.readdirSync(dir).length > 0) { + throw new Error(`Directory already exists and is not empty: ${targetDir}`); + } + + const packageName = + path + .basename(dir) + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, '-') + .replace(/^-|-$/g, '') || 'mastracode-plugin'; + const pluginId = options.id ?? packageName; + const pluginName = options.name ?? humanizeName(packageName); + + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'package.json'), + `${JSON.stringify( + { + name: packageName, + type: 'module', + exports: './src/index.ts', + peerDependencies: { + mastracode: '*', + }, + devDependencies: { + typescript: '^5.9.3', + }, + scripts: { + check: 'tsc --noEmit', + }, + }, + null, + 2, + )}\n`, + ); + fs.writeFileSync( + path.join(dir, 'tsconfig.json'), + `${JSON.stringify( + { + compilerOptions: { + target: 'ES2024', + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + verbatimModuleSyntax: true, + erasableSyntaxOnly: true, + skipLibCheck: true, + noEmit: true, + }, + include: ['src/**/*.ts'], + }, + null, + 2, + )}\n`, + ); + fs.writeFileSync(path.join(dir, 'src/index.ts'), renderIndex(pluginId, pluginName)); + fs.writeFileSync(path.join(dir, 'README.md'), renderReadme(pluginName, pluginId)); + writeScaffoldManifest(targetDir, dir, pluginId, pluginName, options); + ensureMastraCodePackageLink(dir); + return dir; +} + +function writeScaffoldManifest( + originalTarget: string, + scaffoldDir: string, + pluginId: string, + pluginName: string, + options: ScaffoldPluginOptions, +): void { + const projectRoot = options.projectRoot ?? process.cwd(); + const manifestRoot = isBarePluginName(originalTarget) ? projectRoot : scaffoldDir; + const entry = isBarePluginName(originalTarget) + ? path.join(path.relative(projectRoot, scaffoldDir), 'src/index.ts') + : 'src/index.ts'; + upsertPluginManifestEntry(manifestRoot, { + id: pluginId, + name: pluginName, + entry: entry.split(path.sep).join('/'), + }); +} + +export function formatScaffoldSuccess(targetDir: string): string { + return [ + `Created Mastra Code plugin scaffold at ${targetDir}`, + '', + 'Next steps:', + ` cd ${targetDir}`, + ' pnpm install', + ' pnpm check', + ' mastracode', + ' /plugins', + ' Install new plugin → Local path', + ].join('\n'); +} + +function isBarePluginName(target: string): boolean { + return !path.isAbsolute(target) && !target.startsWith('.') && !target.includes('/') && !target.includes('\\'); +} + +function renderIndex(pluginId: string, pluginName: string): string { + return `import { createTool, defineMastraCodePlugin, z } from 'mastracode/plugin'; + +export default defineMastraCodePlugin({ + id: ${JSON.stringify(pluginId)}, + name: ${JSON.stringify(pluginName)}, + description: 'A Mastra Code tool plugin.', + tools: { + example_tool: { + tool: createTool({ + id: 'example_tool', + description: 'Echo a message from the scaffolded plugin.', + inputSchema: z.object({ + message: z.string(), + }), + execute: async context => { + return { message: context.message }; + }, + }), + }, + }, +}); +`; +} + +function renderReadme(pluginName: string, pluginId: string): string { + return `# ${pluginName} + +Mastra Code tool plugin: \`${pluginId}\`. + +## Develop + +\`\`\`sh +pnpm install +pnpm check +\`\`\` + +## Install locally + +Open Mastra Code, run \`/plugins\`, and install this directory as a local plugin. +`; +} + +function humanizeName(value: string): string { + return ( + value + .split(/[-_.]+/g) + .filter(Boolean) + .map(part => part[0]?.toUpperCase() + part.slice(1)) + .join(' ') || 'Mastra Code Plugin' + ); +} diff --git a/mastracode/src/plugins/types.ts b/mastracode/src/plugins/types.ts new file mode 100644 index 000000000000..7c0cc456c230 --- /dev/null +++ b/mastracode/src/plugins/types.ts @@ -0,0 +1,55 @@ +import type { + MastraCodePluginConfigSchema, + MastraCodePluginConfigValue, + MastraCodePluginTools, + MastraCodeToolRenderConfig, +} from '../plugin.js'; + +export type PluginScope = 'global' | 'project'; +export type PluginSource = 'local' | 'github'; +export type PluginStatus = 'active' | 'inactive' | 'blocked' | 'load failed' | 'conflicted'; + +export type InstalledPluginRecord = { + enabled: boolean; + source: PluginSource; + specifier: string; + path: string; + entry: string; + ref?: string; + version?: string; + config?: Record<string, MastraCodePluginConfigValue>; +}; + +export type PluginRegistry = { + plugins: Record<string, InstalledPluginRecord>; + disabledPlugins?: string[]; +}; + +export type ScopedInstalledPluginRecord = InstalledPluginRecord & { + id: string; + scope: PluginScope; + blocked?: boolean; +}; + +export type LoadedPlugin = ScopedInstalledPluginRecord & { + name?: string; + description?: string; + instructions?: string; + status: PluginStatus; + error?: string; + tools: MastraCodePluginTools; + renderConfigs?: Record<string, MastraCodeToolRenderConfig>; + toolNames: string[]; + skillPaths?: string[]; + commandPaths?: string[]; + configSchema?: MastraCodePluginConfigSchema; + configValues?: Record<string, MastraCodePluginConfigValue>; + conflicts?: string[]; +}; + +export type PluginScopePaths = { + scope: PluginScope; + root: string; + registryPath: string; + sourcesPath: string; +}; diff --git a/mastracode/src/providers/__tests__/amazon-bedrock-catalog.test.ts b/mastracode/src/providers/__tests__/amazon-bedrock-catalog.test.ts new file mode 100644 index 000000000000..618ddb36697e --- /dev/null +++ b/mastracode/src/providers/__tests__/amazon-bedrock-catalog.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MODEL_TOKENS } from '../../../../docs/src/plugins/remark-model-tokens/models'; + +const fetchMock = vi.fn(); +vi.stubGlobal('fetch', fetchMock); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('getBedrockModelCatalog', () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + afterEach(async () => { + const { clearBedrockCatalogCache } = await import('../amazon-bedrock.js'); + clearBedrockCatalogCache(); + vi.resetModules(); + }); + + it('fetches models.dev and returns the amazon-bedrock model ids, sorted', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + 'amazon-bedrock': { + models: { + [MODEL_TOKENS.__BEDROCK_MODEL_SONNET_BARE__]: {}, + [MODEL_TOKENS.__BEDROCK_MODEL_OPUS_BARE__]: {}, + [MODEL_TOKENS.__BEDROCK_MODEL_LLAMA_SCOUT_BARE__]: {}, + }, + }, + anthropic: { models: { [MODEL_TOKENS.__AI_SDK_ANTHROPIC_MODEL_SONNET__]: {} } }, + }), + ); + + const { getBedrockModelCatalog } = await import('../amazon-bedrock.js'); + const models = await getBedrockModelCatalog(); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://models.dev/api.json', + expect.objectContaining({ signal: expect.anything() }), + ); + expect(models.map(m => m.id)).toEqual([ + MODEL_TOKENS.__BEDROCK_MODEL_OPUS_BARE__, + MODEL_TOKENS.__BEDROCK_MODEL_SONNET_BARE__, + MODEL_TOKENS.__BEDROCK_MODEL_LLAMA_SCOUT_BARE__, + ]); + }); + + it('caches the result so a second call does not refetch', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ 'amazon-bedrock': { models: { 'a.model': {} } } })); + + const { getBedrockModelCatalog } = await import('../amazon-bedrock.js'); + await getBedrockModelCatalog(); + await getBedrockModelCatalog(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('falls back to a built-in list when the fetch fails', async () => { + fetchMock.mockRejectedValueOnce(new Error('network down')); + + const { getBedrockModelCatalog } = await import('../amazon-bedrock.js'); + const models = await getBedrockModelCatalog(); + + expect(models.length).toBeGreaterThan(0); + expect(models.map(m => m.id)).toContain(MODEL_TOKENS.__BEDROCK_MODEL_OPUS_BARE__); + }); + + it('falls back when models.dev returns a non-OK status', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({}, 503)); + + const { getBedrockModelCatalog } = await import('../amazon-bedrock.js'); + const models = await getBedrockModelCatalog(); + + expect(models.length).toBeGreaterThan(0); + }); +}); + +describe('AmazonBedrockGateway', () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + afterEach(async () => { + const { clearBedrockCatalogCache } = await import('../amazon-bedrock.js'); + clearBedrockCatalogCache(); + vi.resetModules(); + }); + + it('fetchProviders surfaces bedrock models under an unprefixed amazon-bedrock provider key', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + 'amazon-bedrock': { + models: { + [MODEL_TOKENS.__BEDROCK_MODEL_SONNET_BARE__]: {}, + [MODEL_TOKENS.__BEDROCK_MODEL_OPUS_BARE__]: {}, + }, + }, + anthropic: { models: { [MODEL_TOKENS.__AI_SDK_ANTHROPIC_MODEL_SONNET__]: {} } }, + }), + ); + + const { createAmazonBedrockGateway } = await import('../amazon-bedrock-gateway.js'); + const gateway = createAmazonBedrockGateway(); + + expect(gateway.id).toBe('amazon-bedrock'); + expect(gateway.name).toBe('Amazon Bedrock'); + + const providers = await gateway.fetchProviders(); + + // Provider key must be the unprefixed `amazon-bedrock`, NOT namespaced under + // the MastraCode gateway (`mastracode/amazon-bedrock`). + expect(Object.keys(providers)).toEqual(['amazon-bedrock']); + expect(providers['amazon-bedrock'].gateway).toBe('amazon-bedrock'); + expect(providers['amazon-bedrock'].models).toEqual([ + MODEL_TOKENS.__BEDROCK_MODEL_OPUS_BARE__, + MODEL_TOKENS.__BEDROCK_MODEL_SONNET_BARE__, + ]); + }); +}); diff --git a/mastracode/src/providers/amazon-bedrock-gateway.ts b/mastracode/src/providers/amazon-bedrock-gateway.ts new file mode 100644 index 000000000000..247188c477ab --- /dev/null +++ b/mastracode/src/providers/amazon-bedrock-gateway.ts @@ -0,0 +1,135 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; +import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; +import { MastraModelGateway } from '@mastra/core/llm'; +import type { GatewayAuthRequest, GatewayAuthResult, GatewayLanguageModel, ProviderConfig } from '@mastra/core/llm'; +import { getBedrockModelCatalog } from './amazon-bedrock.js'; + +type ModelRequestHeaders = Record<string, string>; + +export const AMAZON_BEDROCK_GATEWAY_ID = 'amazon-bedrock'; + +/** + * Whether AWS credentials look available for Bedrock. + * + * Amazon Bedrock authenticates with AWS SigV4 rather than a bearer API key, so + * this only governs whether Bedrock models are offered as "authenticated" in the + * picker. We look for the common signals (env vars, bearer token, a configured + * profile, or a shared credentials/config file) rather than resolving + * credentials here, since the auth checker must stay sync. + */ +export function hasAwsCredentials(): boolean { + if ( + process.env.AWS_BEARER_TOKEN_BEDROCK || + (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) || + process.env.AWS_SHARED_CREDENTIALS_FILE || + process.env.AWS_CONFIG_FILE || + process.env.AWS_PROFILE || + process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || + process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI || + process.env.AWS_WEB_IDENTITY_TOKEN_FILE + ) { + return true; + } + const home = process.env.HOME || process.env.USERPROFILE; + if (home) { + const awsDir = path.join(home, '.aws'); + const credentialsPath = process.env.AWS_SHARED_CREDENTIALS_FILE ?? path.join(awsDir, 'credentials'); + const configPath = process.env.AWS_CONFIG_FILE ?? path.join(awsDir, 'config'); + if (existsSync(credentialsPath) || existsSync(configPath)) { + return true; + } + } + return false; +} + +/** + * Create an Amazon Bedrock model. + * + * Bedrock authenticates with AWS SigV4 rather than a bearer API key, so this + * resolves credentials through the standard AWS provider chain + * (`fromNodeProviderChain`): environment variables, shared `~/.aws` config and + * SSO profiles, and container/instance roles — the same resolution order the AWS + * CLI uses. The region falls back to `us-east-1` to match the AWS SDK default. + * + * When `AWS_BEARER_TOKEN_BEDROCK` is set, `@ai-sdk/amazon-bedrock` uses bearer + * auth instead and ignores the credential provider, so we leave that path to the + * SDK and only wire up SigV4 here. + */ +function bedrockProvider(modelId: string, headers?: ModelRequestHeaders) { + const region = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1'; + const bedrock = createAmazonBedrock({ + region, + credentialProvider: fromNodeProviderChain(), + headers, + }); + return bedrock(modelId); +} + +/** + * Standalone Amazon Bedrock gateway. + * + * Bedrock is resolved directly via AWS SigV4 (not through the model router) and + * its models are surfaced from the public models.dev catalog. It is exposed as + * its own gateway/provider (`amazon-bedrock/...`) rather than nested under the + * MastraCode gateway namespace. + */ +export class AmazonBedrockGateway extends MastraModelGateway { + readonly id = AMAZON_BEDROCK_GATEWAY_ID; + readonly name = 'Amazon Bedrock'; + + shouldEnable(): boolean { + return hasAwsCredentials(); + } + + async fetchProviders(): Promise<Record<string, ProviderConfig>> { + const providers: Record<string, ProviderConfig> = {}; + try { + const bedrockModels = await getBedrockModelCatalog(); + providers['amazon-bedrock'] = { + name: 'Amazon Bedrock', + apiKeyEnvVar: '', + apiKeyHeader: 'Authorization', + gateway: this.id, + models: bedrockModels.map(model => model.id), + }; + } catch (error) { + console.warn('Failed to load Amazon Bedrock model catalog:', error); + } + return providers; + } + + buildUrl(_modelId: string): string | undefined { + return undefined; + } + + async getApiKey(_modelId: string): Promise<string> { + return hasAwsCredentials() ? 'aws-credential-chain' : ''; + } + + resolveAuth(_request: GatewayAuthRequest): GatewayAuthResult | undefined { + // Amazon Bedrock authenticates via the AWS credential chain rather than a + // stored API key, so report it as authenticated whenever AWS credentials look + // available. The actual SigV4 signing happens inside `bedrockProvider()`. + if (hasAwsCredentials()) { + return { apiKey: 'aws-credential-chain', source: 'gateway' }; + } + return undefined; + } + + resolveLanguageModel(args: { + modelId: string; + providerId: string; + apiKey: string; + headers?: Record<string, string>; + transport?: any; + responsesWebSocket?: any; + }): GatewayLanguageModel { + return bedrockProvider(args.modelId, args.headers) as unknown as GatewayLanguageModel; + } +} + +export function createAmazonBedrockGateway(): AmazonBedrockGateway { + return new AmazonBedrockGateway(); +} diff --git a/mastracode/src/providers/amazon-bedrock.ts b/mastracode/src/providers/amazon-bedrock.ts new file mode 100644 index 000000000000..9afc7ace37f1 --- /dev/null +++ b/mastracode/src/providers/amazon-bedrock.ts @@ -0,0 +1,106 @@ +/** + * Amazon Bedrock model catalog. + * + * Bedrock is not part of mastracode's gateway-synced model router (it + * authenticates with AWS SigV4 rather than an API key / base URL, so it is + * resolved directly in `agents/model.ts`). To still offer Bedrock models in the + * `/models` picker and packs, we fetch the public models.dev catalog — the same + * source the model router uses — and expose the `amazon-bedrock` provider's + * model list. This mirrors the GitHub Copilot catalog approach. + */ + +const MODELS_DEV_API_URL = 'https://models.dev/api.json'; +const BEDROCK_PROVIDER_ID = 'amazon-bedrock'; + +const CATALOG_TTL_MS = 60 * 60 * 1000; +const CATALOG_FAILURE_TTL_MS = 60 * 1000; +const CATALOG_FETCH_TIMEOUT_MS = 5_000; + +export interface BedrockModelEntry { + id: string; +} + +/** + * A small, stable fallback so Bedrock packs keep working offline or when + * models.dev is unreachable. Intentionally minimal — the live catalog is the + * source of truth and supersedes this within one fetch. + */ +const BEDROCK_FALLBACK_MODELS: BedrockModelEntry[] = [ + { id: 'us.anthropic.claude-opus-4-6-v1' }, + { id: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0' }, + { id: 'us.anthropic.claude-haiku-4-5-20251001-v1:0' }, +]; + +interface CatalogCacheEntry { + fetchedAt: number; + ttl: number; + models: BedrockModelEntry[]; +} + +let catalogCache: CatalogCacheEntry | null = null; +let inflightFetch: Promise<BedrockModelEntry[]> | null = null; + +/** Reset the in-process Bedrock catalog cache (test seam). */ +export function clearBedrockCatalogCache(): void { + catalogCache = null; + inflightFetch = null; +} + +async function fetchBedrockModels(signal: AbortSignal): Promise<BedrockModelEntry[]> { + const response = await fetch(MODELS_DEV_API_URL, { signal }); + if (!response.ok) { + throw new Error(`models.dev returned ${response.status}`); + } + const data = (await response.json()) as Record<string, { models?: Record<string, unknown> }>; + const provider = data[BEDROCK_PROVIDER_ID]; + const models = provider?.models ?? {}; + return Object.keys(models) + .sort() + .map(id => ({ id })); +} + +/** + * Return the available Amazon Bedrock models. + * + * - Returns the cached list when a recent fetch succeeded. + * - On cache miss / expiry, fetches the models.dev catalog with a 5s timeout and + * caches it for an hour. + * - On fetch failure, returns a small hard-coded fallback (so packs still work + * offline) and caches that briefly to avoid hammering the network. + * + * Concurrent calls during a fetch share the inflight promise. + */ +export async function getBedrockModelCatalog(): Promise<BedrockModelEntry[]> { + const now = Date.now(); + if (catalogCache && now - catalogCache.fetchedAt < catalogCache.ttl) { + return catalogCache.models; + } + + if (inflightFetch) return inflightFetch; + + inflightFetch = (async (): Promise<BedrockModelEntry[]> => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), CATALOG_FETCH_TIMEOUT_MS); + try { + const models = await fetchBedrockModels(controller.signal); + catalogCache = { fetchedAt: Date.now(), ttl: CATALOG_TTL_MS, models }; + return models; + } catch (error) { + catalogCache = { + fetchedAt: Date.now(), + ttl: CATALOG_FAILURE_TTL_MS, + models: BEDROCK_FALLBACK_MODELS, + }; + console.warn( + 'Failed to fetch live Amazon Bedrock models, using fallback list:', + error instanceof Error ? error.message : error, + ); + return BEDROCK_FALLBACK_MODELS; + } finally { + clearTimeout(timer); + inflightFetch = null; + } + })(); + + return inflightFetch; +} diff --git a/mastracode/src/schema.ts b/mastracode/src/schema.ts index 879bce8070ec..5f7fdee5dae5 100644 --- a/mastracode/src/schema.ts +++ b/mastracode/src/schema.ts @@ -16,6 +16,16 @@ export interface MastraCodeState { subagentModelId?: string; projectPath?: string; projectName?: string; + /** When set, this project is a GitHub/cloud-sandbox-backed project. */ + githubProjectId?: string; + /** Persisted sandbox id for reattaching the project's cloud workspace. */ + sandboxId?: string; + /** Path inside the sandbox the repo is cloned into. */ + sandboxWorkdir?: string; + /** Active git worktree path inside the sandbox for the current unit of work. */ + worktreePath?: string; + /** Active feature branch checked out in the worktree. */ + branch?: string; configDir: string; homeDir?: string; gitBranch?: string; @@ -42,6 +52,9 @@ export interface MastraCodeState { activeForm: string; }>; sandboxAllowedPaths: string[]; + pluginSkillPaths: string[]; + pluginCommandPaths: string[]; + pluginInstructions: string[]; activePlan: { title: string; plan: string; @@ -74,6 +87,11 @@ export const stateSchema = z.object({ subagentModelId: z.string().optional(), projectPath: z.string().optional(), projectName: z.string().optional(), + githubProjectId: z.string().optional(), + sandboxId: z.string().optional(), + sandboxWorkdir: z.string().optional(), + worktreePath: z.string().optional(), + branch: z.string().optional(), configDir: z.string().default(DEFAULT_CONFIG_DIR), homeDir: z.string().optional(), gitBranch: z.string().optional(), @@ -122,6 +140,10 @@ export const stateSchema = z.object({ .default([]), // Sandbox allowed paths (per-thread, absolute paths allowed in addition to project root) sandboxAllowedPaths: z.array(z.string()).default([]), + // Asset directories contributed by active plugins. + pluginSkillPaths: z.array(z.string()).default([]), + pluginCommandPaths: z.array(z.string()).default([]), + pluginInstructions: z.array(z.string()).default([]), // Active plan (set when a plan is approved in Plan mode) activePlan: z .object({ diff --git a/mastracode/src/tools/__tests__/request-sandbox-access.test.ts b/mastracode/src/tools/__tests__/request-sandbox-access.test.ts index dbe023b171ce..7f9d6d636a22 100644 --- a/mastracode/src/tools/__tests__/request-sandbox-access.test.ts +++ b/mastracode/src/tools/__tests__/request-sandbox-access.test.ts @@ -12,8 +12,8 @@ function createMockLocalFilesystem() { return { fs, setAllowedPaths: spy }; } -function createAgentControllerCtx() { - const getState = () => ({ sandboxAllowedPaths: [] }); +function createAgentControllerCtx(state: Record<string, unknown> = {}) { + const getState = () => ({ sandboxAllowedPaths: [], ...state }); const setState = vi.fn(); return { getState, diff --git a/mastracode/src/tools/request-sandbox-access.ts b/mastracode/src/tools/request-sandbox-access.ts index d0bec146bcdf..33db20ced876 100644 --- a/mastracode/src/tools/request-sandbox-access.ts +++ b/mastracode/src/tools/request-sandbox-access.ts @@ -84,10 +84,12 @@ export const requestSandboxAccessTool = createTool({ // filesystem allowlist from `sandboxAllowedPaths` on every call // (getDynamicWorkspace), so an unawaited setState would let that // rebuild clobber the in-turn widen below before the grant lands. - const currentAllowed = (agentControllerCtx?.getState()?.sandboxAllowedPaths as string[] | undefined) ?? []; + const controllerState = agentControllerCtx?.getState(); + const currentAllowed = (controllerState?.sandboxAllowedPaths as string[] | undefined) ?? []; + const nextAllowed = currentAllowed.includes(absolutePath) ? currentAllowed : [...currentAllowed, absolutePath]; if (!currentAllowed.includes(absolutePath)) { await agentControllerCtx?.setState({ - sandboxAllowedPaths: [...currentAllowed, absolutePath], + sandboxAllowedPaths: nextAllowed, }); } diff --git a/mastracode/src/tui/__tests__/agent-lifecycle-goal-timer.test.ts b/mastracode/src/tui/__tests__/agent-lifecycle-goal-timer.test.ts index f086898c8708..b54c914b6ce6 100644 --- a/mastracode/src/tui/__tests__/agent-lifecycle-goal-timer.test.ts +++ b/mastracode/src/tui/__tests__/agent-lifecycle-goal-timer.test.ts @@ -51,6 +51,23 @@ describe('agent lifecycle goal timing', () => { expect(state.goalManager.stopActiveTimer).toHaveBeenCalled(); }); + it('does not render redundant interrupted errors for user aborts', () => { + const updateContent = vi.fn(); + const streamingMessage = { id: 'msg-1' } as any; + const state = createState({ + userInitiatedAbort: true, + streamingComponent: { updateContent } as any, + streamingMessage, + }); + + handleAgentAborted(createContext(state)); + + expect(updateContent).not.toHaveBeenCalled(); + expect(streamingMessage.errorMessage).toBeUndefined(); + expect(state.streamingComponent).toBeUndefined(); + expect(state.streamingMessage).toBeUndefined(); + }); + it('stops active goal timing when an agent error ends the turn', () => { const state = createState(); diff --git a/mastracode/src/tui/__tests__/command-dispatch.test.ts b/mastracode/src/tui/__tests__/command-dispatch.test.ts index b10953d8d6d5..0883574e2f07 100644 --- a/mastracode/src/tui/__tests__/command-dispatch.test.ts +++ b/mastracode/src/tui/__tests__/command-dispatch.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ handleGithubCommand: vi.fn().mockResolvedValue(undefined), handleReportIssueCommand: vi.fn().mockResolvedValue(undefined), handleMcpCommand: vi.fn().mockResolvedValue(undefined), + handlePluginsCommand: vi.fn().mockResolvedValue(undefined), processSlashCommand: vi.fn().mockResolvedValue('custom output'), startGoalWithDefaults: vi.fn().mockResolvedValue(undefined), showError: vi.fn(), @@ -52,6 +53,7 @@ vi.mock('../commands/index.js', () => ({ handleUpdateCommand: vi.fn(), handleMemoryGatewayCommand: vi.fn(), handleApiKeysCommand: vi.fn(), + handlePluginsCommand: mocks.handlePluginsCommand, handleFeedbackCommand: vi.fn(), handleObservabilityCommand: vi.fn(), handleGithubCommand: mocks.handleGithubCommand, @@ -88,6 +90,7 @@ describe('dispatchSlashCommand models routing', () => { mocks.handleGithubCommand.mockClear(); mocks.handleReportIssueCommand.mockClear(); mocks.handleMcpCommand.mockClear(); + mocks.handlePluginsCommand.mockClear(); mocks.processSlashCommand.mockClear(); mocks.startGoalWithDefaults.mockClear(); mocks.showError.mockClear(); @@ -175,6 +178,17 @@ describe('dispatchSlashCommand models routing', () => { expect(mocks.handleGithubCommand).toHaveBeenCalledWith(ctx, ['mastra-ai/mastra#17447']); }); + it('routes /plugins to handlePluginsCommand', async () => { + const state = { customSlashCommands: [] } as any; + const ctx = {} as any; + + const handled = await dispatchSlashCommand('/plugins', state, () => ctx); + + expect(handled).toBe(true); + expect(mocks.handlePluginsCommand).toHaveBeenCalledTimes(1); + expect(mocks.handlePluginsCommand).toHaveBeenCalledWith(ctx, []); + }); + it('routes /report-issue to handleReportIssueCommand', async () => { const state = { customSlashCommands: [] } as any; const ctx = {} as any; diff --git a/mastracode/src/tui/__tests__/mastra-tui-hooks.test.ts b/mastracode/src/tui/__tests__/mastra-tui-hooks.test.ts index f1249dd8804f..190c7c87cca6 100644 --- a/mastracode/src/tui/__tests__/mastra-tui-hooks.test.ts +++ b/mastracode/src/tui/__tests__/mastra-tui-hooks.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ showInfo: vi.fn(), showFormattedError: vi.fn(), notify: vi.fn(), + updateStatusLine: vi.fn(), })); vi.mock('node:child_process', () => ({ @@ -26,6 +27,10 @@ vi.mock('../display.js', () => ({ notify: mocks.notify, })); +vi.mock('../status-line.js', () => ({ + updateStatusLine: mocks.updateStatusLine, +})); + import { MastraTUI } from '../mastra-tui.js'; function createHookResult(overrides: Record<string, unknown> = {}) { @@ -40,6 +45,7 @@ function createHookResult(overrides: Record<string, unknown> = {}) { function createBareTui(hookManager?: Record<string, unknown>) { const tui = Object.create(MastraTUI.prototype) as { state: Record<string, unknown>; + statusTimingTimer: ReturnType<typeof setInterval> | null; caffeinateProcess: MockChildProcess | null; getEventContext: ReturnType<typeof vi.fn>; showHookWarnings: ReturnType<typeof vi.fn>; @@ -48,7 +54,12 @@ function createBareTui(hookManager?: Record<string, unknown>) { stop: () => void; }; - tui.state = { hookManager, ui: { stop: vi.fn() } }; + tui.state = { + hookManager, + ui: { stop: vi.fn(), requestRender: vi.fn() }, + idleCounter: { setTimingState: vi.fn(), update: vi.fn() }, + }; + tui.statusTimingTimer = null; tui.caffeinateProcess = null; tui.getEventContext = vi.fn(() => ({})); tui.showHookWarnings = vi.fn(); @@ -117,6 +128,45 @@ describe('MastraTUI hook wiring', () => { expect(runStop).not.toHaveBeenCalled(); }); + it('ticks idle status line every second while an agent run is active', async () => { + vi.useFakeTimers(); + try { + mocks.dispatchEvent.mockImplementation(async (_event, _ctx, state) => { + state.agentRunStartedAt = Date.now(); + }); + const tui = createBareTui(); + + await tui.handleEvent({ type: 'agent_start' }); + expect((tui.state.idleCounter as any).setTimingState).toHaveBeenCalledWith(tui.state, expect.any(Number)); + (tui.state.idleCounter as any).setTimingState.mockClear(); + + vi.advanceTimersByTime(1_000); + expect((tui.state.idleCounter as any).setTimingState).toHaveBeenCalledWith(tui.state, expect.any(Number)); + } finally { + vi.useRealTimers(); + } + }); + + it('ticks idle status line every minute after an agent run ends', async () => { + vi.useFakeTimers(); + try { + mocks.dispatchEvent.mockImplementation(async (_event, _ctx, state) => { + state.lastAgentRunDurationMs = 1_000; + state.lastAgentRunEndedAt = Date.now(); + }); + const tui = createBareTui(); + + await tui.handleEvent({ type: 'agent_end', reason: 'complete' }); + expect((tui.state.idleCounter as any).setTimingState).toHaveBeenCalledWith(tui.state, expect.any(Number)); + (tui.state.idleCounter as any).setTimingState.mockClear(); + + vi.advanceTimersByTime(60_000); + expect((tui.state.idleCounter as any).setTimingState).toHaveBeenCalledWith(tui.state, expect.any(Number)); + } finally { + vi.useRealTimers(); + } + }); + it('starts caffeinate on macOS agent_start', async () => { vi.stubGlobal('process', { platform: 'darwin', env: {} }); const child = new MockChildProcess(); diff --git a/mastracode/src/tui/__tests__/render-messages.test.ts b/mastracode/src/tui/__tests__/render-messages.test.ts index b61e10a51df4..3f71ac0394a4 100644 --- a/mastracode/src/tui/__tests__/render-messages.test.ts +++ b/mastracode/src/tui/__tests__/render-messages.test.ts @@ -733,6 +733,54 @@ describe('renderExistingMessages tasks', () => { }); describe('renderExistingMessages subagents', () => { + it('uses static plugin renderer config when replaying persisted plugin tool calls', async () => { + const message: AgentControllerMessage = { + id: 'assistant-plugin-renderer', + role: 'assistant', + createdAt: new Date(), + content: [ + { + type: 'tool_call', + id: 'tool-1', + name: 'mastra_expert', + args: { question: 'How does memory rendering work?' }, + }, + { + type: 'tool_result', + id: 'tool-1', + name: 'mastra_expert', + result: 'remembered answer', + isError: false, + }, + ], + } as unknown as AgentControllerMessage; + const state = createState(); + state.quietMode = true; + state.pluginManager = { + getToolRenderConfig: vi.fn(() => ({ type: 'subagent', agentType: 'alexandria', modelId: 'openai/gpt-5.5' })), + } as unknown as TUIState['pluginManager']; + state.session = { + ...state.session, + thread: { listActiveMessages: vi.fn().mockResolvedValue([message]) }, + } as unknown as TUIState['session']; + state.controller = { + session: state.session, + } as unknown as TUIState['controller']; + + await renderExistingMessages(state); + + expect(state.pluginManager?.getToolRenderConfig).toHaveBeenCalledWith('mastra_expert'); + expect(state.chatContainer.children).toHaveLength(1); + expect(state.chatContainer.children[0]).toBeInstanceOf(SubagentExecutionComponent); + const rendered = (state.chatContainer.children[0] as SubagentExecutionComponent) + .render(100) + .join('\n') + .replace(/\x1b\[[0-9;]*m/g, ''); + expect(rendered).toContain('alexandria openai/gpt-5.5'); + expect(rendered).toContain('How does memory rendering work?'); + expect(rendered).toContain('remembered answer'); + }); + it('uses the current model id for persisted forked subagents when no metadata tag is present', async () => { const message: AgentControllerMessage = { id: 'assistant-1', diff --git a/mastracode/src/tui/__tests__/state.test.ts b/mastracode/src/tui/__tests__/state.test.ts index a1e7d1249e28..73680926c773 100644 --- a/mastracode/src/tui/__tests__/state.test.ts +++ b/mastracode/src/tui/__tests__/state.test.ts @@ -13,10 +13,15 @@ vi.mock('@earendil-works/pi-tui', () => { constructor(public terminal: MockProcessTerminal) {} } + class MockSpacer { + setLines() {} + } + return { Container: MockContainer, ProcessTerminal: MockProcessTerminal, TUI: MockTUI, + Spacer: MockSpacer, }; }); @@ -31,6 +36,15 @@ vi.mock('../components/custom-editor.js', () => ({ }, })); +vi.mock('../../onboarding/settings.js', async importOriginal => { + const actual = (await importOriginal()) as Record<string, unknown>; + return { + ...actual, + // Keep state initialization hermetic: don't read the host's real settings. + loadSettings: vi.fn(() => ({ voice: { enabled: false } })), + }; +}); + vi.mock('../../utils/project.js', async importOriginal => { const actual = (await importOriginal()) as Record<string, unknown>; return { @@ -57,7 +71,7 @@ describe('createTUIState', () => { const controller = createAgentController(session); const hookManager = {}; const analytics = {}; - const authStorage = {}; + const authStorage = { getStoredApiKey: vi.fn(() => undefined) }; const mcpManager = {}; const workspace = {}; diff --git a/mastracode/src/tui/__tests__/status-line.test.ts b/mastracode/src/tui/__tests__/status-line.test.ts index f6698db43ebf..53e4f2c82628 100644 --- a/mastracode/src/tui/__tests__/status-line.test.ts +++ b/mastracode/src/tui/__tests__/status-line.test.ts @@ -157,6 +157,59 @@ describe('updateStatusLine', () => { expect(rendered).not.toContain('queued'); }); + it('shows active elapsed time directly after the model name', () => { + vi.useFakeTimers(); + vi.setSystemTime(62_000); + const state = createState(); + state.agentRunStartedAt = 1_000; + state.controller.session.model.get.mockReturnValue('openai/gpt-5'); + + updateStatusLine(state); + + const rendered = state.statusLine.setText.mock.calls[0]?.[0]; + expect(rendered).toContain('openai/gpt-5 1m1s'); + expect(rendered).not.toContain('worked for'); + vi.useRealTimers(); + }); + + it('keeps successful completed run timing beside the model with a checkmark', () => { + const state = createState(); + state.lastAgentRunDurationMs = 61_000; + state.lastAgentRunEndedAt = 1_000; + state.lastAgentRunEndReason = 'done'; + state.controller.session.model.get.mockReturnValue('openai/gpt-5'); + + updateStatusLine(state); + + const rendered = state.statusLine.setText.mock.calls[0]?.[0]; + expect(rendered).toContain('openai/gpt-5 1m1s ✓'); + expect(rendered).not.toContain('done in'); + }); + + it('keeps aborted timing beside the model without an icon and errored timing with an x', () => { + const aborted = createState(); + aborted.lastAgentRunDurationMs = 61_000; + aborted.lastAgentRunEndedAt = 1_000; + aborted.lastAgentRunEndReason = 'aborted'; + aborted.controller.session.model.get.mockReturnValue('openai/gpt-5'); + + updateStatusLine(aborted); + + expect(aborted.statusLine.setText.mock.calls[0]?.[0]).toContain('openai/gpt-5 1m1s'); + expect(aborted.statusLine.setText.mock.calls[0]?.[0]).not.toContain('1m1s ×'); + expect(aborted.statusLine.setText.mock.calls[0]?.[0]).not.toContain('1m1s ✓'); + + const errored = createState(); + errored.lastAgentRunDurationMs = 61_000; + errored.lastAgentRunEndedAt = 1_000; + errored.lastAgentRunEndReason = 'error'; + errored.controller.session.model.get.mockReturnValue('openai/gpt-5'); + + updateStatusLine(errored); + + expect(errored.statusLine.setText.mock.calls[0]?.[0]).toContain('openai/gpt-5 1m1s ×'); + }); + it('shows the active GitHub PR subscription beside the thread path', () => { const state = createState(); state.activeGithubPrSubscriptions = [ diff --git a/mastracode/src/tui/__tests__/tokens-per-sec.test.ts b/mastracode/src/tui/__tests__/tokens-per-sec.test.ts index 99eca441cee7..529e113b93f9 100644 --- a/mastracode/src/tui/__tests__/tokens-per-sec.test.ts +++ b/mastracode/src/tui/__tests__/tokens-per-sec.test.ts @@ -80,7 +80,10 @@ async function decodeStep( ): Promise<void> { vi.setSystemTime(opts.startMs); await dispatchEvent( - { type: 'message_update', message: { content: [{ type: 'text', text: 'streaming...' }] } } as any, + { + type: 'message_update', + message: { role: 'assistant', content: [{ type: 'text', text: 'streaming...' }] }, + } as any, ectx, state, ); @@ -134,7 +137,7 @@ describe('tokens/sec decode-window calculation', () => { vi.setSystemTime(1000); // request issued; nothing streamed yet vi.setSystemTime(4000); await dispatchEvent( - { type: 'message_update', message: { content: [{ type: 'text', text: 'hello' }] } } as any, + { type: 'message_update', message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }] } } as any, ectx, state, ); @@ -148,6 +151,60 @@ describe('tokens/sec decode-window calculation', () => { expect(state.tokensPerSec).toBe(20); }); + it('records stream activity on assistant message updates', async () => { + const state = createMinimalState({ agentRunStartedAt: 1000, agentRunLastStreamPartAt: 1000 }); + const ectx = createEctx(); + + vi.setSystemTime(4000); + await dispatchEvent( + { + type: 'message_update', + message: { role: 'assistant', content: [{ type: 'text', text: 'streaming...' }] }, + } as any, + ectx, + state, + ); + + expect(state.agentRunLastStreamPartAt).toBe(4000); + expect(state.decodeStartedAt).toBe(4000); + expect(ectx.updateStatusLine).toHaveBeenCalled(); + }); + + it('does not open the decode window for non-assistant text updates', async () => { + const state = createMinimalState({ agentRunStartedAt: 1000, agentRunLastStreamPartAt: 1000 }); + const ectx = createEctx(); + + vi.setSystemTime(4000); + await dispatchEvent( + { type: 'message_update', message: { role: 'user', content: [{ type: 'text', text: 'user text' }] } } as any, + ectx, + state, + ); + + expect(state.agentRunLastStreamPartAt).toBe(1000); + expect(state.decodeStartedAt).toBe(0); + expect(ectx.updateStatusLine).toHaveBeenCalled(); + }); + + it('records tool and shell activity without opening the decode window', async () => { + const state = createMinimalState({ agentRunStartedAt: 1000, agentRunLastStreamPartAt: 1000 }); + const ectx = createEctx(); + + vi.setSystemTime(4000); + await dispatchEvent( + { type: 'tool_update', toolCallId: 'tool-1', partialResult: { status: 'working' } } as any, + ectx, + state, + ); + expect(state.agentRunLastStreamPartAt).toBe(4000); + expect(state.decodeStartedAt).toBe(0); + + vi.setSystemTime(5000); + await dispatchEvent({ type: 'shell_output', toolCallId: 'tool-1', output: 'still working' } as any, ectx, state); + expect(state.agentRunLastStreamPartAt).toBe(5000); + expect(state.decodeStartedAt).toBe(0); + }); + it('includes reasoning tokens in the decode rate', async () => { const state = createMinimalState(); const ectx = createEctx(); @@ -179,16 +236,39 @@ describe('tokens/sec decode-window calculation', () => { const state = createMinimalState({ tokensPerSec: 42, decodeStartedAt: 1000 }); const ectx = createEctx(); + vi.setSystemTime(1000); + await dispatchEvent({ type: 'agent_start' } as any, ectx, state); + expect(state.tokensPerSec).toBe(0); + expect(state.decodeStartedAt).toBe(0); + expect(state.agentRunStartedAt).toBe(1000); + expect(state.agentRunLastStreamPartAt).toBe(1000); + expect(state.lastAgentRunDurationMs).toBeUndefined(); + expect(state.lastAgentRunEndedAt).toBeUndefined(); + expect(state.lastAgentRunEndReason).toBeUndefined(); + + state.tokensPerSec = 42; + state.decodeStartedAt = 1500; + // agent_end keeps the reading visible (so short turns stay readable) but // clears the in-flight decode window. + vi.setSystemTime(4000); await dispatchEvent({ type: 'agent_end', reason: 'done' } as any, ectx, state); expect(state.tokensPerSec).toBe(42); expect(state.decodeStartedAt).toBe(0); + expect(state.agentRunStartedAt).toBeUndefined(); + expect(state.agentRunLastStreamPartAt).toBeUndefined(); + expect(state.lastAgentRunDurationMs).toBe(3000); + expect(state.lastAgentRunEndedAt).toBe(4000); + expect(state.lastAgentRunEndReason).toBe('done'); // The next turn's agent_start clears it for a fresh measurement. + vi.setSystemTime(5000); await dispatchEvent({ type: 'agent_start' } as any, ectx, state); expect(state.tokensPerSec).toBe(0); expect(state.decodeStartedAt).toBe(0); + expect(state.agentRunStartedAt).toBe(5000); + expect(state.lastAgentRunDurationMs).toBeUndefined(); + expect(state.lastAgentRunEndedAt).toBeUndefined(); }); it('does not compute a rate for a tool-only step with no streamed content', async () => { @@ -254,4 +334,22 @@ describe('tokens/sec decode-window calculation', () => { // With the decode window never opened, tok/s should remain 0 — NOT 55,000. expect(state.tokensPerSec).toBe(0); }); + + it('records aborted and error end reasons for run summaries', async () => { + const ectx = createEctx(); + + vi.setSystemTime(1000); + const abortedState = createMinimalState({ agentRunStartedAt: 1000 }); + vi.setSystemTime(4000); + await dispatchEvent({ type: 'agent_end', reason: 'aborted' } as any, ectx, abortedState); + expect(abortedState.lastAgentRunDurationMs).toBe(3000); + expect(abortedState.lastAgentRunEndReason).toBe('aborted'); + + vi.setSystemTime(5000); + const errorState = createMinimalState({ agentRunStartedAt: 5000 }); + vi.setSystemTime(9000); + await dispatchEvent({ type: 'agent_end', reason: 'error' } as any, ectx, errorState); + expect(errorState.lastAgentRunDurationMs).toBe(4000); + expect(errorState.lastAgentRunEndReason).toBe('error'); + }); }); diff --git a/mastracode/src/tui/command-dispatch.ts b/mastracode/src/tui/command-dispatch.ts index ec1e070dc928..89d57c6bb414 100644 --- a/mastracode/src/tui/command-dispatch.ts +++ b/mastracode/src/tui/command-dispatch.ts @@ -8,6 +8,7 @@ import { handleHelpCommand, handleCostCommand, handleYoloCommand, + handleVoiceCommand, handleThinkCommand, handlePermissionsCommand, handleNameCommand, @@ -39,6 +40,7 @@ import { handleUpdateCommand, handleMemoryGatewayCommand, handleApiKeysCommand, + handlePluginsCommand, handleFeedbackCommand, handleObservabilityCommand, handleGithubCommand, @@ -183,6 +185,9 @@ export async function dispatchSlashCommand( case 'yolo': handleYoloCommand(ctx); return true; + case 'voice': + await handleVoiceCommand(ctx, args); + return true; case 'settings': await handleSettingsCommand(ctx); return true; @@ -240,6 +245,9 @@ export async function dispatchSlashCommand( case 'api-keys': await handleApiKeysCommand(buildCtx()); return true; + case 'plugins': + await handlePluginsCommand(buildCtx(), args); + return true; case 'feedback': await handleFeedbackCommand(buildCtx(), args); return true; diff --git a/mastracode/src/tui/commands/__tests__/plugins.test.ts b/mastracode/src/tui/commands/__tests__/plugins.test.ts new file mode 100644 index 000000000000..ea91a232dfc4 --- /dev/null +++ b/mastracode/src/tui/commands/__tests__/plugins.test.ts @@ -0,0 +1,449 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { handlePluginsCommand } from '../plugins.js'; + +vi.mock('@earendil-works/pi-tui', () => { + class Box { + children: any[] = []; + constructor(..._args: any[]) {} + addChild(child: any) { + this.children.push(child); + } + } + class Text { + constructor(public text: string) {} + } + class Spacer { + constructor(public size: number) {} + } + class SelectList { + onSelect?: (item: any) => void; + onCancel?: () => void; + constructor(public items: any[]) {} + } + return { Box, Text, Spacer, SelectList }; +}); + +const overlay = vi.hoisted(() => ({ showModalOverlay: vi.fn() })); +vi.mock('../../overlay.js', () => ({ showModalOverlay: overlay.showModalOverlay })); + +const modal = vi.hoisted(() => ({ askModalQuestion: vi.fn() })); +vi.mock('../../modal-question.js', () => ({ askModalQuestion: modal.askModalQuestion })); +vi.mock('../../prompt-api-key.js', () => ({ promptForApiKeyIfNeeded: vi.fn(async () => undefined) })); +vi.mock('../../components/model-selector.js', () => ({ + ModelSelectorComponent: class { + focused = false; + constructor(public options: any) {} + }, +})); +vi.mock('../../theme.js', () => ({ + getSelectListTheme: () => ({}), + theme: { + bg: (_name: string, text: string) => text, + fg: (_name: string, text: string) => text, + bold: (text: string) => text, + }, +})); + +describe('handlePluginsCommand', () => { + beforeEach(() => { + overlay.showModalOverlay.mockClear(); + modal.askModalQuestion.mockReset(); + }); + + it('shows setup guidance when plugin manager is missing', async () => { + const ctx = { showInfo: vi.fn() } as any; + + await handlePluginsCommand(ctx); + + expect(ctx.showInfo).toHaveBeenCalledWith('Plugin system not initialized.'); + }); + + it('opens the plugin list with install item and plugin metadata', async () => { + const pluginManager = { + reload: vi.fn(async () => undefined), + getLoadedPlugins: vi.fn(() => [ + { + id: 'acme.foo', + name: 'Foo Tools', + scope: 'project', + source: 'local', + specifier: '../foo', + enabled: true, + status: 'active', + path: '../foo', + entry: 'src/index.ts', + tools: {}, + toolNames: ['foo_search'], + }, + ]), + }; + const ctx = { pluginManager, state: { ui: { hideOverlay: vi.fn() } } } as any; + + await handlePluginsCommand(ctx); + + expect(pluginManager.reload).toHaveBeenCalledTimes(1); + expect(overlay.showModalOverlay).toHaveBeenCalledTimes(1); + const container = overlay.showModalOverlay.mock.calls[0]?.[1] as any; + const list = container.children.find((child: any) => Array.isArray(child.items)); + expect(list.items.map((item: any) => item.value)).toEqual(['__install__', 'project:acme.foo']); + expect(list.items[1].label).toContain('Foo Tools'); + expect(list.items[1].label).toContain('acme.foo'); + expect(list.items[1].label).toContain('project'); + expect(list.items[1].label).toContain('active'); + }); + + it('configures plugin string and boolean settings from the detail view', async () => { + const plugin = { + id: 'acme.foo', + name: 'Foo Tools', + scope: 'project', + source: 'local', + specifier: '../foo', + enabled: true, + status: 'active', + path: '../foo', + entry: 'src/index.ts', + tools: {}, + toolNames: ['foo_search'], + configSchema: { + answerModel: { type: 'model', label: 'Answer model', default: 'default-model' }, + enabled: { type: 'boolean', label: 'Enabled', default: true }, + prompt: { type: 'string', label: 'Prompt', default: 'hello' }, + }, + configValues: { answerModel: 'default-model', enabled: true, prompt: 'hello' }, + }; + const pluginManager = { + reload: vi.fn(async () => undefined), + getLoadedPlugins: vi.fn(() => [plugin]), + setConfigValue: vi.fn(async () => undefined), + }; + modal.askModalQuestion.mockResolvedValueOnce('Prompt').mockResolvedValueOnce('updated prompt'); + const ctx = { + pluginManager, + authStorage: {}, + state: { + controller: { listAvailableModels: vi.fn(async () => [{ id: 'model-a', name: 'Model A' }]) }, + ui: { hideOverlay: vi.fn() }, + }, + showInfo: vi.fn(), + } as any; + + await handlePluginsCommand(ctx, ['acme.foo']); + const detail = overlay.showModalOverlay.mock.calls[0]?.[1] as any; + const actions = detail.children.find((child: any) => Array.isArray(child.items)); + expect(actions.items.map((item: any) => item.value)).toContain('configure'); + actions.onSelect({ value: 'configure' }); + await new Promise(resolve => setImmediate(resolve)); + + expect(pluginManager.setConfigValue).toHaveBeenCalledWith('acme.foo', 'project', 'prompt', 'updated prompt'); + expect(ctx.showInfo).toHaveBeenCalledWith('Updated plugin setting prompt.'); + }); + + it('returns from plugin config selection to plugin detail on escape', async () => { + const plugin = { + id: 'acme.foo', + name: 'Foo Tools', + scope: 'project', + source: 'local', + specifier: '../foo', + enabled: true, + status: 'active', + path: '../foo', + entry: 'src/index.ts', + tools: {}, + toolNames: ['foo_search'], + configSchema: { answerModel: { type: 'model', label: 'Answer model' } }, + configValues: { answerModel: 'openai/broken' }, + }; + const pluginManager = { + reload: vi.fn(async () => undefined), + getLoadedPlugins: vi.fn(() => [plugin]), + setConfigValue: vi.fn(async () => undefined), + }; + modal.askModalQuestion.mockResolvedValueOnce(null); + const ctx = { + pluginManager, + state: { + controller: { listAvailableModels: vi.fn(async () => [{ id: 'model-a', name: 'Model A' }]) }, + ui: { hideOverlay: vi.fn() }, + }, + showInfo: vi.fn(), + } as any; + + await handlePluginsCommand(ctx, ['acme.foo']); + const detail = overlay.showModalOverlay.mock.calls[0]?.[1] as any; + const actions = detail.children.find((child: any) => Array.isArray(child.items)); + actions.onSelect({ value: 'configure' }); + await new Promise(resolve => setImmediate(resolve)); + + expect(overlay.showModalOverlay).toHaveBeenCalledTimes(2); + expect(pluginManager.setConfigValue).not.toHaveBeenCalled(); + }); + + it('returns from a nested plugin config value view to config selection on escape', async () => { + const plugin = { + id: 'acme.foo', + name: 'Foo Tools', + scope: 'project', + source: 'local', + specifier: '../foo', + enabled: true, + status: 'active', + path: '../foo', + entry: 'src/index.ts', + tools: {}, + toolNames: ['foo_search'], + configSchema: { answerModel: { type: 'model', label: 'Answer model' } }, + configValues: { answerModel: 'openai/broken' }, + }; + const pluginManager = { + reload: vi.fn(async () => undefined), + getLoadedPlugins: vi.fn(() => [plugin]), + setConfigValue: vi.fn(async () => undefined), + }; + modal.askModalQuestion + .mockResolvedValueOnce('Answer model') + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + const ctx = { + pluginManager, + state: { + controller: { listAvailableModels: vi.fn(async () => [{ id: 'model-a', name: 'Model A' }]) }, + ui: { hideOverlay: vi.fn() }, + }, + showInfo: vi.fn(), + } as any; + + await handlePluginsCommand(ctx, ['acme.foo']); + const detail = overlay.showModalOverlay.mock.calls[0]?.[1] as any; + const actions = detail.children.find((child: any) => Array.isArray(child.items)); + actions.onSelect({ value: 'configure' }); + await new Promise(resolve => setImmediate(resolve)); + await new Promise(resolve => setImmediate(resolve)); + + expect(modal.askModalQuestion).toHaveBeenNthCalledWith( + 3, + ctx.state.ui, + expect.objectContaining({ question: 'Configure Foo Tools:' }), + ); + expect(pluginManager.setConfigValue).not.toHaveBeenCalled(); + }); + + it('clears plugin model settings to inherit the parent model', async () => { + const plugin = { + id: 'acme.foo', + name: 'Foo Tools', + scope: 'project', + source: 'local', + specifier: '../foo', + enabled: true, + status: 'active', + path: '../foo', + entry: 'src/index.ts', + tools: {}, + toolNames: ['foo_search'], + configSchema: { + answerModel: { + type: 'model', + label: 'Answer model', + description: 'Model mastra_expert uses to answer questions against the Alexandria repo.', + }, + }, + configValues: { answerModel: 'openai/broken' }, + }; + const pluginManager = { + reload: vi.fn(async () => undefined), + getLoadedPlugins: vi.fn(() => [plugin]), + setConfigValue: vi.fn(async () => undefined), + }; + modal.askModalQuestion.mockResolvedValueOnce('Answer model').mockResolvedValueOnce('Inherit parent model'); + const ctx = { + pluginManager, + authStorage: {}, + state: { + controller: { listAvailableModels: vi.fn(async () => [{ id: 'model-a', name: 'Model A' }]) }, + ui: { hideOverlay: vi.fn() }, + }, + showInfo: vi.fn(), + } as any; + + await handlePluginsCommand(ctx, ['acme.foo']); + const detail = overlay.showModalOverlay.mock.calls[0]?.[1] as any; + const actions = detail.children.find((child: any) => Array.isArray(child.items)); + actions.onSelect({ value: 'configure' }); + await new Promise(resolve => setImmediate(resolve)); + + expect(modal.askModalQuestion).toHaveBeenNthCalledWith( + 1, + ctx.state.ui, + expect.objectContaining({ allowCustomResponse: false }), + ); + expect(modal.askModalQuestion).toHaveBeenNthCalledWith( + 2, + ctx.state.ui, + expect.objectContaining({ + question: expect.stringContaining('Model mastra_expert uses to answer questions against the Alexandria repo.'), + allowCustomResponse: false, + }), + ); + expect(pluginManager.setConfigValue).toHaveBeenCalledWith('acme.foo', 'project', 'answerModel', ''); + expect(ctx.state.controller.listAvailableModels).not.toHaveBeenCalled(); + expect(ctx.showInfo).toHaveBeenCalledWith('Updated plugin setting answerModel.'); + }); + + it('asks for an entry path and retries local install when auto-detection fails', async () => { + const entryError = new Error('Could not find a plugin entry file. Tried: src/index.ts, index.ts'); + const pluginManager = { + reload: vi.fn(async () => undefined), + getLoadedPlugins: vi.fn(() => []), + discoverLocal: vi.fn(() => []), + installLocal: vi.fn().mockRejectedValueOnce(entryError).mockResolvedValueOnce('acme.foo'), + }; + modal.askModalQuestion + .mockResolvedValueOnce('Local path') + .mockResolvedValueOnce('../foo') + .mockResolvedValueOnce('project') + .mockResolvedValueOnce('Install') + .mockResolvedValueOnce('plugin.ts'); + const ctx = { + pluginManager, + state: { ui: { hideOverlay: vi.fn() } }, + showInfo: vi.fn(), + showError: vi.fn(), + } as any; + + await handlePluginsCommand(ctx); + const container = overlay.showModalOverlay.mock.calls[0]?.[1] as any; + const list = container.children.find((child: any) => Array.isArray(child.items)); + list.onSelect({ value: '__install__' }); + await new Promise(resolve => setImmediate(resolve)); + + expect(pluginManager.installLocal).toHaveBeenNthCalledWith(1, '../foo', 'project'); + expect(pluginManager.installLocal).toHaveBeenNthCalledWith(2, '../foo', 'project', { entry: 'plugin.ts' }); + expect(modal.askModalQuestion).toHaveBeenLastCalledWith(ctx.state.ui, { + question: 'Could not auto-detect plugin entry. Entry file or directory path:', + allowCustomResponse: true, + }); + expect(ctx.showInfo).toHaveBeenCalledWith('Installed plugin acme.foo.'); + expect(ctx.showError).not.toHaveBeenCalled(); + }); + + it('offers discovered local source plugins when choosing a local path', async () => { + const discoveredPath = '/project/.mastracode/plugins/sources/local/foo'; + const pluginManager = { + reload: vi.fn(async () => undefined), + getLoadedPlugins: vi.fn(() => []), + discoverLocal: vi.fn(() => [{ name: 'foo', path: discoveredPath, entry: 'src/index.ts' }]), + installLocal: vi.fn().mockResolvedValueOnce('acme.foo'), + }; + modal.askModalQuestion + .mockResolvedValueOnce('Local path') + .mockResolvedValueOnce(discoveredPath) + .mockResolvedValueOnce('project') + .mockResolvedValueOnce('Install'); + const ctx = { + pluginManager, + state: { ui: { hideOverlay: vi.fn() } }, + showInfo: vi.fn(), + showError: vi.fn(), + } as any; + + await handlePluginsCommand(ctx); + const container = overlay.showModalOverlay.mock.calls[0]?.[1] as any; + const list = container.children.find((child: any) => Array.isArray(child.items)); + list.onSelect({ value: '__install__' }); + await new Promise(resolve => setImmediate(resolve)); + + expect(pluginManager.discoverLocal).toHaveBeenCalledWith('.'); + expect(modal.askModalQuestion).toHaveBeenNthCalledWith(2, ctx.state.ui, { + question: 'Local plugin path or discovered plugin:', + options: [{ label: discoveredPath, description: 'foo' }], + allowCustomResponse: true, + }); + expect(pluginManager.installLocal).toHaveBeenCalledWith(discoveredPath, 'project'); + }); + + it('offers nested discovered plugins when a local path is not itself a plugin', async () => { + const entryError = new Error('Could not find a plugin entry file. Tried: src/index.ts, index.ts'); + const discoveredPath = '/other/.mastracode/plugins/sources/local/foo'; + const pluginManager = { + reload: vi.fn(async () => undefined), + getLoadedPlugins: vi.fn(() => []), + discoverLocal: vi.fn((path: string) => + path === '../other-project' ? [{ name: 'foo', path: discoveredPath, entry: 'src/index.ts' }] : [], + ), + installLocal: vi.fn().mockRejectedValueOnce(entryError).mockResolvedValueOnce('acme.foo'), + }; + modal.askModalQuestion + .mockResolvedValueOnce('Local path') + .mockResolvedValueOnce('../other-project') + .mockResolvedValueOnce('project') + .mockResolvedValueOnce('Install') + .mockResolvedValueOnce(discoveredPath); + const ctx = { + pluginManager, + state: { ui: { hideOverlay: vi.fn() } }, + showInfo: vi.fn(), + showError: vi.fn(), + } as any; + + await handlePluginsCommand(ctx); + const container = overlay.showModalOverlay.mock.calls[0]?.[1] as any; + const list = container.children.find((child: any) => Array.isArray(child.items)); + list.onSelect({ value: '__install__' }); + await new Promise(resolve => setImmediate(resolve)); + + expect(pluginManager.discoverLocal).toHaveBeenCalledWith('../other-project'); + expect(modal.askModalQuestion).toHaveBeenLastCalledWith(ctx.state.ui, { + question: 'That path is not a plugin. Install discovered plugin:', + options: [{ label: discoveredPath, description: 'foo' }], + }); + expect(pluginManager.installLocal).toHaveBeenNthCalledWith(1, '../other-project', 'project'); + expect(pluginManager.installLocal).toHaveBeenNthCalledWith(2, discoveredPath, 'project'); + }); + + it('asks for an entry path and retries GitHub install when auto-detection fails', async () => { + const entryError = new Error('Could not find a plugin entry file. Tried: src/index.ts, index.ts'); + const pluginManager = { + reload: vi.fn(async () => undefined), + getLoadedPlugins: vi.fn(() => []), + discoverLocal: vi.fn(() => []), + installGithub: vi.fn().mockRejectedValueOnce(entryError).mockResolvedValueOnce('acme.foo'), + }; + modal.askModalQuestion + .mockResolvedValueOnce('GitHub URL') + .mockResolvedValueOnce('https://github.com/acme/foo') + .mockResolvedValueOnce('global') + .mockResolvedValueOnce('Install') + .mockResolvedValueOnce('plugin.ts'); + const ctx = { + pluginManager, + state: { ui: { hideOverlay: vi.fn() } }, + showInfo: vi.fn(), + showError: vi.fn(), + } as any; + + await handlePluginsCommand(ctx); + const container = overlay.showModalOverlay.mock.calls[0]?.[1] as any; + const list = container.children.find((child: any) => Array.isArray(child.items)); + list.onSelect({ value: '__install__' }); + await new Promise(resolve => setImmediate(resolve)); + + expect(modal.askModalQuestion).toHaveBeenNthCalledWith(4, ctx.state.ui, { + question: + 'Plugins run code inside Mastra Code and can access your workspace. GitHub plugins also auto-update from their repository, so only install plugins from sources you trust. Continue?', + options: [{ label: 'Install' }, { label: 'Cancel' }], + }); + expect(pluginManager.installGithub).toHaveBeenNthCalledWith(1, 'https://github.com/acme/foo', 'global'); + expect(pluginManager.installGithub).toHaveBeenNthCalledWith(2, 'https://github.com/acme/foo', 'global', { + entry: 'plugin.ts', + }); + expect(modal.askModalQuestion).toHaveBeenLastCalledWith(ctx.state.ui, { + question: 'Could not auto-detect plugin entry. Entry file or directory path:', + allowCustomResponse: true, + }); + expect(ctx.showInfo).toHaveBeenCalledWith('Installed plugin acme.foo.'); + expect(ctx.showError).not.toHaveBeenCalled(); + }); +}); diff --git a/mastracode/src/tui/commands/__tests__/voice.test.ts b/mastracode/src/tui/commands/__tests__/voice.test.ts new file mode 100644 index 000000000000..bfba540bdd96 --- /dev/null +++ b/mastracode/src/tui/commands/__tests__/voice.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { SlashCommandContext } from '../types.js'; + +const voiceMocks = vi.hoisted(() => ({ + loadSettings: vi.fn(), + saveSettings: vi.fn(), + askModalQuestion: vi.fn(), + hasProviderCredential: vi.fn(() => true), + openMacSettings: vi.fn(async () => true), +})); + +vi.mock('../../../onboarding/settings.js', () => ({ + loadSettings: voiceMocks.loadSettings, + saveSettings: voiceMocks.saveSettings, +})); + +vi.mock('../../modal-question.js', () => ({ + askModalQuestion: voiceMocks.askModalQuestion, +})); + +vi.mock('../../voice/transcribe.js', () => ({ + hasProviderCredential: voiceMocks.hasProviderCredential, +})); + +vi.mock('../../voice/native/open-settings.js', () => ({ + openMacSettings: voiceMocks.openMacSettings, +})); + +import { handleVoiceCommand } from '../voice.js'; + +type FakeController = { + isEnabled: ReturnType<typeof vi.fn>; + toggle: ReturnType<typeof vi.fn>; + reconfigure: ReturnType<typeof vi.fn>; + verifyReady: ReturnType<typeof vi.fn>; + permissionGuidance: ReturnType<typeof vi.fn>; +}; + +function createContext(opts: { controller?: FakeController; storedEnabled?: boolean } = {}) { + const settings = { + voice: { enabled: opts.storedEnabled ?? false, engine: 'cloud', provider: 'openai', model: 'whisper-1' }, + }; + voiceMocks.loadSettings.mockReturnValue(settings); + const showError = vi.fn(); + const showInfo = vi.fn(); + const ctx = { + state: { voiceController: opts.controller, ui: {} }, + showError, + showInfo, + } as unknown as SlashCommandContext; + return { ctx, settings, showError, showInfo }; +} + +function makeController(enabled: boolean): FakeController { + let on = enabled; + return { + isEnabled: vi.fn(() => on), + toggle: vi.fn(() => { + on = !on; + return on; + }), + reconfigure: vi.fn(), + verifyReady: vi.fn(async () => null), + // Cloud controller in tests: no native permission guidance. + permissionGuidance: vi.fn(async () => null), + }; +} + +describe('handleVoiceCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + voiceMocks.hasProviderCredential.mockReturnValue(true); + }); + + it('shows an error when no controller is available', async () => { + const { ctx, showError } = createContext({ controller: undefined }); + await handleVoiceCommand(ctx); + expect(showError).toHaveBeenCalledWith('Voice input is unavailable.'); + expect(voiceMocks.saveSettings).not.toHaveBeenCalled(); + }); + + it('/voice on enables and persists', async () => { + const controller = makeController(false); + const { ctx, settings } = createContext({ controller, storedEnabled: false }); + await handleVoiceCommand(ctx, ['on']); + expect(controller.toggle).toHaveBeenCalled(); + expect(settings.voice.enabled).toBe(true); + expect(voiceMocks.saveSettings).toHaveBeenCalled(); + expect(controller.reconfigure).toHaveBeenCalled(); + }); + + it('/voice on offers to open Settings when the engine reports blocked permissions', async () => { + const controller = makeController(false); + controller.permissionGuidance.mockResolvedValue({ + state: 'blocked', + summary: 'Microphone access is turned off for your terminal.', + steps: ['Open System Settings › Privacy & Security › Microphone.'], + settingsUrl: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone', + actionLabel: 'Open Microphone settings', + }); + voiceMocks.askModalQuestion.mockResolvedValue('Open Microphone settings'); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + try { + const { ctx } = createContext({ controller, storedEnabled: false }); + await handleVoiceCommand(ctx, ['on']); + expect(controller.permissionGuidance).toHaveBeenCalled(); + expect(voiceMocks.openMacSettings).toHaveBeenCalledWith( + 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone', + ); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + } + }); + + it('/voice on explains the first-run prompt without a modal when permission will prompt', async () => { + const controller = makeController(false); + controller.permissionGuidance.mockResolvedValue({ + state: 'will-prompt', + summary: 'macOS will prompt the first time you dictate.', + steps: ['Hold space and speak.', 'Click Allow when asked.'], + }); + const { ctx, showInfo } = createContext({ controller, storedEnabled: false }); + await handleVoiceCommand(ctx, ['on']); + expect(voiceMocks.askModalQuestion).not.toHaveBeenCalled(); + expect(showInfo).toHaveBeenCalledWith(expect.stringContaining('macOS will prompt')); + }); + + it('/voice off disables and persists', async () => { + const controller = makeController(true); + const { ctx, settings } = createContext({ controller, storedEnabled: true }); + await handleVoiceCommand(ctx, ['off']); + expect(settings.voice.enabled).toBe(false); + expect(voiceMocks.saveSettings).toHaveBeenCalled(); + }); + + it('/voice on is a no-op when already on', async () => { + const controller = makeController(true); + const { ctx, showInfo } = createContext({ controller, storedEnabled: true }); + await handleVoiceCommand(ctx, ['on']); + expect(controller.toggle).not.toHaveBeenCalled(); + expect(showInfo).toHaveBeenCalledWith('Voice input is already on.'); + }); + + it('/voice status reports engine and provider', async () => { + const controller = makeController(true); + const { ctx, showInfo } = createContext({ controller, storedEnabled: true }); + await handleVoiceCommand(ctx, ['status']); + expect(showInfo).toHaveBeenCalledTimes(1); + const text = showInfo.mock.calls[0][0] as string; + expect(text).toContain('Voice input: on'); + expect(text).toContain('Engine:'); + expect(text).toContain('openai/whisper-1'); + // Multi-line, labelled layout rather than one long sentence. + expect(text.split('\n').length).toBeGreaterThan(1); + }); + + it('menu: choosing a provider persists it and resets the model to that default', async () => { + const controller = makeController(false); + const { ctx, settings } = createContext({ controller }); + voiceMocks.askModalQuestion.mockResolvedValueOnce('Provider').mockResolvedValueOnce('groq'); + + await handleVoiceCommand(ctx); + + expect(settings.voice.provider).toBe('groq'); + expect(settings.voice.model).toBe('whisper-large-v3-turbo'); + expect(controller.reconfigure).toHaveBeenCalled(); + }); + + it('menu: choosing macOS engine off-darwin surfaces an error', async () => { + const original = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + try { + const controller = makeController(false); + const { ctx, showError } = createContext({ controller }); + voiceMocks.askModalQuestion.mockResolvedValueOnce('Engine').mockResolvedValueOnce('macOS native (macOS only)'); + + await handleVoiceCommand(ctx); + + expect(showError).toHaveBeenCalledWith( + 'macOS native STT is only available on macOS. Pick a cloud provider instead.', + ); + } finally { + Object.defineProperty(process, 'platform', { value: original, configurable: true }); + } + }); + + it('swallows persistence errors so the in-session change still applies', async () => { + const controller = makeController(false); + const { ctx } = createContext({ controller, storedEnabled: false }); + voiceMocks.saveSettings.mockImplementation(() => { + throw new Error('disk full'); + }); + await expect(handleVoiceCommand(ctx, ['on'])).resolves.toBeUndefined(); + expect(controller.toggle).toHaveBeenCalled(); + }); +}); diff --git a/mastracode/src/tui/commands/index.ts b/mastracode/src/tui/commands/index.ts index c53d75edfb9b..b3a4e028732e 100644 --- a/mastracode/src/tui/commands/index.ts +++ b/mastracode/src/tui/commands/index.ts @@ -3,6 +3,7 @@ export type { SlashCommandContext } from './types.js'; export { handleHelpCommand } from './help.js'; export { handleCostCommand } from './cost.js'; export { handleYoloCommand } from './yolo.js'; +export { handleVoiceCommand } from './voice.js'; export { handleThinkCommand } from './think.js'; export { handlePermissionsCommand } from './permissions.js'; export { handleNameCommand } from './name.js'; @@ -33,6 +34,7 @@ export { handleThemeCommand } from './theme.js'; export { handleUpdateCommand } from './update.js'; export { handleMemoryGatewayCommand } from './memory-gateway.js'; export { handleApiKeysCommand } from './api-keys.js'; +export { handlePluginsCommand } from './plugins.js'; export { handleFeedbackCommand } from './feedback.js'; export { handleObservabilityCommand } from './observability.js'; export { handleGithubCommand } from './github.js'; diff --git a/mastracode/src/tui/commands/plugins.ts b/mastracode/src/tui/commands/plugins.ts new file mode 100644 index 000000000000..a180839b1b40 --- /dev/null +++ b/mastracode/src/tui/commands/plugins.ts @@ -0,0 +1,404 @@ +import { Box, SelectList, Spacer, Text } from '@earendil-works/pi-tui'; +import type { SelectItem } from '@earendil-works/pi-tui'; + +import type { MastraCodePluginConfigOption, MastraCodePluginConfigValue } from '../../plugin.js'; +import type { LoadedPlugin, PluginScope } from '../../plugins/types.js'; +import { ModelSelectorComponent } from '../components/model-selector.js'; +import type { ModelItem } from '../components/model-selector.js'; +import { askModalQuestion } from '../modal-question.js'; +import { showModalOverlay } from '../overlay.js'; +import { promptForApiKeyIfNeeded } from '../prompt-api-key.js'; +import { getSelectListTheme, theme } from '../theme.js'; +import type { SlashCommandContext } from './types.js'; + +const INSTALL_VALUE = '__install__'; +const BACK_VALUE = '__back__'; + +export async function handlePluginsCommand(ctx: SlashCommandContext, args: string[] = []): Promise<void> { + if (!ctx.pluginManager) { + ctx.showInfo('Plugin system not initialized.'); + return; + } + + await ctx.pluginManager.reload(); + const pluginId = args[0]; + if (pluginId) { + const plugins = ctx.pluginManager.getLoadedPlugins(); + const plugin = plugins.find( + candidate => candidate.id === pluginId || `${candidate.scope}:${candidate.id}` === pluginId, + ); + if (!plugin) { + ctx.showError(`Plugin not found: ${pluginId}`); + return; + } + showPluginDetail(ctx, plugin); + return; + } + + showPluginsList(ctx); +} + +function pluginStatus(plugin: LoadedPlugin): string { + if (plugin.status === 'active') return theme.fg('success', 'active'); + if (plugin.status === 'inactive') return theme.fg('dim', 'inactive'); + if (plugin.status === 'blocked') return theme.fg('warning', 'blocked'); + if (plugin.status === 'conflicted') return theme.fg('warning', 'conflicted'); + return theme.fg('error', 'load failed'); +} + +function pluginLabel(plugin: LoadedPlugin): string { + const name = plugin.name ? `${plugin.name} ` : ''; + return ` ${name}${theme.fg('dim', `(${plugin.id})`)} ${theme.fg('dim', plugin.scope)} ${pluginStatus(plugin)}`; +} + +function buildPluginItems(plugins: LoadedPlugin[]): SelectItem[] { + const project = plugins.filter(plugin => plugin.scope === 'project'); + const global = plugins.filter(plugin => plugin.scope === 'global'); + return [ + { value: INSTALL_VALUE, label: ' Install new plugin' }, + ...project.map(plugin => ({ value: `project:${plugin.id}`, label: pluginLabel(plugin) })), + ...global.map(plugin => ({ value: `global:${plugin.id}`, label: pluginLabel(plugin) })), + ]; +} + +function showPluginsList(ctx: SlashCommandContext): void { + const plugins = ctx.pluginManager?.getLoadedPlugins() ?? []; + const items = buildPluginItems(plugins); + const container = new Box(4, 2, text => theme.bg('overlayBg', text)); + container.addChild(new Text(theme.bold(theme.fg('accent', 'Plugins')), 0, 0)); + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg('dim', 'Scaffold with: mastracode plugin scaffold <dir>'), 0, 0)); + container.addChild(new Spacer(1)); + + const list = new SelectList(items, Math.min(items.length, 15), getSelectListTheme()); + list.onSelect = item => { + if (item.value === INSTALL_VALUE) { + ctx.state.ui.hideOverlay(); + void installPluginFlow(ctx); + return; + } + const [scope, id] = item.value.split(':', 2) as [PluginScope, string]; + const plugin = plugins.find(candidate => candidate.scope === scope && candidate.id === id); + if (plugin) { + ctx.state.ui.hideOverlay(); + showPluginDetail(ctx, plugin); + } + }; + list.onCancel = () => ctx.state.ui.hideOverlay(); + + container.addChild(list); + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg('dim', '↑↓ navigate · Enter select · Esc close'), 0, 0)); + const modal = container as Box & { handleInput: (data: string) => void }; + modal.handleInput = (data: string) => list.handleInput(data); + showModalOverlay(ctx.state.ui, modal, { maxHeight: '80%' }); +} + +function reportPluginMutationError(ctx: SlashCommandContext, action: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + ctx.showError(`${action} failed: ${message}`); +} + +function showPluginDetail(ctx: SlashCommandContext, plugin: LoadedPlugin): void { + const container = new Box(4, 2, text => theme.bg('overlayBg', text)); + container.addChild(new Text(theme.bold(theme.fg('accent', plugin.name ?? plugin.id)), 0, 0)); + container.addChild(new Spacer(1)); + container.addChild(new Text(`id: ${plugin.id}`, 0, 0)); + container.addChild(new Text(`scope: ${plugin.scope}`, 0, 0)); + container.addChild(new Text(`source: ${plugin.source} ${plugin.specifier}`, 0, 0)); + container.addChild(new Text(`status: ${plugin.status}`, 0, 0)); + if (plugin.version) container.addChild(new Text(`version: ${plugin.version}`, 0, 0)); + if (plugin.description) container.addChild(new Text(`description: ${plugin.description}`, 0, 0)); + container.addChild(new Text(`tools: ${plugin.toolNames.length ? plugin.toolNames.join(', ') : '(none)'}`, 0, 0)); + const configEntries = Object.entries(plugin.configSchema ?? {}); + if (configEntries.length) { + container.addChild(new Text(`config: ${configEntries.map(([key]) => key).join(', ')}`, 0, 0)); + } + if (plugin.error) container.addChild(new Text(theme.fg('error', `error: ${plugin.error}`), 0, 0)); + if (plugin.status === 'blocked') + container.addChild(new Text(theme.fg('warning', 'blocked by plugins.json disabledPlugins'), 0, 0)); + if (plugin.conflicts?.length) + container.addChild(new Text(theme.fg('warning', `conflicts: ${plugin.conflicts.join(', ')}`), 0, 0)); + container.addChild(new Spacer(1)); + + const actionLabel = plugin.enabled ? 'Deactivate' : 'Activate'; + const actionItems: SelectItem[] = [ + ...(configEntries.length && plugin.status !== 'blocked' ? [{ value: 'configure', label: ' Configure' }] : []), + ...(plugin.status === 'blocked' ? [] : [{ value: 'toggle', label: ` ${actionLabel}` }]), + { value: 'uninstall', label: ' Uninstall' }, + { value: BACK_VALUE, label: ' Back' }, + ]; + const actions = new SelectList(actionItems, actionItems.length, getSelectListTheme()); + actions.onSelect = item => { + if (!ctx.pluginManager) return; + if (item.value === BACK_VALUE) { + ctx.state.ui.hideOverlay(); + showPluginsList(ctx); + return; + } + if (item.value === 'configure') { + ctx.state.ui.hideOverlay(); + void configurePluginFlow(ctx, plugin); + return; + } + if (item.value === 'toggle') { + void ctx.pluginManager + .setEnabled(plugin.id, plugin.scope, !plugin.enabled) + .then(() => { + ctx.state.ui.hideOverlay(); + showPluginsList(ctx); + }) + .catch(error => reportPluginMutationError(ctx, actionLabel, error)); + return; + } + if (item.value === 'uninstall') { + void ctx.pluginManager + .uninstall(plugin.id, plugin.scope) + .then(() => { + ctx.state.ui.hideOverlay(); + showPluginsList(ctx); + }) + .catch(error => reportPluginMutationError(ctx, 'Uninstall', error)); + } + }; + actions.onCancel = () => { + ctx.state.ui.hideOverlay(); + showPluginsList(ctx); + }; + container.addChild(actions); + const modal = container as Box & { handleInput: (data: string) => void }; + modal.handleInput = (data: string) => actions.handleInput(data); + showModalOverlay(ctx.state.ui, modal, { maxHeight: '80%' }); +} + +async function configurePluginFlow(ctx: SlashCommandContext, plugin: LoadedPlugin): Promise<void> { + if (!ctx.pluginManager || !plugin.configSchema) return; + const entries = Object.entries(plugin.configSchema); + const selected = await askModalQuestion(ctx.state.ui, { + question: `Configure ${plugin.name ?? plugin.id}:`, + options: entries.map(([key, option]) => ({ + label: option.label ?? key, + description: formatConfigDescription(key, option, plugin.configValues?.[key]), + })), + allowCustomResponse: false, + }); + if (!selected) { + showPluginDetail(ctx, plugin); + return; + } + + const entry = entries.find(([key, option]) => selected === (option.label ?? key)); + if (!entry) { + showPluginDetail(ctx, plugin); + return; + } + const [key, option] = entry; + const value = await askPluginConfigValue(ctx, plugin, key, option); + if (value === undefined) { + await configurePluginFlow(ctx, plugin); + return; + } + try { + await ctx.pluginManager.setConfigValue(plugin.id, plugin.scope, key, value); + ctx.showInfo(`Updated plugin setting ${key}.`); + } catch (error) { + reportPluginMutationError(ctx, `Update setting ${key}`, error); + } +} + +function formatConfigDescription( + key: string, + option: MastraCodePluginConfigOption, + value: MastraCodePluginConfigValue, +): string { + const current = value === undefined ? '(unset)' : String(value); + return `${option.type} · ${option.description ?? key} · current: ${current}`; +} + +function formatConfigValueQuestion(key: string, option: MastraCodePluginConfigOption): string { + const label = option.label ?? key; + return option.description ? `${label}\n${theme.fg('dim', option.description)}` : label; +} + +async function askPluginConfigValue( + ctx: SlashCommandContext, + plugin: LoadedPlugin, + key: string, + option: MastraCodePluginConfigOption, +): Promise<MastraCodePluginConfigValue> { + const current = plugin.configValues?.[key]; + if (option.type === 'boolean') { + const answer = await askModalQuestion(ctx.state.ui, { + question: formatConfigValueQuestion(key, option), + options: [ + { label: 'Use default', description: 'Clear this setting and use the plugin default' }, + { label: 'On', description: 'true' }, + { label: 'Off', description: 'false' }, + ], + allowCustomResponse: false, + }); + if (!answer) return undefined; + if (answer === 'Use default') return ''; + return answer === 'On'; + } + + if (option.type === 'model') { + return askPluginModelValue( + ctx, + formatConfigValueQuestion(key, option), + typeof current === 'string' ? current : undefined, + ); + } + + const answer = await askModalQuestion(ctx.state.ui, { + question: formatConfigValueQuestion(key, option), + defaultValue: typeof current === 'string' ? current : undefined, + allowCustomResponse: true, + allowEmptyInput: true, + }); + return answer ?? undefined; +} + +async function askPluginModelValue( + ctx: SlashCommandContext, + title: string, + currentModelId?: string, +): Promise<string | undefined> { + const action = await askModalQuestion(ctx.state.ui, { + question: title, + options: [ + { label: 'Select model', description: currentModelId ? `current: ${currentModelId}` : 'Choose a specific model' }, + { label: 'Inherit parent model', description: 'Clear this setting and use the active session model' }, + ], + allowCustomResponse: false, + }); + if (!action) return undefined; + if (action === 'Inherit parent model') return ''; + + const availableModels = await ctx.state.controller.listAvailableModels(); + if (availableModels.length === 0) return undefined; + + return new Promise<string | undefined>(resolve => { + const selector = new ModelSelectorComponent({ + tui: ctx.state.ui, + models: availableModels, + currentModelId, + title, + onSelect: async (model: ModelItem) => { + ctx.state.ui.hideOverlay(); + await promptForApiKeyIfNeeded(ctx.state.ui, model, ctx.authStorage); + resolve(model.id); + }, + onCancel: () => { + ctx.state.ui.hideOverlay(); + resolve(undefined); + }, + }); + + showModalOverlay(ctx.state.ui, selector, { maxHeight: '75%' }); + selector.focused = true; + }); +} + +async function installPluginFlow(ctx: SlashCommandContext): Promise<void> { + if (!ctx.pluginManager) return; + const source = await askModalQuestion(ctx.state.ui, { + question: 'Install plugin from:', + options: [{ label: 'Local path' }, { label: 'GitHub URL' }], + }); + if (!source) return; + + const specifier = + source === 'Local path' + ? await askLocalPluginPath(ctx) + : await askModalQuestion(ctx.state.ui, { + question: 'GitHub URL:', + allowCustomResponse: true, + }); + if (!specifier) return; + + const scopeAnswer = await askModalQuestion(ctx.state.ui, { + question: 'Install scope:', + options: [{ label: 'project' }, { label: 'global' }], + }); + if (scopeAnswer !== 'project' && scopeAnswer !== 'global') return; + + const installWarning = + source === 'GitHub URL' + ? 'Plugins run code inside Mastra Code and can access your workspace. GitHub plugins also auto-update from their repository, so only install plugins from sources you trust. Continue?' + : 'Plugins run code inside Mastra Code and can access your workspace. Continue?'; + const confirmed = await askModalQuestion(ctx.state.ui, { + question: installWarning, + options: [{ label: 'Install' }, { label: 'Cancel' }], + }); + if (confirmed !== 'Install') return; + + try { + const id = await installPluginWithOptionalEntryPrompt(ctx, source, specifier, scopeAnswer); + if (!id) return; + ctx.showInfo(`Installed plugin ${id}.`); + showPluginsList(ctx); + } catch (error) { + ctx.showError(error instanceof Error ? error.message : String(error)); + } +} + +async function askLocalPluginPath(ctx: SlashCommandContext): Promise<string | null> { + const discovered = ctx.pluginManager?.discoverLocal('.') ?? []; + return askModalQuestion(ctx.state.ui, { + question: discovered.length ? 'Local plugin path or discovered plugin:' : 'Local plugin path:', + ...(discovered.length + ? { + options: discovered.map(plugin => ({ label: plugin.path, description: plugin.name })), + allowCustomResponse: true, + } + : { allowCustomResponse: true }), + }); +} + +async function installPluginWithOptionalEntryPrompt( + ctx: SlashCommandContext, + source: string, + specifier: string, + scope: PluginScope, +): Promise<string | undefined> { + if (!ctx.pluginManager) return undefined; + const install = (entry?: string) => { + if (source === 'Local path') { + return entry + ? ctx.pluginManager!.installLocal(specifier, scope, { entry }) + : ctx.pluginManager!.installLocal(specifier, scope); + } + return entry + ? ctx.pluginManager!.installGithub(specifier, scope, { entry }) + : ctx.pluginManager!.installGithub(specifier, scope); + }; + + try { + return await install(); + } catch (error) { + if (!isEntryDetectionError(error)) throw error; + if (source === 'Local path') { + const discovered = ctx.pluginManager.discoverLocal(specifier); + if (discovered.length > 0) { + const selected = await askModalQuestion(ctx.state.ui, { + question: 'That path is not a plugin. Install discovered plugin:', + options: discovered.map(plugin => ({ label: plugin.path, description: plugin.name })), + }); + if (!selected) return undefined; + return ctx.pluginManager.installLocal(selected, scope); + } + } + + const entry = await askModalQuestion(ctx.state.ui, { + question: 'Could not auto-detect plugin entry. Entry file or directory path:', + allowCustomResponse: true, + }); + if (!entry) return undefined; + return install(entry); + } +} + +function isEntryDetectionError(error: unknown): boolean { + return error instanceof Error && error.message.startsWith('Could not find a plugin entry file.'); +} diff --git a/mastracode/src/tui/commands/types.ts b/mastracode/src/tui/commands/types.ts index 30583fc553e0..695bc8b0f5fb 100644 --- a/mastracode/src/tui/commands/types.ts +++ b/mastracode/src/tui/commands/types.ts @@ -8,6 +8,7 @@ import type { MastraCodeAnalytics } from '../../analytics.js'; import type { AuthStorage } from '../../auth/storage.js'; import type { HookManager } from '../../hooks/index.js'; import type { McpManager } from '../../mcp/manager.js'; +import type { PluginManager } from '../../plugins/manager.js'; import type { SlashCommandMetadata } from '../../utils/slash-command-loader.js'; import type { TUIState } from '../state.js'; @@ -17,6 +18,7 @@ export interface SlashCommandContext { session: Session<any>; hookManager?: HookManager; mcpManager?: McpManager; + pluginManager?: PluginManager; analytics?: MastraCodeAnalytics; authStorage?: AuthStorage; customSlashCommands: SlashCommandMetadata[]; diff --git a/mastracode/src/tui/commands/voice.ts b/mastracode/src/tui/commands/voice.ts new file mode 100644 index 000000000000..779976cee1e6 --- /dev/null +++ b/mastracode/src/tui/commands/voice.ts @@ -0,0 +1,242 @@ +import { loadSettings, saveSettings } from '../../onboarding/settings.js'; +import type { VoiceEngine, VoiceSettings } from '../../onboarding/settings.js'; +import { askModalQuestion } from '../modal-question.js'; +import type { PermissionGuidance } from '../voice/engines/types.js'; +import { openMacSettings } from '../voice/native/open-settings.js'; +import { defaultModelForProvider, resolveSTTModel, sttModelsForProvider, sttProviders } from '../voice/stt-registry.js'; +import { hasProviderCredential } from '../voice/transcribe.js'; +import type { SlashCommandContext } from './types.js'; + +/** + * `/voice` — manage push-to-talk voice input. + * + * Subcommands: + * /voice Interactive menu (toggle / engine / provider / model / status) + * /voice on|off Quick toggle (back-compat) + * /voice status Print the current engine, provider/model, and readiness + */ +export async function handleVoiceCommand(ctx: SlashCommandContext, args: string[] = []): Promise<void> { + const controller = ctx.state.voiceController; + if (!controller) { + ctx.showError('Voice input is unavailable.'); + return; + } + + const arg = args[0]?.toLowerCase(); + if (arg === 'on' || arg === 'off') { + await setEnabled(ctx, arg === 'on'); + return; + } + if (arg === 'status') { + await showStatus(ctx); + return; + } + + await runMenu(ctx); +} + +/** Persist a settings patch and re-apply it to the live controller. */ +function applyVoiceSettings(ctx: SlashCommandContext, patch: Partial<VoiceSettings>): VoiceSettings { + const settings = loadSettings(); + settings.voice = { ...settings.voice, ...patch }; + try { + saveSettings(settings); + } catch { + // Persisting is best-effort; the in-session change still applies. + } + ctx.state.voiceController?.reconfigure(settings.voice); + return settings.voice; +} + +async function setEnabled(ctx: SlashCommandContext, enable: boolean): Promise<void> { + const controller = ctx.state.voiceController!; + const currentlyEnabled = controller.isEnabled(); + if (enable === currentlyEnabled) { + ctx.showInfo(`Voice input is already ${enable ? 'on' : 'off'}.`); + return; + } + const nowEnabled = controller.toggle(); + applyVoiceSettings(ctx, { enabled: nowEnabled }); + // After turning voice on, proactively walk the user through any permissions + // the active engine needs — don't make them discover the requirement by + // failing to dictate. + if (nowEnabled) await guidePermissions(ctx); +} + +/** + * Check the active engine's permission state and guide the user through fixing + * it. For macOS native this means offering to open the exact Privacy & Security + * pane when access is blocked, or explaining the first-run prompt when it isn't + * granted yet. No-op for engines that need no permissions (cloud). + */ +async function guidePermissions(ctx: SlashCommandContext): Promise<void> { + const controller = ctx.state.voiceController; + const guidance = await controller?.permissionGuidance(); + if (!guidance || guidance.state === 'ok') return; + await presentGuidance(ctx, guidance); +} + +async function presentGuidance(ctx: SlashCommandContext, guidance: PermissionGuidance): Promise<void> { + const lines = [guidance.summary, ...(guidance.steps ?? []).map((step, i) => ` ${i + 1}. ${step}`)]; + + // When we can jump straight to the right settings pane, offer to do it. + if (guidance.settingsUrl && process.platform === 'darwin') { + const open = guidance.actionLabel ?? 'Open System Settings'; + const choice = await askModalQuestion(ctx.state.ui, { + question: lines.join('\n'), + options: [ + { label: open, description: 'Opens the exact settings pane for you' }, + { label: 'Not now', description: "I'll do it later" }, + ], + }); + if (choice === open) { + const opened = await openMacSettings(guidance.settingsUrl); + ctx.showInfo( + opened + ? 'Opened System Settings. Turn on the toggle for your terminal, then fully quit and reopen it.' + : `Open this URL to fix it: ${guidance.settingsUrl}`, + ); + } + return; + } + + // No actionable deep link (e.g. will prompt on first use) — just explain. + ctx.showInfo(lines.join('\n')); +} + +function describeReadiness(voice: VoiceSettings): string { + if (voice.engine === 'macos-native') { + return process.platform === 'darwin' + ? 'macOS native (on-device). First use prompts for Speech Recognition + Microphone access.' + : 'macOS native is only available on macOS — switch to a cloud provider.'; + } + const entry = resolveSTTModel(voice.provider, voice.model); + const hasKey = hasProviderCredential(entry.provider); + const keyNote = hasKey ? 'API key found.' : `No API key for ${entry.provider} — add one via /api-keys.`; + return `cloud · ${entry.provider}/${entry.model} (${entry.label}). ${keyNote}`; +} + +/** Short engine label for the status header. */ +function engineLabel(voice: VoiceSettings): string { + if (voice.engine === 'macos-native') return 'macOS native (on-device)'; + const entry = resolveSTTModel(voice.provider, voice.model); + return `cloud · ${entry.provider}/${entry.model}`; +} + +async function showStatus(ctx: SlashCommandContext): Promise<void> { + const voice = loadSettings().voice; + const enabled = ctx.state.voiceController?.isEnabled() ? 'on' : 'off'; + + const lines = [`Voice input: ${enabled}`, ` Engine: ${engineLabel(voice)}`]; + + if (voice.engine === 'cloud') { + const entry = resolveSTTModel(voice.provider, voice.model); + lines.push(` API key: ${hasProviderCredential(entry.provider) ? 'found' : `missing — add one via /api-keys`}`); + } + + // Deeper async preflight (e.g. native permission/toolchain probe) when the + // engine supports it, so /voice status reports real readiness, not a static hint. + const problem = await ctx.state.voiceController?.verifyReady(); + if (problem) { + lines.push(` Status: ⚠ action needed`, ...problem.split('\n').map(l => ` ${l}`)); + } else if (voice.engine === 'macos-native') { + lines.push(` Status: ✓ ready — hold space to dictate (macOS may prompt on first use)`); + } else { + lines.push(` Status: ✓ ready — hold space to dictate`); + } + + ctx.showInfo(lines.join('\n')); +} + +async function runMenu(ctx: SlashCommandContext): Promise<void> { + const voice = loadSettings().voice; + const enabled = ctx.state.voiceController?.isEnabled() ?? voice.enabled; + + const choice = await askModalQuestion(ctx.state.ui, { + question: 'Voice input settings', + options: [ + { label: enabled ? 'Turn off' : 'Turn on', description: 'Toggle push-to-talk voice input' }, + { label: 'Engine', description: `Currently: ${voice.engine}` }, + { label: 'Provider', description: `Cloud provider (currently: ${voice.provider})` }, + { label: 'Model', description: `Cloud model (currently: ${voice.model ?? 'default'})` }, + { label: 'Status', description: 'Show engine, provider/model, and readiness' }, + ], + }); + if (!choice) return; + + switch (choice) { + case enabled ? 'Turn off' : 'Turn on': + await setEnabled(ctx, !enabled); + return; + case 'Engine': + await chooseEngine(ctx); + return; + case 'Provider': + await chooseProvider(ctx); + return; + case 'Model': + await chooseModel(ctx); + return; + case 'Status': + await showStatus(ctx); + return; + } +} + +async function chooseEngine(ctx: SlashCommandContext): Promise<void> { + const macOption = process.platform === 'darwin' ? 'macOS native (on-device)' : 'macOS native (macOS only)'; + const choice = await askModalQuestion(ctx.state.ui, { + question: 'Choose STT engine', + options: [ + { label: macOption, description: 'Free, offline, low-latency. Requires macOS.' }, + { label: 'Cloud provider', description: 'Use a cloud transcription provider.' }, + ], + }); + if (!choice) return; + + const engine: VoiceEngine = choice.startsWith('macOS') ? 'macos-native' : 'cloud'; + if (engine === 'macos-native' && process.platform !== 'darwin') { + ctx.showError('macOS native STT is only available on macOS. Pick a cloud provider instead.'); + return; + } + const voice = applyVoiceSettings(ctx, { engine }); + ctx.showInfo(`Voice engine set to ${engine}. ${describeReadiness(voice)}`); + // If they switched to the native engine while voice is on, guide permissions now. + if (engine === 'macos-native' && ctx.state.voiceController?.isEnabled()) { + await guidePermissions(ctx); + } +} + +async function chooseProvider(ctx: SlashCommandContext): Promise<void> { + const providers = sttProviders(); + const choice = await askModalQuestion(ctx.state.ui, { + question: 'Choose cloud STT provider', + options: providers.map(provider => { + const def = defaultModelForProvider(provider); + return { label: provider, description: def ? def.label : undefined }; + }), + }); + if (!choice) return; + + // Switching provider resets the model to that provider's default. + const def = defaultModelForProvider(choice); + const voice = applyVoiceSettings(ctx, { engine: 'cloud', provider: choice, model: def?.model }); + ctx.showInfo(`Voice provider set to ${choice}. ${describeReadiness(voice)}`); +} + +async function chooseModel(ctx: SlashCommandContext): Promise<void> { + const voice = loadSettings().voice; + const models = sttModelsForProvider(voice.provider); + if (models.length === 0) { + ctx.showError(`No STT models known for provider ${voice.provider}. Pick a provider first.`); + return; + } + const choice = await askModalQuestion(ctx.state.ui, { + question: `Choose model for ${voice.provider}`, + options: models.map(m => ({ label: m.model, description: m.label })), + }); + if (!choice) return; + + const updated = applyVoiceSettings(ctx, { engine: 'cloud', model: choice }); + ctx.showInfo(`Voice model set to ${updated.model}. ${describeReadiness(updated)}`); +} diff --git a/mastracode/src/tui/components/__tests__/custom-editor.test.ts b/mastracode/src/tui/components/__tests__/custom-editor.test.ts index e74bc1163b03..985e28e0310c 100644 --- a/mastracode/src/tui/components/__tests__/custom-editor.test.ts +++ b/mastracode/src/tui/components/__tests__/custom-editor.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ superHandleInput: vi.fn(), @@ -21,7 +21,10 @@ vi.mock('node:fs', () => ({ vi.mock('@earendil-works/pi-tui', () => { class MockEditor { - constructor(_tui: unknown, _theme: unknown) {} + protected tui: unknown; + constructor(_tui: unknown, _theme: unknown) { + this.tui = _tui; + } handleInput(data: string): void { mocks.superHandleInput(data); @@ -451,3 +454,241 @@ describe('CustomEditor image paste handling', () => { expect(mocks.superHandleInput).not.toHaveBeenCalled(); }); }); + +describe('CustomEditor voice push-to-talk', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Other describe blocks may leave a custom matchesKey implementation; + // reset it so plain spaces are not misread as another shortcut. + mocks.matchesKey.mockImplementation(() => false); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function makeVoiceHook(overrides: Partial<Record<string, any>> = {}) { + return { + isEnabled: vi.fn(() => true), + isRecording: vi.fn(() => false), + startRecording: vi.fn(), + stopRecording: vi.fn(), + ...overrides, + }; + } + + it('does not engage voice handling when voice input is disabled', () => { + const editor = new CustomEditor({} as any, {} as any); + const hook = makeVoiceHook({ isEnabled: vi.fn(() => false) }); + editor.voiceInput = hook; + + for (let i = 0; i < 6; i++) { + editor.handleInput(' '); + vi.advanceTimersByTime(80); + } + + expect(hook.startRecording).not.toHaveBeenCalled(); + }); + + it('types single space taps instantly without lag', () => { + const editor = new CustomEditor({} as any, {} as any); + const hook = makeVoiceHook(); + editor.voiceInput = hook; + + editor.handleInput(' '); + + // Space is passed straight through immediately — no deferral. + expect(mocks.superHandleInput).toHaveBeenCalledWith(' '); + expect(hook.startRecording).not.toHaveBeenCalled(); + }); + + it('does not trigger on slow, deliberate spaces', () => { + const editor = new CustomEditor({} as any, {} as any); + const hook = makeVoiceHook(); + editor.voiceInput = hook; + + for (let i = 0; i < 5; i++) { + editor.handleInput(' '); + vi.advanceTimersByTime(400); // slower than the repeat-gap threshold + } + + expect(hook.startRecording).not.toHaveBeenCalled(); + }); + + it('starts recording after a rapid space-repeat burst (held key)', () => { + const editor = new CustomEditor({} as any, {} as any); + const hook = makeVoiceHook(); + editor.voiceInput = hook; + + // Simulate terminal auto-repeat at ~84ms cadence. + editor.handleInput(' '); + vi.advanceTimersByTime(84); + editor.handleInput(' '); + vi.advanceTimersByTime(84); + editor.handleInput(' '); // third rapid space confirms a hold + + expect(hook.startRecording).toHaveBeenCalledTimes(1); + // The two literal spaces typed before detection are deleted via backspace. + const backspaces = mocks.superHandleInput.mock.calls.filter(c => c[0] === '\x7f'); + expect(backspaces.length).toBe(2); + }); + + it('stops recording once auto-repeat stops (key released)', () => { + const editor = new CustomEditor({} as any, {} as any); + const hook = makeVoiceHook(); + editor.voiceInput = hook; + + editor.handleInput(' '); + vi.advanceTimersByTime(84); + editor.handleInput(' '); + vi.advanceTimersByTime(84); + editor.handleInput(' '); // recording starts + vi.advanceTimersByTime(84); + editor.handleInput(' '); // repeat keeps it alive + + expect(hook.stopRecording).not.toHaveBeenCalled(); + + // Repeats stop; release idle window elapses. + vi.advanceTimersByTime(300); + + expect(hook.stopRecording).toHaveBeenCalledTimes(1); + }); + + it('inserts the transcript text and requests a render so it shows immediately', () => { + const requestRender = vi.fn(); + const editor = new CustomEditor({ requestRender } as any, {} as any); + let text = ''; + editor.getText = vi.fn(() => text); + mocks.editorSetText.mockImplementation((next: string) => { + text = next; + }); + + editor.insertVoiceTranscript('hello world'); + + expect(mocks.editorSetText).toHaveBeenCalledWith('hello world'); + expect(text).toBe('hello world'); + expect(requestRender).toHaveBeenCalled(); + }); + + it('separates dictated text from existing content with a leading space', () => { + const requestRender = vi.fn(); + const editor = new CustomEditor({ requestRender } as any, {} as any); + let text = 'foo'; + editor.getText = vi.fn(() => text); + mocks.editorSetText.mockImplementation((next: string) => { + text = next; + }); + + // Listening captures the cursor anchor (end of "foo") as the dictation point. + editor.setVoiceListening(true); + editor.insertVoiceTranscript('bar'); + + expect(text).toBe('foo bar'); + }); + + it('replaces the dictated run with each live partial instead of appending', () => { + const requestRender = vi.fn(); + const editor = new CustomEditor({ requestRender } as any, {} as any); + let text = 'note: '; + editor.getText = vi.fn(() => text); + mocks.editorSetText.mockImplementation((next: string) => { + text = next; + }); + + // Anchor dictation after the existing "note: " prefix. + editor.setVoiceListening(true); + + editor.replaceVoiceTranscript('hello'); + expect(text).toBe('note: hello'); + + // A fuller partial supersedes the previous one, keeping the base intact. + editor.replaceVoiceTranscript('hello world'); + expect(text).toBe('note: hello world'); + }); + + it('ignores a late partial transcript after the user resumes typing', () => { + const requestRender = vi.fn(); + const editor = new CustomEditor({ requestRender } as any, {} as any); + let text = ''; + editor.getText = vi.fn(() => text); + mocks.editorSetText.mockImplementation((next: string) => { + text = next; + }); + + editor.setVoiceListening(true); + editor.replaceVoiceTranscript('hello'); + expect(text).toBe('hello'); + + // User edits — the dictation session is no longer active. + mocks.matchesKey.mockReturnValue(false); + editor.handleInput('x'); + + // A stale partial that arrives afterward must not clobber the user's input. + editor.replaceVoiceTranscript('hello world'); + expect(text).not.toBe('hello world'); + }); + + it('drives a listening animation timer while recording', () => { + const requestRender = vi.fn(); + const editor = new CustomEditor({ requestRender } as any, {} as any); + + editor.setVoiceListening(true); + requestRender.mockClear(); + + // The pulse timer should tick and request renders on its own. + vi.advanceTimersByTime(360); + expect(requestRender).toHaveBeenCalled(); + + requestRender.mockClear(); + editor.setVoiceListening(false); + vi.advanceTimersByTime(360); + // After stopping, no further animation ticks occur. + expect(requestRender).toHaveBeenCalledTimes(1); // only the stop's own render + }); + + it('renders dictated text greyed-out and stops greying once edited', async () => { + const { theme } = await import('../../theme.js'); + const [r, g, b] = theme + .getTheme() + .muted.match(/\w\w/g)! + .map(h => parseInt(h, 16)); + const greySeq = `\x1b[38;2;${r};${g};${b}m`; + + const editor = new CustomEditor({ requestRender: vi.fn() } as any, {} as any); + let text = ''; + editor.getText = vi.fn(() => text); + editor.setText = vi.fn((t: string) => { + text = t; + }); + (editor as any).insertTextAtCursor = undefined; + + // Mimic a realistic pi-tui content line: the dictated text, an APC cursor + // marker, a reverse-video cursor at the end, and trailing box padding. + const realisticLine = () => ['────', `hello world\x1b_pi:c\x07\x1b[7m \x1b[0m `, '────']; + mocks.superRender.mockImplementation(realisticLine); + + editor.insertVoiceTranscript('hello world'); + const out = editor.render(40).join('\n'); + // The dictated run is greyed... + expect(out).toContain(`${greySeq}hello world`); + // ...while the cursor highlight and trailing padding are left intact. + expect(out).toContain('\x1b[7m \x1b[0m'); + + // A genuine keystroke marks the text as user-owned; greying stops. + mocks.matchesKey.mockReturnValue(false); + editor.handleInput('x'); + expect(editor.render(40).join('\n')).not.toContain(greySeq); + }); + + it('renders an animated soundwave bar while listening', () => { + const editor = new CustomEditor({ requestRender: vi.fn() } as any, {} as any); + editor.getText = vi.fn(() => 'hello'); + editor.getModeColor = vi.fn(() => '#16c858'); + + editor.setVoiceListening(true); + const output = editor.render(20).join('\n'); + const waveBars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + expect(waveBars.some(bar => output.includes(bar))).toBe(true); + }); +}); diff --git a/mastracode/src/tui/components/__tests__/idle-counter.test.ts b/mastracode/src/tui/components/__tests__/idle-counter.test.ts index b49a6def31d7..1ecc8d9730cb 100644 --- a/mastracode/src/tui/components/__tests__/idle-counter.test.ts +++ b/mastracode/src/tui/components/__tests__/idle-counter.test.ts @@ -1,35 +1,89 @@ import { describe, expect, it } from 'vitest'; -import { IdleCounterComponent, formatIdleDuration } from '../idle-counter.js'; - -describe('formatIdleDuration', () => { - it('formats idle time from whole minutes up through larger units', () => { - expect(formatIdleDuration(1)).toBe('1 minute'); - expect(formatIdleDuration(15)).toBe('15 minutes'); - expect(formatIdleDuration(60)).toBe('1 hour'); - expect(formatIdleDuration(96)).toBe('1 hour 36 minutes'); - expect(formatIdleDuration(24 * 60)).toBe('1 day'); - expect(formatIdleDuration(31 * 24 * 60)).toBe('1 month 1 day'); - expect(formatIdleDuration(365 * 24 * 60)).toBe('1 year'); +import { IdleCounterComponent, formatIdleStatusTiming, formatStatusDuration } from '../idle-counter.js'; + +describe('formatStatusDuration', () => { + it('formats active durations with seconds below an hour', () => { + expect(formatStatusDuration(1_000, { includeSeconds: true })).toBe('1s'); + expect(formatStatusDuration(59_000, { includeSeconds: true })).toBe('59s'); + expect(formatStatusDuration(61_000, { includeSeconds: true })).toBe('1m1s'); + expect(formatStatusDuration(59 * 60_000 + 59_000, { includeSeconds: true })).toBe('59m59s'); + }); + + it('formats hour and day durations without seconds', () => { + expect(formatStatusDuration(60 * 60_000, { includeSeconds: true })).toBe('1hr'); + expect(formatStatusDuration(61 * 60_000 + 1_000, { includeSeconds: true })).toBe('1hr1m'); + expect(formatStatusDuration(24 * 60 * 60_000 + 61 * 60_000, { includeSeconds: true })).toBe('1d1hr1m'); + }); + + it('floors idle durations to compact whole minutes', () => { + expect(formatStatusDuration(3 * 60_000 + 59_000)).toBe('3m'); + expect(formatStatusDuration(61 * 60_000)).toBe('1hr1m'); + expect(formatStatusDuration(24 * 60 * 60_000 + 61 * 60_000)).toBe('1d1hr1m'); + }); +}); + +describe('formatIdleStatusTiming', () => { + it('does not show active elapsed time while the agent is running', () => { + expect(formatIdleStatusTiming({ lastAgentRunDurationMs: undefined, lastAgentRunEndedAt: undefined }, 2_000)).toBe( + '', + ); + }); + + it('shows only idle time after one minute', () => { + const state = { + lastAgentRunDurationMs: 61 * 60_000, + lastAgentRunEndedAt: 1_000, + lastAgentRunEndReason: 'done' as const, + }; + + expect(formatIdleStatusTiming(state, 60_999)).toBe(''); + expect(formatIdleStatusTiming(state, 61_000)).toBe('1m idle'); + expect(formatIdleStatusTiming(state, 181_000)).toBe('3m idle'); + }); + + it('does not show completed activity labels above input', () => { + expect( + formatIdleStatusTiming( + { lastAgentRunDurationMs: 61_000, lastAgentRunEndedAt: 1_000, lastAgentRunEndReason: 'aborted' }, + 2_000, + ), + ).toBe(''); + expect( + formatIdleStatusTiming( + { lastAgentRunDurationMs: 61_000, lastAgentRunEndedAt: 1_000, lastAgentRunEndReason: 'error' }, + 61_000, + ), + ).toBe('1m idle'); + }); + + it('can show restored idle time without a known prior work duration', () => { + expect(formatIdleStatusTiming({ lastAgentRunEndedAt: 0 }, 3 * 60_000)).toBe('3m idle'); + }); + + it('omits timing when no timing state is present', () => { + expect(formatIdleStatusTiming({})).toBe(''); }); }); describe('IdleCounterComponent', () => { - it('reserves one stable line until one minute idle, then renders like a temporal-gap marker', () => { + it('reserves one stable line and renders only idle timing above input', () => { const component = new IdleCounterComponent(); expect(component.render(80)).toEqual(['']); - component.setIdleStartedAt(0, 59_999); + component.setTimingState( + { lastAgentRunDurationMs: 61_000, lastAgentRunEndedAt: 1_000, lastAgentRunEndReason: 'done' }, + 60_999, + ); expect(component.render(80)).toEqual(['']); - component.update(60_000); - expect(component.render(80).join('\n')).toContain('1 minute idle'); - expect(component.render(80).join('\n')).not.toContain('⏳'); - - component.update(96 * 60_000); - expect(component.render(80).join('\n')).toContain('1 hour 36 minutes idle'); + component.update(181_000); + const renderedWithIdle = component.render(80).join('\n'); + expect(renderedWithIdle).not.toContain('done in'); + expect(renderedWithIdle).not.toContain(' · '); + expect(renderedWithIdle).toContain('3m idle'); - component.setIdleStartedAt(undefined); + component.setTimingState(undefined); expect(component.render(80)).toEqual(['']); }); }); diff --git a/mastracode/src/tui/components/__tests__/subagent-execution.test.ts b/mastracode/src/tui/components/__tests__/subagent-execution.test.ts index 53000c2058ca..089a206a191f 100644 --- a/mastracode/src/tui/components/__tests__/subagent-execution.test.ts +++ b/mastracode/src/tui/components/__tests__/subagent-execution.test.ts @@ -66,6 +66,24 @@ describe('SubagentExecutionComponent', () => { expect(lines.some(l => l.includes('⋯'))).toBe(true); }); + it('honors custom labels, icons, and activity height', () => { + const comp = new SubagentExecutionComponent('alexandria', 'Answer question', mockTui, undefined, { + label: 'mastra', + maxActivityLines: 3, + icons: { running: '…', success: 'ok', error: 'bad' }, + }); + for (let i = 0; i < 5; i++) { + comp.addToolStart(`tool_${i}`, { value: `${i}` }); + } + const lines = renderPlain(comp); + const rendered = lines.join('\n'); + + expect(rendered).toContain('mastra alexandria'); + expect(rendered).toContain('… tool_4'); + expect(rendered).toContain('2 more above'); + expect(rendered).not.toContain('tool_0'); + }); + it('marks tool calls as completed', () => { const comp = new SubagentExecutionComponent('explore', 'Find usages', mockTui); comp.addToolStart('search_content', { pattern: 'foo' }); @@ -75,6 +93,15 @@ describe('SubagentExecutionComponent', () => { expect(lines.some(l => l.includes('✓') && l.includes('search_content'))).toBe(true); }); + it('uses available line width for long string args', () => { + const comp = new SubagentExecutionComponent('alexandria', 'Inspect architecture', mockTui); + comp.addToolStart('view', { path: '.sources/mastra/packages/core/src/agent/workflows/agent-execution-loop.ts' }); + const rendered = renderPlain(comp).join('\n'); + + expect(rendered).toContain('.sources/mastra/packages/core/src/agent/workflows/agent'); + expect(rendered).not.toContain('.sources/mastra/packages/core/src/agent/wor…'); + }); + it('shows error status on tool call failure', () => { const comp = new SubagentExecutionComponent('explore', 'Find usages', mockTui); comp.addToolStart('search_content', { pattern: 'foo' }); @@ -84,6 +111,48 @@ describe('SubagentExecutionComponent', () => { expect(lines.some(l => l.includes('✗') && l.includes('search_content'))).toBe(true); }); + it('keeps assistant text in chronological activity order with tools', () => { + const comp = new SubagentExecutionComponent('alexandria', 'Answer question', mockTui); + comp.setText('First answer draft'); + comp.addToolStart('find_files', { pattern: '**/CODE_OF_CONDUCT*' }); + comp.addToolEnd('find_files', 'CODE_OF_CONDUCT.md', false); + comp.setText('First answer draft Final answer after lookup'); + + const rendered = renderPlain(comp).join('\n'); + + expect(rendered.indexOf('First answer draft')).toBeLessThan(rendered.indexOf('find_files')); + expect(rendered.indexOf('find_files')).toBeLessThan(rendered.indexOf('Final answer after lookup')); + }); + + it('does not repeat unchanged full text snapshots after tool calls', () => { + const comp = new SubagentExecutionComponent('alexandria', 'Answer question', mockTui); + comp.setText('That initial grep only scanned a few directories.'); + comp.addToolStart('execute_command', { command: 'rg -l package.json' }); + comp.addToolEnd('execute_command', 'ok', false); + comp.setText('That initial grep only scanned a few directories.'); + comp.addToolStart('execute_command', { command: 'find . -name package.json' }); + comp.addToolEnd('execute_command', 'ok', false); + comp.setText('That initial grep only scanned a few directories.'); + + const rendered = renderPlain(comp).join('\n'); + + expect(rendered.match(/That initial grep only scanned a few directories\./g)).toHaveLength(1); + expect(rendered).toContain('rg -l package.json'); + expect(rendered).toContain('find . -name package.json'); + }); + + it('does not duplicate streamed assistant text as the expanded final result', () => { + const comp = new SubagentExecutionComponent('alexandria', 'Answer question', mockTui, undefined, { + expandOnComplete: true, + }); + comp.setText('Final answer after lookup'); + comp.finish(false, 5000, 'Final answer after lookup'); + + const rendered = renderPlain(comp).join('\n'); + + expect(rendered.match(/Final answer after lookup/g)).toHaveLength(1); + }); + // ─── Default behavior: NO collapse ────────────────────────────────────── describe('default behavior (collapseOnComplete: false)', () => { @@ -267,5 +336,20 @@ describe('SubagentExecutionComponent', () => { expect(lines.some(l => l.includes('╭──'))).toBe(true); expect(lines.some(l => l.includes('Find usages'))).toBe(true); }); + + it('keeps the latest activity visible when completed activity is capped', () => { + const comp = new SubagentExecutionComponent('alexandria', 'Answer a question', mockTui); + for (let i = 0; i < 20; i++) { + comp.addToolStart(`tool_${i}`, { path: `file-${i}.ts` }); + comp.addToolEnd(`tool_${i}`, 'ok', false); + } + comp.finish(false, 5000); + + const rendered = renderPlain(comp).join('\n'); + + expect(rendered).toContain('more above (ctrl+e to expand)'); + expect(rendered).toContain('tool_19'); + expect(rendered).not.toContain('tool_0'); + }); }); }); diff --git a/mastracode/src/tui/components/__tests__/tool-execution-enhanced.test.ts b/mastracode/src/tui/components/__tests__/tool-execution-enhanced.test.ts index 8647b833dc20..f6fb6af0a4b4 100644 --- a/mastracode/src/tui/components/__tests__/tool-execution-enhanced.test.ts +++ b/mastracode/src/tui/components/__tests__/tool-execution-enhanced.test.ts @@ -14,6 +14,57 @@ function stripAnsi(text: string): string { } describe('ToolExecutionComponentEnhanced quiet display', () => { + it('shows the latest lines from partial generic tool progress in quiet mode', () => { + const component = new ToolExecutionComponentEnhanced( + 'mastra_expert', + { question: 'How does tool streaming work?' }, + { quietDisplayMode: 'quiet', quietPreviewLineLimit: 2, collapsedByDefault: true }, + ui, + ); + + component.updateResult( + { + content: [ + { + type: 'text', + text: [ + 'Task: How does tool streaming work?', + '───', + '✓ view {"path":"knowledge/features/tools/README.md"}', + '⋯ search_content {"pattern":"createTool"}', + ].join('\n'), + }, + ], + isError: false, + }, + true, + ); + + const visible = stripAnsi(component.render(120).join('\n')); + expect(visible).toContain('✓ view {"path":"knowledge/features/tools/README.md"}'); + expect(visible).toContain('⋯ search_content {"pattern":"createTool"}'); + expect(visible).not.toContain('Task: How does tool streaming work?'); + }); + + it('keeps completed generic tool previews compact in quiet mode', () => { + const component = new ToolExecutionComponentEnhanced( + 'mastra_expert', + { question: 'How does tool streaming work?' }, + { quietDisplayMode: 'quiet', quietPreviewLineLimit: 2, collapsedByDefault: true }, + ui, + ); + + component.updateResult({ + content: [{ type: 'text', text: ['first', 'second', 'third'].join('\n') }], + isError: false, + }); + + const visible = stripAnsi(component.render(120).join('\n')); + expect(visible).toContain('first'); + expect(visible).toContain('second'); + expect(visible).not.toContain('third'); + }); + it('renders quiet view tools with a path range summary and content preview', () => { const component = new ToolExecutionComponentEnhanced( 'view', diff --git a/mastracode/src/tui/components/custom-editor.ts b/mastracode/src/tui/components/custom-editor.ts index 08b05e91c6d4..0380cc32ab75 100644 --- a/mastracode/src/tui/components/custom-editor.ts +++ b/mastracode/src/tui/components/custom-editor.ts @@ -36,6 +36,30 @@ const IMAGE_MIME_TYPES_BY_EXTENSION: Record<string, string> = { '.heif': 'image/heif', }; +/** + * Push-to-talk voice input interface the editor drives. Implemented by + * VoiceController. Terminals do not emit key-up events, so a held space is + * inferred from auto-repeat: a burst of rapid repeated spaces means the key is + * held (start recording); recording stops once those repeats stop arriving. + */ +export interface VoiceInputHook { + isEnabled(): boolean; + isRecording(): boolean; + startRecording(): void; + stopRecording(): void | Promise<void>; +} + +// A held key repeats only after the OS key-repeat delay (~500ms on macOS), +// then steadily (~80ms cadence). A space tap counts toward "held" only when it +// follows the previous space within this window. +const SPACE_REPEAT_MAX_GAP_MS = 180; +// Number of rapid consecutive spaces that confirm the key is held rather than +// being tapped. The literal spaces typed before this point are removed. +const SPACE_HOLD_REPEAT_THRESHOLD = 3; +// While recording, the space is considered released once no repeat arrives for +// this long (comfortably above the ~80ms repeat cadence). +const SPACE_RELEASE_IDLE_MS = 250; + export type AppAction = | 'clear' | 'exit' @@ -57,6 +81,9 @@ function parseHex(hex: string): [number, number, number] { return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; } +// Vertical bar glyphs ordered low→high; cycled to animate a soundwave cell. +const VOICE_WAVE_BARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const; + const DEFAULT_PROMPT_ICON = '•'; const PROMPT_ICON_CHOICES = [ '☯', @@ -103,6 +130,39 @@ export class CustomEditor extends Editor { public getPromptAnimator?: () => GradientAnimator | undefined; private pendingBracketedPaste: string | null = null; + /** + * Push-to-talk voice hook. When set and enabled, holding the space bar starts + * recording; releasing it (auto-repeat stops) transcribes and inserts at the + * cursor. + */ + public voiceInput?: VoiceInputHook; + // Timestamp of the previous space key event, used to measure repeat cadence. + private lastSpaceAt = 0; + // Count of consecutive rapid spaces seen so far (the current repeat burst). + private spaceRepeatCount = 0; + // Whether a held-space recording session is currently active. + private voiceRecordingActive = false; + // Fires when space auto-repeat stops, signalling the key was released. + private spaceReleaseTimer: ReturnType<typeof setTimeout> | null = null; + + // Whether the "listening" prompt animation is active (recording in progress). + private voiceListening = false; + // Drives the listening pulse so the prompt indicator animates while recording. + private voiceListenTimer: ReturnType<typeof setInterval> | null = null; + private voiceListenPhase = 0; + // The dictated run currently rendered greyed-out. Cleared once the user edits, + // so dictated text reads as "not written by us" until accepted. + private voiceTranscriptText = ''; + // Text surrounding the dictated run, captured at the cursor position where + // dictation began. Lets live replacements rebuild the input in place instead + // of always appending to the end. + private voicePrefix = ''; + private voiceSuffix = ''; + // True while a dictation session owns the trailing run. Set when listening + // starts, cleared on a real user keystroke so late async transcripts that + // arrive after the user resumed typing are ignored rather than re-appended. + private voiceDictationActive = false; + private _cachedModeColorHex?: string; private _cachedColorFn?: (s: string) => string; private promptIcon = DEFAULT_PROMPT_ICON; @@ -222,11 +282,26 @@ export class CustomEditor extends Editor { const colorFn = this._cachedColorFn!; const b = colorFn; const [r, g, bValue] = parseHex(color); - const prompt = chalk.bold.rgb( - Math.round(r * promptBrightness), - Math.round(g * promptBrightness), - Math.round(bValue * promptBrightness), - )(promptChar); + let prompt: string; + if (this.voiceListening) { + // Animated single-cell soundwave: a vertical bar that rises and falls like + // an equalizer, so the prompt reads as a live waveform while recording. + const wave = (Math.sin(this.voiceListenPhase * 0.6) + 1) / 2; // 0..1 + const bar = VOICE_WAVE_BARS[Math.round(wave * (VOICE_WAVE_BARS.length - 1))]!; + // Pulse the wave using the current mode color so it matches the prompt. + const brightness = 0.5 + wave * 0.5; + prompt = chalk.bold.rgb( + Math.round(r * brightness), + Math.round(g * brightness), + Math.round(bValue * brightness), + )(bar); + } else { + prompt = chalk.bold.rgb( + Math.round(r * promptBrightness), + Math.round(g * promptBrightness), + Math.round(bValue * promptBrightness), + )(promptChar); + } // Box structure: "│ > content │" or "│ content │" // Left: "│ > " (4) or "│ " (4), Right: " │" (2) = 6 chars total @@ -284,6 +359,22 @@ export class CustomEditor extends Editor { const textColorClose = '\x1b[39m'; result.push(top); + // How many trailing characters are dictated and should render greyed-out. + const fullText = this.getText(); + let greyRemaining = + this.voiceTranscriptText.length > 0 && fullText.endsWith(this.voiceTranscriptText) + ? this.voiceTranscriptText.length + : 0; + const greyOpen = `\x1b[38;2;${parseHex(theme.getTheme().muted).join(';')}m`; + + for (let i = contentLines.length - 1; i >= 0; i--) { + if (greyRemaining > 0) { + const { line, consumed } = this.greyifyTrailing(contentLines[i]!, greyRemaining, greyOpen, textColorOpen); + contentLines[i] = line; + greyRemaining -= consumed; + } + } + for (let i = 0; i < contentLines.length; i++) { const line = `${textColorOpen}${contentLines[i]!}${textColorClose}`; if (i === 0) { @@ -507,11 +598,281 @@ export class CustomEditor extends Editor { return wasSlashCommand; } + /** + * Capture the cursor position where dictation begins so the dictated run can be + * inserted (and later replaced) in place, even when the cursor sits in the + * middle of existing input. + */ + private beginDictation(): void { + const offset = this.getCursorOffset(); + const full = this.getText(); + this.voicePrefix = full.slice(0, offset); + this.voiceSuffix = full.slice(offset); + this.voiceTranscriptText = ''; + this.voiceDictationActive = true; + } + + /** + * Flatten the editor's {line, col} cursor into a single string offset over the + * newline-joined text. + */ + private getCursorOffset(): number { + const lines = this.getText().split('\n'); + const { line, col } = (this as any).getCursor?.() ?? { + line: lines.length - 1, + col: lines[lines.length - 1]?.length ?? 0, + }; + const clampedLine = Math.min(Math.max(line, 0), lines.length - 1); + let offset = 0; + for (let i = 0; i < clampedLine; i++) offset += (lines[i]?.length ?? 0) + 1; // +1 for the '\n' + return offset + Math.min(Math.max(col, 0), lines[clampedLine]?.length ?? 0); + } + + /** + * Restore the cursor to a flat string offset over the newline-joined text. + * Uses the editor's internal state directly since pi-tui exposes no public + * cursor setter. + */ + private setCursorOffset(offset: number): void { + const lines = this.getText().split('\n'); + let remaining = Math.max(offset, 0); + let lineIdx = 0; + while (lineIdx < lines.length - 1 && remaining > (lines[lineIdx]?.length ?? 0)) { + remaining -= (lines[lineIdx]?.length ?? 0) + 1; + lineIdx += 1; + } + const col = Math.min(remaining, lines[lineIdx]?.length ?? 0); + const state = (this as any).state; + const setCursorCol = (this as any).setCursorCol; + if (state && typeof setCursorCol === 'function') { + state.cursorLine = lineIdx; + setCursorCol.call(this, col); + } + } + + /** + * Insert dictated text at the captured dictation anchor. Used for the final + * (non-live) transcript path. + */ + public insertVoiceTranscript(text: string): void { + const trimmed = text.trim(); + if (!trimmed) return; + this.applyDictation(trimmed); + } + + /** + * Replace the current dictated run with a new transcript. Used for live + * transcription, where each partial result supersedes the previous one as the + * user keeps speaking. + */ + public replaceVoiceTranscript(text: string): void { + // The user resumed typing before this async result arrived; honor their edit + // rather than clobbering it with a stale partial. + if (!this.voiceDictationActive) return; + this.applyDictation(text.trim()); + } + + /** + * Rebuild the input as prefix + dictated payload + suffix, keeping the dictated + * run anchored at the cursor position where dictation began and leaving the + * cursor just after the dictated text. + */ + private applyDictation(trimmed: string): void { + const needsLeadingSpace = this.voicePrefix.length > 0 && !/\s$/.test(this.voicePrefix); + const payload = trimmed ? (needsLeadingSpace ? ` ${trimmed}` : trimmed) : ''; + this.voiceTranscriptText = payload; + this.setText(this.voicePrefix + payload + this.voiceSuffix); + this.setCursorOffset(this.voicePrefix.length + payload.length); + // Programmatic insertion mutates editor state but does not repaint on its + // own, so force a render to show the transcript immediately. + this.tui.requestRender(); + } + + /** + * Wrap up to `count` trailing dictated characters of a rendered content line in + * the grey (muted) color. The underlying pi-tui editor produces plain text plus + * a few artifacts that must be skipped: an APC hardware-cursor marker + * (`\x1b_pi:c\x07`), a reverse-video cursor highlight (`\x1b[7m…\x1b[0m`), and + * trailing padding spaces. We isolate the real content region (everything + * before the cursor highlight / padding) and grey only its trailing characters, + * leaving the cursor and padding untouched. + * + * Returns the recolored line and how many content characters were greyed. + */ + private greyifyTrailing( + line: string, + count: number, + greyOpen: string, + textColorOpen: string, + ): { line: string; consumed: number } { + // Split the content region (real text) from the trailing artifacts: the + // hardware-cursor marker, the cursor highlight, and any padding after it. + const CURSOR_MARKER = '\x1b_pi:c\x07'; + const cursorHighlight = /\x1b\[7m[\s\S]*?\x1b\[0m/; + let head = line; + let tail = ''; + + const markerIdx = line.indexOf(CURSOR_MARKER); + if (markerIdx !== -1) { + head = line.slice(0, markerIdx); + tail = line.slice(markerIdx); + } else { + const hl = cursorHighlight.exec(line); + if (hl) { + head = line.slice(0, hl.index); + tail = line.slice(hl.index); + } else { + // No cursor on this line: trailing run is padding spaces. Strip them so + // we grey real characters, not the box padding. + const trimmed = line.replace(/ +$/, ''); + head = trimmed; + tail = line.slice(trimmed.length); + } + } + + // Within `head`, tokenize into SGR escapes and visible characters and grey + // the trailing `count` visible characters. + const tokens: Array<{ ansi: boolean; text: string }> = []; + const re = /\x1b\[[0-9;]*m/g; + let last = 0; + let mt: RegExpExecArray | null; + while ((mt = re.exec(head)) !== null) { + if (mt.index > last) { + for (const ch of head.slice(last, mt.index)) tokens.push({ ansi: false, text: ch }); + } + tokens.push({ ansi: true, text: mt[0] }); + last = re.lastIndex; + } + for (const ch of head.slice(last)) tokens.push({ ansi: false, text: ch }); + + let consumed = 0; + let firstGreyIdx = -1; + for (let i = tokens.length - 1; i >= 0 && consumed < count; i--) { + if (!tokens[i]!.ansi) { + firstGreyIdx = i; + consumed += 1; + } + } + if (firstGreyIdx === -1) return { line, consumed: 0 }; + + const before = tokens + .slice(0, firstGreyIdx) + .map(t => t.text) + .join(''); + const grey = tokens + .slice(firstGreyIdx) + .map(t => t.text) + .join(''); + // Re-open the normal text color before the cursor/padding tail so only the + // dictated run is grey. + return { line: `${before}${greyOpen}${grey}${textColorOpen}${tail}`, consumed }; + } + + /** + * Start or stop the "listening" prompt animation. While listening, the prompt + * indicator pulses on its own timer so the user gets clear visual feedback + * that the mic is recording. + */ + public setVoiceListening(listening: boolean): void { + if (listening === this.voiceListening) return; + this.voiceListening = listening; + if (listening) { + this.beginDictation(); + this.voiceListenPhase = 0; + this.voiceListenTimer ??= setInterval(() => { + this.voiceListenPhase += 1; + this.tui.requestRender(); + }, 120); + } else if (this.voiceListenTimer) { + clearInterval(this.voiceListenTimer); + this.voiceListenTimer = null; + } + this.tui.requestRender(); + } + + /** + * Push-to-talk space handling. Returns true when the space was consumed by + * voice handling and must not be processed further. + * + * Normal spaces are typed instantly (no deferral, no lag). A held space is + * detected from terminal auto-repeat: once SPACE_HOLD_REPEAT_THRESHOLD rapid + * spaces arrive, the literal spaces already typed are deleted and recording + * begins. While recording, repeats are swallowed and reset a release timer; + * when repeats stop (key released) recording stops and transcribes. + */ + private maybeHandleVoiceSpace(data: string): boolean { + if (data !== ' ' || !this.voiceInput?.isEnabled()) { + if (data !== ' ') { + // Any non-space key ends a potential repeat burst. + this.spaceRepeatCount = 0; + } + return false; + } + + const now = Date.now(); + const gap = this.lastSpaceAt ? now - this.lastSpaceAt : Infinity; + this.lastSpaceAt = now; + + // Already recording: swallow the repeat and keep the release timer alive. + if (this.voiceRecordingActive) { + this.armSpaceReleaseTimer(); + return true; + } + + if (gap <= SPACE_REPEAT_MAX_GAP_MS) { + this.spaceRepeatCount += 1; + } else { + this.spaceRepeatCount = 1; + } + + // Threshold reached: this is a held key. Remove the literal spaces already + // typed during the burst and start recording. + if (this.spaceRepeatCount >= SPACE_HOLD_REPEAT_THRESHOLD) { + const typedSpaces = SPACE_HOLD_REPEAT_THRESHOLD - 1; + for (let i = 0; i < typedSpaces; i++) { + // Backspace removes one previously inserted literal space. + super.handleInput('\x7f'); + } + this.spaceRepeatCount = 0; + this.voiceRecordingActive = true; + this.voiceInput.startRecording(); + this.armSpaceReleaseTimer(); + return true; + } + + // Not yet confirmed as a hold: type the space normally (instant, no lag). + return false; + } + + private armSpaceReleaseTimer(): void { + if (this.spaceReleaseTimer) { + clearTimeout(this.spaceReleaseTimer); + } + this.spaceReleaseTimer = setTimeout(() => { + this.spaceReleaseTimer = null; + this.voiceRecordingActive = false; + this.lastSpaceAt = 0; + this.spaceRepeatCount = 0; + void this.voiceInput?.stopRecording(); + }, SPACE_RELEASE_IDLE_MS); + } + handleInput(data: string): void { if (this.maybeHandleBracketedPaste(data)) { return; } + if (this.maybeHandleVoiceSpace(data)) { + return; + } + + // Any genuine keystroke means the user is editing — stop greying the + // dictated text so it now reads as their own input, and end the dictation + // session so any late async transcript that arrives after this edit is + // ignored instead of clobbering the user's input. + this.voiceTranscriptText = ''; + this.voiceDictationActive = false; + if (matchesKey(data, 'ctrl+v') || matchesKey(data, 'alt+v')) { this.handleExplicitPaste(); return; diff --git a/mastracode/src/tui/components/help-overlay.ts b/mastracode/src/tui/components/help-overlay.ts index 5e5a8eeab6a7..9e5b667dc3ef 100644 --- a/mastracode/src/tui/components/help-overlay.ts +++ b/mastracode/src/tui/components/help-overlay.ts @@ -50,6 +50,7 @@ function getCommands(modes: number): HelpEntry[] { { key: '/setup', description: 'Run the setup wizard' }, { key: '/browser', description: 'Configure browser automation' }, { key: '/api-keys', description: 'Manage provider API keys' }, + { key: '/plugins', description: 'Manage Mastra Code plugins' }, { key: '/theme', description: 'Switch color theme (auto/dark/light)' }, { key: '/update', description: 'Check for and install updates' }, { key: '/observability', description: 'Configure cloud observability' }, diff --git a/mastracode/src/tui/components/idle-counter.ts b/mastracode/src/tui/components/idle-counter.ts index 2273e997e4f2..01c806cbd049 100644 --- a/mastracode/src/tui/components/idle-counter.ts +++ b/mastracode/src/tui/components/idle-counter.ts @@ -1,18 +1,18 @@ /** - * Live idle-time indicator shown above the user input after an agent run completes. + * Live work/idle-time indicator shown above the user input. */ import { Container, Text } from '@earendil-works/pi-tui'; +import type { TUIState } from '../state.js'; +import { formatStatusDuration } from '../status-duration.js'; import { BOX_INDENT, theme } from '../theme.js'; +export { formatStatusDuration } from '../status-duration.js'; + const MINUTE_MS = 60_000; -const HOUR_MINUTES = 60; -const DAY_MINUTES = HOUR_MINUTES * 24; -const MONTH_MINUTES = DAY_MINUTES * 30; -const YEAR_MINUTES = DAY_MINUTES * 365; export class IdleCounterComponent extends Container { - private idleStartedAt?: number; + private timingState?: Pick<TUIState, 'lastAgentRunDurationMs' | 'lastAgentRunEndedAt' | 'lastAgentRunEndReason'>; private textChild: Text; constructor() { @@ -21,24 +21,23 @@ export class IdleCounterComponent extends Container { this.addChild(this.textChild); } - setIdleStartedAt(idleStartedAt: number | undefined, now = Date.now()): void { - this.idleStartedAt = idleStartedAt; + setTimingState( + timingState: Pick<TUIState, 'lastAgentRunDurationMs' | 'lastAgentRunEndedAt' | 'lastAgentRunEndReason'> | undefined, + now = Date.now(), + ): void { + this.timingState = timingState; this.update(now); } update(now = Date.now()): void { - if (this.idleStartedAt === undefined) { - this.textChild.setText(''); - return; - } - - const idleMinutes = Math.floor((now - this.idleStartedAt) / MINUTE_MS); - if (idleMinutes < 1) { + const segments = this.timingState ? formatIdleStatusTimingSegments(this.timingState, now) : null; + if (!segments) { this.textChild.setText(''); return; } - this.textChild.setText(theme.fg('dim', ` ${formatIdleDuration(idleMinutes)} idle`)); + const idle = segments.idle ? theme.fg('dim', segments.idle) : ''; + this.textChild.setText(idle ? ` ${idle}` : ''); } render(width: number): string[] { @@ -47,32 +46,25 @@ export class IdleCounterComponent extends Container { } } -export function formatIdleDuration(totalMinutes: number): string { - const minutes = Math.max(1, Math.floor(totalMinutes)); - const units = [ - { name: 'year', minutes: YEAR_MINUTES }, - { name: 'month', minutes: MONTH_MINUTES }, - { name: 'day', minutes: DAY_MINUTES }, - { name: 'hour', minutes: HOUR_MINUTES }, - { name: 'minute', minutes: 1 }, - ]; - - let remaining = minutes; - const parts: string[] = []; - - for (const unit of units) { - const value = Math.floor(remaining / unit.minutes); - if (value === 0) continue; - - parts.push(formatUnit(value, unit.name)); - remaining %= unit.minutes; +type IdleStatusTimingState = Pick<TUIState, 'lastAgentRunDurationMs' | 'lastAgentRunEndedAt' | 'lastAgentRunEndReason'>; - if (parts.length === 2) break; +export function formatIdleStatusTimingSegments( + state: IdleStatusTimingState, + now = Date.now(), +): { summary: string; idle: string } | null { + if (state.lastAgentRunEndedAt === undefined) { + return null; } - return parts.join(' '); + const idleMs = now - state.lastAgentRunEndedAt; + const idle = idleMs >= MINUTE_MS ? `${formatStatusDuration(idleMs)} idle` : ''; + return idle ? { summary: '', idle } : null; } -function formatUnit(value: number, unit: string): string { - return `${value} ${unit}${value === 1 ? '' : 's'}`; +export function formatIdleStatusTiming(state: IdleStatusTimingState, now = Date.now()): string { + const segments = formatIdleStatusTimingSegments(state, now); + if (!segments) return ''; + return segments.summary && segments.idle + ? `${segments.summary} · ${segments.idle}` + : segments.summary || segments.idle; } diff --git a/mastracode/src/tui/components/subagent-execution.ts b/mastracode/src/tui/components/subagent-execution.ts index b2a0717334a3..8efcd8c298ff 100644 --- a/mastracode/src/tui/components/subagent-execution.ts +++ b/mastracode/src/tui/components/subagent-execution.ts @@ -11,6 +11,7 @@ import { Container, Text } from '@earendil-works/pi-tui'; import type { TUI } from '@earendil-works/pi-tui'; import { safeStringify } from '@mastra/core/utils'; +import chalk from 'chalk'; import { BOX_INDENT, getTermWidth, theme } from '../theme.js'; import type { ChatSpacingKind } from './chat-spacing.js'; import type { IToolExecutionComponent } from './tool-execution-interface.js'; @@ -19,13 +20,21 @@ import type { IToolExecutionComponent } from './tool-execution-interface.js'; // Types // ───────────────────────────────────────────────────────────────────────────── -export interface SubagentToolCall { - name: string; - args: unknown; - result?: string; - isError?: boolean; - done: boolean; -} +export type SubagentActivity = + | { + kind: 'tool'; + name: string; + args: unknown; + result?: string; + isError?: boolean; + done: boolean; + } + | { + kind: 'text'; + text: string; + }; + +export type SubagentToolCall = Extract<SubagentActivity, { kind: 'tool' }>; // ───────────────────────────────────────────────────────────────────────────── // Component @@ -41,6 +50,23 @@ export interface SubagentExecutionOptions { forked?: boolean; /** When true, show full completed content including the final result. Default false. */ expandOnComplete?: boolean; + /** Footer label before the agent type. Default "subagent". */ + label?: string; + /** Max activity lines shown while running. Default 15. */ + maxActivityLines?: number; + /** Max activity lines shown when completed and collapsed. Default 15. */ + collapsedLines?: number; + colors?: { + border?: string; + label?: string; + agentType?: string; + icon?: string; + }; + icons?: { + running?: string; + success?: string; + error?: string; + }; } export class SubagentExecutionComponent extends Container implements IToolExecutionComponent { @@ -50,7 +76,8 @@ export class SubagentExecutionComponent extends Container implements IToolExecut private agentType: string; private task: string; private modelId?: string; - private toolCalls: SubagentToolCall[] = []; + private activity: SubagentActivity[] = []; + private lastTextSnapshot = ''; private done = false; private isError = false; private startTime = Date.now(); @@ -60,6 +87,11 @@ export class SubagentExecutionComponent extends Container implements IToolExecut private collapseOnComplete: boolean; private expandOnComplete: boolean; private forked: boolean; + private label: string; + private maxActivityLines: number; + private collapsedLines: number; + private colors: NonNullable<SubagentExecutionOptions['colors']>; + private icons: Required<NonNullable<SubagentExecutionOptions['icons']>>; constructor(agentType: string, task: string, ui: TUI, modelId?: string, options?: SubagentExecutionOptions) { super(); @@ -70,6 +102,15 @@ export class SubagentExecutionComponent extends Container implements IToolExecut this.collapseOnComplete = options?.collapseOnComplete ?? false; this.expandOnComplete = options?.expandOnComplete ?? false; this.forked = options?.forked ?? false; + this.label = options?.label ?? 'subagent'; + this.maxActivityLines = clampPositiveInt(options?.maxActivityLines, MAX_ACTIVITY_LINES); + this.collapsedLines = clampPositiveInt(options?.collapsedLines, COLLAPSED_LINES); + this.colors = options?.colors ?? {}; + this.icons = { + running: options?.icons?.running ?? '⋯', + success: options?.icons?.success ?? '✓', + error: options?.icons?.error ?? '✗', + }; this.rebuild(); } @@ -77,16 +118,48 @@ export class SubagentExecutionComponent extends Container implements IToolExecut // ── Mutation API ────────────────────────────────────────────────────── addToolStart(name: string, args: unknown): void { - this.toolCalls.push({ name, args, done: false }); + this.activity.push({ kind: 'tool', name, args, done: false }); + this.rebuild(); + } + + setTask(task: string): void { + this.task = task; + this.rebuild(); + } + + setText(text: string): void { + const nextSnapshot = text.trim(); + if (!nextSnapshot || nextSnapshot === this.lastTextSnapshot) return; + + const last = this.activity.at(-1); + const extendsPreviousSnapshot = Boolean(this.lastTextSnapshot && nextSnapshot.startsWith(this.lastTextSnapshot)); + let textToRender = nextSnapshot; + if (extendsPreviousSnapshot) { + const delta = nextSnapshot.slice(this.lastTextSnapshot.length); + textToRender = last?.kind === 'text' ? delta : delta.trimStart(); + } + this.lastTextSnapshot = nextSnapshot; + if (!textToRender) return; + + if (last?.kind === 'text') { + last.text = extendsPreviousSnapshot ? `${last.text}${textToRender}` : textToRender; + } else { + this.activity.push({ kind: 'text', text: textToRender }); + } this.rebuild(); } + + addText(text: string): void { + this.setText(text); + } + addToolEnd(name: string, result: unknown, isError: boolean): void { - for (let i = this.toolCalls.length - 1; i >= 0; i--) { - const toolCall = this.toolCalls[i]!; - if (toolCall.name === name && !toolCall.done) { - toolCall.done = true; - toolCall.isError = isError; - toolCall.result = typeof result === 'string' ? result : safeStringify(result ?? ''); + for (let i = this.activity.length - 1; i >= 0; i--) { + const item = this.activity[i]!; + if (item.kind === 'tool' && item.name === name && !item.done) { + item.done = true; + item.isError = isError; + item.result = typeof result === 'string' ? result : safeStringify(result ?? ''); break; } } @@ -97,7 +170,7 @@ export class SubagentExecutionComponent extends Container implements IToolExecut this.done = true; this.isError = isError; this.durationMs = durationMs; - this.finalResult = result; + this.finalResult = isDuplicateFinalResult(result, this.activity, this.lastTextSnapshot) ? undefined : result; if (this.expandOnComplete) { this.expanded = true; } else if (this.collapseOnComplete) { @@ -129,21 +202,24 @@ export class SubagentExecutionComponent extends Container implements IToolExecut private rebuild(): void { this.clear(); - const border = (char: string) => theme.bold(theme.fg('accent', char)); + const border = (char: string) => + theme.bold(colorText(this.colors.border, char, (text: string) => theme.fg('accent', text))); const termWidth = getTermWidth(); const maxLineWidth = termWidth - 6 - BOX_INDENT * 2; // ── Bottom border with info (always rendered) ── const typeLabelText = this.forked ? 'fork' : this.agentType; - const typeLabel = theme.bold(theme.fg('accent', typeLabelText)); + const typeLabel = theme.bold( + colorText(this.colors.agentType, typeLabelText, (text: string) => theme.fg('accent', text)), + ); const modelLabel = this.modelId ? theme.fg('muted', ` ${this.modelId}`) : ''; const statusIcon = this.done ? this.isError - ? theme.fg('error', ' ✗') - : theme.fg('success', ' ✓') - : theme.fg('muted', ' ⋯'); + ? colorText(this.colors.icon, ` ${this.icons.error}`, (text: string) => theme.fg('error', text)) + : colorText(this.colors.icon, ` ${this.icons.success}`, (text: string) => theme.fg('success', text)) + : colorText(this.colors.icon, ` ${this.icons.running}`, (text: string) => theme.fg('muted', text)); const durationStr = this.done ? theme.fg('muted', ` ${formatDuration(this.durationMs)}`) : ''; - const footerText = `${theme.bold(theme.fg('toolTitle', 'subagent'))} ${typeLabel}${modelLabel}${durationStr}${statusIcon}`; + const footerText = `${theme.bold(colorText(this.colors.label, this.label, (text: string) => theme.fg('toolTitle', text)))} ${typeLabel}${modelLabel}${durationStr}${statusIcon}`; // When collapse-on-complete is enabled, render only the single-line footer summary. // Quiet mode does not enable this for subagents; it is kept for explicit callers/tests. @@ -186,41 +262,35 @@ export class SubagentExecutionComponent extends Container implements IToolExecut this.addChild(new Text(`${border('│')} ${moreText}`, BOX_INDENT, 0)); } - // ── Activity lines (tool calls — capped rolling window) ── - if (this.toolCalls.length > 0) { + // ── Activity lines (assistant text and tool calls — capped rolling window) ── + if (this.activity.length > 0) { // Separator between task and activity this.addChild(new Text(`${border('│')} ${theme.fg('muted', '───')}`, BOX_INDENT, 0)); - const activityLines = this.toolCalls.map(tc => formatToolCallLine(tc, maxLineWidth)); + const activityLines = this.activity.flatMap(item => + formatActivityLine(item, maxLineWidth, this.icons, this.colors.icon), + ); // While streaming: rolling window. When done: collapsible. - const cap = this.done ? COLLAPSED_LINES : MAX_ACTIVITY_LINES; + const cap = this.done ? this.collapsedLines : this.maxActivityLines; let displayLines = activityLines; let hiddenCount = 0; const minHidden = this.done ? 2 : 1; if (!this.expanded && activityLines.length > cap + minHidden - 1) { hiddenCount = activityLines.length - cap; - if (this.done) { - // Show first N lines when collapsed (completed) - displayLines = activityLines.slice(0, cap); - } else { - // Show last N lines while streaming - displayLines = activityLines.slice(-cap); - } + displayLines = activityLines.slice(-cap); } - if (!this.done && hiddenCount > 0) { - const hiddenText = theme.fg('muted', ` ... ${hiddenCount} more above`); + if (hiddenCount > 0) { + const hiddenText = theme.fg( + 'muted', + ` ... ${hiddenCount} more above${this.done ? ' (ctrl+e to expand)' : ''}`, + ); this.addChild(new Text(`${border('│')} ${hiddenText}`, BOX_INDENT, 0)); } const activityContent = displayLines.map(line => `${border('│')} ${line}`).join('\n'); this.addChild(new Text(activityContent, BOX_INDENT, 0)); - - if (this.done && hiddenCount > 0) { - const moreText = theme.fg('muted', `... ${hiddenCount} more (ctrl+e to expand)`); - this.addChild(new Text(`${border('│')} ${moreText}`, BOX_INDENT, 0)); - } } // ── Final result (shown after completion, only when expanded) ── @@ -251,11 +321,52 @@ export class SubagentExecutionComponent extends Container implements IToolExecut // Helpers // ───────────────────────────────────────────────────────────────────────────── -function formatToolCallLine(tc: SubagentToolCall, _maxWidth: number): string { - const icon = tc.done ? (tc.isError ? theme.fg('error', '✗') : theme.fg('success', '✓')) : theme.fg('muted', '⋯'); +function clampPositiveInt(value: number | undefined, fallback: number): number { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback; +} + +function colorText(color: string | undefined, text: string, fallback: (text: string) => string): string { + if (!color) return fallback(text); + try { + return chalk.hex(color)(text); + } catch { + return fallback(text); + } +} + +function formatActivityLine( + activity: SubagentActivity, + maxWidth: number, + icons: Required<NonNullable<SubagentExecutionOptions['icons']>>, + iconColor?: string, +): string[] { + if (activity.kind === 'text') return formatTextActivityLines(activity.text, maxWidth); + return [formatToolCallLine(activity, maxWidth, icons, iconColor)]; +} + +function formatTextActivityLines(text: string, maxWidth: number): string[] { + return text + .trim() + .split('\n') + .map(line => theme.fg('muted', line.length > maxWidth ? `${line.slice(0, maxWidth - 1)}…` : line)); +} + +function formatToolCallLine( + tc: SubagentToolCall, + maxWidth: number, + icons: Required<NonNullable<SubagentExecutionOptions['icons']>>, + iconColor?: string, +): string { + const iconText = tc.done ? (tc.isError ? icons.error : icons.success) : icons.running; + const icon = tc.done + ? tc.isError + ? colorText(iconColor, iconText, (text: string) => theme.fg('error', text)) + : colorText(iconColor, iconText, (text: string) => theme.fg('success', text)) + : colorText(iconColor, iconText, (text: string) => theme.fg('muted', text)); const name = theme.fg('toolTitle', tc.name); - const argsSummary = summarizeArgs(tc.args); - return `${icon} ${name} ${argsSummary}`; + const prefix = `${icon} ${name}`; + const argsSummary = summarizeArgs(tc.args, Math.max(20, maxWidth - stripAnsi(prefix).length - 1)); + return `${prefix} ${argsSummary}`; } function formatDuration(ms: number): string { @@ -264,7 +375,29 @@ function formatDuration(ms: number): string { return `${s}s`; } -function summarizeArgs(args: unknown): string { +function stripAnsi(value: string): string { + return value.replace(/\x1b\[[0-9;]*m/g, ''); +} + +function normalizeText(value: string): string { + return value.trim().replace(/\s+/g, ' '); +} + +function isDuplicateFinalResult( + result: string | undefined, + activity: SubagentActivity[], + lastTextSnapshot: string, +): boolean { + if (!result) return false; + if (lastTextSnapshot && normalizeText(lastTextSnapshot) === normalizeText(result)) return true; + const textItems = activity.filter( + (item): item is Extract<SubagentActivity, { kind: 'text' }> => item.kind === 'text', + ); + const lastText = textItems.at(-1)?.text; + return lastText ? normalizeText(lastText) === normalizeText(result) : false; +} + +function summarizeArgs(args: unknown, maxWidth = 40): string { if (!args || typeof args !== 'object') return ''; const obj = args as Record<string, unknown>; const parts: string[] = []; @@ -290,10 +423,12 @@ function summarizeArgs(args: unknown): string { return theme.fg('muted', taskSummaries.join(', ')); } + let remainingWidth = maxWidth; for (const [_key, val] of Object.entries(obj)) { if (typeof val === 'string') { - const short = val.length > 40 ? val.slice(0, 40) + '…' : val; + const short = val.length > remainingWidth ? val.slice(0, Math.max(1, remainingWidth - 1)) + '…' : val; parts.push(theme.fg('muted', short)); + remainingWidth -= short.length + 1; } else if (Array.isArray(val)) { parts.push(theme.fg('muted', `${val.length} items`)); } else if (typeof val === 'object' && val !== null) { diff --git a/mastracode/src/tui/components/tool-execution-enhanced.ts b/mastracode/src/tui/components/tool-execution-enhanced.ts index 57dcb7c64325..5c5695e82853 100644 --- a/mastracode/src/tui/components/tool-execution-enhanced.ts +++ b/mastracode/src/tui/components/tool-execution-enhanced.ts @@ -857,7 +857,7 @@ export class ToolExecutionComponentEnhanced extends Container implements IToolEx } private formatQuietGenericResultPreview(): string { - if (!this.result || this.isPartial) return ''; + if (!this.result) return ''; const output = this.stripAnsi(this.getFormattedOutput()).trim(); if (!output) return ''; @@ -866,12 +866,12 @@ export class ToolExecutionComponentEnhanced extends Container implements IToolEx const argsSummary = this.stripAnsi(this.formatArgsSummary()).trim(); if (argsSummary && preview === argsSummary) return ''; - return preview + const lines = preview .split('\n') .map(line => line.trimEnd()) - .filter(Boolean) - .slice(0, 2) - .join('\n'); + .filter(Boolean); + + return (this.isPartial ? lines : lines.slice(0, this.quietPreviewLineLimit)).join('\n'); } private formatCompactJsonResult(output: string): string { @@ -2394,8 +2394,8 @@ export class ToolExecutionComponentEnhanced extends Container implements IToolEx const footerText = `${theme.bold(theme.fg('toolTitle', this.toolName))}${argsSummary}${status}`; if (!this.result || this.isPartial) { - // Pending: show bordered header with args preview - const preview = this.formatArgsPreview(); + const partialOutput = this.result ? this.getFormattedOutput() : ''; + const preview = partialOutput ? partialOutput.split('\n') : this.formatArgsPreview(); this.contentBox.addChild(new Text(border('╭──'), 0, 0)); if (preview.length > 0) { const previewLines = preview.map(line => border('│') + ' ' + theme.fg('toolOutput', line)); diff --git a/mastracode/src/tui/event-dispatch.ts b/mastracode/src/tui/event-dispatch.ts index f6a3b9705222..b3bd12c98ce7 100644 --- a/mastracode/src/tui/event-dispatch.ts +++ b/mastracode/src/tui/event-dispatch.ts @@ -68,13 +68,28 @@ export async function dispatchEvent( // would otherwise zero it before it could be read. state.tokensPerSec = 0; state.decodeStartedAt = 0; + state.agentRunStartedAt = Date.now(); + state.agentRunLastStreamPartAt = state.agentRunStartedAt; + state.lastAgentRunDurationMs = undefined; + state.lastAgentRunEndedAt = undefined; + state.lastAgentRunEndReason = undefined; + ectx.updateStatusLine(); handleAgentStart(ectx); break; case 'agent_end': // Keep tokensPerSec as the last turn's reading; only clear the in-flight // decode window so a stale start can't bleed into the next turn. + if (state.agentRunStartedAt !== undefined) { + const now = Date.now(); + state.lastAgentRunDurationMs = Math.max(0, now - state.agentRunStartedAt); + state.lastAgentRunEndedAt = now; + state.lastAgentRunEndReason = event.reason === 'aborted' || event.reason === 'error' ? event.reason : 'done'; + state.agentRunStartedAt = undefined; + state.agentRunLastStreamPartAt = undefined; + } state.decodeStartedAt = 0; + ectx.updateStatusLine(); if (event.reason === 'aborted') { handleAgentAborted(ectx); } else if (event.reason === 'error') { @@ -88,25 +103,30 @@ export async function dispatchEvent( handleMessageStart(ectx, event.message); break; - case 'message_update': - // Only open the decode window when the message carries actual streamed - // text — tool-result-only updates (e.g. plan approval resume) must not - // count toward tokens/sec. This mirrors the web UI's hasAssistantText() - // guard in transcriptReducer. - if ( - state.decodeStartedAt === 0 && - event.message.content.some(part => part.type === 'text' && 'text' in part && part.text.trim().length > 0) - ) { - state.decodeStartedAt = Date.now(); + case 'message_update': { + // Only open the decode window when an assistant message carries actual + // streamed text — tool-result-only updates (e.g. plan approval resume) and + // user/system message updates must not count toward tokens/sec. + const hasAssistantText = + event.message.role === 'assistant' && + event.message.content.some(part => part.type === 'text' && 'text' in part && part.text.trim().length > 0); + if (hasAssistantText) { + state.agentRunLastStreamPartAt = Date.now(); + if (state.decodeStartedAt === 0) { + state.decodeStartedAt = state.agentRunLastStreamPartAt; + } } + ectx.updateStatusLine(); handleMessageUpdate(ectx, event.message); break; + } case 'message_end': handleMessageEnd(ectx, event.message); break; case 'tool_start': + state.agentRunLastStreamPartAt = Date.now(); handleToolStart(ectx, event.toolCallId, event.toolName, event.args); break; @@ -120,10 +140,12 @@ export async function dispatchEvent( break; case 'tool_update': + state.agentRunLastStreamPartAt = Date.now(); handleToolUpdate(ectx, event.toolCallId, event.partialResult); break; case 'shell_output': + state.agentRunLastStreamPartAt = Date.now(); handleShellOutput(ectx, event.toolCallId, event.output, event.stream); break; @@ -147,6 +169,7 @@ export async function dispatchEvent( break; case 'tool_end': + state.agentRunLastStreamPartAt = Date.now(); handleToolEnd(ectx, event.toolCallId, event.result, event.isError); break; diff --git a/mastracode/src/tui/handlers/__tests__/message.test.ts b/mastracode/src/tui/handlers/__tests__/message.test.ts index 9c352a0f1f70..b39f1bf4ebb3 100644 --- a/mastracode/src/tui/handlers/__tests__/message.test.ts +++ b/mastracode/src/tui/handlers/__tests__/message.test.ts @@ -8,6 +8,7 @@ import { NotificationSummaryComponent } from '../../components/notification-summ import { NotificationComponent } from '../../components/notification.js'; import { ReactiveSignalComponent } from '../../components/reactive-signal.js'; import { StateSignalComponent } from '../../components/state-signal.js'; +import { SubagentExecutionComponent } from '../../components/subagent-execution.js'; import { SystemReminderComponent } from '../../components/system-reminder.js'; import { TemporalGapComponent } from '../../components/temporal-gap.js'; import { ToolExecutionComponentEnhanced } from '../../components/tool-execution-enhanced.js'; @@ -310,6 +311,38 @@ describe('handleMessageUpdate system reminders', () => { expect(Math.max(...renderedLines.map(line => line.length))).toBeLessThanOrEqual(80); }); + it('splits parent assistant text around static plugin subagent renderers', () => { + state.pluginManager = { + getToolRenderConfig: vi.fn(() => ({ type: 'subagent', agentType: 'alexandria' })), + } as unknown as TUIState['pluginManager']; + + handleMessageUpdate( + ctx, + createAssistantMessage([ + { type: 'text', text: 'before plugin' }, + { + type: 'tool_call', + id: 'tool-1', + name: 'mastra_expert', + args: { question: 'Explain the agent loop' }, + } as never, + { type: 'text', text: 'after plugin' }, + ]), + ); + + const children = visibleChildren(state); + expect(children).toHaveLength(3); + expect(children[0]).toBeInstanceOf(AssistantMessageComponent); + expect(children[1]).toBeInstanceOf(SubagentExecutionComponent); + expect(children[2]).toBeInstanceOf(AssistantMessageComponent); + expect(stripAnsi((children[0] as AssistantMessageComponent).render(100).join('\n'))).toContain('before plugin'); + expect(stripAnsi((children[1] as SubagentExecutionComponent).render(100).join('\n'))).toContain( + 'Explain the agent loop', + ); + expect(stripAnsi((children[2] as AssistantMessageComponent).render(100).join('\n'))).toContain('after plugin'); + expect(state.streamingComponent).toBe(children[2]); + }); + it('deduplicates repeated streamed reminders within the same assistant run', () => { const message = createAssistantMessage([ { diff --git a/mastracode/src/tui/handlers/__tests__/tool.test.ts b/mastracode/src/tui/handlers/__tests__/tool.test.ts index 2e12767e6b1d..bb6d936c4d44 100644 --- a/mastracode/src/tui/handlers/__tests__/tool.test.ts +++ b/mastracode/src/tui/handlers/__tests__/tool.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { handleToolInputDelta } from '../tool.js'; +import { handleToolInputDelta, handleToolInputStart, handleToolUpdate } from '../tool.js'; function createContext(bufferText: string | undefined) { const updateArgs = vi.fn(); @@ -58,4 +58,65 @@ describe('tool event handlers', () => { expect(updateArgs).not.toHaveBeenCalled(); expect(requestRender).not.toHaveBeenCalled(); }); + + it('routes subagent renderer progress into a subagent-style component', () => { + const component = { updateResult: vi.fn() }; + const requestRender = vi.fn(); + const invalidate = vi.fn(); + const ctx = { + addChildBeforeFollowUps: vi.fn(child => ctx.state.chatContainer.children.push(child)), + state: { + quietMode: false, + pluginManager: { + getToolRenderConfig: vi.fn(() => ({ type: 'subagent', agentType: 'alexandria' })), + }, + pendingTools: new Map(), + pendingSubagents: new Map(), + allToolComponents: [], + seenToolCallIds: new Set(), + session: { + displayState: { + get: () => ({ + toolInputBuffers: new Map([ + ['call-1', { text: '{"question":"Answer from Alexandria"}', toolName: 'mastra_expert' }], + ]), + }), + }, + }, + chatContainer: { children: [], invalidate }, + ui: { requestRender }, + }, + } as any; + + handleToolInputStart(ctx, 'call-1', 'mastra_expert'); + handleToolInputDelta(ctx, 'call-1', 'ignored-delta'); + handleToolUpdate(ctx, 'call-1', { + event: 'tool_start', + toolName: 'search_content', + args: { query: 'plugins' }, + }); + handleToolUpdate(ctx, 'call-1', { + event: 'text', + text: 'Streaming answer text', + }); + handleToolUpdate(ctx, 'call-1', { + event: 'text', + text: 'Updated answer text', + }); + + expect(ctx.state.pendingTools.has('call-1')).toBe(false); + expect(ctx.state.pendingSubagents.has('call-1')).toBe(true); + expect(ctx.state.streamingComponent).toBe(ctx.state.chatContainer.children.at(-1)); + expect(ctx.state.chatContainer.children).toHaveLength(2); + expect(component.updateResult).not.toHaveBeenCalled(); + expect(requestRender).toHaveBeenCalled(); + + const rendered = ctx.state.chatContainer.children + .map((child: any) => child.render?.(80)?.join('\n') ?? '') + .join('\n'); + expect(rendered).toContain('mastra_expert'); + expect(rendered).toContain('search_content'); + expect(rendered).not.toContain('Streaming answer text'); + expect(rendered).toContain('Updated answer text'); + }); }); diff --git a/mastracode/src/tui/handlers/agent-lifecycle.ts b/mastracode/src/tui/handlers/agent-lifecycle.ts index be806e420f04..1d89d79c480f 100644 --- a/mastracode/src/tui/handlers/agent-lifecycle.ts +++ b/mastracode/src/tui/handlers/agent-lifecycle.ts @@ -8,7 +8,6 @@ import { getCurrentGitBranchAsync } from '../../utils/project.js'; import { insertChatComponentWithBoundarySpacing } from '../chat-boundary-reconciliation.js'; import { JudgeDisplayComponent } from '../components/judge-display.js'; import { GradientAnimator } from '../components/obi-loader.js'; -import { showError } from '../display.js'; import { pruneChatContainer } from '../prune-chat.js'; import { clearPendingUserMessages, removePendingUserMessage } from '../render-messages.js'; @@ -143,22 +142,19 @@ export function handleAgentAborted(ctx: EventHandlerContext): void { state.gradientAnimator.fadeOut(); } - // A plan "Request Changes" abort ends the run intentionally; clear any - // streaming state without surfacing an "Interrupted" error so the user can - // type revision feedback against a clean transcript. - if (state.planRejectionAbort) { + // User-initiated aborts and plan "Request Changes" aborts are intentional. + // The timing line already says "canceled after x", so don't also render a + // redundant "Error: Interrupted" message in the transcript. + if (state.planRejectionAbort || state.userInitiatedAbort) { state.streamingComponent = undefined; state.streamingMessage = undefined; } else if (state.streamingComponent && state.streamingMessage) { - // Update streaming message to show it was interrupted + // Update unexpected aborted streams to show they were interrupted. state.streamingMessage.stopReason = 'aborted'; state.streamingMessage.errorMessage = 'Interrupted'; state.streamingComponent.updateContent(state.streamingMessage); state.streamingComponent = undefined; state.streamingMessage = undefined; - } else if (state.userInitiatedAbort) { - // Show standalone "Interrupted" if user pressed Ctrl+C but no streaming component - showError(state, 'Interrupted'); } state.userInitiatedAbort = false; state.planRejectionAbort = false; diff --git a/mastracode/src/tui/handlers/message.ts b/mastracode/src/tui/handlers/message.ts index 75dee3c368de..a47b56eed7e8 100644 --- a/mastracode/src/tui/handlers/message.ts +++ b/mastracode/src/tui/handlers/message.ts @@ -23,6 +23,7 @@ import { UserMessageComponent } from '../components/user-message.js'; import { addChildBeforeMessageOrFollowUps } from '../render-messages.js'; import { getMarkdownTheme } from '../theme.js'; +import { createStaticSubagentComponent } from './tool.js'; import type { EventHandlerContext } from './types.js'; function getCurrentModeColor(ctx: EventHandlerContext): string | undefined { @@ -480,6 +481,17 @@ export function handleMessageUpdate(ctx: EventHandlerContext, message: AgentCont if (!state.seenToolCallIds.has(content.id)) { state.seenToolCallIds.add(content.id); + const preContent = getContentBeforeToolCall(message, content.id, state.seenToolCallIds); + state.streamingComponent.updateContent({ + ...message, + content: preContent, + }); + + if (createStaticSubagentComponent(ctx, content.id, content.name, content.args)) { + createdStreamingComponent = true; + continue; + } + const component = new ToolExecutionComponentEnhanced( content.name, content.args, diff --git a/mastracode/src/tui/handlers/tool.ts b/mastracode/src/tui/handlers/tool.ts index 30e9a5bfbb40..eb5443a29816 100644 --- a/mastracode/src/tui/handlers/tool.ts +++ b/mastracode/src/tui/handlers/tool.ts @@ -15,6 +15,7 @@ import { reconcileChatBoundarySpacers } from '../chat-boundary-reconciliation.js import { AskQuestionInlineComponent } from '../components/ask-question-inline.js'; import { AssistantMessageComponent } from '../components/assistant-message.js'; import { PlanApprovalInlineComponent } from '../components/plan-approval-inline.js'; +import { SubagentExecutionComponent } from '../components/subagent-execution.js'; import { ToolApprovalDialogComponent } from '../components/tool-approval-dialog.js'; import type { ApprovalAction } from '../components/tool-approval-dialog.js'; import { ToolExecutionComponentEnhanced } from '../components/tool-execution-enhanced.js'; @@ -45,6 +46,104 @@ function reconcileToolBoundaries(ctx: EventHandlerContext): void { reconcileChatBoundarySpacers(ctx.state.chatContainer); } +const pluginSubagentToolCallIds = new Set<string>(); + +type SubagentProgressEvent = + | { event: 'text'; text: string } + | { event: 'tool_start'; toolName: string; args?: unknown } + | { event: 'tool_end'; toolName: string; result?: unknown; isError?: boolean } + | { event: 'finish'; isError?: boolean; durationMs?: number; result?: string }; + +function isSubagentProgressEvent(value: unknown): value is SubagentProgressEvent { + if (!value || typeof value !== 'object') return false; + const event = (value as Record<string, unknown>).event; + return event === 'text' || event === 'tool_start' || event === 'tool_end' || event === 'finish'; +} + +function getTaskFromArgs(args: unknown, fallback: string): string { + if (args && typeof args === 'object') { + const question = (args as Record<string, unknown>).question; + if (typeof question === 'string' && question.trim()) return question; + const task = (args as Record<string, unknown>).task; + if (typeof task === 'string' && task.trim()) return task; + } + return fallback; +} + +export function createStaticSubagentComponent( + ctx: EventHandlerContext, + toolCallId: string, + toolName: string, + args: unknown, +): SubagentExecutionComponent | undefined { + const renderConfig = ctx.state.pluginManager?.getToolRenderConfig(toolName); + if (renderConfig?.type !== 'subagent') return undefined; + + const { state } = ctx; + const existing = state.pendingSubagents.get(toolCallId); + if (existing) { + existing.setTask(getTaskFromArgs(args, toolName)); + return existing; + } + + const component = new SubagentExecutionComponent( + renderConfig.agentType ?? 'plugin', + getTaskFromArgs(args, toolName), + state.ui, + renderConfig.modelId, + { + collapseOnComplete: false, + expandOnComplete: state.quietMode, + forked: renderConfig.forked, + label: renderConfig.label, + maxActivityLines: renderConfig.maxActivityLines, + collapsedLines: renderConfig.collapsedLines, + colors: renderConfig.colors, + icons: renderConfig.icons, + }, + ); + state.pendingSubagents.set(toolCallId, component); + pluginSubagentToolCallIds.add(toolCallId); + state.allToolComponents.push(component as any); + ctx.addChildBeforeFollowUps(component); + + state.streamingComponent = new AssistantMessageComponent(undefined, state.hideThinkingBlock, getMarkdownTheme()); + ctx.addChildBeforeFollowUps(state.streamingComponent); + + reconcileToolBoundaries(ctx); + state.ui.requestRender(); + return component; +} + +function handleSubagentProgress( + ctx: EventHandlerContext, + toolCallId: string, + progress: SubagentProgressEvent, +): boolean { + const { state } = ctx; + const component = state.pendingSubagents.get(toolCallId); + if (!component || !pluginSubagentToolCallIds.has(toolCallId)) return false; + + switch (progress.event) { + case 'text': + component.setText(progress.text); + break; + case 'tool_start': + component.addToolStart(progress.toolName, progress.args); + break; + case 'tool_end': + component.addToolEnd(progress.toolName, progress.result, progress.isError ?? false); + break; + case 'finish': + component.finish(progress.isError ?? false, progress.durationMs ?? 0, progress.result); + break; + } + + reconcileToolBoundaries(ctx); + state.ui.requestRender(); + return true; +} + function insertTaskToolErrorComponent(ctx: EventHandlerContext, component: unknown): void { const { state } = ctx; if (state.streamingComponent) { @@ -176,6 +275,11 @@ export function handleToolStart(ctx: EventHandlerContext, toolCallId: string, to const existingComponent = state.pendingTools.get(toolCallId); const existingSubmitPlanComponent = state.pendingSubmitPlanComponents?.get(toolCallId); + if (state.pendingSubagents.has(toolCallId) && pluginSubagentToolCallIds.has(toolCallId)) { + createStaticSubagentComponent(ctx, toolCallId, toolName, args); + return; + } + if (existingComponent) { // Component was created during input streaming — update with final args existingComponent.updateArgs(args); @@ -191,6 +295,10 @@ export function handleToolStart(ctx: EventHandlerContext, toolCallId: string, to return; } + if (createStaticSubagentComponent(ctx, toolCallId, toolName, args)) { + return; + } + // Skip creating regular component for ask_user — it uses AskQuestionInlineComponent // (normally created by handleToolInputStart, but handleToolStart may fire first) if (toolName === 'ask_user') { @@ -244,6 +352,10 @@ export function handleToolStart(ctx: EventHandlerContext, toolCallId: string, to } export function handleToolUpdate(ctx: EventHandlerContext, toolCallId: string, partialResult: unknown): void { + if (isSubagentProgressEvent(partialResult) && handleSubagentProgress(ctx, toolCallId, partialResult)) { + return; + } + const { state } = ctx; const component = state.pendingTools.get(toolCallId); if (component) { @@ -336,6 +448,10 @@ export function handleToolInputStart(ctx: EventHandlerContext, toolCallId: strin ctx.addChildBeforeFollowUps(state.streamingComponent); state.ui.requestRender(); } else if (toolName !== 'subagent') { + if (createStaticSubagentComponent(ctx, toolCallId, toolName, {})) { + return; + } + const component = new ToolExecutionComponentEnhanced( toolName, {}, @@ -451,11 +567,17 @@ export function handleToolEnd(ctx: EventHandlerContext, toolCallId: string, resu // If this is a subagent tool, store the result in the SubagentExecutionComponent const subagentComponent = state.pendingSubagents.get(toolCallId); if (subagentComponent) { - // The final result is available here const resultText = formatToolResult(result); - // We'll need to wait for subagent_end to set this - // Store it temporarily - (subagentComponent as any)._pendingResult = resultText; + if (pluginSubagentToolCallIds.has(toolCallId)) { + subagentComponent.finish(isError, 0, resultText); + state.pendingSubagents.delete(toolCallId); + pluginSubagentToolCallIds.delete(toolCallId); + state.ui.requestRender(); + } else { + // We'll need to wait for subagent_end to set this + // Store it temporarily + (subagentComponent as any)._pendingResult = resultText; + } } // File modification tracking is handled by the AgentController display state diff --git a/mastracode/src/tui/mastra-tui.ts b/mastracode/src/tui/mastra-tui.ts index 45aad82e4dea..7695030acbe2 100644 --- a/mastracode/src/tui/mastra-tui.ts +++ b/mastracode/src/tui/mastra-tui.ts @@ -23,6 +23,7 @@ import { THREAD_ACTIVE_MODEL_PACK_ID_KEY, MEMORY_GATEWAY_PROVIDER, } from '../onboarding/settings.js'; +import type { LoadedPlugin } from '../plugins/types.js'; import { detectPackageManager, fetchChangelog, @@ -152,10 +153,11 @@ export function consumePendingImages( export class MastraTUI { private state: TUIState; private updateCheckTimer: ReturnType<typeof setInterval> | null = null; - private idleCounterTimer: ReturnType<typeof setInterval> | null = null; + private statusTimingTimer: ReturnType<typeof setInterval> | null = null; private hasShownUpdateBanner = false; private caffeinateProcess: ChildProcess | null = null; private cleanupKeyHandlers?: () => void; + private cleanupPluginReloadListener?: () => void; private lastStreamError: string | null = null; private static readonly DOUBLE_CTRL_C_MS = 500; @@ -359,7 +361,7 @@ export class MastraTUI { * Errors are handled via controller events. */ private fireMessage(content: string, images?: Array<{ data: string; mimeType: string }>): void { - this.clearIdleCounter(); + this.clearStatusTimingTicker(); const files = images?.map(img => ({ data: img.data, mediaType: img.mimeType })); this.state.session.sendMessage({ content, files }).catch(error => { showError(this.state, error instanceof Error ? error.message : 'Unknown error'); @@ -430,7 +432,7 @@ export class MastraTUI { pendingNewThread: boolean, ): void { const send = () => { - this.clearIdleCounter(); + this.clearStatusTimingTicker(); this.state.analytics?.capture('mastracode_prompt_submitted', { threadId: this.state.session.thread.getId(), resourceId: this.state.session.identity.getResourceId(), @@ -467,7 +469,7 @@ export class MastraTUI { const hasActiveRun = this.state.session.stream.isActive(); const send = () => { - this.clearIdleCounter(); + this.clearStatusTimingTicker(); if (hasActiveRun) { this.state.pendingApprovalDismiss?.(USER_MESSAGE_APPROVAL_INTERRUPT); } @@ -541,22 +543,35 @@ export class MastraTUI { this.updateCheckTimer = null; } - if (this.idleCounterTimer) { - clearInterval(this.idleCounterTimer); - this.idleCounterTimer = null; - } + this.clearStatusTimingTicker(); if (this.cleanupKeyHandlers) { this.cleanupKeyHandlers(); this.cleanupKeyHandlers = undefined; } + if (this.cleanupPluginReloadListener) { + this.cleanupPluginReloadListener(); + this.cleanupPluginReloadListener = undefined; + } + if (this.state.unsubscribe) { this.state.unsubscribe(); } this.state.ui.stop(); } + private async refreshPluginRuntimeState(plugins: LoadedPlugin[]): Promise<void> { + const activePlugins = plugins.filter(plugin => plugin.status === 'active'); + this.state.session.state.set({ + pluginSkillPaths: activePlugins.flatMap(plugin => plugin.skillPaths ?? []), + pluginCommandPaths: activePlugins.flatMap(plugin => plugin.commandPaths ?? []), + pluginInstructions: activePlugins.flatMap(plugin => (plugin.instructions ? [plugin.instructions] : [])), + }); + await loadCustomSlashCommands(this.state); + await refreshSkillsAutocomplete(this.state); + } + // =========================================================================== // Initialization // =========================================================================== @@ -568,6 +583,15 @@ export class MastraTUI { await this.state.controller.init(); await this.state.controller.getMastra()?.startWorkers(); + if (this.state.pluginManager && !this.cleanupPluginReloadListener) { + this.cleanupPluginReloadListener = this.state.pluginManager.onReload(plugins => + this.refreshPluginRuntimeState(plugins).catch(err => { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[plugin runtime refresh] ${msg}\n`); + }), + ); + } + // Load custom slash commands await loadCustomSlashCommands(this.state); @@ -656,18 +680,34 @@ export class MastraTUI { updateStatusLine(this.state); } - private startIdleCounter(idleStartedAt = Date.now(), now = Date.now()): void { - this.state.idleStartedAt = idleStartedAt; - this.state.idleCounter?.setIdleStartedAt(idleStartedAt, now); + private updateIdleStatusLine(now = Date.now()): void { + this.state.idleCounter?.setTimingState(this.state, now); this.state.ui.requestRender?.(); + } - if (this.idleCounterTimer) { - clearInterval(this.idleCounterTimer); - } + private updateActiveStatusTiming(now = Date.now()): void { + this.state.idleCounter?.setTimingState(this.state, now); + updateStatusLine(this.state); + this.state.ui.requestRender?.(); + } - this.idleCounterTimer = setInterval(() => { - this.state.idleCounter?.update(); - this.state.ui.requestRender?.(); + private startActiveStatusTimingTicker(): void { + this.clearStatusTimingTicker(); + this.updateActiveStatusTiming(); + this.statusTimingTimer = setInterval(() => { + if (this.state.agentRunStartedAt === undefined) { + this.clearStatusTimingTicker(false); + return; + } + this.updateActiveStatusTiming(); + }, 1_000); + } + + private startIdleStatusTimingTicker(): void { + this.clearStatusTimingTicker(); + this.updateIdleStatusLine(); + this.statusTimingTimer = setInterval(() => { + this.updateIdleStatusLine(); }, 60_000); } @@ -675,26 +715,28 @@ export class MastraTUI { await renderExistingMessages(this.state); if (this.state.session.run.isRunning()) { - this.clearIdleCounter(); + this.clearStatusTimingTicker(); return; } if (this.state.lastRenderedMessageAt === undefined) { - this.clearIdleCounter(); + this.clearStatusTimingTicker(); return; } - this.startIdleCounter(this.state.lastRenderedMessageAt); + this.state.lastAgentRunEndedAt = this.state.lastRenderedMessageAt; + this.startIdleStatusTimingTicker(); } - private clearIdleCounter(): void { - this.state.idleStartedAt = undefined; - this.state.idleCounter?.setIdleStartedAt(undefined); - if (this.idleCounterTimer) { - clearInterval(this.idleCounterTimer); - this.idleCounterTimer = null; + private clearStatusTimingTicker(clearDisplay = true): void { + if (this.statusTimingTimer) { + clearInterval(this.statusTimingTimer); + this.statusTimingTimer = null; + } + if (clearDisplay) { + this.state.idleCounter?.setTimingState(undefined); + this.state.ui.requestRender?.(); } - this.state.ui.requestRender?.(); } // =========================================================================== @@ -713,7 +755,7 @@ export class MastraTUI { private async handleEvent(event: AgentControllerEvent): Promise<void> { if (event.type === 'agent_start') { - this.clearIdleCounter(); + this.clearStatusTimingTicker(); this.startCaffeinate(); this.lastStreamError = null; } @@ -737,9 +779,13 @@ export class MastraTUI { await this.syncThreadActivePackMetadata(); } + if (event.type === 'agent_start' && this.state.agentRunStartedAt !== undefined) { + this.startActiveStatusTimingTicker(); + } + if (event.type === 'agent_end') { const stopReason = event.reason === 'aborted' ? 'aborted' : event.reason === 'error' ? 'error' : 'complete'; - this.startIdleCounter(); + this.startIdleStatusTimingTicker(); await this.runStopHook(stopReason); if (event.reason === 'error' && this.lastStreamError) { @@ -1072,6 +1118,7 @@ export class MastraTUI { session: this.state.session, hookManager: this.state.hookManager, mcpManager: this.state.mcpManager, + pluginManager: this.state.pluginManager, analytics: this.state.analytics, authStorage: this.state.authStorage, customSlashCommands: this.state.customSlashCommands, diff --git a/mastracode/src/tui/render-messages.ts b/mastracode/src/tui/render-messages.ts index 08f739c842cb..3f84122e08a5 100644 --- a/mastracode/src/tui/render-messages.ts +++ b/mastracode/src/tui/render-messages.ts @@ -69,6 +69,16 @@ function getCurrentModeColor(state: TUIState): string | undefined { return typeof color === 'string' ? color : undefined; } +function getTaskFromToolArgs(args: unknown, fallback: string): string { + if (args && typeof args === 'object') { + const question = (args as Record<string, unknown>).question; + if (typeof question === 'string' && question.trim()) return question; + const task = (args as Record<string, unknown>).task; + if (typeof task === 'string' && task.trim()) return task; + } + return fallback; +} + // ============================================================================= // renderClearedTasksInline // ============================================================================= @@ -838,6 +848,32 @@ export async function renderExistingMessages(state: TUIState): Promise<void> { } } + const pluginRenderConfig = state.pluginManager?.getToolRenderConfig(content.name); + if (pluginRenderConfig?.type === 'subagent') { + const rawResult = toolResult?.type === 'tool_result' ? formatToolResult(toolResult.result) : undefined; + const isErr = toolResult?.type === 'tool_result' && toolResult.isError; + const subComponent = new SubagentExecutionComponent( + pluginRenderConfig.agentType ?? 'plugin', + getTaskFromToolArgs(content.args, content.name), + state.ui, + pluginRenderConfig.modelId, + { + collapseOnComplete: false, + expandOnComplete: state.quietMode, + forked: pluginRenderConfig.forked, + label: pluginRenderConfig.label, + maxActivityLines: pluginRenderConfig.maxActivityLines, + collapsedLines: pluginRenderConfig.collapsedLines, + colors: pluginRenderConfig.colors, + icons: pluginRenderConfig.icons, + }, + ); + subComponent.finish(isErr ?? false, 0, rawResult); + insertChatComponentWithBoundarySpacing(state.chatContainer, subComponent); + state.allToolComponents.push(subComponent as any); + continue; + } + // Render the tool call const toolComponent = new ToolExecutionComponentEnhanced( content.name, diff --git a/mastracode/src/tui/setup.ts b/mastracode/src/tui/setup.ts index 0d7cd3af476f..33d0ff4ff684 100644 --- a/mastracode/src/tui/setup.ts +++ b/mastracode/src/tui/setup.ts @@ -358,6 +358,10 @@ export function setupAutocomplete(state: TUIState): void { name: 'yolo', description: 'Toggle YOLO mode (auto-approve all tools)', }, + { + name: 'voice', + description: 'Manage push-to-talk voice input (engine, provider, model)', + }, { name: 'review', description: 'Review a GitHub pull request' }, { name: 'report-issue', description: 'Open or browse mastracode issues' }, { name: 'setup', description: 'Re-run the setup wizard' }, @@ -365,6 +369,7 @@ export function setupAutocomplete(state: TUIState): void { { name: 'theme', description: 'Switch color theme (auto/dark/light)' }, { name: 'update', description: 'Check for and install updates' }, { name: 'api-keys', description: 'Manage API keys for model providers' }, + { name: 'plugins', description: 'Manage Mastra Code plugins' }, { name: 'observability', description: 'Configure cloud observability' }, { name: 'github', @@ -439,10 +444,11 @@ export function setupAutocomplete(state: TUIState): void { export async function loadCustomSlashCommands(state: TUIState): Promise<void> { try { - const configDir = (state.session.state.get() as { configDir?: string } | undefined)?.configDir; + const sessionState = state.session.state.get() as { configDir?: string; pluginCommandPaths?: string[] } | undefined; + const configDir = sessionState?.configDir; // Load from all sources (global and local) const globalCommands = await loadCustomCommands(undefined, configDir); - const localCommands = await loadCustomCommands(process.cwd(), configDir); + const localCommands = await loadCustomCommands(process.cwd(), configDir, sessionState?.pluginCommandPaths ?? []); // Merge commands, with local taking precedence over global for same names const commandMap = new Map<string, (typeof globalCommands)[number]>(); diff --git a/mastracode/src/tui/state.ts b/mastracode/src/tui/state.ts index d33d84ab01b4..b0719eb59d5b 100644 --- a/mastracode/src/tui/state.ts +++ b/mastracode/src/tui/state.ts @@ -14,6 +14,8 @@ import type { AuthStorage } from '../auth/storage.js'; import type { HookManager } from '../hooks/index.js'; import type { McpManager } from '../mcp/manager.js'; import type { OnboardingInlineComponent } from '../onboarding/onboarding-inline.js'; +import { loadSettings } from '../onboarding/settings.js'; +import type { PluginManager } from '../plugins/manager.js'; import { detectProject } from '../utils/project.js'; import type { ProjectInfo } from '../utils/project.js'; import type { SlashCommandMetadata } from '../utils/slash-command-loader.js'; @@ -34,9 +36,11 @@ import type { TaskProgressComponent } from './components/task-progress.js'; import type { TemporalGapComponent } from './components/temporal-gap.js'; import type { IToolExecutionComponent } from './components/tool-execution-interface.js'; import type { UserMessageComponent } from './components/user-message.js'; +import { showError, showInfo } from './display.js'; import { GoalManager } from './goal-manager.js'; import { getEditorTheme, mastra, TERM_WIDTH_BUFFER } from './theme.js'; +import { VoiceController } from './voice/voice-controller.js'; export interface PendingSignalMessage { component: Component; @@ -107,6 +111,9 @@ export interface MastraTUIOptions { /** MCP manager for server status and reload */ mcpManager?: McpManager; + /** Plugin manager for /plugins. */ + pluginManager?: PluginManager; + /** * @deprecated Workspace is now obtained from the AgentController. * Configure workspace via AgentControllerConfig.workspace instead. @@ -149,6 +156,7 @@ export interface TUIState { analytics?: MastraCodeAnalytics; authStorage?: AuthStorage; mcpManager?: McpManager; + pluginManager?: PluginManager; workspace?: Workspace; // ── TUI framework (set once) ────────────────────────────────────────── @@ -156,11 +164,11 @@ export interface TUIState { chatContainer: Container; editorContainer: Container; idleCounter?: IdleCounterComponent; - idleStartedAt?: number; lastRenderedMessageAt?: number; editor: CustomEditor; footer: Container; terminal: Terminal; + voiceController?: VoiceController; // ── Agent / streaming ───────────────────────────────────────────────── isInitialized: boolean; @@ -247,6 +255,16 @@ export interface TUIState { modelAuthStatus: { hasAuth: boolean; apiKeyEnvVar?: string }; githubPrGradientAnimator?: GradientAnimator; githubPrPollingActive: boolean; + /** Timestamp (ms) when the current agent run started. */ + agentRunStartedAt?: number; + /** Timestamp (ms) when the current agent run last received streamed content. */ + agentRunLastStreamPartAt?: number; + /** Duration (ms) of the most recently completed agent run. */ + lastAgentRunDurationMs?: number; + /** Timestamp (ms) when the most recent agent run ended. */ + lastAgentRunEndedAt?: number; + /** End state of the most recent agent run. */ + lastAgentRunEndReason?: 'done' | 'aborted' | 'error'; // ── Tokens/sec tracking ──────────────────────────────────────────────── /** @@ -336,6 +354,7 @@ export function createTUIState(options: MastraTUIOptions): TUIState { analytics: options.analytics, authStorage: options.authStorage, mcpManager: options.mcpManager, + pluginManager: options.pluginManager, workspace: options.workspace, // TUI framework @@ -416,5 +435,21 @@ export function createTUIState(options: MastraTUIOptions): TUIState { const color = result.session.mode.resolve()?.metadata?.color; return typeof color === 'string' ? color : undefined; }; + + const voiceSettings = loadSettings().voice; + result.voiceController = new VoiceController({ + authStorage: result.authStorage, + settings: voiceSettings, + onTranscript: text => editor.insertVoiceTranscript(text), + onPartialTranscript: text => editor.replaceVoiceTranscript(text), + showInfo: message => showInfo(result, message), + showError: message => showError(result, message), + onListeningChange: listening => editor.setVoiceListening(listening), + }); + editor.voiceInput = result.voiceController; + if (voiceSettings.enabled) { + result.voiceController.restoreEnabled(); + } + return result; } diff --git a/mastracode/src/tui/status-duration.ts b/mastracode/src/tui/status-duration.ts new file mode 100644 index 000000000000..046d842bb8c9 --- /dev/null +++ b/mastracode/src/tui/status-duration.ts @@ -0,0 +1,22 @@ +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; + +export function formatStatusDuration(ms: number, opts: { includeSeconds?: boolean } = {}): string { + const safeMs = Math.max(0, ms); + const days = Math.floor(safeMs / DAY_MS); + const hours = Math.floor((safeMs % DAY_MS) / HOUR_MS); + const minutes = Math.floor((safeMs % HOUR_MS) / MINUTE_MS); + const seconds = Math.floor((safeMs % MINUTE_MS) / 1000); + + if (days > 0) { + return `${days}d${hours > 0 ? `${hours}hr` : ''}${minutes > 0 ? `${minutes}m` : ''}`; + } + if (hours > 0) { + return `${hours}hr${minutes > 0 ? `${minutes}m` : ''}`; + } + if (opts.includeSeconds) { + return minutes > 0 ? `${minutes}m${seconds}s` : `${Math.max(1, seconds)}s`; + } + return `${Math.max(1, minutes)}m`; +} diff --git a/mastracode/src/tui/status-line.ts b/mastracode/src/tui/status-line.ts index 3a720b305921..91cf259a523b 100644 --- a/mastracode/src/tui/status-line.ts +++ b/mastracode/src/tui/status-line.ts @@ -7,6 +7,7 @@ import chalk from 'chalk'; import { applyGradientSweep } from './components/obi-loader.js'; import { formatObservationStatus, formatReflectionStatus } from './components/om-progress.js'; import type { GithubPrSubscriptionBadge, TUIState } from './state.js'; +import { formatStatusDuration } from './status-duration.js'; import { theme, mastra, tintHex, getTermWidth, extendedColors } from './theme.js'; // Colors for OM modes — read from proxy at render time so they pick up contrast adaptation @@ -271,6 +272,27 @@ export function updateStatusLine(state: TUIState): void { shortModeBadgeWidth = shortName.length + 2; } + const now = Date.now(); + const activeTimingLabel = + state.agentRunStartedAt !== undefined + ? formatStatusDuration(now - state.agentRunStartedAt, { includeSeconds: true }) + : ''; + const activeTimingIsStale = + state.agentRunStartedAt !== undefined && + state.agentRunLastStreamPartAt !== undefined && + now - state.agentRunLastStreamPartAt > 3 * 60_000; + const completedTimingLabel = + !activeTimingLabel && state.lastAgentRunDurationMs !== undefined + ? formatStatusDuration(state.lastAgentRunDurationMs, { includeSeconds: true }) + : ''; + const completedTimingIcon = + state.lastAgentRunEndReason === 'error' + ? '×' + : completedTimingLabel && state.lastAgentRunEndReason !== 'aborted' + ? '✓' + : ''; + const timingLabel = activeTimingLabel || completedTimingLabel; + const buildLine = (opts: { modelId: string; memCompact?: 'percentOnly' | 'noBuffer' | 'full'; @@ -283,9 +305,30 @@ export function updateStatusLine(state: TUIState): void { }): { plain: string; styled: string } | null => { const parts: Array<{ plain: string; styled: string }> = []; // Model ID (always present) — styleModelId adds padding spaces + const timingPlain = timingLabel ? ` ${timingLabel}${completedTimingIcon ? ` ${completedTimingIcon}` : ''}` : ''; + const timingColor = activeTimingIsStale + ? theme.fg('error', timingLabel) + : activeTimingLabel + ? modeColor + ? chalk.hex(modeColor)(timingLabel) + : theme.fg('dim', timingLabel) + : state.lastAgentRunEndReason === 'aborted' + ? theme.fg('warning', timingLabel) + : state.lastAgentRunEndReason === 'error' + ? theme.fg('error', timingLabel) + : theme.fg('success', timingLabel); + const timingIconColor = + state.lastAgentRunEndReason === 'aborted' + ? 'warning' + : state.lastAgentRunEndReason === 'error' + ? 'error' + : 'success'; + const timingStyled = timingLabel + ? ` ${timingColor}${completedTimingIcon ? ` ${theme.fg(timingIconColor, completedTimingIcon)}` : ''}` + : ''; parts.push({ - plain: `${opts.modelId}${tintBg ? ' ' : ''}`, - styled: styleModelId(opts.modelId), + plain: `${opts.modelId}${tintBg ? ' ' : ''}${timingPlain}`, + styled: styleModelId(opts.modelId) + timingStyled, }); const useBadge = opts.badge === 'short' ? shortModeBadge : modeBadge; const useBadgeWidth = opts.badge === 'short' ? shortModeBadgeWidth : modeBadgeWidth; diff --git a/mastracode/src/tui/voice/__tests__/fixtures/models-dev-stt-snapshot.json b/mastracode/src/tui/voice/__tests__/fixtures/models-dev-stt-snapshot.json new file mode 100644 index 000000000000..56a0ea8defbc --- /dev/null +++ b/mastracode/src/tui/voice/__tests__/fixtures/models-dev-stt-snapshot.json @@ -0,0 +1,153 @@ +{ + "alibaba-cn": { + "name": "Alibaba (China)", + "npm": "@ai-sdk/openai-compatible", + "api": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "models": { + "qwen3-asr-flash": { + "modalities": { + "input": ["audio"], + "output": ["text"] + } + } + } + }, + "vercel": { + "name": "Vercel AI Gateway", + "npm": "@ai-sdk/gateway", + "models": { + "xai/grok-stt": { + "modalities": { + "input": ["audio"], + "output": ["text"] + } + }, + "openai/gpt-4o-transcribe": { + "modalities": { + "input": ["audio"], + "output": ["text"] + } + }, + "openai/gpt-4o-mini-transcribe": { + "modalities": { + "input": ["audio"], + "output": ["text"] + } + }, + "openai/whisper-1": { + "modalities": { + "input": ["audio"], + "output": ["text"] + } + } + } + }, + "privatemode-ai": { + "name": "Privatemode AI", + "npm": "@ai-sdk/openai-compatible", + "api": "http://localhost:8080/v1", + "models": { + "whisper-large-v3": { + "modalities": { + "input": ["audio"], + "output": ["text"] + } + } + } + }, + "groq": { + "name": "Groq", + "npm": "@ai-sdk/groq", + "models": { + "whisper-large-v3-turbo": { + "modalities": { + "input": ["audio"], + "output": ["text"] + } + }, + "whisper-large-v3": { + "modalities": { + "input": ["audio"], + "output": ["text"] + } + } + } + }, + "nearai": { + "name": "NEAR AI Cloud", + "npm": "@ai-sdk/openai-compatible", + "api": "https://cloud-api.near.ai/v1", + "models": { + "openai/whisper-large-v3": { + "modalities": { + "input": ["audio"], + "output": ["text"] + } + } + } + }, + "nvidia": { + "name": "Nvidia", + "npm": "@ai-sdk/openai-compatible", + "api": "https://integrate.api.nvidia.com/v1", + "models": { + "openai/whisper-large-v3": { + "modalities": { + "input": ["audio"], + "output": ["text"] + } + } + } + }, + "evroc": { + "name": "evroc", + "npm": "@ai-sdk/openai-compatible", + "api": "https://models.think.evroc.com/v1", + "models": { + "openai/whisper-large-v3-turbo": { + "modalities": { + "input": ["audio"], + "output": ["text"] + } + }, + "openai/whisper-large-v3": { + "modalities": { + "input": ["audio"], + "output": ["text"] + } + }, + "KBLab/kb-whisper-large": { + "modalities": { + "input": ["audio"], + "output": ["text"] + } + } + } + }, + "scaleway": { + "name": "Scaleway", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.scaleway.ai/v1", + "models": { + "whisper-large-v3": { + "modalities": { + "input": ["audio"], + "output": ["text"] + } + } + } + }, + "alibaba": { + "name": "Alibaba", + "npm": "@ai-sdk/openai-compatible", + "api": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + "models": { + "qwen3-asr-flash": { + "modalities": { + "input": ["audio"], + "output": ["text"] + } + } + } + } +} diff --git a/mastracode/src/tui/voice/__tests__/stt-registry.test.ts b/mastracode/src/tui/voice/__tests__/stt-registry.test.ts new file mode 100644 index 000000000000..857927f2d61c --- /dev/null +++ b/mastracode/src/tui/voice/__tests__/stt-registry.test.ts @@ -0,0 +1,143 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_STT_MODEL, + DEFAULT_STT_PROVIDER, + defaultModelForProvider, + resolveSTTModel, + STT_MODELS, + sttModelsForProvider, + sttProviders, +} from '../stt-registry.js'; + +type Snapshot = Record< + string, + { + name?: string; + npm?: string; + api?: string; + models: Record<string, { modalities: { input: string[]; output: string[] } }>; + } +>; + +function loadSnapshot(): Snapshot { + const url = new URL('./fixtures/models-dev-stt-snapshot.json', import.meta.url); + return JSON.parse(readFileSync(fileURLToPath(url), 'utf8')) as Snapshot; +} + +/** + * Providers we deliberately do not surface in the picker, with a reason. These + * appear in the models.dev STT snapshot but are intentionally excluded: + * - `vercel`: AI Gateway that re-exposes openai/xai STT; we list the direct + * `openai` provider instead so users authenticate with their own key. + * - `privatemode-ai`: localhost-only endpoint, not usable as a hosted default. + */ +const INTENTIONALLY_EXCLUDED = new Set(['vercel', 'privatemode-ai']); + +describe('stt-registry', () => { + it('covers every directly-hosted STT model from the models.dev snapshot', () => { + const snapshot = loadSnapshot(); + const registryKeys = new Set(STT_MODELS.map(m => `${m.provider}/${m.model}`)); + + const missing: string[] = []; + for (const [provider, info] of Object.entries(snapshot)) { + if (INTENTIONALLY_EXCLUDED.has(provider)) continue; + for (const model of Object.keys(info.models)) { + if (!registryKeys.has(`${provider}/${model}`)) { + missing.push(`${provider}/${model}`); + } + } + } + + expect(missing, `STT models in models.dev but missing from the registry: ${missing.join(', ')}`).toEqual([]); + }); + + it('points openai-compatible entries at the models.dev base URL', () => { + const snapshot = loadSnapshot(); + for (const entry of STT_MODELS) { + if (entry.resolver !== 'openai-compatible') continue; + expect(entry.baseURL, `${entry.provider} should have a baseURL`).toBeTruthy(); + const snap = snapshot[entry.provider]; + if (snap?.api) { + expect(entry.baseURL).toBe(snap.api); + } + } + }); + + it('only uses the bare openai resolver for the openai provider', () => { + for (const entry of STT_MODELS) { + if (entry.resolver === 'openai') { + expect(entry.provider).toBe('openai'); + expect(entry.baseURL).toBeUndefined(); + } + } + }); + + it('routes groq through its OpenAI-compatible endpoint', () => { + const groq = sttModelsForProvider('groq'); + expect(groq.length).toBeGreaterThan(0); + for (const entry of groq) { + expect(entry.resolver).toBe('openai-compatible'); + expect(entry.baseURL).toBe('https://api.groq.com/openai/v1'); + } + }); + + it('includes Deepgram as a dedicated (non-OpenAI-compatible) provider', () => { + const deepgram = sttModelsForProvider('deepgram'); + expect(deepgram.length).toBeGreaterThan(0); + for (const entry of deepgram) { + expect(entry.resolver).toBe('deepgram'); + // Deepgram uses its own SDK, not an OpenAI-compatible baseURL. + expect(entry.baseURL).toBeUndefined(); + } + expect(defaultModelForProvider('deepgram')?.model).toBe('nova-3'); + }); + + it('does not require Deepgram to appear in the models.dev snapshot', () => { + // Deepgram is added deliberately; it is not in the models.dev STT snapshot. + const snapshot = loadSnapshot(); + expect(snapshot.deepgram).toBeUndefined(); + expect(sttProviders()).toContain('deepgram'); + }); + + it('defaults to OpenAI whisper-1', () => { + expect(DEFAULT_STT_PROVIDER).toBe('openai'); + expect(DEFAULT_STT_MODEL.model).toBe('whisper-1'); + }); + + it('lists providers in registry order without duplicates', () => { + const providers = sttProviders(); + expect(new Set(providers).size).toBe(providers.length); + expect(providers[0]).toBe('openai'); + expect(providers).toContain('groq'); + }); + + it('returns the first model for a provider as its default', () => { + expect(defaultModelForProvider('groq')?.model).toBe('whisper-large-v3-turbo'); + expect(sttModelsForProvider('groq').map(m => m.model)).toEqual(['whisper-large-v3-turbo', 'whisper-large-v3']); + expect(defaultModelForProvider('nope')).toBeUndefined(); + }); + + describe('resolveSTTModel', () => { + it('returns an exact match when provider+model are known', () => { + const m = resolveSTTModel('groq', 'whisper-large-v3'); + expect(m.provider).toBe('groq'); + expect(m.model).toBe('whisper-large-v3'); + }); + + it('falls back to the provider default for an unknown model', () => { + const m = resolveSTTModel('groq', 'made-up'); + expect(m.model).toBe('whisper-large-v3-turbo'); + }); + + it('falls back to the global default for an unknown provider', () => { + const m = resolveSTTModel('made-up', 'made-up'); + expect(m).toEqual(DEFAULT_STT_MODEL); + }); + + it('falls back to the global default with no arguments', () => { + expect(resolveSTTModel()).toEqual(DEFAULT_STT_MODEL); + }); + }); +}); diff --git a/mastracode/src/tui/voice/__tests__/transcribe.test.ts b/mastracode/src/tui/voice/__tests__/transcribe.test.ts new file mode 100644 index 000000000000..9680859d546a --- /dev/null +++ b/mastracode/src/tui/voice/__tests__/transcribe.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * Captured constructor configs so we can assert how each provider's voice client + * was built (model name, apiKey, baseURL) without hitting the network. + */ +const openaiCalls: Array<Record<string, any>> = []; +const deepgramCalls: Array<Record<string, any>> = []; + +vi.mock('@mastra/voice-openai', () => ({ + OpenAIVoice: class { + constructor(config: Record<string, any>) { + openaiCalls.push(config); + } + async listen() { + return 'openai transcript'; + } + }, +})); + +vi.mock('@mastra/voice-deepgram', () => ({ + DeepgramVoice: class { + constructor(config: Record<string, any>) { + deepgramCalls.push(config); + } + async listen() { + // Deepgram returns an object shape, not a bare string. + return { transcript: 'deepgram transcript', words: [] }; + } + }, +})); + +import { transcribeAudio, VoiceCredentialError, resolveProviderApiKey } from '../transcribe.js'; + +const AUDIO = Buffer.from('fake-wav'); + +describe('transcribeAudio provider routing', () => { + beforeEach(() => { + openaiCalls.length = 0; + deepgramCalls.length = 0; + vi.unstubAllEnvs(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('routes the default (openai/whisper-1) through OpenAIVoice with no baseURL', async () => { + vi.stubEnv('OPENAI_API_KEY', 'sk-openai'); + const text = await transcribeAudio(AUDIO); + expect(text).toBe('openai transcript'); + expect(deepgramCalls).toHaveLength(0); + expect(openaiCalls).toHaveLength(1); + expect(openaiCalls[0]!.listeningModel.name).toBe('whisper-1'); + expect(openaiCalls[0]!.listeningModel.apiKey).toBe('sk-openai'); + expect(openaiCalls[0]!.listeningModel.options).toBeUndefined(); + }); + + it('routes an openai-compatible host (groq) through OpenAIVoice with its baseURL', async () => { + vi.stubEnv('GROQ_API_KEY', 'gsk-groq'); + const text = await transcribeAudio(AUDIO, { provider: 'groq', model: 'whisper-large-v3-turbo' }); + expect(text).toBe('openai transcript'); + expect(openaiCalls).toHaveLength(1); + expect(openaiCalls[0]!.listeningModel.name).toBe('whisper-large-v3-turbo'); + expect(openaiCalls[0]!.listeningModel.apiKey).toBe('gsk-groq'); + expect(openaiCalls[0]!.listeningModel.options.baseURL).toBe('https://api.groq.com/openai/v1'); + }); + + it('routes Deepgram through the dedicated DeepgramVoice and normalizes { transcript }', async () => { + vi.stubEnv('DEEPGRAM_API_KEY', 'dg-key'); + const text = await transcribeAudio(AUDIO, { provider: 'deepgram', model: 'nova-3' }); + expect(text).toBe('deepgram transcript'); + expect(openaiCalls).toHaveLength(0); + expect(deepgramCalls).toHaveLength(1); + expect(deepgramCalls[0]!.listeningModel.name).toBe('nova-3'); + expect(deepgramCalls[0]!.listeningModel.apiKey).toBe('dg-key'); + }); + + it('throws VoiceCredentialError when the provider has no key', async () => { + // Force the key empty so the test is deterministic regardless of the host + // environment (e.g. a real DEEPGRAM_API_KEY exported on a dev machine/CI). + vi.stubEnv('DEEPGRAM_API_KEY', ''); + await expect(transcribeAudio(AUDIO, { provider: 'deepgram' })).rejects.toBeInstanceOf(VoiceCredentialError); + }); + + it('resolveProviderApiKey honors env over stored credentials', () => { + vi.stubEnv('DEEPGRAM_API_KEY', 'env-key'); + const authStorage = { getStoredApiKey: () => 'stored-key' } as any; + expect(resolveProviderApiKey('deepgram', authStorage)).toBe('env-key'); + }); + + it('resolveProviderApiKey falls back to stored credentials when env is unset', () => { + const authStorage = { getStoredApiKey: (p: string) => (p === 'groq' ? 'stored-groq' : undefined) } as any; + expect(resolveProviderApiKey('groq', authStorage)).toBe('stored-groq'); + }); +}); diff --git a/mastracode/src/tui/voice/__tests__/voice-controller.test.ts b/mastracode/src/tui/voice/__tests__/voice-controller.test.ts new file mode 100644 index 000000000000..cbe2b182bfe9 --- /dev/null +++ b/mastracode/src/tui/voice/__tests__/voice-controller.test.ts @@ -0,0 +1,231 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { VoiceSettings } from '../../../onboarding/settings.js'; +import type { STTEngine, STTSession, STTSessionCallbacks } from '../engines/types.js'; + +const mocks = vi.hoisted(() => ({ + createSTTEngine: vi.fn(), +})); + +vi.mock('../engines/index.js', () => ({ + createSTTEngine: mocks.createSTTEngine, +})); + +import { VoiceController } from '../voice-controller.js'; + +/** A controllable fake engine + session for driving the controller in tests. */ +function makeFakeEngine(checkReadyResult: string | null = null) { + let captured: STTSessionCallbacks | null = null; + const stop = vi.fn(async () => {}); + const cancel = vi.fn(); + const session: STTSession = { stop, cancel }; + const engine: STTEngine = { + kind: 'cloud', + checkReady: vi.fn(() => checkReadyResult), + start: vi.fn((cb: STTSessionCallbacks) => { + captured = cb; + return session; + }), + }; + return { + engine, + session, + stop, + cancel, + emitPartial: (t: string) => captured?.onPartial(t), + emitFinal: (t: string) => captured?.onFinal(t), + emitError: (e: Error) => captured?.onError(e), + }; +} + +const SETTINGS: VoiceSettings = { enabled: false, engine: 'cloud', provider: 'openai', model: 'whisper-1' }; + +function makeController(opts?: { live?: boolean; checkReady?: string | null }) { + const fake = makeFakeEngine(opts?.checkReady ?? null); + mocks.createSTTEngine.mockReturnValue(fake.engine); + const onTranscript = vi.fn(); + const onPartialTranscript = opts?.live ? vi.fn() : undefined; + const showInfo = vi.fn(); + const showError = vi.fn(); + const controller = new VoiceController({ + settings: SETTINGS, + onTranscript, + onPartialTranscript, + showInfo, + showError, + }); + return { controller, fake, onTranscript, onPartialTranscript, showInfo, showError }; +} + +describe('VoiceController', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('enables when the engine reports ready', () => { + const { controller, showInfo } = makeController(); + expect(controller.enable()).toBe(true); + expect(controller.isEnabled()).toBe(true); + expect(showInfo).toHaveBeenCalled(); + }); + + it('refuses to enable when the engine is not ready', () => { + const { controller, showError } = makeController({ checkReady: 'needs a recorder' }); + expect(controller.enable()).toBe(false); + expect(controller.isEnabled()).toBe(false); + expect(showError).toHaveBeenCalledWith('needs a recorder'); + }); + + it('toggles between enabled and disabled', () => { + const { controller } = makeController(); + expect(controller.toggle()).toBe(true); + expect(controller.toggle()).toBe(false); + expect(controller.isEnabled()).toBe(false); + }); + + it('ignores startRecording while disabled', () => { + const { controller, fake } = makeController(); + controller.startRecording(); + expect(controller.isRecording()).toBe(false); + expect(fake.engine.start).not.toHaveBeenCalled(); + }); + + it('records and streams the final transcript word-by-word (non-live)', async () => { + const { controller, fake, onTranscript } = makeController(); + controller.enable(); + + controller.startRecording(); + expect(controller.isRecording()).toBe(true); + + const stopped = controller.stopRecording(); + fake.emitFinal('hello world'); + await stopped; + // streamTranscript feeds chunks with small delays; wait for it to drain. + await new Promise(r => setTimeout(r, 100)); + + const streamed = onTranscript.mock.calls.map(call => call[0]).join(''); + expect(streamed).toBe('hello world'); + expect(controller.getState()).toBe('idle'); + }); + + it('reports an engine error via showError', async () => { + const { controller, fake, showError } = makeController(); + controller.enable(); + controller.startRecording(); + + fake.emitError(new Error('boom')); + await Promise.resolve(); + + expect(showError).toHaveBeenCalledWith('boom'); + }); + + it('appends permission fix steps when the engine explains a blocked permission', async () => { + const { controller, fake, showError } = makeController(); + // Engine can describe a blocked permission with concrete steps. + (fake.engine as { permissions?: () => Promise<unknown> }).permissions = vi.fn(async () => ({ + state: 'blocked', + summary: 'Microphone access is turned off for your terminal.', + steps: ['Open System Settings › Privacy & Security › Microphone.', 'Turn it on, then restart the terminal.'], + })); + controller.enable(); + controller.startRecording(); + + fake.emitError(new Error('permission denied')); + await Promise.resolve(); + await Promise.resolve(); + + expect(showError).toHaveBeenCalledWith(expect.stringContaining('Microphone access is turned off')); + expect(showError).toHaveBeenCalledWith(expect.stringContaining('1. Open System Settings')); + }); + + it('does not dress a session error with will-prompt guidance', async () => { + const { controller, fake, showError } = makeController(); + // A not-yet-determined state is not the reason a session failed, so it must + // not replace the real error with a "macOS will prompt next time" message. + (fake.engine as { permissions?: () => Promise<unknown> }).permissions = vi.fn(async () => ({ + state: 'will-prompt', + summary: "macOS hasn't asked for access yet — it will prompt the first time you dictate.", + steps: ['Hold the space bar and start speaking.', 'Click Allow when macOS asks.'], + })); + controller.enable(); + controller.startRecording(); + + fake.emitError(new Error('recognizer stopped before it could start')); + await Promise.resolve(); + await Promise.resolve(); + + expect(showError).toHaveBeenCalledWith('recognizer stopped before it could start'); + expect(showError).not.toHaveBeenCalledWith(expect.stringContaining('will prompt')); + }); + + it('shows a hint when the final transcript is empty (non-live)', async () => { + const { controller, fake, showInfo, onTranscript } = makeController(); + controller.enable(); + controller.startRecording(); + + const stopped = controller.stopRecording(); + fake.emitFinal(''); + await stopped; + + expect(onTranscript).not.toHaveBeenCalled(); + expect(showInfo).toHaveBeenCalledWith('No speech detected.'); + }); + + it('streams live partials and replaces with the final on stop (live)', async () => { + const { controller, fake, onPartialTranscript, onTranscript } = makeController({ live: true }); + controller.enable(); + controller.startRecording(); + + fake.emitPartial('hello'); + expect(onPartialTranscript).toHaveBeenCalledWith('hello'); + + const stopped = controller.stopRecording(); + fake.emitFinal('hello world'); + await stopped; + + expect(onPartialTranscript).toHaveBeenLastCalledWith('hello world'); + expect(onTranscript).not.toHaveBeenCalled(); + expect(controller.getState()).toBe('idle'); + }); + + it('cancels an in-progress session on disable', () => { + const { controller, fake } = makeController(); + controller.enable(); + controller.startRecording(); + controller.disable(); + expect(fake.cancel).toHaveBeenCalledTimes(1); + expect(controller.isRecording()).toBe(false); + }); + + it('reconfigure rebuilds the engine and re-validates when enabled', () => { + const { controller, showError } = makeController(); + controller.enable(); + expect(controller.isEnabled()).toBe(true); + + // Next engine reports not-ready; reconfigure should disable + surface it. + const next = makeFakeEngine('macOS native only on macOS'); + mocks.createSTTEngine.mockReturnValue(next.engine); + controller.reconfigure({ ...SETTINGS, engine: 'macos-native' }); + + expect(controller.isEnabled()).toBe(false); + expect(showError).toHaveBeenCalledWith('macOS native only on macOS'); + }); + + it('verifyReady prefers the engine async verify() when present', async () => { + const { controller, fake } = makeController(); + (fake.engine as STTEngine).verify = vi.fn(async () => 'swiftc not installed'); + await expect(controller.verifyReady()).resolves.toBe('swiftc not installed'); + expect(fake.engine.verify).toHaveBeenCalledTimes(1); + expect(fake.engine.checkReady).not.toHaveBeenCalled(); + }); + + it('verifyReady falls back to checkReady() when no async verify exists', async () => { + const { controller, fake } = makeController({ checkReady: 'needs a recorder' }); + expect(fake.engine.verify).toBeUndefined(); + await expect(controller.verifyReady()).resolves.toBe('needs a recorder'); + }); +}); diff --git a/mastracode/src/tui/voice/engines/__tests__/cloud-engine.test.ts b/mastracode/src/tui/voice/engines/__tests__/cloud-engine.test.ts new file mode 100644 index 000000000000..946450361711 --- /dev/null +++ b/mastracode/src/tui/voice/engines/__tests__/cloud-engine.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + detectRecorder: vi.fn(), + stop: vi.fn(), + cancel: vi.fn(), + snapshot: vi.fn(), + MicRecording: vi.fn(), + transcribe: vi.fn(), + createTranscriber: vi.fn(), + hasProviderCredential: vi.fn(), +})); + +vi.mock('../../mic-capture.js', () => ({ + detectRecorder: mocks.detectRecorder, + MicRecording: class { + constructor(..._args: unknown[]) { + mocks.MicRecording(..._args); + } + stop() { + return mocks.stop(); + } + cancel() { + return mocks.cancel(); + } + snapshot() { + return mocks.snapshot(); + } + }, +})); + +vi.mock('../../transcribe.js', () => ({ + createTranscriber: mocks.createTranscriber, + hasProviderCredential: mocks.hasProviderCredential, +})); + +import { CloudSTTEngine } from '../cloud-engine.js'; + +function callbacks() { + return { onPartial: vi.fn(), onFinal: vi.fn(), onError: vi.fn() }; +} + +describe('CloudSTTEngine', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.detectRecorder.mockReturnValue({ kind: 'sox', bin: 'rec' }); + mocks.hasProviderCredential.mockReturnValue(true); + // createTranscriber returns a reusable transcriber bound to the provider; + // its transcribe() takes just the audio buffer. + mocks.createTranscriber.mockReturnValue({ transcribe: mocks.transcribe }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('checkReady passes when recorder and credential exist', () => { + const engine = new CloudSTTEngine({ provider: 'openai' }); + expect(engine.checkReady()).toBeNull(); + }); + + it('checkReady reports a missing recorder', () => { + mocks.detectRecorder.mockReturnValue(null); + const engine = new CloudSTTEngine({ provider: 'openai' }); + expect(engine.checkReady()).toMatch(/recorder/i); + }); + + it('checkReady reports a missing API key with the provider env var', () => { + mocks.hasProviderCredential.mockReturnValue(false); + const engine = new CloudSTTEngine({ provider: 'groq' }); + expect(engine.checkReady()).toMatch(/GROQ_API_KEY/); + }); + + it('records, transcribes on stop, and emits the final transcript', async () => { + mocks.stop.mockResolvedValue(Buffer.from('audio')); + mocks.transcribe.mockResolvedValue('hello world'); + const engine = new CloudSTTEngine({ provider: 'openai', model: 'whisper-1' }); + const cb = callbacks(); + + const session = engine.start(cb); + await session.stop(); + + expect(mocks.MicRecording).toHaveBeenCalledTimes(1); + // The provider client is built once per session and reused across calls. + expect(mocks.createTranscriber).toHaveBeenCalledTimes(1); + expect(mocks.createTranscriber).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'openai', model: 'whisper-1' }), + ); + expect(mocks.transcribe).toHaveBeenCalledWith(Buffer.from('audio')); + expect(cb.onFinal).toHaveBeenCalledWith('hello world'); + }); + + it('emits partial transcripts while recording', async () => { + vi.useFakeTimers(); + mocks.snapshot.mockReturnValue(Buffer.from('partial-audio')); + mocks.transcribe.mockResolvedValue('partial text'); + const engine = new CloudSTTEngine({ provider: 'openai' }); + const cb = callbacks(); + + engine.start(cb); + // First partial fires after the initial short delay. + await vi.advanceTimersByTimeAsync(700); + + expect(cb.onPartial).toHaveBeenCalledWith('partial text'); + }); + + it('does not re-emit an unchanged partial transcript', async () => { + vi.useFakeTimers(); + mocks.snapshot.mockReturnValue(Buffer.from('partial-audio')); + mocks.transcribe.mockResolvedValue('same text'); + const engine = new CloudSTTEngine({ provider: 'openai' }); + const cb = callbacks(); + + engine.start(cb); + await vi.advanceTimersByTimeAsync(300); // first tick (short initial delay) + await vi.advanceTimersByTimeAsync(1300); // second tick, identical text + + expect(cb.onPartial).toHaveBeenCalledTimes(1); + expect(cb.onPartial).toHaveBeenCalledWith('same text'); + }); + + it('reports a transcription error via onError', async () => { + mocks.stop.mockResolvedValue(Buffer.from('audio')); + mocks.transcribe.mockRejectedValue(new Error('boom')); + const engine = new CloudSTTEngine({ provider: 'openai' }); + const cb = callbacks(); + + const session = engine.start(cb); + await session.stop(); + + expect(cb.onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' })); + }); + + it('reuses one transcriber client across live ticks and the final', async () => { + vi.useFakeTimers(); + mocks.snapshot.mockReturnValue(Buffer.from('partial-audio')); + mocks.transcribe.mockResolvedValue('partial text'); + mocks.stop.mockResolvedValue(Buffer.from('audio')); + const engine = new CloudSTTEngine({ provider: 'openai' }); + const cb = callbacks(); + + const session = engine.start(cb); + await vi.advanceTimersByTimeAsync(300); // first tick + await vi.advanceTimersByTimeAsync(1300); // second tick + await session.stop(); + + // Built exactly once even though transcribe() ran several times. + expect(mocks.createTranscriber).toHaveBeenCalledTimes(1); + expect(mocks.transcribe.mock.calls.length).toBeGreaterThan(1); + }); + + it('cancel aborts capture without transcribing', () => { + const engine = new CloudSTTEngine({ provider: 'openai' }); + const cb = callbacks(); + const session = engine.start(cb); + session.cancel(); + expect(mocks.cancel).toHaveBeenCalled(); + expect(cb.onFinal).not.toHaveBeenCalled(); + }); +}); diff --git a/mastracode/src/tui/voice/engines/__tests__/macos-native-engine.test.ts b/mastracode/src/tui/voice/engines/__tests__/macos-native-engine.test.ts new file mode 100644 index 000000000000..365595d5c3a5 --- /dev/null +++ b/mastracode/src/tui/voice/engines/__tests__/macos-native-engine.test.ts @@ -0,0 +1,306 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + resolveRecognizer: vi.fn(), + spawn: vi.fn(), + mkdtemp: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + rm: vi.fn(), +})); + +vi.mock('../../native/compile.js', () => ({ + resolveRecognizer: mocks.resolveRecognizer, +})); + +vi.mock('node:child_process', () => ({ + spawn: mocks.spawn, +})); + +vi.mock('node:fs/promises', () => ({ + mkdtemp: mocks.mkdtemp, + readFile: mocks.readFile, + writeFile: mocks.writeFile, + rm: mocks.rm, +})); + +import { MacosNativeSTTEngine } from '../macos-native-engine.js'; + +/** + * Fake `open` child: the engine launches the recognizer via `open` and tails an + * events file. We model that file in memory; `appendEvent` writes a JSONL line + * that the next `readFile` poll picks up. + */ +class FakeOpenChild extends EventEmitter { + stderr = new EventEmitter() as EventEmitter & { setEncoding: (e: string) => void }; + kill = vi.fn(); + constructor() { + super(); + this.stderr.setEncoding = vi.fn(); + } +} + +const INVOCATION = { + appPath: '/cache/macos-stt.app', + binaryPath: '/cache/macos-stt.app/Contents/MacOS/MastraCodeVoice', +}; + +function callbacks() { + return { onPartial: vi.fn(), onFinal: vi.fn(), onError: vi.fn() }; +} + +const flush = () => new Promise(r => setTimeout(r, 0)); +// The engine polls the event file every 80ms; wait a bit longer than that. +const tick = () => new Promise(r => setTimeout(r, 120)); + +describe('MacosNativeSTTEngine', () => { + let eventFile: string; + + beforeEach(() => { + vi.clearAllMocks(); + eventFile = ''; + mocks.resolveRecognizer.mockResolvedValue(INVOCATION); + mocks.mkdtemp.mockResolvedValue('/tmp/mastracode-voice-XXXX'); + mocks.writeFile.mockResolvedValue(undefined); + mocks.rm.mockResolvedValue(undefined); + // The event file is modeled in memory; reads return the current contents. + mocks.readFile.mockImplementation((path: string) => { + if (String(path).endsWith('events.jsonl')) return Promise.resolve(eventFile); + return Promise.reject(new Error('ENOENT')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function appendEvent(obj: unknown) { + eventFile += JSON.stringify(obj) + '\n'; + } + + it('checkReady fails off darwin', () => { + const spy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + expect(new MacosNativeSTTEngine().checkReady()).toMatch(/only available on macOS/); + spy.mockRestore(); + }); + + it('launches the .app via open through LaunchServices with file-IPC args', async () => { + const child = new FakeOpenChild(); + mocks.spawn.mockReturnValue(child); + + new MacosNativeSTTEngine().start(callbacks()); + await flush(); + + expect(mocks.spawn).toHaveBeenCalledWith( + 'open', + expect.arrayContaining(['-n', '-W', INVOCATION.appPath, '--args', '--events', '--stop']), + expect.anything(), + ); + }); + + it('streams partial results and a final on the final event', async () => { + const child = new FakeOpenChild(); + mocks.spawn.mockReturnValue(child); + const cb = callbacks(); + + const session = new MacosNativeSTTEngine().start(cb); + await flush(); + + appendEvent({ type: 'ready' }); + appendEvent({ type: 'partial', text: 'hello' }); + appendEvent({ type: 'partial', text: 'hello world' }); + await tick(); + expect(cb.onPartial).toHaveBeenLastCalledWith('hello world'); + + const stopped = session.stop(); + appendEvent({ type: 'final', text: 'hello world' }); + await tick(); + await stopped; + + expect(cb.onFinal).toHaveBeenCalledWith('hello world'); + // Stopping writes the stop sentinel file. + expect(mocks.writeFile).toHaveBeenCalledWith(expect.stringMatching(/stop$/), '', 'utf8'); + }); + + it('handles multiple events appended between polls', async () => { + const child = new FakeOpenChild(); + mocks.spawn.mockReturnValue(child); + const cb = callbacks(); + + new MacosNativeSTTEngine().start(cb); + await flush(); + + appendEvent({ type: 'partial', text: 'one' }); + appendEvent({ type: 'partial', text: 'one two' }); + await tick(); + expect(cb.onPartial).toHaveBeenCalledWith('one'); + expect(cb.onPartial).toHaveBeenCalledWith('one two'); + }); + + it('reports an error event via onError', async () => { + const child = new FakeOpenChild(); + mocks.spawn.mockReturnValue(child); + const cb = callbacks(); + + new MacosNativeSTTEngine().start(cb); + await flush(); + + appendEvent({ type: 'error', message: 'permission denied' }); + await tick(); + expect(cb.onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'permission denied' })); + }); + + it('errors when no Swift toolchain is available', async () => { + mocks.resolveRecognizer.mockResolvedValue(null); + const cb = callbacks(); + + new MacosNativeSTTEngine().start(cb); + await flush(); + + expect(cb.onError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringMatching(/Swift/) })); + }); + + it('surfaces a permission/crash error when the app exits before emitting anything', async () => { + const child = new FakeOpenChild(); + mocks.spawn.mockReturnValue(child); + const cb = callbacks(); + + new MacosNativeSTTEngine().start(cb); + await flush(); + + // `open` exits (TCC kill / suppressed prompt) with no JSON ever written. + child.emit('exit', null, 'SIGABRT', ''); + await tick(); + + expect(cb.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringMatching(/before it could start.*Privacy & Security/s) }), + ); + }); + + it('does not error when the app exits after a final event', async () => { + const child = new FakeOpenChild(); + mocks.spawn.mockReturnValue(child); + const cb = callbacks(); + + const session = new MacosNativeSTTEngine().start(cb); + await flush(); + + appendEvent({ type: 'final', text: 'done' }); + await tick(); + await session.stop(); + child.emit('exit', 0, null, ''); + await tick(); + + expect(cb.onError).not.toHaveBeenCalled(); + expect(cb.onFinal).toHaveBeenCalledWith('done'); + }); + + it('cancel writes the stop sentinel and does not kill the app immediately', async () => { + const child = new FakeOpenChild(); + mocks.spawn.mockReturnValue(child); + const cb = callbacks(); + + const session = new MacosNativeSTTEngine().start(cb); + await flush(); + + session.cancel(); + await flush(); + + // The stop sentinel is written so the detached app can wind down and release + // the mic; we must not SIGKILL the wrapper before it has seen the sentinel. + expect(mocks.writeFile).toHaveBeenCalledWith(expect.stringMatching(/stop$/), '', 'utf8'); + expect(child.kill).not.toHaveBeenCalled(); + + // No transcript surfaces from a cancelled session. + appendEvent({ type: 'final', text: 'ignored' }); + await tick(); + expect(cb.onFinal).not.toHaveBeenCalled(); + }); + + it('flushes a final line that lacks a trailing newline when the app exits', async () => { + const child = new FakeOpenChild(); + mocks.spawn.mockReturnValue(child); + const cb = callbacks(); + + new MacosNativeSTTEngine().start(cb); + await flush(); + + // Recognizer wrote a complete JSON object but exited before the newline. + eventFile += JSON.stringify({ type: 'final', text: 'no newline' }); + child.emit('exit', 0, null, ''); + await tick(); + + expect(cb.onFinal).toHaveBeenCalledWith('no newline'); + expect(cb.onError).not.toHaveBeenCalled(); + }); + + describe('verify (permission probe)', () => { + let platformSpy: ReturnType<typeof vi.spyOn>; + beforeEach(() => { + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); + }); + afterEach(() => platformSpy.mockRestore()); + + /** + * Fake probe child: the engine launches the `.app` via `open` with `--probe` + * and `--events <file>`, then reads the probe JSON back from that event file. + * We model that file in memory: the child writes the probe line, then exits. + */ + function probeChild(probe: unknown) { + const child = new EventEmitter() as EventEmitter & { kill: () => void }; + child.kill = vi.fn(); + mocks.readFile.mockImplementation((path: string) => { + if (String(path).endsWith('probe.jsonl')) { + return Promise.resolve(probe === undefined ? '' : JSON.stringify(probe) + '\n'); + } + return Promise.reject(new Error('ENOENT')); + }); + mocks.spawn.mockImplementation(() => { + queueMicrotask(() => child.emit('exit', 0)); + return child; + }); + return child; + } + + it('returns the toolchain error when the recognizer cannot resolve', async () => { + mocks.resolveRecognizer.mockResolvedValue(null); + expect(await new MacosNativeSTTEngine().verify()).toMatch(/Swift toolchain/); + }); + + it('runs the probe through the .app via open (shared bundle TCC identity)', async () => { + probeChild({ type: 'probe', speech: 'authorized', mic: 'authorized', available: true }); + await new MacosNativeSTTEngine().verify(); + expect(mocks.spawn).toHaveBeenCalledWith( + 'open', + expect.arrayContaining(['-n', '-W', '-g', INVOCATION.appPath, '--args', '--probe', '--events']), + expect.anything(), + ); + }); + + it('returns null when both permissions are authorized', async () => { + probeChild({ type: 'probe', speech: 'authorized', mic: 'authorized', available: true }); + expect(await new MacosNativeSTTEngine().verify()).toBeNull(); + }); + + it('flags a denied microphone', async () => { + probeChild({ type: 'probe', speech: 'authorized', mic: 'denied', available: true }); + expect(await new MacosNativeSTTEngine().verify()).toMatch(/Microphone/); + }); + + it('flags denied speech recognition', async () => { + probeChild({ type: 'probe', speech: 'denied', mic: 'authorized', available: true }); + expect(await new MacosNativeSTTEngine().verify()).toMatch(/Speech Recognition/); + }); + + it('explains the first-run prompt when permission is not determined', async () => { + probeChild({ type: 'probe', speech: 'notDetermined', mic: 'notDetermined', available: true }); + expect(await new MacosNativeSTTEngine().verify()).toMatch(/prompt/); + }); + + it('passes verification when the probe cannot run', async () => { + probeChild(undefined); + expect(await new MacosNativeSTTEngine().verify()).toBeNull(); + }); + }); +}); diff --git a/mastracode/src/tui/voice/engines/cloud-engine.ts b/mastracode/src/tui/voice/engines/cloud-engine.ts new file mode 100644 index 000000000000..e081090665ee --- /dev/null +++ b/mastracode/src/tui/voice/engines/cloud-engine.ts @@ -0,0 +1,210 @@ +/** + * Cloud STT engine. + * + * Records microphone audio to a WAV via `MicRecording`, then transcribes it + * through a cloud provider (`transcribe.ts`, provider-agnostic). While + * recording, it periodically re-transcribes the audio-so-far and emits that as + * a partial, which keeps the live text stable and self-correcting (the provider + * re-derives the whole utterance each tick). On stop it emits the final, most + * accurate transcript. + * + * This preserves the original chunked-live behavior for non-macOS / cloud users + * while keeping the controller engine-agnostic. + */ + +import type { AuthStorage } from '../../../auth/storage.js'; +import { detectRecorder, MicRecording } from '../mic-capture.js'; +import type { RecorderInfo } from '../mic-capture.js'; +import { createTranscriber, hasProviderCredential } from '../transcribe.js'; +import type { ReusableTranscriber } from '../transcribe.js'; +import type { STTEngine, STTSession, STTSessionCallbacks } from './types.js'; + +/** How often to re-transcribe the audio-so-far while recording (ms). */ +const LIVE_TRANSCRIBE_INTERVAL_MS = 1200; +/** + * Delay before the *first* live tick. Kept short so the opening words appear + * quickly: the recorder needs a moment to spawn and write a usable WAV, and + * until then `snapshot()` returns null and the tick is a cheap no-op that + * re-arms at this same short cadence — so the first partial fires as soon as + * there's audio rather than waiting a full interval. + */ +const LIVE_TRANSCRIBE_FIRST_MS = 250; + +export interface CloudEngineOptions { + provider: string; + model?: string; + authStorage?: AuthStorage; +} + +function missingRecorderMessage(): string { + if (process.platform === 'darwin') { + return 'Voice input needs a recorder. Install sox (`brew install sox`) or ffmpeg, then run /voice again.'; + } + return 'Voice input needs a recorder. Install one of pipewire-utils (pw-record), pulseaudio-utils (parecord), alsa-utils (arecord), or sox, then run /voice again.'; +} + +class CloudSession implements STTSession { + private recording: MicRecording | null; + private liveTimer: ReturnType<typeof setTimeout> | null = null; + private liveInFlight = false; + private stopped = false; + private lastPartial = ''; + private sawAudio = false; + private readonly transcriber: ReusableTranscriber; + + constructor( + recorder: RecorderInfo, + private readonly options: CloudEngineOptions, + private readonly callbacks: STTSessionCallbacks, + ) { + // Build the provider client once and reuse it across ticks so its HTTP + // connection stays warm (keep-alive). This removes the DNS + TLS handshake + // cost from every request — the main reason the first dictation streamed in + // slowly while later ones felt instant. + this.transcriber = createTranscriber({ + provider: this.options.provider, + model: this.options.model, + authStorage: this.options.authStorage, + }); + try { + this.recording = new MicRecording(recorder); + } catch { + this.recording = null; + // Surface asynchronously so `start()` can return a session handle first. + queueMicrotask(() => this.callbacks.onError(new Error('Could not start the microphone recorder.'))); + return; + } + this.startLiveTranscription(); + } + + async stop(): Promise<void> { + if (this.stopped) return; + this.stopped = true; + this.stopLiveTranscription(); + const recording = this.recording; + this.recording = null; + if (!recording) return; + + let audio: Buffer | null = null; + try { + audio = await recording.stop(); + } catch (err) { + this.callbacks.onError(err instanceof Error ? err : new Error('Could not stop the microphone recorder.')); + return; + } + if (!audio) { + this.callbacks.onFinal(''); + return; + } + + try { + const text = await this.transcriber.transcribe(audio); + this.callbacks.onFinal(text); + } catch (err) { + this.callbacks.onError(err instanceof Error ? err : new Error('Transcription failed.')); + } + } + + cancel(): void { + if (this.stopped) return; + this.stopped = true; + this.stopLiveTranscription(); + if (this.recording) { + this.recording.cancel(); + this.recording = null; + } + } + + /** + * Schedule live ticks as a self-rescheduling timeout chain rather than a + * setInterval. This keeps `liveTimer` a single timeout handle (so cancellation + * is unambiguous) and guarantees ticks never overlap: the next tick is only + * armed after the current one settles, even when a transcription request runs + * longer than the interval. + */ + private startLiveTranscription(): void { + const schedule = (delay: number) => { + if (this.stopped) return; + this.liveTimer = setTimeout(async () => { + await this.runLiveTick(); + // Until the recorder has produced enough audio to transcribe, keep + // polling at the short first-tick cadence so the opening partial fires + // the instant audio is available. Once we've seen audio, fall back to + // the normal interval to avoid hammering the provider. + schedule(this.sawAudio ? LIVE_TRANSCRIBE_INTERVAL_MS : LIVE_TRANSCRIBE_FIRST_MS); + }, delay); + }; + schedule(LIVE_TRANSCRIBE_FIRST_MS); + } + + private stopLiveTranscription(): void { + if (this.liveTimer) { + clearTimeout(this.liveTimer); + this.liveTimer = null; + } + } + + private async runLiveTick(): Promise<void> { + if (this.liveInFlight || this.stopped || !this.recording) return; + const snapshot = this.recording.snapshot(); + if (!snapshot) return; + this.sawAudio = true; + + this.liveInFlight = true; + try { + const text = await this.transcriber.transcribe(snapshot); + if (!this.stopped && text && text !== this.lastPartial) { + this.lastPartial = text; + this.callbacks.onPartial(text); + } + } catch (err) { + // Transient mid-recording failures are expected (e.g. a snapshot taken + // before the recorder flushed a full frame). Log for diagnosability; + // stop() still surfaces a persistent failure to the user. + if (process.env.MASTRACODE_VOICE_DEBUG) { + console.error('[voice] live transcription tick failed:', err); + } + } finally { + this.liveInFlight = false; + } + } +} + +export class CloudSTTEngine implements STTEngine { + readonly kind = 'cloud' as const; + + constructor(private readonly options: CloudEngineOptions) {} + + checkReady(): string | null { + if (!detectRecorder()) return missingRecorderMessage(); + if (!hasProviderCredential(this.options.provider, this.options.authStorage)) { + const env = + this.options.provider === 'openai' ? 'OPENAI_API_KEY' : `${this.options.provider.toUpperCase()}_API_KEY`; + return `Voice input needs a ${this.options.provider} API key. Set ${env} or add one with /api-keys.`; + } + return null; + } + + start(callbacks: STTSessionCallbacks): STTSession { + const recorder = detectRecorder(); + if (!recorder) { + queueMicrotask(() => callbacks.onError(new Error(missingRecorderMessage()))); + return inertSession(); + } + try { + return new CloudSession(recorder, this.options, callbacks); + } catch (err) { + // createTranscriber throws VoiceCredentialError when no key is available; + // checkReady() normally catches this first, but surface it cleanly here too. + queueMicrotask(() => callbacks.onError(err instanceof Error ? err : new Error('Transcription unavailable.'))); + return inertSession(); + } + } +} + +function inertSession(): STTSession { + return { + async stop() {}, + cancel() {}, + }; +} diff --git a/mastracode/src/tui/voice/engines/index.ts b/mastracode/src/tui/voice/engines/index.ts new file mode 100644 index 000000000000..0cb42f86aa66 --- /dev/null +++ b/mastracode/src/tui/voice/engines/index.ts @@ -0,0 +1,25 @@ +/** + * Engine factory: builds the active `STTEngine` from voice settings. + */ + +import type { AuthStorage } from '../../../auth/storage.js'; +import { CloudSTTEngine } from './cloud-engine.js'; +import { MacosNativeSTTEngine } from './macos-native-engine.js'; +import type { STTEngine, STTEngineKind } from './types.js'; + +export interface EngineSettings { + engine: STTEngineKind; + provider: string; + model?: string; +} + +export function createSTTEngine(settings: EngineSettings, authStorage?: AuthStorage): STTEngine { + if (settings.engine === 'macos-native') { + return new MacosNativeSTTEngine(); + } + return new CloudSTTEngine({ provider: settings.provider, model: settings.model, authStorage }); +} + +export * from './types.js'; +export { CloudSTTEngine } from './cloud-engine.js'; +export { MacosNativeSTTEngine } from './macos-native-engine.js'; diff --git a/mastracode/src/tui/voice/engines/macos-native-engine.ts b/mastracode/src/tui/voice/engines/macos-native-engine.ts new file mode 100644 index 000000000000..319a52568a82 --- /dev/null +++ b/mastracode/src/tui/voice/engines/macos-native-engine.ts @@ -0,0 +1,541 @@ +/** + * macOS native STT engine. + * + * Launches the bundled on-device `SFSpeechRecognizer` `.app` (built and cached by + * `native/compile.ts`) through LaunchServices (`open`) — the only launch path + * that makes macOS show the Speech Recognition / Microphone permission prompts. + * Because a LaunchServices-launched app has no usable stdin/stdout pipe back to + * the parent, IPC is file-based: the recognizer appends newline-delimited JSON + * events to an `events.jsonl` file we tail, and we ask it to stop by creating a + * `stop` sentinel file it polls for. Partial hypotheses arrive in true realtime + * with no network and no per-utterance cost; the final result is emitted on stop. + * + * `stop()` writes the stop sentinel so the recognizer flushes a final result, + * then resolves once that final event arrives. `cancel()` tears it down. + */ + +import { spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { resolveRecognizer } from '../native/compile.js'; +import type { PermissionGuidance, STTEngine, STTSession, STTSessionCallbacks } from './types.js'; + +/** Deep links to the exact macOS Privacy & Security panes. */ +const MIC_SETTINGS_URL = 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone'; +const SPEECH_SETTINGS_URL = 'x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition'; + +/** + * How long to wait after closing stdin for the recognizer to flush its final + * result before falling back to SIGTERM. The Swift side gives itself up to ~1.5s + * to emit a final, so this is set a little above that. + */ +const FINAL_FLUSH_GRACE_MS = 2000; + +interface RecognizerEvent { + type: 'ready' | 'partial' | 'final' | 'error'; + text?: string; + message?: string; +} + +type TccStatus = 'authorized' | 'denied' | 'restricted' | 'notDetermined' | 'unknown'; + +interface ProbeResult { + speech: TccStatus; + mic: TccStatus; + available: boolean; +} + +/** + * Run the recognizer in `--probe` mode to read Speech Recognition + Microphone + * authorization status without recording. Returns null if the probe can't run + * (e.g. swiftc missing) — the caller handles that separately. + * + * The probe is launched through the same `.app` bundle via LaunchServices that + * the recording path uses. macOS attributes TCC grants to the bundle identity + * (`ai.mastra.mastracode.voice`), so running the loose binary directly would + * query a *different* TCC identity (the terminal's) and wrongly report + * `notDetermined`/`denied` even after the user granted access to the bundle. + * Because LaunchServices apps have no usable stdout pipe, the probe writes its + * result to an events file (shared file-IPC) that we read back. + */ +async function probePermissions(): Promise<ProbeResult | null> { + const invocation = await resolveRecognizer(); + if (!invocation) return null; + + let workDir: string; + try { + workDir = await mkdtemp(join(tmpdir(), 'mastracode-voice-probe-')); + } catch { + return null; + } + const eventPath = join(workDir, 'probe.jsonl'); + try { + await writeFile(eventPath, '', 'utf8'); + } catch { + // Non-fatal: the probe creates it on first emit. + } + + const cleanup = () => { + void rm(workDir, { recursive: true, force: true }).catch(() => {}); + }; + + return new Promise<ProbeResult | null>(resolve => { + let settled = false; + const done = (result: ProbeResult | null) => { + if (settled) return; + settled = true; + cleanup(); + resolve(result); + }; + + let child: ReturnType<typeof spawn>; + try { + // Launch via LaunchServices so the probe runs under the granted bundle + // identity. `-g` keeps it from stealing focus; `-W` waits for exit. + child = spawn('open', ['-n', '-W', '-g', invocation.appPath, '--args', '--probe', '--events', eventPath], { + stdio: ['ignore', 'ignore', 'ignore'], + }); + } catch { + done(null); + return; + } + + const readProbe = async (): Promise<ProbeResult | null> => { + let out = ''; + try { + out = await readFile(eventPath, 'utf8'); + } catch { + return null; + } + const line = out.split('\n').find(l => l.includes('"type":"probe"') || l.includes('"type": "probe"')); + if (!line) return null; + try { + const parsed = JSON.parse(line) as { speech?: TccStatus; mic?: TccStatus; available?: boolean }; + return { + speech: parsed.speech ?? 'unknown', + mic: parsed.mic ?? 'unknown', + available: parsed.available ?? false, + }; + } catch { + return null; + } + }; + + const timer = setTimeout(() => { + child.kill('SIGKILL'); + void readProbe().then(done); + }, 5000); + + child.on('error', () => { + clearTimeout(timer); + done(null); + }); + child.on('exit', () => { + clearTimeout(timer); + void readProbe().then(done); + }); + }); +} + +/** Turn a probe result into a user-facing problem message, or null if ready. */ +function describeProbe(probe: ProbeResult): string | null { + const settingsHint = 'System Settings › Privacy & Security'; + if (probe.mic === 'denied' || probe.mic === 'restricted') { + return `Microphone access is blocked. Enable MastraCode Voice under ${settingsHint} › Microphone, then restart your terminal.`; + } + if (probe.speech === 'denied' || probe.speech === 'restricted') { + return `Speech Recognition access is blocked. Enable MastraCode Voice under ${settingsHint} › Speech Recognition, then restart your terminal.`; + } + if (probe.mic === 'notDetermined' || probe.speech === 'notDetermined') { + return 'Microphone / Speech Recognition access not granted yet — the first time you hold space to dictate, macOS will prompt; click Allow on both.'; + } + if (!probe.available) { + return 'On-device speech recognition is not available for your locale.'; + } + return null; +} + +/** Turn a probe result into structured, actionable permission guidance. */ +function guidanceFromProbe(probe: ProbeResult): PermissionGuidance { + const blockedMic = probe.mic === 'denied' || probe.mic === 'restricted'; + const blockedSpeech = probe.speech === 'denied' || probe.speech === 'restricted'; + + if (blockedMic || blockedSpeech) { + const which = blockedMic ? 'Microphone' : 'Speech Recognition'; + return { + state: 'blocked', + summary: `${which} access is turned off for MastraCode Voice, so on-device dictation can't run.`, + steps: [ + `Open System Settings › Privacy & Security › ${which}.`, + 'Turn on the switch next to MastraCode Voice.', + 'Fully quit and reopen the terminal so the change takes effect.', + ], + settingsUrl: blockedMic ? MIC_SETTINGS_URL : SPEECH_SETTINGS_URL, + actionLabel: `Open ${which} settings`, + }; + } + + if (probe.mic === 'notDetermined' || probe.speech === 'notDetermined') { + return { + state: 'will-prompt', + summary: "macOS hasn't asked for access yet — it will prompt the first time you dictate.", + steps: [ + 'Hold the space bar and start speaking.', + 'When macOS asks, click Allow for both Microphone and Speech Recognition.', + ], + }; + } + + if (!probe.available) { + return { + state: 'unsupported', + summary: 'On-device speech recognition is not available for your locale. Switch to a cloud provider with /voice.', + }; + } + + return { state: 'ok', summary: 'Microphone and Speech Recognition access are granted.' }; +} + +/** How often to poll the event file for newly appended JSON lines. */ +const EVENT_POLL_INTERVAL_MS = 80; + +class MacosNativeSession implements STTSession { + private openChild: ReturnType<typeof spawn> | null = null; + private workDir: string | null = null; + private eventPath = ''; + private stopPath = ''; + private readOffset = 0; + private pendingLine = ''; + private pollTimer: ReturnType<typeof setInterval> | null = null; + private polling = false; + private sawEvent = false; + private cancelled = false; + private finalResolve: (() => void) | null = null; + private finalPromise: Promise<void>; + private settled = false; + + constructor(private readonly callbacks: STTSessionCallbacks) { + this.finalPromise = new Promise(resolve => { + this.finalResolve = resolve; + }); + void this.launch(); + } + + private async launch(): Promise<void> { + const invocation = await resolveRecognizer(); + if (!invocation) { + this.fail(new Error('Swift toolchain not found. Install Xcode command line tools (`xcode-select --install`).')); + return; + } + + try { + this.workDir = await mkdtemp(join(tmpdir(), 'mastracode-voice-')); + } catch (err) { + this.fail(err instanceof Error ? err : new Error('Could not create a temp dir for the macOS recognizer.')); + return; + } + this.eventPath = join(this.workDir, 'events.jsonl'); + this.stopPath = join(this.workDir, 'stop'); + // Pre-create the event file so the first poll has something to read. + try { + await writeFile(this.eventPath, '', 'utf8'); + } catch { + // Non-fatal: the recognizer creates it on first emit. + } + + // Launch the .app via LaunchServices. `open` is the only path that makes + // macOS present the Speech Recognition / Microphone permission prompts. + // -n always start a new instance + // -W wait until the app exits (so the child exit tells us it's done) + // -g do not bring the app to the foreground / steal focus + // Args after `--args` are forwarded to the app's executable. + let child: ReturnType<typeof spawn>; + try { + child = spawn( + 'open', + ['-n', '-W', '-g', invocation.appPath, '--args', '--events', this.eventPath, '--stop', this.stopPath], + { stdio: ['ignore', 'ignore', 'pipe'] }, + ); + } catch (err) { + this.fail(err instanceof Error ? err : new Error('Could not launch the macOS recognizer.')); + return; + } + this.openChild = child; + let stderr = ''; + child.stderr?.setEncoding('utf8'); + child.stderr?.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', err => this.fail(err instanceof Error ? err : new Error(String(err)))); + child.on('exit', (code, signal) => this.onOpenExit(code, signal, stderr)); + + this.startPolling(); + } + + private startPolling(): void { + this.pollTimer = setInterval(() => { + void this.poll(); + }, EVENT_POLL_INTERVAL_MS); + } + + private async poll(flush = false): Promise<void> { + if (this.settled || this.polling) return; + this.polling = true; + try { + const contents = await readFile(this.eventPath, 'utf8'); + if (contents.length > this.readOffset) { + const fresh = contents.slice(this.readOffset); + this.readOffset = contents.length; + // The recognizer may be mid-write, leaving a trailing partial line. + // Buffer it and only dispatch complete (newline-terminated) lines. + const lines = (this.pendingLine + fresh).split('\n'); + this.pendingLine = lines.pop() ?? ''; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed) this.handleLine(trimmed); + } + } + // On a final drain the recognizer has exited, so any buffered remainder + // is a complete line that simply lacked a trailing newline — flush it. + if (flush && this.pendingLine.trim()) { + const trimmed = this.pendingLine.trim(); + this.pendingLine = ''; + this.handleLine(trimmed); + } + } catch { + // File may not exist yet between launch and first emit; ignore. + } finally { + this.polling = false; + } + } + + /** + * If `open` exits before the recognizer ever emitted a JSON event, the app + * crashed before recording could start — almost always a denied/suppressed + * macOS permission prompt or a TCC kill. Surface a clear, actionable error + * instead of settling silently. + */ + private onOpenExit(code: number | null, signal: NodeJS.Signals | null, stderr: string): void { + // Drain any events the recognizer wrote just before exiting, flushing a + // final line that may lack a trailing newline. + void this.poll(true).then(() => { + if (this.settled) return; + if (!this.sawEvent) { + const detail = stderr.trim(); + const reason = detail + ? detail.split('\n').slice(-1)[0] + : signal + ? `terminated (${signal})` + : `exited with code ${code ?? 'unknown'}`; + this.fail( + new Error( + `macOS speech recognition stopped before it could start (${reason}). ` + + `If macOS didn't prompt you, enable MastraCode Voice under System Settings › Privacy & Security › ` + + `Microphone and Speech Recognition, then fully quit and reopen the terminal.`, + ), + ); + return; + } + this.settle(); + }); + } + + private handleLine(line: string): void { + let event: RecognizerEvent; + try { + event = JSON.parse(line) as RecognizerEvent; + } catch { + return; + } + this.sawEvent = true; + // Once cancelled we only let the recognizer wind down; no transcripts surface. + if (this.cancelled) { + if (event.type === 'final' || event.type === 'error') this.settle(); + return; + } + switch (event.type) { + case 'partial': + if (event.text) this.callbacks.onPartial(event.text); + break; + case 'final': + this.callbacks.onFinal(event.text ?? ''); + this.settle(); + break; + case 'error': + this.fail(new Error(event.message ?? 'macOS speech recognition failed.')); + break; + case 'ready': + default: + break; + } + } + + private fail(error: Error): void { + if (this.settled) return; + this.callbacks.onError(error); + this.teardown(); + this.settle(); + } + + private settle(): void { + if (this.settled) return; + this.settled = true; + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + this.finalResolve?.(); + void this.cleanupWorkDir(); + } + + private teardown(): void { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + if (this.openChild) { + this.openChild.kill('SIGKILL'); + this.openChild = null; + } + } + + /** + * Stop recording without emitting a transcript, but let the recognizer wind + * down gracefully so it releases the microphone. We write the stop sentinel + * and wait for the LaunchServices app to observe it and exit on its own (its + * `exit` handler settles us and removes the temp dir). A SIGKILL + forced + * cleanup is only a safety net if the app never acknowledges the sentinel — + * tearing the temp dir down immediately would delete the stop file before the + * detached app could poll it, leaving the mic live with no control channel. + */ + private async gracefulCancel(): Promise<void> { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + await this.signalStop(); + if (this.settled || !this.openChild) { + this.settle(); + return; + } + const safetyNet = setTimeout(() => { + this.teardown(); + this.settle(); + }, FINAL_FLUSH_GRACE_MS); + void this.finalPromise.finally(() => clearTimeout(safetyNet)); + } + + private async cleanupWorkDir(): Promise<void> { + const dir = this.workDir; + this.workDir = null; + if (!dir) return; + try { + await rm(dir, { recursive: true, force: true }); + } catch { + // Best effort. + } + } + + /** Signal the recognizer to flush a final result, then wait for it. */ + private async signalStop(): Promise<void> { + if (this.stopPath) { + try { + await writeFile(this.stopPath, '', 'utf8'); + } catch { + // If we can't write the sentinel, fall back to killing the app. + this.openChild?.kill('SIGTERM'); + } + } + } + + async stop(): Promise<void> { + if (!this.settled) { + await this.signalStop(); + // The recognizer needs a moment after seeing the stop file to run its + // final recognition pass and append the `final` event. The poll loop will + // pick it up and settle. A delayed kill is only a safety net. + const safetyNet = setTimeout(() => { + if (!this.settled) { + this.openChild?.kill('SIGTERM'); + } + }, FINAL_FLUSH_GRACE_MS); + void this.finalPromise.finally(() => clearTimeout(safetyNet)); + } + await this.finalPromise; + } + + cancel(): void { + if (this.settled || this.cancelled) return; + this.cancelled = true; + void this.gracefulCancel(); + } +} + +export class MacosNativeSTTEngine implements STTEngine { + readonly kind = 'macos-native' as const; + + checkReady(): string | null { + if (process.platform !== 'darwin') { + return 'macOS native speech recognition is only available on macOS. Choose a cloud provider with /voice.'; + } + return null; + } + + /** + * Deeper preflight: confirm the Swift toolchain compiles the recognizer, then + * probe the actual Speech Recognition + Microphone TCC authorization status so + * /voice status can tell the user exactly which permission to enable instead of + * letting it fail silently mid-dictation. + */ + async verify(): Promise<string | null> { + const platform = this.checkReady(); + if (platform) return platform; + const invocation = await resolveRecognizer(); + if (!invocation) { + return 'macOS native STT needs the Swift toolchain. Install Xcode command line tools (`xcode-select --install`), then run /voice again.'; + } + const probe = await probePermissions(); + if (probe) { + const problem = describeProbe(probe); + if (problem) return problem; + } + return null; + } + + /** + * Structured permission guidance for the TUI. Confirms the toolchain compiles, + * then probes the real TCC state and returns actionable steps (and a Settings + * deep link) so the UI can walk the user through granting access. + */ + async permissions(): Promise<PermissionGuidance> { + if (process.platform !== 'darwin') { + return { + state: 'unsupported', + summary: 'macOS native speech recognition is only available on macOS. Choose a cloud provider with /voice.', + }; + } + const invocation = await resolveRecognizer(); + if (!invocation) { + return { + state: 'unsupported', + summary: 'On-device dictation needs the Swift toolchain.', + steps: ['Run `xcode-select --install` to install the Xcode command line tools.', 'Then run /voice again.'], + }; + } + const probe = await probePermissions(); + if (!probe) { + // Probe couldn't run; assume macOS will prompt on first dictation. + return { + state: 'will-prompt', + summary: "Couldn't read permission state — macOS will prompt the first time you dictate.", + steps: ['Hold the space bar and start speaking.', 'Click Allow when macOS asks for access.'], + }; + } + return guidanceFromProbe(probe); + } + + start(callbacks: STTSessionCallbacks): STTSession { + return new MacosNativeSession(callbacks); + } +} diff --git a/mastracode/src/tui/voice/engines/types.ts b/mastracode/src/tui/voice/engines/types.ts new file mode 100644 index 000000000000..75b71a30248f --- /dev/null +++ b/mastracode/src/tui/voice/engines/types.ts @@ -0,0 +1,91 @@ +/** + * Speech-to-text engine abstraction. + * + * An `STTEngine` owns one way of turning microphone audio into text. The voice + * controller stays engine-agnostic: it asks an engine whether it is ready, then + * opens a streaming `STTSession` that emits partial transcripts as the user + * speaks and a final transcript when they stop. + * + * Two implementations exist: + * - `macos-native`: an on-device `SFSpeechRecognizer` process. Truly realtime — + * it streams genuine interim results with no network and no per-tick cost. + * - `cloud`: records to a WAV and transcribes through a cloud provider, polling + * the audio-so-far for partials (provider-agnostic via `transcribe.ts`). + */ + +export type STTEngineKind = 'macos-native' | 'cloud'; + +export interface STTSessionCallbacks { + /** + * Best transcript of everything heard so far. Each call supersedes the prior + * one (replace, not append), so the input box shows live dictation. + */ + onPartial(text: string): void; + /** Final, most-accurate transcript for the utterance. */ + onFinal(text: string): void; + /** A fatal error for this session (capture failure, auth, permission). */ + onError(error: Error): void; +} + +export interface STTSession { + /** Stop capture and resolve once the final transcript has been emitted. */ + stop(): Promise<void>; + /** Abort capture immediately without emitting a final transcript. */ + cancel(): void; +} + +/** + * Whether a required OS permission is granted, will prompt on first use, or is + * actively blocked and needs the user to change a setting. + */ +export type PermissionState = 'ok' | 'will-prompt' | 'blocked' | 'unsupported'; + +/** + * Actionable guidance for getting an engine ready. Lets the TUI walk the user + * through fixing permissions (e.g. open the exact Settings pane) instead of just + * printing a message and assuming they know what to do. + */ +export interface PermissionGuidance { + state: PermissionState; + /** Short, user-facing summary of what's wrong or what will happen. */ + summary: string; + /** Ordered, plain-language steps the user can follow. */ + steps?: string[]; + /** + * A URL that opens the relevant settings UI (e.g. a macOS + * `x-apple.systempreferences:` deep link). When present the TUI can offer to + * open it for the user. + */ + settingsUrl?: string; + /** Label for the action that opens `settingsUrl` (e.g. "Open System Settings"). */ + actionLabel?: string; +} + +export interface STTEngine { + readonly kind: STTEngineKind; + /** + * Begin a streaming recognition session. Implementations should start capture + * synchronously (so the caller can flip into a "recording" state) and deliver + * results through the callbacks. + */ + start(callbacks: STTSessionCallbacks): STTSession; + /** + * Fast, synchronous preflight. Returns `null` when the engine is plausibly + * ready, or a user-facing message explaining what is missing (recorder, API + * key, unsupported platform). Kept sync so toggling voice on stays instant. + */ + checkReady(): string | null; + /** + * Optional deeper preflight that may do async work (e.g. compiling the native + * recognizer). Returns `null` when verified ready, or a user-facing message. + * Used by `/voice status` so failures surface before the user dictates. + */ + verify?(): Promise<string | null>; + /** + * Optional structured permission check. Unlike `verify()` (which returns a + * flat string), this returns actionable guidance — including a deep link to + * the relevant settings pane — so the TUI can guide the user through granting + * access rather than assuming they know how. + */ + permissions?(): Promise<PermissionGuidance>; +} diff --git a/mastracode/src/tui/voice/mic-capture.ts b/mastracode/src/tui/voice/mic-capture.ts new file mode 100644 index 000000000000..0bc77136633d --- /dev/null +++ b/mastracode/src/tui/voice/mic-capture.ts @@ -0,0 +1,273 @@ +/** + * Local microphone capture for push-to-talk voice input. + * + * Spawns an external recorder binary (sox/rec or ffmpeg) to capture audio + * from the system microphone into a temporary WAV file. Audio is recorded as + * 16kHz mono PCM, which is what speech-to-text models expect. + * + * The capture lifecycle is: detect a recorder once, start() spawns the process, + * stop() signals the process to finish and resolves with the recorded buffer. + */ + +import type { ChildProcess } from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process'; +import { existsSync, readFileSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export type RecorderKind = 'sox' | 'ffmpeg' | 'pipewire' | 'pulse' | 'alsa'; + +export interface RecorderInfo { + kind: RecorderKind; + /** Resolved binary name used to spawn the recorder. */ + bin: string; +} + +const SAMPLE_RATE = 16000; +const CHANNELS = 1; + +/** + * Detect an available recorder binary on the host (Linux/macOS). + * + * On Linux, prefer native recorders that ship with common desktop audio stacks + * (PipeWire's `pw-record`, PulseAudio's `parecord`, ALSA's `arecord`) so most + * users need no extra install. macOS has no reliable built-in CLI recorder, so + * it falls back to sox/ffmpeg. Both also fall back to sox/ffmpeg if present. + * + * Returns null if nothing usable is installed. + */ +export function detectRecorder(): RecorderInfo | null { + // Only macOS and Linux are supported; other platforms (e.g. Windows) have no + // input backend wired up, so don't advertise a recorder we can't drive. + if (process.platform !== 'darwin' && process.platform !== 'linux') { + return null; + } + if (process.platform === 'linux') { + if (commandExists('pw-record')) return { kind: 'pipewire', bin: 'pw-record' }; + if (commandExists('parecord')) return { kind: 'pulse', bin: 'parecord' }; + if (commandExists('arecord')) return { kind: 'alsa', bin: 'arecord' }; + } + for (const bin of ['rec', 'sox']) { + if (commandExists(bin)) { + return { kind: 'sox', bin }; + } + } + // ffmpegInputArgs() only knows the macOS avfoundation backend, so only offer + // ffmpeg there. Linux falls back to the dedicated recorders above. + if (process.platform === 'darwin' && commandExists('ffmpeg')) { + return { kind: 'ffmpeg', bin: 'ffmpeg' }; + } + return null; +} + +function commandExists(bin: string): boolean { + try { + const probe = process.platform === 'win32' ? 'where' : 'which'; + execFileSync(probe, [bin], { stdio: ['ignore', 'ignore', 'ignore'], timeout: 3000 }); + return true; + } catch { + return false; + } +} + +/** + * A single in-progress microphone recording. + */ +export class MicRecording { + private proc: ChildProcess; + private outputPath: string; + private recorder: RecorderInfo; + private stderr = ''; + private exited: Promise<void>; + private resolveExited!: () => void; + private stopped = false; + + constructor(recorder: RecorderInfo) { + this.recorder = recorder; + this.outputPath = join(tmpdir(), `mastra-voice-${Date.now()}-${Math.random().toString(36).slice(2)}.wav`); + + const { command, args } = buildRecorderCommand(recorder, this.outputPath); + this.proc = spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'] }); + + this.exited = new Promise<void>(resolve => { + this.resolveExited = resolve; + }); + + this.proc.stderr?.on('data', (chunk: Buffer) => { + this.stderr += chunk.toString(); + }); + this.proc.on('error', () => { + this.resolveExited(); + }); + this.proc.on('close', () => { + this.resolveExited(); + }); + } + + /** + * Stop recording and return the captured audio as a WAV buffer. + * Returns null if recording failed or produced no audio. + */ + async stop(): Promise<Buffer | null> { + if (this.stopped) return null; + this.stopped = true; + + // ffmpeg listens for `q` on stdin to finish cleanly and flush the file. + // sox/rec respond to SIGINT/SIGTERM by finalizing the WAV header. + if (this.recorder.kind === 'ffmpeg' && this.proc.stdin && !this.proc.stdin.destroyed) { + try { + this.proc.stdin.write('q'); + this.proc.stdin.end(); + } catch { + this.proc.kill('SIGINT'); + } + } else { + this.proc.kill('SIGINT'); + } + + await this.exited; + + try { + if (!existsSync(this.outputPath)) return null; + const buffer = readFileSync(this.outputPath); + // A bare WAV header is 44 bytes; anything at or below that is empty audio. + if (buffer.length <= 44) return null; + return buffer; + } catch { + return null; + } finally { + this.cleanup(); + } + } + + /** + * Read the audio captured so far, mid-recording, as a playable WAV buffer. + * + * The recorder is still appending to the file and has not finalized the WAV + * header's size fields, so we read the current bytes and patch the RIFF/data + * chunk sizes to match what's actually on disk. Returns null if there isn't + * enough audio yet to transcribe. + */ + snapshot(): Buffer | null { + if (this.stopped) return null; + try { + if (!existsSync(this.outputPath)) return null; + const buffer = readFileSync(this.outputPath); + // Need a header plus a little audio to be worth transcribing. + if (buffer.length <= 1024) return null; + return fixWavHeader(buffer); + } catch { + return null; + } + } + + /** + * Abort recording without returning audio (e.g. on cancel). + */ + cancel(): void { + if (this.stopped) return; + this.stopped = true; + try { + this.proc.kill('SIGKILL'); + } catch { + // ignore + } + this.cleanup(); + } + + private cleanup(): void { + try { + if (existsSync(this.outputPath)) unlinkSync(this.outputPath); + } catch { + // ignore cleanup errors + } + } +} + +/** + * Patch a WAV buffer's RIFF/data chunk sizes to match its actual byte length. + * + * Recorders write a placeholder (often zero or streaming) size while capture is + * ongoing, so a mid-recording snapshot has wrong sizes. We rewrite the RIFF + * chunk size (total file size minus 8) and the `data` chunk size (bytes after + * the data header) so decoders read the audio we actually have. + */ +function fixWavHeader(buffer: Buffer): Buffer { + if (buffer.length < 44 || buffer.toString('ascii', 0, 4) !== 'RIFF') { + return buffer; + } + const out = Buffer.from(buffer); + // RIFF chunk size = total length - 8 (the "RIFF" tag and this size field). + out.writeUInt32LE(out.length - 8, 4); + + // Locate the "data" sub-chunk; its size field is the 4 bytes that follow. + const dataIdx = out.indexOf('data', 12, 'ascii'); + if (dataIdx !== -1 && dataIdx + 8 <= out.length) { + const dataSize = out.length - (dataIdx + 8); + out.writeUInt32LE(dataSize, dataIdx + 4); + } + return out; +} + +function buildRecorderCommand(recorder: RecorderInfo, outputPath: string): { command: string; args: string[] } { + switch (recorder.kind) { + case 'pipewire': + // PipeWire: record from the default source as 16kHz mono signed 16-bit WAV. + return { + command: recorder.bin, + args: ['--rate', String(SAMPLE_RATE), '--channels', String(CHANNELS), '--format', 's16', outputPath], + }; + case 'pulse': + // PulseAudio's parecord writes a WAV when the path ends in .wav. + return { + command: recorder.bin, + args: ['--rate', String(SAMPLE_RATE), '--channels', String(CHANNELS), '--format', 's16le', outputPath], + }; + case 'alsa': + // ALSA's arecord from the default device. + return { + command: recorder.bin, + args: ['-q', '-f', 'S16_LE', '-c', String(CHANNELS), '-r', String(SAMPLE_RATE), '-t', 'wav', outputPath], + }; + case 'sox': { + // `rec` is sox's record front-end; when only `sox` exists, use `-d` for the + // default input device. Output is 16kHz mono signed 16-bit WAV. + const baseArgs = ['-q', '-c', String(CHANNELS), '-r', String(SAMPLE_RATE), '-b', '16', '-e', 'signed-integer']; + if (recorder.bin === 'rec') { + return { command: 'rec', args: [...baseArgs, outputPath] }; + } + return { command: 'sox', args: ['-d', ...baseArgs, outputPath] }; + } + case 'ffmpeg': + default: { + const input = ffmpegInputArgs(); + return { + command: 'ffmpeg', + args: [ + '-hide_banner', + '-loglevel', + 'error', + ...input, + '-ac', + String(CHANNELS), + '-ar', + String(SAMPLE_RATE), + '-y', + outputPath, + ], + }; + } + } +} + +function ffmpegInputArgs(): string[] { + switch (process.platform) { + case 'darwin': + // Capture from the default audio input device, no video. + return ['-f', 'avfoundation', '-i', ':default']; + case 'linux': + default: + // Prefer PulseAudio's default source; ALSA's `default` is the fallback. + return ['-f', 'pulse', '-i', 'default']; + } +} diff --git a/mastracode/src/tui/voice/native/__tests__/compile.test.ts b/mastracode/src/tui/voice/native/__tests__/compile.test.ts new file mode 100644 index 000000000000..21a6df43acb1 --- /dev/null +++ b/mastracode/src/tui/voice/native/__tests__/compile.test.ts @@ -0,0 +1,149 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + spawn: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + rm: vi.fn(), + stat: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ spawn: mocks.spawn })); +vi.mock('node:fs/promises', () => ({ + readFile: mocks.readFile, + writeFile: mocks.writeFile, + mkdir: mocks.mkdir, + rm: mocks.rm, + stat: mocks.stat, +})); + +import { resolveRecognizer } from '../compile.js'; + +/** Build a fake child process that exits with the given code. */ +function fakeProcess(exitCode: number, error?: Error) { + const child = new EventEmitter() as EventEmitter & { stdin?: unknown }; + queueMicrotask(() => { + if (error) child.emit('error', error); + else child.emit('exit', exitCode); + }); + return child; +} + +describe('resolveRecognizer', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.readFile.mockResolvedValue('// swift source'); + mocks.writeFile.mockResolvedValue(undefined); + mocks.mkdir.mockResolvedValue(undefined); + mocks.rm.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('reuses a cached bundle without building', async () => { + mocks.stat.mockResolvedValue({}); // cached binary exists + const result = await resolveRecognizer('/x/macos-stt.swift', '/x/macos-stt.plist'); + expect(result?.appPath).toMatch(/macos-stt-.*\.app$/); + expect(result?.binaryPath).toMatch(/\.app\/Contents\/MacOS\//); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('keys the cache on both the source and the plist', async () => { + mocks.stat.mockResolvedValue({}); + mocks.readFile.mockImplementation((path: string) => + Promise.resolve(path.endsWith('.plist') ? '<plist a>' : '// swift source'), + ); + const first = await resolveRecognizer('/x/macos-stt.swift', '/x/macos-stt.plist'); + + mocks.readFile.mockImplementation((path: string) => + Promise.resolve(path.endsWith('.plist') ? '<plist b>' : '// swift source'), + ); + const second = await resolveRecognizer('/x/macos-stt.swift', '/x/macos-stt.plist'); + + // Different plist contents must produce a different cached bundle path. + expect(first?.appPath).not.toBe(second?.appPath); + }); + + it('builds an .app bundle (Info.plist + compiled binary) when no cache exists', async () => { + mocks.stat.mockRejectedValue(new Error('ENOENT')); + // swiftc --version (ok), swiftc compile (ok), codesign sign (ok) + mocks.spawn.mockImplementation(() => fakeProcess(0)); + + const result = await resolveRecognizer('/x/macos-stt.swift', '/x/macos-stt.plist'); + expect(result?.appPath).toMatch(/macos-stt-.*\.app$/); + expect(result?.binaryPath).toMatch(/\.app\/Contents\/MacOS\//); + expect(mocks.mkdir).toHaveBeenCalled(); + + // The Info.plist must be written into the bundle's Contents dir. + const plistWrite = mocks.writeFile.mock.calls.find((c: unknown[]) => String(c[0]).endsWith('/Contents/Info.plist')); + expect(plistWrite).toBeDefined(); + + // The Swift binary is compiled straight into Contents/MacOS. + const compileCall = mocks.spawn.mock.calls.find( + (c: unknown[]) => c[0] === 'swiftc' && Array.isArray(c[1]) && (c[1] as string[]).includes('-o'), + ); + expect(compileCall).toBeDefined(); + const compileArgs = compileCall![1] as string[]; + const outPath = compileArgs[compileArgs.indexOf('-o') + 1]; + expect(outPath).toMatch(/\.app\/Contents\/MacOS\//); + }); + + it('ad-hoc signs the .app bundle so the Info.plist binds for TCC', async () => { + mocks.stat.mockRejectedValue(new Error('ENOENT')); + mocks.spawn.mockImplementation(() => fakeProcess(0)); + + const result = await resolveRecognizer('/x/macos-stt.swift', '/x/macos-stt.plist'); + + // A `codesign -f -s -` ad-hoc seal of the bundle must run, otherwise macOS + // never shows the permission prompt and the recognizer is killed by TCC. + const signCall = mocks.spawn.mock.calls.find((c: unknown[]) => c[0] === 'codesign'); + expect(signCall).toBeDefined(); + const signArgs = signCall![1] as string[]; + expect(signArgs).toEqual(expect.arrayContaining(['-f', '-s', '-'])); + // It signs the .app bundle, not a loose binary. + expect(signArgs[signArgs.length - 1]).toBe(result?.appPath); + }); + + it('returns null when the ad-hoc sign fails', async () => { + mocks.stat.mockRejectedValue(new Error('ENOENT')); + mocks.spawn.mockImplementation((cmd: string) => { + // swiftc version + compile succeed; codesign fails. + return fakeProcess(cmd === 'codesign' ? 1 : 0); + }); + const result = await resolveRecognizer('/x/macos-stt.swift', '/x/macos-stt.plist'); + expect(result).toBeNull(); + }); + + it('returns null when swiftc is unavailable (no interpreter fallback)', async () => { + mocks.stat.mockRejectedValue(new Error('ENOENT')); + // swiftc --version fails — there is no swift-interpreter fallback because a + // plist-less process TCC-crashes on first microphone touch. + mocks.spawn.mockImplementation(() => fakeProcess(127)); + const result = await resolveRecognizer('/x/macos-stt.swift', '/x/macos-stt.plist'); + expect(result).toBeNull(); + }); + + it('returns null when the native assets cannot be read', async () => { + mocks.stat.mockRejectedValue(new Error('ENOENT')); + // Asset is missing from the published bundle (or unreadable): no throw, just + // "native unavailable" so the caller can fall back to a cloud engine. + mocks.readFile.mockRejectedValue(new Error('ENOENT')); + const result = await resolveRecognizer('/x/macos-stt.swift', '/x/macos-stt.plist'); + expect(result).toBeNull(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('returns null when the swiftc compile fails', async () => { + mocks.stat.mockRejectedValue(new Error('ENOENT')); + mocks.spawn.mockImplementation((_cmd: string, args: string[]) => { + // swiftc --version succeeds; the actual compile fails. + return fakeProcess(args.includes('--version') ? 0 : 1); + }); + const result = await resolveRecognizer('/x/macos-stt.swift', '/x/macos-stt.plist'); + expect(result).toBeNull(); + }); +}); diff --git a/mastracode/src/tui/voice/native/compile.ts b/mastracode/src/tui/voice/native/compile.ts new file mode 100644 index 000000000000..61471599ff76 --- /dev/null +++ b/mastracode/src/tui/voice/native/compile.ts @@ -0,0 +1,213 @@ +/** + * Lazily build the bundled macOS STT Swift recognizer into a cached `.app` bundle. + * + * On first use we `swiftc`-compile `macos-stt.swift` into `Contents/MacOS/` of a + * `.app` bundle written to the cache dir, alongside a generated `Contents/Info.plist` + * (derived from `macos-stt.plist`). The bundle is keyed by a hash of the script + * source + the Info.plist source so a change to either rebuilds. If a cached + * bundle already exists it is reused (fast path). + * + * Why a `.app` bundle and not a loose binary: macOS only shows the Speech + * Recognition / Microphone TCC permission prompts for a process launched as a + * bundled app through LaunchServices (`open`). A bare CLI executable — even one + * with the Info.plist embedded in its `__info_plist` Mach-O section and ad-hoc + * signed — never triggers the `SFSpeechRecognizer.requestAuthorization` callback; + * macOS silently denies and the recognizer aborts. A real `.app` with an + * `Info.plist` carrying the usage strings, ad-hoc signed and launched via `open`, + * is the only reliable way to get the prompt to appear and access to be granted. + * + * The bundle's binary is also usable directly (not via `open`) for `--probe`, + * which only reads authorization status and does not touch protected APIs. + * + * Compilation is async and never blocks the event loop; callers await it before + * launching the recognizer. + */ + +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, writeFile, rm, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Resolve a native asset (the Swift recognizer or its plist) at runtime. + * + * In dev (tsx) the assets sit next to this source file. In a built CLI the JS is + * bundled into a chunk under `dist/`, so the assets are copied to `dist/native/` + * by tsup. We probe both layouts plus a couple of nearby fallbacks so the + * recognizer resolves regardless of how mastracode was launched. + */ +function resolveAsset(name: string): string { + const here = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + join(here, name), // dev: src/tui/voice/native/<name> + join(here, 'native', name), // bundled chunk sitting beside dist/native/ + join(here, '..', 'native', name), + join(here, '..', '..', 'native', name), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + // Fall back to the dev-layout path so the eventual readFile error is clear. + return join(here, name); +} + +const SCRIPT_PATH = resolveAsset('macos-stt.swift'); +const PLIST_PATH = resolveAsset('macos-stt.plist'); + +/** The bundle name and the executable inside it. */ +const APP_NAME = 'MastraCodeVoice'; +const BUNDLE_ID = 'ai.mastra.mastracode.voice'; + +/** How to launch the recognizer. */ +export interface RecognizerInvocation { + /** Path to the `.app` bundle, launched via `open` for recording (TCC prompt). */ + appPath: string; + /** Path to the executable inside the bundle, run directly for `--probe`. */ + binaryPath: string; +} + +function cacheDir(): string { + const base = process.env.XDG_CACHE_HOME?.trim() || join(homedir(), 'Library', 'Caches'); + return join(base, 'mastracode', 'voice'); +} + +async function exists(path: string): Promise<boolean> { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +async function hasSwiftc(): Promise<boolean> { + return runOk('swiftc', ['--version']); +} + +function runOk(command: string, args: string[]): Promise<boolean> { + return new Promise(resolve => { + let child; + try { + child = spawn(command, args, { stdio: 'ignore' }); + } catch { + resolve(false); + return; + } + child.on('error', () => resolve(false)); + child.on('exit', code => resolve(code === 0)); + }); +} + +function compile(scriptPath: string, outPath: string): Promise<void> { + return new Promise((resolve, reject) => { + const child = spawn('swiftc', ['-O', scriptPath, '-o', outPath], { stdio: 'ignore' }); + child.on('error', reject); + child.on('exit', code => { + if (code === 0) resolve(); + else reject(new Error(`swiftc exited with code ${code}`)); + }); + }); +} + +/** + * Ad-hoc sign the `.app` bundle. The bundle's `Info.plist` (with the TCC usage + * strings) is sealed into the signature, which is what lets macOS read the + * strings and present the permission prompt when the app is launched via `open`. + */ +function adhocSign(appPath: string): Promise<void> { + return new Promise((resolve, reject) => { + const child = spawn('codesign', ['-f', '-s', '-', '-i', BUNDLE_ID, appPath], { stdio: 'ignore' }); + child.on('error', reject); + child.on('exit', code => { + if (code === 0) resolve(); + else reject(new Error(`codesign exited with code ${code}`)); + }); + }); +} + +/** + * Build the bundle's `Info.plist` from the checked-in plist source, adding the + * `.app` bundle keys (`CFBundleExecutable`, `CFBundlePackageType`) that a real + * bundle requires on top of the usage-description strings. + */ +function buildInfoPlist(source: string): string { + // The source plist already carries CFBundleIdentifier/Name + usage strings. + // Inject the executable + package-type keys right after the opening <dict>. + const extras = [ + ' <key>CFBundleExecutable</key>', + ` <string>${APP_NAME}</string>`, + ' <key>CFBundlePackageType</key>', + ' <string>APPL</string>', + ' <key>LSUIElement</key>', + ' <true/>', + ].join('\n'); + if (source.includes('<key>CFBundleExecutable</key>')) return source; + return source.replace('<dict>', `<dict>\n${extras}`); +} + +/** + * Write the `.app` bundle layout: `Contents/Info.plist` + `Contents/MacOS/<exe>`. + * The Swift binary is compiled straight into `Contents/MacOS/`. + */ +async function buildBundle(scriptPath: string, plistSource: string, appPath: string): Promise<void> { + // Rebuild from scratch so a stale partial bundle never lingers. + await rm(appPath, { recursive: true, force: true }); + const macosDir = join(appPath, 'Contents', 'MacOS'); + await mkdir(macosDir, { recursive: true }); + await writeFile(join(appPath, 'Contents', 'Info.plist'), buildInfoPlist(plistSource), 'utf8'); + await compile(scriptPath, join(macosDir, APP_NAME)); + await adhocSign(appPath); +} + +/** + * Resolve how to launch the recognizer, building+caching the `.app` bundle on + * first use. + * + * Returns `null` when `swiftc` is unavailable or the build fails — the caller + * surfaces a clear "install Xcode command line tools" message. + */ +export async function resolveRecognizer( + scriptPath: string = SCRIPT_PATH, + plistPath: string = PLIST_PATH, +): Promise<RecognizerInvocation | null> { + let source: string; + let plist: string; + try { + [source, plist] = await Promise.all([readFile(scriptPath, 'utf8'), readFile(plistPath, 'utf8')]); + } catch { + // Native assets aren't shipped/readable — treat as "native unavailable". + return null; + } + // `v3` busts caches built before the .app-bundle + LaunchServices approach + // (loose binaries never triggered the TCC prompt). + const hash = createHash('sha256').update('v3').update(source).update('\0').update(plist).digest('hex').slice(0, 16); + const dir = cacheDir(); + const appPath = join(dir, `macos-stt-${hash}.app`); + const binaryPath = join(appPath, 'Contents', 'MacOS', APP_NAME); + + if (await exists(binaryPath)) { + return { appPath, binaryPath }; + } + + if (!(await hasSwiftc())) { + return null; + } + + await mkdir(dir, { recursive: true }); + try { + await buildBundle(scriptPath, plist, appPath); + } catch { + return null; + } + return { appPath, binaryPath }; +} + +/** Directory used to cache compiled recognizer bundles (exported for tests). */ +export function recognizerCacheDir(): string { + return cacheDir(); +} + +export { SCRIPT_PATH, PLIST_PATH }; diff --git a/mastracode/src/tui/voice/native/macos-stt.plist b/mastracode/src/tui/voice/native/macos-stt.plist new file mode 100644 index 000000000000..c6c2fdb6db37 --- /dev/null +++ b/mastracode/src/tui/voice/native/macos-stt.plist @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleIdentifier</key> + <string>ai.mastra.mastracode.voice</string> + <key>CFBundleName</key> + <string>MastraCode Voice</string> + <key>CFBundleDisplayName</key> + <string>MastraCode Voice</string> + <key>NSMicrophoneUsageDescription</key> + <string>MastraCode uses the microphone for push-to-talk voice input.</string> + <key>NSSpeechRecognitionUsageDescription</key> + <string>MastraCode uses on-device speech recognition to transcribe your voice input.</string> +</dict> +</plist> diff --git a/mastracode/src/tui/voice/native/macos-stt.swift b/mastracode/src/tui/voice/native/macos-stt.swift new file mode 100644 index 000000000000..e413ded9f7e5 --- /dev/null +++ b/mastracode/src/tui/voice/native/macos-stt.swift @@ -0,0 +1,231 @@ +// On-device speech-to-text for MastraCode push-to-talk voice input. +// +// Taps the default input device with AVAudioEngine and feeds it to +// SFSpeechRecognizer with on-device recognition (offline, low-latency). Emits +// newline-delimited JSON events: +// {"type":"ready"} once recognition has started +// {"type":"partial","text":"..."} on each interim hypothesis +// {"type":"final","text":"..."} once, when stopping +// {"type":"error","message":"..."} on any fatal error +// +// IPC: this helper runs inside a .app bundle launched via LaunchServices +// (`open -n -W`), which is the ONLY way macOS shows the Speech Recognition / +// Microphone permission prompts. A LaunchServices-launched app has no usable +// stdin/stdout pipe back to the parent, so events are written to an event file +// and stopping is signalled by the existence of a stop file. Both paths are +// passed as arguments: +// --events <path> append JSONL events here (falls back to stdout if absent) +// --stop <path> poll for this file; when it appears, flush a final result +// +// Run with `--probe` to report permission/availability without recording (this +// mode writes to stdout and is launched directly, not via `open`): +// {"type":"probe","speech":"authorized|denied|restricted|notDetermined", +// "mic":"authorized|denied|restricted|notDetermined","available":true} + +import AVFoundation +import Foundation +import Speech + +// MARK: - Argument parsing + +func argValue(_ flag: String) -> String? { + let args = CommandLine.arguments + guard let idx = args.firstIndex(of: flag), idx + 1 < args.count else { return nil } + return args[idx + 1] +} + +let eventPath = argValue("--events") +let stopPath = argValue("--stop") + +// MARK: - JSON line output + +func emit(_ obj: [String: Any]) { + guard let data = try? JSONSerialization.data(withJSONObject: obj), + let line = String(data: data, encoding: .utf8) else { return } + let payload = Data((line + "\n").utf8) + if let eventPath = eventPath { + if !FileManager.default.fileExists(atPath: eventPath) { + FileManager.default.createFile(atPath: eventPath, contents: nil) + } + if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: eventPath)) { + handle.seekToEndOfFile() + handle.write(payload) + try? handle.close() + } + } else { + FileHandle.standardOutput.write(payload) + } +} + +func fail(_ message: String) -> Never { + emit(["type": "error", "message": message]) + exit(1) +} + +// MARK: - Recognizer + +final class Recognizer { + private let engine = AVAudioEngine() + private let recognizer = SFSpeechRecognizer() + private var request: SFSpeechAudioBufferRecognitionRequest? + private var task: SFSpeechRecognitionTask? + private var lastText = "" + private var finished = false + + func start() { + guard let recognizer = recognizer, recognizer.isAvailable else { + fail("Speech recognizer is not available for this locale.") + } + + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + if recognizer.supportsOnDeviceRecognition { + request.requiresOnDeviceRecognition = true + } + self.request = request + + let input = engine.inputNode + let format = input.outputFormat(forBus: 0) + input.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in + self?.request?.append(buffer) + } + + engine.prepare() + do { + try engine.start() + } catch { + fail("Could not start the audio engine: \(error.localizedDescription)") + } + + task = recognizer.recognitionTask(with: request) { [weak self] result, error in + guard let self = self else { return } + if let result = result { + let text = result.bestTranscription.formattedString + self.lastText = text + if result.isFinal { + self.finish() + } else { + emit(["type": "partial", "text": text]) + } + } + if error != nil { + // End-of-audio also reports here; flush whatever we have. + self.finish() + } + } + + emit(["type": "ready"]) + } + + func stop() { + guard !finished else { return } + engine.inputNode.removeTap(onBus: 0) + engine.stop() + request?.endAudio() + // Give the recognizer a brief moment to emit its final result; if it + // does not, flush the last partial we saw. + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.finish() + } + } + + private func finish() { + guard !finished else { return } + finished = true + emit(["type": "final", "text": lastText]) + exit(0) + } +} + +// MARK: - Permission helpers + +func speechStatusName(_ status: SFSpeechRecognizerAuthorizationStatus) -> String { + switch status { + case .authorized: return "authorized" + case .denied: return "denied" + case .restricted: return "restricted" + case .notDetermined: return "notDetermined" + @unknown default: return "unknown" + } +} + +func micStatusName(_ status: AVAuthorizationStatus) -> String { + switch status { + case .authorized: return "authorized" + case .denied: return "denied" + case .restricted: return "restricted" + case .notDetermined: return "notDetermined" + @unknown default: return "unknown" + } +} + +// MARK: - Probe mode (report permission/availability, no recording) + +if CommandLine.arguments.contains("--probe") { + let speech = SFSpeechRecognizer.authorizationStatus() + let mic = AVCaptureDevice.authorizationStatus(for: .audio) + let available = SFSpeechRecognizer()?.isAvailable ?? false + emit([ + "type": "probe", + "speech": speechStatusName(speech), + "mic": micStatusName(mic), + "available": available, + ]) + exit(0) +} + +// MARK: - Permissions + lifecycle + +let recognizer = Recognizer() + +// Request microphone access, then speech access, then start. Both TCC prompts +// must be granted; we report a clear, specific message for whichever is missing. +func beginAfterAuthorization() { + AVCaptureDevice.requestAccess(for: .audio) { micGranted in + DispatchQueue.main.async { + guard micGranted else { + fail("Microphone permission was denied. Enable it in System Settings › Privacy & Security › Microphone, then try again.") + } + SFSpeechRecognizer.requestAuthorization { status in + DispatchQueue.main.async { + switch status { + case .authorized: + recognizer.start() + case .denied: + fail("Speech Recognition permission was denied. Enable it in System Settings › Privacy & Security › Speech Recognition, then try again.") + case .restricted: + fail("Speech Recognition is restricted on this device.") + case .notDetermined: + fail("Speech Recognition permission was not granted.") + @unknown default: + fail("Speech Recognition permission is unavailable.") + } + } + } + } + } +} + +// Stop on SIGTERM (parent asked us to wrap up). +let sigterm = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) +sigterm.setEventHandler { recognizer.stop() } +sigterm.resume() +signal(SIGTERM, SIG_IGN) + +// Stop when the parent creates the stop file. LaunchServices-launched apps have +// no stdin pipe, so a sentinel file is the control channel. Poll on the main +// queue every 100ms. +if let stopPath = stopPath { + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + .milliseconds(100), repeating: .milliseconds(100)) + timer.setEventHandler { + if FileManager.default.fileExists(atPath: stopPath) { + timer.cancel() + recognizer.stop() + } + } + timer.resume() +} + +beginAfterAuthorization() +RunLoop.main.run() diff --git a/mastracode/src/tui/voice/native/open-settings.ts b/mastracode/src/tui/voice/native/open-settings.ts new file mode 100644 index 000000000000..fe8577137790 --- /dev/null +++ b/mastracode/src/tui/voice/native/open-settings.ts @@ -0,0 +1,27 @@ +/** + * Open a macOS settings deep link (e.g. an `x-apple.systempreferences:` URL) so + * the TUI can take the user straight to the right Privacy & Security pane + * instead of telling them to go find it themselves. + */ + +import { spawn } from 'node:child_process'; + +/** + * Open `url` with the macOS `open` command. Resolves `true` if the launcher + * started cleanly, `false` otherwise (non-darwin, spawn failure, or non-zero + * exit). Never throws — opening settings is best-effort guidance. + */ +export async function openMacSettings(url: string): Promise<boolean> { + if (process.platform !== 'darwin') return false; + return new Promise(resolve => { + let child: ReturnType<typeof spawn>; + try { + child = spawn('open', [url], { stdio: 'ignore' }); + } catch { + resolve(false); + return; + } + child.on('error', () => resolve(false)); + child.on('exit', code => resolve(code === 0)); + }); +} diff --git a/mastracode/src/tui/voice/stt-registry.ts b/mastracode/src/tui/voice/stt-registry.ts new file mode 100644 index 000000000000..1f026de022b4 --- /dev/null +++ b/mastracode/src/tui/voice/stt-registry.ts @@ -0,0 +1,181 @@ +/** + * Speech-to-text (STT) model registry for push-to-talk voice input. + * + * This is the single source of truth shared by the `/voice` settings picker, + * settings validation, and the cloud transcription resolver. Entries are + * derived from models.dev (https://models.dev/api.json): a model is treated as + * STT when its modalities are audio-in / text-out with no text input. + * + * Most cloud providers here speak the OpenAI `/audio/transcriptions` shape, so + * the cloud engine resolves them through Mastra's `OpenAIVoice` (`MastraVoice`) + * built on the OpenAI client — `openai` uses the default endpoint, every other + * OpenAI-compatible host supplies its `baseURL`. Deepgram is the exception: it + * is not OpenAI-compatible, so it resolves through `@mastra/voice-deepgram` + * (`DeepgramVoice`). Deepgram is not in the models.dev STT snapshot but is a + * first-class hosted STT provider, so it is added deliberately. + * + * The accompanying snapshot test (`__tests__/stt-registry.test.ts`) re-derives + * the models.dev-backed entries from a checked-in snapshot so this list stays + * honest and can be refreshed deliberately rather than drifting by hand. + */ + +/** + * How a provider's transcription model is constructed. + * - `openai`: `OpenAIVoice` with the default OpenAI endpoint. + * - `openai-compatible`: `OpenAIVoice` pointed at the host's `baseURL` + * (its `/audio/transcriptions` endpoint follows the OpenAI shape). + * - `deepgram`: `DeepgramVoice` from `@mastra/voice-deepgram` (not + * OpenAI-compatible; uses the Deepgram SDK). + */ +export type STTResolver = 'openai' | 'openai-compatible' | 'deepgram'; + +export interface STTModel { + /** models.dev provider id (also the AuthStorage key for the API key). */ + provider: string; + /** Bare model id passed to the provider's transcription factory. */ + model: string; + /** Human-friendly label for the picker. */ + label: string; + /** How to build the transcription model. */ + resolver: STTResolver; + /** Base URL for `openai-compatible` providers (from models.dev `api`). */ + baseURL?: string; +} + +/** + * The curated STT catalog. Order matters: the first entry for a provider is its + * default model, and the first overall entry (`openai`/`whisper-1`) is the + * global default. + */ +export const STT_MODELS: readonly STTModel[] = [ + // OpenAI — default provider. + { provider: 'openai', model: 'whisper-1', label: 'OpenAI Whisper', resolver: 'openai' }, + { provider: 'openai', model: 'gpt-4o-transcribe', label: 'OpenAI GPT-4o Transcribe', resolver: 'openai' }, + { + provider: 'openai', + model: 'gpt-4o-mini-transcribe', + label: 'OpenAI GPT-4o mini Transcribe', + resolver: 'openai', + }, + // Groq — fast, cheap whisper hosting via its OpenAI-compatible endpoint. + { + provider: 'groq', + model: 'whisper-large-v3-turbo', + label: 'Groq Whisper Large v3 Turbo', + resolver: 'openai-compatible', + baseURL: 'https://api.groq.com/openai/v1', + }, + { + provider: 'groq', + model: 'whisper-large-v3', + label: 'Groq Whisper Large v3', + resolver: 'openai-compatible', + baseURL: 'https://api.groq.com/openai/v1', + }, + // Deepgram — dedicated STT provider (not OpenAI-compatible); via @mastra/voice-deepgram. + { provider: 'deepgram', model: 'nova-3', label: 'Deepgram Nova-3', resolver: 'deepgram' }, + // Alibaba Qwen ASR (OpenAI-compatible endpoints). + { + provider: 'alibaba', + model: 'qwen3-asr-flash', + label: 'Alibaba Qwen3 ASR Flash', + resolver: 'openai-compatible', + baseURL: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + }, + { + provider: 'alibaba-cn', + model: 'qwen3-asr-flash', + label: 'Alibaba Qwen3 ASR Flash (China)', + resolver: 'openai-compatible', + baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + }, + // Whisper hosts (OpenAI-compatible endpoints). + { + provider: 'scaleway', + model: 'whisper-large-v3', + label: 'Scaleway Whisper Large v3', + resolver: 'openai-compatible', + baseURL: 'https://api.scaleway.ai/v1', + }, + { + provider: 'nvidia', + model: 'openai/whisper-large-v3', + label: 'Nvidia Whisper Large v3', + resolver: 'openai-compatible', + baseURL: 'https://integrate.api.nvidia.com/v1', + }, + { + provider: 'nearai', + model: 'openai/whisper-large-v3', + label: 'NEAR AI Whisper Large v3', + resolver: 'openai-compatible', + baseURL: 'https://cloud-api.near.ai/v1', + }, + { + provider: 'evroc', + model: 'openai/whisper-large-v3-turbo', + label: 'evroc Whisper Large v3 Turbo', + resolver: 'openai-compatible', + baseURL: 'https://models.think.evroc.com/v1', + }, + { + provider: 'evroc', + model: 'openai/whisper-large-v3', + label: 'evroc Whisper Large v3', + resolver: 'openai-compatible', + baseURL: 'https://models.think.evroc.com/v1', + }, + { + provider: 'evroc', + model: 'KBLab/kb-whisper-large', + label: 'evroc KB-Whisper Large', + resolver: 'openai-compatible', + baseURL: 'https://models.think.evroc.com/v1', + }, +] as const; + +/** The global default STT model (first registry entry). */ +export const DEFAULT_STT_MODEL: STTModel = STT_MODELS[0]!; + +/** The default STT provider when none is configured. */ +export const DEFAULT_STT_PROVIDER = DEFAULT_STT_MODEL.provider; + +/** Unique provider ids in registry order. */ +export function sttProviders(): string[] { + const seen = new Set<string>(); + const out: string[] = []; + for (const m of STT_MODELS) { + if (!seen.has(m.provider)) { + seen.add(m.provider); + out.push(m.provider); + } + } + return out; +} + +/** All models offered for a provider, in registry order. */ +export function sttModelsForProvider(provider: string): STTModel[] { + return STT_MODELS.filter(m => m.provider === provider); +} + +/** The default (first) model for a provider, or undefined if unknown. */ +export function defaultModelForProvider(provider: string): STTModel | undefined { + return STT_MODELS.find(m => m.provider === provider); +} + +/** + * Resolve a provider/model pair to a concrete registry entry. + * Falls back to the provider default (then the global default) so callers + * always get a usable entry even if settings name an unknown model. + */ +export function resolveSTTModel(provider?: string, model?: string): STTModel { + if (provider) { + if (model) { + const exact = STT_MODELS.find(m => m.provider === provider && m.model === model); + if (exact) return exact; + } + const providerDefault = defaultModelForProvider(provider); + if (providerDefault) return providerDefault; + } + return DEFAULT_STT_MODEL; +} diff --git a/mastracode/src/tui/voice/transcribe.ts b/mastracode/src/tui/voice/transcribe.ts new file mode 100644 index 000000000000..9f0781480b35 --- /dev/null +++ b/mastracode/src/tui/voice/transcribe.ts @@ -0,0 +1,174 @@ +/** + * Speech-to-text transcription for push-to-talk voice input (cloud path). + * + * Provider-agnostic: most supported cloud providers speak the OpenAI + * `/audio/transcriptions` shape (see `stt-registry.ts`), so we drive them + * through Mastra's own voice abstraction — `OpenAIVoice` (a `MastraVoice`) from + * `@mastra/voice-openai`. The `openai` provider uses the default endpoint; every + * other OpenAI-compatible host is reached by pointing the underlying client at + * its `baseURL`. Deepgram is not OpenAI-compatible, so it is driven through + * `DeepgramVoice` from `@mastra/voice-deepgram` instead. + * + * Using `MastraVoice.listen()` keeps this aligned with the framework's voice + * ecosystem and normalizes provider responses to a transcript string. The voice + * package constructs its own SDK client internally, so this avoids coupling to a + * specific `@ai-sdk/*` model-spec version. + * + * Note: OpenAI OAuth (Codex) tokens cannot be used for the audio transcription + * REST endpoint, so a real provider API key is required. + */ + +import { Readable } from 'node:stream'; +import { DeepgramVoice } from '@mastra/voice-deepgram'; +import { OpenAIVoice } from '@mastra/voice-openai'; +import type { AuthStorage } from '../../auth/storage.js'; +import { DEFAULT_STT_MODEL, resolveSTTModel } from './stt-registry.js'; +import type { STTModel } from './stt-registry.js'; + +/** + * The minimal surface of a `MastraVoice` we use here. Declared structurally to + * avoid coupling to a specific cross-package `MastraVoice` class identity + * (`@mastra/voice-*` packages extend their own bundled copy). + */ +interface VoiceListener { + listen(audioStream: NodeJS.ReadableStream, options?: Record<string, unknown>): Promise<unknown>; +} + +/** + * Per-provider environment variable that holds an API key, checked before the + * stored credential. Mirrors the key names used elsewhere in MastraCode. + */ +const PROVIDER_ENV_VAR: Record<string, string> = { + openai: 'OPENAI_API_KEY', + groq: 'GROQ_API_KEY', + alibaba: 'DASHSCOPE_API_KEY', + 'alibaba-cn': 'DASHSCOPE_API_KEY', + scaleway: 'SCALEWAY_API_KEY', + nvidia: 'NVIDIA_API_KEY', + nearai: 'NEAR_AI_API_KEY', + evroc: 'EVROC_API_KEY', + deepgram: 'DEEPGRAM_API_KEY', +}; + +export class VoiceCredentialError extends Error { + constructor(provider: string) { + super( + `Voice input needs a ${provider} API key. Set ${ + PROVIDER_ENV_VAR[provider] ?? `${provider.toUpperCase()}_API_KEY` + } or add one with /api-keys (OAuth tokens are not supported for transcription).`, + ); + this.name = 'VoiceCredentialError'; + } +} + +/** + * Resolve an API key for a cloud STT provider. + * Honors the env-overrides-stored-key contract: the provider's env var wins, + * then the stored credential is used as a fallback. + */ +export function resolveProviderApiKey(provider: string, authStorage?: AuthStorage): string | undefined { + const envVar = PROVIDER_ENV_VAR[provider]; + const fromEnv = envVar ? process.env[envVar]?.trim() : undefined; + if (fromEnv) return fromEnv; + return authStorage?.getStoredApiKey(provider); +} + +/** + * Build a `MastraVoice` for an STT registry entry. + * - `deepgram`: `DeepgramVoice` (Deepgram SDK; not OpenAI-compatible). + * - everything else: `OpenAIVoice`, where `listeningModel.name` is the model id + * and `options.baseURL` redirects OpenAI-compatible hosts at their endpoint. + */ +function buildVoice(entry: STTModel, apiKey: string): VoiceListener { + if (entry.resolver === 'deepgram') { + return new DeepgramVoice({ + // `name` is typed for Deepgram's own ids; the model string is passed verbatim. + listeningModel: { name: entry.model as never, apiKey }, + }); + } + return new OpenAIVoice({ + // `name`/`options` are typed for OpenAI's own ids, but the underlying client + // accepts any model string + baseURL — both are passed through verbatim. + listeningModel: { + name: entry.model as never, + apiKey, + ...(entry.resolver === 'openai-compatible' && entry.baseURL ? { options: { baseURL: entry.baseURL } } : {}), + }, + }); +} + +export interface TranscribeOptions { + /** STT provider id (see `stt-registry.ts`). Defaults to the registry default. */ + provider?: string; + /** Model id within the provider. Defaults to the provider's default model. */ + model?: string; + authStorage?: AuthStorage; +} + +/** + * Transcribe recorded WAV audio to text via the configured cloud provider. + * Throws VoiceCredentialError if no API key is available for the provider. + */ +export async function transcribeAudio(audio: Buffer, options: TranscribeOptions = {}): Promise<string> { + const entry = resolveSTTModel(options.provider, options.model) ?? DEFAULT_STT_MODEL; + const apiKey = resolveProviderApiKey(entry.provider, options.authStorage); + if (!apiKey) { + throw new VoiceCredentialError(entry.provider); + } + + const voice = buildVoice(entry, apiKey); + const result = await voice.listen(Readable.from(audio), { filetype: 'wav' }); + return normalizeTranscript(result); +} + +/** + * A reusable transcriber bound to one provider/model. Building the underlying + * `MastraVoice` client once and reusing it across calls lets the HTTP client + * keep its connection to the provider warm (keep-alive), which removes the + * DNS + TLS handshake cost from every live-partial tick — the main reason the + * first dictation streams in slowly while later ones feel instant. + */ +export interface ReusableTranscriber { + transcribe(audio: Buffer): Promise<string>; +} + +/** + * Create a transcriber that reuses a single provider client across calls. + * Resolves the provider/model and API key once up front (throwing + * `VoiceCredentialError` if no key is available), so a session can build it on + * start and call `transcribe()` per tick without re-resolving or reconnecting. + */ +export function createTranscriber(options: TranscribeOptions = {}): ReusableTranscriber { + const entry = resolveSTTModel(options.provider, options.model) ?? DEFAULT_STT_MODEL; + const apiKey = resolveProviderApiKey(entry.provider, options.authStorage); + if (!apiKey) { + throw new VoiceCredentialError(entry.provider); + } + const voice = buildVoice(entry, apiKey); + return { + async transcribe(audio: Buffer): Promise<string> { + const result = await voice.listen(Readable.from(audio), { filetype: 'wav' }); + return normalizeTranscript(result); + }, + }; +} + +/** + * Check whether a cloud STT provider has a usable API key, without recording. + */ +export function hasProviderCredential(provider: string, authStorage?: AuthStorage): boolean { + return resolveProviderApiKey(provider, authStorage) !== undefined; +} + +/** + * `MastraVoice.listen()` returns a string for OpenAI-shaped transcription, but + * other provider subclasses may return `{ transcript }` — normalize both. + */ +function normalizeTranscript(result: unknown): string { + if (typeof result === 'string') return result.trim(); + if (result && typeof result === 'object' && 'transcript' in result) { + const transcript = (result as { transcript?: unknown }).transcript; + if (typeof transcript === 'string') return transcript.trim(); + } + return ''; +} diff --git a/mastracode/src/tui/voice/voice-controller.ts b/mastracode/src/tui/voice/voice-controller.ts new file mode 100644 index 000000000000..9847376b4cb4 --- /dev/null +++ b/mastracode/src/tui/voice/voice-controller.ts @@ -0,0 +1,257 @@ +/** + * Push-to-talk voice input controller for the TUI. + * + * Owns the enabled/recording state and ties microphone capture to + * transcription. The editor drives this controller from its key dispatch: + * it calls startRecording() when a held space begins push-to-talk, and + * stopRecording() when the space key is released (detected as an idle gap + * after the last repeated space). + * + * Transcribed text is delivered through the onTranscript callback so the + * editor can insert it at the cursor. + */ + +import type { AuthStorage } from '../../auth/storage.js'; +import type { VoiceSettings } from '../../onboarding/settings.js'; +import { createSTTEngine } from './engines/index.js'; +import type { PermissionGuidance, STTEngine, STTSession } from './engines/types.js'; + +export interface VoiceControllerOptions { + authStorage?: AuthStorage; + /** Voice configuration (engine/provider/model). */ + settings: VoiceSettings; + /** Called with transcribed text to insert at the cursor. */ + onTranscript: (text: string) => void; + /** + * Called repeatedly during recording with the best transcript of the audio + * captured so far. Each call supersedes the previous one (replace, not + * append), so the input shows live dictation as the user keeps speaking. + */ + onPartialTranscript?: (text: string) => void; + /** Show a transient informational message. */ + showInfo: (message: string) => void; + /** Show a transient error message. */ + showError: (message: string) => void; + /** + * Called when push-to-talk recording starts (true) and ends (false) so the + * editor can drive a "listening" cursor animation. + */ + onListeningChange?: (listening: boolean) => void; +} + +export type VoiceState = 'idle' | 'recording' | 'transcribing'; + +export class VoiceController { + private enabled = false; + private state: VoiceState = 'idle'; + private session: STTSession | null = null; + private engine: STTEngine; + private settings: VoiceSettings; + // Whether at least one live partial transcript actually streamed during the + // current recording. Lets stop() distinguish "nothing was heard" from "live + // text already surfaced", so the capture warning only fires when truly empty. + private liveTranscriptEmitted = false; + private readonly options: VoiceControllerOptions; + + constructor(options: VoiceControllerOptions) { + this.options = options; + this.settings = options.settings; + this.engine = createSTTEngine(this.settings, options.authStorage); + } + + /** + * Swap the active engine/provider/model from updated settings. If voice is + * currently enabled it is re-validated against the new engine. + */ + reconfigure(settings: VoiceSettings): void { + this.cancelRecording(); + this.settings = settings; + this.engine = createSTTEngine(settings, this.options.authStorage); + if (this.enabled) { + const problem = this.engine.checkReady(); + if (problem) { + this.enabled = false; + this.options.showError(problem); + } + } + } + + isEnabled(): boolean { + return this.enabled; + } + + /** + * Deeper readiness check that may do async work (e.g. compiling the native + * recognizer). Returns `null` when ready or a user-facing problem message. + * Falls back to the synchronous check when the engine has no async verify. + */ + async verifyReady(): Promise<string | null> { + if (this.engine.verify) return this.engine.verify(); + return this.engine.checkReady(); + } + + /** + * Structured, actionable permission guidance for the active engine (e.g. how + * to grant macOS Microphone/Speech access). Returns `null` for engines that + * don't expose it (cloud), so callers can skip the guided flow. + */ + async permissionGuidance(): Promise<PermissionGuidance | null> { + if (this.engine.permissions) return this.engine.permissions(); + return null; + } + + getState(): VoiceState { + return this.state; + } + + isRecording(): boolean { + return this.state === 'recording'; + } + + /** + * Toggle voice input on/off. Returns the new enabled state. + * Reports problems (missing recorder/credentials) via showError and stays + * disabled when prerequisites are missing. + */ + toggle(): boolean { + if (this.enabled) { + this.disable(); + return false; + } + return this.enable(); + } + + enable(): boolean { + const problem = this.engine.checkReady(); + if (problem) { + this.options.showError(problem); + return false; + } + this.enabled = true; + this.options.showInfo('Voice input on. Hold space to talk; release to transcribe. /voice to turn off.'); + return true; + } + + /** + * Restore the persisted enabled state at startup without emitting the + * interactive "voice input on" message or surfacing errors. Silently stays + * disabled if the active engine is not ready. + */ + restoreEnabled(): void { + if (this.enabled) return; + if (this.engine.checkReady()) return; + this.enabled = true; + } + + disable(): void { + this.cancelRecording(); + this.enabled = false; + this.options.showInfo('Voice input off.'); + } + + /** + * Begin a streaming recognition session. No-op if disabled or already active. + * Partial results stream into the input via onPartialTranscript; the final + * result replaces the live text (live mode) or streams in word-by-word. + */ + startRecording(): void { + if (!this.enabled || this.state !== 'idle') return; + const live = !!this.options.onPartialTranscript; + this.liveTranscriptEmitted = false; + this.state = 'recording'; + this.options.onListeningChange?.(true); + + this.session = this.engine.start({ + onPartial: text => { + if (this.state !== 'recording' || !text) return; + this.liveTranscriptEmitted = true; + this.options.onPartialTranscript?.(text); + }, + onFinal: text => { + if (live) { + // Replace the live partial with the final, most accurate transcript. + if (text) this.options.onPartialTranscript!(text); + else if (!this.liveTranscriptEmitted) this.options.showInfo('No speech detected.'); + } else if (text) { + void this.streamTranscript(text); + } else { + this.options.showInfo('No speech detected.'); + } + }, + onError: err => { + void this.reportSessionError(err); + }, + }); + } + + /** + * Stop the session and let the engine emit its final transcript. + */ + async stopRecording(): Promise<void> { + if (this.state !== 'recording') return; + const session = this.session; + this.session = null; + this.state = 'transcribing'; + this.options.onListeningChange?.(false); + try { + await session?.stop(); + } finally { + this.state = 'idle'; + } + } + + /** + * Abort any in-progress session without transcribing. + */ + cancelRecording(): void { + if (this.session) { + this.session.cancel(); + this.session = null; + } + if (this.state !== 'idle') { + this.state = 'idle'; + this.options.onListeningChange?.(false); + } + } + + /** + * Surface a session error. If the active engine can explain a permission + * problem (e.g. macOS access is blocked), append the concrete fix steps so the + * user knows exactly what to do instead of seeing a bare failure. + */ + private async reportSessionError(err: Error): Promise<void> { + const base = err.message || 'Transcription failed.'; + try { + const guidance = await this.engine.permissions?.(); + // Only dress the error with fix steps when access is actually blocked or + // unavailable. A `will-prompt` (not-yet-determined) state is NOT the reason + // a session failed — surfacing "macOS will prompt next time" in red after a + // crash is confusing, so we let the real error message through instead. + if (guidance && (guidance.state === 'blocked' || guidance.state === 'unsupported') && guidance.steps?.length) { + const steps = guidance.steps.map((step, i) => ` ${i + 1}. ${step}`).join('\n'); + this.options.showError(`${guidance.summary}\n${steps}`); + return; + } + } catch { + // Fall through to the plain error if guidance can't be produced. + } + this.options.showError(base); + } + + /** + * Feed the transcript into the editor incrementally so it visibly streams in + * word-by-word rather than appearing all at once. Each chunk keeps its + * trailing whitespace so word spacing is preserved. + */ + private async streamTranscript(text: string): Promise<void> { + const chunks = text.match(/\S+\s*/g); + if (!chunks) { + this.options.onTranscript(text); + return; + } + for (const chunk of chunks) { + this.options.onTranscript(chunk); + await new Promise(resolve => setTimeout(resolve, 30)); + } + } +} diff --git a/mastracode/src/utils/__tests__/slash-command-loader.test.ts b/mastracode/src/utils/__tests__/slash-command-loader.test.ts index 95034493dbe1..f82e49a5b02b 100644 --- a/mastracode/src/utils/__tests__/slash-command-loader.test.ts +++ b/mastracode/src/utils/__tests__/slash-command-loader.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { parseCommandFile, scanCommandDirectory } from '../slash-command-loader.js'; +import { loadCustomCommands, parseCommandFile, scanCommandDirectory } from '../slash-command-loader.js'; describe('slash command loader', () => { it('parses goal metadata from frontmatter', async () => { @@ -31,4 +31,20 @@ describe('slash command loader', () => { expect(commands).toHaveLength(1); expect(commands[0]).toMatchObject({ name: 'review', goal: true }); }); + + it('loads plugin command directories after built-in custom command locations', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'mastracode-project-')); + const pluginCommandsDir = await mkdtemp(join(tmpdir(), 'mastracode-plugin-commands-')); + await writeFile( + join(pluginCommandsDir, 'alexandria.md'), + '---\ndescription: Ask Alexandria\n---\nAsk $ARGUMENTS\n', + ); + + const commands = await loadCustomCommands(projectDir, '.mastracode', [pluginCommandsDir]); + + expect(commands.find(command => command.name === 'alexandria')).toMatchObject({ + description: 'Ask Alexandria', + sourcePath: join(pluginCommandsDir, 'alexandria.md'), + }); + }); }); diff --git a/mastracode/src/utils/project.ts b/mastracode/src/utils/project.ts index 2b0de2988839..45b7d31c7c0c 100644 --- a/mastracode/src/utils/project.ts +++ b/mastracode/src/utils/project.ts @@ -252,6 +252,14 @@ export interface LibSQLStorageConfig { url: string; authToken?: string; isRemote: boolean; + /** + * Optional explicit url for the recall vector DB. When omitted, the factory + * uses the shared default vector file. Per-tenant storage sets this so each + * tenant's recall vectors live in their own isolated DB, not a shared file. + */ + vectorUrl?: string; + /** Auth token for the vector DB when `vectorUrl` points at a remote libSQL. */ + vectorAuthToken?: string; } /** diff --git a/mastracode/src/utils/slash-command-loader.ts b/mastracode/src/utils/slash-command-loader.ts index 4aa10cc36dda..6637a0913d08 100644 --- a/mastracode/src/utils/slash-command-loader.ts +++ b/mastracode/src/utils/slash-command-loader.ts @@ -139,6 +139,7 @@ export async function scanCommandDirectory(dirPath: string, rootDir?: string): P export async function loadCustomCommands( projectDir?: string, configDirName = DEFAULT_CONFIG_DIR, + extraCommandDirs: string[] = [], ): Promise<SlashCommandMetadata[]> { // Use a Map so later (higher priority) sources override earlier ones with the same name const commandMap = new Map<string, SlashCommandMetadata>(); @@ -186,13 +187,18 @@ export async function loadCustomCommands( addCommands(claudeProjectCommands); } - // 6. Load from mastra project directory <configDirName>/commands (highest priority) + // 6. Load from mastra project directory <configDirName>/commands if (projectDir) { const mastraProjectDir = path.join(projectDir, configDirName, 'commands'); const mastraProjectCommands = await scanCommandDirectory(mastraProjectDir); addCommands(mastraProjectCommands); } + // 7. Load from active plugin command directories (highest priority) + for (const commandsDir of extraCommandDirs) { + addCommands(await scanCommandDirectory(commandsDir)); + } + return Array.from(commandMap.values()); } diff --git a/mastracode/src/utils/storage-factory.ts b/mastracode/src/utils/storage-factory.ts index 4859b519eabc..3a21751e8370 100644 --- a/mastracode/src/utils/storage-factory.ts +++ b/mastracode/src/utils/storage-factory.ts @@ -13,11 +13,34 @@ import { PostgresStore } from '@mastra/pg'; import type { StorageConfig, PgStorageConfig } from './project.js'; import { getDatabasePath, getVectorDatabasePath } from './project.js'; -const MASTRA_CODE_LOCAL_PRAGMAS = { +export const MASTRA_CODE_LOCAL_PRAGMAS = { cacheSize: -128000, mmapSize: 536870912, }; +/** + * Construct a LibSQL store for an arbitrary url/authToken, applying the same + * local pragmas the default factory uses. Shared so per-tenant storage + * (see `web/tenant-storage.ts`) doesn't duplicate the construction. + */ +export function buildLibSQLStore(opts: { id?: string; url: string; authToken?: string }): MastraCompositeStore { + return new LibSQLStore({ + id: opts.id ?? 'mastra-code-storage', + url: opts.url, + ...(opts.authToken ? { authToken: opts.authToken } : {}), + localPragmas: MASTRA_CODE_LOCAL_PRAGMAS, + }); +} + +/** Construct a LibSQL vector store for an arbitrary url/authToken. */ +export function buildLibSQLVector(opts: { id?: string; url: string; authToken?: string }): MastraVector { + return new LibSQLVector({ + id: opts.id ?? 'mastra-code-vectors', + url: opts.url, + ...(opts.authToken ? { authToken: opts.authToken } : {}), + }); +} + export interface StorageResult { storage: MastraCompositeStore; /** The effective backend after any fallback logic has run. */ @@ -27,11 +50,7 @@ export interface StorageResult { } function createFallbackLibSQL(): MastraCompositeStore { - return new LibSQLStore({ - id: 'mastra-code-storage', - url: `file:${getDatabasePath()}`, - localPragmas: MASTRA_CODE_LOCAL_PRAGMAS, - }); + return buildLibSQLStore({ url: `file:${getDatabasePath()}` }); } /** @@ -47,12 +66,7 @@ export async function createStorage(config: StorageConfig): Promise<StorageResul // Default: LibSQL return { - storage: new LibSQLStore({ - id: 'mastra-code-storage', - url: config.url, - ...(config.authToken ? { authToken: config.authToken } : {}), - localPragmas: MASTRA_CODE_LOCAL_PRAGMAS, - }), + storage: buildLibSQLStore({ url: config.url, authToken: config.authToken }), backend: 'libsql', }; } @@ -134,9 +148,12 @@ export async function createVectorStore( }); } - // LibSQL: separate file for vectors - return new LibSQLVector({ - id: 'mastra-code-vectors', - url: `file:${getVectorDatabasePath()}`, + // LibSQL: separate file for vectors. Per-tenant configs supply an explicit + // vectorUrl so each tenant's recall vectors are isolated; otherwise use the + // shared default file. + const libsqlConfig = config as { vectorUrl?: string; vectorAuthToken?: string }; + return buildLibSQLVector({ + url: libsqlConfig.vectorUrl ?? `file:${getVectorDatabasePath()}`, + authToken: libsqlConfig.vectorAuthToken, }); } diff --git a/mastracode/src/web/.env.example b/mastracode/src/web/.env.example new file mode 100644 index 000000000000..90ba5440e210 --- /dev/null +++ b/mastracode/src/web/.env.example @@ -0,0 +1,144 @@ +# MastraCode Web — example environment configuration. +# Copy to .env and fill in real values. Every section is optional: when the +# vars for a feature are absent, that feature is a no-op and the web app +# behaves exactly as before. +# +# Comments live on their own lines (never after `KEY=value`) so that env-file +# loaders don't parse the trailing text as part of the value. + +# --------------------------------------------------------------------------- +# Browser-facing origin +# The public origin the browser uses to reach the app. OAuth callback URLs are +# derived from this. In dev the SPA is served by Vite (e.g. :5173) and proxies +# to the server (:4111), so set this to the SPA origin. Falls back to the +# server bind when unset. +# --------------------------------------------------------------------------- +# MASTRACODE_PUBLIC_URL=http://localhost:5173 + +# --------------------------------------------------------------------------- +# WorkOS auth +# When WORKOS_API_KEY and WORKOS_CLIENT_ID are set, every route requires a +# signed-in user (hosted login + encrypted session). This is a prerequisite +# for the GitHub projects feature below. +# --------------------------------------------------------------------------- +WORKOS_API_KEY= +WORKOS_CLIENT_ID= +# optional — defaults to <MASTRACODE_PUBLIC_URL>/auth/callback +WORKOS_REDIRECT_URI= +# optional (recommended in prod): 32+ char secret to seal session cookies +WORKOS_COOKIE_PASSWORD= +# optional — on first authenticated use, bootstrap a personal WorkOS +# organization for users who have none, so org-scoped features (GitHub connect) +# work without hand-creating an org. Enabled by default. Set to 0 for +# deployments that provision orgs themselves (e.g. SSO). Requires the WorkOS API +# key to have permission to create organizations and memberships. +MASTRACODE_BOOTSTRAP_PERSONAL_ORG= + +# --------------------------------------------------------------------------- +# GitHub projects +# When the GitHub App variables are set AND WorkOS auth is enabled, signed-in +# users can install the GitHub App, pick repositories, and turn each repo into +# a project. Repo and project metadata persist in a separate application +# Postgres (APP_DATABASE_URL), distinct from Mastra storage. +# --------------------------------------------------------------------------- +GITHUB_APP_ID= +GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" +GITHUB_APP_CLIENT_ID= +GITHUB_APP_CLIENT_SECRET= +GITHUB_APP_SLUG=your-app-slug +# For local dev, run `docker compose up -d` from the mastracode/ package root +# (see docker-compose.yml); these defaults match that container. +APP_DATABASE_URL=postgres://user:pass@localhost:54329/mastracode_web +# optional — defaults to <MASTRACODE_PUBLIC_URL>/auth/github/callback +GITHUB_APP_REDIRECT_URI= +# Secret for install/uninstall webhooks. Also used to sign GitHub OAuth/install +# state. REQUIRED for multi-replica deployments: without an explicit secret +# (this or WORKOS_COOKIE_PASSWORD), state is signed with a per-process random +# key and callbacks fail on a replica that did not sign the state. +GITHUB_APP_WEBHOOK_SECRET= + +# --------------------------------------------------------------------------- +# Sandbox provider (repo materialization) +# GitHub-backed projects are materialized (cloned) into a sandbox on open. The +# provider is selected automatically: if a Railway token is set we use an +# isolated cloud VM per project; otherwise we fall back to a LOCAL provider that +# runs git directly on the server host. So repos can always be opened with no +# extra wiring. The sandbox must have `git` and `gh` (the GitHub CLI) and +# outbound access to github.com; `gh` is only needed to open pull requests. +# +# WARNING: the local provider has NO tenant isolation — every project's git runs +# as the server process on the shared host filesystem. It is intended for +# single-user local development only; configure Railway for shared multi-tenant +# deployments. +# --------------------------------------------------------------------------- +RAILWAY_API_TOKEN= +RAILWAY_ENVIRONMENT_ID= +# optional — force a specific provider ('railway' or 'local'). Leave unset to +# auto-select: railway when RAILWAY_API_TOKEN is set, else local. +MASTRACODE_SANDBOX_PROVIDER= +# optional (path inside a cloud sandbox; ignored by the local provider) +MASTRACODE_SANDBOX_WORKDIR=/workspace +# optional — root dir for local-provider checkouts +# (default ~/.mastracode/web/sandboxes); only used when the local provider runs +MASTRACODE_LOCAL_SANDBOX_ROOT= +# optional — idle teardown window in minutes (default 30); the next open +# re-provisions automatically if the VM was stopped +MASTRACODE_SANDBOX_IDLE_MINUTES=30 +# optional — per-replica cap on concurrently live sandboxes. New provisioning +# beyond this returns an actionable error; 0 / unset means unlimited. +MASTRACODE_MAX_SANDBOXES= + +# --------------------------------------------------------------------------- +# Per-(org,user) storage isolation (multi-tenant) +# When WorkOS web auth is enabled, the tenant boundary is (organization, user): +# each user in each org gets their own isolated libSQL database for all agent +# state (threads/messages/memory/recall vectors). The DB location is derived +# server-side from a hash of the (orgId, userId) pair; users without an org +# fall back to a user-only key. By default each tenant gets local libSQL files; +# for hosted deployments use the remote URL templates below ({id} is replaced +# with the hashed tenant key). +# --------------------------------------------------------------------------- +# optional — root dir for local per-tenant libSQL files +MASTRACODE_TENANT_DB_ROOT= +# optional — remote libSQL/Turso per tenant +MASTRACODE_TENANT_DB_URL_TEMPLATE= +# optional — separate remote vector DB per tenant +MASTRACODE_TENANT_VECTOR_URL_TEMPLATE= +# optional — auth token for the remote tenant storage DB +MASTRACODE_TENANT_DB_AUTH_TOKEN= +# optional — auth token for the remote tenant vector DB +MASTRACODE_TENANT_VECTOR_AUTH_TOKEN= +# --------------------------------------------------------------------------- +# Turso auto-provisioning (optional, alternative to the URL templates above). +# When both a platform token and org are set, each tenant's own Turso database +# is created on first access via the Turso Platform API, a scoped token is +# minted per resolution, and the stable db-name/hostname mapping is persisted +# in the app Postgres (APP_DATABASE_URL) so all replicas converge on one DB. +# Requires APP_DATABASE_URL. Takes priority over local files but not over an +# explicit MASTRACODE_TENANT_DB_URL_TEMPLATE. +# --------------------------------------------------------------------------- +# optional — Turso Platform API token (https://app.turso.tech, platform scope) +MASTRACODE_TURSO_PLATFORM_TOKEN= +# optional — Turso organization slug/name that owns the provisioned databases +MASTRACODE_TURSO_ORG= +# optional — Turso group new databases are created in (default "default") +MASTRACODE_TURSO_GROUP= +# optional — set to 1 to fail/warn at startup when no remote tenant DB backend +# (URL template OR Turso provisioning) is configured (local-file tenant DBs do +# not persist or share across replicas) +MASTRACODE_REQUIRE_REMOTE_TENANT_DB= +# optional — idle minutes before an inactive tenant's in-memory Mastra stack is +# evicted from the dispatcher cache (default 30; 0 disables idle eviction) +MASTRACODE_TENANT_IDLE_MINUTES=30 +# optional — max number of tenant stacks kept in memory before LRU eviction +# (default 100; 0 disables the cap) +MASTRACODE_TENANT_MAX_APPS=100 + +# --------------------------------------------------------------------------- +# Multi-replica deployment +# --------------------------------------------------------------------------- +# optional — set to 0 to disable the Postgres advisory-lock layer used to +# serialize per-(project,user) git write operations across replicas. When +# enabled (default), it requires APP_DATABASE_URL. Use 0 for single-process +# local dev. +MASTRACODE_DISTRIBUTED_LOCK= diff --git a/mastracode/src/web/auth.test.ts b/mastracode/src/web/auth.test.ts new file mode 100644 index 000000000000..6f0e40a7a111 --- /dev/null +++ b/mastracode/src/web/auth.test.ts @@ -0,0 +1,409 @@ +import { MastraAuthWorkos } from '@mastra/auth-workos'; +import { Hono } from 'hono'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { WebAuthUser } from './auth.js'; +import { + ensureUserHasOrganization, + getWebAuthOrgId, + getWebAuthUser, + getWebAuthUserId, + isWebAuthEnabled, + mountWebAuth, + webAuthTenant, +} from './auth.js'; + +// Mock @mastra/auth-workos so the tests exercise the gating/routing logic in +// this module without constructing a real WorkOS client. `authenticateToken`'s +// behavior is swapped per-test via `mockAuthenticate`. +const mockAuthenticate = vi.fn(); +const mockGetLoginUrl = vi.fn((_redirectUri: string, _state: string) => 'https://workos.example/login'); +const mockHandleCallback = vi.fn(async () => ({ user: { email: 'a@b.com' }, cookies: ['wos_session=sealed; Path=/'] })); +const mockGetLogoutUrl = vi.fn(async () => 'https://workos.example/logout'); + +// WorkOS SDK surface used by the personal-org bootstrap. Each test controls the +// list/create behavior; defaults model "no memberships, creates org_new". +const mockListMemberships = vi.fn(async () => ({ + autoPagination: async () => [] as Array<{ organizationId: string }>, +})); +const mockCreateOrganization = vi.fn( + async (_payload: Record<string, unknown>, _requestOptions?: Record<string, unknown>) => ({ id: 'org_new' }), +); +const mockCreateMembership = vi.fn(async () => ({ id: 'om_new' })); +const mockGetOrgByExternalId = vi.fn(async (_externalId: string) => ({ id: 'org_recovered' })); +const mockGetWorkOS = vi.fn(() => ({ + organizations: { + createOrganization: mockCreateOrganization, + getOrganizationByExternalId: mockGetOrgByExternalId, + }, + userManagement: { + listOrganizationMemberships: mockListMemberships, + createOrganizationMembership: mockCreateMembership, + }, +})); + +vi.mock('@mastra/auth-workos', () => ({ + MastraAuthWorkos: class { + getLoginUrl = mockGetLoginUrl; + handleCallback = mockHandleCallback; + authenticateToken = mockAuthenticate; + getLogoutUrl = mockGetLogoutUrl; + getWorkOS = mockGetWorkOS; + }, +})); + +const ORIGINAL_ENV = { ...process.env }; + +function enableEnv() { + process.env.WORKOS_API_KEY = 'sk_test'; + process.env.WORKOS_CLIENT_ID = 'client_test'; +} + +function disableEnv() { + delete process.env.WORKOS_API_KEY; + delete process.env.WORKOS_CLIENT_ID; + delete process.env.WORKOS_REDIRECT_URI; +} + +beforeEach(() => { + vi.clearAllMocks(); + disableEnv(); + // Restore default bootstrap mock behavior after clearAllMocks wipes it. + mockListMemberships.mockResolvedValue({ autoPagination: async () => [] }); + mockCreateOrganization.mockResolvedValue({ id: 'org_new' }); + mockCreateMembership.mockResolvedValue({ id: 'om_new' }); + mockGetOrgByExternalId.mockResolvedValue({ id: 'org_recovered' }); + mockGetWorkOS.mockReturnValue({ + organizations: { + createOrganization: mockCreateOrganization, + getOrganizationByExternalId: mockGetOrgByExternalId, + }, + userManagement: { + listOrganizationMemberships: mockListMemberships, + createOrganizationMembership: mockCreateMembership, + }, + }); +}); + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +/** Build a gated app where the protected catch-all returns 200 "ok". */ +function buildApp() { + const app = new Hono(); + const enabled = mountWebAuth(app, { redirectUri: 'http://localhost:4111/auth/callback' }); + app.get('*', c => c.text('ok')); + return { app, enabled }; +} + +describe('isWebAuthEnabled', () => { + it('is false when env vars are missing', () => { + expect(isWebAuthEnabled()).toBe(false); + }); + + it('is false when only one env var is set', () => { + process.env.WORKOS_API_KEY = 'sk_test'; + expect(isWebAuthEnabled()).toBe(false); + }); + + it('is true when both env vars are set', () => { + enableEnv(); + expect(isWebAuthEnabled()).toBe(true); + }); +}); + +describe('mountWebAuth (disabled)', () => { + it('is a no-op and leaves routes ungated', async () => { + const { app, enabled } = buildApp(); + expect(enabled).toBe(false); + + const res = await app.request('/api/anything', { headers: { Accept: 'application/json' } }); + expect(res.status).toBe(200); + expect(await res.text()).toBe('ok'); + }); +}); + +describe('mountWebAuth gate (enabled)', () => { + beforeEach(enableEnv); + + it('redirects unauthenticated HTML navigation to login with returnTo', async () => { + mockAuthenticate.mockResolvedValue(null); + const { app } = buildApp(); + + const res = await app.request('/some/page', { headers: { Accept: 'text/html' } }); + expect(res.status).toBe(302); + const location = res.headers.get('location') ?? ''; + expect(location.startsWith('/auth/login?returnTo=')).toBe(true); + expect(decodeURIComponent(location.split('returnTo=')[1]!)).toBe('/some/page'); + }); + + it('returns 401 JSON for unauthenticated /api requests', async () => { + mockAuthenticate.mockResolvedValue(null); + const { app } = buildApp(); + + const res = await app.request('/api/web/projects', { headers: { Accept: 'application/json' } }); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'unauthorized' }); + }); + + it('returns 401 for unauthenticated non-HTML navigation (XHR)', async () => { + mockAuthenticate.mockResolvedValue(null); + const { app } = buildApp(); + + const res = await app.request('/some/page', { headers: { Accept: 'application/json' } }); + expect(res.status).toBe(401); + }); + + it('passes through when the provider authenticates', async () => { + mockAuthenticate.mockResolvedValue({ email: 'user@example.com', name: 'User' }); + const { app } = buildApp(); + + const res = await app.request('/api/web/projects', { headers: { Accept: 'application/json' } }); + expect(res.status).toBe(200); + expect(await res.text()).toBe('ok'); + }); + + it('treats a thrown provider error as unauthenticated', async () => { + mockAuthenticate.mockRejectedValue(new Error('boom')); + const { app } = buildApp(); + + const res = await app.request('/api/web/projects', { headers: { Accept: 'application/json' } }); + expect(res.status).toBe(401); + }); + + it('stashes the authenticated user on the context for downstream routes', async () => { + mockAuthenticate.mockResolvedValue({ workosId: 'user_123', email: 'user@example.com', name: 'User' }); + const app = new Hono(); + mountWebAuth(app, { redirectUri: 'http://localhost:4111/auth/callback' }); + app.get('/api/web/whoami', c => { + const user = getWebAuthUser(c); + return c.json({ userId: getWebAuthUserId(user) }); + }); + + const res = await app.request('/api/web/whoami', { headers: { Accept: 'application/json' } }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ userId: 'user_123' }); + }); +}); + +describe('mountWebAuth /auth routes (enabled)', () => { + beforeEach(enableEnv); + + it('redirects /auth/login to the WorkOS login URL', async () => { + const { app } = buildApp(); + const res = await app.request('/auth/login?returnTo=/dashboard'); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe('https://workos.example/login'); + expect(mockGetLoginUrl).toHaveBeenCalledOnce(); + }); + + it('rejects external returnTo in login (open-redirect protection)', async () => { + const { app } = buildApp(); + await app.request('/auth/login?returnTo=https://evil.com'); + // The encoded state must carry the sanitized "/" path, not the external URL. + const state = mockGetLoginUrl.mock.calls[0]![1] as string; + const decoded = JSON.parse(Buffer.from(state, 'base64url').toString('utf8')); + expect(decoded.returnTo).toBe('/'); + }); + + it('rejects protocol-relative returnTo', async () => { + const { app } = buildApp(); + await app.request('/auth/login?returnTo=//evil.com'); + const state = mockGetLoginUrl.mock.calls[0]![1] as string; + const decoded = JSON.parse(Buffer.from(state, 'base64url').toString('utf8')); + expect(decoded.returnTo).toBe('/'); + }); + + it('handles the callback, applies cookies, and redirects to decoded returnTo', async () => { + const { app } = buildApp(); + const state = Buffer.from(JSON.stringify({ returnTo: '/dashboard' }), 'utf8').toString('base64url'); + const res = await app.request(`/auth/callback?code=abc&state=${state}`); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe('/dashboard'); + expect(res.headers.get('set-cookie')).toContain('wos_session=sealed'); + expect(mockHandleCallback).toHaveBeenCalledWith('abc', state); + }); + + it('redirects callback back to login when code is missing', async () => { + const { app } = buildApp(); + const res = await app.request('/auth/callback'); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe('/auth/login'); + expect(mockHandleCallback).not.toHaveBeenCalled(); + }); + + it('logout clears the session cookie and redirects to the WorkOS logout URL', async () => { + const { app } = buildApp(); + const res = await app.request('/auth/logout'); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe('https://workos.example/logout'); + expect(res.headers.get('set-cookie')).toContain('Max-Age=0'); + }); + + it('/auth/me reports authenticated:false when no session', async () => { + mockAuthenticate.mockResolvedValue(null); + const { app } = buildApp(); + const res = await app.request('/auth/me'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ authenticated: false, user: null }); + }); + + it('/auth/me reports the user when authenticated', async () => { + mockAuthenticate.mockResolvedValue({ email: 'user@example.com', name: 'User' }); + const { app } = buildApp(); + const res = await app.request('/auth/me'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ authenticated: true, user: { email: 'user@example.com', name: 'User' } }); + }); + + it('/auth/me surfaces the organization id to the SPA', async () => { + mockAuthenticate.mockResolvedValue({ + workosId: 'user_1', + email: 'user@example.com', + name: 'User', + organizationId: 'org_a', + }); + const { app } = buildApp(); + const res = await app.request('/auth/me'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + authenticated: true, + user: { email: 'user@example.com', name: 'User', organizationId: 'org_a' }, + }); + }); +}); + +describe('org-tenant identity', () => { + beforeEach(enableEnv); + + it('getWebAuthOrgId reads the organization id from the user shape', () => { + expect(getWebAuthOrgId({ workosId: 'user_1', organizationId: 'org_a' })).toBe('org_a'); + expect(getWebAuthOrgId({ workosId: 'user_1' })).toBeUndefined(); + expect(getWebAuthOrgId(undefined)).toBeUndefined(); + }); + + it('gate stashes organizationId and webAuthTenant returns { orgId, userId }', async () => { + mockAuthenticate.mockResolvedValue({ workosId: 'user_1', organizationId: 'org_a', email: 'u@e.com' }); + const app = new Hono(); + mountWebAuth(app, { redirectUri: 'http://localhost:4111/auth/callback' }); + app.get('/api/web/whoami', c => c.json(webAuthTenant(c) ?? { tenant: null })); + + const res = await app.request('/api/web/whoami', { headers: { Accept: 'application/json' } }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ orgId: 'org_a', userId: 'user_1' }); + }); + + it('webAuthTenant omits orgId for personal (no-org) users but keeps userId', async () => { + // Bootstrap is best-effort: when org creation fails, the user genuinely + // stays no-org, so the tenant must still expose a userId without an orgId. + mockCreateOrganization.mockRejectedValue(new Error('insufficient permissions')); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockAuthenticate.mockResolvedValue({ workosId: 'user_solo', email: 'solo@e.com' }); + const app = new Hono(); + mountWebAuth(app, { redirectUri: 'http://localhost:4111/auth/callback' }); + app.get('/api/web/whoami', c => { + const tenant = webAuthTenant(c); + return c.json({ orgId: tenant?.orgId ?? null, userId: tenant?.userId ?? null }); + }); + + const res = await app.request('/api/web/whoami', { headers: { Accept: 'application/json' } }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ orgId: null, userId: 'user_solo' }); + }); +}); + +describe('ensureUserHasOrganization (personal-org bootstrap)', () => { + beforeEach(enableEnv); + + function makeProvider() { + // The mocked MastraAuthWorkos has no real constructor side effects; cast + // through unknown so we can call the real ensureUserHasOrganization helper. + return new MastraAuthWorkos() as unknown as Parameters<typeof ensureUserHasOrganization>[0]; + } + + it('creates an org + membership for a no-org user with zero memberships', async () => { + const user: WebAuthUser = { workosId: 'user_1', email: 'solo@example.com' }; + const orgId = await ensureUserHasOrganization(makeProvider(), user); + + expect(orgId).toBe('org_new'); + expect(mockCreateOrganization).toHaveBeenCalledTimes(1); + const [payload, requestOptions] = mockCreateOrganization.mock.calls[0]!; + // Idempotency: externalId + stable idempotency key keyed on the user id. + expect(payload).toMatchObject({ externalId: 'user_1' }); + expect(requestOptions).toEqual({ idempotencyKey: 'mastracode-personal-org:user_1' }); + expect(mockCreateMembership).toHaveBeenCalledWith({ organizationId: 'org_new', userId: 'user_1' }); + }); + + it('returns an existing membership org without creating a new one', async () => { + mockListMemberships.mockResolvedValue({ autoPagination: async () => [{ organizationId: 'org_existing' }] }); + const orgId = await ensureUserHasOrganization(makeProvider(), { workosId: 'user_2' }); + + expect(orgId).toBe('org_existing'); + expect(mockCreateOrganization).not.toHaveBeenCalled(); + expect(mockCreateMembership).not.toHaveBeenCalled(); + }); + + it('is a no-op (no SDK calls) when the user already has an organizationId', async () => { + const orgId = await ensureUserHasOrganization(makeProvider(), { workosId: 'user_3', organizationId: 'org_a' }); + + expect(orgId).toBe('org_a'); + expect(mockGetWorkOS).not.toHaveBeenCalled(); + expect(mockListMemberships).not.toHaveBeenCalled(); + expect(mockCreateOrganization).not.toHaveBeenCalled(); + }); + + it('swallows WorkOS create errors and returns undefined (user stays no-org)', async () => { + mockCreateOrganization.mockRejectedValue(new Error('insufficient permissions')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const orgId = await ensureUserHasOrganization(makeProvider(), { workosId: 'user_4' }); + + expect(orgId).toBeUndefined(); + warn.mockRestore(); + }); + + it('recovers the existing org by externalId when create hits external_id_already_used', async () => { + // A prior partial bootstrap created the org but never attached membership, + // so create now 400s. We must look the org up and (re)attach the user. + mockCreateOrganization.mockRejectedValue({ code: 'external_id_already_used' }); + + const orgId = await ensureUserHasOrganization(makeProvider(), { workosId: 'user_partial' }); + + expect(orgId).toBe('org_recovered'); + expect(mockGetOrgByExternalId).toHaveBeenCalledWith('user_partial'); + expect(mockCreateMembership).toHaveBeenCalledWith({ + organizationId: 'org_recovered', + userId: 'user_partial', + }); + }); + + it('reads the WorkOS error code from rawData when recovering', async () => { + mockCreateOrganization.mockRejectedValue({ rawData: { code: 'external_id_already_used' } }); + + const orgId = await ensureUserHasOrganization(makeProvider(), { workosId: 'user_raw' }); + + expect(orgId).toBe('org_recovered'); + }); + + it('tolerates an already-existing membership on the recovered org', async () => { + mockCreateOrganization.mockRejectedValue({ code: 'external_id_already_used' }); + mockCreateMembership.mockRejectedValue({ code: 'organization_membership_already_exists' }); + + const orgId = await ensureUserHasOrganization(makeProvider(), { workosId: 'user_member' }); + + expect(orgId).toBe('org_recovered'); + }); + + it('gate bootstraps a no-org user so webAuthTenant yields the new org', async () => { + mockAuthenticate.mockResolvedValue({ workosId: 'user_boot', email: 'boot@example.com' }); + const app = new Hono(); + mountWebAuth(app, { redirectUri: 'http://localhost:4111/auth/callback' }); + app.get('/api/web/whoami', c => c.json(webAuthTenant(c) ?? { tenant: null })); + + const res = await app.request('/api/web/whoami', { headers: { Accept: 'application/json' } }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ orgId: 'org_new', userId: 'user_boot' }); + expect(mockCreateOrganization).toHaveBeenCalledTimes(1); + }); +}); diff --git a/mastracode/src/web/auth.ts b/mastracode/src/web/auth.ts new file mode 100644 index 000000000000..1954af618de4 --- /dev/null +++ b/mastracode/src/web/auth.ts @@ -0,0 +1,443 @@ +import { MastraAuthWorkos } from '@mastra/auth-workos'; +import type { Context, Hono } from 'hono'; + +/** + * WorkOS AuthKit gating for the MastraCode web server. + * + * When `WORKOS_API_KEY` and `WORKOS_CLIENT_ID` are both set, every route on the + * web server is placed behind WorkOS AuthKit authentication: unauthenticated + * browser navigations are redirected to the WorkOS hosted login, API/XHR calls + * receive a 401, and a small set of public `/auth/*` routes drive the + * login/callback/logout flow. When the env vars are absent, `mountWebAuth` is a + * no-op and the server behaves exactly as it does without auth. + * + * The actual AuthKit session encryption, code exchange and token validation are + * delegated to the existing `@mastra/auth-workos` provider (`MastraAuthWorkos`). + */ + +/** Minimal shape of the signed-in user surfaced to the SPA (no tokens). */ +export interface WebAuthUser { + /** Stable WorkOS user id used to scope per-user data (GitHub installs etc.). */ + workosId?: string; + /** WorkOS user id alias on some shapes; falls back to `workosId`. */ + id?: string; + email?: string; + name?: string; + /** + * WorkOS organization id. The org is the top-level tenant: it owns the GitHub + * App installation and connected projects, while each user inside the org gets + * isolated building instances. Absent for personal (no-org) accounts. + */ + organizationId?: string; +} + +/** + * Tenant identity: the org is the top-level tenant, and each user inside it is + * an isolated builder. Agent state, worktrees and sandboxes are scoped per + * `(orgId, userId)`. Personal (no-org) users have `orgId === undefined`. + */ +export interface WebAuthTenant { + /** WorkOS organization id, or `undefined` for personal (no-org) accounts. */ + orgId?: string; + /** Stable WorkOS user id. */ + userId: string; +} + +/** Hono context variables set by the auth gate. */ +export interface WebAuthVariables { + webAuthUser: WebAuthUser; +} + +/** Context key under which the gate stashes the authenticated user. */ +const WEB_AUTH_USER_KEY = 'webAuthUser'; + +/** + * Read the authenticated WorkOS user the gate stashed on the context, or + * `undefined` when unauthenticated / auth disabled. Used by downstream routes + * (e.g. GitHub) to scope rows per user. + */ +export function getWebAuthUser(c: Context): WebAuthUser | undefined { + return c.get(WEB_AUTH_USER_KEY) as WebAuthUser | undefined; +} + +/** Resolve the stable user id from a WorkOS user shape. */ +export function getWebAuthUserId(user: WebAuthUser | undefined): string | undefined { + return user?.workosId ?? user?.id; +} + +/** Resolve the WorkOS organization id from a user shape, if present. */ +export function getWebAuthOrgId(user: WebAuthUser | undefined): string | undefined { + return user?.organizationId; +} + +/** + * Resolve the tenant identity `(orgId, userId)` from the authenticated user on + * the context. Returns `undefined` when there is no signed-in user (auth + * disabled or unauthenticated). `orgId` is `undefined` for personal accounts; + * callers gate org-scoped GitHub features on its presence while agent state + * falls back to a user-only tenant. + */ +export function webAuthTenant(c: Context): WebAuthTenant | undefined { + const user = getWebAuthUser(c); + const userId = getWebAuthUserId(user); + if (!userId) return undefined; + return { orgId: getWebAuthOrgId(user), userId }; +} + +/** + * Lazily-created provider used to authenticate session cookies on public + * `/auth/*` routes that the gate skips (e.g. the GitHub connect/callback + * navigations). Kept module-level so callers outside `mountWebAuth` — such as + * the GitHub routes, which are mounted on a separate sub-app — can reuse it. + */ +let sessionProvider: MastraAuthWorkos | undefined; + +function getSessionProvider(): MastraAuthWorkos { + if (!sessionProvider) { + sessionProvider = new MastraAuthWorkos({ + redirectUri: process.env.WORKOS_REDIRECT_URI, + // Resolve `organizationId` from a single membership when the JWT lacks the + // claim. This is what lets a freshly bootstrapped personal org take effect + // on the next request without forcing a re-login. + fetchMemberships: true, + }); + } + return sessionProvider; +} + +/** Build a predictable personal-org name from the user's profile. */ +function personalOrgName(user: WebAuthUser, userId: string): string { + const label = user.email ?? user.name ?? userId; + return `${label}'s org`; +} + +/** Pull a stable error code out of a WorkOS SDK error, if present. */ +function workosErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== 'object') return undefined; + const e = error as { code?: unknown; rawData?: { code?: unknown } }; + if (typeof e.code === 'string') return e.code; + if (e.rawData && typeof e.rawData.code === 'string') return e.rawData.code; + return undefined; +} + +/** + * True when `createOrganization` rejected because an org is already bound to + * this `externalId` — i.e. a prior bootstrap created the org but never attached + * the membership. The org can be recovered via `getOrganizationByExternalId`. + */ +function isExternalIdAlreadyUsed(error: unknown): boolean { + return workosErrorCode(error) === 'external_id_already_used'; +} + +/** + * True when `createOrganizationMembership` rejected because the user is already + * a member of the org. Safe to ignore: the desired end state already holds. + */ +function isMembershipAlreadyExists(error: unknown): boolean { + const code = workosErrorCode(error); + return code === 'organization_membership_already_exists' || code === 'entity_already_exists'; +} + +/** + * Ensure the authenticated user belongs to a WorkOS organization, creating a + * personal org on first use when they have none. + * + * The `organizationId` we need for org-scoped GitHub features lives in the + * WorkOS session, not our app DB, so personal (no-org) accounts otherwise dead + * end at `organization_required`. This puts the user into a real WorkOS org: + * + * - If the user already has an `organizationId` → no-op, return it. + * - Else list their memberships: + * - ≥1 membership → return the first org id (they already belong somewhere; + * we never auto-create when a membership exists). + * - 0 memberships → create a personal org + membership and return its id. + * + * Idempotency: the create call carries `externalId = workosId` and a stable + * `idempotencyKey`, so concurrent/retried first logins never create duplicate + * personal orgs. If a prior run created the org but never attached the + * membership, the create rejects with `external_id_already_used`; we recover the + * existing org by `externalId` and (re)attach the membership instead of failing. + * + * Best-effort: any WorkOS error (e.g. API key lacking org-create permission) is + * swallowed and returns `undefined`, leaving the user in their no-org state + * rather than failing the request. Callers keep the existing + * `organization_required` behavior in that case. + */ +export async function ensureUserHasOrganization( + provider: MastraAuthWorkos, + user: WebAuthUser, +): Promise<string | undefined> { + const existingOrg = getWebAuthOrgId(user); + if (existingOrg) return existingOrg; + + const userId = getWebAuthUserId(user); + if (!userId) return undefined; + + try { + const workos = provider.getWorkOS(); + + const memberships = await workos.userManagement + .listOrganizationMemberships({ userId }) + .then(page => page.autoPagination()); + + const firstExisting = memberships.find(m => m.organizationId)?.organizationId; + if (firstExisting) return firstExisting; + + // Create the personal org. A prior partial bootstrap (org created, but the + // membership step never landed) leaves an org already bound to this + // externalId, so the create 400s with `external_id_already_used`. Recover by + // looking the existing org up by externalId instead of dead-ending forever. + let organizationId: string; + try { + const organization = await workos.organizations.createOrganization( + { + name: personalOrgName(user, userId), + externalId: userId, + metadata: { mastracodePersonalOrg: 'true', workosUserId: userId }, + }, + { idempotencyKey: `mastracode-personal-org:${userId}` }, + ); + organizationId = organization.id; + } catch (error) { + if (!isExternalIdAlreadyUsed(error)) throw error; + const existing = await workos.organizations.getOrganizationByExternalId(userId); + organizationId = existing.id; + } + + // Idempotently attach the user. If they are already a member (e.g. the org + // existed from a prior run), tolerate the conflict and keep the org id. + try { + await workos.userManagement.createOrganizationMembership({ organizationId, userId }); + } catch (error) { + if (!isMembershipAlreadyExists(error)) throw error; + } + + return organizationId; + } catch (error) { + console.warn( + `[WorkOS] Failed to bootstrap personal organization for user ${userId}. ` + + 'The user will see organization_required until this succeeds. ' + + 'Ensure the WorkOS API key can create organizations/memberships.', + error, + ); + return undefined; + } +} + +/** + * Resolve the authenticated user for a request, stashing it on the context. + * + * The gate only authenticates non-`/auth/*` requests via the `Authorization` + * header, so cookie-based browser navigations to public `/auth/*` routes (the + * GitHub connect/callback flow) arrive without a gate-stashed user. This reads + * the WorkOS session cookie from the raw request the same way `/auth/me` does, + * caches the result on the context, and returns it so downstream helpers like + * {@link webAuthTenant} work uniformly on both gated and public routes. + * + * Returns `undefined` when there is no valid session (or auth is disabled). + */ +export async function ensureWebAuthUser(c: Context): Promise<WebAuthUser | undefined> { + const existing = getWebAuthUser(c); + if (existing) return existing; + if (!isWebAuthEnabled()) return undefined; + + const token = getBearerToken(c.req.header('Authorization')); + let user: WebAuthUser | null = null; + try { + user = (await getSessionProvider().authenticateToken(token, c.req.raw)) as WebAuthUser | null; + } catch { + user = null; + } + if (!user) return undefined; + + // Bootstrap a personal org for no-org accounts so org-scoped features (GitHub + // connect) work without leaving the app. Mutating the resolved user lets the + // current request see the org immediately; subsequent requests resolve it via + // the provider's single-membership fallback (`fetchMemberships: true`). + if (!getWebAuthOrgId(user)) { + const orgId = await ensureUserHasOrganization(getSessionProvider(), user); + if (orgId) user.organizationId = orgId; + } + + c.set(WEB_AUTH_USER_KEY, user); + return user; +} + +/** + * Web auth is enabled only when both WorkOS credentials are present. These are + * the same env vars `@mastra/auth-workos` reads, so configuration stays + * consistent with the rest of the repo. + */ +export function isWebAuthEnabled(): boolean { + return Boolean(process.env.WORKOS_API_KEY && process.env.WORKOS_CLIENT_ID); +} + +export interface MountWebAuthOptions { + /** + * Absolute URL WorkOS redirects back to after login. Must match an allowed + * redirect URI configured in the WorkOS dashboard. Defaults to the + * `WORKOS_REDIRECT_URI` env var. + */ + redirectUri?: string; +} + +/** + * Validate that a `returnTo` value is a safe same-site path, to prevent + * open-redirect attacks. Only absolute local paths (`/foo`) are allowed; + * protocol-relative (`//evil.com`) and absolute URLs are rejected. + */ +function sanitizeReturnTo(raw: string | undefined): string { + if (!raw) return '/'; + if (!raw.startsWith('/')) return '/'; + // Reject protocol-relative URLs like "//evil.com" and "/\evil.com". + if (raw.startsWith('//') || raw.startsWith('/\\')) return '/'; + return raw; +} + +/** Encode a validated returnTo path into the OAuth `state` parameter. */ +function encodeState(returnTo: string): string { + return Buffer.from(JSON.stringify({ returnTo }), 'utf8').toString('base64url'); +} + +/** Decode the OAuth `state` parameter back into a sanitized returnTo path. */ +function decodeState(state: string | undefined): string { + if (!state) return '/'; + try { + const parsed = JSON.parse(Buffer.from(state, 'base64url').toString('utf8')) as { returnTo?: string }; + return sanitizeReturnTo(parsed.returnTo); + } catch { + return '/'; + } +} + +/** Extract a bearer token from the Authorization header, if present. */ +function getBearerToken(authorization: string | undefined): string { + if (!authorization) return ''; + const match = /^Bearer\s+(.+)$/i.exec(authorization); + return match?.[1] ?? ''; +} + +/** + * Decide whether a request is a top-level browser navigation (which should be + * redirected to login) versus an API/XHR call (which should get a 401 JSON + * response the SPA can react to). + */ +function isNavigationRequest(path: string, accept: string | undefined): boolean { + if (path.startsWith('/api/')) return false; + return (accept ?? '').includes('text/html'); +} + +/** + * Mount WorkOS AuthKit gating onto the web app. No-op when auth is disabled. + * + * Must be called before the Mastra adapter routes, the `/api/web/*` routes, and + * the static UI handlers so the gate covers every request. + */ +export function mountWebAuth(app: Hono<any>, options: MountWebAuthOptions = {}): boolean { + if (!isWebAuthEnabled()) return false; + + const redirectUri = options.redirectUri ?? process.env.WORKOS_REDIRECT_URI; + // `fetchMemberships: true` lets `authenticateToken` resolve `organizationId` + // from a single membership when the JWT has no org claim — required so a + // bootstrapped personal org resolves without re-auth. + const provider = new MastraAuthWorkos({ redirectUri, fetchMemberships: true }); + + // ── Public auth routes ──────────────────────────────────────────────── + // Registered before the gate so they remain reachable while unauthenticated. + + app.get('/auth/login', c => { + const returnTo = sanitizeReturnTo(c.req.query('returnTo')); + const loginUrl = provider.getLoginUrl(redirectUri ?? '', encodeState(returnTo)); + return c.redirect(loginUrl); + }); + + app.get('/auth/callback', async c => { + const code = c.req.query('code'); + const returnTo = decodeState(c.req.query('state')); + if (!code) { + return c.redirect('/auth/login'); + } + + try { + const result = await provider.handleCallback(code, c.req.query('state') ?? ''); + for (const cookie of result.cookies ?? []) { + c.header('Set-Cookie', cookie, { append: true }); + } + return c.redirect(returnTo); + } catch { + // Code exchange failed (expired/replayed code, misconfig). Send the user + // back to login rather than surfacing a raw error. + return c.redirect('/auth/login'); + } + }); + + app.get('/auth/logout', async c => { + let logoutUrl: string | null = null; + try { + logoutUrl = await provider.getLogoutUrl('/', c.req.raw); + } catch { + logoutUrl = null; + } + // Clear the session cookie regardless of whether WorkOS returned a logout URL. + c.header('Set-Cookie', 'wos_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0', { append: true }); + return c.redirect(logoutUrl ?? '/'); + }); + + app.get('/auth/me', async c => { + // `/auth/me` is public (the gate skips `/auth/*`), so it validates the + // session itself rather than reading a value the gate would have stashed. + const token = getBearerToken(c.req.header('Authorization')); + let user: WebAuthUser | null = null; + try { + user = (await provider.authenticateToken(token, c.req.raw)) as WebAuthUser | null; + } catch { + user = null; + } + if (!user) { + return c.json({ authenticated: false, user: null }); + } + return c.json({ + authenticated: true, + user: { email: user.email, name: user.name, organizationId: user.organizationId }, + }); + }); + + // ── Gate middleware ─────────────────────────────────────────────────── + // Protects everything that is not a public `/auth/*` route. + + app.use('*', async (c, next) => { + const path = c.req.path; + if (path.startsWith('/auth/')) { + return next(); + } + + const token = getBearerToken(c.req.header('Authorization')); + let user: WebAuthUser | null = null; + try { + user = (await provider.authenticateToken(token, c.req.raw)) as WebAuthUser | null; + } catch { + user = null; + } + + if (user) { + // Bootstrap a personal org for no-org accounts so the org id resolves on + // this request (see ensureWebAuthUser for the rationale). + if (!getWebAuthOrgId(user)) { + const orgId = await ensureUserHasOrganization(provider, user); + if (orgId) user.organizationId = orgId; + } + c.set(WEB_AUTH_USER_KEY, user); + return next(); + } + + if (isNavigationRequest(path, c.req.header('Accept'))) { + const url = new URL(c.req.url); + const returnTo = sanitizeReturnTo(url.pathname + url.search); + return c.redirect(`/auth/login?returnTo=${encodeURIComponent(returnTo)}`); + } + + return c.json({ error: 'unauthorized' }, 401); + }); + + return true; +} diff --git a/mastracode/src/web/github/client.ts b/mastracode/src/web/github/client.ts new file mode 100644 index 000000000000..a519a86a5ca7 --- /dev/null +++ b/mastracode/src/web/github/client.ts @@ -0,0 +1,250 @@ +/** + * GitHub App client helpers. + * + * Wraps `@octokit/rest` + `@octokit/auth-app` to authenticate as the GitHub + * App (app JWT) and as a specific installation (installation access token). + * Also builds the user-facing install / OAuth-identify URLs. + * + * The feature is enabled only when the GitHub App env vars are present. The + * server additionally requires web auth to be on (a per-user installation needs + * a logged-in user); that combined check lives in `./config`. + */ + +import { createAppAuth } from '@octokit/auth-app'; +import { Octokit } from '@octokit/rest'; + +export interface GithubAppConfig { + appId: string; + privateKey: string; + clientId: string; + clientSecret: string; + slug: string; +} + +/** + * Normalize a PEM private key supplied via env. Supports the common + * single-line `\n`-escaped form so the key can live in a `.env` value. + */ +function normalizePrivateKey(raw: string): string { + return raw.includes('\\n') ? raw.replace(/\\n/g, '\n') : raw; +} + +/** + * Read the GitHub App config from env, or `undefined` when not fully configured. + */ +export function getGithubAppConfig(): GithubAppConfig | undefined { + const appId = process.env.GITHUB_APP_ID; + const privateKey = process.env.GITHUB_APP_PRIVATE_KEY; + const clientId = process.env.GITHUB_APP_CLIENT_ID; + const clientSecret = process.env.GITHUB_APP_CLIENT_SECRET; + const slug = process.env.GITHUB_APP_SLUG; + if (!appId || !privateKey || !clientId || !clientSecret || !slug) { + return undefined; + } + return { appId, privateKey: normalizePrivateKey(privateKey), clientId, clientSecret, slug }; +} + +/** + * True when all GitHub App env vars are present. Note this does *not* check web + * auth; the server-level gate (`isGithubFeatureEnabled`) combines both. + */ +export function isGithubAppConfigured(): boolean { + return getGithubAppConfig() !== undefined; +} + +function requireConfig(): GithubAppConfig { + const config = getGithubAppConfig(); + if (!config) { + throw new Error('GitHub App is not configured (missing GITHUB_APP_* env vars).'); + } + return config; +} + +/** + * Octokit authenticated as the GitHub App itself (app JWT). Used for + * app-level operations and to mint installation tokens. + */ +export function getAppOctokit(): Octokit { + const config = requireConfig(); + return new Octokit({ + authStrategy: createAppAuth, + auth: { + appId: config.appId, + privateKey: config.privateKey, + clientId: config.clientId, + clientSecret: config.clientSecret, + }, + }); +} + +/** + * Octokit authenticated as a specific installation (installation access token). + * Used to list repos and to operate on a repo on the user's behalf. + */ +export function getInstallationOctokit(installationId: number): Octokit { + const config = requireConfig(); + return new Octokit({ + authStrategy: createAppAuth, + auth: { + appId: config.appId, + privateKey: config.privateKey, + clientId: config.clientId, + clientSecret: config.clientSecret, + installationId, + }, + }); +} + +/** + * Octokit authenticated as a user via their OAuth token (the identify step). + */ +export function getUserOctokit(userToken: string): Octokit { + return new Octokit({ auth: userToken }); +} + +/** + * Mint a short-lived installation access token. Returned token is used only + * server-side / inside the sandbox clone URL and never sent to the browser. + */ +export async function mintInstallationToken(installationId: number): Promise<string> { + const config = requireConfig(); + const auth = createAppAuth({ + appId: config.appId, + privateKey: config.privateKey, + clientId: config.clientId, + clientSecret: config.clientSecret, + }); + const installationAuth = await auth({ type: 'installation', installationId }); + return installationAuth.token; +} + +export interface UserInstallation { + installationId: number; + accountLogin: string | null; + accountType: string | null; +} + +/** + * List the installations the authenticated user can access, via their OAuth + * token (`GET /user/installations`). + */ +export async function listUserInstallations(userToken: string): Promise<UserInstallation[]> { + const octokit = getUserOctokit(userToken); + const installations = await octokit.paginate(octokit.apps.listInstallationsForAuthenticatedUser, { + per_page: 100, + }); + return installations.map(inst => ({ + installationId: inst.id, + accountLogin: inst.account && 'login' in inst.account ? inst.account.login : null, + accountType: inst.account && 'type' in inst.account ? inst.account.type : null, + })); +} + +export interface RepoSummary { + id: number; + fullName: string; + name: string; + owner: string; + defaultBranch: string; + private: boolean; + installationId: number; +} + +/** + * List repos accessible to an installation (paginated). + */ +export async function listInstallationRepos(installationId: number): Promise<RepoSummary[]> { + const octokit = getInstallationOctokit(installationId); + const repos = await octokit.paginate(octokit.apps.listReposAccessibleToInstallation, { + per_page: 100, + }); + return repos.map(repo => ({ + id: repo.id, + fullName: repo.full_name, + name: repo.name, + owner: repo.owner.login, + defaultBranch: repo.default_branch, + private: repo.private, + installationId, + })); +} + +/** + * Fetch a single repo's metadata through an installation token and confirm the + * installation actually has access to it. Returns `null` when the repo is not + * accessible to the installation (so a client can't create a project for an + * arbitrary repo under an installation id it merely owns). + */ +export async function getInstallationRepo(installationId: number, repoFullName: string): Promise<RepoSummary | null> { + const slash = repoFullName.indexOf('/'); + if (slash <= 0) return null; + const owner = repoFullName.slice(0, slash); + const repo = repoFullName.slice(slash + 1); + const octokit = getInstallationOctokit(installationId); + try { + const { data } = await octokit.repos.get({ owner, repo }); + return { + id: data.id, + fullName: data.full_name, + name: data.name, + owner: data.owner.login, + defaultBranch: data.default_branch, + private: data.private, + installationId, + }; + } catch { + return null; + } +} + +/** + * Build the GitHub App install URL. `state` is carried through the install flow + * and validated on callback. + */ +export function buildInstallUrl(state: string): string { + const config = requireConfig(); + const url = new URL(`https://github.com/apps/${config.slug}/installations/new`); + url.searchParams.set('state', state); + return url.toString(); +} + +/** + * Build the OAuth identify URL (authorize) used to confirm the user's identity + * and obtain a user token for listing their installations. + */ +export function buildOAuthIdentifyUrl(state: string, redirectUri: string): string { + const config = requireConfig(); + const url = new URL('https://github.com/login/oauth/authorize'); + url.searchParams.set('client_id', config.clientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('state', state); + return url.toString(); +} + +/** + * Exchange an OAuth `code` for a user access token. + */ +export async function exchangeOAuthCode(code: string, redirectUri: string): Promise<string> { + const config = requireConfig(); + const res = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + signal: AbortSignal.timeout(10_000), + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ + client_id: config.clientId, + client_secret: config.clientSecret, + code, + redirect_uri: redirectUri, + }), + }); + if (!res.ok) { + throw new Error(`GitHub OAuth token exchange failed: ${res.status}`); + } + const data = (await res.json()) as { access_token?: string; error?: string; error_description?: string }; + if (!data.access_token) { + throw new Error( + `GitHub OAuth token exchange returned no token: ${data.error_description ?? data.error ?? 'unknown'}`, + ); + } + return data.access_token; +} diff --git a/mastracode/src/web/github/config.ts b/mastracode/src/web/github/config.ts new file mode 100644 index 000000000000..cea2c59cdfad --- /dev/null +++ b/mastracode/src/web/github/config.ts @@ -0,0 +1,133 @@ +/** + * Shared configuration + state-signing helpers for the GitHub App feature. + * + * The GitHub feature is enabled only when *all three* hold: + * - the GitHub App env vars are present (`isGithubAppConfigured`), + * - web auth is enabled (a per-user installation requires a logged-in user), + * - the application database is configured (`isAppDbConfigured`). + */ + +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; +import { isWebAuthEnabled } from '../auth'; +import { isGithubAppConfigured } from './client'; +import { isAppDbConfigured } from './db'; + +/** + * True when the GitHub App project feature should be active. + */ +export function isGithubFeatureEnabled(): boolean { + return isGithubAppConfigured() && isWebAuthEnabled() && isAppDbConfigured(); +} + +/** + * Secret used to sign the OAuth/install `state`. Falls back to a per-process + * random secret when no explicit one is configured (state is short-lived). + */ +let stateSecret: string | undefined; +function getStateSecret(): string { + if (stateSecret) return stateSecret; + stateSecret = explicitStateSecret() ?? randomBytes(32).toString('hex'); + return stateSecret; +} + +/** + * The explicit, deployment-stable state secret if one is configured. When + * undefined, `getStateSecret()` falls back to a per-process random secret, which + * is NOT stable across replicas: a `state` signed by one replica cannot be + * verified by another. Multi-replica deploys must set an explicit secret. + */ +function explicitStateSecret(): string | undefined { + return process.env.GITHUB_APP_WEBHOOK_SECRET || process.env.WORKOS_COOKIE_PASSWORD || undefined; +} + +/** + * True when a deployment-stable state secret is configured. Startup uses this to + * fail loud when the GitHub feature is on but state signing would not be + * replica-stable. + */ +export function hasExplicitStateSecret(): boolean { + return explicitStateSecret() !== undefined; +} + +/** + * Fail loud at startup if the GitHub feature is on but no replica-stable state + * secret is configured. A random per-process secret silently breaks the + * OAuth/install callback whenever it lands on a different replica than the one + * that signed the `state`. Returns without error when the feature is off (the + * random fallback is acceptable for single-process/local dev). + */ +export function assertReplicaStableStateSecret(): void { + if (!isGithubFeatureEnabled()) return; + if (hasExplicitStateSecret()) return; + throw new Error( + 'GitHub App feature is enabled but no replica-stable state secret is set. ' + + 'Set GITHUB_APP_WEBHOOK_SECRET (or WORKOS_COOKIE_PASSWORD) so OAuth/install ' + + '`state` can be verified across replicas. Without it, the install callback ' + + 'fails whenever it lands on a different replica than the one that signed it.', + ); +} + +interface StatePayload { + orgId: string; + userId: string; + nonce: string; + issuedAt: number; +} + +/** Verified `(orgId, userId)` tenant carried by a signed install `state`. */ +export interface StateTenant { + orgId: string; + userId: string; +} + +/** Signed `state` values expire after this window to bound the CSRF token. */ +const STATE_MAX_AGE_MS = 10 * 60 * 1000; + +/** + * Build a signed `state` bound to the `(orgId, userId)` tenant. The payload is + * base64url JSON with an HMAC suffix so the callback can verify it was not + * tampered with and belongs to the same org + user. + */ +export function signState(orgId: string, userId: string): string { + const payload: StatePayload = { + orgId, + userId, + nonce: randomBytes(8).toString('hex'), + issuedAt: Date.now(), + }; + const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); + const sig = createHmac('sha256', getStateSecret()).update(body).digest('base64url'); + return `${body}.${sig}`; +} + +/** + * Verify a signed `state` and return the bound `(orgId, userId)` tenant, or + * `null` if invalid. + */ +export function verifyState(state: string | undefined): StateTenant | null { + if (!state) return null; + const dot = state.lastIndexOf('.'); + if (dot <= 0) return null; + const body = state.slice(0, dot); + const sig = state.slice(dot + 1); + const expected = createHmac('sha256', getStateSecret()).update(body).digest('base64url'); + const sigBuf = Buffer.from(sig); + const expectedBuf = Buffer.from(expected); + if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) { + return null; + } + try { + const parsed = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as StatePayload; + if (typeof parsed.orgId !== 'string' || typeof parsed.userId !== 'string') return null; + if (typeof parsed.issuedAt !== 'number') return null; + if (Date.now() - parsed.issuedAt > STATE_MAX_AGE_MS) return null; + return { orgId: parsed.orgId, userId: parsed.userId }; + } catch { + return null; + } +} + +/** For tests: reset the cached state secret. */ +export function __resetStateSecretForTests(): void { + stateSecret = undefined; +} diff --git a/mastracode/src/web/github/db.ts b/mastracode/src/web/github/db.ts new file mode 100644 index 000000000000..d5b1b4c85e5f --- /dev/null +++ b/mastracode/src/web/github/db.ts @@ -0,0 +1,91 @@ +/** + * Application database layer for the GitHub App integration. + * + * This is a *separate* Postgres from Mastra's own storage: it is created lazily + * from `APP_DATABASE_URL` and holds only the GitHub installations/projects + * tables defined in `./schema`. The connection is a singleton so the whole + * process shares one pg Pool. + */ + +import { sql } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import pkg from 'pg'; +import { MIGRATION_SQL } from './schema'; +import * as schema from './schema'; + +const { Pool } = pkg; + +export type AppDb = NodePgDatabase<typeof schema>; + +let pool: pkg.Pool | undefined; +let db: AppDb | undefined; +let migrationPromise: Promise<void> | undefined; + +/** + * True when the app database is configured. Required for the GitHub feature. + */ +export function isAppDbConfigured(): boolean { + return Boolean(process.env.APP_DATABASE_URL); +} + +/** + * Get (lazily creating) the Drizzle client bound to `APP_DATABASE_URL`. + * @throws if `APP_DATABASE_URL` is not set. + */ +export function getAppDb(): AppDb { + if (db) return db; + const connectionString = process.env.APP_DATABASE_URL; + if (!connectionString) { + throw new Error('APP_DATABASE_URL is not set; the GitHub App feature requires an application database.'); + } + pool = new Pool({ connectionString }); + db = drizzle(pool, { schema }); + return db; +} + +/** + * Run the idempotent migrations once. Safe to call repeatedly; the underlying + * work runs at most once per process. Throws if the database is unreachable so + * the caller can fail soft (disable the feature) rather than crash. + */ +export async function ensureAppDbReady(): Promise<void> { + if (migrationPromise) return migrationPromise; + migrationPromise = (async () => { + const client = getAppDb(); + await client.execute(sql.raw(MIGRATION_SQL)); + })(); + try { + await migrationPromise; + } catch (err) { + // Reset so a later retry can attempt again. + migrationPromise = undefined; + throw err; + } +} + +/** + * Get the underlying pg `Pool`, lazily creating the client if needed. Used by + * the distributed project lock, which needs a single dedicated connection to + * hold a transaction-scoped advisory lock. + * @throws if `APP_DATABASE_URL` is not set. + */ +export function getAppDbPool(): pkg.Pool { + getAppDb(); + if (!pool) { + throw new Error('APP_DATABASE_URL is not set; the GitHub App feature requires an application database.'); + } + return pool; +} + +/** + * Close the pool. Primarily for tests / graceful shutdown. + */ +export async function closeAppDb(): Promise<void> { + if (pool) { + await pool.end(); + pool = undefined; + db = undefined; + migrationPromise = undefined; + } +} diff --git a/mastracode/src/web/github/local-sandbox.test.ts b/mastracode/src/web/github/local-sandbox.test.ts new file mode 100644 index 000000000000..92536f10d184 --- /dev/null +++ b/mastracode/src/web/github/local-sandbox.test.ts @@ -0,0 +1,129 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getLocalSandboxRoot, LocalSandbox, sandboxEnv } from './local-sandbox'; + +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'mc-local-sandbox-')); + process.env.MASTRACODE_LOCAL_SANDBOX_ROOT = root; +}); + +afterEach(() => { + delete process.env.MASTRACODE_LOCAL_SANDBOX_ROOT; + rmSync(root, { recursive: true, force: true }); +}); + +describe('getLocalSandboxRoot', () => { + it('uses the configured root', () => { + expect(getLocalSandboxRoot()).toBe(root); + }); + + it('defaults under the home dir when unset', () => { + delete process.env.MASTRACODE_LOCAL_SANDBOX_ROOT; + expect(getLocalSandboxRoot()).toMatch(/\.mastracode\/web\/sandboxes$/); + }); +}); + +describe('LocalSandbox', () => { + it('surfaces a stable id keyed to the root and reattaches by id', async () => { + const a = new LocalSandbox(); + expect(a.id).toBe(`local:${root}`); + const info = await a.getInfo(); + expect(info.metadata?.sandboxId).toBe(`local:${root}`); + expect(info.metadata?.provider).toBe('local'); + + const reattached = new LocalSandbox({ sandboxId: a.id }); + expect(reattached.id).toBe(a.id); + }); + + it('runs a successful shell command', async () => { + const sandbox = new LocalSandbox(); + await sandbox.start(); + const res = await sandbox.executeCommand('sh', ['-c', 'echo hello']); + expect(res.exitCode).toBe(0); + expect(res.stdout.trim()).toBe('hello'); + }); + + it('captures non-zero exit codes and stderr', async () => { + const sandbox = new LocalSandbox(); + await sandbox.start(); + const res = await sandbox.executeCommand('sh', ['-c', 'echo oops 1>&2; exit 3']); + expect(res.exitCode).toBe(3); + expect(res.stderr.trim()).toBe('oops'); + }); + + it('returns 127 when the binary does not exist', async () => { + const sandbox = new LocalSandbox(); + await sandbox.start(); + const res = await sandbox.executeCommand('this-binary-does-not-exist-xyz'); + expect(res.exitCode).toBe(127); + }); + + it('runs commands in the sandbox root', async () => { + const sandbox = new LocalSandbox(); + await sandbox.start(); + const res = await sandbox.executeCommand('sh', ['-c', 'pwd']); + // macOS /tmp symlinks to /private/tmp, so compare the basename. + expect(res.stdout.trim().endsWith(root.split('/').pop()!)).toBe(true); + }); + + it('stop() is a no-op that does not throw', async () => { + const sandbox = new LocalSandbox(); + await expect(sandbox.stop()).resolves.toBeUndefined(); + }); + + it('does not expose server secrets to spawned commands', async () => { + process.env.GITHUB_APP_PRIVATE_KEY = 'super-secret-key'; + process.env.WORKOS_API_KEY = 'sk_live_secret'; + try { + const sandbox = new LocalSandbox(); + await sandbox.start(); + const res = await sandbox.executeCommand('sh', [ + '-c', + 'echo "${GITHUB_APP_PRIVATE_KEY:-MISSING}:${WORKOS_API_KEY:-MISSING}"', + ]); + expect(res.stdout.trim()).toBe('MISSING:MISSING'); + } finally { + delete process.env.GITHUB_APP_PRIVATE_KEY; + delete process.env.WORKOS_API_KEY; + } + }); + + it('still passes PATH so binaries resolve', async () => { + const sandbox = new LocalSandbox(); + await sandbox.start(); + const res = await sandbox.executeCommand('sh', ['-c', 'echo "${PATH:-MISSING}"']); + expect(res.stdout.trim()).not.toBe('MISSING'); + expect(res.stdout.trim().length).toBeGreaterThan(0); + }); +}); + +describe('sandboxEnv', () => { + it('keeps allow-listed keys and drops secrets', () => { + const filtered = sandboxEnv({ + PATH: '/usr/bin', + HOME: '/home/me', + LANG: 'en_US.UTF-8', + GITHUB_APP_PRIVATE_KEY: 'secret', + WORKOS_API_KEY: 'secret', + APP_DATABASE_URL: 'postgres://secret', + RAILWAY_API_TOKEN: 'secret', + }); + expect(filtered.PATH).toBe('/usr/bin'); + expect(filtered.HOME).toBe('/home/me'); + expect(filtered.LANG).toBe('en_US.UTF-8'); + expect(filtered.GITHUB_APP_PRIVATE_KEY).toBeUndefined(); + expect(filtered.WORKOS_API_KEY).toBeUndefined(); + expect(filtered.APP_DATABASE_URL).toBeUndefined(); + expect(filtered.RAILWAY_API_TOKEN).toBeUndefined(); + }); + + it('drops undefined values', () => { + const filtered = sandboxEnv({ PATH: '/usr/bin', HOME: undefined }); + expect(filtered.PATH).toBe('/usr/bin'); + expect('HOME' in filtered).toBe(false); + }); +}); diff --git a/mastracode/src/web/github/local-sandbox.ts b/mastracode/src/web/github/local-sandbox.ts new file mode 100644 index 000000000000..28dddddf59d1 --- /dev/null +++ b/mastracode/src/web/github/local-sandbox.ts @@ -0,0 +1,142 @@ +/** + * Local (host-process) sandbox provider. + * + * A drop-in `MaterializationSandbox` that runs commands directly on the server + * host instead of a remote VM. The repo is cloned into a per-project directory + * under a configurable base (`MASTRACODE_LOCAL_SANDBOX_ROOT`, default + * `~/.mastracode/web/sandboxes`). + * + * WARNING: this provider does NOT isolate tenants — every project's git + * operations run as the server process on the same host filesystem. It exists + * for local single-user development when no Railway token is configured. Do not + * use it for a shared multi-tenant deployment; use a real cloud sandbox there. + */ + +import { spawn } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { MaterializationSandbox, SandboxCommandResult } from './sandbox'; + +/** Base directory under which local sandboxes are created. */ +export function getLocalSandboxRoot(): string { + const configured = process.env.MASTRACODE_LOCAL_SANDBOX_ROOT; + if (configured && configured.trim()) return configured.trim(); + return join(homedir(), '.mastracode', 'web', 'sandboxes'); +} + +/** + * Environment variables that are safe to expose to sandboxed commands. The repo + * materializer interpolates any required secrets (e.g. the GitHub install token) + * directly into the command script, so sandboxed commands never need the + * server's own secret env. We therefore pass only a minimal allow-list — enough + * for `git`/`sh` to function — and drop everything else so values like + * `GITHUB_APP_PRIVATE_KEY`, `WORKOS_API_KEY`, and `APP_DATABASE_URL` are never + * handed to a command running against an untrusted checkout. + */ +const ALLOWED_ENV_KEYS = new Set([ + 'PATH', + 'HOME', + 'USER', + 'LOGNAME', + 'SHELL', + 'TMPDIR', + 'LANG', + 'LC_ALL', + 'TERM', + 'TZ', + // Git locates its config/templates via these; safe, non-secret. + 'GIT_EXEC_PATH', + 'GIT_TEMPLATE_DIR', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', +]); + +/** + * Build a sanitized environment for spawned sandbox commands: only the + * allow-listed keys above plus `GIT_*` config knobs that are non-secret by + * convention. This prevents leaking the full server environment to commands + * that run against untrusted repository contents. + */ +export function sandboxEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const out: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(source)) { + if (value === undefined) continue; + if (ALLOWED_ENV_KEYS.has(key)) out[key] = value; + } + return out; +} + +/** + * A sandbox backed by the local host. `start()` ensures the root directory + * exists; commands are spawned via the host shell. Reattach is trivially the + * same id (the host filesystem persists across opens), so `getInfo()` surfaces + * a stable id and `stop()` is a no-op (we never delete checkouts). + */ +export class LocalSandbox implements MaterializationSandbox { + readonly id: string; + private readonly root: string; + + constructor(opts: { sandboxId?: string } = {}) { + this.root = getLocalSandboxRoot(); + // A stable id keyed to the host root so re-opens reattach to the same place. + this.id = opts.sandboxId ?? `local:${this.root}`; + } + + async start(): Promise<void> { + mkdirSync(this.root, { recursive: true }); + } + + async getInfo(): Promise<{ metadata?: Record<string, unknown> }> { + return { metadata: { sandboxId: this.id, provider: 'local', root: this.root } }; + } + + async stop(): Promise<void> { + // No-op: the local checkout persists on the host filesystem. + } + + executeCommand(command: string, args: string[] = [], options?: { timeout?: number }): Promise<SandboxCommandResult> { + return new Promise<SandboxCommandResult>(resolve => { + const child = spawn(command, args, { + cwd: this.root, + // Pass only a sanitized allow-list, never the full server environment, + // so secrets aren't exposed to commands run against untrusted checkouts. + env: sandboxEnv(), + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + let settled = false; + + const timeoutMs = options?.timeout; + const timer = + timeoutMs && timeoutMs > 0 + ? setTimeout(() => { + child.kill('SIGKILL'); + }, timeoutMs) + : undefined; + + const finish = (exitCode: number) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve({ exitCode, stdout, stderr }); + }; + + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on('error', err => { + stderr += (stderr ? '\n' : '') + (err instanceof Error ? err.message : String(err)); + finish(127); + }); + child.on('close', code => { + finish(code ?? 1); + }); + }); + } +} diff --git a/mastracode/src/web/github/org-isolation-scenario.test.ts b/mastracode/src/web/github/org-isolation-scenario.test.ts new file mode 100644 index 000000000000..4a496ae3daff --- /dev/null +++ b/mastracode/src/web/github/org-isolation-scenario.test.ts @@ -0,0 +1,521 @@ +import { Hono } from 'hono'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as AuthModule from '../auth'; + +// ── Phase 2 org-isolation scenario tests ───────────────────────────────── +// These prove the org-tenancy boundary end to end through the real GitHub +// route handlers: +// 1. The same repo connected by two different orgs never bleeds across orgs. +// 2. Two users in one org each get their own per-(project,user) sandbox row, +// and one user's worktree is invisible to the other. +// 3. A user cannot operate on another user's persisted worktree path. +// They reuse the harness shape from `routes.test.ts`: mocked drizzle eq/and, +// an in-memory fake DB, and mocked `./client` / `./sandbox` / `./config`. + +vi.mock('drizzle-orm', () => ({ + eq: (column: any, value: any) => ({ kind: 'eq', column: column?.name, value }), + and: (...conds: any[]) => ({ kind: 'and', conds: conds.filter(Boolean) }), +})); + +// Partially mock `../auth`: keep the real helpers (getWebAuthUser/webAuthTenant) +// so middleware-stashed users flow through unchanged, but make +// `ensureWebAuthUser` simulate cookie-based session resolution + personal-org +// bootstrap on `/auth/*` routes the gate skips. A no-org cookie user comes back +// with an `organizationId` populated (mirroring `ensureUserHasOrganization`), so +// downstream `webAuthTenant` yields a real tenant instead of an org gate 403. +let cookieUser: { workosId: string; organizationId?: string } | null = null; +// Bootstrap is always attempted for no-org users, but the WorkOS create can +// fail (e.g. missing API permissions); toggle to exercise that failure path. +let bootstrapSucceeds = true; +vi.mock('../auth', async () => { + const actual = (await vi.importActual('../auth')) as typeof AuthModule; + return { + ...actual, + ensureWebAuthUser: async (c: any) => { + const existing = actual.getWebAuthUser(c); + if (existing) return existing; + if (!cookieUser) return undefined; + const u = cookieUser as { workosId: string; organizationId?: string }; + // Bootstrap: a personal (no-org) user gets a personal org. When the WorkOS + // create fails, the user stays no-org and the org gate still fires. + const organizationId = u.organizationId ?? (bootstrapSucceeds ? `org-personal-${u.workosId}` : undefined); + const resolved: { workosId: string; organizationId?: string } = { workosId: u.workosId, organizationId }; + c.set('webAuthUser', resolved); + return resolved; + }, + }; +}); + +interface Tables { + installations: Array<{ + orgId?: string; + userId: string; + installationId: number; + accountLogin: string | null; + accountType: string | null; + }>; + projects: Array<Record<string, any>>; + sandboxes: Array<Record<string, any>>; + worktrees: Array<Record<string, any>>; +} +const tables: Tables = { installations: [], projects: [], sandboxes: [], worktrees: [] }; + +vi.mock('./db', () => { + const makeDb = () => ({ + select: () => ({ + from: (table: any) => ({ + where: async (cond: any) => filterRows(table, cond), + }), + }), + insert: (table: any) => ({ + values: (vals: any) => { + const chain = { + onConflictDoNothing: (opts?: any) => { + const ret = insertIfAbsent(table, vals, opts); + const promise: any = Promise.resolve(ret ? [ret] : []); + promise.returning = async () => (ret ? [ret] : []); + return promise; + }, + onConflictDoUpdate: (opts: any) => { + const ret = upsertRow(table, vals, opts); + return { returning: async () => [ret] }; + }, + returning: async () => [insertRow(table, vals)], + }; + return chain; + }, + }), + update: (table: any) => ({ + set: (vals: any) => ({ where: async (cond: any) => updateRows(table, vals, cond) }), + }), + }); + return { getAppDb: () => makeDb() }; +}); + +let mintCount = 0; +vi.mock('./client', () => ({ + buildInstallUrl: (state: string) => `https://github.com/apps/test/installations/new?state=${state}`, + buildOAuthIdentifyUrl: (state: string) => `https://github.com/login/oauth/authorize?state=${state}`, + exchangeOAuthCode: vi.fn(async () => 'user-token'), + listUserInstallations: vi.fn(async () => [{ installationId: 7, accountLogin: 'octo', accountType: 'User' }]), + listInstallationRepos: vi.fn(async () => []), + getInstallationRepo: vi.fn(async (installationId: number, fullName: string) => + fullName === 'octo/hello' + ? { + id: 99, + fullName: 'octo/hello', + name: 'hello', + owner: 'octo', + defaultBranch: 'main', + private: false, + installationId, + } + : null, + ), + mintInstallationToken: vi.fn(async () => `install-token-${++mintCount}`), +})); + +// Mirror production: provisioning persists a sandboxId onto the binding row so +// the later git routes can reattach. We update the fake DB row in place. +const ensureProjectSandbox = vi.fn(async (row: any) => { + const persisted = tables.sandboxes.find(s => s.id === row.id); + if (persisted && !persisted.sandboxId) persisted.sandboxId = `sb-${persisted.userId}`; + return { id: persisted?.sandboxId ?? 'sb' }; +}); +const materializeRepo = vi.fn(async () => {}); +const reattachProjectSandbox = vi.fn(async (_id: string) => ({ id: 'sb' })); +const ensureWorktree = vi.fn(async (_sb: any, _workdir: string, opts: { branch: string; baseBranch: string }) => ({ + worktreePath: `/workspace/hello/../worktrees/${opts.branch}`, + branch: opts.branch, + baseBranch: opts.baseBranch, +})); +const commitAll = vi.fn(async () => ({ committed: true })); +const pushBranch = vi.fn(async () => {}); +const createPullRequest = vi.fn(async () => ({ url: 'https://github.com/octo/hello/pull/1' })); +let sandboxEnabled = true; +vi.mock('./sandbox', () => { + class MaterializeError extends Error { + code: string; + constructor(m: string, code: string) { + super(m); + this.code = code; + } + } + class WorktreeError extends Error { + code: string; + constructor(m: string, code: string) { + super(m); + this.code = code; + } + } + return { + computeSandboxWorkdir: (repo: string) => `/workspace/${repo.split('/').pop()}`, + getSandboxProvider: () => 'railway', + isSandboxEnabled: () => sandboxEnabled, + ensureProjectSandbox: (row: any) => ensureProjectSandbox(row), + materializeRepo: (...args: any[]) => materializeRepo(...(args as [])), + reattachProjectSandbox: (id: string) => reattachProjectSandbox(id), + ensureWorktree: (sb: any, workdir: string, opts: any) => ensureWorktree(sb, workdir, opts), + commitAll: (...args: any[]) => commitAll(...(args as [])), + pushBranch: (...args: any[]) => pushBranch(...(args as [])), + createPullRequest: (...args: any[]) => createPullRequest(...(args as [])), + isValidGitRef: (v: unknown): v is string => + typeof v === 'string' && v.length > 0 && v.length <= 255 && /^[A-Za-z0-9_./-]+$/.test(v), + MaterializeError, + WorktreeError, + }; +}); + +let featureEnabled = true; +vi.mock('./config', () => ({ + isGithubFeatureEnabled: () => featureEnabled, + signState: (orgId: string, userId: string) => `state.${orgId}.${userId}`, + verifyState: (state: string | undefined) => { + if (!state?.startsWith('state.')) return null; + const [orgId, userId] = state.slice('state.'.length).split('.'); + if (!orgId || !userId) return null; + return { orgId, userId }; + }, +})); + +import { mountGithubRoutes } from './routes'; + +// ── Fake table helpers (mirrors routes.test.ts) ───────────────────────── +function tableKind(table: any): keyof Tables { + if (table === installationsRef) return 'installations'; + if (table === worktreesRef) return 'worktrees'; + if (table === sandboxesRef) return 'sandboxes'; + return 'projects'; +} +let installationsRef: any; +let worktreesRef: any; +let sandboxesRef: any; + +function dbNameToJsKey(table: any, dbName: string): string { + for (const [jsKey, col] of Object.entries(table)) { + if ((col as any)?.name === dbName) return jsKey; + } + return dbName; +} +function matches(table: any, row: any, cond: any): boolean { + if (!cond) return true; + if (cond.kind === 'and') return cond.conds.every((c: any) => matches(table, row, c)); + if (cond.kind === 'eq') return row[dbNameToJsKey(table, cond.column)] === cond.value; + return true; +} +function filterRows(table: any, cond?: any): any[] { + return tables[tableKind(table)].filter(row => matches(table, row, cond)); +} +function insertRow(table: any, vals: any): any { + const kind = tableKind(table); + const row = { id: `id-${tables[kind].length + 1}`, ...vals }; + tables[kind].push(row as any); + return row; +} +function upsertRow(table: any, vals: any, opts: any): any { + const kind = tableKind(table); + const targets: string[] = (opts?.target ?? []) + .map((col: any) => (col?.name ? dbNameToJsKey(table, col.name) : undefined)) + .filter(Boolean); + const existing = tables[kind].find(row => targets.every(t => (row as any)[t] === vals[t])); + if (existing) { + Object.assign(existing, opts?.set ?? {}); + return existing; + } + return insertRow(table, vals); +} +function insertIfAbsent(table: any, vals: any, opts: any): any | undefined { + const kind = tableKind(table); + const targets: string[] = (opts?.target ?? []) + .map((col: any) => (col?.name ? dbNameToJsKey(table, col.name) : undefined)) + .filter(Boolean); + const existing = targets.length + ? tables[kind].find(row => targets.every(t => (row as any)[t] === vals[t])) + : undefined; + if (existing) return undefined; + return insertRow(table, vals); +} +function updateRows(table: any, vals: any, cond?: any): void { + for (const row of tables[tableKind(table)]) { + if (matches(table, row, cond)) Object.assign(row, vals); + } +} + +import { githubInstallations, githubProjectSandboxes, githubWorktrees } from './schema'; +installationsRef = githubInstallations; +worktreesRef = githubWorktrees; +sandboxesRef = githubProjectSandboxes; + +function buildApp(user: { workosId: string; organizationId?: string } | null) { + const app = new Hono(); + app.use('*', async (c, next) => { + if (user) c.set('webAuthUser' as never, user as never); + await next(); + }); + mountGithubRoutes(app as any, { baseUrl: 'http://localhost:4111' }); + return app; +} + +function postJson(app: ReturnType<typeof buildApp>, path: string, body: unknown) { + return app.request(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + tables.installations = []; + tables.projects = []; + tables.sandboxes = []; + tables.worktrees = []; + featureEnabled = true; + sandboxEnabled = true; + cookieUser = null; + bootstrapSucceeds = true; + // No Postgres in these scenario tests: keep the project lock in-process. + process.env.MASTRACODE_DISTRIBUTED_LOCK = '0'; + mintCount = 0; + ensureProjectSandbox.mockClear(); + materializeRepo.mockClear(); + reattachProjectSandbox.mockClear(); + ensureWorktree.mockClear(); + commitAll.mockClear(); + pushBranch.mockClear(); + createPullRequest.mockClear(); +}); + +afterEach(() => { + delete process.env.MASTRACODE_DISTRIBUTED_LOCK; + vi.clearAllMocks(); +}); + +// ── Scenario 1: same repo, two orgs, no bleed ──────────────────────────── +describe('same repo connected by two orgs stays isolated', () => { + it('gives each org its own project row and forbids cross-org operations', async () => { + // Each org has its own installation for the same repo. + tables.installations.push({ + orgId: 'orgA', + userId: 'a1', + installationId: 7, + accountLogin: 'octo', + accountType: 'User', + }); + tables.installations.push({ + orgId: 'orgB', + userId: 'b1', + installationId: 7, + accountLogin: 'octo', + accountType: 'User', + }); + + const appA = buildApp({ workosId: 'a1', organizationId: 'orgA' }); + const appB = buildApp({ workosId: 'b1', organizationId: 'orgB' }); + + const resA = await postJson(appA, '/api/web/github/projects', { repoFullName: 'octo/hello', installationId: 7 }); + const resB = await postJson(appB, '/api/web/github/projects', { repoFullName: 'octo/hello', installationId: 7 }); + expect(resA.status).toBe(200); + expect(resB.status).toBe(200); + const projA = (await resA.json()).project.id as string; + const projB = (await resB.json()).project.id as string; + + // The (org_id, repo_id) unique target means two orgs → two distinct rows. + expect(projA).not.toBe(projB); + expect(tables.projects).toHaveLength(2); + expect(tables.projects.find(p => p.id === projA)?.orgId).toBe('orgA'); + expect(tables.projects.find(p => p.id === projB)?.orgId).toBe('orgB'); + + // Org A cannot ensure / worktree / push against Org B's project id. + expect((await postJson(appA, `/api/web/github/projects/${projB}/ensure`, {})).status).toBe(404); + expect((await postJson(appA, `/api/web/github/projects/${projB}/worktree`, { branch: 'feat/x' })).status).toBe(404); + expect((await postJson(appA, `/api/web/github/projects/${projB}/push`, { branch: 'feat/x' })).status).toBe(404); + }); +}); + +// ── Scenario 2: two users, one org, own sandboxes ──────────────────────── +describe('two users in one org each get their own sandbox + worktree', () => { + function seedOrgProject() { + tables.projects.push({ + id: 'p1', + orgId: 'orgA', + userId: 'a1', + installationId: 7, + repoFullName: 'octo/hello', + repoId: 99, + defaultBranch: 'main', + sandboxWorkdir: '/workspace/hello', + }); + } + + it('creates a distinct (project,user) sandbox row per user and hides worktrees across users', async () => { + seedOrgProject(); + const user1 = buildApp({ workosId: 'a1', organizationId: 'orgA' }); + const user2 = buildApp({ workosId: 'a2', organizationId: 'orgA' }); + + // Both users open (ensure) the same org-owned project. + expect((await postJson(user1, '/api/web/github/projects/p1/ensure', {})).status).toBe(200); + expect((await postJson(user2, '/api/web/github/projects/p1/ensure', {})).status).toBe(200); + + // Each got their own per-(project,user) sandbox binding row. + expect(tables.sandboxes).toHaveLength(2); + expect(tables.sandboxes.filter(s => s.githubProjectId === 'p1' && s.userId === 'a1')).toHaveLength(1); + expect(tables.sandboxes.filter(s => s.githubProjectId === 'p1' && s.userId === 'a2')).toHaveLength(1); + + // User 1 creates a worktree; it is owned by user 1 only. + const wt = await postJson(user1, '/api/web/github/projects/p1/worktree', { branch: 'feat/x' }); + expect(wt.status).toBe(200); + const wtPath = (await wt.json()).worktreePath as string; + expect(tables.worktrees).toHaveLength(1); + expect(tables.worktrees[0]).toMatchObject({ userId: 'a1', orgId: 'orgA', githubProjectId: 'p1' }); + + // User 2 cannot commit against user 1's worktree path (scoped to (p,user)). + const crossCommit = await postJson(user2, '/api/web/github/projects/p1/commit', { + message: 'sneaky', + worktreePath: wtPath, + }); + expect(crossCommit.status).toBe(400); + expect((await crossCommit.json()).error).toBe('Invalid worktreePath'); + + // User 1 can commit against their own worktree path. + const ownCommit = await postJson(user1, '/api/web/github/projects/p1/commit', { + message: 'wip', + worktreePath: wtPath, + }); + expect(ownCommit.status).toBe(200); + expect(await ownCommit.json()).toMatchObject({ committed: true }); + }); +}); + +// ── Scenario 3: cross-user worktree path rejected even with same branch ─── +describe('cross-user worktree paths are rejected', () => { + it('does not let user 2 push user 1 worktree path when both share a branch name', async () => { + tables.projects.push({ + id: 'p1', + orgId: 'orgA', + userId: 'a1', + installationId: 7, + repoFullName: 'octo/hello', + repoId: 99, + defaultBranch: 'main', + sandboxWorkdir: '/workspace/hello', + }); + // Both users have their own sandbox bindings + a worktree row on the same + // branch name; uniqueness is (project,user,branch) so both can coexist. + for (const userId of ['a1', 'a2']) { + tables.sandboxes.push({ + id: `sbrow-${userId}`, + githubProjectId: 'p1', + userId, + sandboxId: `sb-${userId}`, + sandboxWorkdir: '/workspace/hello', + materializedAt: new Date(), + }); + tables.worktrees.push({ + id: `wt-${userId}`, + orgId: 'orgA', + userId, + githubProjectId: 'p1', + branch: 'feat/x', + baseBranch: 'main', + worktreePath: `/workspace/hello/../worktrees/${userId}/feat/x`, + }); + } + + const user2 = buildApp({ workosId: 'a2', organizationId: 'orgA' }); + + // User 2 supplies user 1's worktree path → rejected (path not owned). + const res = await postJson(user2, '/api/web/github/projects/p1/push', { + branch: 'feat/x', + worktreePath: '/workspace/hello/../worktrees/a1/feat/x', + }); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe('Invalid worktreePath'); + expect(pushBranch).not.toHaveBeenCalled(); + + // User 2 with their own worktree path succeeds. + const ok = await postJson(user2, '/api/web/github/projects/p1/push', { + branch: 'feat/x', + worktreePath: '/workspace/hello/../worktrees/a2/feat/x', + }); + expect(ok.status).toBe(200); + expect(pushBranch).toHaveBeenCalledOnce(); + }); +}); + +// ── Phase 4: org-scoped GitHub install flow ────────────────────────────── +// The install `state` carries `(orgId, userId)`; the callback persists the +// installation against the org and rejects a session whose org differs from +// the signed state's org. A second user in the same org then sees the shared +// org-level installation and can create projects from it. +describe('install flow binds the installation to the org', () => { + it('persists an org-owned installation, then a second org user can use it', async () => { + // User 1 connects: state must encode their (org, user). + const connect = await buildApp({ workosId: 'a1', organizationId: 'orgA' }).request('/auth/github/connect'); + expect(connect.status).toBe(302); + expect(connect.headers.get('location')).toContain('state=state.orgA.a1'); + + // Callback with a matching state persists the installation against orgA. + const cb = await buildApp({ workosId: 'a1', organizationId: 'orgA' }).request( + '/auth/github/callback?state=state.orgA.a1&code=abc', + ); + expect(cb.headers.get('location')).toBe('/?github=connected'); + expect(tables.installations).toHaveLength(1); + expect(tables.installations[0]).toMatchObject({ orgId: 'orgA', installationId: 7 }); + + // A different user in the same org sees the org-level installation and can + // create a project from it (no second install required). + const user2 = buildApp({ workosId: 'a2', organizationId: 'orgA' }); + const status = await user2.request('/api/web/github/status'); + expect((await status.json()).connected).toBe(true); + + const proj = await postJson(user2, '/api/web/github/projects', { + repoFullName: 'octo/hello', + installationId: 7, + }); + expect(proj.status).toBe(200); + expect(tables.projects).toHaveLength(1); + expect(tables.projects[0]).toMatchObject({ orgId: 'orgA', repoId: 99 }); + }); + + it('rejects a callback whose session org differs from the signed state org', async () => { + // State was signed for orgA but the callback session is in orgB. + const res = await buildApp({ workosId: 'a1', organizationId: 'orgB' }).request( + '/auth/github/callback?state=state.orgA.a1&code=abc', + ); + expect(res.headers.get('location')).toBe('/?github=error'); + expect(tables.installations).toHaveLength(0); + }); +}); + +// ── Personal-org bootstrap: no-org cookie connect reaches install ───────── +// A user who signs in with no WorkOS organization used to dead-end at the org +// gate (`organization_required`). With bootstrap, `ensureWebAuthUser` gives the +// personal account an org on first authenticated use, so a cookie-only +// navigation to `/auth/github/connect` redirects to the GitHub App install with +// the bootstrapped org encoded in the signed state — not a 403. +describe('personal-org bootstrap on the cookie connect flow', () => { + it('redirects a no-org cookie user to install with the bootstrapped org', async () => { + // The gate skips `/auth/*`, so no user is stashed up front; the cookie user + // has no organization yet. + cookieUser = { workosId: 'solo1' }; + + const res = await buildApp(null).request('/auth/github/connect'); + + expect(res.status).toBe(302); + const location = res.headers.get('location') ?? ''; + // Bootstrap produced a personal org; the signed state carries (org, user). + expect(location).toContain('state=state.org-personal-solo1.solo1'); + }); + + it('still org-gates a no-org cookie user when bootstrap fails', async () => { + // When the WorkOS org create fails (e.g. missing API permissions), the + // personal account stays no-org, so the org gate fires as before. + bootstrapSucceeds = false; + cookieUser = { workosId: 'solo1' }; + + const res = await buildApp(null).request('/auth/github/connect'); + + expect(res.status).toBe(403); + expect((await res.json()).error).toBe('organization_required'); + }); +}); diff --git a/mastracode/src/web/github/project-lock-scenario.test.ts b/mastracode/src/web/github/project-lock-scenario.test.ts new file mode 100644 index 000000000000..6407ea7d7601 --- /dev/null +++ b/mastracode/src/web/github/project-lock-scenario.test.ts @@ -0,0 +1,247 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { LockClient, LockPool } from './project-lock'; +import { __resetProjectLocksForTests, hashKey, withDbAdvisoryLock, withProjectLock } from './project-lock'; + +// ── Phase 5 distributed project-lock scenario tests ────────────────────── +// These prove cross-replica serialization on the same key using a fake pg +// client that faithfully models transaction-scoped advisory-lock semantics: +// - pg_advisory_xact_lock(k1, k2) blocks while another transaction holds the +// same key, +// - the lock auto-releases when the holding transaction COMMITs or ROLLBACKs. +// Two `withProjectLock` callers sharing one fake pool model two replicas +// pointed at one Postgres. + +/** + * A fake Postgres modeling per-key advisory-lock queues. A key is "held" by at + * most one transaction at a time; `pg_advisory_xact_lock` waits for the holder + * to COMMIT/ROLLBACK before resolving. + */ +class FakePg implements LockPool { + /** key -> currently-holding client (or undefined when free). */ + private held = new Map<string, FakeClient>(); + /** key -> FIFO queue of waiters resolved when the lock frees. */ + private waiters = new Map<string, Array<() => void>>(); + + connect(): Promise<LockClient> { + return Promise.resolve(new FakeClient(this)); + } + + async acquire(key: string, client: FakeClient): Promise<void> { + if (!this.held.has(key)) { + this.held.set(key, client); + return; + } + await new Promise<void>(resolve => { + const q = this.waiters.get(key) ?? []; + q.push(resolve); + this.waiters.set(key, q); + }); + this.held.set(key, client); + } + + releaseAll(client: FakeClient): void { + for (const [key, holder] of [...this.held.entries()]) { + if (holder !== client) continue; + this.held.delete(key); + const q = this.waiters.get(key); + const next = q?.shift(); + if (next) next(); + } + } + + isHeld(key: string): boolean { + return this.held.has(key); + } +} + +class FakeClient implements LockClient { + private heldKeys: string[] = []; + constructor(private readonly pg: FakePg) {} + + async query(sql: string, params?: unknown[]): Promise<unknown> { + if (sql === 'BEGIN') return undefined; + if (sql === 'COMMIT' || sql === 'ROLLBACK') { + this.pg.releaseAll(this); + this.heldKeys = []; + return undefined; + } + if (sql.includes('pg_advisory_xact_lock')) { + const [k1, k2] = params as [number, number]; + const key = `${k1}:${k2}`; + this.heldKeys.push(key); + await this.pg.acquire(key, this); + return undefined; + } + return undefined; + } + + release(): void { + // Connection back to the pool; any held advisory locks would have been + // released by COMMIT/ROLLBACK already. Defensive cleanup mirrors pg. + this.pg.releaseAll(this); + } +} + +const deferred = () => { + let resolve!: () => void; + const promise = new Promise<void>(r => (resolve = r)); + return { promise, resolve }; +}; + +beforeEach(() => { + __resetProjectLocksForTests(); + process.env.MASTRACODE_DISTRIBUTED_LOCK = '1'; +}); +afterEach(() => { + delete process.env.MASTRACODE_DISTRIBUTED_LOCK; + __resetProjectLocksForTests(); +}); + +// Two replicas share one Postgres but have *independent* in-process lock +// chains. We model each replica's call as a direct advisory-lock acquisition +// (`withDbAdvisoryLock`), since that is the only layer that serializes across +// replicas; the in-process mutex only serializes within a single replica. +describe('cross-replica serialization via advisory locks', () => { + it('serializes overlapping critical sections on the same key across two replicas', async () => { + const pg = new FakePg(); // one shared Postgres + const [k1, k2] = hashKey('proj1:user1'); + const key = `${k1}:${k2}`; + + const order: string[] = []; + const gateA = deferred(); + + // Replica A acquires the advisory lock first. + const a = withDbAdvisoryLock( + 'proj1:user1', + async () => { + order.push('A:start'); + await gateA.promise; + order.push('A:end'); + }, + pg, + ); + // Let A's BEGIN + advisory-lock acquisition settle. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + // Replica B (separate process → no shared in-process chain) tries the same + // key and must block on the Postgres advisory lock. + const b = withDbAdvisoryLock( + 'proj1:user1', + async () => { + order.push('B:start'); + order.push('B:end'); + }, + pg, + ); + + await Promise.resolve(); + await Promise.resolve(); + expect(order).toEqual(['A:start']); + + gateA.resolve(); + await Promise.all([a, b]); + + expect(order).toEqual(['A:start', 'A:end', 'B:start', 'B:end']); + expect(pg.isHeld(key)).toBe(false); + }); + + it('lets different keys interleave', async () => { + const pg = new FakePg(); + const order: string[] = []; + const gate1 = deferred(); + + const [k1a, k1b] = hashKey('proj1:user1'); + const key1 = `${k1a}:${k1b}`; + + const op1 = withDbAdvisoryLock( + 'proj1:user1', + async () => { + order.push('1:start'); + await gate1.promise; + order.push('1:end'); + }, + pg, + ); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + // A different key should not be blocked by op1 holding key1. + const op2 = withDbAdvisoryLock( + 'proj2:user1', + async () => { + order.push('2:start'); + order.push('2:end'); + }, + pg, + ); + + await op2; + // op2 finished while op1 is still holding its lock. + expect(order).toEqual(['1:start', '2:start', '2:end']); + expect(pg.isHeld(key1)).toBe(true); + + gate1.resolve(); + await op1; + expect(order).toEqual(['1:start', '2:start', '2:end', '1:end']); + expect(pg.isHeld(key1)).toBe(false); + }); +}); + +describe('lock released on failure', () => { + it('rolls back and frees the key so the next caller acquires (no deadlock)', async () => { + const pg = new FakePg(); + __resetProjectLocksForTests(); + const [k1, k2] = hashKey('proj1:user1'); + const key = `${k1}:${k2}`; + + await expect( + withProjectLock( + 'proj1:user1', + async () => { + throw new Error('boom'); + }, + pg, + ), + ).rejects.toThrow('boom'); + + // Lock must be free after the failed (rolled-back) transaction. + expect(pg.isHeld(key)).toBe(false); + + let ran = false; + await withProjectLock( + 'proj1:user1', + async () => { + ran = true; + }, + pg, + ); + expect(ran).toBe(true); + expect(pg.isHeld(key)).toBe(false); + }); +}); + +describe('disabled distributed lock falls back to in-process only', () => { + it('does not touch the pg pool when MASTRACODE_DISTRIBUTED_LOCK=0', async () => { + process.env.MASTRACODE_DISTRIBUTED_LOCK = '0'; + let connects = 0; + const pg: LockPool = { + connect: () => { + connects++; + return Promise.resolve({ query: async () => undefined, release: () => {} }); + }, + }; + let ran = false; + await withProjectLock( + 'proj1:user1', + async () => { + ran = true; + }, + pg, + ); + expect(ran).toBe(true); + expect(connects).toBe(0); + }); +}); diff --git a/mastracode/src/web/github/project-lock.ts b/mastracode/src/web/github/project-lock.ts new file mode 100644 index 000000000000..9f3ecf622ce9 --- /dev/null +++ b/mastracode/src/web/github/project-lock.ts @@ -0,0 +1,119 @@ +/** + * Per-(project, user) lock that serializes the worktree/commit/push/PR flows. + * + * The push/PR flows temporarily rewrite the sandbox git remote to a tokenized + * URL and scrub it again in a `finally`; two concurrent operations on the same + * `(project, user)` sandbox could interleave those rewrites and leak a tokenized + * remote. Serializing per `(project, user)` removes that race. + * + * There are two layers: + * 1. An **in-process** promise-chain mutex keyed by the lock key, so repeated + * same-replica callers stay cheap and never touch Postgres for ordering. + * 2. A **Postgres transaction-level advisory lock** (`pg_advisory_xact_lock`) + * so that *different replicas* operating on the same key also serialize. + * Transaction-scoped advisory locks release automatically when the + * transaction ends (commit, rollback, or connection loss), so a crashed + * replica can never hold the lock forever. + * + * Set `MASTRACODE_DISTRIBUTED_LOCK=0` to disable the Postgres layer (local dev, + * single replica) and fall back to the pure in-process mutex. + */ + +import { createHash } from 'node:crypto'; +import { getAppDbPool } from './db'; + +/** Minimal pg pool surface used by the distributed lock (for testability). */ +export interface LockPool { + connect(): Promise<LockClient>; +} +export interface LockClient { + query(sql: string, params?: unknown[]): Promise<unknown>; + release(): void; +} + +const inProcessLocks = new Map<string, Promise<unknown>>(); + +/** True when the Postgres advisory-lock layer should be used. */ +export function isDistributedLockEnabled(): boolean { + return process.env.MASTRACODE_DISTRIBUTED_LOCK !== '0'; +} + +/** + * Hash a lock key into the two signed 32-bit integers that the two-arg form of + * `pg_advisory_xact_lock(int4, int4)` expects. Using two int4 args (rather than + * one int8) keeps the key inside the GitHub-feature advisory-lock namespace and + * avoids collisions with other single-int8 advisory locks. + */ +export function hashKey(key: string): [number, number] { + const digest = createHash('sha256').update(key).digest(); + // Read two independent 32-bit halves as signed int4 values. + const a = digest.readInt32BE(0); + const b = digest.readInt32BE(4); + return [a, b]; +} + +/** + * Run `fn` while holding the lock for `key`. Same-replica callers serialize via + * the in-process mutex; cross-replica callers additionally serialize via a + * Postgres transaction-scoped advisory lock (unless disabled). + * + * The in-process chain swallows rejections so one failed operation does not + * poison the lock for subsequent callers. + */ +export function withProjectLock<T>(key: string, fn: () => Promise<T>, poolOverride?: LockPool): Promise<T> { + const prev = inProcessLocks.get(key) ?? Promise.resolve(); + const run = () => withDbAdvisoryLock(key, fn, poolOverride); + const next = prev.then(run, run); + const tail = next.then( + () => undefined, + () => undefined, + ); + inProcessLocks.set(key, tail); + // Drop the entry once this operation settles, but only if no later caller has + // chained onto it in the meantime — otherwise we'd evict a live waiter's tail. + // This keeps the map from growing unbounded across many distinct project keys. + void tail.then(() => { + if (inProcessLocks.get(key) === tail) { + inProcessLocks.delete(key); + } + }); + return next; +} + +/** + * Acquire only the Postgres transaction-scoped advisory lock for `key` and run + * `fn` inside that transaction. This is the cross-replica serialization layer; + * `withProjectLock` wraps it with an in-process mutex for same-replica callers. + * Exposed so the cross-replica behavior can be tested without the in-process + * chain (each replica has its own in-process state but shares one Postgres). + */ +export async function withDbAdvisoryLock<T>(key: string, fn: () => Promise<T>, poolOverride?: LockPool): Promise<T> { + if (!isDistributedLockEnabled()) { + return fn(); + } + + const pool: LockPool = poolOverride ?? (getAppDbPool() as unknown as LockPool); + const [k1, k2] = hashKey(key); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + // Blocks until no other transaction holds this advisory key. Auto-released + // when the transaction ends below. + await client.query('SELECT pg_advisory_xact_lock($1, $2)', [k1, k2]); + try { + const result = await fn(); + await client.query('COMMIT'); + return result; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } + } finally { + client.release(); + } +} + +/** For tests: clear the in-process lock chains. */ +export function __resetProjectLocksForTests(): void { + inProcessLocks.clear(); +} diff --git a/mastracode/src/web/github/routes-scenario.test.ts b/mastracode/src/web/github/routes-scenario.test.ts new file mode 100644 index 000000000000..4fd0370d02fa --- /dev/null +++ b/mastracode/src/web/github/routes-scenario.test.ts @@ -0,0 +1,471 @@ +import { Hono } from 'hono'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Scenario tests (S1, S2) ────────────────────────────────────────────── +// These exercise the *composition* of the real Phase 4 git route handlers +// across a full write-back journey, and the per-project mutex that serialises +// concurrent remote-rewriting pushes. They reuse the exact harness shape from +// `routes.test.ts`: mocked `drizzle-orm` eq/and, an in-memory fake DB, and +// mocked `./client` / `./sandbox` / `./config` modules. No real network. + +vi.mock('drizzle-orm', () => ({ + eq: (column: any, value: any) => ({ kind: 'eq', column: column?.name, value }), + and: (...conds: any[]) => ({ kind: 'and', conds: conds.filter(Boolean) }), +})); + +interface Tables { + installations: Array<{ + orgId?: string; + userId: string; + installationId: number; + accountLogin: string | null; + accountType: string | null; + }>; + projects: Array<Record<string, any>>; + sandboxes: Array<Record<string, any>>; + worktrees: Array<Record<string, any>>; +} +const tables: Tables = { installations: [], projects: [], sandboxes: [], worktrees: [] }; + +vi.mock('./db', () => { + const makeDb = () => ({ + select: () => ({ + from: (table: any) => ({ + where: async (cond: any) => filterRows(table, cond), + }), + }), + insert: (table: any) => ({ + values: (vals: any) => { + const chain = { + onConflictDoNothing: (opts?: any) => { + const ret = insertIfAbsent(table, vals, opts); + const promise: any = Promise.resolve(ret ? [ret] : []); + promise.returning = async () => (ret ? [ret] : []); + return promise; + }, + onConflictDoUpdate: (opts: any) => { + const ret = upsertRow(table, vals, opts); + return { returning: async () => [ret] }; + }, + returning: async () => [insertRow(table, vals)], + }; + return chain; + }, + }), + update: (table: any) => ({ + set: (vals: any) => ({ where: async (cond: any) => updateRows(table, vals, cond) }), + }), + }); + return { getAppDb: () => makeDb() }; +}); + +vi.mock('./client', () => ({ + buildInstallUrl: (state: string) => `https://github.com/apps/test/installations/new?state=${state}`, + buildOAuthIdentifyUrl: (state: string) => `https://github.com/login/oauth/authorize?state=${state}`, + exchangeOAuthCode: vi.fn(async () => 'user-token'), + listUserInstallations: vi.fn(async () => [{ installationId: 7, accountLogin: 'octo', accountType: 'User' }]), + listInstallationRepos: vi.fn(async () => [ + { + id: 99, + fullName: 'octo/hello', + name: 'hello', + owner: 'octo', + defaultBranch: 'main', + private: false, + installationId: 7, + }, + ]), + getInstallationRepo: vi.fn(async (installationId: number, fullName: string) => + fullName === 'octo/hello' + ? { + id: 99, + fullName: 'octo/hello', + name: 'hello', + owner: 'octo', + defaultBranch: 'main', + private: false, + installationId, + } + : null, + ), + // A fresh token string per call so the scenario can prove per-op minting. + mintInstallationToken: vi.fn(async () => `install-token-${++mintCount}`), +})); + +let mintCount = 0; + +const ensureProjectSandbox = vi.fn(async (_row: any) => ({ id: 'sb' })); +const materializeRepo = vi.fn(async () => {}); +const reattachProjectSandbox = vi.fn(async (_id: string) => ({ id: 'sb' })); +const ensureWorktree = vi.fn(async (_sb: any, _workdir: string, opts: { branch: string; baseBranch: string }) => ({ + worktreePath: `/workspace/hello/../worktrees/${opts.branch}`, + branch: opts.branch, + baseBranch: opts.baseBranch, +})); +const commitAll = vi.fn(async () => ({ committed: true })); +// pushBranch is overridable per-test so S2 can make it block on a deferred. +let pushImpl: (...args: any[]) => Promise<void> = async () => {}; +const pushBranch = vi.fn((...args: any[]) => pushImpl(...args)); +const createPullRequest = vi.fn(async () => ({ url: 'https://github.com/octo/hello/pull/1' })); +let sandboxEnabled = true; +vi.mock('./sandbox', () => { + class MaterializeError extends Error { + code: string; + constructor(m: string, code: string) { + super(m); + this.code = code; + } + } + class WorktreeError extends Error { + code: string; + constructor(m: string, code: string) { + super(m); + this.code = code; + } + } + return { + computeSandboxWorkdir: (repo: string) => `/workspace/${repo.split('/').pop()}`, + getSandboxProvider: () => 'railway', + isSandboxEnabled: () => sandboxEnabled, + ensureProjectSandbox: (row: any) => ensureProjectSandbox(row), + materializeRepo: (...args: any[]) => materializeRepo(...(args as [])), + reattachProjectSandbox: (id: string) => reattachProjectSandbox(id), + ensureWorktree: (sb: any, workdir: string, opts: any) => ensureWorktree(sb, workdir, opts), + commitAll: (...args: any[]) => commitAll(...(args as [])), + pushBranch: (...args: any[]) => pushBranch(...(args as [])), + createPullRequest: (...args: any[]) => createPullRequest(...(args as [])), + isValidGitRef: (v: unknown): v is string => + typeof v === 'string' && v.length > 0 && v.length <= 255 && /^[A-Za-z0-9_./-]+$/.test(v), + MaterializeError, + WorktreeError, + }; +}); + +let featureEnabled = true; +vi.mock('./config', () => ({ + isGithubFeatureEnabled: () => featureEnabled, + signState: (orgId: string, userId: string) => `state.${orgId}.${userId}`, + verifyState: (state: string | undefined) => { + if (!state?.startsWith('state.')) return null; + const [orgId, userId] = state.slice('state.'.length).split('.'); + if (!orgId || !userId) return null; + return { orgId, userId }; + }, +})); + +import { mountGithubRoutes } from './routes'; + +// ── Fake table helpers (mirrors routes.test.ts) ───────────────────────── +function tableKind(table: any): keyof Tables { + if (table === installationsRef) return 'installations'; + if (table === worktreesRef) return 'worktrees'; + if (table === sandboxesRef) return 'sandboxes'; + return 'projects'; +} +let installationsRef: any; +let worktreesRef: any; +let sandboxesRef: any; + +function dbNameToJsKey(table: any, dbName: string): string { + for (const [jsKey, col] of Object.entries(table)) { + if ((col as any)?.name === dbName) return jsKey; + } + return dbName; +} +function matches(table: any, row: any, cond: any): boolean { + if (!cond) return true; + if (cond.kind === 'and') return cond.conds.every((c: any) => matches(table, row, c)); + if (cond.kind === 'eq') return row[dbNameToJsKey(table, cond.column)] === cond.value; + return true; +} +function filterRows(table: any, cond?: any): any[] { + return tables[tableKind(table)].filter(row => matches(table, row, cond)); +} +function insertRow(table: any, vals: any): any { + const kind = tableKind(table); + const row = { id: `id-${tables[kind].length + 1}`, ...vals }; + tables[kind].push(row as any); + return row; +} +function upsertRow(table: any, vals: any, opts: any): any { + const kind = tableKind(table); + const targets: string[] = (opts?.target ?? []) + .map((col: any) => (col?.name ? dbNameToJsKey(table, col.name) : undefined)) + .filter(Boolean); + const existing = tables[kind].find(row => targets.every(t => row[t] === vals[t])); + if (existing) { + Object.assign(existing, opts?.set ?? {}); + return existing; + } + return insertRow(table, vals); +} +function insertIfAbsent(table: any, vals: any, opts: any): any | undefined { + const kind = tableKind(table); + const targets: string[] = (opts?.target ?? []) + .map((col: any) => (col?.name ? dbNameToJsKey(table, col.name) : undefined)) + .filter(Boolean); + const existing = targets.length + ? tables[kind].find(row => targets.every(t => (row as any)[t] === vals[t])) + : undefined; + if (existing) return undefined; + return insertRow(table, vals); +} +function updateRows(table: any, vals: any, cond?: any): void { + for (const row of tables[tableKind(table)]) { + if (matches(table, row, cond)) Object.assign(row, vals); + } +} + +import { githubInstallations, githubProjectSandboxes, githubWorktrees } from './schema'; +installationsRef = githubInstallations; +worktreesRef = githubWorktrees; +sandboxesRef = githubProjectSandboxes; + +// A tiny deferred so S2 can control when a push resolves. +function deferred<T = void>() { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise<T>((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function buildApp(user: { workosId: string; organizationId?: string } | null) { + const app = new Hono(); + app.use('*', async (c, next) => { + if (user) c.set('webAuthUser' as never, user as never); + await next(); + }); + mountGithubRoutes(app as any, { baseUrl: 'http://localhost:4111' }); + return app; +} + +function postJson(app: ReturnType<typeof buildApp>, path: string, body: unknown) { + return app.request(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + tables.installations = []; + tables.projects = []; + tables.sandboxes = []; + tables.worktrees = []; + featureEnabled = true; + sandboxEnabled = true; + // No Postgres in these scenario tests: keep the project lock in-process. + // The in-process mutex still serializes same-replica callers (S2). + process.env.MASTRACODE_DISTRIBUTED_LOCK = '0'; + mintCount = 0; + pushImpl = async () => {}; + ensureProjectSandbox.mockClear(); + materializeRepo.mockClear(); + reattachProjectSandbox.mockClear(); + ensureWorktree.mockClear(); + commitAll.mockClear(); + pushBranch.mockClear(); + createPullRequest.mockClear(); +}); + +afterEach(() => { + delete process.env.MASTRACODE_DISTRIBUTED_LOCK; + vi.clearAllMocks(); +}); + +// ── S1: full write-back journey ────────────────────────────────────────── +describe('S1: full write-back journey through the real route handlers', () => { + it('drives create → ensure → worktree → commit → push → pr for one user', async () => { + const mintModule = (await import('./client')) as unknown as { + mintInstallationToken: ReturnType<typeof vi.fn>; + }; + const mint = mintModule.mintInstallationToken; + tables.installations.push({ + orgId: 'org1', + userId: 'u1', + installationId: 7, + accountLogin: 'octo', + accountType: 'User', + }); + const app = buildApp({ workosId: 'u1', organizationId: 'org1' }); + + // 1. Create the project from an owned installation. + const createRes = await postJson(app, '/api/web/github/projects', { + repoFullName: 'octo/hello', + installationId: 7, + }); + expect(createRes.status).toBe(200); + const projectId = (await createRes.json()).project.id as string; + expect(tables.projects).toHaveLength(1); + expect(projectId).toBeTruthy(); + + // The project must be materializable: seed the per-(project,user) sandbox + // binding the way `ensure` would persist it (provisioning itself is mocked). + tables.sandboxes.push({ + id: 'sbrow-1', + githubProjectId: projectId, + userId: 'u1', + sandboxId: 'sb-1', + sandboxWorkdir: '/workspace/hello', + materializedAt: null, + }); + + // 2. Ensure → provisions the sandbox + materialises the repo. + const ensureRes = await postJson(app, `/api/web/github/projects/${projectId}/ensure`, {}); + expect(ensureRes.status).toBe(200); + expect(ensureProjectSandbox).toHaveBeenCalledOnce(); + expect(materializeRepo).toHaveBeenCalledOnce(); + + // 3. Worktree → persists a github_worktrees row for feat/x. + const wtRes = await postJson(app, `/api/web/github/projects/${projectId}/worktree`, { branch: 'feat/x' }); + expect(wtRes.status).toBe(200); + const wtJson = await wtRes.json(); + expect(wtJson.branch).toBe('feat/x'); + expect(wtJson.baseBranch).toBe('main'); + expect(tables.worktrees).toHaveLength(1); + expect(tables.worktrees[0]).toMatchObject({ + githubProjectId: projectId, + branch: 'feat/x', + baseBranch: 'main', + }); + const persistedWorktreePath = wtJson.worktreePath as string; + expect(tables.worktrees[0].worktreePath).toBe(persistedWorktreePath); + + // 4. Commit in that exact worktree path → the round-trip is honoured: + // a path that only exists because step 3 persisted it now passes + // resolveWorktreePath (no client-path injection possible). + const commitRes = await postJson(app, `/api/web/github/projects/${projectId}/commit`, { + message: 'wip', + worktreePath: persistedWorktreePath, + }); + expect(commitRes.status).toBe(200); + expect(await commitRes.json()).toMatchObject({ committed: true }); + expect((commitAll.mock.calls[0] as unknown as any[])[1]).toBe(persistedWorktreePath); + + // 5. Push that worktree → a fresh token is minted for *this* op. + const mintBeforePush = mint.mock.calls.length; + const pushRes = await postJson(app, `/api/web/github/projects/${projectId}/push`, { + branch: 'feat/x', + worktreePath: persistedWorktreePath, + }); + expect(pushRes.status).toBe(200); + expect(await pushRes.json()).toMatchObject({ pushed: true, branch: 'feat/x' }); + expect(mint.mock.calls.length).toBe(mintBeforePush + 1); + const pushCall = pushBranch.mock.calls[0] as unknown as any[]; + // pushBranch(sandbox, workdir, branch, token, repoFullName) + expect(pushCall[1]).toBe(persistedWorktreePath); + expect(pushCall[2]).toBe('feat/x'); + const pushToken = pushCall[3] as string; + expect(pushToken).toMatch(/^install-token-/); + + // 6. Open a PR → another fresh token is minted (per-op, not reused). + const mintBeforePr = mint.mock.calls.length; + const prRes = await postJson(app, `/api/web/github/projects/${projectId}/pr`, { + branch: 'feat/x', + title: 'My PR', + body: 'Adds a thing', + worktreePath: persistedWorktreePath, + }); + expect(prRes.status).toBe(200); + expect(await prRes.json()).toMatchObject({ url: 'https://github.com/octo/hello/pull/1' }); + expect(mint.mock.calls.length).toBe(mintBeforePr + 1); + const prToken = (createPullRequest.mock.calls[0] as unknown as any[])[2].token as string; + expect(prToken).toMatch(/^install-token-/); + // The push and PR tokens are distinct mints (never reused across ops). + expect(prToken).not.toBe(pushToken); + }); +}); + +// ── S2: concurrent push serialisation (per-project mutex) ───────────────── +describe('S2: per-project mutex serialises concurrent pushes', () => { + function seed(id: string, userId = 'u1', orgId = 'org1') { + tables.projects.push({ + id, + orgId, + userId, + installationId: 7, + repoFullName: 'octo/hello', + repoId: 99, + defaultBranch: 'main', + sandboxWorkdir: '/workspace/hello', + }); + tables.sandboxes.push({ + id: `sbrow-${id}`, + githubProjectId: id, + userId, + sandboxId: `sb-${id}`, + sandboxWorkdir: '/workspace/hello', + materializedAt: new Date(), + }); + } + + it('does not start the second push for the same project until the first resolves', async () => { + seed('p1'); + const app = buildApp({ workosId: 'u1', organizationId: 'org1' }); + + const order: string[] = []; + const gate = deferred(); + let active = 0; + let maxConcurrent = 0; + pushImpl = async () => { + active++; + maxConcurrent = Math.max(maxConcurrent, active); + order.push(`start:${active}`); + // First push blocks on the gate; both wait on the same deferred so the + // mutex (not wall-clock) determines ordering. + await gate.promise; + active--; + order.push('end'); + }; + + const first = postJson(app, '/api/web/github/projects/p1/push', { branch: 'feat/a' }); + const second = postJson(app, '/api/web/github/projects/p1/push', { branch: 'feat/b' }); + + // Let microtasks flush; only the first push body should have begun. + await new Promise(r => setTimeout(r, 10)); + expect(pushBranch).toHaveBeenCalledTimes(1); + expect(maxConcurrent).toBe(1); + + // Release the gate → both complete, second runs only after the first ends. + gate.resolve(); + const [r1, r2] = await Promise.all([first, second]); + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + expect(pushBranch).toHaveBeenCalledTimes(2); + // The mutex never let two push bodies overlap. + expect(maxConcurrent).toBe(1); + expect(order).toEqual(['start:1', 'end', 'start:1', 'end']); + }); + + it('allows pushes for different projects to overlap', async () => { + seed('p1'); + seed('p2'); + const app = buildApp({ workosId: 'u1', organizationId: 'org1' }); + + const gate = deferred(); + let active = 0; + let maxConcurrent = 0; + pushImpl = async () => { + active++; + maxConcurrent = Math.max(maxConcurrent, active); + await gate.promise; + active--; + }; + + const first = postJson(app, '/api/web/github/projects/p1/push', { branch: 'feat/a' }); + const second = postJson(app, '/api/web/github/projects/p2/push', { branch: 'feat/b' }); + + await new Promise(r => setTimeout(r, 10)); + // Distinct project ids → distinct locks → both bodies run concurrently. + expect(pushBranch).toHaveBeenCalledTimes(2); + expect(maxConcurrent).toBe(2); + + gate.resolve(); + const [r1, r2] = await Promise.all([first, second]); + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + }); +}); diff --git a/mastracode/src/web/github/routes.test.ts b/mastracode/src/web/github/routes.test.ts new file mode 100644 index 000000000000..b32fc592f514 --- /dev/null +++ b/mastracode/src/web/github/routes.test.ts @@ -0,0 +1,749 @@ +import { Hono } from 'hono'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as AuthModule from '../auth'; + +// ── Mocks ──────────────────────────────────────────────────────────────── +// Mock drizzle's `eq`/`and` so the fake DB below can honour `where` predicates. +// Each `eq(col, val)` yields a `{ column, value }` descriptor (using the +// column's `.name`), and `and(...)` wraps them so `filterRows` can apply them. +vi.mock('drizzle-orm', () => ({ + eq: (column: any, value: any) => ({ kind: 'eq', column: column?.name, value }), + and: (...conds: any[]) => ({ kind: 'and', conds: conds.filter(Boolean) }), +})); + +// In-memory tables so route handlers exercise real query-builder call shapes +// against a tiny fake. We only model the operations the routes actually use. +interface Tables { + installations: Array<{ + orgId?: string; + userId: string; + installationId: number; + accountLogin: string | null; + accountType: string | null; + }>; + projects: Array<Record<string, any>>; + sandboxes: Array<Record<string, any>>; + worktrees: Array<Record<string, any>>; +} +const tables: Tables = { installations: [], projects: [], sandboxes: [], worktrees: [] }; + +vi.mock('./db', () => { + // Minimal chainable drizzle-like stub keyed off the table object identity. + const makeDb = () => ({ + select: () => ({ + from: (table: any) => ({ + where: async (cond: any) => filterRows(table, cond), + }), + }), + insert: (table: any) => ({ + values: (vals: any) => { + const chain = { + onConflictDoNothing: (opts?: any) => { + const ret = insertIfAbsent(table, vals, opts); + const promise: any = Promise.resolve(ret ? [ret] : []); + promise.returning = async () => (ret ? [ret] : []); + return promise; + }, + onConflictDoUpdate: (opts: any) => { + const ret = upsertRow(table, vals, opts); + return { returning: async () => [ret] }; + }, + returning: async () => [insertRow(table, vals)], + }; + return chain; + }, + }), + update: (table: any) => ({ + set: (vals: any) => ({ where: async () => updateRows(table, vals) }), + }), + }); + return { getAppDb: () => makeDb() }; +}); + +vi.mock('./client', () => ({ + buildInstallUrl: (state: string) => `https://github.com/apps/test/installations/new?state=${state}`, + buildOAuthIdentifyUrl: (state: string) => `https://github.com/login/oauth/authorize?state=${state}`, + exchangeOAuthCode: vi.fn(async () => 'user-token'), + listUserInstallations: vi.fn(async () => [{ installationId: 7, accountLogin: 'octo', accountType: 'User' }]), + listInstallationRepos: vi.fn(async () => [ + { + id: 99, + fullName: 'octo/hello', + name: 'hello', + owner: 'octo', + defaultBranch: 'main', + private: false, + installationId: 7, + }, + ]), + getInstallationRepo: vi.fn(async (installationId: number, fullName: string) => + fullName === 'octo/hello' + ? { + id: 99, + fullName: 'octo/hello', + name: 'hello', + owner: 'octo', + defaultBranch: 'main', + private: false, + installationId, + } + : null, + ), + mintInstallationToken: vi.fn(async () => 'install-token'), +})); + +const ensureProjectSandbox = vi.fn(async (_row: any, onProgress?: (e: any) => void) => { + onProgress?.({ phase: 'provisioning', message: 'Provisioning a new sandbox…' }); + return { id: 'sb' }; +}); +const materializeRepo = vi.fn(async (..._args: any[]) => { + const onProgress = _args[4] as ((e: any) => void) | undefined; + onProgress?.({ phase: 'cloning', message: 'Cloning octo/hello…' }); +}); +const reattachProjectSandbox = vi.fn(async (_id: string) => ({ id: 'sb' })); +const ensureWorktree = vi.fn(async (_sb: any, _workdir: string, opts: { branch: string; baseBranch: string }) => ({ + worktreePath: `/workspace/hello/../worktrees/${opts.branch}`, + branch: opts.branch, + baseBranch: opts.baseBranch, +})); +const commitAll = vi.fn(async () => ({ committed: true })); +const pushBranch = vi.fn(async () => {}); +const createPullRequest = vi.fn(async () => ({ url: 'https://github.com/octo/hello/pull/1' })); +let sandboxEnabled = true; +vi.mock('./sandbox', () => { + class MaterializeError extends Error { + code: string; + constructor(m: string, code: string) { + super(m); + this.code = code; + } + } + class WorktreeError extends Error { + code: string; + constructor(m: string, code: string) { + super(m); + this.code = code; + } + } + return { + computeSandboxWorkdir: (repo: string) => `/workspace/${repo.split('/').pop()}`, + getSandboxProvider: () => 'railway', + isSandboxEnabled: () => sandboxEnabled, + ensureProjectSandbox: (row: any, onProgress?: any) => ensureProjectSandbox(row, onProgress), + materializeRepo: (...args: any[]) => materializeRepo(...(args as [])), + reattachProjectSandbox: (id: string) => reattachProjectSandbox(id), + ensureWorktree: (sb: any, workdir: string, opts: any) => ensureWorktree(sb, workdir, opts), + commitAll: (...args: any[]) => commitAll(...(args as [])), + pushBranch: (...args: any[]) => pushBranch(...(args as [])), + createPullRequest: (...args: any[]) => createPullRequest(...(args as [])), + // Match the real ref validator closely enough for route tests. + isValidGitRef: (v: unknown): v is string => + typeof v === 'string' && v.length > 0 && v.length <= 255 && /^[A-Za-z0-9_./-]+$/.test(v), + MaterializeError, + WorktreeError, + }; +}); + +let featureEnabled = true; +vi.mock('./config', () => ({ + isGithubFeatureEnabled: () => featureEnabled, + signState: (orgId: string, userId: string) => `state.${orgId}.${userId}`, + verifyState: (state: string | undefined) => { + if (!state?.startsWith('state.')) return null; + const [orgId, userId] = state.slice('state.'.length).split('.'); + if (!orgId || !userId) return null; + return { orgId, userId }; + }, +})); + +// Partially mock `../auth`: keep all real helpers (getWebAuthUser/webAuthTenant) +// so the harness's middleware-stashed user flows through normally, but make +// `ensureWebAuthUser` simulate cookie-based session resolution on `/auth/*` +// routes the gate skips — it stashes `cookieUser` onto the context the same way +// production resolves a session cookie before scoping the tenant. +let cookieUser: { workosId: string; organizationId?: string } | null = null; +vi.mock('../auth', async () => { + const actual = (await vi.importActual('../auth')) as typeof AuthModule; + return { + ...actual, + ensureWebAuthUser: async (c: any) => { + const existing = actual.getWebAuthUser(c); + if (existing) return existing; + if (!cookieUser) return undefined; + const u = cookieUser as { workosId: string; organizationId?: string }; + const withOrg: { workosId: string; organizationId?: string } = { + workosId: u.workosId, + organizationId: u.organizationId ?? 'org1', + }; + c.set('webAuthUser', withOrg); + return withOrg; + }, + }; +}); + +import { mountGithubRoutes } from './routes'; + +// ── Fake table helpers ────────────────────────────────────────────────── +function tableKind(table: any): keyof Tables { + if (table === installationsRef) return 'installations'; + if (table === worktreesRef) return 'worktrees'; + if (table === sandboxesRef) return 'sandboxes'; + return 'projects'; +} +// We can't import the actual schema objects easily into the closure used by the +// mock above, so resolve them lazily here for the helpers. +let installationsRef: any; +let worktreesRef: any; +let sandboxesRef: any; + +// Drizzle columns carry their snake_case DB `.name`, but our fake rows use the +// camelCase JS keys. Build a DB-name → JS-key map per table so predicates match. +function dbNameToJsKey(table: any, dbName: string): string { + for (const [jsKey, col] of Object.entries(table)) { + if ((col as any)?.name === dbName) return jsKey; + } + return dbName; +} + +// Apply a mocked `eq`/`and` predicate to a row. +function matches(table: any, row: any, cond: any): boolean { + if (!cond) return true; + if (cond.kind === 'and') return cond.conds.every((c: any) => matches(table, row, c)); + if (cond.kind === 'eq') return row[dbNameToJsKey(table, cond.column)] === cond.value; + return true; +} + +function filterRows(table: any, cond?: any): any[] { + return tables[tableKind(table)].filter(row => matches(table, row, cond)); +} +function insertRow(table: any, vals: any): any { + const kind = tableKind(table); + const row = { id: `id-${tables[kind].length + 1}`, ...vals }; + tables[kind].push(row as any); + return row; +} +function upsertRow(table: any, vals: any, opts: any): any { + const kind = tableKind(table); + // Conflict targets are columns; match an existing row on all of them (mapped + // back to JS keys since vals/rows are camelCase). + const targets: string[] = (opts?.target ?? []) + .map((col: any) => (col?.name ? dbNameToJsKey(table, col.name) : undefined)) + .filter(Boolean); + const existing = tables[kind].find(row => targets.every(t => row[t] === vals[t])); + if (existing) { + Object.assign(existing, opts?.set ?? {}); + return existing; + } + return insertRow(table, vals); +} +// onConflictDoNothing: insert only when no row matches the conflict target; +// returns the inserted row, or undefined when a conflicting row already exists. +function insertIfAbsent(table: any, vals: any, opts: any): any | undefined { + const kind = tableKind(table); + const targets: string[] = (opts?.target ?? []) + .map((col: any) => (col?.name ? dbNameToJsKey(table, col.name) : undefined)) + .filter(Boolean); + if (targets.length) { + const existing = tables[kind].find(row => targets.every(t => row[t] === vals[t])); + if (existing) return undefined; + } + return insertRow(table, vals); +} +function updateRows(table: any, vals: any): void { + for (const row of tables[tableKind(table)]) Object.assign(row, vals); +} + +// Resolve schema refs after import. +import { githubInstallations, githubProjectSandboxes, githubWorktrees } from './schema'; +installationsRef = githubInstallations; +worktreesRef = githubWorktrees; +sandboxesRef = githubProjectSandboxes; + +// ── Test harness ───────────────────────────────────────────────────────── +function buildApp(user: { workosId: string; organizationId?: string } | null) { + const app = new Hono(); + app.use('*', async (c, next) => { + if (user) { + // Default to an organization so org-scoped GitHub features are enabled; + // tests that need a personal (no-org) account pass `organizationId` null. + const withOrg = 'organizationId' in user ? user : { ...user, organizationId: 'org1' }; + c.set('webAuthUser' as never, withOrg as never); + } + await next(); + }); + mountGithubRoutes(app as any, { baseUrl: 'http://localhost:4111' }); + return app; +} + +beforeEach(() => { + tables.installations = []; + tables.projects = []; + tables.sandboxes = []; + tables.worktrees = []; + featureEnabled = true; + sandboxEnabled = true; + cookieUser = null; + // No Postgres in these unit tests: keep the project lock purely in-process. + process.env.MASTRACODE_DISTRIBUTED_LOCK = '0'; + ensureProjectSandbox.mockClear(); + materializeRepo.mockClear(); + reattachProjectSandbox.mockClear(); + ensureWorktree.mockClear(); + commitAll.mockClear(); + pushBranch.mockClear(); + createPullRequest.mockClear(); +}); + +afterEach(() => { + delete process.env.MASTRACODE_DISTRIBUTED_LOCK; + vi.clearAllMocks(); +}); + +describe('status route', () => { + it('reports disabled without the feature', async () => { + featureEnabled = false; + const res = await buildApp({ workosId: 'u1' }).request('/api/web/github/status'); + expect(await res.json()).toMatchObject({ enabled: false, connected: false }); + }); + + it('reports connected installations for the user', async () => { + tables.installations.push({ + orgId: 'org1', + userId: 'u1', + installationId: 7, + accountLogin: 'octo', + accountType: 'User', + }); + const res = await buildApp({ workosId: 'u1' }).request('/api/web/github/status'); + const json = await res.json(); + expect(json.enabled).toBe(true); + expect(json.connected).toBe(true); + expect(json.installations[0].installationId).toBe(7); + }); +}); + +describe('auth scoping', () => { + it('401s when no user is present', async () => { + const res = await buildApp(null).request('/api/web/github/repos'); + expect(res.status).toBe(401); + }); +}); + +describe('connect + callback', () => { + it('redirects connect to the install URL with a signed state', async () => { + const res = await buildApp({ workosId: 'u1' }).request('/auth/github/connect'); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toContain('state=state.org1.u1'); + }); + + it('resolves the session cookie on a cookie-only connect navigation (gate skips /auth/*)', async () => { + // A top-level browser navigation to /auth/github/connect carries only the + // session cookie — no Authorization header — and the auth gate skips + // `/auth/*`, so no user is stashed up front. The route must still resolve + // the session (via ensureWebAuthUser) and redirect to install, not 401. + cookieUser = { workosId: 'u1' }; + const res = await buildApp(null).request('/auth/github/connect'); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toContain('state=state.org1.u1'); + }); + + it('401s on a cookie-only connect navigation when there is no session', async () => { + cookieUser = null; + const res = await buildApp(null).request('/auth/github/connect'); + expect(res.status).toBe(401); + }); + + it('persists installations on a cookie-only callback navigation', async () => { + cookieUser = { workosId: 'u1' }; + const res = await buildApp(null).request('/auth/github/callback?state=state.org1.u1&code=abc'); + expect(res.headers.get('location')).toBe('/?github=connected'); + expect(tables.installations).toHaveLength(1); + }); + + it('rejects a callback whose state belongs to another user', async () => { + const res = await buildApp({ workosId: 'u1' }).request( + '/auth/github/callback?state=state.org1.someone-else&code=x', + ); + expect(res.headers.get('location')).toBe('/?github=error'); + expect(tables.installations).toHaveLength(0); + }); + + it('rejects a callback whose state belongs to another org', async () => { + const res = await buildApp({ workosId: 'u1' }).request('/auth/github/callback?state=state.org2.u1&code=x'); + expect(res.headers.get('location')).toBe('/?github=error'); + expect(tables.installations).toHaveLength(0); + }); + + it('persists installations on a valid callback', async () => { + const res = await buildApp({ workosId: 'u1' }).request('/auth/github/callback?state=state.org1.u1&code=abc'); + expect(res.headers.get('location')).toBe('/?github=connected'); + expect(tables.installations).toHaveLength(1); + }); + + it('does not trust an unverified installation_id without a code', async () => { + const res = await buildApp({ workosId: 'u1' }).request( + '/auth/github/callback?state=state.org1.u1&installation_id=999', + ); + // No code → bounce through OAuth identify, persist nothing. + expect(res.status).toBe(302); + expect(res.headers.get('location')).toContain('/login/oauth/authorize'); + expect(tables.installations).toHaveLength(0); + }); +}); + +describe('create project', () => { + it('inserts a github-sourced project for an owned installation', async () => { + tables.installations.push({ + orgId: 'org1', + userId: 'u1', + installationId: 7, + accountLogin: 'octo', + accountType: 'User', + }); + const res = await buildApp({ workosId: 'u1' }).request('/api/web/github/projects', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ repoFullName: 'octo/hello', repoId: 99, installationId: 7 }), + }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.project.source).toBe('github'); + expect(json.project.name).toBe('octo/hello'); + expect(tables.projects).toHaveLength(1); + }); + + it('rejects an invalid repo name', async () => { + tables.installations.push({ + orgId: 'org1', + userId: 'u1', + installationId: 7, + accountLogin: 'octo', + accountType: 'User', + }); + const res = await buildApp({ workosId: 'u1' }).request('/api/web/github/projects', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ repoFullName: 'not-a-repo', repoId: 99, installationId: 7 }), + }); + expect(res.status).toBe(400); + }); + + it('404s when the repo is not accessible to the installation', async () => { + tables.installations.push({ + orgId: 'org1', + userId: 'u1', + installationId: 7, + accountLogin: 'octo', + accountType: 'User', + }); + const res = await buildApp({ workosId: 'u1' }).request('/api/web/github/projects', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ repoFullName: 'octo/other-repo', installationId: 7 }), + }); + expect(res.status).toBe(404); + }); + + it('persists the server-returned defaultBranch, ignoring the client value', async () => { + tables.installations.push({ + orgId: 'org1', + userId: 'u1', + installationId: 7, + accountLogin: 'octo', + accountType: 'User', + }); + const res = await buildApp({ workosId: 'u1' }).request('/api/web/github/projects', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + repoFullName: 'octo/hello', + installationId: 7, + defaultBranch: "main'; rm -rf /; '", + }), + }); + expect(res.status).toBe(200); + expect(tables.projects[0].defaultBranch).toBe('main'); + }); + + it('404s when the installation is not owned by the user', async () => { + const res = await buildApp({ workosId: 'u1' }).request('/api/web/github/projects', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ repoFullName: 'octo/hello', repoId: 99, installationId: 7 }), + }); + expect(res.status).toBe(404); + }); +}); + +describe('ensure (materialize)', () => { + it('503s when the sandbox is not configured', async () => { + sandboxEnabled = false; + tables.projects.push({ + id: 'p1', + orgId: 'org1', + userId: 'u1', + installationId: 7, + repoFullName: 'octo/hello', + sandboxWorkdir: '/workspace/hello', + }); + const res = await buildApp({ workosId: 'u1' }).request('/api/web/github/projects/p1/ensure', { method: 'POST' }); + expect(res.status).toBe(503); + expect((await res.json()).error).toBe('sandbox_not_configured'); + }); + + it('provisions + materializes and returns a resourceId', async () => { + tables.projects.push({ + id: 'p1', + orgId: 'org1', + userId: 'u1', + installationId: 7, + repoFullName: 'octo/hello', + defaultBranch: 'main', + sandboxWorkdir: '/workspace/hello', + }); + const res = await buildApp({ workosId: 'u1' }).request('/api/web/github/projects/p1/ensure', { method: 'POST' }); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ resourceId: 'p1', githubProjectId: 'p1' }); + expect(ensureProjectSandbox).toHaveBeenCalledOnce(); + expect(materializeRepo).toHaveBeenCalledOnce(); + // A per-user sandbox binding row was created for the caller. + expect(tables.sandboxes).toHaveLength(1); + expect(tables.sandboxes[0]).toMatchObject({ githubProjectId: 'p1', userId: 'u1' }); + }); + + it('404s for a project the user does not own', async () => { + const res = await buildApp({ workosId: 'u1' }).request('/api/web/github/projects/missing/ensure', { + method: 'POST', + }); + expect(res.status).toBe(404); + }); + + it('streams server-side progress events when the client accepts an event stream', async () => { + tables.projects.push({ + id: 'p1', + orgId: 'org1', + userId: 'u1', + installationId: 7, + repoFullName: 'octo/hello', + defaultBranch: 'main', + sandboxWorkdir: '/workspace/hello', + }); + const res = await buildApp({ workosId: 'u1' }).request('/api/web/github/projects/p1/ensure', { + method: 'POST', + headers: { Accept: 'text/event-stream' }, + }); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/event-stream'); + const body = await res.text(); + // Progress events surface each server step, then a terminal `done` carries the result. + expect(body).toContain('event: progress'); + expect(body).toContain('Provisioning a new sandbox…'); + expect(body).toContain('Cloning octo/hello…'); + expect(body).toContain('event: done'); + expect(body).toContain('"resourceId":"p1"'); + }); +}); + +// ── Phase 4: worktree / commit / push / pr git routes ───────────────────── +function seedMaterializedProject(opts: { orgId?: string; userId?: string } = {}) { + const orgId = opts.orgId ?? 'org1'; + const userId = opts.userId ?? 'u1'; + tables.projects.push({ + id: 'p1', + orgId, + userId, + installationId: 7, + repoFullName: 'octo/hello', + repoId: 99, + defaultBranch: 'main', + sandboxWorkdir: '/workspace/hello', + }); + tables.sandboxes.push({ + id: 'sbrow-1', + githubProjectId: 'p1', + userId, + sandboxId: 'sb-1', + sandboxWorkdir: '/workspace/hello', + materializedAt: new Date(), + }); +} + +function postJson(app: ReturnType<typeof buildApp>, path: string, body: unknown) { + return app.request(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('worktree route', () => { + it('401s without an authenticated user', async () => { + seedMaterializedProject(); + const res = await postJson(buildApp(null), '/api/web/github/projects/p1/worktree', { branch: 'feat/x' }); + expect(res.status).toBe(401); + }); + + it('503s when the sandbox is not configured', async () => { + sandboxEnabled = false; + seedMaterializedProject(); + const res = await postJson(buildApp({ workosId: 'u1' }), '/api/web/github/projects/p1/worktree', { + branch: 'feat/x', + }); + expect(res.status).toBe(503); + }); + + it('404s for a project owned by another org', async () => { + seedMaterializedProject({ orgId: 'other-org' }); + const res = await postJson(buildApp({ workosId: 'u1' }), '/api/web/github/projects/p1/worktree', { + branch: 'feat/x', + }); + expect(res.status).toBe(404); + expect(ensureWorktree).not.toHaveBeenCalled(); + }); + + it('400s on an invalid branch name', async () => { + seedMaterializedProject(); + const res = await postJson(buildApp({ workosId: 'u1' }), '/api/web/github/projects/p1/worktree', { + branch: 'bad branch!', + }); + expect(res.status).toBe(400); + expect(ensureWorktree).not.toHaveBeenCalled(); + }); + + it('creates a worktree, persists a row, and returns the path', async () => { + seedMaterializedProject(); + const res = await postJson(buildApp({ workosId: 'u1' }), '/api/web/github/projects/p1/worktree', { + branch: 'feat/x', + }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.branch).toBe('feat/x'); + expect(json.baseBranch).toBe('main'); + expect(json.resourceId).toBe('p1'); + expect(reattachProjectSandbox).toHaveBeenCalledWith('sb-1'); + expect(ensureWorktree).toHaveBeenCalledOnce(); + expect(tables.worktrees).toHaveLength(1); + expect(tables.worktrees[0]).toMatchObject({ githubProjectId: 'p1', branch: 'feat/x', userId: 'u1' }); + }); + + it('upserts the worktree row on conflict instead of duplicating', async () => { + seedMaterializedProject(); + const app = buildApp({ workosId: 'u1' }); + await postJson(app, '/api/web/github/projects/p1/worktree', { branch: 'feat/x' }); + await postJson(app, '/api/web/github/projects/p1/worktree', { branch: 'feat/x' }); + expect(tables.worktrees).toHaveLength(1); + }); +}); + +describe('commit route', () => { + it('400s on an empty message', async () => { + seedMaterializedProject(); + const res = await postJson(buildApp({ workosId: 'u1' }), '/api/web/github/projects/p1/commit', { + message: ' ', + }); + expect(res.status).toBe(400); + expect(commitAll).not.toHaveBeenCalled(); + }); + + it('400s on an unknown worktreePath', async () => { + seedMaterializedProject(); + const res = await postJson(buildApp({ workosId: 'u1' }), '/api/web/github/projects/p1/commit', { + message: 'wip', + worktreePath: '/etc/passwd', + }); + expect(res.status).toBe(400); + expect(commitAll).not.toHaveBeenCalled(); + }); + + it('commits on the base checkout when no worktreePath is given', async () => { + seedMaterializedProject(); + const res = await postJson(buildApp({ workosId: 'u1' }), '/api/web/github/projects/p1/commit', { + message: 'wip', + }); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ committed: true }); + expect(commitAll).toHaveBeenCalledOnce(); + // The base repo workdir is used when worktreePath is omitted. + expect((commitAll.mock.calls[0] as unknown as any[])[1]).toBe('/workspace/hello'); + }); + + it('commits in a persisted worktree path', async () => { + seedMaterializedProject(); + tables.worktrees.push({ + id: 'w1', + userId: 'u1', + githubProjectId: 'p1', + branch: 'feat/x', + baseBranch: 'main', + worktreePath: '/workspace/worktrees/feat-x', + }); + const res = await postJson(buildApp({ workosId: 'u1' }), '/api/web/github/projects/p1/commit', { + message: 'wip', + worktreePath: '/workspace/worktrees/feat-x', + }); + expect(res.status).toBe(200); + expect((commitAll.mock.calls[0] as unknown as any[])[1]).toBe('/workspace/worktrees/feat-x'); + }); +}); + +describe('push route', () => { + it('400s on an invalid branch', async () => { + seedMaterializedProject(); + const res = await postJson(buildApp({ workosId: 'u1' }), '/api/web/github/projects/p1/push', { + branch: 'bad branch', + }); + expect(res.status).toBe(400); + expect(pushBranch).not.toHaveBeenCalled(); + }); + + it('mints a token and pushes the branch', async () => { + seedMaterializedProject(); + const res = await postJson(buildApp({ workosId: 'u1' }), '/api/web/github/projects/p1/push', { + branch: 'feat/x', + }); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ pushed: true, branch: 'feat/x' }); + expect(pushBranch).toHaveBeenCalledOnce(); + // pushBranch(sandbox, workdir, branch, token, repoFullName) + const call = pushBranch.mock.calls[0] as unknown as any[]; + expect(call[2]).toBe('feat/x'); + expect(call[3]).toBe('install-token'); + expect(call[4]).toBe('octo/hello'); + }); +}); + +describe('pr route', () => { + it('400s on a missing title', async () => { + seedMaterializedProject(); + const res = await postJson(buildApp({ workosId: 'u1' }), '/api/web/github/projects/p1/pr', { + branch: 'feat/x', + }); + expect(res.status).toBe(400); + expect(createPullRequest).not.toHaveBeenCalled(); + }); + + it('400s on an invalid base branch', async () => { + seedMaterializedProject(); + const res = await postJson(buildApp({ workosId: 'u1' }), '/api/web/github/projects/p1/pr', { + branch: 'feat/x', + base: 'bad base', + title: 'My PR', + }); + expect(res.status).toBe(400); + expect(createPullRequest).not.toHaveBeenCalled(); + }); + + it('opens a PR and returns its URL', async () => { + seedMaterializedProject(); + const res = await postJson(buildApp({ workosId: 'u1' }), '/api/web/github/projects/p1/pr', { + branch: 'feat/x', + title: 'My PR', + body: 'Adds a thing', + }); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ url: 'https://github.com/octo/hello/pull/1' }); + expect(createPullRequest).toHaveBeenCalledOnce(); + const opts = (createPullRequest.mock.calls[0] as unknown as any[])[2]; + expect(opts).toMatchObject({ token: 'install-token', base: 'main', head: 'feat/x', title: 'My PR' }); + }); +}); diff --git a/mastracode/src/web/github/routes.ts b/mastracode/src/web/github/routes.ts new file mode 100644 index 000000000000..f5da2f924752 --- /dev/null +++ b/mastracode/src/web/github/routes.ts @@ -0,0 +1,751 @@ +/** + * Hono routes for the GitHub App project feature. + * + * Mounted alongside the other `/api/web/*` routes, behind the WorkOS auth gate. + * Every route additionally re-checks the authenticated user (`getWebAuthUser`) + * and scopes all rows by that user's stable WorkOS id, so a user can only ever + * see and operate on their own installations and projects. + * + * When the feature is disabled (`isGithubFeatureEnabled()` false), `mountGithubRoutes` + * is a no-op except for `GET /api/web/github/status`, which reports `enabled:false` + * so the SPA can cleanly hide all GitHub UI. + */ + +import { and, eq } from 'drizzle-orm'; +import type { Context, Hono } from 'hono'; +import { streamSSE } from 'hono/streaming'; +import { ensureWebAuthUser, getWebAuthUser, webAuthTenant } from '../auth'; +import type { WebAuthTenant } from '../auth'; +import { + buildInstallUrl, + buildOAuthIdentifyUrl, + exchangeOAuthCode, + getInstallationRepo, + listInstallationRepos, + listUserInstallations, + mintInstallationToken, +} from './client'; +import { isGithubFeatureEnabled, signState, verifyState } from './config'; +import { getAppDb } from './db'; +import { withProjectLock } from './project-lock'; +import { + commitAll, + computeSandboxWorkdir, + createPullRequest, + ensureProjectSandbox, + ensureWorktree, + getSandboxProvider, + isSandboxEnabled, + isValidGitRef as isValidGitRefSandbox, + materializeRepo, + MaterializeError, + pushBranch, + reattachProjectSandbox, + SandboxBudgetError, + teardownProjectSandbox, + WorktreeError, +} from './sandbox'; +import type { GitIdentity, MaterializationSandbox, PrepareProgress, ProgressFn } from './sandbox'; +import { githubInstallations, githubProjects, githubProjectSandboxes, githubWorktrees } from './schema'; +import type { GithubProjectRow, GithubProjectSandboxRow } from './schema'; + +export interface MountGithubRoutesOptions { + /** + * Absolute base URL of the web server (e.g. `http://localhost:4111`), used to + * build the OAuth/install redirect URI when one isn't explicitly configured. + */ + baseUrl?: string; + /** Explicit OAuth callback URI; defaults to `<baseUrl>/auth/github/callback`. */ + redirectUri?: string; +} + +/** Validate an `owner/name` repo full name. */ +function isValidRepoFullName(value: unknown): value is string { + return typeof value === 'string' && value.length <= 256 && /^[\w.-]+\/[\w.-]+$/.test(value); +} + +/** + * Validate a git branch/ref name against a strict whitelist. The value is later + * interpolated into a shell `git clone --branch` command, so it must never + * contain shell metacharacters. We accept only git-ref-safe characters and + * reject anything else rather than relying on shell quoting alone. + */ +function isValidGitRef(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= 255 && /^[A-Za-z0-9_./-]+$/.test(value); +} + +/** + * Resolve the org-scoped tenant for a GitHub request. GitHub project features + * are org-owned, so they require both a signed-in user and a WorkOS + * organization. Returns the `(orgId, userId)` tenant (with `orgId` narrowed to a + * non-null string) or a ready-to-return error response: 401 when unauthenticated, + * 403 when the user has no organization (personal account). + */ +function resolveOrgTenant(c: Context): { tenant: WebAuthTenant & { orgId: string } } | { response: Response } { + const tenant = webAuthTenant(c); + if (!tenant) return { response: c.json({ error: 'unauthorized' }, 401) }; + if (!tenant.orgId) { + return { + response: c.json( + { + error: 'organization_required', + message: 'GitHub projects require a WorkOS organization. Personal accounts cannot connect repositories.', + }, + 403, + ), + }; + } + return { tenant: { orgId: tenant.orgId, userId: tenant.userId } }; +} + +/** + * Shape returned to the SPA for a GitHub-backed project, matching the front-end + * `Project` model (`source: 'github'`). + */ +function toProjectPayload(row: GithubProjectRow) { + return { + id: row.id, + name: row.repoFullName, + source: 'github' as const, + githubProjectId: row.id, + }; +} + +/** + * Mount the GitHub routes. Returns whether the feature is enabled. + */ +export function mountGithubRoutes(app: Hono<any>, options: MountGithubRoutesOptions = {}): boolean { + // The status route is always mounted so the SPA can detect the disabled state. + app.get('/api/web/github/status', async c => { + if (!isGithubFeatureEnabled()) { + return c.json({ enabled: false, connected: false, installations: [] }); + } + const tenant = webAuthTenant(c); + if (!tenant) return c.json({ error: 'unauthorized' }, 401); + + // Org-scoped: personal (no-org) users have GitHub projects disabled. Report + // enabled (so the SPA can show the org-required hint) but never connected. + if (!tenant.orgId) { + return c.json({ + enabled: true, + sandboxEnabled: isSandboxEnabled(), + organizationRequired: true, + connected: false, + installations: [], + }); + } + + const rows = await getAppDb().select().from(githubInstallations).where(eq(githubInstallations.orgId, tenant.orgId)); + + return c.json({ + enabled: true, + sandboxEnabled: isSandboxEnabled(), + connected: rows.length > 0, + installations: rows.map(r => ({ + installationId: r.installationId, + accountLogin: r.accountLogin, + accountType: r.accountType, + })), + }); + }); + + if (!isGithubFeatureEnabled()) { + return false; + } + + const redirectUri = options.redirectUri ?? `${(options.baseUrl ?? '').replace(/\/$/, '')}/auth/github/callback`; + + // ── Connect: redirect to the GitHub App install URL ───────────────────── + app.get('/auth/github/connect', async c => { + // Cookie-based browser navigation: the auth gate skips `/auth/*`, so resolve + // the session from the request cookie before scoping the tenant. + await ensureWebAuthUser(c); + const resolved = resolveOrgTenant(c); + if ('response' in resolved) return resolved.response; + const state = signState(resolved.tenant.orgId, resolved.tenant.userId); + return c.redirect(buildInstallUrl(state)); + }); + + // ── Callback: confirm identity, persist the installation against the org ── + app.get('/auth/github/callback', async c => { + // Cookie-based browser navigation: the auth gate skips `/auth/*`, so resolve + // the session from the request cookie before scoping the tenant. + await ensureWebAuthUser(c); + const resolved = resolveOrgTenant(c); + if ('response' in resolved) return resolved.response; + const { orgId, userId } = resolved.tenant; + + const state = c.req.query('state'); + const stateTenant = verifyState(state); + if (!stateTenant || stateTenant.userId !== userId || stateTenant.orgId !== orgId) { + // CSRF / cross-user/org linking protection: the signed state must belong + // to the same logged-in user *and* their current org. + console.warn( + '[GitHub] Install callback rejected: state/tenant mismatch.', + JSON.stringify({ + stateValid: Boolean(stateTenant), + stateOrgId: stateTenant?.orgId, + stateUserId: stateTenant?.userId, + sessionOrgId: orgId, + sessionUserId: userId, + }), + ); + return c.redirect('/?github=error'); + } + + const code = c.req.query('code'); + // We only ever persist installations that GitHub confirms belong to *this* + // user via the OAuth code path. The raw `installation_id` from the install + // redirect is not trusted on its own — anyone with a valid state could pass + // an arbitrary id — so when no code is present we bounce through the OAuth + // identify flow to obtain a verified user token first. + if (!code) { + return c.redirect(buildOAuthIdentifyUrl(signState(orgId, userId), redirectUri)); + } + + try { + const userToken = await exchangeOAuthCode(code, redirectUri); + const installations = await listUserInstallations(userToken); + const db = getAppDb(); + for (const inst of installations) { + // The installation is org-owned; `userId` records who connected it. + await db + .insert(githubInstallations) + .values({ + orgId, + userId, + installationId: inst.installationId, + accountLogin: inst.accountLogin, + accountType: inst.accountType, + }) + .onConflictDoNothing({ + target: [githubInstallations.orgId, githubInstallations.installationId], + }); + } + } catch (error) { + console.warn( + `[GitHub] Install callback failed to persist installations for org ${orgId} / user ${userId}.`, + error, + ); + return c.redirect('/?github=error'); + } + + return c.redirect('/?github=connected'); + }); + + // ── List repos across the org's installations ─────────────────────────── + app.get('/api/web/github/repos', async c => { + const resolved = resolveOrgTenant(c); + if ('response' in resolved) return resolved.response; + + const installs = await getAppDb() + .select() + .from(githubInstallations) + .where(eq(githubInstallations.orgId, resolved.tenant.orgId)); + + const query = (c.req.query('q') ?? '').toLowerCase(); + const repos = []; + for (const inst of installs) { + const list = await listInstallationRepos(inst.installationId); + for (const repo of list) { + if (query && !repo.fullName.toLowerCase().includes(query)) continue; + repos.push(repo); + } + } + return c.json({ repos }); + }); + + // ── Create a project from a repo (no sandbox, no clone yet) ────────────── + app.post('/api/web/github/projects', async c => { + const resolved = resolveOrgTenant(c); + if ('response' in resolved) return resolved.response; + const { orgId, userId } = resolved.tenant; + + let body: { repoFullName?: unknown; installationId?: unknown }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Invalid JSON body' }, 400); + } + + if (!isValidRepoFullName(body.repoFullName)) { + return c.json({ error: 'Invalid repoFullName' }, 400); + } + const installationId = Number(body.installationId); + if (!Number.isFinite(installationId)) { + return c.json({ error: 'Invalid installationId' }, 400); + } + + // The installation must belong to this org. + const owned = await getAppDb() + .select() + .from(githubInstallations) + .where(and(eq(githubInstallations.orgId, orgId), eq(githubInstallations.installationId, installationId))); + if (owned.length === 0) { + return c.json({ error: 'Installation not found for organization' }, 404); + } + + // Verify the repo is actually accessible to the installation and use the + // server-returned metadata rather than trusting the client's repoId / + // defaultBranch. This prevents creating a project for an arbitrary repo. + const repo = await getInstallationRepo(installationId, body.repoFullName); + if (!repo) { + return c.json({ error: 'Repository not accessible to installation' }, 404); + } + const defaultBranch = isValidGitRef(repo.defaultBranch) ? repo.defaultBranch : 'main'; + const sandboxWorkdir = computeSandboxWorkdir(repo.fullName); + + const [row] = await getAppDb() + .insert(githubProjects) + .values({ + orgId, + userId, + installationId, + repoFullName: repo.fullName, + repoId: repo.id, + defaultBranch, + sandboxProvider: getSandboxProvider(), + sandboxWorkdir, + }) + .onConflictDoUpdate({ + target: [githubProjects.orgId, githubProjects.repoId], + set: { installationId, repoFullName: repo.fullName, defaultBranch, sandboxWorkdir }, + }) + .returning(); + + return c.json({ project: toProjectPayload(row!) }); + }); + + // ── Materialize a project into the caller's per-user sandbox ───────────── + app.post('/api/web/github/projects/:id/ensure', async c => { + const resolved = resolveOrgTenant(c); + if ('response' in resolved) return resolved.response; + const { orgId, userId } = resolved.tenant; + + if (!isSandboxEnabled()) { + return c.json({ error: 'sandbox_not_configured', message: 'No sandbox provider is configured.' }, 503); + } + + const projectId = c.req.param('id'); + if (!projectId) return c.json({ error: 'Project not found' }, 404); + const [project] = await getAppDb() + .select() + .from(githubProjects) + .where(and(eq(githubProjects.id, projectId), eq(githubProjects.orgId, orgId))); + if (!project) { + return c.json({ error: 'Project not found' }, 404); + } + + // Stream live server-side progress when the client asks for it (EventSource + // / fetch with `Accept: text/event-stream`); otherwise fall back to a single + // JSON response so non-streaming callers and tests keep working unchanged. + const wantsStream = (c.req.header('accept') ?? '').includes('text/event-stream'); + if (wantsStream) { + return streamSSE(c, async stream => { + try { + const result = await prepareProject( + project, + userId, + ev => void stream.writeSSE({ event: 'progress', data: JSON.stringify(ev) }), + ); + await stream.writeSSE({ event: 'done', data: JSON.stringify(result) }); + } catch (err) { + await stream.writeSSE({ event: 'error', data: JSON.stringify(ensureErrorPayload(err).body) }); + } + }); + } + + try { + const result = await prepareProject(project, userId); + return c.json(result); + } catch (err) { + const { status, body } = ensureErrorPayload(err); + return c.json(body, status); + } + }); + + // ── Worktree / branch / commit / push / PR ────────────────────────────── + mountProjectGitRoutes(app); + + return true; +} + +/** Derive a commit/author identity from the authenticated WorkOS user. */ +function identityFromUser(user: { name?: string; email?: string } | undefined): GitIdentity { + return { name: user?.name ?? null, email: user?.email ?? null }; +} + +/** + * Resolve a live, started sandbox for the caller's per-user sandbox binding. The + * sandbox must already have been provisioned (`sandboxId` set) — the git write + * routes never clone, they operate on the existing checkout. + */ +async function resolveProjectSandbox(sandboxRow: GithubProjectSandboxRow): Promise<MaterializationSandbox> { + if (!sandboxRow.sandboxId) { + throw new MaterializeError('Project sandbox is not provisioned. Open the project first.', 'clone-failed'); + } + return reattachProjectSandbox(sandboxRow.sandboxId); +} + +/** + * Load (or create) the caller's per-(project,user) sandbox binding row. The + * binding inherits its workdir from the org-owned project, but `sandboxId` / + * `materializedAt` stay null until the user first opens the project. + */ +async function loadOrCreateSandboxRow(project: GithubProjectRow, userId: string): Promise<GithubProjectSandboxRow> { + const [existing] = await getAppDb() + .select() + .from(githubProjectSandboxes) + .where(and(eq(githubProjectSandboxes.githubProjectId, project.id), eq(githubProjectSandboxes.userId, userId))); + if (existing) return existing; + + const [created] = await getAppDb() + .insert(githubProjectSandboxes) + .values({ + githubProjectId: project.id, + userId, + sandboxWorkdir: project.sandboxWorkdir, + }) + .onConflictDoNothing({ target: [githubProjectSandboxes.githubProjectId, githubProjectSandboxes.userId] }) + .returning(); + if (created) return created; + + // Lost a race: another request inserted the binding first. Re-read it. + const [row] = await getAppDb() + .select() + .from(githubProjectSandboxes) + .where(and(eq(githubProjectSandboxes.githubProjectId, project.id), eq(githubProjectSandboxes.userId, userId))); + return row!; +} + +interface EnsureResult { + resourceId: string; + githubProjectId: string; + sandboxId: string | null; + sandboxWorkdir: string; +} + +/** + * Provision/reattach the caller's sandbox and materialize the repo into it, + * emitting coarse progress events as each server step happens. Shared by both + * the JSON and SSE variants of the `/ensure` route. Throws on failure so the + * caller can shape the response (HTTP status vs SSE `error` event). + */ +async function prepareProject( + project: GithubProjectRow, + userId: string, + onProgress?: ProgressFn, +): Promise<EnsureResult> { + const sandboxRow = await loadOrCreateSandboxRow(project, userId); + const sandbox = await ensureProjectSandbox(sandboxRow, onProgress); + // Re-read the sandbox binding so we have the freshly persisted sandboxId. + const [fresh] = await getAppDb() + .select() + .from(githubProjectSandboxes) + .where(eq(githubProjectSandboxes.id, sandboxRow.id)); + const token = await mintInstallationToken(project.installationId); + const finalRow = fresh ?? sandboxRow; + await materializeRepo( + finalRow, + { repoFullName: project.repoFullName, defaultBranch: project.defaultBranch }, + sandbox, + token, + onProgress, + ); + const result: EnsureResult = { + resourceId: project.id, + githubProjectId: project.id, + sandboxId: finalRow.sandboxId, + sandboxWorkdir: finalRow.sandboxWorkdir, + }; + const done: PrepareProgress = { phase: 'done', message: 'Workspace ready.' }; + onProgress?.(done); + return result; +} + +/** Shape an /ensure failure into an HTTP status + JSON body (also used as the SSE error payload). */ +function ensureErrorPayload(err: unknown): { + status: 429 | 502 | 500; + body: { error: string; message: string }; +} { + if (err instanceof SandboxBudgetError) { + return { status: 429, body: { error: err.code, message: err.message } }; + } + if (err instanceof MaterializeError) { + return { status: 502, body: { error: err.code, message: err.message } }; + } + return { + status: 500, + body: { error: 'materialize_failed', message: err instanceof Error ? err.message : String(err) }, + }; +} + +/** Map a sandbox/worktree error to an actionable HTTP response. */ +function gitErrorResponse(c: Context, err: unknown) { + if (err instanceof WorktreeError) { + return c.json({ error: err.code, message: err.message }, err.code === 'invalid-branch' ? 400 : 502); + } + if (err instanceof MaterializeError) { + return c.json({ error: err.code, message: err.message }, 502); + } + return c.json({ error: 'git_failed', message: err instanceof Error ? err.message : String(err) }, 500); +} + +/** + * Load the org-owned project and the caller's per-user sandbox binding for a git + * route. Centralizes the auth + org/ownership checks every git route shares: + * the project is scoped by `(id, orgId)`, the sandbox binding by + * `(githubProjectId, userId)`. Returns the tenant, project, and sandbox row, or + * a ready-to-return error response. + */ +async function loadOwnedProject( + c: Context, +): Promise< + | { orgId: string; userId: string; project: GithubProjectRow; sandboxRow: GithubProjectSandboxRow } + | { response: Response } +> { + const resolved = resolveOrgTenant(c); + if ('response' in resolved) return { response: resolved.response }; + const { orgId, userId } = resolved.tenant; + + if (!isSandboxEnabled()) { + return { + response: c.json({ error: 'sandbox_not_configured', message: 'No sandbox provider is configured.' }, 503), + }; + } + + const projectId = c.req.param('id'); + if (!projectId) { + return { response: c.json({ error: 'Project not found' }, 404) }; + } + const [project] = await getAppDb() + .select() + .from(githubProjects) + .where(and(eq(githubProjects.id, projectId), eq(githubProjects.orgId, orgId))); + if (!project) { + return { response: c.json({ error: 'Project not found' }, 404) }; + } + const sandboxRow = await loadOrCreateSandboxRow(project, userId); + return { orgId, userId, project, sandboxRow }; +} + +function mountProjectGitRoutes(app: Hono<any>): void { + // ── Create / reuse a worktree + feature branch ────────────────────────── + app.post('/api/web/github/projects/:id/worktree', async c => { + const owned = await loadOwnedProject(c); + if ('response' in owned) return owned.response; + const { orgId, userId, project, sandboxRow } = owned; + + let body: { branch?: unknown; baseBranch?: unknown }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Invalid JSON body' }, 400); + } + if (!isValidGitRefSandbox(body.branch)) { + return c.json({ error: 'Invalid branch' }, 400); + } + const baseBranch = body.baseBranch === undefined ? project.defaultBranch : body.baseBranch; + if (!isValidGitRefSandbox(baseBranch)) { + return c.json({ error: 'Invalid baseBranch' }, 400); + } + const branch = body.branch; + + try { + return await withProjectLock(`${project.id}:${userId}`, async () => { + const sandbox = await resolveProjectSandbox(sandboxRow); + const result = await ensureWorktree(sandbox, sandboxRow.sandboxWorkdir, { branch, baseBranch }); + + await getAppDb() + .insert(githubWorktrees) + .values({ + orgId, + userId, + githubProjectId: project.id, + branch: result.branch, + baseBranch: result.baseBranch, + worktreePath: result.worktreePath, + }) + .onConflictDoUpdate({ + target: [githubWorktrees.githubProjectId, githubWorktrees.userId, githubWorktrees.branch], + set: { baseBranch: result.baseBranch, worktreePath: result.worktreePath }, + }); + + return c.json({ + worktreePath: result.worktreePath, + branch: result.branch, + baseBranch: result.baseBranch, + resourceId: project.id, + }); + }); + } catch (err) { + return gitErrorResponse(c, err); + } + }); + + // ── Stage all + commit inside a worktree ──────────────────────────────── + app.post('/api/web/github/projects/:id/commit', async c => { + const owned = await loadOwnedProject(c); + if ('response' in owned) return owned.response; + const { userId, project, sandboxRow } = owned; + + let body: { message?: unknown; worktreePath?: unknown }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Invalid JSON body' }, 400); + } + if (typeof body.message !== 'string' || body.message.trim().length === 0 || body.message.length > 5000) { + return c.json({ error: 'Invalid message' }, 400); + } + const workdir = await resolveWorktreePath(project.id, userId, body.worktreePath, sandboxRow.sandboxWorkdir); + if (!workdir) { + return c.json({ error: 'Invalid worktreePath' }, 400); + } + + try { + return await withProjectLock(`${project.id}:${userId}`, async () => { + const sandbox = await resolveProjectSandbox(sandboxRow); + const result = await commitAll(sandbox, workdir, body.message as string, identityFromUser(getWebAuthUser(c))); + return c.json({ committed: result.committed }); + }); + } catch (err) { + return gitErrorResponse(c, err); + } + }); + + // ── Push a branch back to GitHub ──────────────────────────────────────── + app.post('/api/web/github/projects/:id/push', async c => { + const owned = await loadOwnedProject(c); + if ('response' in owned) return owned.response; + const { userId, project, sandboxRow } = owned; + + let body: { branch?: unknown; worktreePath?: unknown }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Invalid JSON body' }, 400); + } + if (!isValidGitRefSandbox(body.branch)) { + return c.json({ error: 'Invalid branch' }, 400); + } + const branch = body.branch; + const workdir = await resolveWorktreePath(project.id, userId, body.worktreePath, sandboxRow.sandboxWorkdir); + if (!workdir) { + return c.json({ error: 'Invalid worktreePath' }, 400); + } + + try { + return await withProjectLock(`${project.id}:${userId}`, async () => { + const sandbox = await resolveProjectSandbox(sandboxRow); + const token = await mintInstallationToken(project.installationId); + await pushBranch(sandbox, workdir, branch, token, project.repoFullName); + return c.json({ pushed: true, branch }); + }); + } catch (err) { + return gitErrorResponse(c, err); + } + }); + + // ── Open a pull request via the gh CLI ────────────────────────────────── + app.post('/api/web/github/projects/:id/pr', async c => { + const owned = await loadOwnedProject(c); + if ('response' in owned) return owned.response; + const { userId, project, sandboxRow } = owned; + + let body: { branch?: unknown; base?: unknown; title?: unknown; body?: unknown; worktreePath?: unknown }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Invalid JSON body' }, 400); + } + if (!isValidGitRefSandbox(body.branch)) { + return c.json({ error: 'Invalid branch' }, 400); + } + const base = body.base === undefined ? project.defaultBranch : body.base; + if (!isValidGitRefSandbox(base)) { + return c.json({ error: 'Invalid base' }, 400); + } + if (typeof body.title !== 'string' || body.title.trim().length === 0 || body.title.length > 256) { + return c.json({ error: 'Invalid title' }, 400); + } + if (body.body !== undefined && (typeof body.body !== 'string' || body.body.length > 65536)) { + return c.json({ error: 'Invalid body' }, 400); + } + const head = body.branch; + const title = body.title; + const prBody = body.body as string | undefined; + const workdir = await resolveWorktreePath(project.id, userId, body.worktreePath, sandboxRow.sandboxWorkdir); + if (!workdir) { + return c.json({ error: 'Invalid worktreePath' }, 400); + } + + try { + return await withProjectLock(`${project.id}:${userId}`, async () => { + const sandbox = await resolveProjectSandbox(sandboxRow); + const token = await mintInstallationToken(project.installationId); + const result = await createPullRequest(sandbox, workdir, { token, base, head, title, body: prBody }); + return c.json({ url: result.url }); + }); + } catch (err) { + return gitErrorResponse(c, err); + } + }); + + // ── Tear down the caller's sandbox for a project ──────────────────────── + // Per-user teardown only: drops the caller's `(project, user)` sandbox + // binding and stops the VM, freeing a slot in the per-replica budget. Project + // deletion at the org level is out of scope (org admin model is later). + app.delete('/api/web/github/projects/:id/sandbox', async c => { + const owned = await loadOwnedProject(c); + if ('response' in owned) return owned.response; + const { userId, project, sandboxRow } = owned; + + if (!sandboxRow.sandboxId) { + // Nothing provisioned for this user — idempotent success. + return c.json({ tornDown: false }); + } + + try { + return await withProjectLock(`${project.id}:${userId}`, async () => { + const sandbox = await reattachProjectSandbox(sandboxRow.sandboxId!); + await teardownProjectSandbox(sandboxRow, sandbox); + return c.json({ tornDown: true }); + }); + } catch (err) { + return gitErrorResponse(c, err); + } + }); +} + +/** + * Resolve and validate the worktree path a git write operation targets. The + * path is never trusted from the client verbatim: it must either be the + * project's repo workdir (committing/pushing on the base checkout) or match a + * persisted worktree row for this project. Returns the validated path or + * `undefined` when it isn't recognized. + */ +async function resolveWorktreePath( + projectId: string, + userId: string, + worktreePath: unknown, + repoWorkdir: string, +): Promise<string | undefined> { + if (worktreePath === undefined || worktreePath === repoWorkdir) { + return repoWorkdir; + } + if (typeof worktreePath !== 'string') { + return undefined; + } + const [row] = await getAppDb() + .select() + .from(githubWorktrees) + .where( + and( + eq(githubWorktrees.githubProjectId, projectId), + eq(githubWorktrees.userId, userId), + eq(githubWorktrees.worktreePath, worktreePath), + ), + ); + return row ? row.worktreePath : undefined; +} diff --git a/mastracode/src/web/github/sandbox-filesystem.test.ts b/mastracode/src/web/github/sandbox-filesystem.test.ts new file mode 100644 index 000000000000..37488759c6a3 --- /dev/null +++ b/mastracode/src/web/github/sandbox-filesystem.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; +import { SandboxFilesystem } from './sandbox-filesystem'; +import type { SandboxCommandResult, SandboxExec } from './sandbox-filesystem'; + +/** + * Fake sandbox that records every command and returns scripted results. Lets us + * assert the exact shell the filesystem issues without a real VM. + */ +class FakeSandbox implements SandboxExec { + readonly id = 'fake-sandbox'; + readonly calls: string[] = []; + private responder: (script: string) => SandboxCommandResult; + + constructor(responder?: (script: string) => SandboxCommandResult) { + this.responder = responder ?? (() => ({ exitCode: 0, stdout: '', stderr: '' })); + } + + async executeCommand(command: string, args?: string[]): Promise<SandboxCommandResult> { + // The filesystem always shells via `sh -c <script>`. + const script = command === 'sh' && args?.[0] === '-c' ? args[1]! : [command, ...(args ?? [])].join(' '); + this.calls.push(script); + return this.responder(script); + } +} + +const WORKDIR = '/workspace/repo'; + +function makeFs(responder?: (script: string) => SandboxCommandResult) { + const sandbox = new FakeSandbox(responder); + const fs = new SandboxFilesystem({ sandbox, workdir: WORKDIR }); + return { sandbox, fs }; +} + +describe('SandboxFilesystem', () => { + it('reads a file via base64 and decodes it', async () => { + const content = 'hello world'; + const b64 = Buffer.from(content, 'utf8').toString('base64'); + const { sandbox, fs } = makeFs(script => { + // The realpath containment check runs first; resolve it inside the workdir. + if (script.startsWith('readlink')) return { exitCode: 0, stdout: `${WORKDIR}/src/index.ts`, stderr: '' }; + return { exitCode: 0, stdout: b64, stderr: '' }; + }); + + const result = await fs.readFile('/src/index.ts', { encoding: 'utf8' }); + + expect(result).toBe(content); + expect(sandbox.calls.some(c => c.includes(`base64 < '${WORKDIR}/src/index.ts'`))).toBe(true); + }); + + it('rejects a symlink whose realpath escapes the workspace root', async () => { + const { fs } = makeFs(script => { + if (script.startsWith('readlink')) return { exitCode: 0, stdout: '/etc/passwd', stderr: '' }; + return { exitCode: 0, stdout: '', stderr: '' }; + }); + + await expect(fs.readFile('/link')).rejects.toThrow(/escapes workspace root \(symlink\)/); + }); + + it('rejects a write whose parent directory is a symlink escaping the workspace', async () => { + // The leaf doesn't exist yet, but its parent `evil` resolves to /etc, so + // readlink -f on the parent returns an out-of-root path. + const { fs, sandbox } = makeFs(script => { + if (script.startsWith('readlink')) return { exitCode: 0, stdout: '/etc', stderr: '' }; + return { exitCode: 0, stdout: '', stderr: '' }; + }); + + await expect(fs.writeFile('/evil/passwd', 'x')).rejects.toThrow(/escapes workspace root \(symlink\)/); + // The write command must never have run. + expect(sandbox.calls.some(c => c.includes('base64 -d >'))).toBe(false); + }); + + it('allows a write when the parent realpath stays inside the workspace', async () => { + const { fs, sandbox } = makeFs(script => { + if (script.startsWith('readlink')) return { exitCode: 0, stdout: `${WORKDIR}/src`, stderr: '' }; + return { exitCode: 0, stdout: '', stderr: '' }; + }); + + await fs.writeFile('/src/new.ts', 'x'); + expect(sandbox.calls.some(c => c.includes('base64 -d >'))).toBe(true); + }); + + it('passes a command timeout to the sandbox', async () => { + const timeouts: Array<number | undefined> = []; + const sandbox: SandboxExec = { + id: 'fake', + async executeCommand(_cmd, _args, options) { + timeouts.push(options?.timeout); + return { exitCode: 0, stdout: '', stderr: '' }; + }, + }; + const fs = new SandboxFilesystem({ sandbox, workdir: WORKDIR }); + await fs.exists('/x'); + expect(timeouts[0]).toBe(30_000); + }); + + it('writes a file by piping base64 into the resolved path', async () => { + const { sandbox, fs } = makeFs(); + + await fs.writeFile('/notes.txt', 'data'); + + const b64 = Buffer.from('data', 'utf8').toString('base64'); + const writeCall = sandbox.calls.find(c => c.includes('base64 -d >')); + expect(writeCall).toContain(`mkdir -p '${WORKDIR}'`); + expect(writeCall).toContain(`printf %s '${b64}' | base64 -d > '${WORKDIR}/notes.txt'`); + }); + + it('lists a directory and parses type/name pairs', async () => { + const { sandbox, fs } = makeFs(script => { + if (script.startsWith('readlink')) return { exitCode: 0, stdout: WORKDIR, stderr: '' }; + return { exitCode: 0, stdout: 'd\tsrc\nf\tREADME.md\n', stderr: '' }; + }); + + const entries = await fs.readdir('/'); + + expect(entries).toEqual([ + { name: 'src', type: 'directory' }, + { name: 'README.md', type: 'file' }, + ]); + expect(sandbox.calls.some(c => c.includes(`cd '${WORKDIR}'`))).toBe(true); + }); + + it('stats a file and returns parsed metadata', async () => { + const { fs } = makeFs(script => { + if (script.startsWith('readlink')) return { exitCode: 0, stdout: `${WORKDIR}/a.txt`, stderr: '' }; + return { exitCode: 0, stdout: 'regular file\t42\t1700000000\t-1\n', stderr: '' }; + }); + + const stat = await fs.stat('/a.txt'); + + expect(stat.type).toBe('file'); + expect(stat.size).toBe(42); + expect(stat.name).toBe('a.txt'); + expect(stat.path).toBe('/a.txt'); + }); + + it('removes a file via rm', async () => { + const { sandbox, fs } = makeFs(); + await fs.deleteFile('/old.txt', { force: true }); + expect(sandbox.calls[0]).toContain(`rm -f '${WORKDIR}/old.txt'`); + }); + + it('reports existence from the exit code', async () => { + const { fs: existsFs } = makeFs(() => ({ exitCode: 0, stdout: '', stderr: '' })); + const { fs: missingFs } = makeFs(() => ({ exitCode: 1, stdout: '', stderr: '' })); + await expect(existsFs.exists('/x')).resolves.toBe(true); + await expect(missingFs.exists('/x')).resolves.toBe(false); + }); + + it('rejects paths that escape the workspace root', async () => { + const { fs } = makeFs(); + await expect(fs.readFile('/../../etc/passwd')).rejects.toThrow(/escapes workspace root/); + await expect(fs.writeFile('/../secret', 'x')).rejects.toThrow(/escapes workspace root/); + }); + + it('exposes basePath and a sandbox-derived id', () => { + const { fs } = makeFs(); + expect(fs.basePath).toBe(WORKDIR); + expect(fs.id).toBe('sandbox-fs:fake-sandbox'); + expect(fs.getInfo().metadata).toMatchObject({ basePath: WORKDIR, sandboxId: 'fake-sandbox' }); + }); +}); diff --git a/mastracode/src/web/github/sandbox-filesystem.ts b/mastracode/src/web/github/sandbox-filesystem.ts new file mode 100644 index 000000000000..31977498031e --- /dev/null +++ b/mastracode/src/web/github/sandbox-filesystem.ts @@ -0,0 +1,362 @@ +/** + * SandboxFilesystem + * + * A `WorkspaceFilesystem` that stores files inside a remote `MastraSandbox` + * (e.g. a Railway VM) rather than on the server host. File operations are + * implemented by shelling out through the sandbox's `executeCommand`, so the + * agent's file tools and command tools share one VM and one view of the repo. + * + * Paths are workspace-relative (`/src/foo.ts`) and resolve under the sandbox + * working directory (`basePath`). A traversal guard rejects any path that + * escapes the workdir, mirroring `LocalFilesystem`'s contained mode. + * + * Reads/writes use base64 over the wire so binary content survives the shell. + */ + +import { posix as posixPath } from 'node:path'; +import type { + CopyOptions, + FileContent, + FileEntry, + FileStat, + FilesystemInfo, + ListOptions, + ProviderStatus, + ReadOptions, + RemoveOptions, + WorkspaceFilesystem, + WriteOptions, +} from '@mastra/core/workspace'; + +/** Minimal command result shape we depend on. */ +export interface SandboxCommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +/** Minimal sandbox surface the filesystem needs. */ +export interface SandboxExec { + readonly id: string; + executeCommand(command: string, args?: string[], options?: { timeout?: number }): Promise<SandboxCommandResult>; +} + +export interface SandboxFilesystemOptions { + /** Live sandbox to run commands in. */ + sandbox: SandboxExec; + /** Absolute path inside the sandbox that is the workspace root. */ + workdir: string; + /** Optional stable id; defaults to a sandbox-derived id. */ + id?: string; +} + +/** Default per-command deadline so a hung sandbox can't block file tools forever. */ +const COMMAND_TIMEOUT_MS = 30_000; + +/** Single-quote a string for safe POSIX shell interpolation. */ +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function isFileContentString(content: FileContent): content is string { + return typeof content === 'string'; +} + +function toBuffer(content: FileContent): Buffer { + if (isFileContentString(content)) return Buffer.from(content, 'utf8'); + return Buffer.from(content); +} + +export class SandboxFilesystem implements WorkspaceFilesystem { + readonly id: string; + readonly name = 'SandboxFilesystem'; + readonly provider = 'sandbox'; + readonly basePath: string; + status: ProviderStatus = 'ready'; + + private readonly sandbox: SandboxExec; + + constructor(options: SandboxFilesystemOptions) { + this.sandbox = options.sandbox; + this.basePath = options.workdir; + this.id = options.id ?? `sandbox-fs:${options.sandbox.id}`; + } + + // ── Path handling ────────────────────────────────────────────────────── + + /** + * Resolve a workspace path to an absolute path inside the sandbox, enforcing + * that it stays within the workdir. + */ + private resolve(inputPath: string): string { + const rel = inputPath.startsWith('/') ? inputPath.slice(1) : inputPath; + const resolved = posixPath.normalize(posixPath.join(this.basePath, rel)); + const root = posixPath.normalize(this.basePath); + if (resolved !== root && !resolved.startsWith(`${root}/`)) { + throw new Error(`Path escapes workspace root: ${inputPath}`); + } + return resolved; + } + + resolveAbsolutePath(inputPath: string): string | undefined { + return this.resolve(inputPath); + } + + // ── Command helper ───────────────────────────────────────────────────── + + private async exec(script: string): Promise<SandboxCommandResult> { + return this.sandbox.executeCommand('sh', ['-c', script], { timeout: COMMAND_TIMEOUT_MS }); + } + + /** + * Lexical guard catches `..` traversal, but a symlink inside the workdir can + * still point outside it. After resolving a path that refers to an existing + * entry, verify its realpath is still contained in the workdir. + */ + private async assertContainedRealpath(abs: string, inputPath: string): Promise<void> { + const result = await this.exec(`readlink -f -- ${shellQuote(abs)} 2>/dev/null`); + const real = result.stdout.trim(); + // If readlink couldn't resolve (path doesn't exist yet), nothing to check. + if (result.exitCode !== 0 || !real) return; + const root = posixPath.normalize(this.basePath); + if (real !== root && !real.startsWith(`${root}/`)) { + throw new Error(`Path escapes workspace root (symlink): ${inputPath}`); + } + } + + /** + * Guard for write destinations. The lexical guard catches `..`, but a symlink + * inside the workdir can redirect a write outside it. For an existing target + * we check its realpath; for a not-yet-existing target we check the realpath + * of its nearest existing ancestor directory, since a symlinked parent is the + * escape vector (e.g. `link -> /etc` then writing `link/passwd`). + */ + private async assertContainedDest(abs: string, inputPath: string): Promise<void> { + // First check the target itself (covers overwriting an existing symlink). + await this.assertContainedRealpath(abs, inputPath); + // Then check the parent directory's realpath; readlink -f resolves the + // nearest existing ancestor when the leaf doesn't exist yet. + const parent = posixPath.dirname(abs); + if (parent && parent !== abs) { + await this.assertContainedRealpath(parent, inputPath); + } + } + + private async execOk(script: string, context: string): Promise<SandboxCommandResult> { + const result = await this.exec(script); + if (result.exitCode !== 0) { + throw new Error(`${context} failed (exit ${result.exitCode}): ${result.stderr.trim() || result.stdout.trim()}`); + } + return result; + } + + // ── File operations ──────────────────────────────────────────────────── + + async readFile(path: string, options?: ReadOptions): Promise<string | Buffer> { + const abs = this.resolve(path); + await this.assertContainedRealpath(abs, path); + const result = await this.exec(`base64 < ${shellQuote(abs)}`); + if (result.exitCode !== 0) { + throw new Error(`File not found: ${path}`); + } + const buffer = Buffer.from(result.stdout.replace(/\s/g, ''), 'base64'); + if (options?.encoding) { + return buffer.toString(options.encoding); + } + return buffer; + } + + async writeFile(path: string, content: FileContent, options?: WriteOptions): Promise<void> { + const abs = this.resolve(path); + await this.assertContainedDest(abs, path); + const b64 = toBuffer(content).toString('base64'); + const dir = posixPath.dirname(abs); + const mkdir = options?.recursive === false ? '' : `mkdir -p ${shellQuote(dir)} && `; + if (options?.overwrite === false) { + const exists = await this.exists(path); + if (exists) throw new Error(`File already exists: ${path}`); + } + await this.execOk(`${mkdir}printf %s ${shellQuote(b64)} | base64 -d > ${shellQuote(abs)}`, `writeFile ${path}`); + } + + async appendFile(path: string, content: FileContent): Promise<void> { + const abs = this.resolve(path); + await this.assertContainedDest(abs, path); + const b64 = toBuffer(content).toString('base64'); + await this.execOk( + `mkdir -p ${shellQuote(posixPath.dirname(abs))} && printf %s ${shellQuote(b64)} | base64 -d >> ${shellQuote(abs)}`, + `appendFile ${path}`, + ); + } + + async deleteFile(path: string, options?: RemoveOptions): Promise<void> { + const abs = this.resolve(path); + const force = options?.force ? '-f ' : ''; + const result = await this.exec(`rm ${force}${shellQuote(abs)}`); + if (result.exitCode !== 0 && !options?.force) { + throw new Error(`File not found: ${path}`); + } + } + + async copyFile(src: string, dest: string, options?: CopyOptions): Promise<void> { + const srcAbs = this.resolve(src); + const destAbs = this.resolve(dest); + await this.assertContainedRealpath(srcAbs, src); + await this.assertContainedDest(destAbs, dest); + const recursive = options?.recursive ? '-r ' : ''; + if (options?.overwrite === false) { + const exists = await this.exists(dest); + if (exists) throw new Error(`Destination exists: ${dest}`); + } + await this.execOk(`cp ${recursive}${shellQuote(srcAbs)} ${shellQuote(destAbs)}`, `copyFile ${src} -> ${dest}`); + } + + async moveFile(src: string, dest: string, options?: CopyOptions): Promise<void> { + const srcAbs = this.resolve(src); + const destAbs = this.resolve(dest); + await this.assertContainedRealpath(srcAbs, src); + await this.assertContainedDest(destAbs, dest); + if (options?.overwrite === false) { + const exists = await this.exists(dest); + if (exists) throw new Error(`Destination exists: ${dest}`); + } + await this.execOk(`mv ${shellQuote(srcAbs)} ${shellQuote(destAbs)}`, `moveFile ${src} -> ${dest}`); + } + + // ── Directory operations ─────────────────────────────────────────────── + + async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> { + const abs = this.resolve(path); + await this.assertContainedDest(abs, path); + const flag = options?.recursive === false ? '' : '-p '; + await this.execOk(`mkdir ${flag}${shellQuote(abs)}`, `mkdir ${path}`); + } + + async rmdir(path: string, options?: RemoveOptions): Promise<void> { + const abs = this.resolve(path); + if (options?.recursive) { + const force = options?.force ? '-f ' : ''; + await this.execOk(`rm -r ${force}${shellQuote(abs)}`, `rmdir ${path}`); + return; + } + const result = await this.exec(`rmdir ${shellQuote(abs)}`); + if (result.exitCode !== 0 && !options?.force) { + throw new Error(`Directory not empty or not found: ${path}`); + } + } + + async readdir(path: string, options?: ListOptions): Promise<FileEntry[]> { + const abs = this.resolve(path); + await this.assertContainedRealpath(abs, path); + if (options?.recursive) { + // Use find for recursive listings; emit "type\tpath". + const result = await this.exec( + `find ${shellQuote(abs)} -mindepth 1 ${options.maxDepth ? `-maxdepth ${Number(options.maxDepth)} ` : ''}-printf '%y\\t%p\\n' 2>/dev/null`, + ); + if (result.exitCode !== 0) throw new Error(`Directory not found: ${path}`); + return this.parseFindOutput(result.stdout, abs, options); + } + // Non-recursive: list with name + type via a portable loop. + const result = await this.exec( + `cd ${shellQuote(abs)} 2>/dev/null && for f in * .[!.]*; do [ -e "$f" ] || continue; if [ -d "$f" ]; then echo "d\t$f"; else echo "f\t$f"; fi; done`, + ); + if (result.exitCode !== 0) throw new Error(`Directory not found: ${path}`); + return this.parseListOutput(result.stdout, options); + } + + private parseListOutput(stdout: string, options?: ListOptions): FileEntry[] { + const entries: FileEntry[] = []; + for (const line of stdout.split('\n')) { + if (!line) continue; + const tab = line.indexOf('\t'); + if (tab < 0) continue; + const type = line.slice(0, tab) === 'd' ? 'directory' : 'file'; + const name = line.slice(tab + 1); + if (!name || name === '.' || name === '..') continue; + if (type === 'file' && !this.matchesExtension(name, options?.extension)) continue; + entries.push({ name, type }); + } + return entries; + } + + private parseFindOutput(stdout: string, base: string, options?: ListOptions): FileEntry[] { + const entries: FileEntry[] = []; + for (const line of stdout.split('\n')) { + if (!line) continue; + const tab = line.indexOf('\t'); + if (tab < 0) continue; + const type = line.slice(0, tab) === 'd' ? 'directory' : 'file'; + const fullPath = line.slice(tab + 1); + const name = posixPath.relative(base, fullPath); + if (!name) continue; + if (type === 'file' && !this.matchesExtension(name, options?.extension)) continue; + entries.push({ name, type }); + } + return entries; + } + + private matchesExtension(name: string, extension?: string | string[]): boolean { + if (!extension) return true; + const exts = Array.isArray(extension) ? extension : [extension]; + return exts.some(ext => name.endsWith(ext)); + } + + // ── Path / metadata ──────────────────────────────────────────────────── + + async exists(path: string): Promise<boolean> { + const abs = this.resolve(path); + const result = await this.exec(`test -e ${shellQuote(abs)}`); + return result.exitCode === 0; + } + + async stat(path: string): Promise<FileStat> { + const abs = this.resolve(path); + await this.assertContainedRealpath(abs, path); + // %F=type, %s=size, %X=atime, %Y=mtime (epoch seconds), %W=birth (or -1). + const result = await this.exec(`stat -c '%F\\t%s\\t%Y\\t%W' ${shellQuote(abs)}`); + if (result.exitCode !== 0) { + throw new Error(`Path not found: ${path}`); + } + const [kind, sizeStr, mtimeStr, ctimeStr] = result.stdout.trim().split('\t'); + const type = kind && kind.includes('directory') ? 'directory' : 'file'; + const size = Number(sizeStr) || 0; + const mtime = Number(mtimeStr) || 0; + const ctime = Number(ctimeStr); + return { + name: posixPath.basename(abs), + path: `/${posixPath.relative(this.basePath, abs)}`, + type, + size: type === 'directory' ? 0 : size, + modifiedAt: new Date(mtime * 1000), + createdAt: new Date((ctime > 0 ? ctime : mtime) * 1000), + }; + } + + // ── Lifecycle ────────────────────────────────────────────────────────── + + async init(): Promise<void> { + await this.execOk(`mkdir -p ${shellQuote(this.basePath)}`, 'init workdir'); + } + + async destroy(): Promise<void> { + // The sandbox lifecycle is owned by the caller; nothing to tear down here. + } + + async isReady(): Promise<boolean> { + const result = await this.exec(`test -d ${shellQuote(this.basePath)}`); + return result.exitCode === 0; + } + + getInfo(): FilesystemInfo { + return { + id: this.id, + name: this.name, + provider: this.provider, + metadata: { basePath: this.basePath, sandboxId: this.sandbox.id }, + }; + } + + getInstructions(): string { + return `Files are stored in a remote sandbox at ${this.basePath}. Use absolute workspace paths like /src/index.ts. All reads, writes and commands run inside the same sandbox.`; + } +} diff --git a/mastracode/src/web/github/sandbox-fleet-scenario.test.ts b/mastracode/src/web/github/sandbox-fleet-scenario.test.ts new file mode 100644 index 000000000000..42faebfcabe8 --- /dev/null +++ b/mastracode/src/web/github/sandbox-fleet-scenario.test.ts @@ -0,0 +1,307 @@ +import { Hono } from 'hono'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Phase 7 sandbox-fleet scenario tests ───────────────────────────────── +// These prove the lightweight per-replica sandbox budget and the per-user +// teardown path end to end: +// 1. Cap enforcement + recovery: with a cap of 1, a second fresh provision is +// rejected, then succeeds once the first is torn down (counter decremented). +// 2. Teardown clears the per-(project,user) binding and decrements the live +// counter, so the next open re-provisions a fresh sandbox. +// 3. Cross-user teardown is structurally impossible: a DELETE only ever +// resolves the caller's own `(project, user)` binding, so one user can never +// tear down another user's sandbox. +// +// Parts 1 & 2 drive the real `ensureProjectSandbox` / `teardownProjectSandbox` +// helpers; part 3 drives the real DELETE route. All share one in-memory fake DB. + +vi.mock('drizzle-orm', () => ({ + eq: (column: any, value: any) => ({ kind: 'eq', column: column?.name, value }), + and: (...conds: any[]) => ({ kind: 'and', conds: conds.filter(Boolean) }), +})); + +// Enable the GitHub feature so the project git routes (incl. DELETE) mount. +vi.mock('./config', () => ({ + isGithubFeatureEnabled: () => true, + signState: (orgId: string, userId: string) => `state.${orgId}.${userId}`, + verifyState: (state: string | undefined) => { + if (!state?.startsWith('state.')) return null; + const [orgId, userId] = state.slice('state.'.length).split('.'); + if (!orgId || !userId) return null; + return { orgId, userId }; + }, +})); + +// ── Shared in-memory DB shaped like routes.test.ts ──────────────────────── +interface Tables { + projects: Array<Record<string, any>>; + sandboxes: Array<Record<string, any>>; +} +const tables: Tables = { projects: [], sandboxes: [] }; + +function dbNameToJsKey(name: string): string { + return name.replace(/_([a-z])/g, (_m, c) => c.toUpperCase()); +} + +function matches(row: Record<string, any>, cond: any): boolean { + if (!cond) return true; + if (cond.kind === 'and') return cond.conds.every((c: any) => matches(row, c)); + if (cond.kind === 'eq') return row[dbNameToJsKey(cond.column)] === cond.value; + return true; +} + +let sandboxesRef: any; +function tableKind(table: any): keyof Tables { + return table === sandboxesRef ? 'sandboxes' : 'projects'; +} + +vi.mock('./db', () => ({ + getAppDb: () => ({ + select: () => ({ + from: (table: any) => ({ + where: async (cond: any) => tables[tableKind(table)].filter(r => matches(r, cond)), + }), + }), + insert: (table: any) => ({ + values: (vals: any) => { + const kind = tableKind(table); + const push = () => { + const row = { id: `gen-${kind}-${tables[kind].length}`, ...vals }; + tables[kind].push(row); + return row; + }; + const chain: any = { + onConflictDoNothing: (opts?: any) => { + // Honor the conflict target: if a row already matches on the target + // columns, insert nothing (mirrors Postgres ON CONFLICT DO NOTHING). + const targets: string[] = (opts?.target ?? []) + .map((col: any) => (col?.name ? dbNameToJsKey(col.name) : undefined)) + .filter(Boolean); + const existing = targets.length ? tables[kind].find(r => targets.every(t => r[t] === vals[t])) : undefined; + const row = existing ? undefined : push(); + const result = row ? [row] : []; + const p: any = Promise.resolve(result); + p.returning = async () => result; + return p; + }, + returning: async () => [push()], + }; + return chain; + }, + }), + update: (table: any) => ({ + set: (vals: any) => ({ + where: async (cond: any) => { + for (const r of tables[tableKind(table)]) { + if (matches(r, cond)) Object.assign(r, vals); + } + }, + }), + }), + }), +})); + +import type * as RoutesModule from './routes'; +import { + __resetLiveSandboxCount, + ensureProjectSandbox, + getLiveSandboxCount, + resetSandboxFactory, + SandboxBudgetError, + setSandboxFactory, + teardownProjectSandbox, +} from './sandbox'; +import type { MaterializationSandbox } from './sandbox'; +import { githubProjectSandboxes } from './schema'; +import type { GithubProjectSandboxRow } from './schema'; + +sandboxesRef = githubProjectSandboxes; + +/** Minimal fake sandbox VM that records lifecycle calls. */ +class FakeSandbox implements MaterializationSandbox { + readonly id: string; + startCount = 0; + stopCount = 0; + constructor(id: string) { + this.id = id; + } + async start(): Promise<void> { + this.startCount += 1; + } + async stop(): Promise<void> { + this.stopCount += 1; + } + async getInfo() { + return { metadata: { railwaySandboxId: `vm-${this.id}` } }; + } + async executeCommand() { + return { exitCode: 0, stdout: '', stderr: '' }; + } +} + +function makeBindingRow(id: string): GithubProjectSandboxRow { + const row = { + id, + githubProjectId: `proj-${id}`, + userId: 'u1', + sandboxId: null, + sandboxWorkdir: '/workspace/hello', + materializedAt: null, + createdAt: new Date(), + } satisfies GithubProjectSandboxRow; + tables.sandboxes.push(row as unknown as Record<string, any>); + return row; +} + +afterEach(() => { + resetSandboxFactory(); + __resetLiveSandboxCount(0); + tables.projects = []; + tables.sandboxes = []; + delete process.env.MASTRACODE_MAX_SANDBOXES; + vi.restoreAllMocks(); +}); + +describe('S7 — sandbox fleet budget', () => { + it('cap=1: a second fresh provision is rejected, then succeeds after teardown frees a slot', async () => { + process.env.MASTRACODE_MAX_SANDBOXES = '1'; + let made = 0; + setSandboxFactory(({ providerSandboxId }) => new FakeSandbox(providerSandboxId ?? `fresh-${++made}`)); + + const rowA = makeBindingRow('a'); + const rowB = makeBindingRow('b'); + + // First fresh provision succeeds and consumes the single slot. + const sandboxA = (await ensureProjectSandbox(rowA)) as FakeSandbox; + expect(sandboxA.startCount).toBe(1); + expect(getLiveSandboxCount()).toBe(1); + expect(rowA.sandboxId).toBe('vm-fresh-1'); + + // Second fresh provision is over budget → rejected before spending quota. + const err = await ensureProjectSandbox(rowB).catch(e => e); + expect(err).toBeInstanceOf(SandboxBudgetError); + expect(err.max).toBe(1); + expect(getLiveSandboxCount()).toBe(1); + expect(rowB.sandboxId).toBeNull(); + + // Tear down A → frees the slot. + await teardownProjectSandbox(rowA, sandboxA); + expect(sandboxA.stopCount).toBe(1); + expect(getLiveSandboxCount()).toBe(0); + expect(rowA.sandboxId).toBeNull(); + + // Now B provisions successfully. + const sandboxB = (await ensureProjectSandbox(rowB)) as FakeSandbox; + expect(sandboxB.startCount).toBe(1); + expect(getLiveSandboxCount()).toBe(1); + expect(rowB.sandboxId).toBe('vm-fresh-2'); + }); + + it('teardown clears the per-(project,user) binding and the next open re-provisions fresh', async () => { + let made = 0; + setSandboxFactory(({ providerSandboxId }) => new FakeSandbox(providerSandboxId ?? `fresh-${++made}`)); + + const row = makeBindingRow('a'); + + const first = (await ensureProjectSandbox(row)) as FakeSandbox; + expect(getLiveSandboxCount()).toBe(1); + expect(row.sandboxId).toBe('vm-fresh-1'); + + // Simulate a materialized binding so teardown clears that too. + (row as { materializedAt: Date | null }).materializedAt = new Date(); + + await teardownProjectSandbox(row, first); + expect(first.stopCount).toBe(1); + expect(getLiveSandboxCount()).toBe(0); + expect(row.sandboxId).toBeNull(); + expect(row.materializedAt).toBeNull(); + + // Next open re-provisions a brand new sandbox (fresh provider id). + const second = (await ensureProjectSandbox(row)) as FakeSandbox; + expect(second).not.toBe(first); + expect(second.startCount).toBe(1); + expect(getLiveSandboxCount()).toBe(1); + expect(row.sandboxId).toBe('vm-fresh-2'); + }); + + it('teardown of a never-provisioned binding is a no-op that does not underflow the counter', async () => { + const row = makeBindingRow('a'); // sandboxId stays null + await teardownProjectSandbox(row); + expect(getLiveSandboxCount()).toBe(0); + expect(row.sandboxId).toBeNull(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────── +// Part 3: route-level cross-user teardown isolation. The DELETE handler always +// resolves the caller's own `(project, user)` binding, so user 2's teardown can +// never touch user 1's sandbox. + +describe('S7 — cross-user teardown isolation (route level)', () => { + let mountGithubRoutes: (typeof RoutesModule)['mountGithubRoutes']; + + beforeEach(async () => { + tables.projects = [ + { id: 'p1', orgId: 'org1', installationId: 7, repoFullName: 'octo/hello', sandboxWorkdir: '/workspace/hello' }, + ]; + // Only user 1 has a provisioned sandbox binding. + tables.sandboxes = [ + { id: 's1', githubProjectId: 'p1', userId: 'u1', sandboxId: 'vm-u1', sandboxWorkdir: '/workspace/hello' }, + ]; + process.env.MASTRACODE_DISTRIBUTED_LOCK = '0'; + process.env.MASTRACODE_SANDBOX_PROVIDER = 'railway'; + process.env.RAILWAY_API_TOKEN = 'test-token'; // makes isSandboxEnabled() true + + // Real teardown/reattach run; the factory yields a fake VM so reattach starts + // a recordable sandbox instead of hitting Railway. + setSandboxFactory(({ providerSandboxId }) => new FakeSandbox(providerSandboxId ?? 'fresh')); + + ({ mountGithubRoutes } = await import('./routes')); + }); + + afterEach(() => { + delete process.env.MASTRACODE_DISTRIBUTED_LOCK; + delete process.env.MASTRACODE_SANDBOX_PROVIDER; + delete process.env.RAILWAY_API_TOKEN; + }); + + function buildApp(workosId: string) { + const app = new Hono(); + app.use('*', async (c, next) => { + (c as any).set('webAuthUser', { id: workosId, workosId, organizationId: 'org1', name: 'Test', email: 't@e.co' }); + await next(); + }); + mountGithubRoutes(app as any, {}); + return app; + } + + it("user 2's teardown never touches user 1's sandbox binding", async () => { + const app = buildApp('u2'); + const res = await app.request('/api/web/github/projects/p1/sandbox', { method: 'DELETE' }); + + // u2 has no provisioned binding → idempotent no-op success. + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ tornDown: false }); + + // u1's sandbox row is untouched. + const u1Row = tables.sandboxes.find(r => r.userId === 'u1'); + expect(u1Row?.sandboxId).toBe('vm-u1'); + // u2's own (freshly created) binding has no sandbox. + const u2Row = tables.sandboxes.find(r => r.userId === 'u2'); + expect(u2Row?.sandboxId ?? null).toBeNull(); + }); + + it('user 1 can tear down their own sandbox', async () => { + __resetLiveSandboxCount(1); // u1 has one live sandbox + const app = buildApp('u1'); + const res = await app.request('/api/web/github/projects/p1/sandbox', { method: 'DELETE' }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ tornDown: true }); + + // The caller's own binding is cleared and the counter is decremented. + const u1Row = tables.sandboxes.find(r => r.userId === 'u1'); + expect(u1Row?.sandboxId).toBeNull(); + expect(getLiveSandboxCount()).toBe(0); + }); +}); diff --git a/mastracode/src/web/github/sandbox-scenario.test.ts b/mastracode/src/web/github/sandbox-scenario.test.ts new file mode 100644 index 000000000000..5100e21eaf4b --- /dev/null +++ b/mastracode/src/web/github/sandbox-scenario.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// `pushBranch` writes a DB row only on success paths in some flows; the unit +// suite stubs `./db` the same way so the helpers can run without Postgres. +vi.mock('./db', () => ({ + getAppDb: () => ({ + update: () => ({ + set: () => ({ + where: async () => {}, + }), + }), + }), +})); + +import { createPullRequest, MaterializeError, pushBranch } from './sandbox'; +import type { MaterializationSandbox, SandboxCommandResult } from './sandbox'; + +type Responder = (script: string) => SandboxCommandResult; +const OK: SandboxCommandResult = { exitCode: 0, stdout: '', stderr: '' }; + +/** + * Records every shell script the helper runs so the scenario can assert the + * security invariant across the WHOLE operation, not a single command. + */ +class RecordingSandbox implements MaterializationSandbox { + readonly id = 'logical-id'; + readonly calls: string[] = []; + startCount = 0; + private responder: Responder; + + constructor(responder?: Responder) { + this.responder = responder ?? (() => OK); + } + + async start(): Promise<void> { + this.startCount += 1; + } + + async getInfo() { + return { metadata: { railwaySandboxId: 'railway-vm-123' } }; + } + + async executeCommand(command: string, args?: string[]): Promise<SandboxCommandResult> { + const script = command === 'sh' && args?.[0] === '-c' ? args[1]! : [command, ...(args ?? [])].join(' '); + this.calls.push(script); + return this.responder(script); + } +} + +const TOKEN = 'ghs_supersecrettoken1234567890'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('S4 — token leak negative scenarios on failure paths', () => { + it('pushBranch: a failed git push still scrubs the remote and never leaves the token behind', async () => { + // The push itself fails; the finally-scrub must still run. + const sandbox = new RecordingSandbox(script => + script.includes('push -u origin') ? { exitCode: 1, stdout: '', stderr: 'rejected: non-fast-forward' } : OK, + ); + + const err = await pushBranch(sandbox, '/workspace/hello', 'feat/x', TOKEN, 'octocat/hello').catch(e => e); + + expect(err).toBeInstanceOf(MaterializeError); + expect(err.code).toBe('push-failed'); + + // Invariant 1: the FINAL remote rewrite restores the clean (scrubbed) URL. + const remoteRewrites = sandbox.calls.filter(c => c.includes('remote set-url origin')); + expect(remoteRewrites.length).toBeGreaterThanOrEqual(2); + const finalRewrite = remoteRewrites.at(-1)!; + expect(finalRewrite).toContain('https://github.com/octocat/hello.git'); + expect(finalRewrite).not.toContain(TOKEN); + + // Invariant 2: the token only ever appears in the tokenized-remote rewrite, + // and NEVER survives into the last command recorded against the sandbox. + const lastCommand = sandbox.calls.at(-1)!; + expect(lastCommand).not.toContain(TOKEN); + + // Invariant 3: every command that carries the token is a tokenized + // `remote set-url` — the token is never smuggled into push/config/log. + const tokenCommands = sandbox.calls.filter(c => c.includes(TOKEN)); + expect(tokenCommands.length).toBeGreaterThan(0); + for (const c of tokenCommands) { + expect(c).toContain('remote set-url origin'); + expect(c).toContain(`https://x-access-token:${TOKEN}@github.com/octocat/hello.git`); + } + }); + + it('pushBranch: an egress failure during push is classified and still scrubs the token', async () => { + const sandbox = new RecordingSandbox(script => + script.includes('push -u origin') + ? { exitCode: 128, stdout: '', stderr: 'fatal: unable to access: Could not resolve host: github.com' } + : OK, + ); + + const err = await pushBranch(sandbox, '/workspace/hello', 'feat/x', TOKEN, 'octocat/hello').catch(e => e); + + expect(err).toBeInstanceOf(MaterializeError); + expect(err.code).toBe('egress-blocked'); + + // The scrub still runs after the egress failure. + const finalRewrite = sandbox.calls.filter(c => c.includes('remote set-url origin')).at(-1)!; + expect(finalRewrite).toContain('https://github.com/octocat/hello.git'); + expect(finalRewrite).not.toContain(TOKEN); + expect(sandbox.calls.at(-1)).not.toContain(TOKEN); + }); + + it('createPullRequest: a failed gh pr create keeps GH_TOKEN inside that single invocation only', async () => { + const sandbox = new RecordingSandbox(script => { + if (script === 'gh --version') return { exitCode: 0, stdout: 'gh version 2.0.0', stderr: '' }; + if (script.includes('gh pr create')) { + return { exitCode: 1, stdout: '', stderr: 'pull request already exists' }; + } + return OK; + }); + + const err = await createPullRequest(sandbox, '/workspace/worktrees/feat-x', { + token: TOKEN, + base: 'main', + head: 'feat/x', + title: 'Add feature', + body: 'body', + }).catch(e => e); + + expect(err).toBeInstanceOf(MaterializeError); + expect(err.code).toBe('pr-failed'); + + // The token may appear ONLY in the single failing `gh pr create` command, + // as an inline env prefix — never in the preflight or any other command. + const tokenCommands = sandbox.calls.filter(c => c.includes(TOKEN)); + expect(tokenCommands).toHaveLength(1); + expect(tokenCommands[0]).toContain(`GH_TOKEN='${TOKEN}' gh pr create`); + + // The preflight ran but carried no token. + const preflight = sandbox.calls.find(c => c === 'gh --version')!; + expect(preflight).not.toContain(TOKEN); + + // Token is never exported into the session or written to git config. + expect(sandbox.calls.some(c => c.includes('export GH_TOKEN'))).toBe(false); + expect(sandbox.calls.some(c => c.includes('git config') && c.includes(TOKEN))).toBe(false); + }); + + it('createPullRequest: an egress failure from gh is classified without leaking the token', async () => { + const sandbox = new RecordingSandbox(script => { + if (script === 'gh --version') return { exitCode: 0, stdout: 'gh version 2.0.0', stderr: '' }; + if (script.includes('gh pr create')) { + return { exitCode: 1, stdout: '', stderr: 'could not resolve host: github.com' }; + } + return OK; + }); + + const err = await createPullRequest(sandbox, '/workspace/hello', { + token: TOKEN, + base: 'main', + head: 'feat/x', + title: 't', + }).catch(e => e); + + expect(err).toBeInstanceOf(MaterializeError); + expect(err.code).toBe('egress-blocked'); + + const tokenCommands = sandbox.calls.filter(c => c.includes(TOKEN)); + expect(tokenCommands).toHaveLength(1); + expect(tokenCommands[0]).toContain('gh pr create'); + }); +}); diff --git a/mastracode/src/web/github/sandbox.test.ts b/mastracode/src/web/github/sandbox.test.ts new file mode 100644 index 000000000000..f65b71209619 --- /dev/null +++ b/mastracode/src/web/github/sandbox.test.ts @@ -0,0 +1,799 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Capture DB updates without a real Postgres. `getAppDb()` returns a chainable +// stub whose terminal `.where()` records the `set(...)` payload. +const dbUpdates: Array<Record<string, unknown>> = []; +vi.mock('./db', () => ({ + getAppDb: () => ({ + update: () => ({ + set: (values: Record<string, unknown>) => ({ + where: async () => { + dbUpdates.push(values); + }, + }), + }), + }), +})); + +import { + computeSandboxWorkdir, + computeWorktreePath, + configureGitIdentity, + createPullRequest, + ensureProjectSandbox, + ensureWorktree, + getSandboxIdleMinutes, + getSandboxProvider, + isSandboxEnabled, + isValidGitRef, + materializeRepo, + MaterializeError, + pushBranch, + resetSandboxFactory, + resolveGitIdentity, + safeBranchDir, + setSandboxFactory, + shellQuote, + withInstallToken, + WorktreeError, +} from './sandbox'; +import type { MaterializationSandbox, RepoMaterializeInfo, SandboxCommandResult } from './sandbox'; +import type { GithubProjectSandboxRow } from './schema'; + +type Responder = (script: string) => SandboxCommandResult; +const OK: SandboxCommandResult = { exitCode: 0, stdout: '', stderr: '' }; + +class FakeSandbox implements MaterializationSandbox { + readonly id = 'logical-id'; + readonly calls: string[] = []; + startCount = 0; + providerId = 'railway-vm-123'; + private responder: Responder; + + constructor(responder?: Responder) { + this.responder = responder ?? (() => OK); + } + + async start(): Promise<void> { + this.startCount += 1; + } + + async getInfo() { + return { metadata: { railwaySandboxId: this.providerId } }; + } + + async executeCommand(command: string, args?: string[]): Promise<SandboxCommandResult> { + const script = command === 'sh' && args?.[0] === '-c' ? args[1]! : [command, ...(args ?? [])].join(' '); + this.calls.push(script); + return this.responder(script); + } +} + +function makeRow(overrides: Partial<GithubProjectSandboxRow> = {}): GithubProjectSandboxRow { + return { + id: 'sbrow-1', + githubProjectId: 'proj-1', + userId: 'user-1', + sandboxId: null, + sandboxWorkdir: '/workspace/hello', + materializedAt: null, + createdAt: new Date(), + ...overrides, + }; +} + +function makeRepoInfo(overrides: Partial<RepoMaterializeInfo> = {}): RepoMaterializeInfo { + return { repoFullName: 'octocat/hello', defaultBranch: 'main', ...overrides }; +} + +beforeEach(() => { + dbUpdates.length = 0; +}); + +afterEach(() => { + resetSandboxFactory(); + delete process.env.RAILWAY_API_TOKEN; + delete process.env.MASTRACODE_SANDBOX_PROVIDER; + delete process.env.MASTRACODE_SANDBOX_WORKDIR; + delete process.env.MASTRACODE_SANDBOX_IDLE_MINUTES; +}); + +describe('getSandboxProvider', () => { + it('defaults to railway when a Railway token is set', () => { + process.env.RAILWAY_API_TOKEN = 'tok'; + expect(getSandboxProvider()).toBe('railway'); + }); + + it('falls back to local when no Railway token is set', () => { + expect(getSandboxProvider()).toBe('local'); + }); + + it('honors an explicit provider override', () => { + process.env.MASTRACODE_SANDBOX_PROVIDER = 'railway'; + expect(getSandboxProvider()).toBe('railway'); + }); +}); + +describe('isSandboxEnabled', () => { + it('is true for railway when a token is set', () => { + process.env.RAILWAY_API_TOKEN = 'tok'; + expect(isSandboxEnabled()).toBe(true); + }); + + it('is true without a token (auto-falls back to local)', () => { + expect(isSandboxEnabled()).toBe(true); + }); + + it('is false when railway is explicitly selected without a token', () => { + process.env.MASTRACODE_SANDBOX_PROVIDER = 'railway'; + expect(isSandboxEnabled()).toBe(false); + }); + + it('is false for an unknown provider', () => { + process.env.MASTRACODE_SANDBOX_PROVIDER = 'mystery'; + process.env.RAILWAY_API_TOKEN = 'tok'; + expect(isSandboxEnabled()).toBe(false); + }); + + it('is true for the local provider without any token', () => { + process.env.MASTRACODE_SANDBOX_PROVIDER = 'local'; + expect(isSandboxEnabled()).toBe(true); + }); +}); + +describe('computeSandboxWorkdir', () => { + it('defaults to /workspace/<repo> for the railway provider', () => { + process.env.RAILWAY_API_TOKEN = 'tok'; + expect(computeSandboxWorkdir('octocat/hello')).toBe('/workspace/hello'); + }); + + it('appends the repo name to a configured base', () => { + process.env.MASTRACODE_SANDBOX_WORKDIR = '/srv/checkouts'; + expect(computeSandboxWorkdir('octocat/hello')).toBe('/srv/checkouts/hello'); + }); + + it('does not double-append when the base already ends in the repo name', () => { + process.env.MASTRACODE_SANDBOX_WORKDIR = '/srv/hello'; + expect(computeSandboxWorkdir('octocat/hello')).toBe('/srv/hello'); + }); + + it('checks out under the local sandbox root for the local provider', () => { + process.env.MASTRACODE_SANDBOX_PROVIDER = 'local'; + process.env.MASTRACODE_LOCAL_SANDBOX_ROOT = '/tmp/mc-sandboxes'; + expect(computeSandboxWorkdir('octocat/hello')).toBe('/tmp/mc-sandboxes/hello'); + delete process.env.MASTRACODE_LOCAL_SANDBOX_ROOT; + }); +}); + +describe('ensureProjectSandbox', () => { + it('provisions a new sandbox and persists the provider id on first open', async () => { + const sandbox = new FakeSandbox(); + setSandboxFactory(() => sandbox); + + const result = await ensureProjectSandbox(makeRow({ sandboxId: null })); + + expect(result).toBe(sandbox); + expect(sandbox.startCount).toBe(1); + expect(dbUpdates).toEqual([{ sandboxId: 'railway-vm-123' }]); + }); + + it('reattaches to the stored sandbox id without re-persisting', async () => { + const sandbox = new FakeSandbox(); + let factoryArgs: { providerSandboxId?: string } | undefined; + setSandboxFactory(opts => { + factoryArgs = opts; + return sandbox; + }); + + await ensureProjectSandbox(makeRow({ sandboxId: 'railway-vm-existing' })); + + expect(factoryArgs?.providerSandboxId).toBe('railway-vm-existing'); + expect(dbUpdates).toEqual([]); + }); + + it('passes the idle timeout to the provider on provision', async () => { + process.env.MASTRACODE_SANDBOX_IDLE_MINUTES = '15'; + const sandbox = new FakeSandbox(); + let factoryArgs: { idleTimeoutMinutes?: number } | undefined; + setSandboxFactory(opts => { + factoryArgs = opts; + return sandbox; + }); + + await ensureProjectSandbox(makeRow({ sandboxId: null })); + + expect(factoryArgs?.idleTimeoutMinutes).toBe(15); + }); + + it('re-provisions and clears the stale id when reattach to a dead sandbox fails', async () => { + const dead = new FakeSandbox(); + dead.start = async () => { + throw new Error('sandbox not found'); + }; + const fresh = new FakeSandbox(); + fresh.providerId = 'railway-vm-new'; + + const provided: Array<string | undefined> = []; + setSandboxFactory(opts => { + provided.push(opts.providerSandboxId); + return opts.providerSandboxId ? dead : fresh; + }); + + const result = await ensureProjectSandbox(makeRow({ sandboxId: 'railway-vm-dead' })); + + // First call reattaches (dead), second provisions fresh. + expect(provided).toEqual(['railway-vm-dead', undefined]); + expect(result).toBe(fresh); + expect(fresh.startCount).toBe(1); + // The stale id is cleared, then the new provider id persisted. + expect(dbUpdates).toEqual([{ sandboxId: null }, { sandboxId: 'railway-vm-new' }]); + }); +}); + +describe('getSandboxIdleMinutes', () => { + afterEach(() => { + delete process.env.MASTRACODE_SANDBOX_IDLE_MINUTES; + }); + + it('defaults to 30 minutes when unset', () => { + expect(getSandboxIdleMinutes()).toBe(30); + }); + + it('reads a positive integer from the env', () => { + process.env.MASTRACODE_SANDBOX_IDLE_MINUTES = '45'; + expect(getSandboxIdleMinutes()).toBe(45); + }); + + it('falls back to 30 for non-positive or invalid values', () => { + process.env.MASTRACODE_SANDBOX_IDLE_MINUTES = '0'; + expect(getSandboxIdleMinutes()).toBe(30); + process.env.MASTRACODE_SANDBOX_IDLE_MINUTES = 'nope'; + expect(getSandboxIdleMinutes()).toBe(30); + }); +}); + +describe('materializeRepo', () => { + it('clones on first open, scrubs the token, and marks materialized', async () => { + const sandbox = new FakeSandbox(); + await materializeRepo(makeRow({ materializedAt: null }), makeRepoInfo(), sandbox, 'tok-123'); + + const joined = sandbox.calls.join('\n'); + expect(sandbox.calls[0]).toBe('git --version'); + expect(joined).toContain('git clone --depth=1 --single-branch --branch'); + expect(joined).toContain('https://x-access-token:tok-123@github.com/octocat/hello.git'); + // token scrubbed afterwards + expect(joined).toContain('remote set-url origin'); + expect(joined).toContain('https://github.com/octocat/hello.git'); + expect(sandbox.calls.some(c => c.includes('git pull'))).toBe(false); + expect(dbUpdates.at(-1)).toHaveProperty('materializedAt'); + }); + + it('pulls (not clones) on re-open', async () => { + const sandbox = new FakeSandbox(); + await materializeRepo(makeRow({ materializedAt: new Date() }), makeRepoInfo(), sandbox, 'tok-xyz'); + + const joined = sandbox.calls.join('\n'); + expect(joined).toContain('git -C '); + expect(joined).toContain('pull --ff-only'); + expect(sandbox.calls.some(c => c.includes('git clone'))).toBe(false); + expect(joined).toContain('https://x-access-token:tok-xyz@github.com/octocat/hello.git'); + }); + + it('throws git-missing when git is absent', async () => { + const sandbox = new FakeSandbox(script => + script === 'git --version' ? { exitCode: 127, stdout: '', stderr: 'not found' } : OK, + ); + await expect(materializeRepo(makeRow(), makeRepoInfo(), sandbox, 'tok')).rejects.toMatchObject({ + code: 'git-missing', + }); + }); + + it('surfaces an egress-blocked error when github.com is unreachable', async () => { + const sandbox = new FakeSandbox(script => { + if (script === 'git --version') return OK; + if (script.includes('git clone')) { + return { exitCode: 128, stdout: '', stderr: 'fatal: unable to access: Could not resolve host: github.com' }; + } + return OK; + }); + const err = await materializeRepo(makeRow(), makeRepoInfo(), sandbox, 'tok').catch(e => e); + expect(err).toBeInstanceOf(MaterializeError); + expect(err.code).toBe('egress-blocked'); + }); + + it('refuses to run git when the default branch is not git-ref-safe', async () => { + const sandbox = new FakeSandbox(); + const err = await materializeRepo( + makeRow(), + makeRepoInfo({ defaultBranch: "main'; rm -rf /; '" }), + sandbox, + 'tok', + ).catch(e => e); + expect(err).toBeInstanceOf(MaterializeError); + // No git command should have been executed for an invalid branch. + expect(sandbox.calls).toHaveLength(0); + }); + + it('refuses to run git when the repo full name is not owner/name shaped', async () => { + const sandbox = new FakeSandbox(); + const err = await materializeRepo(makeRow(), makeRepoInfo({ repoFullName: 'evil; whoami' }), sandbox, 'tok').catch( + e => e, + ); + expect(err).toBeInstanceOf(MaterializeError); + expect(sandbox.calls).toHaveLength(0); + }); + + it('scrubs the tokenized remote even when the pull fails on re-open', async () => { + const sandbox = new FakeSandbox(script => { + if (script === 'git --version') return OK; + if (script.includes('pull --ff-only')) { + return { exitCode: 1, stdout: '', stderr: 'fatal: not a fast-forward' }; + } + return OK; + }); + + const err = await materializeRepo( + makeRow({ materializedAt: new Date() }), + makeRepoInfo(), + sandbox, + 'tok-secret', + ).catch(e => e); + + // The pull failure is surfaced... + expect(err).toBeInstanceOf(MaterializeError); + expect(err.code).toBe('pull-failed'); + // ...but the token is still scrubbed back to the tokenless URL afterwards, + // and no tokenized remote is left as the final remote state. + const scrub = sandbox.calls.filter(c => c.includes('remote set-url origin')).at(-1); + expect(scrub).toContain('https://github.com/octocat/hello.git'); + expect(scrub).not.toContain('tok-secret'); + // The repo is not marked materialized when the pull failed. + expect(dbUpdates.some(u => 'materializedAt' in u)).toBe(false); + }); + + it('surfaces a scrub failure on the success path when the remote reset fails', async () => { + const sandbox = new FakeSandbox(script => { + if (script.includes('remote set-url origin') && script.includes('github.com/octocat/hello.git')) { + // The final tokenless scrub fails — the token may still be persisted. + return { exitCode: 1, stdout: '', stderr: 'error: could not write config' }; + } + return OK; + }); + + const err = await materializeRepo(makeRow({ materializedAt: new Date() }), makeRepoInfo(), sandbox, 'tok').catch( + e => e, + ); + expect(err).toBeInstanceOf(MaterializeError); + expect(err.code).toBe('pull-failed'); + expect(String(err.message)).toContain('scrub'); + }); +}); + +describe('isValidGitRef', () => { + it('accepts normal branch names', () => { + expect(isValidGitRef('main')).toBe(true); + expect(isValidGitRef('feat/cloud-agent')).toBe(true); + expect(isValidGitRef('release-1.2.3')).toBe(true); + }); + + it('rejects empty, oversized, and shell-unsafe values', () => { + expect(isValidGitRef('')).toBe(false); + expect(isValidGitRef('a'.repeat(256))).toBe(false); + expect(isValidGitRef("main'; rm -rf /; '")).toBe(false); + expect(isValidGitRef('has space')).toBe(false); + expect(isValidGitRef(123)).toBe(false); + }); + + it('rejects leading-dash refs that git could parse as options', () => { + expect(isValidGitRef('--mirror')).toBe(false); + expect(isValidGitRef('-D')).toBe(false); + }); +}); + +describe('shellQuote', () => { + it('wraps simple values in single quotes', () => { + expect(shellQuote('main')).toBe(`'main'`); + expect(shellQuote('feat/cloud-agent')).toBe(`'feat/cloud-agent'`); + }); + + it('escapes embedded single quotes with the canonical POSIX sequence', () => { + // A single quote must close the quoted string, emit an escaped quote, then + // reopen — the four-character sequence '\'' — so the value cannot terminate + // the quoted string early. + expect(shellQuote(`it's`)).toBe(`'it'\\''s'`); + }); + + it('neutralizes command-injection attempts', () => { + // Even if an unvalidated value (e.g. a commit message or PR body) reaches + // the shell, the injected command stays inside a quoted literal. + const malicious = `'; rm -rf / #`; + const quoted = shellQuote(malicious); + // The result is a single shell word: opening quote, escaped quotes around + // the payload, closing quote. No unescaped quote can break out. + expect(quoted.startsWith(`'`)).toBe(true); + expect(quoted.endsWith(`'`)).toBe(true); + expect(quoted).toBe(`''\\''; rm -rf / #'`); + }); +}); + +describe('resolveGitIdentity', () => { + it('uses provided name and email verbatim', () => { + expect(resolveGitIdentity({ name: 'Ada Lovelace', email: 'ada@example.com' })).toEqual({ + name: 'Ada Lovelace', + email: 'ada@example.com', + }); + }); + + it('derives a noreply identity from the login when name/email are absent', () => { + expect(resolveGitIdentity({ login: 'octocat' })).toEqual({ + name: 'octocat', + email: 'octocat@users.noreply.github.com', + }); + }); + + it('falls back to a stable default identity with no inputs', () => { + expect(resolveGitIdentity({})).toEqual({ + name: 'Mastra Code', + email: 'mastra-code@users.noreply.github.com', + }); + }); +}); + +describe('configureGitIdentity', () => { + it('configures user.name and user.email in the workdir, quoted', async () => { + const sandbox = new FakeSandbox(); + await configureGitIdentity(sandbox, '/workspace/hello', { name: 'Ada Lovelace', email: 'ada@example.com' }); + + const joined = sandbox.calls.join('\n'); + expect(joined).toContain("git -C '/workspace/hello' config user.name 'Ada Lovelace'"); + expect(joined).toContain("git -C '/workspace/hello' config user.email 'ada@example.com'"); + }); + + it('surfaces a commit-failed error when config fails', async () => { + const sandbox = new FakeSandbox(script => + script.includes('config user.name') ? { exitCode: 1, stdout: '', stderr: 'boom' } : OK, + ); + const err = await configureGitIdentity(sandbox, '/workspace/hello', { login: 'octocat' }).catch(e => e); + expect(err).toBeInstanceOf(MaterializeError); + expect(err.code).toBe('commit-failed'); + }); +}); + +describe('withInstallToken', () => { + it('rewrites origin to the tokenized URL, runs fn, then scrubs the token', async () => { + const sandbox = new FakeSandbox(); + const order: string[] = []; + + await withInstallToken(sandbox, '/workspace/hello', 'octocat/hello', 'tok-secret', async () => { + order.push('fn'); + }); + + const setUrlCalls = sandbox.calls.filter(c => c.includes('remote set-url origin')); + // First rewrite carries the token, the final scrub restores the clean URL. + expect(setUrlCalls[0]).toContain('https://x-access-token:tok-secret@github.com/octocat/hello.git'); + expect(setUrlCalls.at(-1)).toContain('https://github.com/octocat/hello.git'); + expect(setUrlCalls.at(-1)).not.toContain('tok-secret'); + // fn ran while the tokenized remote was set (between the two set-url calls). + expect(order).toEqual(['fn']); + }); + + it('scrubs the token even when fn throws', async () => { + const sandbox = new FakeSandbox(); + const err = await withInstallToken(sandbox, '/workspace/hello', 'octocat/hello', 'tok-secret', async () => { + throw new Error('push exploded'); + }).catch(e => e); + + expect(String(err.message)).toContain('push exploded'); + const scrub = sandbox.calls.filter(c => c.includes('remote set-url origin')).at(-1); + expect(scrub).toContain('https://github.com/octocat/hello.git'); + expect(scrub).not.toContain('tok-secret'); + }); + + it('rejects a malformed repo full name before touching the remote', async () => { + const sandbox = new FakeSandbox(); + const err = await withInstallToken(sandbox, '/workspace/hello', 'evil; whoami', 'tok', async () => undefined).catch( + e => e, + ); + expect(err).toBeInstanceOf(MaterializeError); + expect(err.code).toBe('push-failed'); + expect(sandbox.calls).toHaveLength(0); + }); +}); + +describe('pushBranch', () => { + it('pushes the branch with -u origin using a tokenized remote, then scrubs', async () => { + const sandbox = new FakeSandbox(); + await pushBranch(sandbox, '/workspace/hello', 'feat/cloud-agent', 'tok-secret', 'octocat/hello'); + + const joined = sandbox.calls.join('\n'); + expect(joined).toContain("git -C '/workspace/hello' push -u origin 'feat/cloud-agent'"); + // tokenized remote was used during the push... + expect(joined).toContain('https://x-access-token:tok-secret@github.com/octocat/hello.git'); + // ...and scrubbed back afterwards. + const scrub = sandbox.calls.filter(c => c.includes('remote set-url origin')).at(-1); + expect(scrub).toContain('https://github.com/octocat/hello.git'); + expect(scrub).not.toContain('tok-secret'); + }); + + it('rejects an unsafe branch name before running git', async () => { + const sandbox = new FakeSandbox(); + const err = await pushBranch(sandbox, '/workspace/hello', "x'; rm -rf /; '", 'tok', 'octocat/hello').catch(e => e); + expect(err).toBeInstanceOf(MaterializeError); + expect(err.code).toBe('push-failed'); + expect(sandbox.calls).toHaveLength(0); + }); + + it('scrubs the token even when the push itself fails', async () => { + const sandbox = new FakeSandbox(script => + script.includes('push -u origin') ? { exitCode: 1, stdout: '', stderr: 'rejected' } : OK, + ); + const err = await pushBranch(sandbox, '/workspace/hello', 'feat/x', 'tok-secret', 'octocat/hello').catch(e => e); + + expect(err).toBeInstanceOf(MaterializeError); + expect(err.code).toBe('push-failed'); + const scrub = sandbox.calls.filter(c => c.includes('remote set-url origin')).at(-1); + expect(scrub).toContain('https://github.com/octocat/hello.git'); + expect(scrub).not.toContain('tok-secret'); + }); + + it('classifies an egress failure during push', async () => { + const sandbox = new FakeSandbox(script => + script.includes('push -u origin') + ? { exitCode: 128, stdout: '', stderr: 'fatal: unable to access: Could not resolve host: github.com' } + : OK, + ); + const err = await pushBranch(sandbox, '/workspace/hello', 'feat/x', 'tok', 'octocat/hello').catch(e => e); + expect(err).toBeInstanceOf(MaterializeError); + expect(err.code).toBe('egress-blocked'); + }); +}); + +describe('safeBranchDir', () => { + it('leaves already-safe names untouched', () => { + expect(safeBranchDir('main')).toBe('main'); + expect(safeBranchDir('release-1.2.3')).toBe('release-1.2.3'); + }); + + it('collapses slashes and unsafe chars and appends a hash to stay unique', () => { + expect(safeBranchDir('feat/cloud-agent')).toBe('feat-cloud-agent-53bf6e98'); + expect(safeBranchDir('release/1.2.3')).toBe('release-1.2.3-88ded651'); + }); + + it('never produces an empty segment', () => { + expect(safeBranchDir('///')).toBe('work-732c4e97'); + }); + + it('gives ambiguous branches distinct directories', () => { + // Without the hash suffix both of these would collapse to `feat-a`. + expect(safeBranchDir('feat/a')).not.toBe(safeBranchDir('feat-a')); + }); +}); + +describe('computeWorktreePath', () => { + it('places worktrees in a sibling worktrees/ dir of the repo checkout', () => { + expect(computeWorktreePath('/workspace/hello', 'feat/x')).toBe('/workspace/worktrees/feat-x-79b4cc55'); + }); + + it('tolerates a trailing slash on the repo workdir', () => { + expect(computeWorktreePath('/workspace/hello/', 'main')).toBe('/workspace/worktrees/main'); + }); +}); + +describe('ensureWorktree', () => { + // The default FakeSandbox responder returns OK for everything, which would + // make `test -e <path>/.git` look like the worktree already exists. Use a + // responder that fails the existence check so the create path runs. + const notExisting = (script: string): SandboxCommandResult => + script.startsWith('test -e') ? { exitCode: 1, stdout: '', stderr: '' } : OK; + + it('creates a branch + worktree from the base branch when none exists', async () => { + const sandbox = new FakeSandbox(notExisting); + const result = await ensureWorktree(sandbox, '/workspace/hello', { branch: 'feat/x', baseBranch: 'main' }); + + expect(result).toEqual({ + worktreePath: '/workspace/worktrees/feat-x-79b4cc55', + branch: 'feat/x', + baseBranch: 'main', + reused: false, + }); + const joined = sandbox.calls.join('\n'); + expect(joined).toContain("git -C '/workspace/hello' fetch origin 'main'"); + expect(joined).toContain( + "git -C '/workspace/hello' worktree add -B 'feat/x' '/workspace/worktrees/feat-x-79b4cc55' 'main'", + ); + }); + + it('reuses an existing worktree without running git worktree add', async () => { + // Default responder => `test -e` returns OK => path exists => reuse. + const sandbox = new FakeSandbox(); + const result = await ensureWorktree(sandbox, '/workspace/hello', { branch: 'feat/x', baseBranch: 'main' }); + + expect(result.reused).toBe(true); + expect(result.worktreePath).toBe('/workspace/worktrees/feat-x-79b4cc55'); + expect(sandbox.calls.some(c => c.includes('worktree add'))).toBe(false); + }); + + it('rejects an unsafe branch name before touching the sandbox', async () => { + const sandbox = new FakeSandbox(notExisting); + const err = await ensureWorktree(sandbox, '/workspace/hello', { + branch: "x'; rm -rf /; '", + baseBranch: 'main', + }).catch(e => e); + expect(err).toBeInstanceOf(WorktreeError); + expect(err.code).toBe('invalid-branch'); + expect(sandbox.calls).toHaveLength(0); + }); + + it('rejects an unsafe base branch name', async () => { + const sandbox = new FakeSandbox(notExisting); + const err = await ensureWorktree(sandbox, '/workspace/hello', { + branch: 'feat/x', + baseBranch: 'bad branch', + }).catch(e => e); + expect(err).toBeInstanceOf(WorktreeError); + expect(err.code).toBe('invalid-branch'); + expect(sandbox.calls).toHaveLength(0); + }); + + it('surfaces a worktree-failed error when git worktree add fails', async () => { + const sandbox = new FakeSandbox(script => { + if (script.startsWith('test -e')) return { exitCode: 1, stdout: '', stderr: '' }; + if (script.includes('worktree add')) return { exitCode: 1, stdout: '', stderr: 'fatal: branch in use' }; + return OK; + }); + const err = await ensureWorktree(sandbox, '/workspace/hello', { branch: 'feat/x', baseBranch: 'main' }).catch( + e => e, + ); + expect(err).toBeInstanceOf(WorktreeError); + expect(err.code).toBe('worktree-failed'); + }); +}); + +describe('createPullRequest', () => { + const PR_URL = 'https://github.com/octocat/hello/pull/7'; + // gh prints the PR URL to stdout on success. + const ghOk = (script: string): SandboxCommandResult => { + if (script === 'gh --version') return { exitCode: 0, stdout: 'gh version 2.0.0', stderr: '' }; + if (script.includes('gh pr create')) return { exitCode: 0, stdout: `${PR_URL}\n`, stderr: '' }; + return OK; + }; + + it('opens a PR and parses the URL from gh stdout', async () => { + const sandbox = new FakeSandbox(ghOk); + const result = await createPullRequest(sandbox, '/workspace/worktrees/feat-x', { + token: 'tok-123', + base: 'main', + head: 'feat/x', + title: 'Add feature', + body: 'Some body', + }); + + expect(result).toEqual({ url: PR_URL }); + const ghCall = sandbox.calls.find(c => c.includes('gh pr create'))!; + expect(ghCall).toContain("cd '/workspace/worktrees/feat-x'"); + expect(ghCall).toContain("--base 'main'"); + expect(ghCall).toContain("--head 'feat/x'"); + expect(ghCall).toContain("--title 'Add feature'"); + expect(ghCall).toContain("--body 'Some body'"); + }); + + it('passes GH_TOKEN only inline to the gh process, never persisted', async () => { + const sandbox = new FakeSandbox(ghOk); + await createPullRequest(sandbox, '/workspace/hello', { + token: 'tok-secret', + base: 'main', + head: 'feat/x', + title: 't', + }); + + const ghCall = sandbox.calls.find(c => c.includes('gh pr create'))!; + // Token appears exactly once, as an inline env prefix on the gh command. + expect(ghCall).toContain("GH_TOKEN='tok-secret' gh pr create"); + // It is never written via git config or exported to the session. + expect(sandbox.calls.some(c => c.includes('export GH_TOKEN'))).toBe(false); + expect(sandbox.calls.some(c => c.includes('git config') && c.includes('tok-secret'))).toBe(false); + }); + + it('shell-quotes a malicious title so it cannot break out', async () => { + const sandbox = new FakeSandbox(ghOk); + await createPullRequest(sandbox, '/workspace/hello', { + token: 'tok', + base: 'main', + head: 'feat/x', + title: "evil'; rm -rf / #", + }); + const ghCall = sandbox.calls.find(c => c.includes('gh pr create'))!; + expect(ghCall).toContain(`--title 'evil'\\''; rm -rf / #'`); + }); + + it('defaults body to an empty string when omitted', async () => { + const sandbox = new FakeSandbox(ghOk); + await createPullRequest(sandbox, '/workspace/hello', { + token: 'tok', + base: 'main', + head: 'feat/x', + title: 't', + }); + const ghCall = sandbox.calls.find(c => c.includes('gh pr create'))!; + expect(ghCall).toContain("--body ''"); + }); + + it('surfaces an actionable gh-missing error when gh is not installed', async () => { + const sandbox = new FakeSandbox(script => + script === 'gh --version' ? { exitCode: 127, stdout: '', stderr: 'gh: not found' } : OK, + ); + const err = await createPullRequest(sandbox, '/workspace/hello', { + token: 'tok', + base: 'main', + head: 'feat/x', + title: 't', + }).catch(e => e); + expect(err).toBeInstanceOf(MaterializeError); + expect(err.code).toBe('gh-missing'); + // gh pr create must not run when the preflight fails. + expect(sandbox.calls.some(c => c.includes('gh pr create'))).toBe(false); + }); + + it('rejects an invalid base or head branch before touching the sandbox', async () => { + const sandbox = new FakeSandbox(ghOk); + const err = await createPullRequest(sandbox, '/workspace/hello', { + token: 'tok', + base: 'bad branch', + head: 'feat/x', + title: 't', + }).catch(e => e); + expect(err).toBeInstanceOf(MaterializeError); + expect(err.code).toBe('pr-failed'); + expect(sandbox.calls).toHaveLength(0); + }); + + it('classifies an egress failure from gh', async () => { + const sandbox = new FakeSandbox(script => { + if (script === 'gh --version') return { exitCode: 0, stdout: 'gh version 2.0.0', stderr: '' }; + if (script.includes('gh pr create')) + return { exitCode: 1, stdout: '', stderr: 'could not resolve host: github.com' }; + return OK; + }); + const err = await createPullRequest(sandbox, '/workspace/hello', { + token: 'tok', + base: 'main', + head: 'feat/x', + title: 't', + }).catch(e => e); + expect(err).toBeInstanceOf(MaterializeError); + expect(err.code).toBe('egress-blocked'); + }); + + it('surfaces a pr-failed error when gh exits non-zero for another reason', async () => { + const sandbox = new FakeSandbox(script => { + if (script === 'gh --version') return { exitCode: 0, stdout: 'gh version 2.0.0', stderr: '' }; + if (script.includes('gh pr create')) return { exitCode: 1, stdout: '', stderr: 'pull request already exists' }; + return OK; + }); + const err = await createPullRequest(sandbox, '/workspace/hello', { + token: 'tok', + base: 'main', + head: 'feat/x', + title: 't', + }).catch(e => e); + expect(err).toBeInstanceOf(MaterializeError); + expect(err.code).toBe('pr-failed'); + expect(err.message).toContain('pull request already exists'); + }); + + it('errors when gh succeeds but emits no PR URL', async () => { + const sandbox = new FakeSandbox(script => { + if (script === 'gh --version') return { exitCode: 0, stdout: 'gh version 2.0.0', stderr: '' }; + if (script.includes('gh pr create')) return { exitCode: 0, stdout: 'created\n', stderr: '' }; + return OK; + }); + const err = await createPullRequest(sandbox, '/workspace/hello', { + token: 'tok', + base: 'main', + head: 'feat/x', + title: 't', + }).catch(e => e); + expect(err).toBeInstanceOf(MaterializeError); + expect(err.code).toBe('pr-failed'); + }); +}); diff --git a/mastracode/src/web/github/sandbox.ts b/mastracode/src/web/github/sandbox.ts new file mode 100644 index 000000000000..b680b3588c2e --- /dev/null +++ b/mastracode/src/web/github/sandbox.ts @@ -0,0 +1,895 @@ +/** + * Sandbox provisioning + repo materialization for GitHub-backed projects. + * + * A GitHub repo is never cloned onto the server host. Instead each project gets + * its own isolated cloud sandbox (a `MastraSandbox`, e.g. a Railway VM) and the + * repo is cloned *inside* that sandbox. The agent's file tools and command tools + * then operate entirely against the remote checkout. + * + * - `ensureProjectSandbox(row)` provisions a new sandbox (persisting its provider + * id so re-opens reattach) or reattaches to the stored one. + * - `materializeRepo(row, token)` runs `git clone` (first open) or `git pull` + * (re-open) inside the sandbox, using a short-lived installation token that is + * scrubbed from the git remote afterwards so it never persists in the VM. + * + * The Railway sandbox is constructed behind a swappable factory so tests can + * inject a fake sandbox and other providers can be added later. + */ + +import { createHash } from 'node:crypto'; +import { RailwaySandbox } from '@mastra/railway'; +import { eq } from 'drizzle-orm'; +import { getAppDb } from './db'; +import { getLocalSandboxRoot, LocalSandbox } from './local-sandbox'; +import { githubProjectSandboxes } from './schema'; +import type { GithubProjectSandboxRow } from './schema'; + +/** Minimal command result shape we depend on. */ +export interface SandboxCommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +/** + * Minimal live-sandbox surface this module needs: an id, a way to start it, a + * way to learn the provider's reattach id, and command execution. + */ +export interface MaterializationSandbox { + readonly id: string; + start(): Promise<void>; + getInfo(): Promise<{ metadata?: Record<string, unknown> }>; + executeCommand(command: string, args?: string[], options?: { timeout?: number }): Promise<SandboxCommandResult>; + /** Tear down the underlying VM. Optional: providers without it are no-ops. */ + stop?(): Promise<void>; +} + +/** + * A coarse-grained step of the sandbox-preparation flow, reported as it happens + * so the UI can show the user what the server is doing instead of a static + * "Preparing…" toast. `phase` is a stable machine token; `message` is + * user-facing copy. + */ +export interface PrepareProgress { + phase: 'reattaching' | 'provisioning' | 'preparing-workspace' | 'cloning' | 'pulling' | 'finalizing' | 'done'; + message: string; +} + +/** Callback invoked with each preparation step. Best-effort; never throws. */ +export type ProgressFn = (event: PrepareProgress) => void; + +function reportProgress(onProgress: ProgressFn | undefined, event: PrepareProgress): void { + if (!onProgress) return; + try { + onProgress(event); + } catch { + // Progress reporting must never break the actual work. + } +} + +/** + * Factory that builds a (not-yet-started) sandbox. When `providerSandboxId` is + * provided the sandbox should reattach to that existing VM instead of + * provisioning a new one. + */ +export type SandboxFactory = (opts: { + providerSandboxId?: string; + env?: Record<string, string>; + /** Idle teardown window (minutes). The provider stops the VM after this idle period. */ + idleTimeoutMinutes?: number; +}) => MaterializationSandbox; + +/** + * Resolve the active sandbox provider. An explicit `MASTRACODE_SANDBOX_PROVIDER` + * always wins. Otherwise we pick automatically: Railway when a Railway token is + * configured, else the local host-process provider. This means a repo can + * always be opened — Railway in a configured cloud deploy, local in dev — with + * no extra env wiring. + */ +export function getSandboxProvider(): string { + const explicit = process.env.MASTRACODE_SANDBOX_PROVIDER; + if (explicit) return explicit; + return process.env.RAILWAY_API_TOKEN ? 'railway' : 'local'; +} + +/** + * True when a sandbox provider is usable. The local provider is always usable + * (it runs git on the host process), so this is only false when an explicit + * provider is misconfigured (e.g. `railway` selected without a token, or an + * unknown provider name). + */ +export function isSandboxEnabled(): boolean { + const provider = getSandboxProvider(); + if (provider === 'railway') { + return Boolean(process.env.RAILWAY_API_TOKEN); + } + if (provider === 'local') { + return true; + } + return false; +} + +/** + * Compute the in-sandbox working directory for a repo. Server-side only; never + * derived from client input. + */ +export function computeSandboxWorkdir(repoFullName: string): string { + const base = process.env.MASTRACODE_SANDBOX_WORKDIR; + const repoName = repoFullName.split('/').pop() || 'repo'; + if (base) { + // If the configured base already ends with the repo name, use it as-is. + return base.endsWith(`/${repoName}`) ? base : `${base.replace(/\/$/, '')}/${repoName}`; + } + // The local provider runs on the host filesystem, where `/workspace` is not + // writable; check out under the local sandbox root instead. + if (getSandboxProvider() === 'local') { + return `${getLocalSandboxRoot().replace(/\/$/, '')}/${repoName}`; + } + return `/workspace/${repoName}`; +} + +/** + * Idle teardown window for provisioned sandboxes, in minutes. Read from + * `MASTRACODE_SANDBOX_IDLE_MINUTES`; defaults to 30. The provider stops an idle + * VM after this window so abandoned sandboxes don't linger (GC). A re-open + * detects the stopped VM and re-provisions cleanly. + */ +export function getSandboxIdleMinutes(): number | undefined { + const raw = process.env.MASTRACODE_SANDBOX_IDLE_MINUTES; + if (raw === undefined || raw === '') return 30; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return 30; + return Math.floor(parsed); +} + +/** + * Per-replica cap on concurrently *provisioned* sandboxes. Reads + * `MASTRACODE_MAX_SANDBOXES`; 0 / unset means unlimited. This is a lightweight + * per-process budget to keep a single replica from exhausting provider quota — + * it is not a global, cross-replica scheduler (that is a deferred follow-up). + */ +export function getMaxSandboxes(): number { + const raw = process.env.MASTRACODE_MAX_SANDBOXES; + if (raw === undefined || raw === '') return 0; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return 0; + return Math.floor(parsed); +} + +/** + * Count of sandboxes this replica has freshly provisioned and not yet torn + * down. Reattaches to existing VMs do not count (they reuse an already-billed + * sandbox). Used to enforce `getMaxSandboxes()`. + */ +let liveSandboxCount = 0; + +/** Current live (freshly provisioned, not torn down) sandbox count. */ +export function getLiveSandboxCount(): number { + return liveSandboxCount; +} + +/** For tests: reset the live-sandbox counter to a known state. */ +export function __resetLiveSandboxCount(value = 0): void { + liveSandboxCount = value; +} + +/** Raised when provisioning would exceed the per-replica sandbox budget. */ +export class SandboxBudgetError extends Error { + readonly code = 'sandbox-budget-exceeded' as const; + constructor(readonly max: number) { + super( + `Sandbox budget exceeded: this server already has ${max} active sandbox(es) ` + + `(MASTRACODE_MAX_SANDBOXES=${max}). Close an existing project's sandbox and try again.`, + ); + this.name = 'SandboxBudgetError'; + } +} + +/** Railway-backed sandbox, optionally reattaching by id. */ +const railwayFactory: SandboxFactory = ({ providerSandboxId, env, idleTimeoutMinutes }) => + new RailwaySandbox({ + ...(providerSandboxId ? { sandboxId: providerSandboxId } : {}), + ...(env ? { env } : {}), + ...(idleTimeoutMinutes !== undefined ? { idleTimeoutMinutes } : {}), + }); + +/** Local host-process sandbox (single-user dev; no tenant isolation). */ +const localFactory: SandboxFactory = ({ providerSandboxId }) => + new LocalSandbox(providerSandboxId ? { sandboxId: providerSandboxId } : {}); + +/** + * Default factory: dispatch on the configured provider. Resolved per call so + * `MASTRACODE_SANDBOX_PROVIDER` is honored without re-importing the module. + */ +const defaultFactory: SandboxFactory = opts => + getSandboxProvider() === 'local' ? localFactory(opts) : railwayFactory(opts); + +let sandboxFactory: SandboxFactory = defaultFactory; + +/** Override the sandbox factory (tests / alternative providers). */ +export function setSandboxFactory(factory: SandboxFactory): void { + sandboxFactory = factory; +} + +/** Reset to the default provider-dispatching factory. */ +export function resetSandboxFactory(): void { + sandboxFactory = defaultFactory; +} + +/** + * The provider's reattach id for a started sandbox. For Railway this is the + * underlying `railwaySandboxId` in `getInfo().metadata`. + */ +async function readProviderSandboxId(sandbox: MaterializationSandbox): Promise<string | undefined> { + const info = await sandbox.getInfo(); + const id = info.metadata?.railwaySandboxId ?? info.metadata?.sandboxId; + return typeof id === 'string' ? id : undefined; +} + +/** + * Provision a new sandbox (persisting its provider id on first open) or + * reattach to the stored one. Returns a started, live sandbox. + */ +export async function ensureProjectSandbox( + row: GithubProjectSandboxRow, + onProgress?: ProgressFn, +): Promise<MaterializationSandbox> { + const idleTimeoutMinutes = getSandboxIdleMinutes(); + + // Reattach path: if we have a stored sandbox id, try to reattach. The VM may + // have been torn down by the provider's idle GC (or otherwise died), in which + // case `start()` fails. Recover by clearing the stale id and provisioning a + // fresh sandbox so the next open succeeds instead of being permanently wedged. + if (row.sandboxId) { + reportProgress(onProgress, { phase: 'reattaching', message: 'Reconnecting to your sandbox…' }); + const reattached = sandboxFactory({ providerSandboxId: row.sandboxId, idleTimeoutMinutes }); + try { + await reattached.start(); + return reattached; + } catch { + await getAppDb() + .update(githubProjectSandboxes) + .set({ sandboxId: null }) + .where(eq(githubProjectSandboxes.id, row.id)); + // fall through to fresh provision below + } + } + + // Fresh provision: enforce the per-replica budget before spending quota. + const max = getMaxSandboxes(); + if (max > 0 && liveSandboxCount >= max) { + throw new SandboxBudgetError(max); + } + + reportProgress(onProgress, { phase: 'provisioning', message: 'Provisioning a new sandbox…' }); + const sandbox = sandboxFactory({ idleTimeoutMinutes }); + await sandbox.start(); + liveSandboxCount += 1; + + const providerSandboxId = await readProviderSandboxId(sandbox); + if (providerSandboxId) { + await getAppDb() + .update(githubProjectSandboxes) + .set({ sandboxId: providerSandboxId }) + .where(eq(githubProjectSandboxes.id, row.id)); + } + + return sandbox; +} + +/** + * Tear down a user's sandbox for a project: stop the live VM (best-effort) and + * clear the persisted `sandboxId`/`materializedAt` on the per-(project,user) + * binding row so the next open re-provisions cleanly. Decrements the + * per-replica live-sandbox counter. + * + * @param row the per-(project,user) sandbox binding to tear down + * @param sandbox an already-reattached live sandbox to stop, when available + */ +export async function teardownProjectSandbox( + row: GithubProjectSandboxRow, + sandbox?: MaterializationSandbox, +): Promise<void> { + if (sandbox?.stop) { + try { + await sandbox.stop(); + } catch { + // Best-effort: the VM may already be gone (idle GC). Still clear the row. + } + } + if (row.sandboxId) { + if (liveSandboxCount > 0) liveSandboxCount -= 1; + await getAppDb() + .update(githubProjectSandboxes) + .set({ sandboxId: null, materializedAt: null }) + .where(eq(githubProjectSandboxes.id, row.id)); + } +} + +/** + * Reattach to an already-provisioned sandbox by its provider id and start it. + * Used by the workspace seam when opening a GitHub project that was already + * materialized (sandbox id + workdir carried on controller state), so no DB + * round-trip is needed. + */ +export async function reattachProjectSandbox(providerSandboxId: string): Promise<MaterializationSandbox> { + const sandbox = sandboxFactory({ providerSandboxId, idleTimeoutMinutes: getSandboxIdleMinutes() }); + await sandbox.start(); + return sandbox; +} + +/** + * Single-quote a string for safe POSIX shell interpolation. Wraps the value in + * single quotes and escapes any embedded single quote using the canonical + * close-quote / escaped-quote / reopen-quote sequence (`'\''`). This is the + * standard POSIX-safe construction and prevents the quoted string from being + * terminated early. + */ +export function shellQuote(value: string): string { + // Replace each ' with the four-character sequence: ' \ ' ' + return `'` + value.split(`'`).join(`'\\''`) + `'`; +} + +/** Run a shell script in the sandbox via `sh -c`. */ +async function sh(sandbox: MaterializationSandbox, script: string): Promise<SandboxCommandResult> { + return sandbox.executeCommand('sh', ['-c', script]); +} + +/** Error raised when the sandbox cannot materialize the repo (actionable). */ +export class MaterializeError extends Error { + constructor( + message: string, + readonly code: + | 'git-missing' + | 'egress-blocked' + | 'clone-failed' + | 'pull-failed' + | 'push-failed' + | 'commit-failed' + | 'gh-missing' + | 'pr-failed', + ) { + super(message); + this.name = 'MaterializeError'; + } +} + +/** + * Build the token-auth clone/pull URL for a repo. The token lives only inside + * this URL and is scrubbed from the remote after the operation. + */ +function tokenUrl(repoFullName: string, token: string): string { + return `https://x-access-token:${token}@github.com/${repoFullName}.git`; +} + +function cleanUrl(repoFullName: string): string { + return `https://github.com/${repoFullName}.git`; +} + +/** Repo metadata needed to materialize, read from the org-owned project row. */ +export interface RepoMaterializeInfo { + repoFullName: string; + defaultBranch: string; +} + +/** + * Materialize the repo inside the user's sandbox. Clones on first open, pulls on + * re-open. Always scrubs the install token from the remote afterwards and sets + * `materialized_at` on the per-user sandbox binding row. + * + * @param sandboxRow the per-(project,user) sandbox binding (provisioned via + * `ensureProjectSandbox`) + * @param repo repo metadata from the org-owned project row + * @param sandbox the live sandbox to run git inside + * @param token a freshly minted, short-lived installation access token + */ +export async function materializeRepo( + sandboxRow: GithubProjectSandboxRow, + repoInfo: RepoMaterializeInfo, + sandbox: MaterializationSandbox, + token: string, + onProgress?: ProgressFn, +): Promise<void> { + const workdir = sandboxRow.sandboxWorkdir; + const repo = repoInfo.repoFullName; + + // 0. Defense in depth: never build a git command from values that aren't + // strictly shaped, even if a malformed row reached the DB. Inputs are also + // validated at the route boundary before storage. + if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) { + throw new MaterializeError(`Refusing to materialize: invalid repo full name '${repo}'.`, 'clone-failed'); + } + if (!/^[A-Za-z0-9_./-]+$/.test(repoInfo.defaultBranch)) { + throw new MaterializeError( + `Refusing to materialize: invalid default branch '${repoInfo.defaultBranch}'.`, + 'clone-failed', + ); + } + + // 1. Preflight: git must be installed in the sandbox template. + const gitVersion = await sh(sandbox, 'git --version'); + if (gitVersion.exitCode !== 0) { + throw new MaterializeError( + 'git is not installed in the sandbox. The sandbox template must include git.', + 'git-missing', + ); + } + + const authUrl = tokenUrl(repo, token); + + try { + if (!sandboxRow.materializedAt) { + // 2a. First open: shallow-clone the default branch into the workdir. A + // shallow single-branch clone is dramatically faster for large repos; the + // later re-open uses `git pull --ff-only`, which works on shallow clones. + reportProgress(onProgress, { + phase: 'cloning', + message: `Cloning ${repo} (first open can take a minute)…`, + }); + const clone = await sh( + sandbox, + `git clone --depth=1 --single-branch --branch ${shellQuote(repoInfo.defaultBranch)} ${shellQuote(authUrl)} ${shellQuote(workdir)}`, + ); + if (clone.exitCode !== 0) { + throw classifyGitFailure(clone, 'clone-failed'); + } + } else { + // 2b. Re-open: refresh remote to the token URL and fast-forward pull. + reportProgress(onProgress, { phase: 'pulling', message: `Updating ${repo} to the latest changes…` }); + const setUrl = await sh(sandbox, `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(authUrl)}`); + if (setUrl.exitCode !== 0) { + throw new MaterializeError(`Failed to set git remote: ${setUrl.stderr}`, 'pull-failed'); + } + const pull = await sh(sandbox, `git -C ${shellQuote(workdir)} pull --ff-only`); + if (pull.exitCode !== 0) { + throw classifyGitFailure(pull, 'pull-failed'); + } + } + } finally { + // 3. Always scrub the token from the remote so it isn't left in the VM's + // git config, even when the clone/pull above failed partway through. This + // is best-effort on the failure path (the workdir may not exist yet after a + // failed clone); on the success path the scrub must succeed or we surface it. + await scrubRemote(sandbox, workdir, repo, Boolean(sandboxRow.materializedAt)); + } + + // 4. Mark materialized. + reportProgress(onProgress, { phase: 'finalizing', message: 'Finalizing workspace…' }); + await getAppDb() + .update(githubProjectSandboxes) + .set({ materializedAt: new Date() }) + .where(eq(githubProjectSandboxes.id, sandboxRow.id)); +} + +/** + * Reset the git remote back to the tokenless URL. On a successful clone/pull the + * workdir always has a `.git`, so a non-zero exit code here means the token may + * still be persisted — surface it. On the failure path the workdir may not exist + * (e.g. a failed clone), so a non-zero exit is tolerated. + */ +async function scrubRemote( + sandbox: MaterializationSandbox, + workdir: string, + repoFullName: string, + expectGitDir: boolean, +): Promise<void> { + const result = await sh( + sandbox, + `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(cleanUrl(repoFullName))}`, + ); + if (result.exitCode !== 0 && expectGitDir) { + throw new MaterializeError( + `Failed to scrub installation token from git remote: ${result.stderr.trim() || result.stdout.trim()}`, + 'pull-failed', + ); + } +} + +/** + * Turn a failed git command into an actionable error, detecting the common + * "cannot reach github.com" egress failure. + */ +function classifyGitFailure( + result: SandboxCommandResult, + fallback: 'clone-failed' | 'pull-failed' | 'push-failed', +): MaterializeError { + const stderr = result.stderr || ''; + if (/could not resolve host|failed to connect|network is unreachable|Connection timed out/i.test(stderr)) { + return new MaterializeError( + 'The sandbox could not reach github.com. The sandbox network must allow outbound egress to github.com.', + 'egress-blocked', + ); + } + const verb = fallback === 'clone-failed' ? 'clone' : fallback === 'pull-failed' ? 'pull' : 'push'; + return new MaterializeError(`git ${verb} failed: ${stderr}`, fallback); +} + +// --------------------------------------------------------------------------- +// Phase 1 — git identity + token-scoped push primitive +// +// These helpers let the sandbox author and push commits safely. The install +// token is short-lived, minted per-operation server-side, injected only into +// the temporary remote URL inside the sandbox, and always scrubbed in a +// `finally` so it never persists in `.git/config`. +// --------------------------------------------------------------------------- + +/** + * Validate a git ref (branch) name. Server-side defense-in-depth: only allow a + * conservative character set so a branch can never be built into a shell + * command in a way that escapes quoting. Mirrors the route-layer check. + */ +export function isValidGitRef(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= 255 && + // Reject leading-dash refs (e.g. `--mirror`) so the value can never be + // parsed as a git option when interpolated into a command. + !value.startsWith('-') && + /^[A-Za-z0-9_./-]+$/.test(value) + ); +} + +/** Identity used to author commits inside the sandbox. */ +export interface GitIdentity { + name?: string | null; + email?: string | null; + /** GitHub login, used to derive a stable noreply identity when name/email are absent. */ + login?: string | null; +} + +/** + * Resolve a concrete `{ name, email }` for git authorship from a possibly-sparse + * identity. Falls back to a GitHub-style noreply identity so commits are never + * authored with an empty or host-derived identity. + */ +export function resolveGitIdentity(identity: GitIdentity): { name: string; email: string } { + const login = (identity.login || '').trim(); + const name = (identity.name || '').trim() || login || 'Mastra Code'; + const email = + (identity.email || '').trim() || + (login ? `${login}@users.noreply.github.com` : 'mastra-code@users.noreply.github.com'); + return { name, email }; +} + +/** + * Configure `user.name` / `user.email` for the given repo working tree inside + * the sandbox so commits are authored correctly. Values are shell-quoted. + */ +export async function configureGitIdentity( + sandbox: MaterializationSandbox, + workdir: string, + identity: GitIdentity, +): Promise<void> { + const { name, email } = resolveGitIdentity(identity); + const setName = await sh(sandbox, `git -C ${shellQuote(workdir)} config user.name ${shellQuote(name)}`); + if (setName.exitCode !== 0) { + throw new MaterializeError(`Failed to set git user.name: ${setName.stderr.trim()}`, 'commit-failed'); + } + const setEmail = await sh(sandbox, `git -C ${shellQuote(workdir)} config user.email ${shellQuote(email)}`); + if (setEmail.exitCode !== 0) { + throw new MaterializeError(`Failed to set git user.email: ${setEmail.stderr.trim()}`, 'commit-failed'); + } +} + +/** + * Temporarily rewrite `origin` to a tokenized URL, run `fn` (e.g. a push), and + * **always** scrub the remote back to the tokenless URL in a `finally`. The + * token therefore only ever lives in the remote URL for the duration of the + * operation and is never left in the VM's git config. + * + * On the success path the scrub must succeed (a leaked token is a hard error); + * if it fails we surface it. On the failure path the scrub is best-effort but + * still attempted, and the original operation error is rethrown. + */ +export async function withInstallToken<T>( + sandbox: MaterializationSandbox, + workdir: string, + repoFullName: string, + token: string, + fn: () => Promise<T>, +): Promise<T> { + if (!/^[\w.-]+\/[\w.-]+$/.test(repoFullName)) { + throw new MaterializeError(`Refusing to push: invalid repo full name '${repoFullName}'.`, 'push-failed'); + } + + const setUrl = await sh( + sandbox, + `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(tokenUrl(repoFullName, token))}`, + ); + if (setUrl.exitCode !== 0) { + // Best-effort scrub even though set-url failed, then surface the failure. + await scrubRemote(sandbox, workdir, repoFullName, false); + throw new MaterializeError(`Failed to set git remote: ${setUrl.stderr.trim()}`, 'push-failed'); + } + + try { + return await fn(); + } finally { + // Always restore the tokenless remote. The workdir has a `.git` (we just + // rewrote its remote) so a scrub failure means the token may still persist + // — surface it. + await scrubRemote(sandbox, workdir, repoFullName, true); + } +} + +/** + * Push a branch back to GitHub from inside the sandbox using a short-lived + * installation token. The branch is ref-validated, the token is injected only + * into the remote URL via `withInstallToken`, and egress failures are + * classified into actionable errors. + */ +export async function pushBranch( + sandbox: MaterializationSandbox, + workdir: string, + branch: string, + token: string, + repoFullName: string, +): Promise<void> { + if (!isValidGitRef(branch)) { + throw new MaterializeError(`Refusing to push: invalid branch name '${branch}'.`, 'push-failed'); + } + + await withInstallToken(sandbox, workdir, repoFullName, token, async () => { + const push = await sh(sandbox, `git -C ${shellQuote(workdir)} push -u origin ${shellQuote(branch)}`); + if (push.exitCode !== 0) { + throw classifyGitFailure(push, 'push-failed'); + } + }); +} + +export interface CommitResult { + /** True when a commit was created; false when there was nothing to commit. */ + committed: boolean; +} + +/** + * Stage every change in the working tree and create a commit inside the + * sandbox. The git identity is configured first so authorship is correct. When + * there is nothing to commit this is a no-op (`committed: false`) rather than an + * error, so callers can safely commit-then-push without first diffing. + * + * @param sandbox the live sandbox containing the checkout + * @param workdir the worktree (or repo) path to commit in + * @param message the commit message (quoted; arbitrary text is safe) + * @param identity authorship identity for the commit + */ +export async function commitAll( + sandbox: MaterializationSandbox, + workdir: string, + message: string, + identity: GitIdentity, +): Promise<CommitResult> { + await configureGitIdentity(sandbox, workdir, identity); + + const add = await sh(sandbox, `git -C ${shellQuote(workdir)} add -A`); + if (add.exitCode !== 0) { + throw new MaterializeError(`git add failed: ${add.stderr.trim() || add.stdout.trim()}`, 'commit-failed'); + } + + // Nothing staged → nothing to commit. `git diff --cached --quiet` exits 1 when + // there are staged changes, 0 when the index is clean. + const staged = await sh(sandbox, `git -C ${shellQuote(workdir)} diff --cached --quiet`); + if (staged.exitCode === 0) { + return { committed: false }; + } + + const commit = await sh(sandbox, `git -C ${shellQuote(workdir)} commit -m ${shellQuote(message)}`); + if (commit.exitCode !== 0) { + throw new MaterializeError(`git commit failed: ${commit.stderr.trim() || commit.stdout.trim()}`, 'commit-failed'); + } + + return { committed: true }; +} + +// --------------------------------------------------------------------------- +// Phase 2 — worktree / branch lifecycle +// +// Each unit of work gets its own branch + working tree inside the same sandbox +// as the base checkout. The worktree path is always computed server-side from a +// sanitized branch name; client input never reaches a filesystem path. +// --------------------------------------------------------------------------- + +/** Error raised when a worktree cannot be created/reused inside the sandbox. */ +export class WorktreeError extends Error { + constructor( + message: string, + readonly code: 'invalid-branch' | 'worktree-failed', + ) { + super(message); + this.name = 'WorktreeError'; + } +} + +/** + * Reduce a (already ref-validated) branch name to a filesystem-safe directory + * segment for the worktree path: slashes/dots/unsafe chars collapsed to `-`. + * This only affects the *directory name*, never the git branch itself. + * + * Sanitization is lossy (e.g. `feat/a` and `feat-a` both reduce to `feat-a`), + * so an 8-char hash of the original branch is appended whenever the sanitized + * form differs from the input. That keeps clean names (`main`) readable while + * guaranteeing distinct branches never share a worktree directory. + */ +export function safeBranchDir(branch: string): string { + const sanitized = + branch + .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/\/+/g, '-') + .replace(/^[-.]+|[-.]+$/g, '') + .slice(0, 100) || 'work'; + if (sanitized === branch) return sanitized; + const hash = createHash('sha256').update(branch).digest('hex').slice(0, 8); + return `${sanitized}-${hash}`; +} + +/** + * Compute the absolute worktree path for a branch, server-side only. Worktrees + * live alongside the repo checkout under a sibling `worktrees/` directory so the + * repo's `.git` is shared. Never derived from client-supplied paths. + */ +export function computeWorktreePath(repoWorkdir: string, branch: string): string { + const parent = repoWorkdir.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || ''; + return `${parent}/worktrees/${safeBranchDir(branch)}`; +} + +export interface EnsureWorktreeResult { + worktreePath: string; + branch: string; + baseBranch: string; + /** True when an existing worktree was reused rather than freshly created. */ + reused: boolean; +} + +/** + * Create (or reuse) a git worktree + branch inside the sandbox for a unit of + * work. Idempotent: if a worktree already exists at the computed path it is + * reused. The branch is created from `baseBranch` when it does not yet exist. + * + * @param sandbox live sandbox containing the base checkout + * @param repoWorkdir the base repo checkout path inside the sandbox + * @param branch the feature branch (ref-validated server-side) + * @param baseBranch the branch to fork from (ref-validated; default's repo branch) + */ +export async function ensureWorktree( + sandbox: MaterializationSandbox, + repoWorkdir: string, + { branch, baseBranch }: { branch: string; baseBranch: string }, +): Promise<EnsureWorktreeResult> { + if (!isValidGitRef(branch)) { + throw new WorktreeError(`Invalid branch name '${branch}'.`, 'invalid-branch'); + } + if (!isValidGitRef(baseBranch)) { + throw new WorktreeError(`Invalid base branch name '${baseBranch}'.`, 'invalid-branch'); + } + + const worktreePath = computeWorktreePath(repoWorkdir, branch); + + // Idempotent reuse: a worktree already checked out at this path has a `.git` + // file (worktrees use a gitfile, not a directory). Reuse it as-is. + const exists = await sh(sandbox, `test -e ${shellQuote(`${worktreePath}/.git`)}`); + if (exists.exitCode === 0) { + return { worktreePath, branch, baseBranch, reused: true }; + } + + // Make sure the base ref is present locally before forking from it. + await sh(sandbox, `git -C ${shellQuote(repoWorkdir)} fetch origin ${shellQuote(baseBranch)}`); + + // Create the worktree. If the branch already exists, check it out into the + // worktree; otherwise create it from the base branch. `git worktree add -B` + // creates-or-resets the branch to the base, which keeps this idempotent for a + // fresh worktree while still working when the branch already exists remotely. + const add = await sh( + sandbox, + `git -C ${shellQuote(repoWorkdir)} worktree add -B ${shellQuote(branch)} ${shellQuote(worktreePath)} ${shellQuote(baseBranch)}`, + ); + if (add.exitCode !== 0) { + throw new WorktreeError(`git worktree add failed: ${add.stderr.trim() || add.stdout.trim()}`, 'worktree-failed'); + } + + return { worktreePath, branch, baseBranch, reused: false }; +} + +// --------------------------------------------------------------------------- +// Phase 3 — `gh` CLI pull-request creation primitive +// +// PRs are opened from inside the sandbox with the GitHub CLI. `gh` must be +// present in the sandbox template (preflighted only on the PR path so clone / +// open still work when it is absent). The token is passed to `gh` via a +// per-invocation `GH_TOKEN` env that is scoped to the single `gh` process and +// never written to git config, a shell rc, or the VM's environment. +// --------------------------------------------------------------------------- + +export interface CreatePullRequestArgs { + /** Short-lived installation token, injected only into the `gh` process env. */ + token: string; + /** Base branch the PR merges into. Ref-validated. */ + base: string; + /** Head branch the PR is opened from. Ref-validated. */ + head: string; + /** PR title. */ + title: string; + /** PR body (optional). */ + body?: string; +} + +export interface CreatePullRequestResult { + /** The PR URL parsed from `gh pr create` stdout. */ + url: string; +} + +/** + * Preflight that `gh` is installed in the sandbox. Only called on the PR path so + * a missing `gh` never blocks clone/open. Surfaces an actionable error naming + * the sandbox template requirement. + */ +async function assertGhAvailable(sandbox: MaterializationSandbox): Promise<void> { + const version = await sh(sandbox, 'gh --version'); + if (version.exitCode !== 0) { + throw new MaterializeError( + 'The GitHub CLI (gh) is not installed in the sandbox. The sandbox template must include gh to open pull requests.', + 'gh-missing', + ); + } +} + +/** Match the first GitHub PR URL in `gh pr create` output. */ +function parsePullRequestUrl(stdout: string): string | undefined { + const match = stdout.match(/https:\/\/github\.com\/[^\s]+\/pull\/\d+/); + return match?.[0]; +} + +/** + * Open a pull request from inside the sandbox via `gh pr create`. The token is + * passed only through a per-invocation `GH_TOKEN` env scoped to the single `gh` + * process (never persisted), all arguments are shell-quoted, and the resulting + * PR URL is parsed from stdout. + * + * @param sandbox live sandbox containing the checkout + * @param workdir the worktree (or repo) path the PR head branch is checked out in + */ +export async function createPullRequest( + sandbox: MaterializationSandbox, + workdir: string, + { token, base, head, title, body }: CreatePullRequestArgs, +): Promise<CreatePullRequestResult> { + if (!isValidGitRef(base)) { + throw new MaterializeError(`Refusing to open PR: invalid base branch '${base}'.`, 'pr-failed'); + } + if (!isValidGitRef(head)) { + throw new MaterializeError(`Refusing to open PR: invalid head branch '${head}'.`, 'pr-failed'); + } + + await assertGhAvailable(sandbox); + + // GH_TOKEN is prefixed inline so it is exported only to the single `gh` + // process and never to the wider shell session, git config, or VM env. `gh` + // is run from inside the checkout so it targets the correct repo/head branch. + const ghCommand = [ + `GH_TOKEN=${shellQuote(token)} gh pr create`, + `--base ${shellQuote(base)}`, + `--head ${shellQuote(head)}`, + `--title ${shellQuote(title)}`, + `--body ${shellQuote(body ?? '')}`, + ].join(' '); + const script = `cd ${shellQuote(workdir)} && ${ghCommand}`; + + const result = await sh(sandbox, script); + if (result.exitCode !== 0) { + const classified = classifyGitFailure(result, 'push-failed'); + if (classified.code === 'egress-blocked') { + throw classified; + } + throw new MaterializeError(`gh pr create failed: ${result.stderr.trim() || result.stdout.trim()}`, 'pr-failed'); + } + + const url = parsePullRequestUrl(result.stdout); + if (!url) { + throw new MaterializeError( + `gh pr create succeeded but no PR URL was found in its output: ${result.stdout.trim()}`, + 'pr-failed', + ); + } + + return { url }; +} diff --git a/mastracode/src/web/github/schema.ts b/mastracode/src/web/github/schema.ts new file mode 100644 index 000000000000..538188789820 --- /dev/null +++ b/mastracode/src/web/github/schema.ts @@ -0,0 +1,233 @@ +/** + * Drizzle schema for the separate application Postgres backing the GitHub App + * integration. This database is distinct from Mastra's own storage: it holds + * only the GitHub App installations an org has connected and the repos they have + * turned into MastraCode Web projects. No agent memory or Mastra data lives here. + * + * The tenancy model is **org-first**: + * - A GitHub App installation and the projects (connected repos) are owned by a + * WorkOS **organization** (`org_id`). The same repo can be connected + * independently by different orgs. + * - The per-user build artifacts — the sandbox a repo is materialized into and + * the worktrees/branches created in it — are owned by `(org, user)`. Each user + * in an org gets their own sandbox + worktrees for the org's project. + * + * `user_id` on the installation/project rows records *who connected it* (audit) + * and no longer scopes reads; org-scoped reads use `org_id`. + */ + +import { bigint, pgTable, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core'; + +/** + * A GitHub App installation an org has connected. The installation is org-owned: + * any user in the org can list repos and create projects from it. + */ +export const githubInstallations = pgTable( + 'github_installations', + { + id: uuid('id').primaryKey().defaultRandom(), + /** Owning WorkOS organization id. */ + orgId: text('org_id').notNull(), + /** Stable WorkOS user id of whoever connected it (audit only). */ + userId: text('user_id').notNull(), + /** GitHub numeric installation id. */ + installationId: bigint('installation_id', { mode: 'number' }).notNull(), + /** GitHub account login the installation belongs to (user or org). */ + accountLogin: text('account_login'), + /** 'User' or 'Organization'. */ + accountType: text('account_type'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + table => [uniqueIndex('github_installations_org_installation_unique').on(table.orgId, table.installationId)], +); + +/** + * A repo an org has turned into a project. The project is pure org-level repo + * metadata; the per-user sandbox the repo is materialized into lives in + * `github_project_sandboxes`. One project per repo per org. + */ +export const githubProjects = pgTable( + 'github_projects', + { + id: uuid('id').primaryKey().defaultRandom(), + /** Owning WorkOS organization id. */ + orgId: text('org_id').notNull(), + /** Stable WorkOS user id of whoever created it (audit only). */ + userId: text('user_id').notNull(), + /** Installation the repo is accessed through. */ + installationId: bigint('installation_id', { mode: 'number' }).notNull(), + /** `owner/name`. */ + repoFullName: text('repo_full_name').notNull(), + /** GitHub numeric repo id (stable across renames). */ + repoId: bigint('repo_id', { mode: 'number' }).notNull(), + /** Repo default branch, used as the clone branch. */ + defaultBranch: text('default_branch').notNull().default('main'), + /** Sandbox provider id, e.g. 'railway'. */ + sandboxProvider: text('sandbox_provider').notNull().default('railway'), + /** Path inside the sandbox the repo is cloned into. */ + sandboxWorkdir: text('sandbox_workdir').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + table => [uniqueIndex('github_projects_org_repo_unique').on(table.orgId, table.repoId)], +); + +/** + * The per-user sandbox a project's repo is materialized into. Each `(project, + * user)` gets its own sandbox + checkout, so two users in the same org work in + * isolation against the org's project. `sandboxId` / `materializedAt` are null + * until the user first opens the project. + */ +export const githubProjectSandboxes = pgTable( + 'github_project_sandboxes', + { + id: uuid('id').primaryKey().defaultRandom(), + /** Project (org-owned) this sandbox materializes. */ + githubProjectId: uuid('github_project_id').notNull(), + /** Owning WorkOS user id (the sandbox belongs to this user only). */ + userId: text('user_id').notNull(), + /** Provider sandbox id once provisioned; null until first open. */ + sandboxId: text('sandbox_id'), + /** Path inside the sandbox the repo is cloned into. */ + sandboxWorkdir: text('sandbox_workdir').notNull(), + /** Set when the repo has been cloned into the sandbox; null until then. */ + materializedAt: timestamp('materialized_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + table => [uniqueIndex('github_project_sandboxes_project_user_unique').on(table.githubProjectId, table.userId)], +); + +/** + * A git worktree / feature branch created inside a user's sandbox for a unit of + * work. Owned by `(org, user)`; one row per `(githubProjectId, userId, branch)` + * so two users in an org can use the same branch name in their own trees. + */ +export const githubWorktrees = pgTable( + 'github_worktrees', + { + id: uuid('id').primaryKey().defaultRandom(), + /** Owning WorkOS organization id. */ + orgId: text('org_id').notNull(), + /** Owning WorkOS user id (the worktree belongs to this user only). */ + userId: text('user_id').notNull(), + /** Project the worktree belongs to. */ + githubProjectId: uuid('github_project_id').notNull(), + /** The feature branch this worktree checks out. */ + branch: text('branch').notNull(), + /** The branch this worktree's branch was forked from. */ + baseBranch: text('base_branch').notNull(), + /** Absolute path of the worktree inside the sandbox (server-computed). */ + worktreePath: text('worktree_path').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + table => [ + uniqueIndex('github_worktrees_project_user_branch_unique').on(table.githubProjectId, table.userId, table.branch), + ], +); + +/** + * Stable mapping from a tenant key (the sha256 of an `(org, user)` identity, see + * `tenant-storage.ts`) to the Turso database provisioned for that tenant. Only + * the durable `db_name`/`hostname` are persisted — never the auth token, which + * is minted fresh per resolution. The row is written once, on first provision, + * with `onConflictDoNothing` so concurrent replicas converge on a single DB. + */ +export const tenantDatabases = pgTable('tenant_databases', { + /** sha256 hex of the `(org, user)` identity (the tenant key). */ + tenantKey: text('tenant_key').primaryKey(), + /** Turso database name (deterministic, derived from the tenant key). */ + dbName: text('db_name').notNull(), + /** Turso database hostname, e.g. `<db>-<org>.turso.io`. */ + hostname: text('hostname').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export type GithubInstallationRow = typeof githubInstallations.$inferSelect; +export type GithubProjectRow = typeof githubProjects.$inferSelect; +export type GithubProjectSandboxRow = typeof githubProjectSandboxes.$inferSelect; +export type GithubWorktreeRow = typeof githubWorktrees.$inferSelect; +export type NewGithubInstallationRow = typeof githubInstallations.$inferInsert; +export type NewGithubProjectRow = typeof githubProjects.$inferInsert; +export type NewGithubProjectSandboxRow = typeof githubProjectSandboxes.$inferInsert; +export type NewGithubWorktreeRow = typeof githubWorktrees.$inferInsert; +export type TenantDatabaseRow = typeof tenantDatabases.$inferSelect; +export type NewTenantDatabaseRow = typeof tenantDatabases.$inferInsert; + +/** + * Idempotent DDL run on boot when the feature is enabled. We keep migrations + * inline (rather than drizzle-kit generated files) because the schema is small + * and only ever grows additively; `CREATE TABLE IF NOT EXISTS` keeps boot safe + * to re-run. New org-scoping columns/indexes are added with + * `ADD COLUMN IF NOT EXISTS` / `CREATE UNIQUE INDEX IF NOT EXISTS` so existing + * deployments migrate forward without a separate migration step. + * + * Pre-GA note: existing rows predate `org_id` and are left NULL. Org-scoped + * reads require a non-null `org_id`, so legacy rows are simply not returned; + * this is acceptable while the feature is behind env flags and not yet GA. + */ +export const MIGRATION_SQL = ` +CREATE TABLE IF NOT EXISTS github_installations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id text NOT NULL, + installation_id bigint NOT NULL, + account_login text, + account_type text, + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE github_installations ADD COLUMN IF NOT EXISTS org_id text; + +CREATE UNIQUE INDEX IF NOT EXISTS github_installations_org_installation_unique + ON github_installations (org_id, installation_id); + +CREATE TABLE IF NOT EXISTS github_projects ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id text NOT NULL, + installation_id bigint NOT NULL, + repo_full_name text NOT NULL, + repo_id bigint NOT NULL, + default_branch text NOT NULL DEFAULT 'main', + sandbox_provider text NOT NULL DEFAULT 'railway', + sandbox_workdir text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE github_projects ADD COLUMN IF NOT EXISTS org_id text; + +CREATE UNIQUE INDEX IF NOT EXISTS github_projects_org_repo_unique + ON github_projects (org_id, repo_id); + +CREATE TABLE IF NOT EXISTS github_project_sandboxes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + github_project_id uuid NOT NULL, + user_id text NOT NULL, + sandbox_id text, + sandbox_workdir text NOT NULL, + materialized_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS github_project_sandboxes_project_user_unique + ON github_project_sandboxes (github_project_id, user_id); + +CREATE TABLE IF NOT EXISTS github_worktrees ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id text NOT NULL, + github_project_id uuid NOT NULL, + branch text NOT NULL, + base_branch text NOT NULL, + worktree_path text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE github_worktrees ADD COLUMN IF NOT EXISTS org_id text; + +CREATE UNIQUE INDEX IF NOT EXISTS github_worktrees_project_user_branch_unique + ON github_worktrees (github_project_id, user_id, branch); + +CREATE TABLE IF NOT EXISTS tenant_databases ( + tenant_key text PRIMARY KEY, + db_name text NOT NULL, + hostname text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +`; diff --git a/mastracode/src/web/github/state-secret-scenario.test.ts b/mastracode/src/web/github/state-secret-scenario.test.ts new file mode 100644 index 000000000000..ebb7b19324bf --- /dev/null +++ b/mastracode/src/web/github/state-secret-scenario.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + __resetStateSecretForTests, + assertReplicaStableStateSecret, + hasExplicitStateSecret, + isGithubFeatureEnabled, + signState, + verifyState, +} from './config'; + +// ── Phase 6 state-secret deploy scenario ───────────────────────────────── +// The OAuth/install `state` is HMAC-signed with a secret. With no explicit +// secret it falls back to a per-process random one, which breaks across +// replicas: a `state` signed by replica A cannot be verified by replica B. +// These tests simulate a second replica via `__resetStateSecretForTests()` +// (drops the cached secret, forcing a fresh resolution as a new process would). + +const GITHUB_ENV_KEYS = [ + 'GITHUB_APP_ID', + 'GITHUB_APP_PRIVATE_KEY', + 'GITHUB_APP_CLIENT_ID', + 'GITHUB_APP_CLIENT_SECRET', + 'GITHUB_APP_SLUG', + 'WORKOS_API_KEY', + 'WORKOS_CLIENT_ID', + 'APP_DATABASE_URL', + 'GITHUB_APP_WEBHOOK_SECRET', + 'WORKOS_COOKIE_PASSWORD', +] as const; + +const saved: Record<string, string | undefined> = {}; + +function enableGithubFeature(): void { + process.env.GITHUB_APP_ID = 'app-id'; + process.env.GITHUB_APP_PRIVATE_KEY = 'pk'; + process.env.GITHUB_APP_CLIENT_ID = 'client-id'; + process.env.GITHUB_APP_CLIENT_SECRET = 'client-secret'; + process.env.GITHUB_APP_SLUG = 'slug'; + process.env.WORKOS_API_KEY = 'workos-key'; + process.env.WORKOS_CLIENT_ID = 'workos-client'; + process.env.APP_DATABASE_URL = 'postgres://localhost/app'; +} + +beforeEach(() => { + for (const k of GITHUB_ENV_KEYS) saved[k] = process.env[k]; + for (const k of GITHUB_ENV_KEYS) delete process.env[k]; + __resetStateSecretForTests(); +}); + +afterEach(() => { + for (const k of GITHUB_ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + __resetStateSecretForTests(); +}); + +describe('explicit secret verifies across replicas', () => { + it('state signed on replica A verifies on replica B when an explicit secret is set', () => { + process.env.GITHUB_APP_WEBHOOK_SECRET = 'shared-stable-secret'; + __resetStateSecretForTests(); + + // Replica A signs. + const state = signState('orgA', 'user1'); + + // Replica B: simulate a fresh process by dropping the cached secret. Because + // the secret is read from env, B resolves the SAME secret. + __resetStateSecretForTests(); + const tenant = verifyState(state); + + expect(tenant).toEqual({ orgId: 'orgA', userId: 'user1' }); + }); + + it('WORKOS_COOKIE_PASSWORD also provides a stable secret', () => { + process.env.WORKOS_COOKIE_PASSWORD = 'cookie-pw-stable'; + __resetStateSecretForTests(); + const state = signState('orgA', 'user1'); + __resetStateSecretForTests(); + expect(verifyState(state)).toEqual({ orgId: 'orgA', userId: 'user1' }); + }); +}); + +describe('random fallback fails across replicas', () => { + it('state signed on replica A fails to verify on replica B with no explicit secret', () => { + // No explicit secret → per-process random. + expect(hasExplicitStateSecret()).toBe(false); + const state = signState('orgA', 'user1'); + + // Replica B with a *different* random secret cannot verify. + __resetStateSecretForTests(); + expect(verifyState(state)).toBeNull(); + }); + + it('same process (no reset) still verifies its own random-signed state', () => { + const state = signState('orgA', 'user1'); + // No reset → same in-process random secret → verifies. + expect(verifyState(state)).toEqual({ orgId: 'orgA', userId: 'user1' }); + }); +}); + +describe('startup guard', () => { + it('throws when the GitHub feature is on but no explicit secret is set', () => { + enableGithubFeature(); + expect(isGithubFeatureEnabled()).toBe(true); + expect(hasExplicitStateSecret()).toBe(false); + expect(() => assertReplicaStableStateSecret()).toThrow(/replica-stable state secret/); + }); + + it('passes when the GitHub feature is on and an explicit secret is set', () => { + enableGithubFeature(); + process.env.GITHUB_APP_WEBHOOK_SECRET = 'shared-stable-secret'; + expect(() => assertReplicaStableStateSecret()).not.toThrow(); + }); + + it('is a no-op when the GitHub feature is off (random fallback is fine locally)', () => { + // No GitHub env → feature off. + expect(isGithubFeatureEnabled()).toBe(false); + expect(() => assertReplicaStableStateSecret()).not.toThrow(); + }); +}); diff --git a/mastracode/src/web/server.ts b/mastracode/src/web/server.ts index 02da11211e10..eb41cfa2757b 100644 --- a/mastracode/src/web/server.ts +++ b/mastracode/src/web/server.ts @@ -11,8 +11,15 @@ import { Hono } from 'hono'; import { mountAgentControllerOnMastra } from '../index.js'; import type { MastraCodeConfig } from '../index.js'; +import { mountWebAuth } from './auth.js'; import { mountConfigRoutes } from './config-routes.js'; import { mountFsRoutes } from './fs-routes.js'; +import { assertReplicaStableStateSecret, isGithubFeatureEnabled } from './github/config.js'; +import { ensureAppDbReady } from './github/db.js'; +import { mountGithubRoutes } from './github/routes.js'; +import { isSandboxEnabled } from './github/sandbox.js'; +import { TenantDispatcher } from './tenant-server.js'; +import { assertRemoteTenantDbIfRequired } from './tenant-storage.js'; const CONTROLLER_ID = 'code'; @@ -74,6 +81,43 @@ export async function startWebServer(options: WebServerOptions = {}): Promise<We // middleware and every Mastra route under `/api`, with the same schema // validation, SSE framing, and error handling the production server uses. const app = new Hono<{ Bindings: HonoBindings; Variables: HonoVariables }>(); + + // The browser-facing origin used to build OAuth callback URLs. In dev the SPA + // is served by Vite on a different port (e.g. :5173) and proxies to this + // server, so callback URLs must point at the SPA origin, not this bind. Set + // MASTRACODE_PUBLIC_URL to that origin; otherwise we fall back to the bind. + const publicOrigin = ( + process.env.MASTRACODE_PUBLIC_URL ?? `http://${hostname === '0.0.0.0' ? 'localhost' : hostname}:${port}` + ).replace(/\/+$/, ''); + + // Optional WorkOS AuthKit gate. Mounted BEFORE the Mastra adapter and the + // custom routes so the `app.use('*')` gate runs ahead of every other handler + // and protects the whole surface. No-op unless WorkOS env vars are set. + const redirectUri = process.env.WORKOS_REDIRECT_URI ?? `${publicOrigin}/auth/callback`; + const webAuthEnabled = mountWebAuth(app, { redirectUri }); + process.stderr.write(`MastraCode web auth: ${webAuthEnabled ? 'enabled (WorkOS AuthKit)' : 'disabled'}\n`); + + // Per-tenant isolation: when web auth is enabled, every authenticated user + // operates against their own Mastra bound to their own libSQL storage/vector + // pair so no tenant's threads/messages/memory/recall can leak into another's. + // The dispatcher intercepts the Mastra controller surface (`/api/*`), routes + // authenticated users to their isolated tenant app, and falls through to the + // shared adapter below for the auth-disabled / unauthenticated public path. + let tenantDispatcher: TenantDispatcher | undefined; + if (webAuthEnabled) { + // Fail loud if a remote tenant DB is required (multi-replica/ephemeral + // deploy) but only local-file tenant DBs are configured. + assertRemoteTenantDbIfRequired(); + tenantDispatcher = new TenantDispatcher({ + baseConfig: mastraCodeConfig, + controllerId: CONTROLLER_ID, + }); + app.use('/api/*', tenantDispatcher.middleware()); + process.stderr.write('MastraCode web storage: per-tenant libSQL (isolated)\n'); + } else { + process.stderr.write('MastraCode web storage: shared (single store)\n'); + } + const adapter = new MastraServer({ app, mastra }); await adapter.init(); @@ -88,6 +132,33 @@ export async function startWebServer(options: WebServerOptions = {}): Promise<We // /api-keys command). Reuses the controller model catalog + the credential store. mountConfigRoutes(app, { controller, authStorage: result.authStorage }); + // Optional GitHub App + cloud-sandbox project feature. Enabled only when the + // GitHub App env vars, web auth, and the app DB are all configured. Fails soft: + // if the app DB can't be reached we log and leave the feature disabled rather + // than crashing the server. + if (isGithubFeatureEnabled()) { + // Fail loud if state signing wouldn't be stable across replicas. A random + // per-process secret silently breaks the OAuth/install callback on a replica + // that didn't sign the `state`. + assertReplicaStableStateSecret(); + const baseUrl = publicOrigin; + let githubReady = false; + try { + await ensureAppDbReady(); + githubReady = true; + } catch (err) { + process.stderr.write( + `MastraCode GitHub: app DB unavailable, feature disabled (${err instanceof Error ? err.message : String(err)})\n`, + ); + } + if (githubReady) { + mountGithubRoutes(app, { baseUrl }); + process.stderr.write(`MastraCode GitHub: enabled (sandbox ${isSandboxEnabled() ? 'enabled' : 'disabled'})\n`); + } + } else { + process.stderr.write('MastraCode GitHub: disabled\n'); + } + // Serve the built UI when available (production / `mastracode web`). const resolvedUiDir = uiDir ?? defaultUiDir(); if (resolvedUiDir && existsSync(join(resolvedUiDir, 'index.html'))) { @@ -103,7 +174,11 @@ export async function startWebServer(options: WebServerOptions = {}): Promise<We url: `http://localhost:${port}`, stop: async () => { await new Promise<void>(resolve => server.close(() => resolve())); - await Promise.allSettled([controller.getMastra()?.stopWorkers(), controller.stopHeartbeats()]); + await Promise.allSettled([ + controller.getMastra()?.stopWorkers(), + controller.stopIntervals(), + tenantDispatcher?.stopAll(), + ]); }, }; } diff --git a/mastracode/src/web/tenant-cache-scenario.test.ts b/mastracode/src/web/tenant-cache-scenario.test.ts new file mode 100644 index 000000000000..08f38b3ad634 --- /dev/null +++ b/mastracode/src/web/tenant-cache-scenario.test.ts @@ -0,0 +1,224 @@ +import { Hono } from 'hono'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Phase 6 tenant-cache eviction scenario ─────────────────────────────── +// The TenantDispatcher caches a full Mastra stack per tenant. Left unbounded +// it leaks memory as a team grows. These scenarios drive the idle-TTL sweep +// and the LRU max-size cap end to end using an injected fake builder + clock, +// so no real Mastra stack boots. + +const mockWebAuthTenant = vi.fn(); +vi.mock('./auth.js', () => ({ + webAuthTenant: (c: unknown) => mockWebAuthTenant(c), +})); + +interface TenantIdentity { + orgId?: string; + userId: string; +} + +const mockGetUserStorage = vi.fn(); +vi.mock('./tenant-storage.js', () => ({ + getUserStorage: (identity: TenantIdentity) => mockGetUserStorage(identity), +})); + +import { TenantDispatcher } from './tenant-server.js'; +import type { TenantAppBuilder } from './tenant-server.js'; + +function tenantKeyOf(identity: TenantIdentity): string { + return identity.orgId ? `${identity.orgId}:${identity.userId}` : identity.userId; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockGetUserStorage.mockImplementation((identity: TenantIdentity) => { + const key = tenantKeyOf(identity); + return { tenantKey: `key_${key}`, storageConfig: { tenant: key } }; + }); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +/** + * A fake tenant-app builder that records the storage configs it was asked to + * build and the stop calls it received, with a per-app echo route proving the + * request was routed to the matching stack. + */ +function makeFakeBuilder() { + const builtTenants: string[] = []; + const stopped: string[] = []; + const builder: TenantAppBuilder = async storage => { + const tenant = (storage as unknown as { tenant: string }).tenant; + builtTenants.push(tenant); + const app = new Hono(); + app.get('/api/echo', c => c.json({ tenant })); + return { + fetch: (request, ...rest) => app.fetch(request as Request, ...(rest as [])), + stop: async () => { + stopped.push(tenant); + }, + }; + }; + return { builder, builtTenants, stopped }; +} + +function buildOuterApp(dispatcher: TenantDispatcher) { + const app = new Hono(); + app.use('/api/*', dispatcher.middleware()); + return app; +} + +describe('idle eviction round-trip', () => { + it('evicts an idle tenant (stops it) then rebuilds it on the next request', async () => { + const { builder, builtTenants, stopped } = makeFakeBuilder(); + let clock = 1_000; + const dispatcher = new TenantDispatcher({ + baseConfig: {}, + controllerId: 'code', + buildTenantApp: builder, + idleMs: 10 * 60_000, // 10 minutes + maxApps: 0, // disable LRU cap; isolate idle behavior + now: () => clock, + }); + const app = buildOuterApp(dispatcher); + + mockWebAuthTenant.mockReturnValue({ orgId: 'org_a', userId: 'user_a' }); + const first = await app.request('/api/echo'); + expect(await first.json()).toEqual({ tenant: 'org_a:user_a' }); + expect(dispatcher.size()).toBe(1); + expect(builtTenants).toEqual(['org_a:user_a']); + + // Advance past the idle window, then a request from a DIFFERENT tenant + // triggers the sweep that evicts the idle one. + clock += 11 * 60_000; + mockWebAuthTenant.mockReturnValue({ orgId: 'org_a', userId: 'user_b' }); + await app.request('/api/echo'); + // Allow the fire-and-forget stop() to settle. + await Promise.resolve(); + await Promise.resolve(); + + expect(stopped).toContain('org_a:user_a'); + // user_a evicted, user_b cached. + expect(dispatcher.size()).toBe(1); + + // user_a returns → rebuilt fresh. + clock += 1_000; + mockWebAuthTenant.mockReturnValue({ orgId: 'org_a', userId: 'user_a' }); + const back = await app.request('/api/echo'); + expect(await back.json()).toEqual({ tenant: 'org_a:user_a' }); + // Built twice in total for user_a (original + rebuild). + expect(builtTenants.filter(t => t === 'org_a:user_a')).toHaveLength(2); + }); + + it('keeps a tenant alive while it is actively used', async () => { + const { builder, builtTenants } = makeFakeBuilder(); + let clock = 0; + const dispatcher = new TenantDispatcher({ + baseConfig: {}, + controllerId: 'code', + buildTenantApp: builder, + idleMs: 10 * 60_000, + maxApps: 0, + now: () => clock, + }); + const app = buildOuterApp(dispatcher); + mockWebAuthTenant.mockReturnValue({ userId: 'user_a' }); + + await app.request('/api/echo'); + // Repeated use within the idle window refreshes lastUsed each time. + for (let i = 0; i < 5; i++) { + clock += 5 * 60_000; + await app.request('/api/echo'); + } + // Only one build despite 30 minutes elapsing, because each access refreshed. + expect(builtTenants).toEqual(['user_a']); + expect(dispatcher.size()).toBe(1); + }); +}); + +describe('max-size LRU eviction', () => { + it('evicts the least-recently-used tenant when the cap is exceeded', async () => { + const { builder, builtTenants, stopped } = makeFakeBuilder(); + let clock = 0; + const dispatcher = new TenantDispatcher({ + baseConfig: {}, + controllerId: 'code', + buildTenantApp: builder, + idleMs: 0, // disable idle sweep; isolate LRU behavior + maxApps: 2, + now: () => clock, + }); + const app = buildOuterApp(dispatcher); + + // Build user_a, then user_b. + mockWebAuthTenant.mockReturnValue({ userId: 'user_a' }); + clock = 1; + await app.request('/api/echo'); + mockWebAuthTenant.mockReturnValue({ userId: 'user_b' }); + clock = 2; + await app.request('/api/echo'); + expect(dispatcher.size()).toBe(2); + + // Touch user_a so user_b becomes the LRU. + mockWebAuthTenant.mockReturnValue({ userId: 'user_a' }); + clock = 3; + await app.request('/api/echo'); + + // Build user_c → exceeds cap → user_b (LRU) evicted. + mockWebAuthTenant.mockReturnValue({ userId: 'user_c' }); + clock = 4; + await app.request('/api/echo'); + await Promise.resolve(); + await Promise.resolve(); + + expect(dispatcher.size()).toBe(2); + expect(stopped).toEqual(['user_b']); + expect(builtTenants).toEqual(['user_a', 'user_b', 'user_c']); + }); +}); + +describe('no cross-tenant bleed under eviction', () => { + it('rebuilds an evicted tenant with its OWN storage config, never another tenant’s', async () => { + const { builder } = makeFakeBuilder(); + const seenStorageForBuild: Array<{ identity: string; tenant: string }> = []; + const wrapped: TenantAppBuilder = async (storage, ctx) => { + seenStorageForBuild.push({ + identity: 'n/a', + tenant: (storage as unknown as { tenant: string }).tenant, + }); + return builder(storage, ctx); + }; + let clock = 0; + const dispatcher = new TenantDispatcher({ + baseConfig: {}, + controllerId: 'code', + buildTenantApp: wrapped, + idleMs: 1, // tiny idle window so eviction is easy to trigger + maxApps: 0, + now: () => clock, + }); + const app = buildOuterApp(dispatcher); + + mockWebAuthTenant.mockReturnValue({ orgId: 'org_a', userId: 'user_a' }); + clock = 10; + const a1 = await app.request('/api/echo'); + expect(await a1.json()).toEqual({ tenant: 'org_a:user_a' }); + + // Advance to force eviction of user_a on the next (different) request. + clock = 100; + mockWebAuthTenant.mockReturnValue({ orgId: 'org_b', userId: 'user_a' }); + const b1 = await app.request('/api/echo'); + expect(await b1.json()).toEqual({ tenant: 'org_b:user_a' }); + + // user_a (org_a) comes back → rebuilt against org_a's storage, not org_b's. + clock = 200; + mockWebAuthTenant.mockReturnValue({ orgId: 'org_a', userId: 'user_a' }); + const a2 = await app.request('/api/echo'); + expect(await a2.json()).toEqual({ tenant: 'org_a:user_a' }); + + // Every build saw the storage config matching its own composite key. + expect(seenStorageForBuild.map(s => s.tenant)).toEqual(['org_a:user_a', 'org_b:user_a', 'org_a:user_a']); + }); +}); diff --git a/mastracode/src/web/tenant-isolation-turso.test.ts b/mastracode/src/web/tenant-isolation-turso.test.ts new file mode 100644 index 000000000000..525f9bb85d98 --- /dev/null +++ b/mastracode/src/web/tenant-isolation-turso.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { __clearTenantStorageCache, resolveTenantStorage } from './tenant-storage.js'; + +// Turso-mode isolation: distinct tenants resolve to distinct provisioned +// databases, and the same tenant is stable. The provisioner is mocked so the +// test never touches the network or the app Postgres; the mock derives a +// deterministic db name from the tenant key, exactly as the real provisioner +// does, so we can assert per-tenant uniqueness and stability. + +const isTursoProvisioningEnabled = vi.fn(() => true); +const provisionTursoTenant = vi.fn(); + +vi.mock('./tenant-provisioner.js', () => ({ + isTursoProvisioningEnabled: () => isTursoProvisioningEnabled(), + // Mirror the real provisioner: derive a stable db name from the tenant key. + provisionTursoTenant: (tenantKey: string) => provisionTursoTenant(tenantKey), + tursoDbName: (tenantKey: string) => `mc-${tenantKey.slice(0, 40)}`, +})); + +const ORIGINAL_ENV = { ...process.env }; + +beforeEach(() => { + __clearTenantStorageCache(); + isTursoProvisioningEnabled.mockReset(); + isTursoProvisioningEnabled.mockReturnValue(true); + provisionTursoTenant.mockReset(); + provisionTursoTenant.mockImplementation(async (tenantKey: string) => { + const dbName = `mc-${tenantKey.slice(0, 40)}`; + const url = `libsql://${dbName}.turso.io`; + return { url, authToken: `jwt-${dbName}`, vectorUrl: url, vectorAuthToken: `jwt-${dbName}` }; + }); + delete process.env.MASTRACODE_TENANT_DB_URL_TEMPLATE; +}); + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + __clearTenantStorageCache(); +}); + +describe('Turso-mode per-(org,user) isolation', () => { + it('gives two distinct tenants distinct provisioned databases', async () => { + const a = await resolveTenantStorage({ orgId: 'org_a', userId: 'user_1' }); + const b = await resolveTenantStorage({ orgId: 'org_b', userId: 'user_1' }); + + expect(a.tenantKey).not.toBe(b.tenantKey); + expect(a.storageConfig.isRemote).toBe(true); + expect(b.storageConfig.isRemote).toBe(true); + expect(a.storageConfig.url).not.toBe(b.storageConfig.url); + // Each tenant's db name embeds its own tenant key. + expect(a.storageConfig.url).toContain(a.tenantKey.slice(0, 40)); + expect(b.storageConfig.url).toContain(b.tenantKey.slice(0, 40)); + }); + + it('is stable for the same tenant across resolutions', async () => { + const first = await resolveTenantStorage({ orgId: 'org_a', userId: 'user_1' }); + __clearTenantStorageCache(); + const second = await resolveTenantStorage({ orgId: 'org_a', userId: 'user_1' }); + + expect(first.tenantKey).toBe(second.tenantKey); + expect(first.storageConfig.url).toBe(second.storageConfig.url); + }); +}); diff --git a/mastracode/src/web/tenant-isolation.test.ts b/mastracode/src/web/tenant-isolation.test.ts new file mode 100644 index 000000000000..0f5567d17b9b --- /dev/null +++ b/mastracode/src/web/tenant-isolation.test.ts @@ -0,0 +1,222 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import type { MastraCompositeStore } from '@mastra/core/storage'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { buildLibSQLStore } from '../utils/storage-factory.js'; +import { __clearTenantStorageCache, resolveTenantStorage } from './tenant-storage.js'; + +// The composite store types `stores`/`stores.memory` as optionally undefined; +// after `init()` the libSQL memory domain is always present, so narrow once. +function memoryOf(store: MastraCompositeStore) { + const memory = store.stores?.memory; + if (!memory) throw new Error('libSQL memory domain not initialised'); + return memory; +} + +// ── S3: true cross-tenant DB isolation with real libSQL stores ─────────── +// `tenant-storage.test.ts` only proves the *resolved paths* differ. This +// scenario goes a layer deeper: it constructs real `LibSQLStore` instances +// from the resolved per-tenant `storageConfig.url` and proves the storage +// backend itself is the tenant wall — user B cannot read a thread/messages +// user A wrote, and the two tenants live in two distinct database files. + +// Strip the `file:` prefix the resolver puts on local urls to get a real path. +function dbFilePath(url: string): string { + return url.startsWith('file:') ? url.slice('file:'.length) : url; +} + +let root: string; +const savedRoot = process.env.MASTRACODE_TENANT_DB_ROOT; +const savedTemplate = process.env.MASTRACODE_TENANT_DB_URL_TEMPLATE; + +beforeEach(() => { + // Force the local-file branch of the resolver into a throwaway temp dir. + delete process.env.MASTRACODE_TENANT_DB_URL_TEMPLATE; + root = mkdtempSync(path.join(os.tmpdir(), 'mc-tenant-iso-')); + process.env.MASTRACODE_TENANT_DB_ROOT = root; + __clearTenantStorageCache(); +}); + +afterEach(() => { + __clearTenantStorageCache(); + if (savedRoot === undefined) delete process.env.MASTRACODE_TENANT_DB_ROOT; + else process.env.MASTRACODE_TENANT_DB_ROOT = savedRoot; + if (savedTemplate === undefined) delete process.env.MASTRACODE_TENANT_DB_URL_TEMPLATE; + else process.env.MASTRACODE_TENANT_DB_URL_TEMPLATE = savedTemplate; + try { + rmSync(root, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +// Write a private thread + message into a tenant store. Returns the ids used. +async function seedPrivateThread(mem: ReturnType<typeof memoryOf>, marker: string) { + const threadId = `thread-${marker}`; + const resourceId = `resource-${marker}`; + const messageId = `msg-${marker}`; + const now = new Date(); + await mem.saveThread({ + thread: { id: threadId, resourceId, title: `${marker} secret`, createdAt: now, updatedAt: now }, + }); + await mem.saveMessages({ + messages: [ + { + id: messageId, + threadId, + resourceId, + role: 'user', + content: { format: 2, parts: [{ type: 'text', text: `${marker} private content` }] }, + createdAt: now, + } as never, + ], + }); + return { threadId, resourceId, messageId }; +} + +describe('S3: cross-tenant libSQL isolation', () => { + it('keeps one tenant from reading another tenant threads and messages', async () => { + const a = await resolveTenantStorage({ userId: 'user_a' }); + const b = await resolveTenantStorage({ userId: 'user_b' }); + + // Different tenants → different hashed dirs → different db files. + expect(a.storageConfig.url).not.toBe(b.storageConfig.url); + + const storeA = buildLibSQLStore({ id: 'tenant-a', url: a.storageConfig.url }); + const storeB = buildLibSQLStore({ id: 'tenant-b', url: b.storageConfig.url }); + await storeA.init(); + await storeB.init(); + const memA = memoryOf(storeA); + const memB = memoryOf(storeB); + + const threadId = 'thread-shared-id'; + const resourceId = 'resource-shared-id'; + const now = new Date(); + + // 1. User A writes a thread + a message. + await memA.saveThread({ + thread: { id: threadId, resourceId, title: 'A secret', createdAt: now, updatedAt: now }, + }); + await memA.saveMessages({ + messages: [ + { + id: 'msg-1', + threadId, + resourceId, + role: 'user', + content: { format: 2, parts: [{ type: 'text', text: 'tenant-a private content' }] }, + createdAt: now, + } as never, + ], + }); + + // 2. User B queries the SAME ids → must see nothing (no cross-tenant read). + expect(await memB.getThreadById({ threadId })).toBeNull(); + const bThreads = await memB.listThreads({ filter: { resourceId } }); + expect(bThreads.threads).toHaveLength(0); + const bMessages = await memB.listMessagesById({ messageIds: ['msg-1'] }); + expect(bMessages.messages).toHaveLength(0); + + // 3. User A reads its own data back → present. + const aThread = await memA.getThreadById({ threadId }); + expect(aThread?.id).toBe(threadId); + expect(aThread?.title).toBe('A secret'); + const aThreads = await memA.listThreads({ filter: { resourceId } }); + expect(aThreads.threads).toHaveLength(1); + const aMessages = await memA.listMessagesById({ messageIds: ['msg-1'] }); + expect(aMessages.messages).toHaveLength(1); + + // 4. Two distinct db files actually exist on disk under distinct dirs. + const fileA = dbFilePath(a.storageConfig.url); + const fileB = dbFilePath(b.storageConfig.url); + expect(fileA).not.toBe(fileB); + expect(path.dirname(fileA)).not.toBe(path.dirname(fileB)); + expect(existsSync(fileA)).toBe(true); + expect(existsSync(fileB)).toBe(true); + + await storeA.close?.(); + await storeB.close?.(); + }); +}); + +describe('S3: per-(org,user) libSQL isolation', () => { + it('isolates two users in the SAME org into distinct databases', async () => { + const a = await resolveTenantStorage({ orgId: 'org_a', userId: 'user_1' }); + const b = await resolveTenantStorage({ orgId: 'org_a', userId: 'user_2' }); + + expect(a.tenantKey).not.toBe(b.tenantKey); + expect(a.storageConfig.url).not.toBe(b.storageConfig.url); + + const storeA = buildLibSQLStore({ id: 'org-a-u1', url: a.storageConfig.url }); + const storeB = buildLibSQLStore({ id: 'org-a-u2', url: b.storageConfig.url }); + await storeA.init(); + await storeB.init(); + const memA = memoryOf(storeA); + const memB = memoryOf(storeB); + + const { threadId, resourceId, messageId } = await seedPrivateThread(memA, 'orgA-u1'); + + // Same org, different user → no cross-read. + expect(await memB.getThreadById({ threadId })).toBeNull(); + expect((await memB.listThreads({ filter: { resourceId } })).threads).toHaveLength(0); + expect((await memB.listMessagesById({ messageIds: [messageId] })).messages).toHaveLength(0); + + // Owner reads its own data back. + expect((await memA.getThreadById({ threadId }))?.id).toBe(threadId); + + // Distinct files on disk under distinct hashed dirs. + const fileA = dbFilePath(a.storageConfig.url); + const fileB = dbFilePath(b.storageConfig.url); + expect(path.dirname(fileA)).not.toBe(path.dirname(fileB)); + expect(existsSync(fileA)).toBe(true); + expect(existsSync(fileB)).toBe(true); + + await storeA.close?.(); + await storeB.close?.(); + }); + + it('isolates the SAME user across two orgs into distinct databases', async () => { + const a = await resolveTenantStorage({ orgId: 'org_a', userId: 'user_1' }); + const b = await resolveTenantStorage({ orgId: 'org_b', userId: 'user_1' }); + + expect(a.tenantKey).not.toBe(b.tenantKey); + expect(a.storageConfig.url).not.toBe(b.storageConfig.url); + + const storeA = buildLibSQLStore({ id: 'u1-org-a', url: a.storageConfig.url }); + const storeB = buildLibSQLStore({ id: 'u1-org-b', url: b.storageConfig.url }); + await storeA.init(); + await storeB.init(); + const memA = memoryOf(storeA); + const memB = memoryOf(storeB); + + const { threadId, resourceId, messageId } = await seedPrivateThread(memA, 'u1-orgA'); + + // Same user, different org → no cross-read. + expect(await memB.getThreadById({ threadId })).toBeNull(); + expect((await memB.listThreads({ filter: { resourceId } })).threads).toHaveLength(0); + expect((await memB.listMessagesById({ messageIds: [messageId] })).messages).toHaveLength(0); + + expect((await memA.getThreadById({ threadId }))?.id).toBe(threadId); + + await storeA.close?.(); + await storeB.close?.(); + }); +}); + +describe('S3: remote URL template per-(org,user) isolation', () => { + it('produces distinct remote urls carrying each tenant composite key', async () => { + process.env.MASTRACODE_TENANT_DB_URL_TEMPLATE = 'libsql://{id}.turso.io'; + + const a = await resolveTenantStorage({ orgId: 'org_a', userId: 'user_1' }); + const b = await resolveTenantStorage({ orgId: 'org_b', userId: 'user_1' }); + + expect(a.storageConfig.isRemote).toBe(true); + expect(b.storageConfig.isRemote).toBe(true); + expect(a.storageConfig.url).toBe(`libsql://${a.tenantKey}.turso.io`); + expect(b.storageConfig.url).toBe(`libsql://${b.tenantKey}.turso.io`); + expect(a.storageConfig.url).not.toBe(b.storageConfig.url); + }); +}); diff --git a/mastracode/src/web/tenant-provisioner.test.ts b/mastracode/src/web/tenant-provisioner.test.ts new file mode 100644 index 000000000000..a0906ef7f491 --- /dev/null +++ b/mastracode/src/web/tenant-provisioner.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock drizzle's eq() into a plain descriptor we can read in the fake DB. +vi.mock('drizzle-orm', () => ({ + eq: (_column: any, value: any) => ({ kind: 'eq', value }), +})); + +// ── Fake app Postgres (tenant_databases mapping table) ────────────────────── +// Mirrors the fake-DB harness used in routes-scenario.test.ts: a single +// in-memory rows array, with select().from().where() filtering on tenant_key +// and insert().values().onConflictDoNothing() honoring the primary key. +interface TenantDbRow { + tenantKey: string; + dbName: string; + hostname: string; +} +let rows: TenantDbRow[] = []; +let appDbConfigured = true; + +function filterByTenantKey(cond: any): TenantDbRow[] { + // The provisioner always queries `eq(tenantDatabases.tenantKey, key)`. + if (!cond || cond.kind !== 'eq') return [...rows]; + return rows.filter(r => r.tenantKey === cond.value); +} + +vi.mock('./github/db.js', () => ({ + isAppDbConfigured: () => appDbConfigured, + getAppDb: () => ({ + select: () => ({ + from: () => ({ + where: async (cond: any) => filterByTenantKey(cond), + }), + }), + insert: () => ({ + values: (vals: TenantDbRow) => ({ + onConflictDoNothing: async () => { + if (!rows.some(r => r.tenantKey === vals.tenantKey)) rows.push({ ...vals }); + }, + }), + }), + }), +})); + +// ── Mock Turso Platform API client ────────────────────────────────────────── +const createDb = vi.fn(); +const getDb = vi.fn(); +const createToken = vi.fn(); +const createClient = vi.fn((_opts: unknown) => ({ + databases: { create: createDb, get: getDb, createToken }, +})); + +vi.mock('@tursodatabase/api', () => ({ createClient: (opts: unknown) => createClient(opts) })); + +import { + __resetTursoClient, + isTursoProvisioningEnabled, + lookupTenantDb, + provisionTursoTenant, + recordTenantDb, + tursoDbName, +} from './tenant-provisioner.js'; + +const ORIGINAL_ENV = { ...process.env }; + +beforeEach(() => { + rows = []; + appDbConfigured = true; + __resetTursoClient(); + createDb.mockReset(); + getDb.mockReset(); + createToken.mockReset(); + createClient.mockClear(); + createToken.mockResolvedValue({ jwt: 'jwt-default' }); + process.env.MASTRACODE_TURSO_PLATFORM_TOKEN = 'platform-token'; + process.env.MASTRACODE_TURSO_ORG = 'my-org'; + delete process.env.MASTRACODE_TURSO_GROUP; +}); + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + __resetTursoClient(); +}); + +describe('tursoDbName', () => { + it('is deterministic and Turso-safe', () => { + const key = 'f'.repeat(64); + const name = tursoDbName(key); + expect(name).toMatch(/^mc-[a-z0-9-]+$/); + expect(name).not.toMatch(/[^a-z0-9-]/); + expect(tursoDbName(key)).toBe(name); + }); + + it('stays well under the length limit', () => { + expect(tursoDbName('a'.repeat(200)).length).toBeLessThanOrEqual(43); + }); + + it('produces distinct names for distinct tenant keys', () => { + expect(tursoDbName('a'.repeat(64))).not.toBe(tursoDbName('b'.repeat(64))); + }); +}); + +describe('isTursoProvisioningEnabled', () => { + it('is true only when both the platform token and org are set', () => { + expect(isTursoProvisioningEnabled()).toBe(true); + delete process.env.MASTRACODE_TURSO_ORG; + expect(isTursoProvisioningEnabled()).toBe(false); + delete process.env.MASTRACODE_TURSO_PLATFORM_TOKEN; + expect(isTursoProvisioningEnabled()).toBe(false); + }); +}); + +describe('provisionTursoTenant', () => { + const tenantKey = 'a'.repeat(64); + + it('creates the database, persists the mapping, and mints a token', async () => { + createDb.mockResolvedValue({ hostname: 'mc-host.turso.io' }); + createToken.mockResolvedValue({ jwt: 'jwt-fresh' }); + + const result = await provisionTursoTenant(tenantKey); + + expect(createDb).toHaveBeenCalledWith(tursoDbName(tenantKey), { group: 'default' }); + expect(result.url).toBe('libsql://mc-host.turso.io'); + expect(result.authToken).toBe('jwt-fresh'); + expect(result.vectorUrl).toBe('libsql://mc-host.turso.io'); + expect(result.vectorAuthToken).toBe('jwt-fresh'); + // Mapping was persisted. + const mapping = await lookupTenantDb(tenantKey); + expect(mapping).toEqual({ dbName: tursoDbName(tenantKey), hostname: 'mc-host.turso.io' }); + }); + + it('honours MASTRACODE_TURSO_GROUP', async () => { + process.env.MASTRACODE_TURSO_GROUP = 'prod'; + createDb.mockResolvedValue({ hostname: 'h.turso.io' }); + await provisionTursoTenant(tenantKey); + expect(createDb).toHaveBeenCalledWith(tursoDbName(tenantKey), { group: 'prod' }); + }); + + it('recovers from an "already exists" race via databases.get', async () => { + createDb.mockRejectedValue(new Error('database already exists')); + getDb.mockResolvedValue({ hostname: 'mc-existing.turso.io' }); + + const result = await provisionTursoTenant(tenantKey); + + expect(getDb).toHaveBeenCalledWith(tursoDbName(tenantKey)); + expect(result.url).toBe('libsql://mc-existing.turso.io'); + const mapping = await lookupTenantDb(tenantKey); + expect(mapping?.hostname).toBe('mc-existing.turso.io'); + }); + + it('reuses the persisted mapping and mints a fresh token without re-creating', async () => { + await recordTenantDb(tenantKey, tursoDbName(tenantKey), 'mc-known.turso.io'); + createToken.mockResolvedValue({ jwt: 'jwt-known' }); + + const result = await provisionTursoTenant(tenantKey); + + expect(createDb).not.toHaveBeenCalled(); + expect(createToken).toHaveBeenCalledWith(tursoDbName(tenantKey)); + expect(result.url).toBe('libsql://mc-known.turso.io'); + expect(result.authToken).toBe('jwt-known'); + }); + + it('mints a fresh token on every resolution', async () => { + await recordTenantDb(tenantKey, tursoDbName(tenantKey), 'mc-known.turso.io'); + createToken.mockResolvedValueOnce({ jwt: 'jwt-1' }).mockResolvedValueOnce({ jwt: 'jwt-2' }); + + const first = await provisionTursoTenant(tenantKey); + const second = await provisionTursoTenant(tenantKey); + + expect(first.authToken).toBe('jwt-1'); + expect(second.authToken).toBe('jwt-2'); + expect(createToken).toHaveBeenCalledTimes(2); + }); + + it('throws when the app database is not configured', async () => { + appDbConfigured = false; + await expect(provisionTursoTenant(tenantKey)).rejects.toThrow(/APP_DATABASE_URL/); + expect(createDb).not.toHaveBeenCalled(); + }); + + it('rethrows a non-"already exists" create error', async () => { + createDb.mockRejectedValue(new Error('rate limited')); + await expect(provisionTursoTenant(tenantKey)).rejects.toThrow('rate limited'); + expect(getDb).not.toHaveBeenCalled(); + }); +}); diff --git a/mastracode/src/web/tenant-provisioner.ts b/mastracode/src/web/tenant-provisioner.ts new file mode 100644 index 000000000000..62c2d60995da --- /dev/null +++ b/mastracode/src/web/tenant-provisioner.ts @@ -0,0 +1,160 @@ +/** + * Turso auto-provisioning for per-tenant agent state. + * + * In a deployed environment each `(org, user)` tenant needs its own remote Turso + * database — server-local libSQL files are ephemeral and not shared across + * replicas. The `MASTRACODE_TENANT_DB_URL_TEMPLATE` mode assumes the tenant DB + * already exists at a predictable URL; this module instead *creates* the + * database (and a scoped auth token) via the Turso Platform API the first time a + * tenant is seen, then persists the stable `db_name`/`hostname` mapping in the + * app Postgres so all replicas converge on the same DB and no re-create happens + * on cold start. + * + * Only the durable mapping is persisted; the auth token is minted fresh per + * resolution so no long-lived credential is ever stored. + * + * The `@tursodatabase/api` client is imported dynamically so the dependency is + * only loaded when provisioning is actually used — local dev and tests that + * don't configure Turso never load it. + */ + +import { eq } from 'drizzle-orm'; +import { getAppDb, isAppDbConfigured } from './github/db.js'; +import { tenantDatabases } from './github/schema.js'; + +/** The remote libSQL descriptor produced for a provisioned tenant. */ +export interface ProvisionedTenantDb { + url: string; + authToken: string; + vectorUrl: string; + vectorAuthToken: string; +} + +/** True when both the Turso platform token and org slug/id are configured. */ +export function isTursoProvisioningEnabled(): boolean { + return Boolean(process.env.MASTRACODE_TURSO_PLATFORM_TOKEN && process.env.MASTRACODE_TURSO_ORG); +} + +/** + * Derive a deterministic, Turso-safe database name from a tenant key. Turso + * database names must be lowercase `[a-z0-9-]`, may not start/end with a dash, + * and are length-bounded. The tenant key is already a sha256 hex string, so it + * is safe to slice; we prefix `mc-` and bound the length to stay well under the + * limit while remaining unique per tenant. + */ +export function tursoDbName(tenantKey: string): string { + const safe = tenantKey.toLowerCase().replace(/[^a-z0-9]/g, ''); + return `mc-${safe.slice(0, 40)}`; +} + +/** The minimal shape of the Turso Platform API client we depend on. */ +interface TursoClient { + databases: { + create(name: string, options?: { group?: string }): Promise<{ hostname: string; name?: string }>; + get(name: string): Promise<{ hostname: string; name?: string }>; + createToken(name: string): Promise<{ jwt: string }>; + }; +} + +let clientPromise: Promise<TursoClient> | undefined; + +/** Lazily construct (and memoize) the Turso Platform API client. */ +async function getTursoClient(): Promise<TursoClient> { + if (clientPromise) return clientPromise; + clientPromise = (async () => { + const token = process.env.MASTRACODE_TURSO_PLATFORM_TOKEN; + const org = process.env.MASTRACODE_TURSO_ORG; + if (!token || !org) { + throw new Error('Turso provisioning requires MASTRACODE_TURSO_PLATFORM_TOKEN and MASTRACODE_TURSO_ORG.'); + } + const mod = await import('@tursodatabase/api'); + return mod.createClient({ org, token }) as unknown as TursoClient; + })(); + return clientPromise; +} + +/** True when a Turso create error indicates the database already exists. */ +function isAlreadyExists(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /already exists|already in use|conflict/i.test(message); +} + +/** The configured Turso group, defaulting to `default`. */ +function tursoGroup(): string { + return process.env.MASTRACODE_TURSO_GROUP || 'default'; +} + +/** + * Look up an existing tenant → Turso DB mapping. Returns `undefined` when the + * tenant has not yet been provisioned. + */ +export async function lookupTenantDb(tenantKey: string): Promise<{ dbName: string; hostname: string } | undefined> { + const [row] = await getAppDb().select().from(tenantDatabases).where(eq(tenantDatabases.tenantKey, tenantKey)); + if (!row) return undefined; + return { dbName: row.dbName, hostname: row.hostname }; +} + +/** + * Persist a tenant → Turso DB mapping. Uses `onConflictDoNothing` so concurrent + * replicas provisioning the same tenant converge on the first writer's row. + */ +export async function recordTenantDb(tenantKey: string, dbName: string, hostname: string): Promise<void> { + await getAppDb().insert(tenantDatabases).values({ tenantKey, dbName, hostname }).onConflictDoNothing(); +} + +/** Build the remote libSQL descriptor from a hostname + freshly minted token. */ +function descriptorFor(hostname: string, jwt: string): ProvisionedTenantDb { + const url = `libsql://${hostname}`; + return { url, authToken: jwt, vectorUrl: url, vectorAuthToken: jwt }; +} + +/** + * Provision (or recover) the Turso database for a tenant and return a ready + * remote libSQL descriptor with a freshly minted scoped token. + * + * Resolution: + * 1. If a mapping already exists in Postgres, mint a token for the known DB + * and return — no Turso create call. + * 2. Otherwise create the database (idempotent: an "already exists" race falls + * back to `databases.get`), record the mapping, mint a token, return. + * + * @throws if the app database is not configured — Turso provisioning needs it + * for the durable mapping table, and silently falling back to local files in a + * deployed env would defeat the isolation guarantee. + */ +export async function provisionTursoTenant(tenantKey: string): Promise<ProvisionedTenantDb> { + if (!isAppDbConfigured()) { + throw new Error( + 'Turso tenant provisioning requires APP_DATABASE_URL for the tenant_databases mapping table. ' + + 'Set APP_DATABASE_URL, or unset MASTRACODE_TURSO_PLATFORM_TOKEN/MASTRACODE_TURSO_ORG to use local files.', + ); + } + + const dbName = tursoDbName(tenantKey); + const turso = await getTursoClient(); + + const existing = await lookupTenantDb(tenantKey); + if (existing) { + const { jwt } = await turso.databases.createToken(existing.dbName); + return descriptorFor(existing.hostname, jwt); + } + + let hostname: string; + try { + const db = await turso.databases.create(dbName, { group: tursoGroup() }); + hostname = db.hostname; + } catch (err) { + if (!isAlreadyExists(err)) throw err; + const db = await turso.databases.get(dbName); + hostname = db.hostname; + } + + await recordTenantDb(tenantKey, dbName, hostname); + const { jwt } = await turso.databases.createToken(dbName); + return descriptorFor(hostname, jwt); +} + +/** Reset the memoized Turso client (test helper). */ +export function __resetTursoClient(): void { + clientPromise = undefined; +} diff --git a/mastracode/src/web/tenant-server.test.ts b/mastracode/src/web/tenant-server.test.ts new file mode 100644 index 000000000000..c22732a5760d --- /dev/null +++ b/mastracode/src/web/tenant-server.test.ts @@ -0,0 +1,170 @@ +import { Hono } from 'hono'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Track which tenant storage each built app was bound to so we can assert +// isolation (distinct users -> distinct mounts). +const builtStorages: unknown[] = []; + +vi.mock('../index.js', () => ({ + mountAgentControllerOnMastra: vi.fn(async (config: { storage?: unknown }) => { + builtStorages.push(config.storage); + return { + mastra: { __storage: config.storage }, + controller: { + getMastra: () => ({ stopWorkers: vi.fn(async () => {}) }), + stopIntervals: vi.fn(async () => {}), + }, + }; + }), +})); + +// A fake MastraServer adapter: registers a single /api/echo route on the passed +// Hono app that returns the tenant's storage marker, proving the request was +// routed to the right per-tenant app. +vi.mock('@mastra/hono', () => ({ + MastraServer: class { + private app: Hono; + private mastra: { __storage?: { tenant?: string } }; + constructor(opts: { app: Hono; mastra: { __storage?: { tenant?: string } } }) { + this.app = opts.app; + this.mastra = opts.mastra; + } + async init() { + this.app.get('/api/echo', c => c.json({ tenant: this.mastra.__storage?.tenant ?? null })); + } + }, +})); + +const mockWebAuthTenant = vi.fn(); +vi.mock('./auth.js', () => ({ + webAuthTenant: (c: unknown) => mockWebAuthTenant(c), +})); + +interface TenantIdentity { + orgId?: string; + userId: string; +} + +const mockGetUserStorage = vi.fn(); +vi.mock('./tenant-storage.js', () => ({ + getUserStorage: (identity: TenantIdentity) => mockGetUserStorage(identity), +})); + +import { TenantDispatcher } from './tenant-server.js'; + +function tenantStorageFor(identity: TenantIdentity) { + const key = identity.orgId ? `${identity.orgId}:${identity.userId}` : identity.userId; + return { tenantKey: `key_${key}`, storageConfig: { tenant: key } }; +} + +beforeEach(() => { + vi.clearAllMocks(); + builtStorages.length = 0; + mockGetUserStorage.mockImplementation((identity: TenantIdentity) => tenantStorageFor(identity)); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function buildApp(dispatcher: TenantDispatcher) { + const app = new Hono(); + app.use('/api/*', dispatcher.middleware()); + // Shared fallback route (the "auth disabled" / shared adapter path). + app.get('/api/echo', c => c.json({ tenant: 'SHARED' })); + // A custom web route that must NOT be forwarded to tenant apps. + app.get('/api/web/status', c => c.json({ route: 'web' })); + return app; +} + +describe('TenantDispatcher', () => { + it('forwards authenticated requests to the user-specific tenant app', async () => { + const dispatcher = new TenantDispatcher({ baseConfig: {}, controllerId: 'code' }); + const app = buildApp(dispatcher); + + mockWebAuthTenant.mockReturnValue({ userId: 'user_a' }); + const res = await app.request('/api/echo'); + expect(await res.json()).toEqual({ tenant: 'user_a' }); + }); + + it('routes two different users to two isolated tenant apps', async () => { + const dispatcher = new TenantDispatcher({ baseConfig: {}, controllerId: 'code' }); + const app = buildApp(dispatcher); + + mockWebAuthTenant.mockReturnValue({ userId: 'user_a' }); + const resA = await app.request('/api/echo'); + mockWebAuthTenant.mockReturnValue({ userId: 'user_b' }); + const resB = await app.request('/api/echo'); + + expect(await resA.json()).toEqual({ tenant: 'user_a' }); + expect(await resB.json()).toEqual({ tenant: 'user_b' }); + // Two distinct tenant stacks were built with distinct storage configs. + expect(builtStorages).toEqual([{ tenant: 'user_a' }, { tenant: 'user_b' }]); + }); + + it('routes two users in the same org to two isolated tenant apps', async () => { + const dispatcher = new TenantDispatcher({ baseConfig: {}, controllerId: 'code' }); + const app = buildApp(dispatcher); + + mockWebAuthTenant.mockReturnValue({ orgId: 'org_a', userId: 'user_a' }); + const resA = await app.request('/api/echo'); + mockWebAuthTenant.mockReturnValue({ orgId: 'org_a', userId: 'user_b' }); + const resB = await app.request('/api/echo'); + + expect(await resA.json()).toEqual({ tenant: 'org_a:user_a' }); + expect(await resB.json()).toEqual({ tenant: 'org_a:user_b' }); + expect(builtStorages).toEqual([{ tenant: 'org_a:user_a' }, { tenant: 'org_a:user_b' }]); + }); + + it('routes the same user in two orgs to two isolated tenant apps', async () => { + const dispatcher = new TenantDispatcher({ baseConfig: {}, controllerId: 'code' }); + const app = buildApp(dispatcher); + + mockWebAuthTenant.mockReturnValue({ orgId: 'org_a', userId: 'user_a' }); + const resA = await app.request('/api/echo'); + mockWebAuthTenant.mockReturnValue({ orgId: 'org_b', userId: 'user_a' }); + const resB = await app.request('/api/echo'); + + expect(await resA.json()).toEqual({ tenant: 'org_a:user_a' }); + expect(await resB.json()).toEqual({ tenant: 'org_b:user_a' }); + expect(builtStorages).toEqual([{ tenant: 'org_a:user_a' }, { tenant: 'org_b:user_a' }]); + }); + + it('reuses the cached tenant app for repeated requests by the same identity', async () => { + const dispatcher = new TenantDispatcher({ baseConfig: {}, controllerId: 'code' }); + const app = buildApp(dispatcher); + + mockWebAuthTenant.mockReturnValue({ orgId: 'org_a', userId: 'user_a' }); + await app.request('/api/echo'); + await app.request('/api/echo'); + expect(builtStorages).toHaveLength(1); + }); + + it('falls through to the shared app when there is no authenticated user', async () => { + const dispatcher = new TenantDispatcher({ baseConfig: {}, controllerId: 'code' }); + const app = buildApp(dispatcher); + + mockWebAuthTenant.mockReturnValue(undefined); + const res = await app.request('/api/echo'); + expect(await res.json()).toEqual({ tenant: 'SHARED' }); + expect(builtStorages).toHaveLength(0); + }); + + it('does not forward /api/web/* custom routes to tenant apps', async () => { + const dispatcher = new TenantDispatcher({ baseConfig: {}, controllerId: 'code' }); + const app = buildApp(dispatcher); + + mockWebAuthTenant.mockReturnValue({ userId: 'user_a' }); + const res = await app.request('/api/web/status'); + expect(await res.json()).toEqual({ route: 'web' }); + expect(builtStorages).toHaveLength(0); + }); + + it('stops all tenant stacks on shutdown', async () => { + const dispatcher = new TenantDispatcher({ baseConfig: {}, controllerId: 'code' }); + const app = buildApp(dispatcher); + mockWebAuthTenant.mockReturnValue({ userId: 'user_a' }); + await app.request('/api/echo'); + await expect(dispatcher.stopAll()).resolves.toBeUndefined(); + }); +}); diff --git a/mastracode/src/web/tenant-server.ts b/mastracode/src/web/tenant-server.ts new file mode 100644 index 000000000000..aec2203128f6 --- /dev/null +++ b/mastracode/src/web/tenant-server.ts @@ -0,0 +1,246 @@ +/** + * Per-tenant Mastra controller dispatch for the multi-tenant web server. + * + * When web auth is enabled, every authenticated WorkOS user must operate against + * their OWN Mastra instance bound to their OWN isolated libSQL storage/vector + * pair (see `tenant-storage.ts`). A single shared Mastra/controller would land + * all tenants' threads, messages, memory and recall vectors in one store — a + * hard privacy violation. + * + * The Hono adapter binds a single fixed `Mastra` into request context at + * construction time (`c.set('mastra', this.mastra)`), so outer middleware can't + * retarget a shared controller per request. Instead, each tenant gets its own + * fully isolated `MastraServer` adapter (its own Hono sub-app + Mastra + + * storage). This dispatcher lazily builds and caches one per WorkOS user and + * forwards `/api/*` requests to the right tenant app. + * + * Auth-disabled / local-dev keeps using the single shared adapter built by the + * caller — this module is only engaged when `webAuthUser` is present. + */ + +import { MastraServer } from '@mastra/hono'; +import type { HonoBindings, HonoVariables } from '@mastra/hono'; +import { Hono } from 'hono'; +import type { Context } from 'hono'; + +import { mountAgentControllerOnMastra } from '../index.js'; +import type { MastraCodeConfig } from '../index.js'; + +import { webAuthTenant } from './auth.js'; +import { getUserStorage } from './tenant-storage.js'; +import type { TenantIdentity } from './tenant-storage.js'; + +/** A fully isolated per-tenant controller stack. */ +interface TenantApp { + /** The tenant's Hono app with the Mastra surface mounted under `/api`. */ + fetch: (request: Request, ...rest: unknown[]) => Response | Promise<Response>; + /** Stop the tenant's workers/heartbeats on eviction or shutdown. */ + stop: () => Promise<void>; +} + +/** + * Builds a fully isolated tenant app for a given storage config. Injectable so + * tests can exercise eviction/LRU behavior without booting a real Mastra stack. + */ +export type TenantAppBuilder = ( + storage: MastraCodeConfig['storage'], + ctx: { baseConfig: MastraCodeConfig; controllerId: string }, +) => Promise<TenantApp>; + +export interface TenantDispatcherOptions { + /** Base controller config shared by every tenant (minus storage). */ + baseConfig: MastraCodeConfig; + /** Controller id, matching the shared controller. */ + controllerId: string; + /** + * Tenant app builder. Defaults to the real Mastra-backed builder; injected in + * tests to avoid booting a real Mastra stack. + */ + buildTenantApp?: TenantAppBuilder; + /** + * Evict tenant apps idle for longer than this. Defaults to + * `MASTRACODE_TENANT_IDLE_MINUTES` (minutes) or 30 minutes. 0 disables + * idle-based eviction. + */ + idleMs?: number; + /** + * Cap on cached tenant apps. When exceeded, the least-recently-used app is + * evicted. Defaults to `MASTRACODE_TENANT_MAX_APPS` or 100. 0 disables the cap. + */ + maxApps?: number; + /** Clock injection for deterministic eviction tests. Defaults to `Date.now`. */ + now?: () => number; +} + +/** Default real Mastra-backed tenant app builder. */ +const defaultBuildTenantApp: TenantAppBuilder = async (storage, { baseConfig, controllerId }) => { + const result = await mountAgentControllerOnMastra({ + ...baseConfig, + storage, + controllerId, + }); + + const app = new Hono<{ Bindings: HonoBindings; Variables: HonoVariables }>(); + const adapter = new MastraServer({ app, mastra: result.mastra }); + await adapter.init(); + + return { + fetch: (request, ...rest) => app.fetch(request as Request, ...(rest as [])), + stop: async () => { + await Promise.allSettled([result.controller.getMastra()?.stopWorkers(), result.controller.stopIntervals()]); + }, + }; +}; + +function resolveIdleMs(option: number | undefined): number { + if (option !== undefined) return option; + const raw = process.env.MASTRACODE_TENANT_IDLE_MINUTES; + const minutes = raw ? Number(raw) : NaN; + if (Number.isFinite(minutes) && minutes >= 0) return minutes * 60_000; + return 30 * 60_000; +} + +function resolveMaxApps(option: number | undefined): number { + if (option !== undefined) return option; + const raw = process.env.MASTRACODE_TENANT_MAX_APPS; + const max = raw ? Number(raw) : NaN; + if (Number.isFinite(max) && max >= 0) return max; + return 100; +} + +/** Cache entry tracking the built app plus its last-used timestamp. */ +interface CacheEntry { + app: Promise<TenantApp>; + lastUsed: number; +} + +/** + * Builds and caches per-tenant Mastra controller stacks and dispatches requests + * to them based on the authenticated WorkOS user. + * + * The cache is bounded: apps idle past `idleMs` are swept and evicted (their + * workers stopped), and an LRU `maxApps` cap prevents unbounded growth as a team + * grows. Evicted tenants are lazily rebuilt on their next request. + */ +export class TenantDispatcher { + private readonly baseConfig: MastraCodeConfig; + private readonly controllerId: string; + private readonly build: TenantAppBuilder; + private readonly idleMs: number; + private readonly maxApps: number; + private readonly now: () => number; + /** tenantKey -> cache entry (in-flight or resolved tenant app + lastUsed). */ + private readonly apps = new Map<string, CacheEntry>(); + + constructor(options: TenantDispatcherOptions) { + this.baseConfig = options.baseConfig; + this.controllerId = options.controllerId; + this.build = options.buildTenantApp ?? defaultBuildTenantApp; + this.idleMs = resolveIdleMs(options.idleMs); + this.maxApps = resolveMaxApps(options.maxApps); + this.now = options.now ?? Date.now; + } + + /** Get-or-create the tenant app for an `(org, user)` identity. */ + private async getTenantApp(identity: TenantIdentity): Promise<TenantApp> { + this.sweepIdle(); + const { tenantKey, storageConfig } = await getUserStorage(identity); + const existing = this.apps.get(tenantKey); + if (existing) { + existing.lastUsed = this.now(); + return existing.app; + } + + const built = this.build(storageConfig, { + baseConfig: this.baseConfig, + controllerId: this.controllerId, + }).catch(err => { + // Don't cache failures — let the next request retry a clean build. + this.apps.delete(tenantKey); + throw err; + }); + this.apps.set(tenantKey, { app: built, lastUsed: this.now() }); + this.enforceMaxApps(); + return built; + } + + /** Evict any tenant apps idle for longer than `idleMs`. */ + private sweepIdle(): void { + if (this.idleMs <= 0) return; + const cutoff = this.now() - this.idleMs; + for (const [key, entry] of [...this.apps.entries()]) { + if (entry.lastUsed <= cutoff) { + this.evict(key, entry); + } + } + } + + /** Evict the least-recently-used apps until within `maxApps`. */ + private enforceMaxApps(): void { + if (this.maxApps <= 0) return; + while (this.apps.size > this.maxApps) { + let lruKey: string | undefined; + let lruEntry: CacheEntry | undefined; + for (const [key, entry] of this.apps) { + if (!lruEntry || entry.lastUsed < lruEntry.lastUsed) { + lruKey = key; + lruEntry = entry; + } + } + if (!lruKey || !lruEntry) break; + this.evict(lruKey, lruEntry); + } + } + + /** Remove an entry from the cache and stop its workers (fire-and-forget). */ + private evict(key: string, entry: CacheEntry): void { + this.apps.delete(key); + // Both the build (`entry.app`) and the subsequent `stop()` can reject; + // swallow either so eviction never produces an unhandled rejection. + void entry.app.then(app => app.stop()).catch(() => undefined); + } + + /** + * Hono middleware: when an authenticated user is present, forward the request + * to that user's isolated Mastra app and return its response. When no user is + * present (auth disabled), fall through to the shared adapter via `next()`. + */ + middleware() { + return async (c: Context, next: () => Promise<void>): Promise<Response | void> => { + // Custom web-only routes (`/api/web/...`: config, fs, GitHub) live on the + // outer app and use the app DB + webAuthUser, not tenant Mastra storage. + // They must NOT be forwarded to the tenant app (which has no such routes). + if (c.req.path.startsWith('/api/web/')) { + return next(); + } + const identity = webAuthTenant(c); + if (!identity) { + // Auth disabled or unauthenticated public route — use the shared path. + return next(); + } + const tenant = await this.getTenantApp(identity); + return tenant.fetch(c.req.raw); + }; + } + + /** Tear down all cached tenant stacks (server shutdown). */ + async stopAll(): Promise<void> { + const entries = [...this.apps.values()]; + this.apps.clear(); + await Promise.allSettled( + entries.map(async entry => { + try { + const app = await entry.app; + await app.stop(); + } catch { + // ignore — already failed to build + } + }), + ); + } + + /** For tests: number of currently cached tenant apps. */ + size(): number { + return this.apps.size; + } +} diff --git a/mastracode/src/web/tenant-storage.test.ts b/mastracode/src/web/tenant-storage.test.ts new file mode 100644 index 000000000000..55841fbe6bec --- /dev/null +++ b/mastracode/src/web/tenant-storage.test.ts @@ -0,0 +1,208 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { __clearTenantStorageCache, getUserStorage, resolveTenantStorage, tenantKeyFor } from './tenant-storage.js'; + +const provisionTursoTenant = vi.fn(); +const isTursoProvisioningEnabled = vi.fn(() => false); + +vi.mock('./tenant-provisioner.js', () => ({ + provisionTursoTenant: (...args: unknown[]) => provisionTursoTenant(...args), + isTursoProvisioningEnabled: () => isTursoProvisioningEnabled(), +})); + +const ORIGINAL_ENV = { ...process.env }; +let tmpRoot: string; + +beforeEach(() => { + __clearTenantStorageCache(); + provisionTursoTenant.mockReset(); + isTursoProvisioningEnabled.mockReset(); + isTursoProvisioningEnabled.mockReturnValue(false); + delete process.env.MASTRACODE_TENANT_DB_URL_TEMPLATE; + delete process.env.MASTRACODE_TENANT_VECTOR_URL_TEMPLATE; + delete process.env.MASTRACODE_TENANT_DB_AUTH_TOKEN; + delete process.env.MASTRACODE_TENANT_VECTOR_AUTH_TOKEN; + tmpRoot = mkdtempSync(path.join(os.tmpdir(), 'mc-tenant-')); + process.env.MASTRACODE_TENANT_DB_ROOT = tmpRoot; +}); + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + rmSync(tmpRoot, { recursive: true, force: true }); +}); + +describe('tenantKeyFor', () => { + it('produces a stable filesystem-safe sha256 hex key', () => { + const key = tenantKeyFor({ userId: 'user_workos_12345' }); + expect(key).toMatch(/^[0-9a-f]{64}$/); + expect(tenantKeyFor({ userId: 'user_workos_12345' })).toBe(key); + }); + + it('produces distinct keys for distinct users', () => { + expect(tenantKeyFor({ userId: 'user_a' })).not.toBe(tenantKeyFor({ userId: 'user_b' })); + }); + + it('produces distinct keys for the same user in different orgs', () => { + expect(tenantKeyFor({ orgId: 'org_a', userId: 'user_a' })).not.toBe( + tenantKeyFor({ orgId: 'org_b', userId: 'user_a' }), + ); + }); + + it('produces distinct keys for different users in the same org', () => { + expect(tenantKeyFor({ orgId: 'org_a', userId: 'user_a' })).not.toBe( + tenantKeyFor({ orgId: 'org_a', userId: 'user_b' }), + ); + }); + + it('falls back to a user-only key for personal (no-org) accounts', () => { + expect(tenantKeyFor({ orgId: undefined, userId: 'user_a' })).toBe(tenantKeyFor({ userId: 'user_a' })); + }); + + it('does not collide an org-scoped key with a user-only key', () => { + expect(tenantKeyFor({ orgId: 'org_a', userId: 'user_a' })).not.toBe(tenantKeyFor({ userId: 'user_a' })); + }); +}); + +describe('resolveTenantStorage (local libSQL)', () => { + it('creates a hashed per-tenant directory with separate storage and vector DBs', async () => { + const { tenantKey, storageConfig } = await resolveTenantStorage({ userId: 'user_a' }); + const dir = path.join(tmpRoot, tenantKey); + expect(existsSync(dir)).toBe(true); + expect(storageConfig.backend).toBe('libsql'); + expect(storageConfig.isRemote).toBe(false); + expect(storageConfig.url).toBe(`file:${path.join(dir, 'storage.db')}`); + expect(storageConfig.vectorUrl).toBe(`file:${path.join(dir, 'vectors.db')}`); + }); + + it('gives distinct users distinct DB paths', async () => { + const a = await resolveTenantStorage({ userId: 'user_a' }); + const b = await resolveTenantStorage({ userId: 'user_b' }); + expect(a.tenantKey).not.toBe(b.tenantKey); + expect(a.storageConfig.url).not.toBe(b.storageConfig.url); + expect(a.storageConfig.vectorUrl).not.toBe(b.storageConfig.vectorUrl); + }); + + it('never uses the raw workos id as a path component', async () => { + const rawId = 'user/with/../traversal'; + const { tenantKey, storageConfig } = await resolveTenantStorage({ userId: rawId }); + expect(tenantKey).toMatch(/^[0-9a-f]{64}$/); + expect(storageConfig.url).not.toContain('..'); + expect(storageConfig.url).toContain(tenantKey); + }); + + it('rejects an empty user id', async () => { + await expect(resolveTenantStorage({ userId: '' })).rejects.toThrow(); + }); +}); + +describe('resolveTenantStorage (remote URL template)', () => { + it('expands the {id} placeholder for storage and vector urls', async () => { + process.env.MASTRACODE_TENANT_DB_URL_TEMPLATE = 'libsql://{id}-org.turso.io'; + process.env.MASTRACODE_TENANT_VECTOR_URL_TEMPLATE = 'libsql://{id}-vec.turso.io'; + process.env.MASTRACODE_TENANT_DB_AUTH_TOKEN = 'tok_storage'; + process.env.MASTRACODE_TENANT_VECTOR_AUTH_TOKEN = 'tok_vector'; + + const { tenantKey, storageConfig } = await resolveTenantStorage({ userId: 'user_a' }); + expect(storageConfig.isRemote).toBe(true); + expect(storageConfig.url).toBe(`libsql://${tenantKey}-org.turso.io`); + expect(storageConfig.vectorUrl).toBe(`libsql://${tenantKey}-vec.turso.io`); + expect(storageConfig.authToken).toBe('tok_storage'); + expect(storageConfig.vectorAuthToken).toBe('tok_vector'); + }); + + it('falls back to the storage url and token for vectors when no vector template is set', async () => { + process.env.MASTRACODE_TENANT_DB_URL_TEMPLATE = 'libsql://{id}.turso.io'; + process.env.MASTRACODE_TENANT_DB_AUTH_TOKEN = 'tok_shared'; + + const { storageConfig } = await resolveTenantStorage({ userId: 'user_a' }); + expect(storageConfig.vectorUrl).toBe(storageConfig.url); + expect(storageConfig.vectorAuthToken).toBe('tok_shared'); + }); + + it('does not touch the local filesystem when a template is configured', async () => { + process.env.MASTRACODE_TENANT_DB_URL_TEMPLATE = 'libsql://{id}.turso.io'; + const { tenantKey } = await resolveTenantStorage({ userId: 'user_a' }); + expect(existsSync(path.join(tmpRoot, tenantKey))).toBe(false); + }); + + it('prefers the explicit template over Turso provisioning', async () => { + process.env.MASTRACODE_TENANT_DB_URL_TEMPLATE = 'libsql://{id}.turso.io'; + isTursoProvisioningEnabled.mockReturnValue(true); + + const { storageConfig } = await resolveTenantStorage({ userId: 'user_a' }); + expect(storageConfig.url).toContain('.turso.io'); + expect(provisionTursoTenant).not.toHaveBeenCalled(); + }); +}); + +describe('resolveTenantStorage (Turso auto-provisioning)', () => { + it('provisions the tenant DB and returns a remote libsql descriptor', async () => { + isTursoProvisioningEnabled.mockReturnValue(true); + provisionTursoTenant.mockResolvedValue({ + url: 'libsql://mc-abc123.turso.io', + authToken: 'jwt_token', + vectorUrl: 'libsql://mc-abc123.turso.io', + vectorAuthToken: 'jwt_token', + }); + + const { tenantKey, storageConfig } = await resolveTenantStorage({ orgId: 'org_a', userId: 'user_a' }); + expect(provisionTursoTenant).toHaveBeenCalledWith(tenantKey); + expect(storageConfig.backend).toBe('libsql'); + expect(storageConfig.isRemote).toBe(true); + expect(storageConfig.url).toBe('libsql://mc-abc123.turso.io'); + expect(storageConfig.authToken).toBe('jwt_token'); + expect(storageConfig.vectorUrl).toBe('libsql://mc-abc123.turso.io'); + expect(storageConfig.vectorAuthToken).toBe('jwt_token'); + expect(existsSync(path.join(tmpRoot, tenantKey))).toBe(false); + }); + + it('propagates a provisioning failure instead of falling back to local files', async () => { + isTursoProvisioningEnabled.mockReturnValue(true); + provisionTursoTenant.mockRejectedValue(new Error('turso down')); + + await expect(resolveTenantStorage({ userId: 'user_a' })).rejects.toThrow('turso down'); + }); +}); + +describe('getUserStorage caching', () => { + it('returns the same cached descriptor for the same identity', async () => { + const first = await getUserStorage({ orgId: 'org_a', userId: 'user_a' }); + const second = await getUserStorage({ orgId: 'org_a', userId: 'user_a' }); + expect(second).toBe(first); + }); + + it('returns distinct descriptors for distinct users', async () => { + const a = await getUserStorage({ userId: 'user_a' }); + const b = await getUserStorage({ userId: 'user_b' }); + expect(a).not.toBe(b); + expect(a.tenantKey).not.toBe(b.tenantKey); + }); + + it('returns distinct descriptors for the same user in different orgs', async () => { + const a = await getUserStorage({ orgId: 'org_a', userId: 'user_a' }); + const b = await getUserStorage({ orgId: 'org_b', userId: 'user_a' }); + expect(a).not.toBe(b); + expect(a.tenantKey).not.toBe(b.tenantKey); + }); + + it('provisions only once for concurrent first-hits of the same tenant', async () => { + isTursoProvisioningEnabled.mockReturnValue(true); + provisionTursoTenant.mockResolvedValue({ + url: 'libsql://mc-x.turso.io', + authToken: 'jwt', + vectorUrl: 'libsql://mc-x.turso.io', + vectorAuthToken: 'jwt', + }); + + const [a, b] = await Promise.all([ + getUserStorage({ orgId: 'org_a', userId: 'user_a' }), + getUserStorage({ orgId: 'org_a', userId: 'user_a' }), + ]); + expect(a).toBe(b); + expect(provisionTursoTenant).toHaveBeenCalledTimes(1); + }); +}); diff --git a/mastracode/src/web/tenant-storage.ts b/mastracode/src/web/tenant-storage.ts new file mode 100644 index 000000000000..c17b4c76eba2 --- /dev/null +++ b/mastracode/src/web/tenant-storage.ts @@ -0,0 +1,220 @@ +/** + * Per-user (tenant) storage resolution for the multi-tenant web server. + * + * The web server authenticates browser clients via WorkOS AuthKit (see + * `auth.ts`). Without per-tenant isolation, every authenticated user's agent + * state (threads, messages, memory, observational memory, recall vectors) lands + * in a single shared storage backend — only separated by `resourceId` + * convention. That is a hard multi-tenancy/privacy violation: a bug or a crafted + * `resourceId` could read another tenant's conversations. + * + * This module resolves a dedicated, isolated libSQL database **per + * (org, user)** so the tenant boundary is the storage backend itself, not just a + * scoping convention. The resolver is provider-agnostic in shape (mirroring + * `getStorageConfig`) so a hosted deployment can point at a network volume or + * swap in remote libSQL/Turso per user via a URL template. + * + * Resolution strategy (highest priority first): + * 1. `MASTRACODE_TENANT_DB_URL_TEMPLATE` — remote libSQL/Turso. The template + * may contain a `{id}` placeholder replaced with the filesystem-safe + * tenant key. Auth token from `MASTRACODE_TENANT_DB_AUTH_TOKEN`. The vector + * DB url comes from `MASTRACODE_TENANT_VECTOR_URL_TEMPLATE` (same `{id}`), + * falling back to the storage template when absent. + * 2. Turso auto-provisioning — when `MASTRACODE_TURSO_PLATFORM_TOKEN` and + * `MASTRACODE_TURSO_ORG` are set, the tenant's own Turso database is + * created (and a scoped token minted) on first access via the Turso + * Platform API, then the stable mapping is persisted in the app Postgres. + * See `tenant-provisioner.ts`. + * 3. Local libSQL files under `MASTRACODE_TENANT_DB_ROOT` (default + * `~/.mastracode/web/tenants/<sha256(orgId\0userId)>/`), with `storage.db` + * + `vectors.db`. + * + * The `(org, user)` identity is hashed (sha256, hex) to a filesystem-safe + * directory name — the raw ids are never used as a path component, and no + * client-supplied path ever reaches the filesystem. + */ + +import { createHash } from 'node:crypto'; +import { mkdirSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import type { LibSQLStorageConfig } from '../utils/project.js'; +import { isTursoProvisioningEnabled, provisionTursoTenant } from './tenant-provisioner.js'; + +/** + * A resolved per-tenant storage descriptor. This is a `StorageConfig`-shaped + * value that flows into the existing `mountAgentControllerOnMastra({ storage })` + * pipeline, so all the usual composition (composite store, observability + * domains, memory, recall vectors) is built per tenant with no duplication. + */ +export interface TenantStorage { + /** Filesystem-safe key derived from the (org, user) identity (sha256 hex). */ + tenantKey: string; + /** Storage config passed to the controller factory for this tenant. */ + storageConfig: LibSQLStorageConfig; +} + +/** + * The tenant identity for agent-state isolation: a WorkOS organization plus a + * user inside it. `orgId` is `undefined`/empty for personal (no-org) accounts, + * which fall back to a user-only key so single-user dev keeps working. + */ +export interface TenantIdentity { + orgId?: string; + userId: string; +} + +/** + * Hash the `(orgId, userId)` identity to a filesystem-safe, collision-resistant + * dir name. Two users in the same org get distinct keys; the same user across + * two orgs gets distinct keys. Personal accounts (no org) hash the user id only, + * so they stay stable and isolated from any org-scoped tenant. + */ +export function tenantKeyFor(identity: TenantIdentity): string { + const composite = identity.orgId ? `${identity.orgId}\u0000${identity.userId}` : identity.userId; + return createHash('sha256').update(composite).digest('hex'); +} + +/** Root directory for local per-tenant libSQL files. */ +function tenantDbRoot(): string { + const fromEnv = process.env.MASTRACODE_TENANT_DB_ROOT; + if (fromEnv) return fromEnv; + return path.join(os.homedir(), '.mastracode', 'web', 'tenants'); +} + +/** Apply a `{id}` template, tolerating templates without the placeholder. */ +function applyTemplate(template: string, tenantKey: string): string { + return template.includes('{id}') ? template.replaceAll('{id}', tenantKey) : `${template}${tenantKey}`; +} + +/** + * Resolve the storage descriptor for a tenant. Pure (no caching, no I/O beyond + * ensuring the local directory exists) so it is easy to unit test. Use + * {@link getUserStorage} for the cached entry point. + */ +export async function resolveTenantStorage(identity: TenantIdentity): Promise<TenantStorage> { + if (!identity.userId) { + throw new Error('resolveTenantStorage requires a non-empty userId'); + } + const tenantKey = tenantKeyFor(identity); + + // 1. Remote libSQL/Turso via URL template. + const urlTemplate = process.env.MASTRACODE_TENANT_DB_URL_TEMPLATE; + if (urlTemplate) { + const url = applyTemplate(urlTemplate, tenantKey); + const vectorTemplate = process.env.MASTRACODE_TENANT_VECTOR_URL_TEMPLATE; + const vectorUrl = vectorTemplate ? applyTemplate(vectorTemplate, tenantKey) : url; + const authToken = process.env.MASTRACODE_TENANT_DB_AUTH_TOKEN; + const vectorAuthToken = process.env.MASTRACODE_TENANT_VECTOR_AUTH_TOKEN ?? authToken; + return { + tenantKey, + storageConfig: { + backend: 'libsql', + url, + authToken, + isRemote: true, + vectorUrl, + vectorAuthToken, + }, + }; + } + + // 2. Turso auto-provisioning via the Turso Platform API. + if (isTursoProvisioningEnabled()) { + const provisioned = await provisionTursoTenant(tenantKey); + return { + tenantKey, + storageConfig: { + backend: 'libsql', + url: provisioned.url, + authToken: provisioned.authToken, + isRemote: true, + vectorUrl: provisioned.vectorUrl, + vectorAuthToken: provisioned.vectorAuthToken, + }, + }; + } + + // 3. Local libSQL files under a hashed per-tenant directory. + const dir = path.join(tenantDbRoot(), tenantKey); + mkdirSync(dir, { recursive: true }); + return { + tenantKey, + storageConfig: { + backend: 'libsql', + url: `file:${path.join(dir, 'storage.db')}`, + isRemote: false, + vectorUrl: `file:${path.join(dir, 'vectors.db')}`, + }, + }; +} + +/** In-process cache of resolved tenant descriptors, keyed by tenant key. */ +const tenantCache = new Map<string, TenantStorage>(); + +/** + * In-flight resolution promises, keyed by tenant key. Guards against duplicate + * concurrent provisioning when several requests for the same brand-new tenant + * arrive before the first resolution finishes. + */ +const inFlight = new Map<string, Promise<TenantStorage>>(); + +/** + * Get-or-create the cached tenant storage descriptor for an `(org, user)` + * identity. Same identity → same cached descriptor; distinct identities → + * distinct descriptors backed by distinct databases. Concurrent first-hits for + * the same tenant share a single resolution (and thus a single provision call). + */ +export async function getUserStorage(identity: TenantIdentity): Promise<TenantStorage> { + const tenantKey = tenantKeyFor(identity); + const cached = tenantCache.get(tenantKey); + if (cached) return cached; + + const pending = inFlight.get(tenantKey); + if (pending) return pending; + + const promise = resolveTenantStorage(identity) + .then(resolved => { + tenantCache.set(resolved.tenantKey, resolved); + return resolved; + }) + .finally(() => { + inFlight.delete(tenantKey); + }); + inFlight.set(tenantKey, promise); + return promise; +} + +/** + * True when a remote (network-backed) tenant DB backend is configured — either + * an explicit URL template or Turso auto-provisioning. + */ +export function hasRemoteTenantDb(): boolean { + return Boolean(process.env.MASTRACODE_TENANT_DB_URL_TEMPLATE) || isTursoProvisioningEnabled(); +} + +/** + * Fail loud at startup when a remote tenant DB is required but not configured. + * Local-file tenant DBs do not survive container restarts and are not shared + * across replicas, so a multi-replica/ephemeral deploy must set either + * `MASTRACODE_TENANT_DB_URL_TEMPLATE` or Turso auto-provisioning + * (`MASTRACODE_TURSO_PLATFORM_TOKEN` + `MASTRACODE_TURSO_ORG`). Gated behind + * `MASTRACODE_REQUIRE_REMOTE_TENANT_DB=1` so local dev is unaffected. + */ +export function assertRemoteTenantDbIfRequired(): void { + if (process.env.MASTRACODE_REQUIRE_REMOTE_TENANT_DB !== '1') return; + if (hasRemoteTenantDb()) return; + throw new Error( + 'MASTRACODE_REQUIRE_REMOTE_TENANT_DB=1 but no remote tenant DB backend is configured. ' + + 'Local-file tenant databases do not persist across container restarts and are not ' + + 'shared across replicas. Set MASTRACODE_TENANT_DB_URL_TEMPLATE to a remote libSQL/Turso URL, ' + + 'or set MASTRACODE_TURSO_PLATFORM_TOKEN + MASTRACODE_TURSO_ORG to auto-provision Turso databases.', + ); +} + +/** Clear the in-process tenant cache (test helper). */ +export function __clearTenantStorageCache(): void { + tenantCache.clear(); + inFlight.clear(); +} diff --git a/mastracode/src/web/ui/App.tsx b/mastracode/src/web/ui/App.tsx index 85946773301a..10558f99da29 100644 --- a/mastracode/src/web/ui/App.tsx +++ b/mastracode/src/web/ui/App.tsx @@ -1,10 +1,16 @@ import type { PlanResume } from '@mastra/client-js'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useApiConfig } from '../../shared/api/config'; +import { fetchAuthState, redirectToLogin } from './auth'; +import type { WebAuthState } from './auth'; import { CommandPalette } from './CommandPalette'; import { matchCommands, SLASH_COMMANDS } from './commands'; import type { SlashCommand } from './commands'; import { GoalPanel, StatusLine, Transcript } from './components'; +import { createWorktree, ensureRepoMaterialized, fetchGithubStatus } from './github'; +import type { GithubStatus } from './github'; +import { GithubConnectModal } from './GithubConnectModal'; import { ArrowDownIcon, ChevronIcon, @@ -23,8 +29,13 @@ import { ensureResourceId, loadActiveProjectId, saveActiveProjectId, + updateProject, + projectWorktrees, + selectedWorktree, + selectWorktree, + upsertWorktree, } from './projects'; -import type { Project } from './projects'; +import type { Project, Worktree } from './projects'; import { ProjectsModal } from './ProjectsModal'; import { SettingsPanel } from './SettingsPanel'; import { ShortcutsOverlay } from './ShortcutsOverlay'; @@ -36,6 +47,50 @@ import { useAgentControllerSession } from './useAgentControllerSession'; export default function App() { const { toast } = useToast(); + const { baseUrl } = useApiConfig(); + + // ── Optional WorkOS auth identity ─────────────────────────────────── + // Populated from /auth/me. When the server has no auth configured this stays + // disabled and no sign-out UI is shown. + const [authState, setAuthState] = useState<WebAuthState>({ authEnabled: false, authenticated: false }); + const [authLoading, setAuthLoading] = useState(true); + useEffect(() => { + void fetchAuthState() + .then(setAuthState) + .finally(() => setAuthLoading(false)); + }, []); + const signOut = useCallback(() => { + window.location.assign('/auth/logout'); + }, []); + + // ── Optional GitHub App integration ───────────────────────────────── + // Only meaningful when authenticated. Disabled status hides all GitHub UI. + const [githubStatus, setGithubStatus] = useState<GithubStatus>({ + enabled: false, + connected: false, + installations: [], + }); + const githubEnabled = githubStatus.enabled; + const [githubOpen, setGithubOpen] = useState(false); + useEffect(() => { + if (authState.authEnabled && !authState.authenticated) return; + void fetchGithubStatus().then(setGithubStatus); + }, [authState.authEnabled, authState.authenticated]); + + // After the install/connect redirect lands back on `/?github=connected`, + // re-fetch status, open the repo picker, and clean the URL. + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get('github') !== 'connected') return; + params.delete('github'); + const qs = params.toString(); + window.history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : '')); + void fetchGithubStatus().then(s => { + setGithubStatus(s); + if (s.enabled) setGithubOpen(true); + }); + }, []); + // ── Projects (localStorage) ───────────────────────────────────────── const [projects, setProjects] = useState<Project[]>(() => loadProjects()); // Restore the last active project on reload (if it still exists), so the @@ -51,16 +106,31 @@ export default function App() { saveActiveProjectId(activeProjectId); }, [activeProjectId]); + // The selected workspace (git worktree) for a GitHub project. One resourceId + // is shared across a repo's worktrees (and with the TUI), so threads are + // partitioned per worktree by the `projectPath` tag, not by resourceId. + const activeWorktree = activeProject?.source === 'github' ? selectedWorktree(activeProject) : undefined; + // resourceId is the server-resolved (TUI-compatible) id, so a project opened // in the terminal and here share the same session. Stays disabled until the // active project's resourceId is known. const resourceId = activeProject?.resourceId ?? DEFAULT_RESOURCE_ID; - const sessionEnabled = !!activeProject?.resourceId; + + // Thread-scoping tag: the worktree path for GitHub projects (so each + // workspace keeps its own threads), or the filesystem path for local ones. + const sessionProjectPath = activeWorktree?.worktreePath ?? activeProject?.path; + + // Stay disabled until the session can be created with a stable scoping tag. + // For GitHub projects the worktree path (projectPath) is the thread tag, so + // we must wait for it to resolve — otherwise the auto-created thread is + // stamped with an empty tag and never shows up in the worktree's list. + const sessionEnabled = !!activeProject?.resourceId && (activeProject.source !== 'github' || !!sessionProjectPath); const session = useAgentControllerSession({ agentControllerId: 'code', resourceId, - projectPath: activeProject?.path, + projectPath: sessionProjectPath, + baseUrl, enabled: sessionEnabled, }); const { transcript, status, modes, threads, send, steer, abort, approveTool, respondSuspension } = session; @@ -148,11 +218,12 @@ export default function App() { // (not just when a whole new entry is appended). const lastTranscriptEntry = transcript.entries[transcript.entries.length - 1]; const streamingLen = - lastTranscriptEntry?.kind === 'assistant' - ? lastTranscriptEntry.segments.reduce( - (n, s) => (s.kind === 'text' || s.kind === 'thinking' ? n + s.text.length : n), - 0, - ) + lastTranscriptEntry?.kind === 'message' && lastTranscriptEntry.message.role === 'assistant' + ? lastTranscriptEntry.message.content.parts.reduce((n, part) => { + if (part.type === 'text') return n + part.text.length; + if (part.type === 'reasoning') return n + part.reasoning.length; + return n; + }, 0) : 0; // True when the user has scrolled up far enough that new content would land @@ -216,27 +287,98 @@ export default function App() { // streamed for the current turn. const lastEntry = transcript.entries[transcript.entries.length - 1]; const lastEntryHasText = - lastEntry?.kind === 'assistant' && lastEntry.segments.some(s => s.kind === 'text' && s.text.trim().length > 0); - const showWorkingIndicator = busy && !(lastEntry?.kind === 'assistant' && lastEntry.streaming && lastEntryHasText); + lastEntry?.kind === 'message' && + lastEntry.message.role === 'assistant' && + lastEntry.message.content.parts.some(part => part.type === 'text' && part.text.trim().length > 0); + const showWorkingIndicator = + busy && + !( + lastEntry?.kind === 'message' && + lastEntry.message.role === 'assistant' && + lastEntry.streaming && + lastEntryHasText + ); // A restored active project from a pre-resourceId build won't have one yet; // backfill it so the session can connect. Runs once per project that needs it. const backfilledRef = useRef<string | null>(null); useEffect(() => { - if (activeProject && !activeProject.resourceId && backfilledRef.current !== activeProject.id) { + // GitHub projects are resolved via materialize-on-open, not path resolution. + if ( + activeProject && + activeProject.source !== 'github' && + !activeProject.resourceId && + backfilledRef.current !== activeProject.id + ) { backfilledRef.current = activeProject.id; void ensureResourceId(activeProject).then(() => setProjects(loadProjects())); } }, [activeProject]); + // Sandbox binding for the active GitHub project, pushed into session state once + // the session connects so `getDynamicWorkspace` reattaches the right sandbox. + const githubBindingRef = useRef<{ githubProjectId: string; sandboxId: string; sandboxWorkdir: string } | null>(null); + // When a project is selected, ensure it has a server-resolved (TUI-matching) // resourceId, then activate it. The hook re-mounts with that resourceId, // creating/resuming the shared session; projectPath is pushed once connected. + const [preparing, setPreparing] = useState(false); + const [prepareStatus, setPrepareStatus] = useState('Preparing sandbox…'); const handleSelectProject = async (project: Project | null) => { if (!project) { setActiveProjectId(null); return; } + + // GitHub projects are materialized into a cloud sandbox on open: provision/ + // reattach the sandbox, clone/pull the repo, then bind the sandbox into the + // session state so the workspace reattaches to it. + if (project.source === 'github' && project.githubProjectId) { + setPrepareStatus('Preparing sandbox…'); + setPreparing(true); + try { + const result = await ensureRepoMaterialized(project.githubProjectId, ev => setPrepareStatus(ev.message)); + githubBindingRef.current = { + githubProjectId: result.githubProjectId, + sandboxId: result.sandboxId, + sandboxWorkdir: result.sandboxWorkdir, + }; + // Persist the sandbox binding on the project so a re-opened project + // (e.g. after a reload, before the ref is repopulated) still has the + // sandbox id/workdir available for the workspace to reattach. Seed the + // repo-root worktree so the worktree tree always has a base entry. + const rootBranch = project.gitBranch ?? 'main'; + const rootWorktree: Worktree = { + branch: rootBranch, + worktreePath: result.sandboxWorkdir, + baseBranch: rootBranch, + }; + const existingWorktrees = project.worktrees?.filter(w => w.worktreePath !== result.sandboxWorkdir) ?? []; + const filled: Project = { + ...project, + resourceId: result.resourceId, + sandboxId: result.sandboxId, + sandboxWorkdir: result.sandboxWorkdir, + worktrees: [rootWorktree, ...existingWorktrees], + }; + updateProject(filled); + setProjects(loadProjects()); + setActiveProjectId(filled.id); + } catch (e) { + const code = (e as { code?: string }).code; + const msg = + code === 'sandbox_not_configured' + ? 'This server has no sandbox provider configured, so GitHub repos can’t be opened.' + : e instanceof Error + ? e.message + : String(e); + toast(msg, 'error'); + } finally { + setPreparing(false); + } + return; + } + // Backfill resourceId for legacy projects created before it was stored. if (!project.resourceId) { try { @@ -251,25 +393,95 @@ export default function App() { setActiveProjectId(project.id); }; - // After the session connects for a new project, set projectPath. + // The session-state payload that binds the workspace to the active project: + // a path for local projects, or the sandbox binding for GitHub projects. + const projectStatePayload = useCallback((): Record<string, unknown> => { + if (activeProject?.source === 'github') { + // Prefer the freshly materialized binding from this open; fall back to the + // binding persisted on the project (e.g. after a page reload). + const binding = + githubBindingRef.current && githubBindingRef.current.githubProjectId === activeProject.githubProjectId + ? githubBindingRef.current + : null; + const worktree = selectedWorktree(activeProject); + return { + // projectPath doubles as the per-worktree thread-scoping tag, so it must + // be the selected worktree path (matching the session prop), not empty. + projectPath: worktree?.worktreePath ?? '', + githubProjectId: activeProject.githubProjectId, + sandboxId: binding?.sandboxId ?? activeProject.sandboxId, + sandboxWorkdir: binding?.sandboxWorkdir ?? activeProject.sandboxWorkdir, + // Bind the agent's workspace to the selected worktree so file edits + + // commands run against that branch's checkout, not the repo root. + worktreePath: worktree?.worktreePath, + branch: worktree?.branch, + }; + } + return { projectPath: activeProject?.path ?? '' }; + }, [activeProject]); + + // Switch the active workspace to an existing worktree: persist the selection + // and rebind the session (which re-tags threads via projectPath and points + // the workspace at the worktree checkout). + const handleSelectWorktree = useCallback( + (worktreePath: string) => { + if (!activeProject || activeProject.source !== 'github') return; + const updated = selectWorktree(activeProject, worktreePath); + setProjects(loadProjects()); + const worktree = updated.worktrees?.find(w => w.worktreePath === worktreePath); + void session.setState({ + projectPath: worktreePath, + worktreePath, + branch: worktree?.branch, + }); + }, + [activeProject, session], + ); + + // Create a new worktree (feature branch) in the sandbox, append it to the + // project's worktree list, then select it as the active workspace. + const handleCreateWorktree = useCallback( + async (branch: string, baseBranch?: string) => { + if (!activeProject || activeProject.source !== 'github' || !activeProject.githubProjectId) return; + const result = await createWorktree(activeProject.githubProjectId, branch, baseBranch); + const worktree: Worktree = { + branch: result.branch, + worktreePath: result.worktreePath, + baseBranch: result.baseBranch, + }; + const withWorktree = upsertWorktree(activeProject, worktree); + const selected = selectWorktree(withWorktree, worktree.worktreePath); + setProjects(loadProjects()); + void session.setState({ + projectPath: worktree.worktreePath, + worktreePath: worktree.worktreePath, + branch: worktree.branch, + }); + return selected; + }, + [activeProject, session], + ); + + // After the session connects for a new project, push its workspace binding. + // Only advance the tracked resourceId once the session is actually ready and + // the state has been pushed; otherwise a resourceId change that arrives before + // `status === 'ready'` would mark itself handled and never push the binding. const prevResourceId = useRef(resourceId); useEffect(() => { - if (resourceId !== prevResourceId.current) { + if (resourceId !== prevResourceId.current && status === 'ready') { prevResourceId.current = resourceId; - if (status === 'ready') { - void session.setState({ projectPath: activeProject?.path ?? '' }); - } + void session.setState(projectStatePayload()); } - }, [resourceId, status, activeProject, session]); + }, [resourceId, status, projectStatePayload, session]); - // Also set projectPath on initial connection for the active project. + // Also push the binding on initial connection for the active project. const initialSet = useRef(false); useEffect(() => { if (status === 'ready' && !initialSet.current && activeProject) { initialSet.current = true; - void session.setState({ projectPath: activeProject.path }); + void session.setState(projectStatePayload()); } - }, [status, activeProject, session]); + }, [status, activeProject, projectStatePayload, session]); const onSubmit = (e: { preventDefault: () => void }) => { e.preventDefault(); @@ -476,6 +688,39 @@ export default function App() { } }; + // ── Auth gate ─────────────────────────────────────────────────────── + // While the initial /auth/me check is in flight, render nothing to avoid a + // splash flash. When auth is enabled but the user is signed out, show the + // splash; the user explicitly chooses to sign in (no auto-redirect). + if (authLoading) { + return <div className="auth-splash auth-splash-loading" />; + } + if (authState.authEnabled && !authState.authenticated) { + return ( + <div className="auth-splash"> + <div className="auth-splash-card"> + <div className="auth-splash-brand"> + <LogoMark size={36} className="auth-splash-logo" /> + <span className="auth-splash-wordmark"> + Mastra<span className="auth-splash-wordmark-accent">Code</span> + </span> + </div> + <h1 className="auth-splash-title">Welcome back</h1> + <p className="auth-splash-tagline"> + Sign in to your account to access your projects and pick up where you left off. + </p> + <button type="button" className="auth-splash-button" onClick={redirectToLogin}> + Continue with WorkOS + <span className="auth-splash-button-arrow" aria-hidden="true"> + → + </span> + </button> + <p className="auth-splash-footnote">Secured by WorkOS · single sign-on</p> + </div> + </div> + ); + } + return ( <div className={`app-layout ${sidebarOpen ? 'sidebar-open' : ''}`}> <Sidebar @@ -485,6 +730,14 @@ export default function App() { setProjectsOpen(true); closeSidebar(); }} + onConnectGithub={ + githubEnabled + ? () => { + setGithubOpen(true); + closeSidebar(); + } + : undefined + } threads={threads} activeThreadId={transcript.threadId} onSwitchThread={id => { @@ -508,6 +761,22 @@ export default function App() { void session.cloneThread(id); toast('Thread cloned', 'success'); }} + worktrees={activeProject?.source === 'github' ? projectWorktrees(activeProject) : undefined} + selectedWorktreePath={activeWorktree?.worktreePath} + onSelectWorktree={path => { + handleSelectWorktree(path); + closeSidebar(); + }} + onCreateWorktree={async (branch, baseBranch) => { + // Let the Sidebar surface failures inline (it keeps the input open for + // retry); we only handle the success path here. + await handleCreateWorktree(branch, baseBranch); + toast(`Worktree ${branch} ready`, 'success'); + closeSidebar(); + }} + account={ + authState.authEnabled && authState.authenticated ? { user: authState.user, onSignOut: signOut } : undefined + } /> {/* Dim + dismiss overlay for the off-canvas sidebar on mobile. */} @@ -611,7 +880,7 @@ export default function App() { )} <div className="banner-row"> <dt>Workspace</dt> - <dd>{activeProject.path}</dd> + <dd>{sessionProjectPath || activeProject.path || '—'}</dd> </div> </dl> <p className="banner-ready">Ready for new conversation</p> @@ -753,6 +1022,23 @@ export default function App() { onClose={() => setProjectsOpen(false)} /> )} + + {githubOpen && ( + <GithubConnectModal + status={githubStatus} + onProjectCreated={p => void handleSelectProject(p)} + onClose={() => setGithubOpen(false)} + /> + )} + + {preparing && ( + <div className="palette-overlay"> + <div className="github-preparing" role="status" aria-live="polite"> + <span className="github-preparing-spinner" aria-hidden="true" /> + {prepareStatus} + </div> + </div> + )} </div> ); } diff --git a/mastracode/src/web/ui/GithubConnectModal.tsx b/mastracode/src/web/ui/GithubConnectModal.tsx new file mode 100644 index 000000000000..a6497e5604a3 --- /dev/null +++ b/mastracode/src/web/ui/GithubConnectModal.tsx @@ -0,0 +1,155 @@ +import { useEffect, useState } from 'react'; + +import type { GithubRepo, GithubStatus } from './github'; +import { connectGithub, createProjectFromRepo, listGithubRepos } from './github'; +import { CloseIcon, FolderIcon, LogoMark, SearchIcon } from './icons'; +import type { Project } from './projects'; +import { addGithubProject } from './projects'; + +interface GithubConnectModalProps { + status: GithubStatus; + onProjectCreated: (project: Project) => void; + onClose: () => void; +} + +/** + * Modal for the GitHub App flow. Two steps: + * 1. Connect — shown when the feature is enabled but the user has no + * installation yet; a button kicks off the GitHub App install redirect. + * 2. Pick a repo — a searchable list of repos across the user's installations; + * selecting one creates a `source: 'github'` project and selects it. + * + * No clone happens here — the repo is materialized into its sandbox on open. + */ +export function GithubConnectModal({ status, onProjectCreated, onClose }: GithubConnectModalProps) { + const connected = status.connected; + const [query, setQuery] = useState(''); + const [repos, setRepos] = useState<GithubRepo[]>([]); + const [loading, setLoading] = useState(connected); + const [busyRepoId, setBusyRepoId] = useState<number | null>(null); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + // Load repos once connected. Searching re-queries the server. + useEffect(() => { + if (!connected) return; + let cancelled = false; + setLoading(true); + setError(null); + listGithubRepos(query || undefined) + .then(list => { + if (!cancelled) setRepos(list); + }) + .catch(e => { + if (!cancelled) setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [connected, query]); + + const handlePick = async (repo: GithubRepo) => { + setBusyRepoId(repo.id); + setError(null); + try { + const created = await createProjectFromRepo(repo); + const stored = addGithubProject(created); + onProjectCreated(stored); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusyRepoId(null); + } + }; + + return ( + <div className="palette-overlay" onClick={onClose}> + <div + className="projects-modal" + role="dialog" + aria-modal="true" + aria-label="Connect GitHub" + onClick={e => e.stopPropagation()} + > + <div className="projects-head"> + <div className="projects-head-title"> + <LogoMark size={20} className="logo-mark" /> + <span>{connected ? 'Open a GitHub repo' : 'Connect GitHub'}</span> + </div> + <button className="settings-close" onClick={onClose} aria-label="Close"> + <CloseIcon size={16} /> + </button> + </div> + + {!connected ? ( + <> + <p className="projects-sub"> + Install the MastraCode GitHub App to pick repositories you have access to and turn them into projects. + Each repo is cloned into its own isolated cloud sandbox when you open it. + </p> + <button className="projects-add-btn" onClick={connectGithub}> + <span>Connect GitHub</span> + </button> + </> + ) : ( + <> + <p className="projects-sub"> + Choose a repository. It's cloned into an isolated cloud sandbox the first time you open the project. + </p> + <div className="github-search"> + <SearchIcon size={15} className="github-search-icon" /> + <input + className="github-search-input" + type="text" + placeholder="Filter repositories…" + value={query} + onChange={e => setQuery(e.target.value)} + autoFocus + /> + </div> + + {error && <p className="github-error">{error}</p>} + + <div className="projects-list"> + {loading ? ( + <p className="github-muted">Loading repositories…</p> + ) : repos.length === 0 ? ( + <p className="github-muted">No repositories found.</p> + ) : ( + repos.map(repo => ( + <button + key={repo.id} + className="project-card" + disabled={busyRepoId !== null} + onClick={() => void handlePick(repo)} + title={repo.fullName} + > + <FolderIcon size={18} className="project-card-icon" /> + <span className="project-card-text"> + <span className="project-card-name">{repo.fullName}</span> + <span className="project-card-path"> + {repo.private ? 'private' : 'public'} · {repo.defaultBranch} + </span> + </span> + {busyRepoId === repo.id && <span className="project-card-badge">Adding…</span>} + </button> + )) + )} + </div> + </> + )} + </div> + </div> + ); +} diff --git a/mastracode/src/web/ui/ProjectsModal.tsx b/mastracode/src/web/ui/ProjectsModal.tsx index c2949af1674a..5d5c2ca25786 100644 --- a/mastracode/src/web/ui/ProjectsModal.tsx +++ b/mastracode/src/web/ui/ProjectsModal.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { DirectoryBrowser } from './DirectoryPicker'; -import { CloseIcon, FolderIcon, LogoMark, PlusIcon } from './icons'; +import { CloseIcon, FolderIcon, GithubIcon, LogoMark, PlusIcon } from './icons'; import type { Project } from './projects'; import { addProject, loadProjects, removeProject } from './projects'; @@ -105,6 +105,7 @@ export function ProjectsModal({ <div className="projects-list"> {projects.map(p => { const active = p.id === activeProjectId; + const isGithub = p.source === 'github'; return ( <button key={p.id} @@ -113,12 +114,26 @@ export function ProjectsModal({ onSelectProject(p); onClose(); }} - title={p.path} + title={isGithub ? 'GitHub repository' : p.path} > - <FolderIcon size={18} className="project-card-icon" /> + {isGithub ? ( + <GithubIcon size={18} className="project-card-icon" /> + ) : ( + <FolderIcon size={18} className="project-card-icon" /> + )} <span className="project-card-text"> - <span className="project-card-name">{p.name}</span> - <span className="project-card-path">{p.path}</span> + <span className="project-card-name" title={p.name}> + {p.name} + </span> + {/* For GitHub projects `name` is the `owner/repo` identifier; keep it + visible and show the tracked branch (when known) in the subtitle + instead of a redundant "GitHub repo" label. */} + <span className="project-card-path" title={isGithub ? p.name : p.path}> + {isGithub ? (p.gitBranch ? `branch: ${p.gitBranch}` : 'GitHub repo') : p.path} + </span> + </span> + <span className={`project-card-source ${isGithub ? 'github' : 'local'}`}> + {isGithub ? 'GitHub' : 'Local'} </span> {active && <span className="project-card-badge">Active</span>} <span diff --git a/mastracode/src/web/ui/SettingsPanel.tsx b/mastracode/src/web/ui/SettingsPanel.tsx index ab1538aaab2e..aae36508bbb6 100644 --- a/mastracode/src/web/ui/SettingsPanel.tsx +++ b/mastracode/src/web/ui/SettingsPanel.tsx @@ -6,6 +6,7 @@ import type { ToolCategory, } from '@mastra/client-js'; import { useEffect, useMemo, useRef, useState } from 'react'; +import type { ReactElement } from 'react'; import { CustomProvidersSection } from './CustomProvidersSection'; import { @@ -62,7 +63,7 @@ const NOTIFICATION_MODES: { value: NotificationMode; label: string }[] = [ { value: 'both', label: 'Both' }, ]; -const TABS: { id: Tab; label: string; icon: (p: { size?: number }) => JSX.Element }[] = [ +const TABS: { id: Tab; label: string; icon: (p: { size?: number }) => ReactElement }[] = [ { id: 'general', label: 'General', icon: PaletteIcon }, { id: 'model', label: 'Model', icon: SearchIcon }, { id: 'packs', label: 'Packs', icon: LayersIcon }, diff --git a/mastracode/src/web/ui/Sidebar.tsx b/mastracode/src/web/ui/Sidebar.tsx index 47384bd32f93..335bb93c2fef 100644 --- a/mastracode/src/web/ui/Sidebar.tsx +++ b/mastracode/src/web/ui/Sidebar.tsx @@ -1,8 +1,8 @@ import type { AgentControllerThreadInfo } from '@mastra/client-js'; import { useEffect, useRef, useState } from 'react'; -import { CloseIcon, EllipsisIcon, FolderIcon, PlusIcon, Wordmark } from './icons'; -import type { Project } from './projects'; +import { ChevronIcon, CloseIcon, EllipsisIcon, FolderIcon, GithubIcon, PlusIcon, TargetIcon, Wordmark } from './icons'; +import type { Project, Worktree } from './projects'; const MAX_THREADS = 5; @@ -26,6 +26,11 @@ interface SidebarProps { activeProjectId: string | null; /** Open the app-level Projects modal (add / manage / switch). */ onManageProjects: () => void; + /** + * Open the GitHub connect / repo-picker modal. Only provided when the GitHub + * App feature is enabled; otherwise the entry point is hidden. + */ + onConnectGithub?: () => void; threads: AgentControllerThreadInfo[]; activeThreadId?: string; onSwitchThread: (threadId: string) => void; @@ -33,12 +38,33 @@ interface SidebarProps { onDeleteThread: (threadId: string) => void; onRenameThread: (threadId: string, title: string) => void; onCloneThread: (threadId: string) => void; + /** + * Worktrees (workspaces) of the active GitHub project. Empty for local + * projects, which keep a flat thread list instead of the worktree tree. + */ + worktrees?: Worktree[]; + /** Path of the currently selected worktree, if any. */ + selectedWorktreePath?: string; + /** Switch the active workspace to an existing worktree. */ + onSelectWorktree?: (worktreePath: string) => void; + /** Create a new worktree (feature branch) and select it. */ + onCreateWorktree?: (branch: string, baseBranch?: string) => Promise<unknown> | void; + /** + * Signed-in account info + sign-out handler. Only provided when the optional + * WorkOS auth gate is active and the user is authenticated; otherwise the + * account section is hidden entirely. + */ + account?: { + user?: { email?: string; name?: string }; + onSignOut: () => void; + }; } export function Sidebar({ projects, activeProjectId, onManageProjects, + onConnectGithub, threads, activeThreadId, onSwitchThread, @@ -46,6 +72,11 @@ export function Sidebar({ onDeleteThread, onRenameThread, onCloneThread, + worktrees, + selectedWorktreePath, + onSelectWorktree, + onCreateWorktree, + account, }: SidebarProps) { // Per-thread action menu (⋯): which thread's menu is open, and inline-rename state. const [menuFor, setMenuFor] = useState<string | null>(null); @@ -53,6 +84,29 @@ export function Sidebar({ const [renameDraft, setRenameDraft] = useState(''); const menuRef = useRef<HTMLDivElement | null>(null); + // Worktree tree (GitHub projects): collapse toggle + inline "new workspace" input. + const [treeCollapsed, setTreeCollapsed] = useState(false); + const [creatingWorktree, setCreatingWorktree] = useState(false); + const [newBranchDraft, setNewBranchDraft] = useState(''); + const [creatingBusy, setCreatingBusy] = useState(false); + const [worktreeError, setWorktreeError] = useState<string | null>(null); + + const submitNewWorktree = async () => { + const branch = newBranchDraft.trim(); + if (!branch || !onCreateWorktree) return; + setCreatingBusy(true); + setWorktreeError(null); + try { + await onCreateWorktree(branch); + setNewBranchDraft(''); + setCreatingWorktree(false); + } catch (err) { + setWorktreeError(err instanceof Error ? err.message : 'Failed to create worktree'); + } finally { + setCreatingBusy(false); + } + }; + // Close the action menu on outside click / Escape. useEffect(() => { if (!menuFor) return; @@ -93,6 +147,81 @@ export function Sidebar({ .slice(0, MAX_THREADS); const activeProject = projects.find(p => p.id === activeProjectId); + const isGithubProject = activeProject?.source === 'github'; + const worktreeList = worktrees ?? []; + // The worktree these threads belong to: the explicit selection, else the first. + const activeWorktreePath = selectedWorktreePath ?? worktreeList[0]?.worktreePath; + + // A single thread row (button + ⋯ menu, or inline-rename input). Shared by the + // flat local list and the per-worktree nested list. + const renderThread = (t: AgentControllerThreadInfo) => + renamingId === t.id ? ( + <div key={t.id} className="sidebar-thread renaming"> + <input + className="sidebar-rename-input" + autoFocus + value={renameDraft} + placeholder="Thread title" + onChange={e => setRenameDraft(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') commitRename(t.id); + if (e.key === 'Escape') { + setRenamingId(null); + setRenameDraft(''); + } + }} + onBlur={() => commitRename(t.id)} + /> + </div> + ) : ( + <div key={t.id} className={`sidebar-thread ${t.id === activeThreadId ? 'active' : ''}`}> + <button className="sidebar-thread-main" onClick={() => onSwitchThread(t.id)}> + <span className={`sidebar-thread-title ${t.title ? '' : 'untitled'}`}>{t.title || 'Untitled'}</span> + {t.updatedAt && <span className="sidebar-thread-date">{relativeTime(t.updatedAt)}</span>} + </button> + <div className="sidebar-thread-menu" ref={menuFor === t.id ? menuRef : undefined}> + <button + className="sidebar-thread-action" + title="Thread actions" + aria-label="Thread actions" + aria-haspopup="menu" + aria-expanded={menuFor === t.id} + onClick={e => { + e.stopPropagation(); + setMenuFor(prev => (prev === t.id ? null : t.id)); + }} + > + <EllipsisIcon size={15} /> + </button> + {menuFor === t.id && ( + <div className="sidebar-menu-popover" role="menu"> + <button role="menuitem" onClick={() => startRename(t)}> + Rename + </button> + <button + role="menuitem" + onClick={() => { + setMenuFor(null); + onCloneThread(t.id); + }} + > + Clone + </button> + <button + role="menuitem" + className="danger" + onClick={() => { + setMenuFor(null); + onDeleteThread(t.id); + }} + > + Delete + </button> + </div> + )} + </div> + </div> + ); return ( <div className="sidebar"> @@ -118,25 +247,36 @@ export function Sidebar({ <button className={`project-switcher ${activeProject ? '' : 'empty'}`} onClick={onManageProjects} - title={activeProject ? activeProject.path : 'Select a project'} + title={activeProject ? (activeProject.path ?? activeProject.name) : 'Select a project'} > - <FolderIcon size={16} className="project-switcher-icon" /> + {activeProject?.source === 'github' ? ( + <GithubIcon size={16} className="project-switcher-icon" /> + ) : ( + <FolderIcon size={16} className="project-switcher-icon" /> + )} <span className="project-switcher-text"> {activeProject ? ( <> <span className="project-switcher-name">{activeProject.name}</span> - <span className="project-switcher-path">{activeProject.path}</span> + <span className="project-switcher-path"> + {activeProject.source === 'github' ? 'GitHub repo' : activeProject.path} + </span> </> ) : ( <span className="project-switcher-name">Select a project…</span> )} </span> + {activeProject && ( + <span className={`project-switcher-source ${activeProject.source === 'github' ? 'github' : 'local'}`}> + {activeProject.source === 'github' ? 'GitHub' : 'Local'} + </span> + )} <CloseIcon size={13} className="project-switcher-chevron" /> </button> </div> - {/* ── Threads (scoped to active project) ────────────────────────── */} - {activeProject && ( + {/* ── Local project: flat thread list ───────────────────────────── */} + {activeProject && !isGithubProject && ( <div className="sidebar-section sidebar-section-grow"> <div className="sidebar-section-header"> <span className="sidebar-section-title"> @@ -154,79 +294,134 @@ export function Sidebar({ <div className="sidebar-list"> {sortedThreads.length === 0 && <div className="sidebar-empty">No threads yet</div>} - {sortedThreads.map(t => - renamingId === t.id ? ( - <div key={t.id} className="sidebar-thread renaming"> - <input - className="sidebar-rename-input" - autoFocus - value={renameDraft} - placeholder="Thread title" - onChange={e => setRenameDraft(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') commitRename(t.id); - if (e.key === 'Escape') { - setRenamingId(null); - setRenameDraft(''); - } - }} - onBlur={() => commitRename(t.id)} - /> + {sortedThreads.map(renderThread)} + {threads.length > MAX_THREADS && ( + <div className="sidebar-overflow">+{threads.length - MAX_THREADS} more</div> + )} + </div> + </div> + )} + + {/* ── GitHub project: project → worktree → threads tree ─────────── */} + {activeProject && isGithubProject && ( + <div className="sidebar-section sidebar-section-grow"> + <div className="sidebar-section-header"> + <button + className="sidebar-tree-toggle" + aria-expanded={!treeCollapsed} + onClick={() => setTreeCollapsed(c => !c)} + > + <ChevronIcon size={13} className={`sidebar-tree-chevron ${treeCollapsed ? '' : 'open'}`} /> + <span className="sidebar-section-title">Worktrees</span> + {worktreeList.length > 0 && <span className="sidebar-count">{worktreeList.length}</span>} + </button> + <button + className="sidebar-icon-btn" + title="New worktree" + aria-label="New worktree" + onClick={() => setCreatingWorktree(v => !v)} + disabled={!onCreateWorktree} + > + <PlusIcon size={15} /> + </button> + </div> + + {creatingWorktree && ( + <div className="sidebar-newworkspace"> + <input + className="sidebar-rename-input" + autoFocus + value={newBranchDraft} + placeholder="new-branch-name" + aria-label="New worktree branch name" + disabled={creatingBusy} + onChange={e => { + setNewBranchDraft(e.target.value); + if (worktreeError) setWorktreeError(null); + }} + onKeyDown={e => { + if (e.key === 'Enter') void submitNewWorktree(); + if (e.key === 'Escape') { + setCreatingWorktree(false); + setNewBranchDraft(''); + setWorktreeError(null); + } + }} + onBlur={() => { + if (!newBranchDraft.trim() && !worktreeError) setCreatingWorktree(false); + }} + /> + {worktreeError && ( + <div className="sidebar-newworkspace-error" role="alert"> + {worktreeError} </div> - ) : ( - <div key={t.id} className={`sidebar-thread ${t.id === activeThreadId ? 'active' : ''}`}> - <button className="sidebar-thread-main" onClick={() => onSwitchThread(t.id)}> - <span className={`sidebar-thread-title ${t.title ? '' : 'untitled'}`}>{t.title || 'Untitled'}</span> - {t.updatedAt && <span className="sidebar-thread-date">{relativeTime(t.updatedAt)}</span>} - </button> - <div className="sidebar-thread-menu" ref={menuFor === t.id ? menuRef : undefined}> + )} + </div> + )} + + {!treeCollapsed && ( + <div className="sidebar-tree"> + {worktreeList.length === 0 && <div className="sidebar-empty">Preparing worktree…</div>} + {worktreeList.map(w => { + const isActive = w.worktreePath === activeWorktreePath; + return ( + <div key={w.worktreePath} className="sidebar-worktree-group"> <button - className="sidebar-thread-action" - title="Thread actions" - aria-label="Thread actions" - aria-haspopup="menu" - aria-expanded={menuFor === t.id} - onClick={e => { - e.stopPropagation(); - setMenuFor(prev => (prev === t.id ? null : t.id)); - }} + className={`sidebar-worktree ${isActive ? 'active' : ''}`} + title={w.worktreePath} + onClick={() => onSelectWorktree?.(w.worktreePath)} > - <EllipsisIcon size={15} /> + <TargetIcon size={13} className="sidebar-worktree-icon" /> + <span className="sidebar-worktree-branch">{w.branch}</span> </button> - {menuFor === t.id && ( - <div className="sidebar-menu-popover" role="menu"> - <button role="menuitem" onClick={() => startRename(t)}> - Rename - </button> - <button - role="menuitem" - onClick={() => { - setMenuFor(null); - onCloneThread(t.id); - }} - > - Clone - </button> - <button - role="menuitem" - className="danger" - onClick={() => { - setMenuFor(null); - onDeleteThread(t.id); - }} - > - Delete - </button> + + {isActive && ( + <div className="sidebar-worktree-threads"> + <div className="sidebar-worktree-threads-header"> + <span className="sidebar-worktree-threads-title">Threads</span> + <button + className="sidebar-icon-btn" + title="New thread" + aria-label="New thread" + onClick={() => onCreateThread()} + > + <PlusIcon size={14} /> + </button> + </div> + {sortedThreads.length === 0 && <div className="sidebar-empty">No threads yet</div>} + {sortedThreads.map(renderThread)} + {threads.length > MAX_THREADS && ( + <div className="sidebar-overflow">+{threads.length - MAX_THREADS} more</div> + )} </div> )} </div> - </div> - ), - )} - {threads.length > MAX_THREADS && ( - <div className="sidebar-overflow">+{threads.length - MAX_THREADS} more</div> + ); + })} + </div> + )} + </div> + )} + + {/* ── Connect GitHub repo (sits just above the account footer) ──── */} + {onConnectGithub && ( + <button className="sidebar-github-btn" onClick={onConnectGithub} title="Connect a GitHub repository"> + <span>Connect GitHub repo</span> + </button> + )} + + {/* ── Account (only when WorkOS auth is active) ─────────────────── */} + {account && ( + <div className="sidebar-section sidebar-account"> + <div className="sidebar-account-info"> + <span className="sidebar-account-name">{account.user?.name || account.user?.email || 'Signed in'}</span> + {account.user?.email && account.user?.name && ( + <span className="sidebar-account-email">{account.user.email}</span> )} </div> + <button className="sidebar-signout-btn" onClick={account.onSignOut} title="Sign out"> + Sign out + </button> </div> )} </div> diff --git a/mastracode/src/web/ui/__tests__/message-rendering.msw.test.tsx b/mastracode/src/web/ui/__tests__/message-rendering.msw.test.tsx new file mode 100644 index 000000000000..f2984f10a52a --- /dev/null +++ b/mastracode/src/web/ui/__tests__/message-rendering.msw.test.tsx @@ -0,0 +1,186 @@ +import type { AgentControllerEvent, AgentControllerMessage, AgentControllerSessionState } from '@mastra/client-js'; +import { screen, within } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { server } from '../../../../e2e/web-ui/msw-server'; +import { renderWithProviders, TEST_BASE_URL } from '../../../../e2e/web-ui/render'; +import App from '../App'; +import type { Project } from '../projects'; + +const API = `${TEST_BASE_URL}/api/agent-controller/code`; +const RESOURCE_ID = 'resource-test'; +const SESSION = `${API}/sessions/${RESOURCE_ID}`; +const THREAD_ID = 'thread-test'; +const PROJECT_PATH = '/tmp/mastracode-test'; + +function seedProject() { + const project: Project = { + id: 'project-test', + name: 'MastraCode Test', + path: PROJECT_PATH, + resourceId: RESOURCE_ID, + createdAt: 1, + }; + localStorage.setItem('mastracode-projects', JSON.stringify([project])); + localStorage.setItem('mastracode-active-project', project.id); +} + +function sessionState(): AgentControllerSessionState { + return { + controllerId: 'code', + resourceId: RESOURCE_ID, + modeId: 'build', + modelId: 'openai/gpt-4o-mini', + threadId: THREAD_ID, + settings: { yolo: false, thinkingLevel: 'medium', notifications: 'bell', smartEditing: true }, + }; +} + +function sse(events: AgentControllerEvent[] = []): Response { + const encoder = new TextEncoder(); + return new Response( + new ReadableStream<Uint8Array>({ + start(controller) { + for (const event of events) controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + }, + cancel() {}, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ); +} + +function delayedSse(event: AgentControllerEvent) { + const encoder = new TextEncoder(); + let emit: () => void = () => {}; + let markReady: () => void = () => {}; + const ready = new Promise<void>(resolve => { + markReady = resolve; + }); + const response = new Response( + new ReadableStream<Uint8Array>({ + start(controller) { + emit = () => controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + markReady(); + }, + cancel() {}, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ); + return { response, emit: () => ready.then(() => emit()) }; +} + +function useAgentControllerHandlers({ + messages = [], + events = [], +}: { + messages?: AgentControllerMessage[]; + events?: AgentControllerEvent[]; +} = {}) { + server.use( + http.post(`${API}/sessions`, () => + HttpResponse.json({ controllerId: 'code', resourceId: RESOURCE_ID, threadId: THREAD_ID }), + ), + http.get(`${API}/modes`, () => HttpResponse.json({ modes: [{ id: 'build', label: 'Build' }] })), + http.get(`${API}/models`, () => HttpResponse.json({ models: [] })), + http.get(SESSION, () => HttpResponse.json(sessionState())), + http.put(`${SESSION}/state`, () => HttpResponse.json(sessionState())), + http.get(`${SESSION}/threads`, () => HttpResponse.json({ threads: [] })), + http.get(`${SESSION}/threads/${THREAD_ID}/messages`, () => HttpResponse.json({ messages })), + http.get(`${SESSION}/stream`, () => sse(events)), + ); +} + +afterEach(() => localStorage.clear()); + +describe('MastraCode message rendering', () => { + it('renders hydrated persisted text, thinking, and tool content through Mastra message parts', async () => { + seedProject(); + useAgentControllerHandlers({ + messages: [ + { + id: 'assistant-1', + role: 'assistant', + content: [ + { type: 'text', text: '**Hello** from hydrate' }, + { type: 'thinking', thinking: 'checking files' }, + { type: 'tool_call', id: 'tool-1', name: 'view', args: { path: 'README.md' } }, + { type: 'tool_result', id: 'tool-1', name: 'view', result: 'readme contents' }, + ], + }, + ], + }); + + renderWithProviders(<App />); + + expect(await screen.findByText('Hello')).toBeInTheDocument(); + expect(screen.getByText('from hydrate')).toBeInTheDocument(); + expect(screen.getByText('checking files')).toBeInTheDocument(); + const toolName = screen.getAllByText('view').find(node => node.closest('.tool-card')); + if (!toolName) throw new Error('missing view tool card'); + const card = toolName.closest('.tool-card'); + if (!(card instanceof HTMLElement)) throw new Error('missing view tool card wrapper'); + expect(within(card).getByText('Done')).toBeInTheDocument(); + }); + + it('renders assistant text when SSE message updates arrive after subscription', async () => { + seedProject(); + const stream = delayedSse({ + type: 'message_update', + message: { id: 'assistant-stream', role: 'assistant', content: [{ type: 'text', text: 'Streaming now' }] }, + }); + useAgentControllerHandlers(); + server.use(http.get(`${SESSION}/stream`, () => stream.response)); + + renderWithProviders(<App />); + + expect(await screen.findByText('ready')).toBeInTheDocument(); + await stream.emit(); + + expect(await screen.findByText('Streaming now')).toBeInTheDocument(); + }); + + it('renders tool lifecycle events inline before a later message update re-emits the tool part', async () => { + seedProject(); + useAgentControllerHandlers({ + events: [ + { type: 'tool_input_start', toolCallId: 'tool-live', toolName: 'execute_command' }, + { + type: 'tool_input_delta', + toolCallId: 'tool-live', + argsTextDelta: '{"command":"pnpm test"}', + toolName: 'execute_command', + }, + { type: 'tool_start', toolCallId: 'tool-live', toolName: 'execute_command', args: { command: 'pnpm test' } }, + { type: 'shell_output', toolCallId: 'tool-live', output: 'passing tests', stream: 'stdout' }, + { type: 'tool_end', toolCallId: 'tool-live', result: 'ok' }, + ], + }); + + renderWithProviders(<App />); + + const toolName = await screen.findByText('execute_command'); + const card = toolName.closest('.tool-card'); + if (!(card instanceof HTMLElement)) throw new Error('missing tool card'); + expect(within(card).getByText('Done')).toBeInTheDocument(); + expect(within(card).getByText('passing tests')).toBeInTheDocument(); + }); + + it('renders status metadata as status UI instead of raw JSON', async () => { + seedProject(); + useAgentControllerHandlers({ + messages: [ + { + id: 'assistant-status', + role: 'assistant', + content: [{ type: 'om_thread_title_updated', text: 'Thread title updated: Better title' }], + }, + ], + }); + + renderWithProviders(<App />); + + expect(await screen.findByText('Thread title updated: Better title')).toBeInTheDocument(); + expect(screen.queryByText(/om_thread_title_updated/)).not.toBeInTheDocument(); + }); +}); diff --git a/mastracode/src/web/ui/agent-controller-message-accumulator.test.ts b/mastracode/src/web/ui/agent-controller-message-accumulator.test.ts new file mode 100644 index 000000000000..88f6ba02e146 --- /dev/null +++ b/mastracode/src/web/ui/agent-controller-message-accumulator.test.ts @@ -0,0 +1,65 @@ +import type { AgentControllerMessage } from '@mastra/client-js'; +import { describe, expect, it } from 'vitest'; + +import { toMastraDBMessage } from './agent-controller-message-accumulator'; + +describe('agent controller message accumulator', () => { + it('converts visible controller content into ordered Mastra message parts', () => { + const message: AgentControllerMessage = { + id: 'message-1', + role: 'assistant', + content: [ + { type: 'text', text: 'I will inspect the file.' }, + { type: 'thinking', thinking: 'Need to check the current implementation.' }, + { type: 'tool_call', id: 'tool-1', name: 'read_file', args: { path: 'src/index.ts' } }, + { type: 'tool_result', id: 'tool-1', name: 'read_file', result: 'export const value = 1;' }, + ], + }; + + const converted = toMastraDBMessage(message); + + expect(converted).toMatchObject({ + id: 'message-1', + role: 'assistant', + content: { format: 2 }, + }); + expect(converted.createdAt).toBeInstanceOf(Date); + expect(converted.content.parts).toEqual([ + { type: 'text', text: 'I will inspect the file.' }, + { + type: 'reasoning', + reasoning: 'Need to check the current implementation.', + details: [{ type: 'text', text: 'Need to check the current implementation.' }], + }, + { + type: 'tool-invocation', + toolInvocation: { + state: 'result', + toolCallId: 'tool-1', + toolName: 'read_file', + args: { path: 'src/index.ts' }, + result: 'export const value = 1;', + }, + }, + ]); + }); + + it('stores structured status content as harness metadata with readable fallback text', () => { + const message: AgentControllerMessage = { + id: 'status-1', + role: 'system', + content: [ + { type: 'notification_summary', text: 'Review pending notifications' }, + { type: 'om_thread_title_updated', text: 'Refactor transcript renderer' }, + ], + }; + + const converted = toMastraDBMessage(message); + + expect(converted.content.metadata?.harnessContent).toEqual(message.content); + expect(converted.content.parts).toEqual([ + { type: 'text', text: 'Review pending notifications' }, + { type: 'text', text: 'Thread title updated: Refactor transcript renderer' }, + ]); + }); +}); diff --git a/mastracode/src/web/ui/agent-controller-message-accumulator.ts b/mastracode/src/web/ui/agent-controller-message-accumulator.ts new file mode 100644 index 000000000000..b8337813b70b --- /dev/null +++ b/mastracode/src/web/ui/agent-controller-message-accumulator.ts @@ -0,0 +1,120 @@ +import type { AgentControllerMessage, AgentControllerMessageContent } from '@mastra/client-js'; +import type { MastraDBMessage, MastraMessagePart } from '@mastra/core/agent'; + +const fallbackCreatedAtByMessageId = new Map<string, Date>(); + +export function toMastraDBMessage(message: AgentControllerMessage): MastraDBMessage { + const harnessContent = message.content.filter(isHarnessMetadataContent); + + return { + id: message.id, + role: message.role, + createdAt: createdAtForMessage(message.id), + content: { + format: 2, + parts: toMastraMessageParts(message.content), + ...(harnessContent.length > 0 ? { metadata: { harnessContent } } : {}), + }, + }; +} + +function toMastraMessageParts(content: AgentControllerMessageContent[]): MastraMessagePart[] { + const parts: MastraMessagePart[] = []; + const toolPartIndexById = new Map<string, number>(); + + for (const part of content) { + switch (part.type) { + case 'text': + if (part.text) parts.push({ type: 'text', text: part.text }); + break; + case 'thinking': + if (part.thinking) { + parts.push({ + type: 'reasoning', + reasoning: part.thinking, + details: [{ type: 'text', text: part.thinking }], + }); + } + break; + case 'tool_call': { + const toolCallId = part.id ?? ''; + toolPartIndexById.set(toolCallId, parts.length); + parts.push({ + type: 'tool-invocation', + toolInvocation: { + state: 'call', + toolCallId, + toolName: part.name ?? '', + args: part.args, + }, + }); + break; + } + case 'tool_result': { + const toolCallId = part.id ?? ''; + const existingIndex = toolPartIndexById.get(toolCallId); + const previousPart = existingIndex === undefined ? undefined : parts[existingIndex]; + const previousInvocation = previousPart?.type === 'tool-invocation' ? previousPart.toolInvocation : undefined; + const toolName = part.name ?? previousInvocation?.toolName ?? ''; + const resultPart: MastraMessagePart = part.isError + ? { + type: 'tool-invocation', + toolInvocation: { + state: 'output-error', + toolCallId, + toolName, + args: previousInvocation?.args, + result: part.result, + errorText: typeof part.result === 'string' ? part.result : JSON.stringify(part.result), + }, + } + : { + type: 'tool-invocation', + toolInvocation: { + state: 'result', + toolCallId, + toolName, + args: previousInvocation?.args, + result: part.result, + }, + }; + + if (existingIndex === undefined) { + toolPartIndexById.set(toolCallId, parts.length); + parts.push(resultPart); + } else { + parts[existingIndex] = resultPart; + } + break; + } + default: { + const statusText = toStatusText(part); + if (statusText) parts.push({ type: 'text', text: statusText }); + break; + } + } + } + + return parts; +} + +function isHarnessMetadataContent(part: AgentControllerMessageContent): boolean { + return !['text', 'thinking', 'tool_call', 'tool_result'].includes(part.type); +} + +function toStatusText(part: AgentControllerMessageContent): string | null { + if (part.type === 'om_thread_title_updated' && part.text) { + return `Thread title updated: ${part.text}`; + } + + return part.text ?? null; +} + +function createdAtForMessage(messageId: string): Date { + const existing = fallbackCreatedAtByMessageId.get(messageId); + if (existing) return existing; + + const createdAt = new Date(); + fallbackCreatedAtByMessageId.set(messageId, createdAt); + return createdAt; +} diff --git a/mastracode/src/web/ui/auth.ts b/mastracode/src/web/ui/auth.ts new file mode 100644 index 000000000000..815ed502aa00 --- /dev/null +++ b/mastracode/src/web/ui/auth.ts @@ -0,0 +1,60 @@ +/** + * Client-side glue for the optional WorkOS AuthKit gate (see ../auth.ts). + * + * The server protects the whole surface; this module makes the SPA cooperate: + * - `fetchAuthState()` reads `/auth/me` to decide whether to show the splash + * (unauthenticated) or the app, and to render identity / sign-out. Degrades + * gracefully to "auth disabled" when the route is absent. + * - `redirectToLogin()` / `loginUrl()` send the user to the hosted WorkOS login + * from the splash "Sign in" button. + */ + +export interface WebAuthState { + /** Whether the server has WorkOS auth configured. */ + authEnabled: boolean; + authenticated: boolean; + user?: { email?: string; name?: string }; +} + +/** + * Build the hosted-login URL, preserving the current location so the user is + * returned here after authenticating. Used by the splash "Sign in" button. + */ +export function loginUrl(): string { + const returnTo = window.location.pathname + window.location.search; + return `/auth/login?returnTo=${encodeURIComponent(returnTo)}`; +} + +/** + * Redirect the browser to the hosted login. Called from the splash screen when + * the user clicks "Sign in". + */ +export function redirectToLogin(): void { + window.location.assign(loginUrl()); +} + +/** + * Fetch the current auth state from `/auth/me`. When the route is missing (auth + * disabled), reports `authEnabled: false` so the UI hides all auth affordances. + */ +export async function fetchAuthState(): Promise<WebAuthState> { + try { + const res = await fetch('/auth/me', { headers: { Accept: 'application/json' } }); + if (res.status === 404) { + return { authEnabled: false, authenticated: false }; + } + if (!res.ok) { + return { authEnabled: true, authenticated: false }; + } + const data = (await res.json()) as { authenticated?: boolean; user?: { email?: string; name?: string } | null }; + return { + authEnabled: true, + authenticated: Boolean(data.authenticated), + user: data.user ?? undefined, + }; + } catch { + // Network error or non-JSON response → treat as auth not configured so the + // app stays usable rather than blocking on a missing endpoint. + return { authEnabled: false, authenticated: false }; + } +} diff --git a/mastracode/src/web/ui/components.tsx b/mastracode/src/web/ui/components.tsx index 1af3a58ca800..8b2781cdc261 100644 --- a/mastracode/src/web/ui/components.tsx +++ b/mastracode/src/web/ui/components.tsx @@ -1,5 +1,7 @@ import type { PlanResume, AgentControllerOMProgress } from '@mastra/client-js'; -import { memo, useEffect, useState } from 'react'; +import { MessageFactory } from '@mastra/react'; +import type { FilePart, MessageRoleRenderers, ReasoningPart, TextPart, ToolInvocationPart } from '@mastra/react'; +import { memo, useEffect, useMemo, useState } from 'react'; import { highlightCode, languageForPath } from './highlight'; import { BellIcon, BrainIcon, ChevronIcon, CopyIcon, FolderIcon, LogoMark, TargetIcon, ToolIcon } from './icons'; @@ -8,8 +10,8 @@ import { useToast } from './toast'; import type { ApprovalPrompt, - AssistantEntry, GoalSnapshot, + MessageEntry, NoticeEntry, NotificationEntry, NotificationSummaryEntry, @@ -17,7 +19,6 @@ import type { SuspensionPrompt, TimelineEntry, ToolCall, - UserEntry, OMPhase, } from './transcript'; @@ -141,13 +142,26 @@ interface EditArgs { content?: string; } +function hasProperty<K extends string>(value: object, key: K): value is object & Record<K, unknown> { + return key in value; +} + +function stringProperty(value: unknown, key: string): string | undefined { + if (!value || typeof value !== 'object' || !hasProperty(value, key)) return undefined; + return typeof value[key] === 'string' ? value[key] : undefined; +} + /** Detect edit-style tools whose args are better shown as a diff/code block. */ function editArgs(toolName: string, args: unknown): EditArgs | undefined { - if (!args || typeof args !== 'object') return undefined; - const a = args as EditArgs; - const isReplace = /string_replace|str_replace/i.test(toolName) && typeof a.new_string === 'string'; - const isWrite = /write_file|create_file/i.test(toolName) && typeof a.content === 'string'; - return isReplace || isWrite ? a : undefined; + const edit = { + path: stringProperty(args, 'path'), + old_string: stringProperty(args, 'old_string'), + new_string: stringProperty(args, 'new_string'), + content: stringProperty(args, 'content'), + }; + const isReplace = /string_replace|str_replace/i.test(toolName) && edit.new_string !== undefined; + const isWrite = /write_file|create_file/i.test(toolName) && edit.content !== undefined; + return isReplace || isWrite ? edit : undefined; } function ToolCard({ tool, forceExpanded }: { tool: ToolCall; forceExpanded?: boolean }) { @@ -277,6 +291,36 @@ interface SuspendPayloadShape { title?: string; } +function suspensionPayloadShape(payload: unknown): SuspendPayloadShape { + const planValue = payload && typeof payload === 'object' && hasProperty(payload, 'plan') ? payload.plan : undefined; + const plan = + planValue && typeof planValue === 'object' + ? { + title: stringProperty(planValue, 'title'), + summary: stringProperty(planValue, 'summary'), + } + : undefined; + + const optionsValue = + payload && typeof payload === 'object' && hasProperty(payload, 'options') ? payload.options : undefined; + const options = Array.isArray(optionsValue) + ? optionsValue.flatMap(option => { + const label = stringProperty(option, 'label'); + if (!label) return []; + return [{ label, description: stringProperty(option, 'description') }]; + }) + : undefined; + + return { + question: stringProperty(payload, 'question'), + options, + requestedPath: stringProperty(payload, 'requestedPath') ?? stringProperty(payload, 'path'), + reason: stringProperty(payload, 'reason'), + title: stringProperty(payload, 'title'), + plan, + }; +} + function SuspensionCard({ prompt, onRespond, @@ -284,7 +328,7 @@ function SuspensionCard({ prompt: SuspensionPrompt; onRespond: (toolCallId: string, resumeData: string | string[] | PlanResume, promptId: string) => void; }) { - const payload = (prompt.suspendPayload ?? {}) as SuspendPayloadShape; + const payload = suspensionPayloadShape(prompt.suspendPayload); if (prompt.toolName === 'submit_plan') { return ( @@ -465,10 +509,8 @@ export const Transcript = memo(function Transcript({ <> {entries.map(entry => { switch (entry.kind) { - case 'user': - return <UserBubble key={entry.id} entry={entry} />; - case 'assistant': - return <AssistantBubble key={entry.id} entry={entry} />; + case 'message': + return <MessageBubble key={entry.id} entry={entry} />; case 'notice': return <Notice key={entry.id} entry={entry} />; case 'approval': @@ -489,78 +531,159 @@ export const Transcript = memo(function Transcript({ ); }); -function UserBubble({ entry }: { entry: UserEntry }) { - return ( - <div className="msg msg-user"> - <div className="msg-head"> - <span className={`msg-role ${entry.steer ? 'role-steer' : ''}`}>{entry.steer ? 'Steer' : 'You'}</span> - </div> - <div className="bubble bubble-user"> - <div className="text">{entry.text}</div> - </div> - </div> - ); -} - -function AssistantBubble({ entry }: { entry: AssistantEntry }) { +function MessageBubble({ entry }: { entry: MessageEntry }) { // null = no group override; true/false = expand/collapse all in this bubble. const [allExpanded, setAllExpanded] = useState<boolean | undefined>(undefined); + const parts = entry.message.content.parts ?? []; + const toolCount = parts.reduce((n, part) => (part.type === 'tool-invocation' ? n + 1 : n), 0); + const hasRenderablePart = parts.some( + part => + (part.type === 'text' && part.text.trim().length > 0) || + (part.type === 'reasoning' && part.reasoning.trim().length > 0) || + part.type === 'tool-invocation' || + part.type === 'file', + ); - const toolCount = entry.segments.reduce((n, s) => (s.kind === 'tool' ? n + 1 : n), 0); - const hasText = entry.segments.some(s => s.kind === 'text' && s.text.trim().length > 0); - if (!hasText && toolCount === 0) return null; - - // The streaming cursor trails the final text segment while the model is still - // generating (so it sits at the live insertion point, not after a tool card). - const lastTextIdx = (() => { - for (let i = entry.segments.length - 1; i >= 0; i--) { - if (entry.segments[i].kind === 'text') return i; + const lastTextPart = (() => { + for (let i = parts.length - 1; i >= 0; i--) { + if (parts[i].type === 'text') return parts[i]; } - return -1; + return undefined; })(); - return ( - <div className="msg msg-assistant"> - <div className="msg-head"> - <span className="msg-avatar"> - <LogoMark size={14} /> - </span> - <span className="msg-role">Agent</span> - {toolCount > 1 && ( - <button - type="button" - className="tool-group-toggle" - onClick={() => setAllExpanded(v => (v === true ? false : true))} - aria-pressed={allExpanded === true} - > - {allExpanded ? 'Collapse all' : `Expand all (${toolCount})`} - </button> - )} - </div> - <div className="bubble bubble-assistant"> - {entry.segments.map((seg, i) => { - if (seg.kind === 'text') { - return ( - <div className="prose" key={`t-${i}`}> - <Markdown>{seg.text}</Markdown> - {entry.streaming && i === lastTextIdx && <span className="streaming-cursor" />} - </div> - ); - } - if (seg.kind === 'thinking') { - return ( - <div className="thinking-block" key={`k-${i}`}> - <Markdown>{seg.text}</Markdown> - </div> - ); - } - const tool = entry.toolsById[seg.toolCallId]; - if (!tool) return null; - return <ToolCard key={`tool-${seg.toolCallId}`} tool={tool} forceExpanded={allExpanded} />; - })} - </div> - </div> + const roles = useMemo<MessageRoleRenderers>( + () => ({ + User: ({ children }) => ( + <div className="msg msg-user"> + <div className="msg-head"> + <span className={`msg-role ${entry.steer ? 'role-steer' : ''}`}>{entry.steer ? 'Steer' : 'You'}</span> + </div> + <div className="bubble bubble-user">{children}</div> + </div> + ), + Assistant: ({ children }) => ( + <div className="msg msg-assistant"> + <div className="msg-head"> + <span className="msg-avatar"> + <LogoMark size={14} /> + </span> + <span className="msg-role">Agent</span> + {toolCount > 1 && ( + <button + type="button" + className="tool-group-toggle" + onClick={() => setAllExpanded(v => (v === true ? false : true))} + aria-pressed={allExpanded === true} + > + {allExpanded ? 'Collapse all' : `Expand all (${toolCount})`} + </button> + )} + </div> + <div className="bubble bubble-assistant">{children}</div> + </div> + ), + System: ({ children }) => ( + <div className="msg msg-assistant"> + <div className="msg-head"> + <span className="msg-role">System</span> + </div> + <div className="bubble bubble-assistant">{children}</div> + </div> + ), + Signal: ({ children }) => ( + <div className="msg msg-assistant"> + <div className="msg-head"> + <span className="msg-role">Signal</span> + </div> + <div className="bubble bubble-assistant">{children}</div> + </div> + ), + }), + [allExpanded, entry.steer, toolCount], + ); + + const renderers = useMemo( + () => ({ + Text: (part: TextPart) => + entry.message.role === 'user' ? ( + <div className="text">{part.text}</div> + ) : ( + <div className="prose"> + <Markdown>{part.text}</Markdown> + {entry.streaming && part === lastTextPart && <span className="streaming-cursor" />} + </div> + ), + Reasoning: (part: ReasoningPart) => ( + <div className="thinking-block"> + <Markdown>{part.reasoning}</Markdown> + </div> + ), + ToolInvocation: (part: ToolInvocationPart) => { + const runtime = entry.runtimeTools?.[part.toolInvocation.toolCallId]; + const tool = toolFromInvocationPart(part, runtime); + return <ToolCard tool={tool} forceExpanded={allExpanded} />; + }, + File: (part: FilePart) => <pre className="result-block">{stringify(part)}</pre>, + }), + [allExpanded, entry.message.role, entry.runtimeTools, entry.streaming, lastTextPart], + ); + + const status = statusMetadata(entry); + if (status) return <StatusMetadataCard status={status} />; + if (entry.message.role === 'assistant' && !hasRenderablePart) return null; + + return <MessageFactory message={entry.message} roles={roles} {...renderers} fallback={() => null} />; +} + +function toolFromInvocationPart(part: ToolInvocationPart, runtime?: ToolCall): ToolCall { + const invocation = part.toolInvocation; + const failed = invocation.state === 'output-error' || invocation.state === 'output-denied'; + const persistedResult = 'result' in invocation ? invocation.result : undefined; + return { + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + argsText: runtime?.argsText ?? '', + args: runtime?.args ?? ('args' in invocation ? invocation.args : undefined), + status: runtime?.status ?? (failed ? 'error' : invocation.state === 'result' ? 'done' : 'running'), + result: runtime?.result ?? persistedResult ?? invocation.errorText, + output: runtime?.output ?? '', + }; +} + +interface StatusMetadata { + id: string; + text: string; + level: 'info' | 'error'; +} + +function statusMetadata(entry: MessageEntry): StatusMetadata | undefined { + const harnessContent = entry.message.content.metadata?.harnessContent; + if (!Array.isArray(harnessContent)) return undefined; + + const statusPart = harnessContent.find( + part => + typeof part === 'object' && + part !== null && + 'type' in part && + typeof part.type === 'string' && + (part.type === 'notification_summary' || part.type.startsWith('om_') || part.type === 'harness-error'), ); + if (!statusPart || typeof statusPart !== 'object' || !('type' in statusPart)) return undefined; + + const text = 'text' in statusPart && typeof statusPart.text === 'string' ? statusPart.text : messageText(entry); + return { + id: `${entry.id}-${String(statusPart.type)}`, + text, + level: statusPart.type === 'harness-error' ? 'error' : 'info', + }; +} + +function messageText(entry: MessageEntry): string { + return entry.message.content.parts.flatMap(part => (part.type === 'text' ? [part.text] : [])).join(''); +} + +function StatusMetadataCard({ status }: { status: StatusMetadata }) { + return <div className={`notice ${status.level === 'error' ? 'error' : ''}`}>{status.text}</div>; } function Notice({ entry }: { entry: NoticeEntry }) { diff --git a/mastracode/src/web/ui/github.msw.test.tsx b/mastracode/src/web/ui/github.msw.test.tsx new file mode 100644 index 000000000000..90c1af5bfb74 --- /dev/null +++ b/mastracode/src/web/ui/github.msw.test.tsx @@ -0,0 +1,92 @@ +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; + +import { server } from '../../../e2e/web-ui/msw-server'; +import { commitChanges, createWorktree, openPullRequest, pushBranch } from './github'; +import type { GitOpError } from './github'; + +/** + * The GitHub git-op helpers use raw relative `fetch('/api/web/github/...')` + * (not the injected ApiConfig base), so jsdom resolves them against its default + * origin. Match handlers against that origin. + */ +const ORIGIN = 'http://localhost:3000'; +const PROJECT = 'proj-1'; + +function gitOpUrl(action: string): string { + return `${ORIGIN}/api/web/github/projects/${PROJECT}/${action}`; +} + +describe('github git-op helpers', () => { + it('createWorktree posts branch/baseBranch and returns the worktree result', async () => { + let received: unknown; + server.use( + http.post(gitOpUrl('worktree'), async ({ request }) => { + received = await request.json(); + return HttpResponse.json({ + worktreePath: '/workspace/worktrees/feat-x', + branch: 'feat-x', + baseBranch: 'main', + resourceId: 'res-1', + }); + }), + ); + + const result = await createWorktree(PROJECT, 'feat-x', 'main'); + + expect(received).toEqual({ branch: 'feat-x', baseBranch: 'main' }); + expect(result.worktreePath).toBe('/workspace/worktrees/feat-x'); + expect(result.branch).toBe('feat-x'); + expect(result.baseBranch).toBe('main'); + }); + + it('commitChanges reports committed=false when nothing changed', async () => { + server.use(http.post(gitOpUrl('commit'), () => HttpResponse.json({ committed: false }))); + const result = await commitChanges(PROJECT, 'msg', '/workspace/worktrees/feat-x'); + expect(result.committed).toBe(false); + }); + + it('pushBranch returns the pushed branch', async () => { + let received: unknown; + server.use( + http.post(gitOpUrl('push'), async ({ request }) => { + received = await request.json(); + return HttpResponse.json({ pushed: true, branch: 'feat-x' }); + }), + ); + const result = await pushBranch(PROJECT, 'feat-x', '/workspace/worktrees/feat-x'); + expect(received).toEqual({ branch: 'feat-x', worktreePath: '/workspace/worktrees/feat-x' }); + expect(result.pushed).toBe(true); + }); + + it('openPullRequest returns the PR url', async () => { + server.use(http.post(gitOpUrl('pr'), () => HttpResponse.json({ url: 'https://github.com/o/r/pull/7' }))); + const result = await openPullRequest(PROJECT, { branch: 'feat-x', title: 'My PR' }); + expect(result.url).toBe('https://github.com/o/r/pull/7'); + }); + + it('surfaces the server error code/message on failure', async () => { + server.use( + http.post(gitOpUrl('worktree'), () => + HttpResponse.json({ error: 'Invalid branch', message: 'branch name is invalid' }, { status: 400 }), + ), + ); + await expect(createWorktree(PROJECT, 'bad ref')).rejects.toMatchObject({ + code: 'Invalid branch', + message: 'branch name is invalid', + status: 400, + }); + }); + + it('flags authRequired on a 401', async () => { + server.use(http.post(gitOpUrl('push'), () => new HttpResponse(null, { status: 401 }))); + let caught: GitOpError | undefined; + try { + await pushBranch(PROJECT, 'feat-x'); + } catch (e) { + caught = e as GitOpError; + } + expect(caught?.authRequired).toBe(true); + expect(caught?.status).toBe(401); + }); +}); diff --git a/mastracode/src/web/ui/github.ts b/mastracode/src/web/ui/github.ts new file mode 100644 index 000000000000..cee11f424f47 --- /dev/null +++ b/mastracode/src/web/ui/github.ts @@ -0,0 +1,304 @@ +/** + * Browser-side helpers for the GitHub App project flow. + * + * All requests go to the server's `/api/web/github/*` and `/auth/github/*` + * routes, which are behind the WorkOS auth gate and scoped to the logged-in + * user. The browser never sees installation tokens — those live only inside the + * server and the cloud sandbox. + */ + +import type { Project } from './projects'; + +export interface GithubInstallation { + installationId: number; + accountLogin: string | null; + accountType: string | null; +} + +export interface GithubStatus { + enabled: boolean; + sandboxEnabled?: boolean; + connected: boolean; + installations: GithubInstallation[]; + /** + * True when the status request failed because the user is not authenticated + * (HTTP 401), as opposed to the feature being genuinely disabled. Lets the SPA + * prompt re-login instead of silently hiding GitHub. + */ + authRequired?: boolean; +} + +export interface GithubRepo { + id: number; + fullName: string; + name: string; + owner: string; + defaultBranch: string; + private: boolean; + installationId: number; +} + +/** + * Read GitHub feature/connection status. Resolves to a disabled status on 404, + * a network error, or when the feature is off, so the SPA can cleanly hide the + * feature. A 401 is reported distinctly via `authRequired` so the SPA can prompt + * re-login instead of treating the feature as disabled. + */ +export async function fetchGithubStatus(): Promise<GithubStatus> { + try { + const res = await fetch('/api/web/github/status', { headers: { Accept: 'application/json' } }); + if (res.status === 401) { + return { enabled: false, connected: false, installations: [], authRequired: true }; + } + if (!res.ok) return { enabled: false, connected: false, installations: [] }; + return (await res.json()) as GithubStatus; + } catch { + return { enabled: false, connected: false, installations: [] }; + } +} + +/** Begin the GitHub App install/connect flow (full-page redirect). */ +export function connectGithub(): void { + window.location.assign('/auth/github/connect'); +} + +/** List repos across the user's installations, optionally filtered by query. */ +export async function listGithubRepos(query?: string): Promise<GithubRepo[]> { + const url = query ? `/api/web/github/repos?q=${encodeURIComponent(query)}` : '/api/web/github/repos'; + const res = await fetch(url, { headers: { Accept: 'application/json' } }); + if (!res.ok) throw new Error(`Failed to list repos (${res.status})`); + const body = (await res.json()) as { repos: GithubRepo[] }; + return body.repos; +} + +/** + * Create a project from a repo. The server persists a `github_projects` row + * (no sandbox, no clone yet) and returns a `Project` payload of `source: github`. + */ +export async function createProjectFromRepo(repo: GithubRepo): Promise<Project> { + const res = await fetch('/api/web/github/projects', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + repoFullName: repo.fullName, + repoId: repo.id, + installationId: repo.installationId, + defaultBranch: repo.defaultBranch, + }), + }); + if (!res.ok) throw new Error(`Failed to create project (${res.status})`); + const body = (await res.json()) as { project: Project }; + return body.project; +} + +export interface MaterializeResult { + resourceId: string; + githubProjectId: string; + sandboxId: string; + sandboxWorkdir: string; +} + +/** A coarse-grained step of the server-side sandbox preparation. */ +export interface PrepareProgress { + phase: 'reattaching' | 'provisioning' | 'preparing-workspace' | 'cloning' | 'pulling' | 'finalizing' | 'done'; + message: string; +} + +/** + * Materialize a GitHub project into its cloud sandbox: provision/reattach the + * sandbox and clone/pull the repo inside it. Streams live server-side progress + * via SSE, invoking `onProgress` for each step so the UI can show the user what + * is happening. Returns the resourceId used to open the project. Throws an Error + * whose message carries the server's error code so the UI can surface + * "sandbox not configured" distinctly. + */ +export async function ensureRepoMaterialized( + githubProjectId: string, + onProgress?: (event: PrepareProgress) => void, +): Promise<MaterializeResult> { + const res = await fetch(`/api/web/github/projects/${encodeURIComponent(githubProjectId)}/ensure`, { + method: 'POST', + headers: { Accept: 'text/event-stream' }, + }); + + // Non-2xx responses are sent as plain JSON (auth gate, 503, 404, etc.) rather + // than as an SSE stream, so handle those before reading the event stream. + if (!res.ok) { + throw await ensureError(res); + } + + const contentType = res.headers.get('content-type') ?? ''; + if (!contentType.includes('text/event-stream') || !res.body) { + // Server fell back to a single JSON response — read it directly. + return (await res.json()) as MaterializeResult; + } + + let result: MaterializeResult | undefined; + let failure: (Error & { code?: string }) | undefined; + + await readSSE(res.body, (event, data) => { + if (event === 'progress') { + onProgress?.(JSON.parse(data) as PrepareProgress); + } else if (event === 'done') { + result = JSON.parse(data) as MaterializeResult; + } else if (event === 'error') { + const body = JSON.parse(data) as { error?: string; message?: string }; + failure = new Error(body.message ?? 'Failed to prepare project') as Error & { code?: string }; + failure.code = body.error; + } + }); + + if (failure) throw failure; + if (!result) throw new Error('Sandbox preparation ended without a result.'); + return result; +} + +/** Build an Error carrying the server's error code from a non-OK JSON response. */ +async function ensureError(res: Response): Promise<Error & { code?: string }> { + let code = `http_${res.status}`; + let message = `Failed to prepare project (${res.status})`; + try { + const body = (await res.json()) as { error?: string; message?: string }; + if (body.error) code = body.error; + if (body.message) message = body.message; + } catch { + /* ignore non-JSON */ + } + const err = new Error(message) as Error & { code?: string }; + err.code = code; + return err; +} + +/** + * Minimal SSE reader over a fetch ReadableStream. Parses `event:`/`data:` frames + * separated by blank lines and invokes `onEvent` for each. Defaults the event + * name to `message` per the SSE spec. + */ +async function readSSE( + body: ReadableStream<Uint8Array>, + onEvent: (event: string, data: string) => void, +): Promise<void> { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + // Normalize CRLF/CR to LF so frame and line splitting work regardless of + // how the server terminates SSE lines (the spec allows \r\n, \r, or \n). + buffer += decoder.decode(value, { stream: true }).replace(/\r\n|\r/g, '\n'); + let sep: number; + while ((sep = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, sep); + buffer = buffer.slice(sep + 2); + let event = 'message'; + const dataLines: string[] = []; + for (const line of frame.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim(); + else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, '')); + } + if (dataLines.length > 0) onEvent(event, dataLines.join('\n')); + } + } +} + +/** + * An error from a git write operation (worktree/commit/push/pr) that carries the + * server's error code so the UI can distinguish actionable failures (e.g. + * `authRequired` for a 401, `Invalid branch` for a 400) from generic failures. + */ +export interface GitOpError extends Error { + code?: string; + status?: number; + authRequired?: boolean; +} + +/** + * POST helper for the per-project git endpoints. Parses the server's JSON body, + * surfacing `error`/`message` codes on failure (and `authRequired` for 401) so + * callers can react without re-implementing the parsing dance each time. + */ +async function postProjectGitOp<T>(githubProjectId: string, action: string, payload: unknown): Promise<T> { + const res = await fetch(`/api/web/github/projects/${encodeURIComponent(githubProjectId)}/${action}`, { + method: 'POST', + headers: { 'content-type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(payload ?? {}), + }); + if (!res.ok) { + let code = `http_${res.status}`; + let message = `Request failed (${res.status})`; + try { + const body = (await res.json()) as { error?: string; message?: string }; + if (body.error) code = body.error; + if (body.message) message = body.message; + else if (body.error) message = body.error; + } catch { + /* ignore non-JSON */ + } + const err = new Error(message) as GitOpError; + err.code = code; + err.status = res.status; + if (res.status === 401) err.authRequired = true; + throw err; + } + return (await res.json()) as T; +} + +export interface WorktreeResult { + worktreePath: string; + branch: string; + baseBranch: string; + resourceId: string; +} + +/** + * Create (or reuse) a git worktree + feature branch for a unit of work inside + * the project's cloud sandbox. `baseBranch` defaults to the project's default + * branch server-side when omitted. + */ +export async function createWorktree( + githubProjectId: string, + branch: string, + baseBranch?: string, +): Promise<WorktreeResult> { + return postProjectGitOp<WorktreeResult>(githubProjectId, 'worktree', { branch, baseBranch }); +} + +export interface CommitResult { + committed: boolean; +} + +/** + * Stage all changes and commit them inside the given worktree. `worktreePath` + * is validated server-side against persisted worktrees; omit it to commit on the + * base checkout. Resolves with `committed: false` when there was nothing to commit. + */ +export async function commitChanges( + githubProjectId: string, + message: string, + worktreePath?: string, +): Promise<CommitResult> { + return postProjectGitOp<CommitResult>(githubProjectId, 'commit', { message, worktreePath }); +} + +export interface PushResult { + pushed: boolean; + branch: string; +} + +/** Push a branch back to GitHub from inside the sandbox (token minted server-side). */ +export async function pushBranch(githubProjectId: string, branch: string, worktreePath?: string): Promise<PushResult> { + return postProjectGitOp<PushResult>(githubProjectId, 'push', { branch, worktreePath }); +} + +export interface PullRequestResult { + url: string; +} + +/** Open a pull request via the sandbox `gh` CLI. `base` defaults to the project default branch. */ +export async function openPullRequest( + githubProjectId: string, + args: { branch: string; title: string; body?: string; base?: string; worktreePath?: string }, +): Promise<PullRequestResult> { + return postProjectGitOp<PullRequestResult>(githubProjectId, 'pr', args); +} diff --git a/mastracode/src/web/ui/icons.tsx b/mastracode/src/web/ui/icons.tsx index 9354f2c68095..c6a22ea06151 100644 --- a/mastracode/src/web/ui/icons.tsx +++ b/mastracode/src/web/ui/icons.tsx @@ -165,6 +165,13 @@ export const GearIcon = ({ size = 16, className }: IconProps) => export const FolderIcon = ({ size = 16, className }: IconProps) => svg(<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />, size, className); +/** GitHub octocat mark (fill-based; inherits currentColor). */ +export const GithubIcon = ({ size = 16, className }: IconProps) => ( + <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true"> + <path d="M12 .5C5.73.5.5 5.73.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56 0-.27-.01-1.17-.02-2.13-3.2.7-3.88-1.36-3.88-1.36-.52-1.33-1.28-1.69-1.28-1.69-1.05-.71.08-.7.08-.7 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.56-.29-5.25-1.28-5.25-5.7 0-1.26.45-2.29 1.19-3.1-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.18 1.18a11 11 0 0 1 5.79 0c2.21-1.49 3.18-1.18 3.18-1.18.63 1.59.23 2.76.11 3.05.74.81 1.19 1.84 1.19 3.1 0 4.43-2.69 5.41-5.26 5.69.41.36.78 1.06.78 2.14 0 1.55-.01 2.8-.01 3.18 0 .31.21.68.8.56A11.51 11.51 0 0 0 23.5 12C23.5 5.73 18.27.5 12 .5z" /> + </svg> +); + export const BellIcon = ({ size = 15, className }: IconProps) => svg( <> diff --git a/mastracode/src/web/ui/projects.ts b/mastracode/src/web/ui/projects.ts index 25e7e77ecee8..e50c2f55e29c 100644 --- a/mastracode/src/web/ui/projects.ts +++ b/mastracode/src/web/ui/projects.ts @@ -15,11 +15,63 @@ const STORAGE_KEY = 'mastracode-projects'; const ACTIVE_KEY = 'mastracode-active-project'; +/** + * A workspace (git worktree) inside a GitHub project's sandbox. Each worktree + * is a distinct branch checked out at its own path. A repo's worktrees share + * one session resourceId (and that id is shared with the TUI); their threads + * are partitioned per workspace by the `projectPath` tag (the worktree path). + * The project root is itself the first worktree (the default branch); + * additional ones are created via "New workspace". + */ +export interface Worktree { + branch: string; + worktreePath: string; + baseBranch: string; +} + export interface Project { /** Stable local id (localStorage key). Not used for the session. */ id: string; name: string; - path: string; + /** Absolute filesystem path for local projects. Absent for GitHub projects. */ + path?: string; + /** + * Project source. Absent (legacy) is treated as `local`. GitHub projects are + * materialized into a cloud sandbox on open rather than resolved from a path. + */ + source?: 'local' | 'github'; + /** Server-side GitHub project id; present only when `source === 'github'`. */ + githubProjectId?: string; + /** + * Cloud sandbox binding for a GitHub project, persisted after the repo is + * materialized so a re-opened project (e.g. after a page reload) can reattach + * to the same sandbox without re-running the open flow first. + */ + sandboxId?: string; + sandboxWorkdir?: string; + /** + * Workspaces (git worktrees) for a GitHub project. The first entry is the + * repo root on its default branch; additional entries are feature-branch + * worktrees created via "New workspace". Each carries its own resourceId so + * its threads are isolated. Absent/empty for local projects. + */ + worktrees?: Worktree[]; + /** + * Currently selected worktree for a GitHub project (by worktreePath). The + * session binds to this worktree's path + resourceId. Falls back to the repo + * root when unset. + */ + selectedWorktreePath?: string; + /** + * Active feature branch + worktree for a GitHub project, persisted after a + * worktree is created so a re-opened project rebinds the same worktree + * workspace (the agent edits the worktree path, not the repo root). + * + * @deprecated Superseded by `worktrees` + `selectedWorktreePath`; retained so + * projects persisted by older builds keep working until migrated on open. + */ + activeBranch?: string; + activeWorktreePath?: string; /** * Server-resolved resourceId (TUI-compatible). May be absent on projects * created before this field existed; `ensureResourceId` backfills it. @@ -63,7 +115,8 @@ export function loadProjects(): Project[] { !!p && typeof p === 'object' && typeof (p as Project).id === 'string' && - typeof (p as Project).path === 'string', + // Local projects carry a path; GitHub projects carry a githubProjectId. + (typeof (p as Project).path === 'string' || typeof (p as Project).githubProjectId === 'string'), ); } catch { return []; @@ -95,6 +148,84 @@ export async function addProject(name: string, path: string): Promise<Project> { return project; } +/** + * Persist a project created from a GitHub repo. The server already created the + * `github_projects` row and returned a `Project`-shaped payload; we just store + * it locally (de-duped by `githubProjectId`) so it shows up in the project list. + * The `resourceId` is filled in later, on open, by `ensureRepoMaterialized`. + */ +export function addGithubProject(project: Project): Project { + const projects = loadProjects(); + const existing = projects.find(p => p.githubProjectId && p.githubProjectId === project.githubProjectId); + if (existing) return existing; + const stored: Project = { ...project, source: 'github', createdAt: project.createdAt ?? Date.now() }; + projects.push(stored); + saveProjects(projects); + return stored; +} + +/** + * Replace a stored project in place (by id) and persist. Used to record the + * server-resolved `resourceId` for a GitHub project once it's materialized. + */ +export function updateProject(project: Project): void { + const projects = loadProjects().map(p => (p.id === project.id ? project : p)); + saveProjects(projects); +} + +/** + * The worktree list for a project, normalizing legacy projects: a GitHub + * project always has at least the repo-root worktree (its default branch), and + * a pre-`worktrees` project with an `activeBranch` gets that folded in. + */ +export function projectWorktrees(project: Project): Worktree[] { + if (project.source !== 'github') return []; + if (project.worktrees && project.worktrees.length > 0) return project.worktrees; + + // Migrate legacy shape: synthesize the root worktree, plus the previously + // persisted active feature worktree if one existed. + const rootBranch = project.gitBranch ?? 'main'; + const rootPath = project.sandboxWorkdir ?? ''; + const list: Worktree[] = [{ branch: rootBranch, worktreePath: rootPath, baseBranch: rootBranch }]; + if (project.activeBranch && project.activeWorktreePath && project.activeBranch !== rootBranch) { + list.push({ + branch: project.activeBranch, + worktreePath: project.activeWorktreePath, + baseBranch: rootBranch, + }); + } + return list; +} + +/** The currently selected worktree for a project, or the repo root by default. */ +export function selectedWorktree(project: Project): Worktree | undefined { + const list = projectWorktrees(project); + if (list.length === 0) return undefined; + const match = project.selectedWorktreePath + ? list.find(w => w.worktreePath === project.selectedWorktreePath) + : undefined; + return match ?? list[0]; +} + +/** + * Append (or update) a worktree on a project and persist. De-duped by branch. + * Returns the updated project. Does NOT change the selection. + */ +export function upsertWorktree(project: Project, worktree: Worktree): Project { + const existing = projectWorktrees(project); + const without = existing.filter(w => w.branch !== worktree.branch); + const updated: Project = { ...project, worktrees: [...without, worktree] }; + updateProject(updated); + return updated; +} + +/** Persist the selected worktree for a project and return the updated project. */ +export function selectWorktree(project: Project, worktreePath: string): Project { + const updated: Project = { ...project, selectedWorktreePath: worktreePath }; + updateProject(updated); + return updated; +} + /** * Return a project guaranteed to have a `resourceId`, resolving + persisting it * if a legacy project predates the field. The session resourceId always comes @@ -102,6 +233,7 @@ export async function addProject(name: string, path: string): Promise<Project> { */ export async function ensureResourceId(project: Project): Promise<Project> { if (project.resourceId) return project; + if (!project.path) throw new Error('Cannot resolve a resourceId for a project without a path'); const resolved = await resolveProjectPath(project.path); const updated: Project = { ...project, resourceId: resolved.resourceId, gitBranch: resolved.gitBranch }; const projects = loadProjects().map(p => (p.id === project.id ? updated : p)); diff --git a/mastracode/src/web/ui/styles.css b/mastracode/src/web/ui/styles.css index d77719e9f98e..7c82542e99dd 100644 --- a/mastracode/src/web/ui/styles.css +++ b/mastracode/src/web/ui/styles.css @@ -1155,6 +1155,76 @@ body::before { /* ── Goal panel ─────────────────────────────────────────────────────────── */ +.branch-panel { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 18px; + border-bottom: 1px solid var(--border); + background: var(--bg-surface); + font-size: 12px; + flex-shrink: 0; +} + +.branch-panel-row { + display: flex; + align-items: center; + gap: 8px; +} + +.branch-panel-icon { + display: inline-flex; + color: var(--accent); +} + +.branch-panel-current { + font-size: 12.5px; +} + +.branch-panel-muted { + color: var(--fg-dim); +} + +.branch-panel-input { + flex: 1; + font-size: 12px; + padding: 4px 8px; +} + +.branch-panel-textarea { + width: 100%; + font-size: 12px; + padding: 4px 8px; + resize: vertical; + font-family: inherit; +} + +.branch-panel-pr { + display: flex; + flex-direction: column; + gap: 8px; +} + +.branch-panel-actions { + display: flex; + gap: 8px; +} + +.branch-panel-prlink { + font-size: 12px; + color: var(--accent); +} + +.branch-panel-disabled { + flex-direction: row; + align-items: center; + color: var(--fg-dim); +} + +.branch-panel-hint { + font-size: 12px; +} + .goal-bar { display: flex; align-items: center; @@ -1677,6 +1747,56 @@ body::before { min-height: 0; } +.sidebar-account { + margin-top: auto; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid var(--border); +} + +.sidebar-account-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.sidebar-account-name { + font-size: 12px; + font-weight: 600; + color: var(--fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-account-email { + font-size: 11px; + color: var(--fg-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-signout-btn { + flex-shrink: 0; + font-size: 11px; + font-weight: 600; + color: var(--fg-muted); + background: var(--bg-surface3); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 8px; + cursor: pointer; +} + +.sidebar-signout-btn:hover { + color: var(--fg); + background: var(--bg-surface); +} + .sidebar-section-header { display: flex; align-items: center; @@ -1782,6 +1902,21 @@ body::before { direction: rtl; text-align: left; } +.project-switcher-source { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--fg-muted); + background: var(--bg-surface3); + border: 1px solid var(--border); + padding: 1px 6px; + border-radius: 999px; + flex-shrink: 0; +} +.project-switcher-source.github { + color: var(--fg); +} .project-switcher-chevron { color: var(--fg-muted); transform: rotate(90deg); @@ -1853,6 +1988,113 @@ body::before { flex-shrink: 0; } +/* ── Worktree tree (GitHub projects: project → worktree → threads) ──────── */ + +/* Collapsible "Workspaces" header toggle */ +.sidebar-tree-toggle { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + padding: 0; + border: none; + background: transparent; + color: inherit; + cursor: pointer; + text-align: left; +} +.sidebar-tree-chevron { + color: var(--fg-muted); + transition: transform 0.12s ease; + flex-shrink: 0; +} +.sidebar-tree-chevron.open { + transform: rotate(90deg); +} + +/* Inline "new workspace" branch-name input */ +.sidebar-newworkspace { + padding: 2px 12px 6px; +} + +.sidebar-newworkspace-error { + margin-top: 4px; + font-size: 11px; + line-height: 1.4; + color: var(--danger, #e5484d); +} + +.sidebar-tree { + flex: 1; + overflow-y: auto; + padding: 2px 6px; +} + +.sidebar-worktree-group { + margin-bottom: 2px; +} + +/* A worktree (branch) row */ +.sidebar-worktree { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 6px 8px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--fg-dim); + font-size: 12px; + cursor: pointer; + text-align: left; + transition: background 0.1s; +} +.sidebar-worktree:hover { + background: var(--bg-surface2); + color: var(--fg); +} +.sidebar-worktree.active { + background: var(--accent-soft); + color: var(--fg); + box-shadow: inset 2px 0 0 var(--accent); +} +.sidebar-worktree-icon { + color: var(--fg-muted); + flex-shrink: 0; +} +.sidebar-worktree.active .sidebar-worktree-icon { + color: var(--accent); +} +.sidebar-worktree-branch { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; +} + +/* Threads nested under the selected worktree */ +.sidebar-worktree-threads { + margin: 2px 0 4px 10px; + padding-left: 8px; + border-left: 1px solid var(--border, var(--bg-surface3)); +} +.sidebar-worktree-threads-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 4px 2px 4px; +} +.sidebar-worktree-threads-title { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--fg-muted); +} + /* Per-thread ⋯ action menu */ .sidebar-thread-menu { position: relative; @@ -2054,6 +2296,21 @@ body::before { border-radius: 999px; flex-shrink: 0; } +.project-card-source { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--fg-muted); + background: var(--bg-surface3); + border: 1px solid var(--border); + padding: 2px 7px; + border-radius: 999px; + flex-shrink: 0; +} +.project-card-source.github { + color: var(--fg); +} .project-card-remove { display: inline-flex; align-items: center; @@ -2099,6 +2356,92 @@ body::before { border-color: var(--accent); } +/* ── GitHub connect / repo picker ─────────────────────────────────── */ +.sidebar-github-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: auto; + margin: 8px 12px; + padding: 9px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: transparent; + color: var(--fg-dim); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: + background 0.12s, + color 0.12s, + border-color 0.12s; +} +.sidebar-github-btn:hover { + background: var(--bg-surface2); + color: var(--fg); + border-color: var(--accent); +} +.github-search { + display: flex; + align-items: center; + gap: 8px; + margin: 0 14px 8px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-surface2); +} +.github-search-icon { + color: var(--fg-dim); + flex-shrink: 0; +} +.github-search-input { + flex: 1; + border: none; + background: transparent; + color: var(--fg); + font-size: 13px; + outline: none; +} +.github-muted { + margin: 8px 14px; + color: var(--fg-dim); + font-size: 13px; +} +.github-error { + margin: 0 14px 8px; + color: var(--danger, #e5484d); + font-size: 12px; +} +.github-preparing { + margin-top: 18vh; + display: flex; + align-items: center; + gap: 10px; + padding: 14px 22px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + background: var(--bg-elevated); + color: var(--fg); + font-size: 14px; + box-shadow: var(--shadow-lg); +} +.github-preparing-spinner { + width: 14px; + height: 14px; + flex-shrink: 0; + border: 2px solid var(--border-strong); + border-top-color: var(--fg); + border-radius: 50%; + animation: github-preparing-spin 0.8s linear infinite; +} +@keyframes github-preparing-spin { + to { + transform: rotate(360deg); + } +} + /* ── Directory browser (embedded inside the Projects modal) ─────────── */ .dirbrowser { display: flex; @@ -3277,3 +3620,175 @@ body::before { transition: none; } } + +/* ── Auth splash (shown when signed out) ──────────────────────────────── */ +.auth-splash { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 24px; + overflow: hidden; +} + +/* Ambient brand glow specific to the splash, layered above the page glow. */ +.auth-splash::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient(42rem 30rem at 50% -8%, var(--accent-soft), transparent 62%), + radial-gradient(34rem 26rem at 50% 120%, oklch(0.769 0.188 70.08 / 0.06), transparent 60%); +} + +/* While /auth/me is in flight: a blank, non-flashing screen. */ +.auth-splash-loading { + pointer-events: none; +} +.auth-splash-loading::before { + display: none; +} + +.auth-splash-card { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0; + width: 100%; + max-width: 25rem; + padding: 40px; + text-align: left; + background: linear-gradient(180deg, color-mix(in oklch, var(--bg-surface2) 92%, transparent), var(--bg-surface)); + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + animation: auth-splash-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +/* Accent hairline along the top edge of the card. */ +.auth-splash-card::before { + content: ''; + position: absolute; + inset: 0 0 auto 0; + height: 1px; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: linear-gradient(90deg, transparent, var(--accent), var(--accent-2), transparent); + opacity: 0.7; +} + +@keyframes auth-splash-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.auth-splash-brand { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 28px; +} + +.auth-splash-logo { + filter: drop-shadow(0 2px 8px var(--accent-soft)); +} + +.auth-splash-wordmark { + font-size: 17px; + font-weight: 650; + letter-spacing: -0.01em; + color: var(--fg); +} +.auth-splash-wordmark-accent { + color: var(--accent); +} + +.auth-splash-title { + margin: 0 0 8px; + font-size: 22px; + font-weight: 640; + letter-spacing: -0.02em; + color: var(--fg); +} + +.auth-splash-tagline { + margin: 0 0 28px; + font-size: 14px; + line-height: 1.55; + color: var(--fg-muted); +} + +.auth-splash-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 11px 18px; + font: inherit; + font-size: 14px; + font-weight: 600; + color: oklch(0.16 0 0); + background: var(--accent); + border: none; + border-radius: var(--radius); + cursor: pointer; + box-shadow: + 0 1px 0 oklch(1 0 0 / 0.18) inset, + 0 6px 18px -10px var(--accent); + transition: + background 0.15s ease, + transform 0.12s ease, + box-shadow 0.15s ease; +} + +.auth-splash-button:hover { + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: + 0 1px 0 oklch(1 0 0 / 0.22) inset, + 0 10px 24px -10px var(--accent); +} +.auth-splash-button:active { + transform: translateY(0); +} +.auth-splash-button:focus-visible { + outline: 2px solid var(--accent-ring); + outline-offset: 2px; +} + +.auth-splash-button-arrow { + transition: transform 0.15s ease; +} +.auth-splash-button:hover .auth-splash-button-arrow { + transform: translateX(3px); +} + +.auth-splash-footnote { + margin: 18px 0 0; + width: 100%; + padding-top: 18px; + border-top: 1px solid var(--border); + font-size: 12px; + color: var(--fg-dim); + text-align: center; +} + +@media (prefers-reduced-motion: reduce) { + .auth-splash-card { + animation: none; + } + .auth-splash-button, + .auth-splash-button-arrow { + transition: none; + } +} diff --git a/mastracode/src/web/ui/transcript.test.ts b/mastracode/src/web/ui/transcript.test.ts new file mode 100644 index 000000000000..18f11daf0976 --- /dev/null +++ b/mastracode/src/web/ui/transcript.test.ts @@ -0,0 +1,170 @@ +import type { AgentControllerMessage } from '@mastra/client-js'; +import { describe, expect, it } from 'vitest'; + +import { initialTranscript, transcriptReducer } from './transcript'; + +type MessageEntryFixture = { + kind: 'message'; + message: { content: { parts: unknown[] } }; +}; + +function messageParts(entry: unknown): unknown[] { + return isMessageEntry(entry) ? entry.message.content.parts : []; +} + +function isMessageEntry(entry: unknown): entry is MessageEntryFixture { + return ( + typeof entry === 'object' && entry !== null && 'kind' in entry && entry.kind === 'message' && 'message' in entry + ); +} + +describe('transcript reducer message entries', () => { + it('hydrates controller messages as ordered MastraDBMessage entries', () => { + const messages: AgentControllerMessage[] = [ + { id: 'user-1', role: 'user', content: [{ type: 'text', text: 'Inspect this' }] }, + { + id: 'assistant-1', + role: 'assistant', + content: [ + { type: 'text', text: 'I will inspect it.' }, + { type: 'thinking', thinking: 'Need the file first.' }, + { type: 'tool_call', id: 'tool-1', name: 'view', args: { path: 'src/index.ts' } }, + { type: 'tool_result', id: 'tool-1', name: 'view', result: 'export const value = 1;' }, + ], + }, + ]; + + const state = transcriptReducer(initialTranscript, { type: 'hydrate', messages }); + + expect(state.entries).toHaveLength(2); + expect(state.entries[0]).toMatchObject({ + kind: 'message', + id: 'user-1', + message: { role: 'user', content: { format: 2, parts: [{ type: 'text', text: 'Inspect this' }] } }, + }); + expect(state.entries[1]).toMatchObject({ kind: 'message', id: 'assistant-1', streaming: false }); + expect(messageParts(state.entries[1])).toEqual([ + { type: 'text', text: 'I will inspect it.' }, + { + type: 'reasoning', + reasoning: 'Need the file first.', + details: [{ type: 'text', text: 'Need the file first.' }], + }, + { + type: 'tool-invocation', + toolInvocation: { + state: 'result', + toolCallId: 'tool-1', + toolName: 'view', + args: { path: 'src/index.ts' }, + result: 'export const value = 1;', + }, + }, + ]); + }); + + it('streams message updates without replacing non-message transcript state', () => { + const withNotice = transcriptReducer(initialTranscript, { + type: 'localNotice', + level: 'info', + text: 'Command handled', + }); + + const state = transcriptReducer(withNotice, { + type: 'event', + event: { + type: 'message_update', + message: { id: 'assistant-1', role: 'assistant', content: [{ type: 'text', text: 'Streaming text' }] }, + }, + }); + + expect(state.pending).toBe(false); + expect(state.entries[0]).toMatchObject({ kind: 'notice', text: 'Command handled' }); + expect(state.entries[1]).toMatchObject({ kind: 'message', id: 'assistant-1', streaming: true }); + expect(messageParts(state.entries[1])).toEqual([{ type: 'text', text: 'Streaming text' }]); + }); + + it('keeps tool lifecycle events visible inline before a message update re-emits the tool call', () => { + const started = transcriptReducer(initialTranscript, { + type: 'event', + event: { type: 'tool_start', toolCallId: 'tool-1', toolName: 'view', args: { path: 'src/index.ts' } }, + }); + + expect(messageParts(started.entries[0])).toEqual([ + { + type: 'tool-invocation', + toolInvocation: { + state: 'call', + toolCallId: 'tool-1', + toolName: 'view', + args: { path: 'src/index.ts' }, + }, + }, + ]); + + const ended = transcriptReducer(started, { + type: 'event', + event: { type: 'tool_end', toolCallId: 'tool-1', result: 'done', isError: false }, + }); + + expect(messageParts(ended.entries[0])).toEqual([ + { + type: 'tool-invocation', + toolInvocation: { + state: 'result', + toolCallId: 'tool-1', + toolName: 'view', + args: { path: 'src/index.ts' }, + result: 'done', + }, + }, + ]); + }); + + it('preserves non-message state while using message entries', () => { + const withTask = transcriptReducer(initialTranscript, { + type: 'event', + event: { + type: 'task_updated', + tasks: [ + { id: 'task-1', content: 'Refactor transcript', status: 'in_progress', activeForm: 'Refactoring transcript' }, + ], + }, + }); + const state = transcriptReducer(withTask, { + type: 'event', + event: { + type: 'display_state_changed', + displayState: { + tokenUsage: { totalTokens: 42 }, + omProgress: { msgTokens: 10, maxMsgTokens: 100, memTokens: 5, maxMemTokens: 50 }, + }, + }, + }); + const withSummary = transcriptReducer(state, { + type: 'event', + event: { + type: 'notification_summary', + message: '2 pending notifications', + pending: 2, + bySource: { agent: 2 }, + byPriority: { medium: 2 }, + notificationIds: ['n1', 'n2'], + }, + }); + const withApproval = transcriptReducer(withSummary, { + type: 'event', + event: { type: 'tool_approval_required', toolCallId: 'tool-1', toolName: 'edit', args: { path: 'src/index.ts' } }, + }); + + expect(withApproval.tasks).toEqual([ + { id: 'task-1', content: 'Refactor transcript', status: 'in_progress', activeForm: 'Refactoring transcript' }, + ]); + expect(withApproval.usage).toEqual({ totalTokens: 42 }); + expect(withApproval.omProgress).toEqual({ msgTokens: 10, maxMsgTokens: 100, memTokens: 5, maxMemTokens: 50 }); + expect(withApproval.entries).toEqual([ + expect.objectContaining({ kind: 'notification_summary', pending: 2 }), + expect.objectContaining({ kind: 'approval', toolCallId: 'tool-1' }), + ]); + }); +}); diff --git a/mastracode/src/web/ui/transcript.ts b/mastracode/src/web/ui/transcript.ts index 7e5e11c55e94..b2b43a839525 100644 --- a/mastracode/src/web/ui/transcript.ts +++ b/mastracode/src/web/ui/transcript.ts @@ -1,4 +1,3 @@ -import { agentControllerMessageText } from '@mastra/client-js'; import type { AgentControllerEvent, KnownAgentControllerEvent, @@ -6,6 +5,9 @@ import type { AgentControllerTaskSnapshot, AgentControllerOMProgress, } from '@mastra/client-js'; +import type { MastraDBMessage, MastraMessagePart } from '@mastra/core/agent'; + +import { toMastraDBMessage } from './agent-controller-message-accumulator'; /** * Transcript model + reducer. @@ -38,26 +40,14 @@ export interface ToolCall { * status/result, which arrives on separate tool_* events) lives in the entry's * `toolsById` map and is resolved at render time. */ -export type AssistantSegment = - | { kind: 'text'; text: string } - | { kind: 'thinking'; text: string } - | { kind: 'tool'; toolCallId: string }; - -export interface AssistantEntry { - kind: 'assistant'; +export interface MessageEntry { + kind: 'message'; id: string; - /** Ordered text / thinking / tool segments, in execution order. */ - segments: AssistantSegment[]; - /** Live tool state keyed by tool-call id, referenced by tool segments. */ - toolsById: Record<string, ToolCall>; + message: MastraDBMessage; + /** Live tool state from tool_* events, overlaid by toolCallId without changing persisted message parts. */ + runtimeTools?: Record<string, ToolCall>; /** True while the model is still generating tokens for this message. */ - streaming: boolean; -} - -export interface UserEntry { - kind: 'user'; - id: string; - text: string; + streaming?: boolean; /** A steer (interjection) vs a normal message. */ steer?: boolean; } @@ -123,8 +113,7 @@ export interface SubagentEntry { export type PromptEntry = ApprovalPrompt | SuspensionPrompt; export type TimelineEntry = - | UserEntry - | AssistantEntry + | MessageEntry | NoticeEntry | PromptEntry | NotificationEntry @@ -248,7 +237,14 @@ export function transcriptReducer(state: TranscriptState, action: Action): Trans pending: true, entries: [ ...state.entries, - { kind: 'user', id: `local-${Date.now()}-${noticeSeq++}`, text: action.text, steer: action.steer }, + toMessageEntry( + toMastraDBMessage({ + id: `local-${Date.now()}-${noticeSeq++}`, + role: 'user', + content: [{ type: 'text', text: action.text }], + }), + { steer: action.steer }, + ), ], }; case 'localNotice': @@ -530,103 +526,78 @@ function hydrate( omProgress?: AgentControllerOMProgress, usage?: UsageSnapshot, ): TranscriptState { - const entries: TimelineEntry[] = []; - for (const message of messages) { - if (message.role === 'user') { - entries.push({ kind: 'user', id: message.id, text: agentControllerMessageText(message) }); - } else if (message.role === 'assistant') { - const { segments, toolsById } = buildSegments(message); - entries.push({ kind: 'assistant', id: message.id, segments, toolsById, streaming: false }); - } - // 'system' messages aren't shown in the transcript. - } + const entries = messages.map(message => toMessageEntry(toMastraDBMessage(message), { streaming: false })); return { ...initialTranscript, entries, modeId, modelId, threadId, omProgress, usage }; } -/** - * Walk a message's content parts in order and produce ordered segments plus the - * tool state they reference. `prevTools` carries forward live tool runtime - * (streamed argsText / shell output / status) captured from tool_* events, - * which the persisted content parts don't include. - * - * This mirrors the TUI's `AssistantMessageComponent`, which renders each - * content part where it appears instead of concatenating text and grouping - * tools. - */ -function buildSegments( - message: AgentControllerMessage, - prevTools: Record<string, ToolCall> = {}, -): { segments: AssistantSegment[]; toolsById: Record<string, ToolCall> } { - const segments: AssistantSegment[] = []; - const toolsById: Record<string, ToolCall> = {}; - let toolSeq = 0; - for (const part of message.content) { - if (part.type === 'text' && typeof part.text === 'string') { - if (part.text.length > 0) segments.push({ kind: 'text', text: part.text }); - } else if (part.type === 'thinking' && typeof part.thinking === 'string') { - if (part.thinking.trim().length > 0) segments.push({ kind: 'thinking', text: part.thinking }); - } else if (part.type === 'tool_call') { - const toolCallId = part.id ?? `${message.id}-tool-${toolSeq++}`; - const result = message.content.find(c => c.type === 'tool_result' && c.id === part.id); - const prev = prevTools[toolCallId]; - toolsById[toolCallId] = { - toolCallId, - toolName: part.name ?? prev?.toolName ?? 'tool', - // Keep streamed args text; fall back to nothing. - argsText: prev?.argsText ?? '', - args: part.args ?? prev?.args, - // A present tool_result means the call resolved; otherwise keep the - // live status (running) seeded from tool_* events. - status: result ? (result.isError ? 'error' : 'done') : (prev?.status ?? 'running'), - result: result?.result ?? prev?.result, - output: prev?.output ?? '', - }; - segments.push({ kind: 'tool', toolCallId }); - } - // 'tool_result' parts are folded into their tool_call above. - } - return { segments, toolsById }; +function toMessageEntry( + message: MastraDBMessage, + options: { streaming?: boolean; steer?: boolean; runtimeTools?: Record<string, ToolCall> } = {}, +): MessageEntry { + return { + kind: 'message', + id: message.id, + message, + runtimeTools: options.runtimeTools, + streaming: options.streaming, + steer: options.steer, + }; } function upsertAssistant(state: TranscriptState, message: AgentControllerMessage, streaming: boolean): TranscriptState { if (message.role !== 'assistant') return state; const entries = [...state.entries]; - const idx = entries.findIndex(e => e.kind === 'assistant' && e.id === message.id); - const prev = idx !== -1 ? (entries[idx] as AssistantEntry) : undefined; - const { segments, toolsById } = buildSegments(message, prev?.toolsById); - - // Preserve any tools (and their segments) that arrived via tool_* events but - // aren't yet reflected in the streamed content — keeps a tool visible the - // instant it starts, before the next message_update lands. - if (prev) { - for (const seg of prev.segments) { - if (seg.kind === 'tool' && !toolsById[seg.toolCallId]) { - segments.push(seg); - const carried = prev.toolsById[seg.toolCallId]; - if (carried) toolsById[seg.toolCallId] = carried; - } + let idx = entries.findIndex(e => e.kind === 'message' && e.message.role === 'assistant' && e.id === message.id); + if (idx === -1) { + const latestIdx = latestAssistantIndex(entries); + const latest = latestIdx === -1 ? undefined : entries[latestIdx]; + if (latest?.kind === 'message' && latest.message.role === 'assistant' && latest.id.startsWith('assistant-tools-')) { + idx = latestIdx; } } + const prev = idx !== -1 ? entries[idx] : undefined; + const prevEntry = prev?.kind === 'message' ? prev : undefined; + const nextMessage = preserveRuntimeToolParts(toMastraDBMessage(message), prevEntry?.message); + const entry = toMessageEntry(nextMessage, { streaming, runtimeTools: prevEntry?.runtimeTools }); - const entry: AssistantEntry = { kind: 'assistant', id: message.id, segments, toolsById, streaming }; if (idx === -1) entries.push(entry); else entries[idx] = entry; return { ...state, entries }; } +function preserveRuntimeToolParts(message: MastraDBMessage, previous?: MastraDBMessage): MastraDBMessage { + if (!previous) return message; + + const parts = [...message.content.parts]; + const existingToolIds = new Set(parts.map(toolCallIdForPart).filter((id): id is string => Boolean(id))); + + for (const part of previous.content.parts) { + const toolCallId = toolCallIdForPart(part); + if (toolCallId && !existingToolIds.has(toolCallId)) { + parts.push(part); + existingToolIds.add(toolCallId); + } + } + + return { ...message, content: { ...message.content, parts } }; +} + /** True when the most recent assistant entry has any visible text. */ function hasAssistantText(state: TranscriptState): boolean { const idx = latestAssistantIndex(state.entries); if (idx === -1) return false; const entry = state.entries[idx]; - if (entry.kind !== 'assistant') return false; - return entry.segments.some(s => s.kind === 'text' && s.text.trim().length > 0); + if (entry.kind !== 'message') return false; + return entry.message.content.parts.some( + part => part.type === 'text' && 'text' in part && part.text.trim().length > 0, + ); } /** Find the latest assistant entry, creating one if none exists. */ function latestAssistantIndex(entries: TimelineEntry[]): number { for (let i = entries.length - 1; i >= 0; i--) { - if (entries[i].kind === 'assistant') return i; + const entry = entries[i]; + if (entry.kind === 'message' && entry.message.role === 'assistant') return i; } return -1; } @@ -640,36 +611,88 @@ function withTool( const entries = [...state.entries]; let idx = latestAssistantIndex(entries); if (idx === -1) { - entries.push({ - kind: 'assistant', + const message = toMastraDBMessage({ id: `assistant-tools-${Date.now()}`, - segments: [], - toolsById: {}, - streaming: false, + role: 'assistant', + content: [], }); + entries.push(toMessageEntry(message, { streaming: false })); idx = entries.length - 1; } - const assistant = entries[idx] as AssistantEntry; - const toolsById = { ...assistant.toolsById }; - const existing = toolsById[toolCallId] ?? { - toolCallId, - toolName: seed?.toolName ?? 'tool', + + const entry = entries[idx]; + if (entry.kind !== 'message') return state; + + const parts = [...entry.message.content.parts]; + const runtimeTools = { ...(entry.runtimeTools ?? {}) }; + const existing = + runtimeTools[toolCallId] ?? toolCallFromPart(parts.find(part => toolCallIdForPart(part) === toolCallId)); + const tool = update( + existing ?? { + toolCallId, + toolName: seed?.toolName ?? 'tool', + argsText: '', + args: seed?.args, + status: 'running', + output: '', + }, + ); + runtimeTools[toolCallId] = tool; + + const partIndex = parts.findIndex(part => toolCallIdForPart(part) === toolCallId); + if (partIndex === -1) parts.push(toolPart(tool)); + else parts[partIndex] = toolPart(tool); + + entries[idx] = { + ...entry, + runtimeTools, + message: { ...entry.message, content: { ...entry.message.content, parts } }, + }; + return { ...state, entries }; +} + +function toolCallIdForPart(part: MastraMessagePart): string | undefined { + if (part.type !== 'tool-invocation') return undefined; + return part.toolInvocation.toolCallId; +} + +function toolCallFromPart(part: MastraMessagePart | undefined): ToolCall | undefined { + if (!part || part.type !== 'tool-invocation') return undefined; + const invocation = part.toolInvocation; + return { + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, argsText: '', - args: seed?.args, - status: 'running' as const, + args: 'args' in invocation ? invocation.args : undefined, + status: invocation.state === 'result' ? 'done' : 'running', + result: 'result' in invocation ? invocation.result : undefined, output: '', }; - toolsById[toolCallId] = update(existing); +} - // Ensure a tool segment exists in execution order. A tool's first event can - // arrive before the message_update that would place it from content, so we - // append the segment here to keep it inline at the point it started. - const segments = assistant.segments.some(s => s.kind === 'tool' && s.toolCallId === toolCallId) - ? assistant.segments - : [...assistant.segments, { kind: 'tool' as const, toolCallId }]; +function toolPart(tool: ToolCall): MastraMessagePart { + if (tool.status === 'running') { + return { + type: 'tool-invocation', + toolInvocation: { + state: 'call', + toolCallId: tool.toolCallId, + toolName: tool.toolName, + args: tool.args, + }, + }; + } - entries[idx] = { ...assistant, segments, toolsById }; - return { ...state, entries }; + return { + type: 'tool-invocation', + toolInvocation: { + state: 'result', + toolCallId: tool.toolCallId, + toolName: tool.toolName, + args: tool.args, + result: tool.result, + }, + }; } function pushPrompt(state: TranscriptState, prompt: PromptEntry): TranscriptState { diff --git a/mastracode/src/web/ui/useAgentControllerSession.ts b/mastracode/src/web/ui/useAgentControllerSession.ts index 93fe76750723..87dc897a7516 100644 --- a/mastracode/src/web/ui/useAgentControllerSession.ts +++ b/mastracode/src/web/ui/useAgentControllerSession.ts @@ -103,6 +103,12 @@ export function useAgentControllerSession({ const sessionRef = useRef<Session | null>(null); const agentControllerRef = useRef<ReturnType<MastraClient['getAgentController']> | null>(null); + // The session-init effect intentionally does not re-run on projectPath changes + // (that would re-subscribe the stream). Mirror the latest value into a ref so + // thread creation always tags with the current worktree path, even when the + // path resolves a tick after the session connects. + const projectPathRef = useRef(projectPath); + projectPathRef.current = projectPath; const [models, setModels] = useState<AgentControllerAvailableModel[]>([]); const [settings, setSettings] = useState<AgentControllerSessionSettings | null>(null); @@ -229,8 +235,9 @@ export function useAgentControllerSession({ try { const [created, agentControllerModes] = await Promise.all([ // Scope initial thread selection to the active project so worktrees - // sharing a resourceId each resume their own thread. - session.create({ tags: projectPath ? { projectPath } : undefined }), + // sharing a resourceId each resume their own thread. Read the ref so a + // path that resolved just after connect still tags the thread. + session.create({ tags: projectPathRef.current ? { projectPath: projectPathRef.current } : undefined }), controller.listModes(), ]); if (disposed) return; @@ -293,12 +300,19 @@ export function useAgentControllerSession({ }; }, [agentControllerId, resourceId, baseUrl, refreshThreads, enabled]); - const send = useCallback(async (text: string) => { - const session = sessionRef.current; - if (!session || !text.trim()) return; - dispatch({ type: 'localUser', text }); - await session.sendMessage(text); - }, []); + const send = useCallback( + async (text: string) => { + const session = sessionRef.current; + if (!session || !text.trim()) return; + dispatch({ type: 'localUser', text }); + await session.sendMessage(text); + // The first message in the zero state turns the freshly-bound thread into + // a listable one (and gives it a title). Refresh so it shows in the + // sidebar instead of staying on "No conversations yet". + void refreshThreads(); + }, + [refreshThreads], + ); const steer = useCallback(async (text: string) => { const session = sessionRef.current; diff --git a/mastracode/src/web/vite.config.ts b/mastracode/src/web/vite.config.ts index ddabe468bab2..13573ffbc766 100644 --- a/mastracode/src/web/vite.config.ts +++ b/mastracode/src/web/vite.config.ts @@ -32,6 +32,18 @@ export default defineConfig({ target: 'http://localhost:4111', changeOrigin: true, }, + // Optional WorkOS auth routes live on the API server too; proxy them so + // the dev UI (:5173) can reach login/callback/logout/me on :4111. + // + // Match only the `/auth/<route>` paths — NOT a bare `/auth` prefix. + // A plain `'/auth'` key prefix-matches Vite module requests like + // `/auth.ts` (the client auth module) and wrongly proxies them to the + // API server, which 401s / ECONNREFUSEs. The trailing-slash regex keeps + // module imports on Vite while still forwarding real auth routes. + '^/auth/': { + target: 'http://localhost:4111', + changeOrigin: true, + }, }, }, }); diff --git a/mastracode/tsup.config.ts b/mastracode/tsup.config.ts index c54d7446d699..1ce9723c61d6 100644 --- a/mastracode/tsup.config.ts +++ b/mastracode/tsup.config.ts @@ -1,16 +1,27 @@ -import { readFileSync } from 'node:fs'; +import { copyFileSync, mkdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { generateTypes } from '@internal/types-builder'; import { defineConfig } from 'tsup'; const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')); +/** + * Native macOS STT assets that are read at runtime (not bundled into JS): the + * Swift recognizer source and its embedded Info.plist. They are compiled with + * `swiftc` on first use, so they must ship alongside the bundle. `compile.ts` + * resolves them from `dist/native/` (with a `src/` fallback for dev). + */ +const NATIVE_VOICE_ASSETS = ['macos-stt.swift', 'macos-stt.plist']; + export default defineConfig({ entry: { index: 'src/index.ts', cli: 'src/main.ts', tui: 'src/tui/index.ts', acp: 'src/acp.ts', + headless: 'src/headless/index.ts', + plugin: 'src/plugin.ts', }, format: ['esm', 'cjs'], clean: true, @@ -24,6 +35,13 @@ export default defineConfig({ }, sourcemap: true, onSuccess: async () => { + // Copy runtime-read native voice assets into dist/native so the compiled + // recognizer can be built on the user's machine from the shipped sources. + const destDir = join(process.cwd(), 'dist', 'native'); + mkdirSync(destDir, { recursive: true }); + for (const asset of NATIVE_VOICE_ASSETS) { + copyFileSync(join(process.cwd(), 'src', 'tui', 'voice', 'native', asset), join(destDir, asset)); + } await generateTypes(process.cwd()); }, }); diff --git a/packages/_internals/auth/src/ee/interfaces/permissions.generated.ts b/packages/_internals/auth/src/ee/interfaces/permissions.generated.ts index 31d3a9e86f06..5735c54df665 100644 --- a/packages/_internals/auth/src/ee/interfaces/permissions.generated.ts +++ b/packages/_internals/auth/src/ee/interfaces/permissions.generated.ts @@ -22,6 +22,7 @@ export const RESOURCES = [ 'datasets', 'embedders', 'experiments', + 'heartbeats', 'infrastructure', 'logs', 'mcp', @@ -108,6 +109,8 @@ export const PERMISSION_PATTERNS = { 'embedders:*': 'embedders:*', /** Full access to experiments */ 'experiments:*': 'experiments:*', + /** Full access to heartbeats */ + 'heartbeats:*': 'heartbeats:*', /** Full access to infrastructure */ 'infrastructure:*': 'infrastructure:*', /** Full access to logs */ @@ -196,6 +199,14 @@ export const PERMISSION_PATTERNS = { 'embedders:read': 'embedders:read', /** View experiments */ 'experiments:read': 'experiments:read', + /** Delete heartbeats */ + 'heartbeats:delete': 'heartbeats:delete', + /** Execute heartbeats */ + 'heartbeats:execute': 'heartbeats:execute', + /** View heartbeats */ + 'heartbeats:read': 'heartbeats:read', + /** Create and modify heartbeats */ + 'heartbeats:write': 'heartbeats:write', /** View infrastructure */ 'infrastructure:read': 'infrastructure:read', /** View logs */ @@ -366,6 +377,10 @@ export const PERMISSIONS = [ 'datasets:write', 'embedders:read', 'experiments:read', + 'heartbeats:delete', + 'heartbeats:execute', + 'heartbeats:read', + 'heartbeats:write', 'infrastructure:read', 'logs:read', 'mcp:execute', @@ -484,6 +499,14 @@ export const MastraFGAPermissions = { EMBEDDERS_READ: 'embedders:read', /** View experiments */ EXPERIMENTS_READ: 'experiments:read', + /** Delete heartbeats */ + HEARTBEATS_DELETE: 'heartbeats:delete', + /** Execute heartbeats */ + HEARTBEATS_EXECUTE: 'heartbeats:execute', + /** View heartbeats */ + HEARTBEATS_READ: 'heartbeats:read', + /** Create and modify heartbeats */ + HEARTBEATS_WRITE: 'heartbeats:write', /** View infrastructure */ INFRASTRUCTURE_READ: 'infrastructure:read', /** View logs */ diff --git a/packages/agent-builder/CHANGELOG.md b/packages/agent-builder/CHANGELOG.md index 22c1901dcb03..ef315a05d5f2 100644 --- a/packages/agent-builder/CHANGELOG.md +++ b/packages/agent-builder/CHANGELOG.md @@ -1,5 +1,31 @@ # @mastra/agent-builder +## 1.1.3-alpha.2 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`d0702ee`](https://github.com/mastra-ai/mastra/commit/d0702eedc1594cb2d0d83476440cfc2ec8820adb), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/schema-compat@1.3.2-alpha.1 + - @mastra/memory@1.21.3-alpha.2 + +## 1.1.3-alpha.1 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`c607ece`](https://github.com/mastra-ai/mastra/commit/c607eceeda028a80b24d00ee7dae376db73df526), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/memory@1.21.3-alpha.1 + +## 1.1.3-alpha.0 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`9feeaa0`](https://github.com/mastra-ai/mastra/commit/9feeaa0f9a1af07039e5b4f22b932b0cb18617e8), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/schema-compat@1.3.2-alpha.0 + - @mastra/memory@1.21.3-alpha.0 + ## 1.1.2 ### Patch Changes diff --git a/packages/agent-builder/package.json b/packages/agent-builder/package.json index 702d271e53b9..0777cdcdef30 100644 --- a/packages/agent-builder/package.json +++ b/packages/agent-builder/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/agent-builder", - "version": "1.1.2", + "version": "1.1.3-alpha.2", "license": "Apache-2.0", "type": "module", "main": "dist/index.js", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 40ef26c7adfb..72073aa2d5ff 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,102 @@ # mastra +## 1.17.0-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + - @mastra/deployer@1.48.0-alpha.9 + +## 1.17.0-alpha.8 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + - @mastra/deployer@1.48.0-alpha.8 + +## 1.17.0-alpha.7 + +### Minor Changes + +- `mastra dev` and `mastra build` now pick up file-based agents defined under `src/mastra/agents/<name>/`. Agents created this way appear in Studio and respond just like agents registered in code, and the two styles can be mixed in one project. Files committed under `agents/<name>/workspace/` are mirrored into the agent's workspace so it starts with them on disk. Agents can also declare subagents under `agents/<name>/subagents/<childId>/`, which the agent can delegate to as a tool named after the directory. ([#18609](https://github.com/mastra-ai/mastra/pull/18609)) + + ```text + src/mastra/agents/weather/ + config.ts # export default agentConfig({ model: 'openai/gpt-4o' }) + instructions.md + tools/get_weather.ts + ``` + + ```bash + mastra dev # discovers and registers src/mastra/agents/weather automatically + ``` + +### Patch Changes + +- Fix `ENOENT: .mastra-fs-agents-entry.mjs` when running `mastra dev`/`mastra build` in a project that uses file-based agents. The generated fs-agents wrapper entry was written before `bundler.prepare()` emptied the output directory, so it was wiped before the bundler could read it. Wrapper generation is now split: `prepareFsAgentsEntry` returns the generated source without writing, and the new `writeFsAgentsEntry` writes it after `prepare()` runs. ([#18694](https://github.com/mastra-ai/mastra/pull/18694)) + + ```ts + const fsAgents = await prepareFsAgentsEntry({ entryFile, mastraDir, outputDirectory }); + await bundler.prepare(outputDirectory); // empties output dir + await writeFsAgentsEntry(fsAgents); // wrapper now survives for the bundler + ``` + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`7331245`](https://github.com/mastra-ai/mastra/commit/733124501b4504578648cf15ab6d64330e8778c7), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + - @mastra/deployer@1.48.0-alpha.7 + +## 1.16.1-alpha.6 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`9e76ed9`](https://github.com/mastra-ai/mastra/commit/9e76ed9f9d92619ccf5b77978d8cdea76bcae61e), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/deployer@1.48.0-alpha.6 + +## 1.16.1-alpha.5 + +### Patch Changes + +- Added storage-backed discovery of suspended agent runs, so human-in-the-loop approval UIs can recover a pending run after a page refresh or server restart. ([#17898](https://github.com/mastra-ai/mastra/pull/17898)) + + `agent.listSuspendedRuns()` lists runs waiting on a tool-call approval or on a tool that called `suspend()`. Unlike the in-memory `getActiveThreadRunId()`, it reads from storage, so it works after a restart and across multiple server instances: + + ```ts + const { runs, total } = await agent.listSuspendedRuns({ threadId, resourceId }); + if (runs[0]) { + // runs[0].toolCalls -> [{ toolCallId, toolName, args, requiresApproval }] + await agent.approveToolCall({ runId: runs[0].runId, toolCallId: runs[0].toolCalls[0].toolCallId }); + } + ``` + + Supports `threadId`/`resourceId`/date filters and pagination, mirroring `listWorkflowRuns()`. The same surface is exposed over HTTP as `GET /agents/:agentId/suspended-runs` and on the client SDK as `agent.listSuspendedRuns()`; server-enforced request-context values take precedence over client query parameters, so clients cannot list runs outside their scope. + + `sendToolApproval()` now falls back to this storage-backed discovery when no active run is found in memory for the thread, so approvals keep working after a restart. If several suspended runs match, it throws an error asking for a `toolCallId` to disambiguate. + + **Why:** approval UIs previously had no public way to recover a suspended run after a refresh or restart, forcing apps to parse internal workflow snapshots. + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/deployer@1.48.0-alpha.5 + +## 1.16.1-alpha.4 + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + - @mastra/deployer@1.48.0-alpha.4 + +## 1.16.1-alpha.3 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/deployer@1.48.0-alpha.3 + ## 1.16.1-alpha.2 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 704c477eaae3..3fa4e80de17c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "mastra", - "version": "1.16.1-alpha.2", + "version": "1.17.0-alpha.9", "license": "Apache-2.0", "description": "cli for mastra", "type": "module", diff --git a/packages/cli/src/commands/api/route-metadata.generated.ts b/packages/cli/src/commands/api/route-metadata.generated.ts index b1cf99350600..51b4d01f8f76 100644 --- a/packages/cli/src/commands/api/route-metadata.generated.ts +++ b/packages/cli/src/commands/api/route-metadata.generated.ts @@ -257,6 +257,28 @@ export const API_ROUTE_METADATA = { "kind": "single" } }, + "GET /agents/:agentId/suspended-runs": { + "method": "GET", + "path": "/agents/:agentId/suspended-runs", + "pathParams": [ + "agentId" + ], + "queryParams": [ + "fromDate", + "page", + "perPage", + "resourceId", + "threadId", + "toDate" + ], + "bodyParams": [], + "hasQuery": true, + "hasBody": false, + "responseShape": { + "kind": "object-property", + "listProperty": "runs" + } + }, "POST /agents/:agentId/approve-tool-call-generate": { "method": "POST", "path": "/agents/:agentId/approve-tool-call-generate", @@ -5637,6 +5659,148 @@ export const API_ROUTE_METADATA = { "kind": "single" } }, + "GET /heartbeats": { + "method": "GET", + "path": "/heartbeats", + "pathParams": [], + "queryParams": [ + "agentId", + "name", + "resourceId", + "threadId" + ], + "bodyParams": [], + "hasQuery": true, + "hasBody": false, + "responseShape": { + "kind": "object-property", + "listProperty": "heartbeats" + } + }, + "GET /heartbeats/:heartbeatId": { + "method": "GET", + "path": "/heartbeats/:heartbeatId", + "pathParams": [ + "heartbeatId" + ], + "queryParams": [], + "bodyParams": [], + "hasQuery": false, + "hasBody": false, + "responseShape": { + "kind": "single" + } + }, + "POST /heartbeats": { + "method": "POST", + "path": "/heartbeats", + "pathParams": [], + "queryParams": [], + "bodyParams": [ + "agentId", + "attributes", + "cron", + "id", + "ifActive", + "ifIdle", + "metadata", + "name", + "prompt", + "providerOptions", + "resourceId", + "signalType", + "tagName", + "threadId", + "timezone" + ], + "hasQuery": false, + "hasBody": true, + "responseShape": { + "kind": "single" + } + }, + "PATCH /heartbeats/:heartbeatId": { + "method": "PATCH", + "path": "/heartbeats/:heartbeatId", + "pathParams": [ + "heartbeatId" + ], + "queryParams": [], + "bodyParams": [ + "attributes", + "cron", + "ifActive", + "ifIdle", + "metadata", + "name", + "prompt", + "providerOptions", + "signalType", + "tagName", + "timezone" + ], + "hasQuery": false, + "hasBody": true, + "responseShape": { + "kind": "single" + } + }, + "DELETE /heartbeats/:heartbeatId": { + "method": "DELETE", + "path": "/heartbeats/:heartbeatId", + "pathParams": [ + "heartbeatId" + ], + "queryParams": [], + "bodyParams": [], + "hasQuery": false, + "hasBody": false, + "responseShape": { + "kind": "single" + } + }, + "POST /heartbeats/:heartbeatId/pause": { + "method": "POST", + "path": "/heartbeats/:heartbeatId/pause", + "pathParams": [ + "heartbeatId" + ], + "queryParams": [], + "bodyParams": [], + "hasQuery": false, + "hasBody": false, + "responseShape": { + "kind": "single" + } + }, + "POST /heartbeats/:heartbeatId/resume": { + "method": "POST", + "path": "/heartbeats/:heartbeatId/resume", + "pathParams": [ + "heartbeatId" + ], + "queryParams": [], + "bodyParams": [], + "hasQuery": false, + "hasBody": false, + "responseShape": { + "kind": "single" + } + }, + "POST /heartbeats/:heartbeatId/run": { + "method": "POST", + "path": "/heartbeats/:heartbeatId/run", + "pathParams": [ + "heartbeatId" + ], + "queryParams": [], + "bodyParams": [], + "hasQuery": false, + "hasBody": false, + "responseShape": { + "kind": "single" + } + }, "GET /channels/platforms": { "method": "GET", "path": "/channels/platforms", diff --git a/packages/cli/src/commands/build/build.ts b/packages/cli/src/commands/build/build.ts index d23cc9477ddc..5aa9dc8276df 100644 --- a/packages/cli/src/commands/build/build.ts +++ b/packages/cli/src/commands/build/build.ts @@ -1,5 +1,6 @@ import { join } from 'node:path'; import { getDeployer } from '@mastra/deployer'; +import { prepareFsAgentsEntry, writeFsAgentsEntry, mirrorFsAgentWorkspaces } from '@mastra/deployer/build'; import { FileService } from '../../services/service.file'; import { checkMastraPeerDeps, logPeerDepWarnings } from '../../utils/check-peer-deps'; import { createLogger } from '../../utils/logger'; @@ -34,21 +35,34 @@ export async function build({ const fs = new FileService(); const mastraEntryFile = fs.getFirstExistingFile([join(mastraDir, 'index.ts'), join(mastraDir, 'index.js')]); + // Discover fs-routed agents under agents/* and, if any exist, wrap the entry + // so they are registered onto the user's mastra instance during the build. + const fsAgents = await prepareFsAgentsEntry(mastraDir, mastraEntryFile, outputDirectory); + const bundleEntryFile = fsAgents.entryFile; + const platformDeployer = await getDeployer(mastraEntryFile, outputDirectory); if (!platformDeployer) { const deployer = new BuildBundler({ studio }); deployer.__setLogger(logger); - // Use the bundler's getAllToolPaths method to prepare tools paths - const discoveredTools = deployer.getAllToolPaths(mastraDir, tools); + // Use the bundler's getAllToolPaths method to prepare tools paths, plus + // any tools defined under agents/*/tools for fs-routed agents. + const discoveredTools = deployer.getAllToolPaths(mastraDir, [...(tools ?? []), ...fsAgents.toolPaths]); await deployer.prepare(outputDirectory); - await deployer.bundle(mastraEntryFile, outputDirectory, { + // Write the fs-routed agents wrapper after prepare() empties the output + // directory, so it survives for the bundler. No-op when none are found. + await writeFsAgentsEntry(fsAgents); + await deployer.bundle(bundleEntryFile, outputDirectory, { toolsPaths: discoveredTools, projectRoot: rootDir, }); + // Mirror authored `agents/<name>/workspace/**` seeds into the bundle so + // fs-routed agents start with those files on disk. + await mirrorFsAgentWorkspaces(mastraDir, join(outputDirectory, 'output')); + // Write build manifest with source hash for staleness detection const sourceHash = await computeSourceHash(rootDir, mastraDir); await writeBuildManifest(outputDirectory, sourceHash); @@ -68,14 +82,21 @@ export async function build({ platformDeployer.__setLogger(logger); - const discoveredTools = platformDeployer.getAllToolPaths(mastraDir, tools ?? []); + const discoveredTools = platformDeployer.getAllToolPaths(mastraDir, [...(tools ?? []), ...fsAgents.toolPaths]); await platformDeployer.prepare(outputDirectory); - await platformDeployer.bundle(mastraEntryFile, outputDirectory, { + // Write the fs-routed agents wrapper after prepare() empties the output + // directory, so it survives for the bundler. No-op when none are found. + await writeFsAgentsEntry(fsAgents); + await platformDeployer.bundle(bundleEntryFile, outputDirectory, { toolsPaths: discoveredTools, projectRoot: rootDir, }); + // Mirror authored `agents/<name>/workspace/**` seeds into the bundle so + // fs-routed agents start with those files on disk. + await mirrorFsAgentWorkspaces(mastraDir, join(outputDirectory, 'output')); + // Write build manifest with source hash for staleness detection const sourceHash = await computeSourceHash(rootDir, mastraDir); await writeBuildManifest(outputDirectory, sourceHash); diff --git a/packages/cli/src/commands/dev/dev.test.ts b/packages/cli/src/commands/dev/dev.test.ts index 1a5f28e91115..816f9939d318 100644 --- a/packages/cli/src/commands/dev/dev.test.ts +++ b/packages/cli/src/commands/dev/dev.test.ts @@ -36,6 +36,13 @@ vi.mock('@mastra/deployer/build', async importOriginal => { return { normalizeStudioBase: actual.normalizeStudioBase, + prepareFsAgentsEntry: vi.fn().mockImplementation(async (_mastraDir: string, entryFile: string) => ({ + entryFile, + toolPaths: [], + agentCount: 0, + })), + writeFsAgentsEntry: vi.fn().mockResolvedValue(undefined), + mirrorFsAgentWorkspaces: vi.fn().mockResolvedValue([]), getServerOptions: vi.fn().mockResolvedValue({ port: 4111, host: 'localhost', diff --git a/packages/cli/src/commands/dev/dev.ts b/packages/cli/src/commands/dev/dev.ts index a1e97315c52b..5acdfebab354 100644 --- a/packages/cli/src/commands/dev/dev.ts +++ b/packages/cli/src/commands/dev/dev.ts @@ -4,7 +4,13 @@ import { join, resolve } from 'node:path'; import process from 'node:process'; import devcert from '@expo/devcert'; import { FileService } from '@mastra/deployer'; -import { getServerOptions, normalizeStudioBase } from '@mastra/deployer/build'; +import { + getServerOptions, + normalizeStudioBase, + prepareFsAgentsEntry, + writeFsAgentsEntry, + mirrorFsAgentWorkspaces, +} from '@mastra/deployer/build'; import { execa } from 'execa'; import getPort from 'get-port'; import pc from 'picocolors'; @@ -437,13 +443,20 @@ export async function dev({ await acquireDevLock(dotMastraPath); const fileService = new FileService(); - const entryFile = fileService.getFirstExistingFile([join(mastraDir, 'index.ts'), join(mastraDir, 'index.js')]); + const userEntryFile = fileService.getFirstExistingFile([join(mastraDir, 'index.ts'), join(mastraDir, 'index.js')]); const bundler = new DevBundler(env); bundler.__setLogger(createLogger(debug)); // Keep Pino logger for internal bundler operations - // Use the bundler's getAllToolPaths method to prepare tools paths - const discoveredTools = bundler.getAllToolPaths(mastraDir, tools ?? []); + // Discover fs-routed agents under agents/* and, if any exist, wrap the entry so + // they are registered onto the user's mastra instance. Falls back to the user + // entry unchanged when there are none. + const fsAgents = await prepareFsAgentsEntry(mastraDir, userEntryFile, dotMastraPath); + const entryFile = fsAgents.entryFile; + + // Use the bundler's getAllToolPaths method to prepare tools paths, plus any + // tools defined under agents/*/tools for fs-routed agents. + const discoveredTools = bundler.getAllToolPaths(mastraDir, [...(tools ?? []), ...fsAgents.toolPaths]); const loadedEnv = await bundler.loadEnvVars(); @@ -469,7 +482,7 @@ export async function dev({ } } - const serverOptions = await getServerOptions(entryFile, join(dotMastraPath, 'output')); + const serverOptions = await getServerOptions(userEntryFile, join(dotMastraPath, 'output')); let portToUse = serverOptions?.port ?? process.env.PORT; let hostToUse = serverOptions?.host ?? process.env.HOST ?? 'localhost'; const studioBasePathToUse = normalizeStudioBase(serverOptions?.studioBase ?? '/'); @@ -523,6 +536,18 @@ export async function dev({ await bundler.prepare(dotMastraPath); + // Write the generated fs-routed agents wrapper entry. Runs after `prepare()` + // empties the output directory so the wrapper is not wiped before the watcher + // reads it. No-op when there are no fs-routed agents. + await writeFsAgentsEntry(fsAgents); + + // Mirror authored `agents/<name>/workspace/**` seeds into the bundled output + // directory, where the generated entry resolves each agent's default + // workspace at runtime. Runs after `prepare()` so it is not wiped. + if (fsAgents.agentCount > 0) { + await mirrorFsAgentWorkspaces(mastraDir, join(dotMastraPath, 'output')); + } + const watcher = await bundler.watch(entryFile, dotMastraPath, discoveredTools); await startServer( diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index b9e4b669d1bb..50d3488e0bb2 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,290 @@ # @mastra/core +## 1.48.0-alpha.9 + +### Minor Changes + +- support inline JSON prompt injection ([#18652](https://github.com/mastra-ai/mastra/pull/18652)) + + Added `structuredOutput.jsonPromptInjection: 'inline'` to + append JSON schema instructions to the latest user message + instead of the system prompt. This helps keep the system + prompt stable on providers that cache prompt prefixes. + + ```ts + await agent.generate('Summarize this text', { + structuredOutput: { + schema, + jsonPromptInjection: 'inline', + }, + }); + ``` + +## 1.48.0-alpha.8 + +### Minor Changes + +- Added `createCodingAgent` factory and a reusable `buildBasePrompt` so other projects can build a coding agent on top of the same defaults MastraCode uses. ([#18695](https://github.com/mastra-ai/mastra/pull/18695)) + + The factory wires sensible, portable defaults that you can override per field: + - **Workspace** — a local filesystem + sandbox rooted at `process.cwd()` (set `basePath`, pass your own `workspace`, or pass `workspace: undefined` to opt out entirely). + - **Task signals** — `TaskSignalProvider` so a task list persists across turns. + - **Error handling** — retries on `ECONNRESET` and bad-request errors, plus prefill and provider-history compatibility processors. + - **Goal judging** — the default goal judge prompt. + + `buildBasePrompt` is parameterized with `productName`, `coAuthorName` (both default to "Mastra Code"), and `coAuthorEmail` (defaults to "noreply@mastra.ai"), so you can brand the system prompt and commit trailer without forking it. + + ```ts + import { createCodingAgent } from '@mastra/core/coding-agent'; + + const agent = createCodingAgent({ + id: 'my-coding-agent', + name: 'My Coding Agent', + model: 'openai/gpt-5', + instructions: 'You help with my project.', + tools: {}, + basePath: '/path/to/repo', + }); + ``` + +### Patch Changes + +- Fixed background task execution metadata updates so they no longer rewrite the model-visible tool invocation state. ([#18556](https://github.com/mastra-ai/mastra/pull/18556)) + +## 1.48.0-alpha.7 + +### Minor Changes + +- Added file-based agents: define an agent by file convention under `src/mastra/agents/<name>/` alongside agents created with `new Agent()`. ([#18609](https://github.com/mastra-ai/mastra/pull/18609)) + + A directory becomes an agent when it has a `config.ts` or `instructions.md`. The directory name is the agent name. `instructions.md` supplies the instructions, `tools/*.ts` supply tools, and `skills/` supplies skills (a `createSkill()` module, a packaged `SKILL.md` directory, or a flat `<skill>.md`). Each file-based agent also gets a workspace by default (contained filesystem + shell sandbox rooted at a per-agent `workspace/` dir); customize it with a `workspace.ts` default export or `config.workspace`. Both styles register into the same Mastra instance and show up together in Studio, the server, and the bundler. + + **Before** + + ```ts + import { Agent } from '@mastra/core/agent'; + + export const weather = new Agent({ + id: 'weather', + name: 'weather', + instructions: 'You are a weather assistant.', + model: 'openai/gpt-4o', + }); + ``` + + **After (file-based, optional)** + + ```ts + // src/mastra/agents/weather/config.ts + import { agentConfig } from '@mastra/core/agent'; + + export default agentConfig({ + model: 'openai/gpt-4o', + // instructions taken from instructions.md, tools from tools/*.ts + }); + ``` + + A file-based agent can also declare **subagents** under `agents/<name>/subagents/<childId>/`, using the same directory layout as an agent (`config.ts`, `instructions.md`, `tools/`, `skills/`, `workspace.ts` / `workspace/`). Each subagent is assembled independently and wired into the parent's `agents` map, so the loop exposes it as a delegation tool named after the directory. A subagent's `config.ts` must set a non-empty `description` (build error otherwise), subagents inherit nothing from the parent, and they are one level deep (a nested `subagents/` directory is ignored with a warning). A subagent id colliding with a parent tool key or another subagent id is a build error; an id also present in `config.agents` keeps the `config.agents` entry with a warning. + + Code-registered agents win on name collisions, and a `config.ts` that exports `new Agent()` is used as-is (its sibling `instructions.md`, `tools/`, and `subagents/` are ignored with a warning), so existing projects are unaffected. + + The core API surface is `agentConfig()` plus the `assembleAgentFromFsEntry()` / `Mastra.__registerFsAgents()` helpers that turn a discovered directory into a registered agent. Directory discovery itself is performed by the build pipeline; importing the `mastra` instance directly as a library does not scan `agents/<name>/` directories, so register those agents in code if you need them outside the build pipeline. + +### Patch Changes + +- `DurableAgent` now matches `Agent` behavior in three places where the durable loop previously diverged: ([#18677](https://github.com/mastra-ai/mastra/pull/18677)) + - `isTaskComplete` scorers receive `requestContext` as `customContext`, so the same scorer code works on both agents. Only JSON-serializable entries from `requestContext` are forwarded; non-serializable values are dropped. Do not store secrets in `RequestContext` if you persist durable agent snapshots. + - Provider-defined tools (e.g. OpenAI `web_search`) resolve and execute when invoked by the model, instead of surfacing as `ToolNotFoundError`. + - Each iteration of a multi-step durable run produces a distinct assistant `messageId`, matching the non-durable loop and unblocking downstream consumers (signal drains, audit logs, replay) that key off message identity. + +- Fix a polynomial ReDoS in the model gateway error matcher. The `Missing .+ environment variable` pattern used to classify expected missing-auth errors could backtrack catastrophically on adversarial error messages; it now uses `Missing [^ ]+ environment variable`, which matches the same real messages without the ambiguous overlap. ([#18680](https://github.com/mastra-ai/mastra/pull/18680)) + +## 1.48.0-alpha.6 + +### Minor Changes + +- Renamed the AgentController interval API. `heartbeatHandlers` is now `intervalHandlers`, the `HeartbeatHandler` type is now `IntervalHandler`, and the `removeHeartbeat()`/`stopHeartbeats()` methods are now `removeInterval()`/`stopIntervals()`. This better reflects that these are fixed-interval background tasks, not liveness pings, and is distinct from the unrelated `mastra.heartbeats` scheduled-agent feature. ([#18665](https://github.com/mastra-ai/mastra/pull/18665)) + + **Before** + + ```ts + const { controller } = await createMastraCode({ + heartbeatHandlers: [{ id: 'sync', intervalMs: 60_000, handler: async () => {} }], + }); + await controller.removeHeartbeat({ id: 'sync' }); + await controller.stopHeartbeats(); + ``` + + **After** + + ```ts + const { controller } = await createMastraCode({ + intervalHandlers: [{ id: 'sync', intervalMs: 60_000, handler: async () => {} }], + }); + await controller.removeInterval({ id: 'sync' }); + await controller.stopIntervals(); + ``` + +### Patch Changes + +- add agent reference to processor execution context ([#18651](https://github.com/mastra-ai/mastra/pull/18651)) + +- Fix in-memory observability `listTraces` ignoring the `startExclusive` and `endExclusive` flags on `startedAt`/`endedAt` filters. Exclusive date-range bounds now drop a trace that sits exactly on the boundary, matching the pg/libsql adapters (and the in-memory log/metric filters). Closes #18635. ([#18675](https://github.com/mastra-ai/mastra/pull/18675)) + +- Fix in-memory scores store `listScoresByScorerId` returning scores in insertion order instead of newest first. The pg and libsql adapters order by `createdAt DESC`, and the sibling `listScoresBySpan` already does, so the in-memory store now sorts the same way before paginating. Closes #18618. ([#18619](https://github.com/mastra-ai/mastra/pull/18619)) + +- Fixed gs:// and s3:// file/image references being downloaded and corrupted into data: URIs during durable agent execution. The durable LLM step now forwards the model's supportedUrls (matching standard execution), so URLs a provider fetches natively (e.g. Vertex gs://) pass through as references instead of failing with "Failed to download asset" or being base64-wrapped. ([#18649](https://github.com/mastra-ai/mastra/pull/18649)) + +- Updated dependencies [[`d0702ee`](https://github.com/mastra-ai/mastra/commit/d0702eedc1594cb2d0d83476440cfc2ec8820adb)]: + - @mastra/schema-compat@1.3.2-alpha.1 + +## 1.48.0-alpha.5 + +### Minor Changes + +- **Added** heartbeats: schedule an agent to run on a recurring cron, either inside an existing conversation thread or on its own. ([#18184](https://github.com/mastra-ai/mastra/pull/18184)) + + A heartbeat fires a prompt to an agent on a schedule. When it has a thread, the run is delivered into that thread as a normal agent signal, so anything watching the thread sees it like any other message; without a thread, the agent just runs in isolation. Each heartbeat has its own id and an optional `name`, so one agent or thread can have several heartbeats with different schedules and prompts. The id is generated for you, or you can pass your own `id` to `create` for a stable handle (it's normalized to `hb_<slug>`). Heartbeats are persisted, so they keep firing across process restarts with no extra setup. + + ```ts + const hb = await mastra.heartbeats.create({ + agentId: 'chef', + name: 'morning-checkin', + threadId, + resourceId, + cron: '*/5 * * * *', + prompt: 'Check in on the user', + ifActive: { behavior: 'discard' }, // skip if the user is mid-conversation + ifIdle: { behavior: 'wake' }, // wake the agent if the thread is idle + }); + + // Threadless: run the agent on a cron with no conversation. + await mastra.heartbeats.create({ + agentId: 'chef', + cron: '0 * * * *', + prompt: 'Run the hourly summary', + }); + + await mastra.heartbeats.list({ agentId: 'chef' }); + await mastra.heartbeats.get(hb.id); + await mastra.heartbeats.update(hb.id, { prompt: 'check in gently' }); + await mastra.heartbeats.pause(hb.id); + await mastra.heartbeats.resume(hb.id); + await mastra.heartbeats.run(hb.id); // fire once now + await mastra.heartbeats.delete(hb.id); + ``` + + The same CRUD is available over HTTP through `@mastra/server` (under `/api/heartbeats`) and as top-level methods on the `@mastra/client-js` client (`client.createHeartbeat`, `client.getHeartbeat`, `client.listHeartbeats`, etc.). + + **Lifecycle hooks** + + React to heartbeat runs via `heartbeat` on the `Mastra` constructor. It's a single hook bundle that runs for every agent's heartbeats; each hook receives the firing `agentId` so you can branch on it. `prepare` resolves fire-time parameters (for example, creating a fresh thread per fire), and `onFinish` / `onError` / `onAbort` mirror `agent.stream`. + + ```ts + new Mastra({ + // ... + heartbeat: { + // Return overrides, `null` to skip this fire, or `undefined` to use defaults. + prepare: async ({ agentId, heartbeat }) => { + if (agentId === 'chef' && heartbeat.name === 'daily-digest') { + return { threadId: await createDailyThread(), resourceId: 'slack:U095PUH0FKL' }; + } + }, + onFinish: ({ agentId, outcome, result, heartbeat }) => { + metrics.record({ agentId, heartbeat: heartbeat.name, outcome }); + }, + onError: ({ agentId, error, phase, heartbeat }) => { + alerts.send(`heartbeat ${agentId}/${heartbeat.name} failed in ${phase}: ${error.message}`); + }, + }, + }); + ``` + + **Signal shaping** + + A heartbeat fire surfaces to the agent as a signal. By default it uses the `notification` type and renders as `<heartbeat>…</heartbeat>`; override `signalType` and `tagName` to change either. `ifActive` and `ifIdle` mirror the `agent.sendSignal` options shape (`{ behavior, attributes }`, plus `streamOptions` on `ifIdle`) and stay JSON-serializable so they persist with the schedule. `ifIdle.streamOptions` currently accepts `requestContext`, which is rehydrated onto the woken run. Top-level `attributes` are rendered on the signal tag, and top-level `providerOptions` are merged into the signal payload on every fire. + + ```ts + await mastra.heartbeats.create({ + agentId: 'chef', + threadId, + resourceId, + cron: '*/5 * * * *', + prompt: 'Check in on the user', + tagName: 'check-in', // renders as <check-in>…</check-in> + attributes: { source: 'cron' }, + providerOptions: { openai: { store: false } }, + ifIdle: { + behavior: 'wake', + streamOptions: { requestContext: { locale: 'en-US' } }, + }, + }); + ``` + +- Added storage-backed discovery of suspended agent runs, so human-in-the-loop approval UIs can recover a pending run after a page refresh or server restart. ([#17898](https://github.com/mastra-ai/mastra/pull/17898)) + + `agent.listSuspendedRuns()` lists runs waiting on a tool-call approval or on a tool that called `suspend()`. Unlike the in-memory `getActiveThreadRunId()`, it reads from storage, so it works after a restart and across multiple server instances: + + ```ts + const { runs, total } = await agent.listSuspendedRuns({ threadId, resourceId }); + if (runs[0]) { + // runs[0].toolCalls -> [{ toolCallId, toolName, args, requiresApproval }] + await agent.approveToolCall({ runId: runs[0].runId, toolCallId: runs[0].toolCalls[0].toolCallId }); + } + ``` + + Supports `threadId`/`resourceId`/date filters and pagination, mirroring `listWorkflowRuns()`. The same surface is exposed over HTTP as `GET /agents/:agentId/suspended-runs` and on the client SDK as `agent.listSuspendedRuns()`; server-enforced request-context values take precedence over client query parameters, so clients cannot list runs outside their scope. + + `sendToolApproval()` now falls back to this storage-backed discovery when no active run is found in memory for the thread, so approvals keep working after a restart. If several suspended runs match, it throws an error asking for a `toolCallId` to disambiguate. + + **Why:** approval UIs previously had no public way to recover a suspended run after a refresh or restart, forcing apps to parse internal workflow snapshots. + +## 1.48.0-alpha.4 + +### Patch Changes + +- Bring `InngestAgent` (Inngest-backed durable agent) to parity with `DurableAgent` for per-call execution options, abort handling, idle-aware resume, and `generate()`. ([#18615](https://github.com/mastra-ai/mastra/pull/18615)) + + `InngestAgent.stream()` and `resume()` now accept the same execution-option surface as `DurableAgent`, including `stopWhen`, `activeTools`, `structuredOutput`, `versions`, `system`, `disableBackgroundTasks`, `tracingOptions`, `actor`, `transform`, `prepareStep`, `isTaskComplete`, `delegation`, function-form `requireToolApproval`, and the lifecycle callbacks `onAbort` / `onIterationComplete`. Closure-shaped options (`prepareStep`, `transform`, function-form `isTaskComplete` / `requireToolApproval`, `stopWhen` callbacks) continue to work in-process; they degrade after a worker hop the same way they do for in-memory `DurableAgent`. + + ```ts + const result = await inngestAgent.stream(messages, { + runId: 'run-1', + abortSignal: controller.signal, + stopWhen: stepCountIs(5), + onIterationComplete: ({ iteration }) => console.log('done', iteration), + }); + + // Cancel a live run from the caller + result.abort(); + + // Resume and drive the run to completion in a single call + await inngestAgent.resume({ runId: 'run-1', resumeData, untilIdle: true }); + + // Durable equivalents of Agent.generate / resumeGenerate + const out = await inngestAgent.generate(messages, { runId: 'run-2' }); + const resumed = await inngestAgent.resumeGenerate({ runId: 'run-2', resumeData }); + ``` + + `@mastra/core` re-exports `globalRunRegistry` and `runResumeDurableStreamUntilIdle` from `@mastra/core/agent/durable` so durable-agent integrations can share the same registry and idle-wrapper plumbing. + +- Amazon Bedrock models now appear under their own `amazon-bedrock/<model>` provider in the model picker instead of the `mastracode/amazon-bedrock/<model>` namespace. Bedrock is resolved through a dedicated Amazon Bedrock gateway that authenticates with the AWS credential chain (SigV4) and surfaces models from the public models.dev catalog. Saved model selections using the previous `mastracode/amazon-bedrock/...` IDs are still resolved at runtime, so existing config keeps working. ([#17937](https://github.com/mastra-ai/mastra/pull/17937)) + +- Fixed custom model gateways being overridden by default gateways. GatewayManager now deduplicates gateways by ID (first-wins) so custom gateways take precedence over defaults. Narrowed the auth-availability check to only swallow expected missing-credential errors instead of all errors, so real gateway failures surface during debugging. ([#18602](https://github.com/mastra-ai/mastra/pull/18602)) + +## 1.48.0-alpha.3 + +### Patch Changes + +- Fixed thread metadata being lost when a processor or working memory writes to it during an agent run. The thread is re-saved when the run finishes, and it was using a stale in-memory snapshot that overwrote any metadata written mid-run via updateThread. The agent now re-reads the latest persisted thread before that save, so mid-run metadata is preserved. Affects all storage backends (Postgres, LibSQL, and others). Fixes #16216. ([#18152](https://github.com/mastra-ai/mastra/pull/18152)) + +- Fix in-memory workflow storage `getWorkflowRunById` returning `null` when `workflowName` is omitted. `workflowName` is optional in the storage contract and the pg/libsql adapters match by `runId` alone when it is not provided, but the in-memory store always compared `workflow_name === workflowName`, which never matched for an undefined name. It now matches by `runId`, only filters by `workflowName` when provided, and returns the most recent run for parity with the persistent adapters. Closes #18585. ([#18586](https://github.com/mastra-ai/mastra/pull/18586)) + +- Fixed 'Type instantiation is excessively deep' (TS2589) errors that occurred when defining workflows with Zod schemas. Workflow and step type inference is now significantly faster and no longer causes TypeScript to crash or report depth errors. ([#18608](https://github.com/mastra-ai/mastra/pull/18608)) + +- Updated dependencies [[`9feeaa0`](https://github.com/mastra-ai/mastra/commit/9feeaa0f9a1af07039e5b4f22b932b0cb18617e8), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/schema-compat@1.3.2-alpha.0 + ## 1.48.0-alpha.2 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 8d8a866a1690..bc9c26ad7deb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/core", - "version": "1.48.0-alpha.2", + "version": "1.48.0-alpha.9", "license": "Apache-2.0", "type": "module", "main": "dist/index.js", @@ -161,6 +161,16 @@ "default": "./dist/channels/index.cjs" } }, + "./coding-agent": { + "import": { + "types": "./dist/coding-agent/index.d.ts", + "default": "./dist/coding-agent/index.js" + }, + "require": { + "types": "./dist/coding-agent/index.d.ts", + "default": "./dist/coding-agent/index.cjs" + } + }, "./datasets": { "import": { "types": "./dist/datasets/index.d.ts", diff --git a/packages/core/src/agent-controller/agent-controller.ts b/packages/core/src/agent-controller/agent-controller.ts index 1c3606a414de..be6143b84a38 100644 --- a/packages/core/src/agent-controller/agent-controller.ts +++ b/packages/core/src/agent-controller/agent-controller.ts @@ -44,7 +44,7 @@ import { } from './tools'; import type { AvailableModel, - HeartbeatHandler, + IntervalHandler, AgentControllerConfig, AgentControllerMessage, AgentControllerMessageContent, @@ -181,7 +181,7 @@ export class AgentController<TState = {}> { private initPromise: Promise<void> | undefined = undefined; private browser: DynamicArgument<MastraBrowser | undefined> = undefined; private workspace: DynamicArgument<Workspace | undefined> = undefined; - private heartbeatTimers = new Map<string, { timer: NodeJS.Timeout; shutdown?: () => void | Promise<void> }>(); + private intervalTimers = new Map<string, { timer: NodeJS.Timeout; shutdown?: () => void | Promise<void> }>(); /** * The mode every new session starts in. Resolved once at construction from * `config.defaultModeId` (or the configured default/first mode) and reused by @@ -747,7 +747,7 @@ export class AgentController<TState = {}> { this.propagateRuntimeServicesToAgent(agent); } - this.startHeartbeats(); + this.startIntervals(); } private async getMemoryStorage(): Promise<MemoryStorage> { @@ -1141,14 +1141,24 @@ export class AgentController<TState = {}> { /** * Check if the current model's provider has authentication configured. * Delegates to the {@link GatewayManager} auth chain (the same resolution - * the model router uses at run time). Falls back to `hasAuth: true` when - * no model is selected or the chain cannot resolve auth. + * the model router uses at run time). Returns `hasAuth: true` only when no + * model is selected; gateway-chain failures return `hasAuth: false` so the + * auth-status endpoint stays stable instead of erroring. */ async getCurrentModelAuthStatus(session: Session<TState>): Promise<ModelAuthStatus> { const modelId = session.model.get(); if (!modelId) return { hasAuth: true }; - const hasAuth = this.#gatewayManager ? await this.#gatewayManager.hasAuth(modelId) : true; + // hasAuth returns false for expected missing-auth/missing-gateway cases. + // It rethrows unexpected gateway failures (token exchange errors, network + // bugs) — catch those here so the UI auth-status endpoint stays stable + // and falls back to "no auth" instead of erroring. + let hasAuth = true; + try { + hasAuth = this.#gatewayManager ? await this.#gatewayManager.hasAuth(modelId) : true; + } catch { + hasAuth = false; + } if (hasAuth) return { hasAuth: true }; // Surface the env-var hint from the catalog when available. @@ -1482,6 +1492,17 @@ export class AgentController<TState = {}> { memory: { thread: session.thread.getId(), resource: session.identity.getResourceId() }, abortSignal: session.run.ensureAbortController().signal, requestContext, + outputWriter: async (chunk: { type?: string; data?: unknown }) => { + if (chunk.type !== 'data-mastracode-tool-progress') return; + const data = chunk.data as { toolCallId?: string; progress?: unknown } | undefined; + if (!data?.toolCallId || data.progress === undefined) return; + + session.emit({ type: 'tool_update', toolCallId: data.toolCallId, partialResult: data.progress }); + const output = this.formatToolProgressOutput(data.progress); + if (output) { + session.emit({ type: 'shell_output', toolCallId: data.toolCallId, output, stream: 'stdout' }); + } + }, ...(tracingContext && { tracingContext }), ...(tracingOptions && { tracingOptions }), ...(callTimeInstructions && { instructions: callTimeInstructions }), @@ -1500,6 +1521,17 @@ export class AgentController<TState = {}> { return streamOptions; } + private formatToolProgressOutput(progress: unknown): string { + if (typeof progress === 'string') return progress.endsWith('\n') ? progress : `${progress}\n`; + if (typeof progress !== 'object' || progress === null) return `${String(progress)}\n`; + + const record = progress as { status?: unknown; detail?: unknown }; + const parts = [record.status, record.detail].filter( + (part): part is string => typeof part === 'string' && part.length > 0, + ); + return parts.length > 0 ? `${parts.join(': ')}\n` : ''; + } + /** * Options that every harness-driven agent run must carry — the initial stream * AND every `resumeStream`. Centralized so the two paths can't drift: a @@ -2145,42 +2177,42 @@ export class AgentController<TState = {}> { } // =========================================================================== - // Heartbeat Handlers + // Interval Handlers // =========================================================================== - private startHeartbeats(): void { - const handlers = [...(this.config.heartbeatHandlers ?? [])]; + private startIntervals(): void { + const handlers = [...(this.config.intervalHandlers ?? [])]; if (!handlers.length) return; - for (const hb of handlers) { - if (this.heartbeatTimers.has(hb.id)) continue; + for (const iv of handlers) { + if (this.intervalTimers.has(iv.id)) continue; const run = async () => { try { - await hb.handler(); + await iv.handler(); } catch (error) { - console.error(`[Heartbeat:${hb.id}] failed:`, error); + console.error(`[Interval:${iv.id}] failed:`, error); } }; - if (hb.immediate !== false) { + if (iv.immediate !== false) { void run(); } - const timer = setInterval(run, hb.intervalMs); + const timer = setInterval(run, iv.intervalMs); timer.unref(); - this.heartbeatTimers.set(hb.id, { timer, shutdown: hb.shutdown }); + this.intervalTimers.set(iv.id, { timer, shutdown: iv.shutdown }); } } - registerHeartbeat(handler: HeartbeatHandler): void { - void this.removeHeartbeat({ id: handler.id }); + registerInterval(handler: IntervalHandler): void { + void this.removeInterval({ id: handler.id }); const run = async () => { try { await handler.handler(); } catch (error) { - console.error(`[Heartbeat:${handler.id}] failed:`, error); + console.error(`[Interval:${handler.id}] failed:`, error); } }; @@ -2190,32 +2222,32 @@ export class AgentController<TState = {}> { const timer = setInterval(run, handler.intervalMs); timer.unref(); - this.heartbeatTimers.set(handler.id, { timer, shutdown: handler.shutdown }); + this.intervalTimers.set(handler.id, { timer, shutdown: handler.shutdown }); } - async removeHeartbeat({ id }: { id: string }): Promise<void> { - const entry = this.heartbeatTimers.get(id); + async removeInterval({ id }: { id: string }): Promise<void> { + const entry = this.intervalTimers.get(id); if (entry) { clearInterval(entry.timer); - this.heartbeatTimers.delete(id); + this.intervalTimers.delete(id); try { await entry.shutdown?.(); } catch (error) { - console.error(`[Heartbeat:${id}] shutdown failed:`, error); + console.error(`[Interval:${id}] shutdown failed:`, error); } } } - async stopHeartbeats(): Promise<void> { - const entries = [...this.heartbeatTimers.entries()]; - this.heartbeatTimers.clear(); + async stopIntervals(): Promise<void> { + const entries = [...this.intervalTimers.entries()]; + this.intervalTimers.clear(); for (const [id, entry] of entries) { clearInterval(entry.timer); try { await entry.shutdown?.(); } catch (error) { - console.error(`[Heartbeat:${id}] shutdown failed:`, error); + console.error(`[Interval:${id}] shutdown failed:`, error); } } } @@ -2228,7 +2260,7 @@ export class AgentController<TState = {}> { // The AgentController owns no session; per-session teardown (thread-subscription // cleanup) is the caller's responsibility via `session.thread.*`. Here we // only tear down AgentController-shared resources. - await this.stopHeartbeats(); + await this.stopIntervals(); } // =========================================================================== diff --git a/packages/core/src/agent-controller/display-state.test.ts b/packages/core/src/agent-controller/display-state.test.ts index 2662d6c4f6a2..47d5c23b9486 100644 --- a/packages/core/src/agent-controller/display-state.test.ts +++ b/packages/core/src/agent-controller/display-state.test.ts @@ -319,6 +319,55 @@ describe('tool lifecycle', () => { }); }); + it('maps Mastra Code tool progress data chunks to tool updates', async () => { + const events: AgentControllerEvent[] = []; + session.subscribe(event => { + events.push(event); + }); + + await (session as any).processStream( + { + fullStream: new ReadableStream({ + start(controller) { + controller.enqueue({ + type: 'tool-call', + runId: 'run-1', + from: ChunkFrom.AGENT, + payload: { + toolCallId: 'call-1', + toolName: 'plugin_tool', + args: {}, + }, + }); + controller.enqueue({ + type: 'data-mastracode-tool-progress', + runId: 'run-1', + from: ChunkFrom.USER, + data: { + toolCallId: 'call-1', + progress: { status: 'thinking', detail: 'Agent is answering…' }, + }, + transient: true, + }); + controller.close(); + }, + }), + }, + new RequestContext(), + ); + + expect(events).toContainEqual( + expect.objectContaining({ + type: 'tool_update', + toolCallId: 'call-1', + partialResult: { status: 'thinking', detail: 'Agent is answering…' }, + }), + ); + expect(session.displayState.get().activeTools.get('call-1')!.partialResult).toBe( + '{"status":"thinking","detail":"Agent is answering…"}', + ); + }); + it('uses display transforms while processing tool stream chunks', async () => { const events: AgentControllerEvent[] = []; session.subscribe(event => { diff --git a/packages/core/src/agent-controller/index.ts b/packages/core/src/agent-controller/index.ts index 4728f989f33e..ab19be2b111a 100644 --- a/packages/core/src/agent-controller/index.ts +++ b/packages/core/src/agent-controller/index.ts @@ -41,7 +41,7 @@ export type { AgentControllerSubagent, AgentControllerSubagentHistoryEntry, AgentControllerThread, - HeartbeatHandler, + IntervalHandler, ModelAuthStatus, ModelUseCountProvider, ModelUseCountTracker, diff --git a/packages/core/src/agent-controller/list-available-models.test.ts b/packages/core/src/agent-controller/list-available-models.test.ts index 0f82f75cff36..ad7d30c787d9 100644 --- a/packages/core/src/agent-controller/list-available-models.test.ts +++ b/packages/core/src/agent-controller/list-available-models.test.ts @@ -180,4 +180,22 @@ describe('AgentController.listAvailableModels', () => { const status = await controller.getCurrentModelAuthStatus(session); expect(status).toEqual({ hasAuth: true }); }); + + it('getCurrentModelAuthStatus falls back to hasAuth false when the gateway throws an unexpected error', async () => { + // resolveAuth throws a non-missing-auth error (e.g. a token-exchange failure). + // hasAuth rethrows it, but getCurrentModelAuthStatus should catch it so the + // UI auth-status endpoint stays stable instead of erroring. + const gateway = createFakeGateway({ + resolveAuth: () => { + throw new Error('token exchange failed'); + }, + }); + const controller = createController(gateway, 'test-gateway/acme/sonic-fast'); + await controller.init(); + const session = await controller.createSession({ id: 'test-session', ownerId: 'test-owner' }); + session.model.set({ modelId: 'test-gateway/acme/sonic-fast' }); + + const status = await controller.getCurrentModelAuthStatus(session); + expect(status.hasAuth).toBe(false); + }); }); diff --git a/packages/core/src/agent-controller/session-run-engine.ts b/packages/core/src/agent-controller/session-run-engine.ts index 4403e50b92a1..07465a7c2a08 100644 --- a/packages/core/src/agent-controller/session-run-engine.ts +++ b/packages/core/src/agent-controller/session-run-engine.ts @@ -23,6 +23,17 @@ import type { AgentControllerMessage, TokenUsage } from './types'; * being assembled, content indices for streaming deltas, and suspend/terminal * flags. One per run; recreated per run within a subscribed thread stream. */ +function formatToolProgressOutput(progress: unknown): string { + if (typeof progress === 'string') return progress.endsWith('\n') ? progress : `${progress}\n`; + if (typeof progress !== 'object' || progress === null) return `${String(progress)}\n`; + + const record = progress as { status?: unknown; detail?: unknown }; + const parts = [record.status, record.detail].filter( + (part): part is string => typeof part === 'string' && part.length > 0, + ); + return parts.length > 0 ? `${parts.join(': ')}\n` : `${JSON.stringify(progress)}\n`; +} + type StreamState = { currentMessage: AgentControllerMessage; lastFinishedMessage?: AgentControllerMessage; @@ -711,6 +722,18 @@ export class SessionRunEngine { break; } + case 'data-mastracode-tool-progress': { + const d = (chunk as any).data as Record<string, any> | undefined; + if (d?.toolCallId && d?.progress !== undefined) { + this.#session.emit({ type: 'tool_update', toolCallId: d.toolCallId, partialResult: d.progress }); + const output = formatToolProgressOutput(d.progress); + if (output) { + this.#session.emit({ type: 'shell_output', toolCallId: d.toolCallId, output, stream: 'stdout' }); + } + } + break; + } + // Sandbox streaming data chunks (from workspace execute_command tool) case 'data-sandbox-stdout': { const d = (chunk as any).data as Record<string, any> | undefined; diff --git a/packages/core/src/agent-controller/session.ts b/packages/core/src/agent-controller/session.ts index 3baff1724f81..9dd1b6e7dc1c 100644 --- a/packages/core/src/agent-controller/session.ts +++ b/packages/core/src/agent-controller/session.ts @@ -3490,8 +3490,7 @@ export class Session<TState = unknown> { // The resume data is the user's answer (a bare string), which the approval // re-check would reject because it cannot carry an `{ approved }` field. // Exempt these tools so the answer reaches the model as-is. - const isInteractive = - suspension.toolName === 'ask_user' || suspension.toolName === 'request_access'; + const isInteractive = suspension.toolName === 'ask_user' || suspension.toolName === 'request_access'; if (isInteractive) { sharedOptions.requireToolApproval = false; } diff --git a/packages/core/src/agent-controller/types.ts b/packages/core/src/agent-controller/types.ts index e65a1a8a6354..cf04954c7a62 100644 --- a/packages/core/src/agent-controller/types.ts +++ b/packages/core/src/agent-controller/types.ts @@ -14,14 +14,14 @@ import type { Workspace, WorkspaceStatus } from '../workspace'; import type { TaskItemSnapshot } from './tools'; // ============================================================================= -// Heartbeat Handlers +// Interval Handlers // ============================================================================= /** * A periodic task that the AgentController runs on a timer. - * Heartbeat handlers start during `init()` and are cleaned up on `stopHeartbeats()`. + * Interval handlers start during `init()` and are cleaned up on `stopIntervals()`. */ -export interface HeartbeatHandler { +export interface IntervalHandler { /** Unique identifier for this handler (used for dedup and logging) */ id: string; /** Interval in milliseconds between invocations */ @@ -30,7 +30,7 @@ export interface HeartbeatHandler { handler: () => void | Promise<void>; /** Whether to run the handler immediately on start (default: true) */ immediate?: boolean; - /** Called when the handler is removed or all heartbeats are stopped */ + /** Called when the handler is removed or all intervals are stopped */ shutdown?: () => void | Promise<void>; } @@ -276,10 +276,10 @@ export interface AgentControllerConfig<TState = {}> { browser?: DynamicArgument<MastraBrowser | undefined>; /** - * Periodic heartbeat handlers started during `init()`. + * Periodic interval handlers started during `init()`. * Use for background tasks like gateway sync, cache refresh, etc. */ - heartbeatHandlers?: HeartbeatHandler[]; + intervalHandlers?: IntervalHandler[]; /** * Custom ID generator for AgentController-managed IDs such as threads and mode-run identifiers. diff --git a/packages/core/src/agent/__tests__/memory-metadata.test.ts b/packages/core/src/agent/__tests__/memory-metadata.test.ts index 664a66329644..078148a34937 100644 --- a/packages/core/src/agent/__tests__/memory-metadata.test.ts +++ b/packages/core/src/agent/__tests__/memory-metadata.test.ts @@ -2,8 +2,16 @@ import { simulateReadableStream, MockLanguageModelV1 } from '@internal/ai-sdk-v4 import { convertArrayToReadableStream, MockLanguageModelV2 } from '@internal/ai-sdk-v5/test'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MockMemory } from '../../memory/mock'; +import type { Processor } from '../../processors'; import { Agent } from '../agent'; +class SerializingMockMemory extends MockMemory { + async saveThread({ thread }: { thread: StorageThreadType }): Promise<StorageThreadType> { + await super.saveThread({ thread: structuredClone(thread) }); + return thread; + } +} + function memoryMetadataTests(version: 'v1' | 'v2') { describe(`${version} - agent memory with metadata`, () => { let dummyModel: MockLanguageModelV1 | MockLanguageModelV2; @@ -231,6 +239,50 @@ function memoryMetadataTests(version: 'v1' | 'v2') { expect(thread?.metadata).toEqual({ existingField: 'should-persist', client: 'updated' }); }); + it('should preserve metadata written mid-run by a processor when finishing a new thread', async () => { + const mockMemory = new SerializingMockMemory(); + + const metadataWriter: Processor = { + id: 'metadata-writer', + async processInput({ messages }) { + await mockMemory.updateThread({ + id: 'thread-processor-metadata', + title: '', + metadata: { fromProcessor: 'survived' }, + }); + return messages; + }, + }; + + const agent = new Agent({ + id: 'test-agent', + name: 'Test Agent', + instructions: 'test', + model: dummyModel, + memory: mockMemory, + inputProcessors: [metadataWriter], + }); + + if (version === 'v1') { + await agent.generateLegacy('hello', { + memory: { + resource: 'user-1', + thread: { id: 'thread-processor-metadata', metadata: { client: 'test' } }, + }, + }); + } else { + await agent.generate('hello', { + memory: { + resource: 'user-1', + thread: { id: 'thread-processor-metadata', metadata: { client: 'test' } }, + }, + }); + } + + const thread = await mockMemory.getThreadById({ threadId: 'thread-processor-metadata' }); + expect(thread?.metadata).toEqual({ client: 'test', fromProcessor: 'survived' }); + }); + it('should not update metadata if it is the same using generate', async () => { const mockMemory = new MockMemory(); const initialThread: StorageThreadType = { diff --git a/packages/core/src/agent/__tests__/suspended-run-discovery.test.ts b/packages/core/src/agent/__tests__/suspended-run-discovery.test.ts new file mode 100644 index 000000000000..0f7eee165c1c --- /dev/null +++ b/packages/core/src/agent/__tests__/suspended-run-discovery.test.ts @@ -0,0 +1,781 @@ +/** + * Storage-backed suspended-run discovery. + * + * `getActiveThreadRunId()` is backed by an in-memory map, so it returns + * `undefined` after a server restart or on a different instance. These tests + * cover the durable path: `agent.listSuspendedRuns()` discovers suspended runs from + * workflow snapshot storage, and `sendToolApproval()` falls back to it when + * the in-memory map has no entry — making HITL approvals survive restarts. + */ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod/v4'; +import { Mastra } from '../../mastra'; +import { InMemoryStore } from '../../storage'; +import { createTool } from '../../tools'; +import type { WorkflowRunState } from '../../workflows/types'; +import { Agent } from '../agent'; +import { convertArrayToReadableStream, MockLanguageModelV2 } from './mock-model'; + +const mockFindUser = vi.fn().mockImplementation(async (data: { name: string }) => { + return { name: data.name, email: 'dero@mail.com' }; +}); + +function createFindUserTool() { + return createTool({ + id: 'Find user tool', + description: 'Returns the name and email of a user', + inputSchema: z.object({ name: z.string() }), + requireApproval: true, + execute: async input => { + return mockFindUser(input); + }, + }); +} + +function createMockModel({ + toolCallOnFirstCall = true, + toolCallId = 'call-1', + toolName = 'findUserTool', +}: { toolCallOnFirstCall?: boolean; toolCallId?: string; toolName?: string } = {}) { + let callCount = 0; + return new MockLanguageModelV2({ + doStream: async () => { + callCount++; + if (toolCallOnFirstCall && callCount === 1) { + return { + rawCall: { rawPrompt: null, rawSettings: {} }, + warnings: [], + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: 'id-0', modelId: 'mock-model-id', timestamp: new Date(0) }, + { + type: 'tool-call', + toolCallId, + toolName, + input: '{"name":"Dero Israel"}', + providerExecuted: false, + }, + { + type: 'finish', + finishReason: 'tool-calls', + usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, + }, + ]), + }; + } + return { + rawCall: { rawPrompt: null, rawSettings: {} }, + warnings: [], + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: 'id-1', modelId: 'mock-model-id', timestamp: new Date(0) }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'User found' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: 'stop', + usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, + }, + ]), + }; + }, + }); +} + +/** + * Emits two parallel tool calls on the first model turn, then a final text + * answer. When both tools require approval the loop forces sequential + * (concurrency 1) execution, so the calls suspend one at a time. + */ +function createParallelToolCallsModel() { + let callCount = 0; + return new MockLanguageModelV2({ + doStream: async () => { + callCount++; + if (callCount === 1) { + return { + rawCall: { rawPrompt: null, rawSettings: {} }, + warnings: [], + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: 'id-0', modelId: 'mock-model-id', timestamp: new Date(0) }, + { + type: 'tool-call', + toolCallId: 'call-A', + toolName: 'toolA', + input: '{"name":"A"}', + providerExecuted: false, + }, + { + type: 'tool-call', + toolCallId: 'call-B', + toolName: 'toolB', + input: '{"name":"B"}', + providerExecuted: false, + }, + { + type: 'finish', + finishReason: 'tool-calls', + usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, + }, + ]), + }; + } + return { + rawCall: { rawPrompt: null, rawSettings: {} }, + warnings: [], + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: 'id-final', modelId: 'mock-model-id', timestamp: new Date(0) }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'done' }, + { type: 'text-end', id: 'text-1' }, + { type: 'finish', finishReason: 'stop', usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 } }, + ]), + }; + }, + }); +} + +function createApprovalTool(id: string) { + return createTool({ + id, + description: id, + inputSchema: z.object({ name: z.string() }), + requireApproval: true, + execute: async input => input, + }); +} + +/** + * A model that always delegates by emitting a single tool call to a named + * sub-agent. Used to build multi-level supervisor → sub-agent chains: each + * delegating agent re-suspends its own loop when the agent it called suspends. + */ +function createDelegationModel({ toolName, toolCallId }: { toolName: string; toolCallId: string }) { + return new MockLanguageModelV2({ + doStream: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + warnings: [], + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: `${toolCallId}-0`, modelId: 'mock-model-id', timestamp: new Date(0) }, + { + type: 'tool-call', + toolCallId, + toolName, + input: '{"message":"Find Dero Israel"}', + providerExecuted: false, + }, + { type: 'finish', finishReason: 'tool-calls', usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } }, + ]), + }), + }); +} + +function createSuspendedSetup({ + storage = new InMemoryStore(), + toolCallOnFirstCall = true, + toolCallId, +}: { storage?: InMemoryStore; toolCallOnFirstCall?: boolean; toolCallId?: string } = {}) { + const agent = new Agent({ + id: 'user-agent', + name: 'User Agent', + instructions: 'You find users.', + model: createMockModel({ toolCallOnFirstCall, toolCallId }), + tools: { findUserTool: createFindUserTool() }, + }); + + const mastra = new Mastra({ + agents: { agent }, + logger: false, + storage, + }); + + return { agent, mastra, storage }; +} + +async function suspendRun(agent: Agent, threadId: string, resourceId: string) { + const stream = await agent.stream('Find the user with name - Dero Israel', { + requireToolApproval: true, + memory: { thread: threadId, resource: resourceId }, + }); + + let toolCallId = ''; + for await (const chunk of stream.fullStream) { + if (chunk.type === 'tool-call-approval') { + toolCallId = chunk.payload.toolCallId; + } + } + expect(toolCallId).toBeTruthy(); + return { runId: stream.runId, toolCallId }; +} + +afterEach(() => { + mockFindUser.mockClear(); +}); + +// The loop workflows pick their engine from MASTRA_EVENTED_EXECUTION at +// creation time (per stream call), so discovery must work against rows +// persisted by both the default (direct) engine and the evented engine. +describe.each([ + { engine: 'default', evented: false }, + { engine: 'evented', evented: true }, +])('suspended-run discovery ($engine engine)', ({ evented }) => { + beforeAll(() => { + if (evented) vi.stubEnv('MASTRA_EVENTED_EXECUTION', 'true'); + }); + + afterAll(() => { + if (evented) vi.unstubAllEnvs(); + }); + + describe('agent.listSuspendedRuns()', () => { + it('returns suspended runs with thread, resource, and tool-call info', async () => { + const { agent } = createSuspendedSetup(); + const { runId, toolCallId } = await suspendRun(agent, 'thread-1', 'resource-1'); + + const { runs, total } = await agent.listSuspendedRuns(); + expect(total).toBe(1); + expect(runs).toHaveLength(1); + expect(runs[0]).toEqual({ + runId, + status: 'suspended', + threadId: 'thread-1', + resourceId: 'resource-1', + suspendedAt: expect.any(Date), + toolCalls: [ + { + toolCallId, + toolName: 'findUserTool', + args: { name: 'Dero Israel' }, + requiresApproval: true, + }, + ], + }); + }, 30000); + + it('filters by threadId and resourceId', async () => { + const { agent } = createSuspendedSetup(); + const { runId } = await suspendRun(agent, 'thread-1', 'resource-1'); + + expect((await agent.listSuspendedRuns({ threadId: 'thread-1' })).runs).toHaveLength(1); + expect((await agent.listSuspendedRuns({ threadId: 'other-thread' })).runs).toHaveLength(0); + expect((await agent.listSuspendedRuns({ resourceId: 'resource-1' })).runs).toHaveLength(1); + expect((await agent.listSuspendedRuns({ resourceId: 'other-resource' })).runs).toHaveLength(0); + + const scoped = await agent.listSuspendedRuns({ threadId: 'thread-1', resourceId: 'resource-1' }); + expect(scoped.runs.map(run => run.runId)).toEqual([runId]); + expect(scoped.total).toBe(1); + }, 30000); + + it('paginates with perPage/page while keeping total accurate', async () => { + // The mock model only tool-calls on its first invocation, so suspend each + // run from a fresh agent sharing the same storage. + const storage = new InMemoryStore(); + await suspendRun(createSuspendedSetup({ storage }).agent, 'thread-1', 'resource-1'); + await suspendRun(createSuspendedSetup({ storage }).agent, 'thread-2', 'resource-1'); + const { agent } = createSuspendedSetup({ storage }); + await suspendRun(agent, 'thread-3', 'resource-1'); + + const pageOne = await agent.listSuspendedRuns({ resourceId: 'resource-1', perPage: 2, page: 0 }); + expect(pageOne.total).toBe(3); + expect(pageOne.runs).toHaveLength(2); + + const pageTwo = await agent.listSuspendedRuns({ resourceId: 'resource-1', perPage: 2, page: 1 }); + expect(pageTwo.total).toBe(3); + expect(pageTwo.runs).toHaveLength(1); + + const pageOneIds = pageOne.runs.map(run => run.runId); + const pageTwoIds = pageTwo.runs.map(run => run.runId); + expect(new Set([...pageOneIds, ...pageTwoIds]).size).toBe(3); + + // Without both perPage and page, all matching runs are returned. + expect((await agent.listSuspendedRuns({ resourceId: 'resource-1', perPage: 2 })).runs).toHaveLength(3); + }, 30000); + + it('only returns runs owned by the listing agent', async () => { + const storage = new InMemoryStore(); + const agentA = new Agent({ + id: 'agent-a', + name: 'Agent A', + instructions: 'You find users.', + model: createMockModel(), + tools: { findUserTool: createFindUserTool() }, + }); + const agentB = new Agent({ + id: 'agent-b', + name: 'Agent B', + instructions: 'You find users.', + model: createMockModel(), + tools: { findUserTool: createFindUserTool() }, + }); + new Mastra({ agents: { agentA, agentB }, logger: false, storage }); + + // Both agents suspend for the same resource (distinct threads — a thread + // only allows one active run at a time). + const { runId: runA } = await suspendRun(agentA, 'thread-a', 'shared-resource'); + const { runId: runB } = await suspendRun(agentB, 'thread-b', 'shared-resource'); + + const listedByA = await agentA.listSuspendedRuns({ resourceId: 'shared-resource' }); + expect(listedByA.runs.map(run => run.runId)).toEqual([runA]); + expect(listedByA.total).toBe(1); + + const listedByB = await agentB.listSuspendedRuns({ resourceId: 'shared-resource' }); + expect(listedByB.runs.map(run => run.runId)).toEqual([runB]); + expect(listedByB.total).toBe(1); + }, 30000); + + it('hides snapshots without an owning agent id from every agent (default-deny)', async () => { + const storage = new InMemoryStore(); + const agentA = new Agent({ + id: 'agent-a', + name: 'Agent A', + instructions: 'You find users.', + model: createMockModel(), + tools: { findUserTool: createFindUserTool() }, + }); + const agentB = new Agent({ + id: 'agent-b', + name: 'Agent B', + instructions: 'You find users.', + model: createMockModel(), + tools: { findUserTool: createFindUserTool() }, + }); + new Mastra({ agents: { agentA, agentB }, logger: false, storage }); + + const { runId } = await suspendRun(agentA, 'thread-a', 'shared-resource'); + + // Simulate a legacy snapshot persisted before __agentId was introduced by + // stripping it from every suspended step's payload, then re-persisting. + const workflowsStore = (await storage.getStore('workflows'))!; + const run = await workflowsStore.getWorkflowRunById({ runId, workflowName: 'agentic-loop' }); + expect(run).not.toBeNull(); + const snapshot = run!.snapshot as WorkflowRunState; + for (const key in snapshot.context) { + const step = snapshot.context[key]; + if (step?.status === 'suspended' && step.suspendPayload) { + delete (step.suspendPayload as Record<string, unknown>).__agentId; + } + } + await workflowsStore.persistWorkflowSnapshot({ + workflowName: 'agentic-loop', + runId, + resourceId: 'shared-resource', + snapshot, + }); + + // A snapshot with no owning agent id must not leak to any agent. + expect((await agentA.listSuspendedRuns({ resourceId: 'shared-resource' })).total).toBe(0); + expect((await agentB.listSuspendedRuns({ resourceId: 'shared-resource' })).total).toBe(0); + }, 30000); + + it('rejects invalid pagination inputs', async () => { + const { agent } = createSuspendedSetup(); + + await expect(agent.listSuspendedRuns({ perPage: 0 })).rejects.toMatchObject({ + id: 'AGENT_LIST_SUSPENDED_RUNS_INVALID_PER_PAGE', + }); + await expect(agent.listSuspendedRuns({ perPage: 1.5 })).rejects.toMatchObject({ + id: 'AGENT_LIST_SUSPENDED_RUNS_INVALID_PER_PAGE', + }); + await expect(agent.listSuspendedRuns({ page: -1 })).rejects.toMatchObject({ + id: 'AGENT_LIST_SUSPENDED_RUNS_INVALID_PAGE', + }); + await expect(agent.listSuspendedRuns({ page: 0.5 })).rejects.toMatchObject({ + id: 'AGENT_LIST_SUSPENDED_RUNS_INVALID_PAGE', + }); + }, 30000); + + it('filters by fromDate and toDate', async () => { + const { agent } = createSuspendedSetup(); + await suspendRun(agent, 'thread-1', 'resource-1'); + + const past = new Date(Date.now() - 60_000); + const future = new Date(Date.now() + 60_000); + + expect((await agent.listSuspendedRuns({ fromDate: past })).runs).toHaveLength(1); + expect((await agent.listSuspendedRuns({ fromDate: future })).runs).toHaveLength(0); + expect((await agent.listSuspendedRuns({ toDate: future })).runs).toHaveLength(1); + expect((await agent.listSuspendedRuns({ toDate: past })).runs).toHaveLength(0); + }, 30000); + + it('discovers suspend()-style suspensions with their suspend payload', async () => { + const getUserTool = createTool({ + id: 'Get user tool', + description: 'Returns a user, suspends to ask for the name', + inputSchema: z.object({ name: z.string() }), + suspendSchema: z.object({ message: z.string() }), + resumeSchema: z.object({ name: z.string() }), + execute: async (_input, context) => { + if (!context?.agent?.resumeData) { + return await context?.agent?.suspend({ message: 'Please provide the name of the user' }); + } + return { name: context.agent.resumeData.name, email: 'dero@mail.com' }; + }, + }); + + const agent = new Agent({ + id: 'suspending-agent', + name: 'Suspending Agent', + instructions: 'You find users.', + model: createMockModel({ toolName: 'getUserTool' }), + tools: { getUserTool }, + }); + new Mastra({ agents: { agent }, logger: false, storage: new InMemoryStore() }); + + const stream = await agent.stream('Find the user', { + memory: { thread: 'thread-1', resource: 'resource-1' }, + }); + for await (const _chunk of stream.fullStream) { + // consume until the run suspends + } + + const { runs } = await agent.listSuspendedRuns({ threadId: 'thread-1' }); + expect(runs).toHaveLength(1); + expect(runs[0]!.runId).toBe(stream.runId); + expect(runs[0]!.toolCalls).toEqual([ + expect.objectContaining({ + requiresApproval: false, + suspendPayload: expect.objectContaining({ message: 'Please provide the name of the user' }), + }), + ]); + }, 30000); + + it('returns an empty list once the run is resumed and completes', async () => { + const { agent } = createSuspendedSetup(); + const { runId, toolCallId } = await suspendRun(agent, 'thread-1', 'resource-1'); + + const resumeStream = await agent.approveToolCall({ runId, toolCallId }); + for await (const _chunk of resumeStream.fullStream) { + // consume + } + + expect((await agent.listSuspendedRuns()).runs).toHaveLength(0); + }, 30000); + + it('drains parallel approval-requiring tool calls one suspension at a time', async () => { + // The model requests two tool calls in the same turn. Because both + // require approval, the loop runs them sequentially, so each call + // suspends on its own — discovery always reports exactly the tool call + // that is currently blocking (and therefore resumable), never a stale or + // half-executed sibling. + const agent = new Agent({ + id: 'user-agent', + name: 'User Agent', + instructions: 'You call tools.', + model: createParallelToolCallsModel(), + tools: { toolA: createApprovalTool('toolA'), toolB: createApprovalTool('toolB') }, + }); + new Mastra({ agents: { agent }, logger: false, storage: new InMemoryStore() }); + + const stream = await agent.stream('do both', { + requireToolApproval: true, + memory: { thread: 'thread-par', resource: 'resource-par' }, + }); + for await (const _chunk of stream.fullStream) { + // consume until the first suspension + } + + // First suspension: only the first tool call is parked and discoverable. + const first = await agent.listSuspendedRuns({ threadId: 'thread-par' }); + expect(first.runs).toHaveLength(1); + expect(first.runs[0]!.runId).toBe(stream.runId); + expect(first.runs[0]!.toolCalls).toEqual([ + { toolCallId: 'call-A', toolName: 'toolA', args: { name: 'A' }, requiresApproval: true }, + ]); + + // Approve the first call; the loop resumes and re-suspends on the second. + const afterFirst = await agent.approveToolCall({ runId: stream.runId, toolCallId: 'call-A' }); + for await (const _chunk of afterFirst.fullStream) { + // consume until the next suspension + } + + const second = await agent.listSuspendedRuns({ threadId: 'thread-par' }); + expect(second.runs).toHaveLength(1); + expect(second.runs[0]!.runId).toBe(stream.runId); + expect(second.runs[0]!.toolCalls).toEqual([ + { toolCallId: 'call-B', toolName: 'toolB', args: { name: 'B' }, requiresApproval: true }, + ]); + + // Approve the second call; the run completes and is no longer discoverable. + const afterSecond = await agent.approveToolCall({ runId: stream.runId, toolCallId: 'call-B' }); + for await (const _chunk of afterSecond.fullStream) { + // consume to completion + } + + expect((await agent.listSuspendedRuns({ threadId: 'thread-par' })).runs).toHaveLength(0); + }, 30000); + + it('scopes nested supervisor/subagent suspensions by threadId to the resumable outer run', async () => { + const subAgent = new Agent({ + id: 'billing-agent', + name: 'Billing Agent', + instructions: 'You handle billing.', + model: createMockModel(), + tools: { findUserTool: createFindUserTool() }, + }); + const supervisorModel = new MockLanguageModelV2({ + doStream: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + warnings: [], + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: 'sup-0', modelId: 'mock-model-id', timestamp: new Date(0) }, + { + type: 'tool-call', + toolCallId: 'sup-call-1', + toolName: 'agent-billing-agent', + input: '{"message":"Find Dero Israel"}', + providerExecuted: false, + }, + { type: 'finish', finishReason: 'tool-calls', usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } }, + ]), + }), + }); + const supervisor = new Agent({ + id: 'support-agent', + name: 'Support Agent', + instructions: 'You delegate to billing.', + model: supervisorModel, + agents: { 'billing-agent': subAgent }, + }); + new Mastra({ agents: { supervisor, subAgent }, logger: false, storage: new InMemoryStore() }); + + const stream = await supervisor.stream('Find the user Dero Israel via billing', { + memory: { thread: 'thread-1', resource: 'resource-1' }, + }); + for await (const _chunk of stream.fullStream) { + // consume until suspension + } + + // Both the supervisor's outer run and the subagent's inner run persist + // suspended agentic-loop rows, but snapshots carry the owning agent's id: + // the supervisor only sees its own resumable outer run... + const allRuns = await supervisor.listSuspendedRuns(); + expect(allRuns.runs.map(run => run.runId)).toEqual([stream.runId]); + + // ...while the subagent sees its inner run. + const subAgentRuns = await subAgent.listSuspendedRuns(); + expect(subAgentRuns.runs).toHaveLength(1); + expect(subAgentRuns.runs[0]!.runId).not.toBe(stream.runId); + + const scoped = await supervisor.listSuspendedRuns({ threadId: 'thread-1', resourceId: 'resource-1' }); + expect(scoped.runs.map(run => run.runId)).toEqual([stream.runId]); + expect(scoped.runs[0]!.toolCalls).toEqual([ + { + toolCallId: 'sup-call-1', + toolName: 'agent-billing-agent', + args: { message: 'Find Dero Israel' }, + requiresApproval: true, + }, + ]); + }, 30000); + + it('scopes deep (3-level) supervisor → mid → leaf suspensions to each agent owner', async () => { + // leaf has the real requireApproval tool; mid delegates to leaf; + // supervisor delegates to mid. A suspension at the leaf bubbles up, + // re-suspending mid's and the supervisor's delegating tool calls in turn, + // producing three chained suspended agentic-loop rows. + const leaf = new Agent({ + id: 'leaf-agent', + name: 'Leaf Agent', + instructions: 'You find users.', + model: createMockModel(), + tools: { findUserTool: createFindUserTool() }, + }); + const mid = new Agent({ + id: 'mid-agent', + name: 'Mid Agent', + instructions: 'You delegate to leaf.', + model: createDelegationModel({ toolName: 'agent-leaf-agent', toolCallId: 'mid-call-1' }), + agents: { 'leaf-agent': leaf }, + }); + const supervisor = new Agent({ + id: 'supervisor-agent', + name: 'Supervisor Agent', + instructions: 'You delegate to mid.', + model: createDelegationModel({ toolName: 'agent-mid-agent', toolCallId: 'sup-call-1' }), + agents: { 'mid-agent': mid }, + }); + new Mastra({ agents: { supervisor, mid, leaf }, logger: false, storage: new InMemoryStore() }); + + const stream = await supervisor.stream('Find the user Dero Israel', { + memory: { thread: 'thread-1', resource: 'resource-1' }, + }); + for await (const _chunk of stream.fullStream) { + // consume until suspension + } + + // Each agent in the chain sees only its own suspended run. + const supervisorRuns = await supervisor.listSuspendedRuns(); + expect(supervisorRuns.runs.map(run => run.runId)).toEqual([stream.runId]); + // The supervisor's resumable run shows the delegation call to mid, not the + // real approval tool (which lives on the leaf's run). + expect(supervisorRuns.runs[0]!.toolCalls).toEqual([ + { + toolCallId: 'sup-call-1', + toolName: 'agent-mid-agent', + args: { message: 'Find Dero Israel' }, + requiresApproval: true, + }, + ]); + + const midRuns = await mid.listSuspendedRuns(); + expect(midRuns.runs).toHaveLength(1); + expect(midRuns.runs[0]!.runId).not.toBe(stream.runId); + expect(midRuns.runs[0]!.toolCalls[0]!.toolName).toBe('agent-leaf-agent'); + + const leafRuns = await leaf.listSuspendedRuns(); + expect(leafRuns.runs).toHaveLength(1); + // Only the innermost (leaf) run surfaces the actual approval tool + args. + expect(leafRuns.runs[0]!.toolCalls).toEqual([ + { + toolCallId: expect.any(String), + toolName: 'findUserTool', + args: { name: 'Dero Israel' }, + requiresApproval: true, + }, + ]); + + // All three runs are distinct rows. + const allRunIds = [supervisorRuns.runs[0]!.runId, midRuns.runs[0]!.runId, leafRuns.runs[0]!.runId]; + expect(new Set(allRunIds).size).toBe(3); + }, 30000); + + it('returns an empty list for a standalone agent (ephemeral in-memory storage)', async () => { + // Mastra falls back to an in-memory store when no storage is configured + // (and warns about it), so discovery never throws — it just finds nothing + // durable. Suspended runs only survive restarts with persistent storage. + const agent = new Agent({ + id: 'no-storage-agent', + name: 'No Storage Agent', + instructions: 'You find users.', + model: createMockModel(), + tools: { findUserTool: createFindUserTool() }, + }); + + expect((await agent.listSuspendedRuns()).runs).toEqual([]); + }, 30000); + }); + + describe('agent.sendToolApproval() storage fallback', () => { + it('approves a suspended run after a simulated restart (in-memory state lost)', async () => { + const storage = new InMemoryStore(); + const { agent } = createSuspendedSetup({ storage }); + const { runId, toolCallId } = await suspendRun(agent, 'thread-1', 'resource-1'); + + // Simulate a server restart: a fresh Agent + Mastra process sharing the + // same storage. The in-memory thread-run map is empty, but the suspended + // snapshot is still in storage. + const { agent: restartedAgent, mastra } = createSuspendedSetup({ storage, toolCallOnFirstCall: false }); + expect(restartedAgent.getActiveThreadRunId({ threadId: 'thread-1', resourceId: 'resource-1' })).toBeUndefined(); + + const result = await restartedAgent.sendToolApproval({ + threadId: 'thread-1', + resourceId: 'resource-1', + toolCallId, + approved: true, + }); + expect(result).toEqual({ accepted: true, runId, toolCallId }); + + // The resumed run executes the approved tool and runs to completion, + // leaving no suspended rows behind. + const workflowsStore = (await mastra.getStorage()!.getStore('workflows'))!; + await vi.waitFor( + async () => { + expect(mockFindUser).toHaveBeenCalledWith(expect.objectContaining({ name: 'Dero Israel' })); + expect((await workflowsStore.listWorkflowRuns({})).runs).toHaveLength(0); + }, + { timeout: 10000 }, + ); + }, 30000); + + it('declines a suspended run after a simulated restart', async () => { + const storage = new InMemoryStore(); + const { agent } = createSuspendedSetup({ storage }); + const { runId } = await suspendRun(agent, 'thread-1', 'resource-1'); + + const { agent: restartedAgent, mastra } = createSuspendedSetup({ storage, toolCallOnFirstCall: false }); + + const result = await restartedAgent.sendToolApproval({ + threadId: 'thread-1', + resourceId: 'resource-1', + approved: false, + }); + expect(result.runId).toBe(runId); + + const workflowsStore = (await mastra.getStorage()!.getStore('workflows'))!; + await vi.waitFor( + async () => { + expect((await workflowsStore.listWorkflowRuns({})).runs).toHaveLength(0); + }, + { timeout: 10000 }, + ); + expect(mockFindUser).not.toHaveBeenCalled(); + }, 30000); + + it('throws on ambiguous suspended runs and disambiguates by toolCallId', async () => { + const storage = new InMemoryStore(); + await suspendRun(createSuspendedSetup({ storage, toolCallId: 'call-a' }).agent, 'thread-1', 'resource-1'); + const second = await suspendRun( + createSuspendedSetup({ storage, toolCallId: 'call-b' }).agent, + 'thread-1', + 'resource-1', + ); + + // Fresh process: two suspended runs match the thread and no toolCallId + // is provided, so the fallback cannot pick one. + const { agent: restartedAgent } = createSuspendedSetup({ storage, toolCallOnFirstCall: false }); + await expect( + restartedAgent.sendToolApproval({ + threadId: 'thread-1', + resourceId: 'resource-1', + approved: true, + }), + ).rejects.toMatchObject({ + id: 'AGENT_SEND_TOOL_APPROVAL_AMBIGUOUS_SUSPENDED_RUNS', + }); + + // Passing the toolCallId narrows the match to a single run. + const result = await restartedAgent.sendToolApproval({ + threadId: 'thread-1', + resourceId: 'resource-1', + toolCallId: second.toolCallId, + approved: true, + }); + expect(result).toEqual({ accepted: true, runId: second.runId, toolCallId: second.toolCallId }); + }, 30000); + + it('throws when no active or suspended run exists for the thread', async () => { + const { agent } = createSuspendedSetup(); + + await expect( + agent.sendToolApproval({ + threadId: 'thread-without-run', + resourceId: 'resource-1', + approved: true, + }), + ).rejects.toMatchObject({ + id: 'AGENT_SEND_TOOL_APPROVAL_NO_ACTIVE_THREAD_RUN', + }); + }, 30000); + + it('surfaces storage failures instead of reporting "no suspended run"', async () => { + const storage = new InMemoryStore(); + const { agent } = createSuspendedSetup({ storage }); + + const workflowsStore = (await storage.getStore('workflows'))!; + vi.spyOn(workflowsStore, 'listWorkflowRuns').mockRejectedValue(new Error('storage outage')); + + await expect( + agent.sendToolApproval({ + threadId: 'thread-1', + resourceId: 'resource-1', + approved: true, + }), + ).rejects.toThrow('storage outage'); + }, 30000); + }); +}); diff --git a/packages/core/src/agent/__tests__/title-generation.test.ts b/packages/core/src/agent/__tests__/title-generation.test.ts index d45718a6130e..bc2291b09cdc 100644 --- a/packages/core/src/agent/__tests__/title-generation.test.ts +++ b/packages/core/src/agent/__tests__/title-generation.test.ts @@ -1,6 +1,6 @@ import { simulateReadableStream, MockLanguageModelV1 } from '@internal/ai-sdk-v4/test'; import { convertArrayToReadableStream, MockLanguageModelV2 } from '@internal/ai-sdk-v5/test'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { noopLogger } from '../../logger'; import { MockMemory } from '../../memory/mock'; import { RequestContext } from '../../request-context'; @@ -1966,6 +1966,106 @@ function titleGenerationTests(version: 'v1' | 'v2') { expect(thread?.title).toBe(originalTitle); }); + it('should catch title persistence failures without causing an unhandled rejection', async () => { + if (version !== 'v2') { + return; + } + + const titleText = 'Generated thread title'; + const mockMemory = new MockMemory(); + const originalSaveThread = mockMemory.saveThread.bind(mockMemory); + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trackException: vi.fn(), + getTransports: vi.fn().mockReturnValue(new Map()), + listLogs: vi.fn().mockResolvedValue({ logs: [], total: 0, page: 1, perPage: 10, hasMore: false }), + listLogsByRunId: vi.fn().mockResolvedValue({ logs: [], total: 0, page: 1, perPage: 10, hasMore: false }), + }; + + vi.spyOn(mockMemory, 'saveThread').mockImplementation(async args => { + if (args.thread.title === titleText) { + throw new Error('sqlite write failed'); + } + + return originalSaveThread(args); + }); + + const titleModel = new MockLanguageModelV2({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 }, + text: titleText, + content: [{ type: 'text', text: titleText }], + warnings: [], + }), + doStream: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + warnings: [], + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: 'id-0', modelId: 'mock-model-id', timestamp: new Date(0) }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: titleText }, + { type: 'text-end', id: 'text-1' }, + { type: 'finish', finishReason: 'stop', usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 } }, + ]), + }), + }); + + mockMemory.getMergedThreadConfig = () => { + return { + generateTitle: { + model: titleModel, + }, + }; + }; + + const agent = new Agent({ + id: 'title-persist-error-agent', + name: 'Title Persist Error Agent', + instructions: 'test agent', + model: dummyModel, + memory: mockMemory, + }); + agent.__setLogger(logger as any); + + let unhandledReason: unknown = null; + const onUnhandledRejection = (reason: unknown) => { + unhandledReason = reason; + }; + process.once('unhandledRejection', onUnhandledRejection); + + try { + await agent.generate('Test message', { + memory: { + resource: 'user-1', + thread: { + id: 'thread-title-persist-error', + title: '', + }, + }, + }); + + await new Promise(resolve => setTimeout(resolve, 100)); + } finally { + process.removeListener('unhandledRejection', onUnhandledRejection); + } + + expect(unhandledReason).toBeNull(); + expect(logger.error).toHaveBeenCalledWith( + 'Error persisting generated title:', + expect.objectContaining({ message: 'sqlite write failed' }), + ); + + const thread = await mockMemory.getThreadById({ threadId: 'thread-title-persist-error' }); + expect(thread).toBeDefined(); + expect(thread?.title).toBe(''); + }); + it('should handle empty or null instructions appropriately', async () => { let capturedPrompt = ''; diff --git a/packages/core/src/agent/agent-legacy.ts b/packages/core/src/agent/agent-legacy.ts index 5de68eed60b3..a6b47827dacb 100644 --- a/packages/core/src/agent/agent-legacy.ts +++ b/packages/core/src/agent/agent-legacy.ts @@ -540,26 +540,9 @@ export class AgentLegacyHandler { threadId, }); - const messageListResponses = new MessageList({ - threadId, - resourceId, - generateMessageId: this.capabilities.mastra?.generateId?.bind(this.capabilities.mastra), - // @ts-expect-error Flag for agent network messages - _agentNetworkAppend: this.capabilities._agentNetworkAppend, - }) - .add(result.response.messages, 'response') - .get.all.core(); - - const usedWorkingMemory = messageListResponses?.some( - m => m.role === 'tool' && m?.content?.some(c => c?.toolName === 'updateWorkingMemory'), - ); - // working memory updates the thread, so we need to get the latest thread if we used it + // re-read the latest thread so metadata written mid-run (working memory, processors) isn't overwritten const memory = await this.capabilities.getMemory({ requestContext }); - const thread = usedWorkingMemory - ? threadId - ? await memory?.getThreadById({ threadId }) - : undefined - : threadAfter; + const thread = (threadId ? await memory?.getThreadById({ threadId }) : undefined) ?? threadAfter; if (memory && resourceId && thread) { try { diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 81756bbd8af9..4fcab628ac1d 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -47,7 +47,6 @@ import type { VersionOverrides } from '../mastra/types'; import { mergeVersionOverrides } from '../mastra/types'; import type { MastraMemory } from '../memory/memory'; import type { MemoryConfig, MemoryConfigInternal } from '../memory/types'; -import { isWorkingMemoryToolName } from '../memory/working-memory-utils'; import { resolveNotificationDeliveryDecision } from '../notifications/delivery-policy'; import { createNotificationSignal, @@ -105,7 +104,7 @@ import type { MastraVoice } from '../voice'; import { DefaultVoice } from '../voice'; import { createWorkflow } from '../workflows/create'; import type { Step } from '../workflows/step'; -import type { OutputWriter, WorkflowResult, WorkflowRunState } from '../workflows/types'; +import type { OutputWriter, WorkflowResult, WorkflowRunState, WorkflowRunStatus } from '../workflows/types'; import { waitForSuspendedSnapshot } from '../workflows/utils'; import type { AnyWorkflow } from '../workflows/workflow'; import { createStep, isProcessor } from '../workflows/workflow'; @@ -245,6 +244,74 @@ type AgentSnapshotMemoryInfo = { resourceId?: string; }; +/** + * A suspended tool call inside a suspended agent run — either waiting on a + * tool-call approval (`requireApproval` / `requireToolApproval`) or on resume + * data for a tool that called `suspend()`. + */ +export interface AgentRunToolCall { + toolCallId?: string; + toolName?: string; + /** Arguments the model supplied for the tool call (approval suspensions only). */ + args?: unknown; + /** True when the run is waiting on a tool-call approval. */ + requiresApproval: boolean; + /** The tool-defined suspend payload when the tool itself called `suspend()`. */ + suspendPayload?: unknown; +} + +/** + * Statuses of agent runs discoverable via {@link Agent.listSuspendedRuns}. + * + * Agent run snapshots are only persisted while a run is waiting on input and + * are deleted when the run reaches a terminal state, so `'suspended'` is the + * only status discoverable from storage today. + */ +export type AgentRunStatus = Extract<WorkflowRunStatus, 'suspended'>; + +/** + * Filters for {@link Agent.listSuspendedRuns}. Mirrors the `listWorkflowRuns` + * filter contract, plus the agent-level `threadId` filter. + */ +export interface AgentListSuspendedRunsOptions { + /** Only return runs that belong to this memory thread. */ + threadId?: string; + /** Only return runs that belong to this memory resource. */ + resourceId?: string; + /** Only return runs created at or after this date. */ + fromDate?: Date; + /** Only return runs created at or before this date. */ + toDate?: Date; + /** + * Number of items per page. Pagination is applied when both `perPage` and + * `page` are provided; otherwise all matching runs are returned. + */ + perPage?: number; + /** Zero-indexed page number. */ + page?: number; +} + +/** + * An agent run discovered from workflow snapshot storage. + */ +export interface AgentRun { + /** Run ID accepted by `resumeStream()`, `approveToolCall()`, and `declineToolCall()`. */ + runId: string; + status: AgentRunStatus; + threadId?: string; + resourceId?: string; + /** When the run's snapshot was last persisted (i.e. when it suspended). */ + suspendedAt: Date; + /** Suspended tool calls awaiting approval or resume data. */ + toolCalls: AgentRunToolCall[]; +} + +export interface AgentListSuspendedRunsResult { + runs: AgentRun[]; + /** Total number of matching runs, before pagination. */ + total: number; +} + function getInvocationActor(context: unknown): ActorSignal | undefined { return (context as { actor?: ActorSignal } | undefined)?.actor; } @@ -1351,6 +1418,7 @@ export class Agent< errorProcessors, logger: this.logger, agentName: this.name, + agent: this, processorStates, }); } @@ -1496,7 +1564,6 @@ export class Agent< // processor hookup. Channels render the agent's stream to the originating // chat platform via this processor; without it, replies never reach Slack. const channelProcessors = this.#agentChannels ? this.#agentChannels.getOutputProcessors(configuredProcessors) : []; - // Combine all processors into a single workflow // User-configured processors run first so they can transform chunks // (e.g. PII redaction, translation) before the channel renders them. @@ -4212,6 +4279,7 @@ export class Agent< toolsets, requestContext, mastraProxy, + outputWriter, autoResumeSuspendedTools, backgroundTaskEnabled, ...rest @@ -4222,6 +4290,7 @@ export class Agent< toolsets: ToolsetsInput; requestContext: RequestContext; mastraProxy?: MastraUnion; + outputWriter?: OutputWriter; autoResumeSuspendedTools?: boolean; backgroundTaskEnabled?: boolean; } & Partial<ObservabilityContext>) { @@ -4253,6 +4322,7 @@ export class Agent< requestContext, ...observabilityContext, model: await this.getModel({ requestContext }), + outputWriter, tracingPolicy: this.#options?.tracingPolicy, requireApproval: (toolObj as any).requireApproval, backgroundConfig: (toolObj as any).background, @@ -5586,6 +5656,7 @@ export class Agent< resourceId?: string; runId?: string; requestContext?: RequestContext; + outputWriter?: OutputWriter; memoryConfig?: MemoryConfig; autoResumeSuspendedTools?: boolean; hooks?: ToolHooks; @@ -5619,6 +5690,7 @@ export class Agent< resourceId: resourceIdFromContext || options.resourceId || optionMemory?.resource || mergedMemory?.resource, runId: mergedOptions.runId, requestContext, + outputWriter: mergedOptions.outputWriter, memoryConfig: options.memoryConfig ?? mergedMemory?.options, autoResumeSuspendedTools: mergedOptions.autoResumeSuspendedTools, // Use the deep-merged delegation so default callbacks (e.g. messageFilter) @@ -5704,6 +5776,7 @@ export class Agent< ...observabilityContext, mastraProxy, toolsets: toolsets!, + outputWriter, autoResumeSuspendedTools, backgroundTaskEnabled, }); @@ -6179,9 +6252,19 @@ export class Agent< return undefined; } - #getSuspendedToolInfo( - existingSnapshot: WorkflowRunState | null | undefined, - ): { toolCallId?: string; toolName?: string } | undefined { + #getSnapshotAgentId(existingSnapshot: WorkflowRunState | null | undefined): string | undefined { + for (const key in existingSnapshot?.context) { + const step = existingSnapshot?.context[key]; + if (step && step.status === 'suspended' && step.suspendPayload?.__agentId) { + return step.suspendPayload.__agentId; + } + } + + return undefined; + } + + #getSuspendedToolCalls(existingSnapshot: WorkflowRunState | null | undefined): AgentRunToolCall[] { + const toolCalls: AgentRunToolCall[] = []; for (const key in existingSnapshot?.context) { const step = existingSnapshot?.context[key]; if (step?.status !== 'suspended') continue; @@ -6189,20 +6272,30 @@ export class Agent< if (!payload) continue; if (payload.requireToolApproval) { - return { + toolCalls.push({ toolCallId: payload.requireToolApproval.toolCallId, toolName: payload.requireToolApproval.toolName, - }; - } - if (payload.toolCallSuspended || payload.toolName || payload.toolCallId) { - return { + args: payload.requireToolApproval.args, + requiresApproval: true, + }); + } else if (payload.toolCallSuspended || payload.toolName || payload.toolCallId) { + toolCalls.push({ toolCallId: payload.toolCallId, toolName: payload.toolName, - }; + requiresApproval: false, + suspendPayload: payload.toolCallSuspended, + }); } } - return undefined; + return toolCalls; + } + + #getSuspendedToolInfo( + existingSnapshot: WorkflowRunState | null | undefined, + ): { toolCallId?: string; toolName?: string } | undefined { + const [first] = this.#getSuspendedToolCalls(existingSnapshot); + return first ? { toolCallId: first.toolCallId, toolName: first.toolName } : undefined; } #getResumeSpanInput(resumeData: unknown, suspendedToolInfo?: { toolCallId?: string; toolName?: string }): unknown { @@ -6766,14 +6859,9 @@ export class Agent< resourceId, }); - const messageListResponses = messageList.get.response.aiV4.core(); - - const usedWorkingMemory = messageListResponses.some( - m => m.role === 'tool' && m.content.some(c => isWorkingMemoryToolName(c.toolName)), - ); - // working memory updates the thread, so we need to get the latest thread if we used it + // re-read the latest thread so metadata written mid-run (working memory, processors) isn't overwritten const memory = await this.getMemory({ requestContext }); - const thread = usedWorkingMemory ? (threadId ? await memory?.getThreadById({ threadId }) : undefined) : threadAfter; + const thread = (!readOnlyMemory && threadId ? await memory?.getThreadById({ threadId }) : undefined) ?? threadAfter; // Add LLM response messages to the list // Prefer dbMessages (MastraDBMessage[] with original IDs) over response.messages @@ -6851,13 +6939,17 @@ export class Agent< ).then( async title => { if (title) { - await memory.createThread({ - threadId: thread.id, - resourceId, - memoryConfig, - title, - metadata: thread.metadata, - }); + try { + await memory.createThread({ + threadId: thread.id, + resourceId, + memoryConfig, + title, + metadata: thread.metadata, + }); + } catch (error) { + this.logger.error('Error persisting generated title:', error); + } } }, error => { @@ -7276,6 +7368,121 @@ export class Agent< return agentThreadStreamRuntime.getActiveThreadRunId(options, this.getPubSub()); } + /** + * Lists suspended agent runs from workflow snapshot storage — runs waiting on + * a tool-call approval (`requireApproval` / `requireToolApproval`) or on a + * tool that called `suspend()`. + * + * Unlike {@link getActiveThreadRunId}, which only knows about runs started by the + * current process, this is backed by storage: it works after a server restart and + * across multiple server instances. Pass the returned `runId` to `resumeStream()`, + * `approveToolCall()`, or `declineToolCall()`. + * + * Results are scoped to runs started by this agent: snapshots persist the owning + * agent's id, and runs whose snapshots carry a different id are skipped. Filter by + * `threadId`/`resourceId` to scope results to a conversation. + * + * @example + * ```typescript + * const { runs } = await agent.listSuspendedRuns({ threadId, resourceId }); + * if (runs[0]) { + * await agent.approveToolCall({ runId: runs[0].runId }); + * } + * ``` + */ + async listSuspendedRuns(options: AgentListSuspendedRunsOptions = {}): Promise<AgentListSuspendedRunsResult> { + const { threadId, resourceId, fromDate, toDate, perPage, page } = options; + + if (perPage !== undefined && (!Number.isInteger(perPage) || perPage <= 0)) { + throw new MastraError({ + id: 'AGENT_LIST_SUSPENDED_RUNS_INVALID_PER_PAGE', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + text: `Agent "${this.name}" listSuspendedRuns() requires perPage to be a positive integer.`, + details: { agentName: this.name, perPage }, + }); + } + if (page !== undefined && (!Number.isInteger(page) || page < 0)) { + throw new MastraError({ + id: 'AGENT_LIST_SUSPENDED_RUNS_INVALID_PAGE', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + text: `Agent "${this.name}" listSuspendedRuns() requires page to be a non-negative integer.`, + details: { agentName: this.name, page }, + }); + } + + const effectiveMastra = this.#mastra ?? (await this.#getOrCreateEphemeralMastra()); + const workflowsStore = await effectiveMastra?.getStorage()?.getStore('workflows'); + + if (!workflowsStore) { + throw new MastraError({ + id: 'AGENT_LIST_SUSPENDED_RUNS_NO_STORAGE', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + text: + `Agent "${this.name}" listSuspendedRuns() requires storage to discover suspended runs. ` + + `Register the agent on a Mastra instance with persistent storage (e.g. PostgreSQL, LibSQL).`, + details: { agentName: this.name }, + }); + } + + // threadId/resourceId live inside the snapshot state rather than in storage + // columns, so fetch all matching rows and filter/paginate here to keep + // `total` accurate. + const { runs } = await workflowsStore.listWorkflowRuns({ + workflowName: 'agentic-loop', + status: 'suspended', + fromDate, + toDate, + }); + + const matchedRuns: AgentRun[] = []; + for (const run of runs) { + let snapshot = run.snapshot; + if (typeof snapshot === 'string') { + try { + snapshot = JSON.parse(snapshot) as WorkflowRunState; + } catch { + continue; + } + } + if (snapshot?.status !== 'suspended') continue; + + // Snapshots persist the owning agent's id, so runs started by other + // agents sharing the same agentic-loop snapshot storage are skipped. + // Default-deny: a snapshot whose owning agent id is missing or does not + // match is not surfaced, so runs cannot leak across agents. + const runAgentId = this.#getSnapshotAgentId(snapshot); + if (runAgentId !== this.id) continue; + + // thread/resource info travels in the suspended stream state; the run row's + // resourceId column is used as the primary source when present. + const memoryInfo = this.#getSnapshotMemoryInfo(snapshot); + const runThreadId = memoryInfo?.threadId; + const runResourceId = run.resourceId ?? memoryInfo?.resourceId; + if (threadId && runThreadId !== threadId) continue; + if (resourceId && runResourceId !== resourceId) continue; + + matchedRuns.push({ + runId: run.runId, + status: 'suspended', + threadId: runThreadId, + resourceId: runResourceId, + suspendedAt: run.updatedAt, + toolCalls: this.#getSuspendedToolCalls(snapshot), + }); + } + + const total = matchedRuns.length; + const paginatedRuns = + perPage !== undefined && page !== undefined + ? matchedRuns.slice(page * perPage, (page + 1) * perPage) + : matchedRuns; + + return { runs: paginatedRuns, total }; + } + abortThreadStream(options: AgentSubscribeToThreadOptions): boolean { return agentThreadStreamRuntime.abortThread(options, this.getPubSub()); } @@ -7490,7 +7697,7 @@ export class Agent< const result = agentThreadStreamRuntime.sendSignal<OUTPUT>( this as Agent<any, any, any, any>, signal, - { ...target, ifIdle: { ...target.ifIdle, behavior: record.priority === 'low' ? 'persist' : 'wake' } }, + target, this.getPubSub(), ); let delivered: SendAgentSignalAccepted<OUTPUT>; @@ -7544,20 +7751,6 @@ export class Agent< return results; } - /** - * @experimental Agent notification signal APIs are experimental and may change in a future release. - * - * Resolves stream options for deferred notification dispatch. Called by the - * notification dispatcher so idle-thread wakes carry the requestContext and - * model configuration the agent needs. - */ - getNotificationStreamOptions(target: { - resourceId: string; - threadId: string; - }): Record<string, unknown> | Promise<Record<string, unknown> | undefined> | undefined { - return this.#notifications?.getNotificationStreamOptions?.(target); - } - /** * @experimental Agent signals are experimental and may change in a future release. */ @@ -8333,13 +8526,62 @@ export class Agent< return { accepted: continuation.accepted, runId: continuation.runId, toolCallId: options.toolCallId }; } - const runId = this.getActiveThreadRunId({ threadId, resourceId }); + let runId = this.getActiveThreadRunId({ threadId, resourceId }); + // Tracks whether runId was recovered from storage (not the in-memory active-run + // map). Storage-discovered runs are not present in the in-memory thread runtime, + // so they must resume directly via resumeStream() rather than through + // sendStreamResume(), whose getResumableThreadRun() guard is in-memory only. + let resolvedFromStorage = false; + + if (!runId) { + // The in-memory active-run map only knows about runs started by this process. + // After a server restart (or on another instance) fall back to storage-backed + // suspended-run discovery so approvals stay durable. + let suspendedRuns: AgentRun[] = []; + try { + ({ runs: suspendedRuns } = await this.listSuspendedRuns({ threadId, resourceId })); + } catch (error) { + // Only swallow the expected no-storage case — storage outages and + // store-driver errors must surface instead of masquerading as + // "no suspended run exists". + if (!(error instanceof MastraError) || error.id !== 'AGENT_LIST_SUSPENDED_RUNS_NO_STORAGE') { + throw error; + } + } + + const matchingRuns = options.toolCallId + ? suspendedRuns.filter(run => run.toolCalls.some(toolCall => toolCall.toolCallId === options.toolCallId)) + : suspendedRuns; + + if (matchingRuns.length > 1) { + throw new MastraError({ + id: 'AGENT_SEND_TOOL_APPROVAL_AMBIGUOUS_SUSPENDED_RUNS', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + text: + `Agent "${this.name}" sendToolApproval() found ${matchingRuns.length} suspended runs for thread "${threadId}". ` + + `Pass a toolCallId to disambiguate, or resume a specific run with approveToolCall()/declineToolCall() and an explicit runId.`, + details: { + threadId, + resourceId, + agentName: this.name, + runIds: matchingRuns.map(run => run.runId).join(', '), + }, + }); + } + + runId = matchingRuns[0]?.runId; + resolvedFromStorage = runId !== undefined; + } + if (!runId) { throw new MastraError({ id: 'AGENT_SEND_TOOL_APPROVAL_NO_ACTIVE_THREAD_RUN', domain: ErrorDomain.AGENT, category: ErrorCategory.USER, - text: `Agent "${this.name}" sendToolApproval() could not find an active run for thread "${threadId}".`, + text: + `Agent "${this.name}" sendToolApproval() could not find an active or suspended run for thread "${threadId}". ` + + `The run may have already completed or been resumed.`, details: { threadId, resourceId, @@ -8353,27 +8595,45 @@ export class Agent< executionOptions as Record<string, unknown>, ) as unknown as AgentExecutionOptions<OUTPUT>; + const resumeData = + customResumeData !== undefined + ? customResumeData + : approved + ? { approved } + : declineContext + ? { approved, ...declineContext } + : { approved }; + const resumeStreamOptions = { + ...resumeOptions, + memory: { + ...(resumeOptions.memory ?? {}), + thread: resumeOptions.memory?.thread ?? threadId, + resource: resumeOptions.memory?.resource ?? resourceId, + }, + }; + + if (resolvedFromStorage) { + // The run was recovered from storage and is not tracked by the in-memory + // thread runtime, so sendStreamResume()'s getResumableThreadRun() guard would + // reject it. Resume directly from the persisted snapshot, mirroring the + // explicit-runId approveToolCall()/declineToolCall() entry points. + // @ts-expect-error - resumeStream overloads don't narrow cleanly here; matches + // the same pattern used by approveToolCall()/declineToolCall() above. + await this.resumeStream(resumeData, { + ...resumeStreamOptions, + runId, + ...(options.toolCallId ? { toolCallId: options.toolCallId } : {}), + }); + return { accepted: true, runId, toolCallId: options.toolCallId }; + } + return this.sendStreamResume({ threadId, resourceId, runId, toolCallId: options.toolCallId, - resumeData: - customResumeData !== undefined - ? customResumeData - : approved - ? { approved } - : declineContext - ? { approved, ...declineContext } - : { approved }, - streamOptions: { - ...resumeOptions, - memory: { - ...(resumeOptions.memory ?? {}), - thread: resumeOptions.memory?.thread ?? threadId, - resource: resumeOptions.memory?.resource ?? resourceId, - }, - }, + resumeData, + streamOptions: resumeStreamOptions, }); } diff --git a/packages/core/src/agent/durable/__tests__/durable-agent-images.test.ts b/packages/core/src/agent/durable/__tests__/durable-agent-images.test.ts index 703526b10013..c287ae0962e8 100644 --- a/packages/core/src/agent/durable/__tests__/durable-agent-images.test.ts +++ b/packages/core/src/agent/durable/__tests__/durable-agent-images.test.ts @@ -311,6 +311,82 @@ describe('DurableAgent image handling', () => { expect(result.threadId).toBe('image-thread'); }); }); + + describe('cloud-storage URL references (gs://, s3://)', () => { + // Regression: durable execution must forward the model's `supportedUrls` so a + // natively-supported scheme (e.g. `gs://`) passes through instead of being + // downloaded or base64-wrapped. + const GS_URI = 'gs://devtest-petcircle-assets/add-pet-details/dog.png'; + + function createCapturingModel(prompts: unknown[], supportedUrls?: Record<string, RegExp[]>) { + return new MockLanguageModelV2({ + supportedUrls, + doStream: async (options: any) => { + prompts.push(options.prompt); + return { + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: 'id-0', modelId: 'mock-model-id', timestamp: new Date(0) }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'I can see the image.' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: 'stop', + usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, + }, + ]), + rawCall: { rawPrompt: null, rawSettings: {} }, + warnings: [], + }; + }, + }); + } + + function findFilePart(prompt: any) { + const userMessage = (prompt as any[]).find(m => m.role === 'user'); + return userMessage?.content?.find((part: any) => part.type === 'file' || part.type === 'image'); + } + + it('passes a gs:// reference through to the model when it declares support', async () => { + const prompts: unknown[] = []; + const mockModel = createCapturingModel(prompts, { '*': [/^https?:\/\/.*$/, /^gs:\/\/.*$/] }); + + const baseAgent = new Agent({ + id: 'gs-image-agent', + name: 'GS Image Agent', + instructions: 'Describe images.', + model: mockModel as LanguageModelV2, + }); + const durableAgent = createDurableAgent({ agent: baseAgent, pubsub }); + + const { output, cleanup } = await durableAgent.stream([ + { + role: 'user', + content: [ + { type: 'text', text: 'Which breed is this dog?' }, + { type: 'image', image: GS_URI, mimeType: 'image/png' }, + ], + }, + ]); + await output.consumeStream(); + + // The model was actually invoked (prompt building did not throw on gs://). + expect(prompts.length).toBeGreaterThan(0); + + const filePart = findFilePart(prompts[0]); + expect(filePart).toBeDefined(); + + // The gs:// reference is preserved verbatim and NOT base64-wrapped. + // `findFilePart` may return a file part (`.data`) or an image part (`.image`). + const rawValue = filePart.type === 'image' ? filePart.image : filePart.data; + const dataValue = rawValue instanceof URL ? rawValue.toString() : String(rawValue); + expect(dataValue).toBe(GS_URI); + expect(dataValue.startsWith('data:')).toBe(false); + + cleanup(); + }); + }); }); describe('DurableAgent image edge cases', () => { diff --git a/packages/core/src/agent/durable/__tests__/durable-agent-is-task-complete.test.ts b/packages/core/src/agent/durable/__tests__/durable-agent-is-task-complete.test.ts index fdfecf565e0e..a85a5ef12c01 100644 --- a/packages/core/src/agent/durable/__tests__/durable-agent-is-task-complete.test.ts +++ b/packages/core/src/agent/durable/__tests__/durable-agent-is-task-complete.test.ts @@ -15,6 +15,7 @@ import type { LanguageModelV2 } from '@ai-sdk/provider-v5'; import { MockLanguageModelV2, convertArrayToReadableStream } from '@internal/ai-sdk-v5/test'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { EventEmitterPubSub } from '../../../events/event-emitter'; +import { RequestContext } from '../../../request-context'; import { Agent } from '../../agent'; import { createDurableAgent } from '../create-durable-agent'; @@ -228,6 +229,41 @@ describe('DurableAgent isTaskComplete', () => { expect(textEndChunks.length).toBeGreaterThanOrEqual(2); }); + it('forwards requestContext entries as customContext to isTaskComplete scorers', async () => { + const model = createTextModel('done'); + const baseAgent = new Agent({ + id: 'task-complete-ctx-agent', + name: 'Task Complete Ctx Agent', + instructions: 'noop', + model: model as LanguageModelV2, + }); + const durableAgent = createDurableAgent({ agent: baseAgent, pubsub }); + + const scorer = passingScorer(); + const requestContext = new RequestContext(); + requestContext.set('userId', 'user-123'); + requestContext.set('tenantId', 'tenant-abc'); + + const { output, cleanup } = await durableAgent.stream('go', { + requestContext, + isTaskComplete: { + scorers: [scorer as any], + } as any, + maxSteps: 2, + }); + + await drain(output.fullStream as unknown as ReadableStream<any>); + await cleanup(); + + expect(scorer.run).toHaveBeenCalledTimes(1); + const runArg = (scorer.run as any).mock.calls[0][0]; + // runStreamCompletionScorers forwards `customContext` as `requestContext` + // on the scorer.run input, mirroring the non-durable path. + expect(runArg.requestContext).toBeDefined(); + expect(runArg.requestContext.userId).toBe('user-123'); + expect(runArg.requestContext.tenantId).toBe('tenant-abc'); + }); + it('suppresses feedback message when suppressFeedback is true', async () => { const model = createSequencedTextModel(['attempt one', 'attempt two']); const baseAgent = new Agent({ diff --git a/packages/core/src/agent/durable/__tests__/durable-agent-request-context.test.ts b/packages/core/src/agent/durable/__tests__/durable-agent-request-context.test.ts index c014cab4aa40..b473246361fd 100644 --- a/packages/core/src/agent/durable/__tests__/durable-agent-request-context.test.ts +++ b/packages/core/src/agent/durable/__tests__/durable-agent-request-context.test.ts @@ -256,7 +256,7 @@ describe('DurableAgent RequestContext reserved keys', () => { }); describe('RequestContext serialization', () => { - it('should not include requestContext in serialized workflow input', async () => { + it('snapshots JSON-safe requestContext entries for parity with the non-durable agent', async () => { const mockModel = createTextModel('Hello!'); const baseAgent = new Agent({ @@ -268,18 +268,26 @@ describe('DurableAgent RequestContext reserved keys', () => { const durableAgent = createDurableAgent({ agent: baseAgent, pubsub }); const requestContext = new RequestContext(); - requestContext.set('sensitiveData', 'should-not-serialize'); + requestContext.set('userId', 'user-123'); + // Non-JSON values are dropped from the snapshot. + requestContext.set('liveHandle', () => 'not-serializable'); const result = await durableAgent.prepare('Hello', { requestContext, }); - // Workflow input should be JSON-serializable - // RequestContext is not serialized (it's stored in registry or passed separately) - const serialized = JSON.stringify(result.workflowInput); - expect(serialized).toBeDefined(); - expect(serialized).not.toContain('sensitiveData'); - expect(serialized).not.toContain('should-not-serialize'); + // The serializable subset of requestContext is snapshotted on workflow + // input so durable scorers can see customContext, mirroring the + // non-durable agent which forwards Object.fromEntries(requestContext.entries()) + // to scorers. The full RequestContext (which can hold live handles) is + // not serialized — it stays on the run registry. + // + // The snapshot is taken *before* preparation mutates the request context + // (e.g. adding MASTRA_VERSIONS_KEY / MastraMemory), so persisted + // customContext must reflect only caller-provided entries. + const entries = (result.workflowInput as { requestContextEntries?: Record<string, unknown> }) + .requestContextEntries; + expect(entries).toEqual({ userId: 'user-123' }); }); }); }); diff --git a/packages/core/src/agent/durable/index.ts b/packages/core/src/agent/durable/index.ts index ca0b6b0392f3..8827ede72c82 100644 --- a/packages/core/src/agent/durable/index.ts +++ b/packages/core/src/agent/durable/index.ts @@ -88,13 +88,17 @@ export { EventedAgent, isEventedAgentClass, type EventedAgentConfig } from './ev export { createEventedAgent, isEventedAgent, type CreateEventedAgentOptions } from './create-evented-agent'; // Stream until idle (durable variant) -export { runDurableStreamUntilIdle, type DurableStreamUntilIdleDeps } from './durable-stream-until-idle'; +export { + runDurableStreamUntilIdle, + runResumeDurableStreamUntilIdle, + type DurableStreamUntilIdleDeps, +} from './durable-stream-until-idle'; // Preparation utilities export { prepareForDurableExecution, type PreparationOptions, type PreparationResult } from './preparation'; // Run registry for non-serializable state -export { RunRegistry, ExtendedRunRegistry, type ExtendedRunRegistryEntry } from './run-registry'; +export { RunRegistry, ExtendedRunRegistry, globalRunRegistry, type ExtendedRunRegistryEntry } from './run-registry'; // Stream adapter for pubsub-based streaming export { diff --git a/packages/core/src/agent/durable/preparation.ts b/packages/core/src/agent/durable/preparation.ts index 2f35bc38bdc6..cef175fb1eae 100644 --- a/packages/core/src/agent/durable/preparation.ts +++ b/packages/core/src/agent/durable/preparation.ts @@ -22,6 +22,31 @@ import type { AgentInstructions, AgentMethodType, AgentModelManagerConfig, Tools import type { DurableAgenticWorkflowInput, RunRegistryEntry, SerializableStructuredOutput } from './types'; import { createWorkflowInput } from './utils/serialize-state'; +/** + * JSON-safe snapshot of `requestContext.entries()` so durable steps (e.g. + * is-task-complete scorers) can see the same `customContext` the non-durable + * path passes. Best-effort: entries that fail a JSON round-trip are skipped + * so a single non-serializable value can't break the workflow input. + */ +function snapshotRequestContextEntries( + requestContext: RequestContext | undefined, +): Record<string, unknown> | undefined { + if (!requestContext) return undefined; + const out: Record<string, unknown> = {}; + let any = false; + for (const [key, value] of requestContext.entries()) { + try { + const cloned = JSON.parse(JSON.stringify(value)); + out[key as string] = cloned; + any = true; + } catch { + // Skip non-serializable entries silently — they wouldn't survive the + // wire on cross-process engines anyway. + } + } + return any ? out : undefined; +} + /** * Mirror of Agent#convertInstructionsToString — used for the AGENT_RUN span * `attributes.instructions` field so durable runs publish the same shape as @@ -155,6 +180,12 @@ export async function prepareForDurableExecution<OUTPUT = undefined>( // 2. Get request context const requestContext = providedRequestContext ?? new RequestContext(); + // 2a. Snapshot caller-provided RequestContext entries *before* preparation + // mutates the context (version overrides at step 3, MastraMemory at step 4). + // The persisted `customContext` should reflect only what the caller passed in, + // not internal-key state added during prep. + const requestContextEntriesSnapshot = snapshotRequestContextEntries(requestContext); + // 2b. Merge the wrapped agent's defaultOptions under the per-request options, // mirroring the non-durable Agent.stream()/generate() paths. Without this the // agent's configured defaults (maxSteps, providerOptions, etc.) are silently @@ -531,6 +562,7 @@ export async function prepareForDurableExecution<OUTPUT = undefined>( messageId, agentSpanData: agentSpan?.exportSpan(), modelSpanData: modelSpan?.exportSpan(), + requestContextEntries: requestContextEntriesSnapshot, }); // 14. Create registry entry for non-serializable state diff --git a/packages/core/src/agent/durable/types.ts b/packages/core/src/agent/durable/types.ts index 627d21f06872..38c160bbef54 100644 --- a/packages/core/src/agent/durable/types.ts +++ b/packages/core/src/agent/durable/types.ts @@ -135,7 +135,7 @@ export interface SerializableStructuredOutput { /** JSON Schema representation of the output schema */ schema?: JSONSchema7; /** Whether to use JSON prompt injection instead of native response format */ - jsonPromptInjection?: boolean; + jsonPromptInjection?: boolean | 'system' | 'inline'; /** Whether to use the parent agent's model for structuring */ useAgent?: boolean; /** Model config for a dedicated structuring model (if different from the main model) */ @@ -269,6 +269,13 @@ export interface DurableAgenticWorkflowInput { modelSpanData?: unknown; /** Starting step index for continuation across iterations */ stepIndex?: number; + /** + * JSON-safe snapshot of `requestContext.entries()` from the call site. + * Threaded through workflow input so durable steps (e.g. `is-task-complete` + * scorers) can pass it as `customContext`, matching the non-durable path. + * Only plain JSON-safe entries should appear here. + */ + requestContextEntries?: Record<string, unknown>; } /** diff --git a/packages/core/src/agent/durable/utils/serialize-state.ts b/packages/core/src/agent/durable/utils/serialize-state.ts index 2947a7081106..0ef04abf2c22 100644 --- a/packages/core/src/agent/durable/utils/serialize-state.ts +++ b/packages/core/src/agent/durable/utils/serialize-state.ts @@ -315,6 +315,7 @@ export function createWorkflowInput(params: { messageId: string; agentSpanData?: unknown; modelSpanData?: unknown; + requestContextEntries?: Record<string, unknown>; }): DurableAgenticWorkflowInput { return { __workflowKind: 'durable-agent', @@ -331,6 +332,7 @@ export function createWorkflowInput(params: { messageId: params.messageId, agentSpanData: params.agentSpanData, modelSpanData: params.modelSpanData, + requestContextEntries: params.requestContextEntries, }; } diff --git a/packages/core/src/agent/durable/workflows/create-durable-agentic-workflow.message-id-rotation.test.ts b/packages/core/src/agent/durable/workflows/create-durable-agentic-workflow.message-id-rotation.test.ts new file mode 100644 index 000000000000..b9d8204220ee --- /dev/null +++ b/packages/core/src/agent/durable/workflows/create-durable-agentic-workflow.message-id-rotation.test.ts @@ -0,0 +1,149 @@ +/** + * DurableAgent messageId rotation between iterations + * + * The non-durable agentic loop rotates the per-iteration messageId so each + * iteration's assistant message lands under a distinct id. The durable + * `dowhile` predicate must do the same; otherwise downstream consumers + * (memory persistence, downstream replay, audit logs, signal drains) cannot + * tell which assistant content was produced in which iteration. + * + * Rotation happens inside the `dowhile` predicate by calling + * `mastra.generateId()` (with a `randomUUID()` fallback). The cleanest + * verifiable surface is to install a deterministic `idGenerator` on the + * Mastra instance and assert it is invoked at the iteration boundary, and + * that the rotated id flows into the workflow state for the next iteration. + */ + +import type { LanguageModelV2 } from '@ai-sdk/provider-v5'; +import { MockLanguageModelV2, convertArrayToReadableStream } from '@internal/ai-sdk-v5/test'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { EventEmitterPubSub } from '../../../events/event-emitter'; +import { Mastra } from '../../../mastra'; +import { MockMemory } from '../../../memory/mock'; +import { createTool } from '../../../tools'; +import { Agent } from '../../agent'; +import { createDurableAgent } from '../create-durable-agent'; + +function createToolThenTextModel(toolName: string, toolArgs: object, finalText: string) { + let callCount = 0; + return new MockLanguageModelV2({ + doStream: async () => { + callCount += 1; + const stream: ReadableStream<any> = + callCount === 1 + ? convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: `id-${callCount}`, modelId: 'mock-model-id', timestamp: new Date(0) }, + { + type: 'tool-call', + toolCallType: 'function', + toolCallId: `call-${callCount}`, + toolName, + input: JSON.stringify(toolArgs), + providerExecuted: false, + }, + { + type: 'finish', + finishReason: 'tool-calls', + usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, + }, + ]) + : convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: `id-${callCount}`, modelId: 'mock-model-id', timestamp: new Date(0) }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: finalText }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: 'stop', + usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, + }, + ]); + return { stream, rawCall: { rawPrompt: null, rawSettings: {} }, warnings: [] }; + }, + }); +} + +describe('DurableAgent messageId rotation between iterations', () => { + let pubsub: EventEmitterPubSub; + + beforeEach(() => { + pubsub = new EventEmitterPubSub(); + }); + + afterEach(async () => { + await pubsub.close(); + }); + + it('invokes mastra.generateId() in the dowhile predicate when continuing to the next iteration', async () => { + const model = createToolThenTextModel('weatherTool', { location: 'Toronto' }, 'It is sunny.'); + + const weatherTool = createTool({ + id: 'weatherTool', + description: 'Get weather for a location', + inputSchema: z.object({ location: z.string() }), + execute: async () => ({ temperature: 20, conditions: 'sunny' }), + }); + + let counter = 0; + const generated: string[] = []; + const idGenerator = vi.fn(() => { + counter += 1; + const id = `rotated-msg-${counter}`; + generated.push(id); + return id; + }); + + const memory = new MockMemory(); + const baseAgent = new Agent({ + id: 'msgid-rotation-agent', + name: 'MsgId Rotation Agent', + instructions: 'Get weather information.', + model: model as LanguageModelV2, + tools: { weatherTool }, + memory, + }); + const durableAgent = createDurableAgent({ agent: baseAgent, pubsub }); + // Attach the deterministic generator to the Mastra instance the durable + // workflow reads from when rotating. The Mastra constructor wires + // `idGenerator` onto every registered agent, so `mastra.generateId()` + // inside the dowhile predicate returns the rotated IDs below. + new Mastra({ + agents: { 'msgid-rotation-agent': durableAgent }, + idGenerator, + logger: false, + }); + + const result = await durableAgent.stream('Weather in Toronto?', { + memory: { thread: 'thread-rotation', resource: 'resource-rotation' }, + }); + for await (const _chunk of result.fullStream) { + // Drain + } + await result.output.getFullOutput(); + result.cleanup(); + + // `mastra.generateId()` is the shared id factory; the dowhile predicate + // is one of several callers. Assert the boundary effect rather than a + // call count: the predicate must call it at least once between + // iterations, and the rotated id must reach iteration 2's assistant + // message in persisted memory. + expect(generated.length).toBeGreaterThan(0); + + const persisted = await memory.recall({ + threadId: 'thread-rotation', + resourceId: 'resource-rotation', + }); + const assistantIds = persisted.messages.filter(m => m.role === 'assistant').map(m => m.id); + // Two iterations should produce two distinct assistant messages once the + // predicate marks the response boundary alongside rotating messageId. + expect(assistantIds.length).toBeGreaterThanOrEqual(2); + expect(new Set(assistantIds).size).toBeGreaterThanOrEqual(2); + // At least one assistant message id must have been minted by the + // instrumented generator — the rotation boundary. + const matched = assistantIds.filter(id => generated.includes(id)); + expect(matched.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/core/src/agent/durable/workflows/create-durable-agentic-workflow.ts b/packages/core/src/agent/durable/workflows/create-durable-agentic-workflow.ts index 799ba566d1a8..49b868757cf2 100644 --- a/packages/core/src/agent/durable/workflows/create-durable-agentic-workflow.ts +++ b/packages/core/src/agent/durable/workflows/create-durable-agentic-workflow.ts @@ -63,6 +63,9 @@ const durableAgenticInputSchema = z.object({ // Exported AGENT_RUN / MODEL_GENERATION span data, threaded so the run shares one trace agentSpanData: z.any().optional(), modelSpanData: z.any().optional(), + // JSON-safe snapshot of requestContext.entries() so durable steps can read + // it (e.g. is-task-complete scorers pass it as customContext). + requestContextEntries: z.record(z.string(), z.any()).optional(), }); // Re-export shared output schema (identical across implementations) @@ -275,7 +278,7 @@ export function createDurableAgenticWorkflow(options?: DurableAgenticWorkflowOpt ) // Run the agentic loop with dowhile .dowhile(singleIterationWorkflow, async params => { - const { inputData } = params; + const { inputData, mastra } = params; const state = inputData as IterationState; const initData = params.getInitData() as DurableAgenticWorkflowInput; const pubsub = (params as any)[PUBSUB_SYMBOL] as PubSub | undefined; @@ -309,6 +312,33 @@ export function createDurableAgenticWorkflow(options?: DurableAgenticWorkflowOpt const isFinal = !shouldContinue || !underMaxSteps || stopWhenMatched; + // Rotate messageId for the next iteration. Each iteration's assistant + // response is a distinct message, mirroring the non-durable agentic + // loop which calls rotateResponseMessageId() between iterations. The + // mutated state.messageId flows into the next singleIterationWorkflow + // input via map-to-llm-input. + // + // We also mark the current MessageList's last assistant message as a + // response boundary so MessageMerger won't collapse the next + // iteration's assistant content into it. Without this, persisted + // memory keeps a single assistant message and the rotated id is never + // observable to consumers. + if (!isFinal) { + const nextMessageId = + (mastra as Mastra | undefined)?.generateId?.() ?? globalThis.crypto?.randomUUID?.() ?? `msg_${Date.now()}`; + state.messageId = nextMessageId; + + try { + const boundaryList = new MessageList(); + boundaryList.deserialize(state.messageListState); + boundaryList.markResponseMessageBoundary(); + state.messageListState = boundaryList.serialize(); + } catch { + // Boundary marking is best-effort; if deserialization fails the + // next iteration will still run with the un-marked state. + } + } + // Emit an iteration-complete event for observability. This fires after // every iteration (including the last one) so client callbacks can // track progress. continue/feedback return values are not honored — diff --git a/packages/core/src/agent/durable/workflows/steps/is-task-complete.ts b/packages/core/src/agent/durable/workflows/steps/is-task-complete.ts index 6dd4a0c47044..b3b28ecb8e20 100644 --- a/packages/core/src/agent/durable/workflows/steps/is-task-complete.ts +++ b/packages/core/src/agent/durable/workflows/steps/is-task-complete.ts @@ -62,6 +62,7 @@ export function createDurableIsTaskCompleteStep(defaultMaxSteps: number = Durabl agentId?: string; agentName?: string; state?: { threadId?: string; resourceId?: string }; + requestContextEntries?: Record<string, unknown>; }; const registryEntry = globalRunRegistry.get(state.runId); @@ -141,7 +142,7 @@ export function createDurableIsTaskCompleteStep(defaultMaxSteps: number = Durabl runId: state.runId, threadId: initData.state?.threadId, resourceId: initData.state?.resourceId, - customContext: undefined, + customContext: initData.requestContextEntries, }; let result: IsTaskCompleteRunResult | undefined; diff --git a/packages/core/src/agent/durable/workflows/steps/llm-execution.ts b/packages/core/src/agent/durable/workflows/steps/llm-execution.ts index cb58ce1fed1f..f1103bb332a0 100644 --- a/packages/core/src/agent/durable/workflows/steps/llm-execution.ts +++ b/packages/core/src/agent/durable/workflows/steps/llm-execution.ts @@ -301,12 +301,24 @@ export function createDurableLLMExecutionStep(_options?: DurableLLMExecutionStep }; } - // Get messages for LLM (using async llmPrompt for proper format conversion) + // Forward the model's `supportedUrls` (may be a Promise) so URLs a provider + // fetches natively (e.g. Vertex `gs://`) pass through instead of being + // downloaded/inlined. Mirrors loop/workflows/agentic-execution/llm-execution-step.ts. + let resolvedSupportedUrls: Record<string, RegExp[]> | undefined; + const modelSupportedUrls = currentModel?.supportedUrls; + if (modelSupportedUrls) { + resolvedSupportedUrls = + typeof (modelSupportedUrls as PromiseLike<unknown>).then === 'function' + ? await (modelSupportedUrls as PromiseLike<Record<string, RegExp[]>>) + : (modelSupportedUrls as Record<string, RegExp[]>); + } const llmPromptForModel = currentModel.specificationVersion === 'v3' || currentModel.specificationVersion === 'v4' ? messageList.get.all.aiV6.llmPrompt : messageList.get.all.aiV5.llmPrompt; - const inputMessages = (await llmPromptForModel()) as LanguageModelV2Prompt; + const inputMessages = (await llmPromptForModel({ + supportedUrls: resolvedSupportedUrls, + })) as LanguageModelV2Prompt; // Enable defer mode - step-finish won't auto-close the step span // This allows us to export the step span and close it later after tool execution diff --git a/packages/core/src/agent/durable/workflows/steps/tool-call-bg.test.ts b/packages/core/src/agent/durable/workflows/steps/tool-call-bg.test.ts index 26669dc7d90b..0df7bdd4de40 100644 --- a/packages/core/src/agent/durable/workflows/steps/tool-call-bg.test.ts +++ b/packages/core/src/agent/durable/workflows/steps/tool-call-bg.test.ts @@ -61,6 +61,7 @@ function makeInitData(overrides: Record<string, any> = {}) { function makeMessageList() { return { updateToolInvocation: vi.fn().mockReturnValue(true), + updateMessageMetadataByToolCallId: vi.fn().mockReturnValue(true), add: vi.fn(), }; } @@ -278,7 +279,7 @@ describe('durable tool-call background task dispatch', () => { expect(saveQueueManager.flushMessages).toHaveBeenCalledWith(messageList, 'thread-1', undefined); }); - it('onExecution hook updates tool invocation metadata with startedAt/taskId', async () => { + it('onExecution hook updates message metadata with startedAt/taskId', async () => { const pubsub = mockPubsub(); const { messageList } = setupRegistry(); const initData = makeInitData(); @@ -312,14 +313,8 @@ describe('durable tool-call background task dispatch', () => { startedAt, }); - expect(messageList.updateToolInvocation).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'tool-invocation', - toolInvocation: expect.objectContaining({ - state: 'call', - toolCallId: TOOL_CALL_ID, - }), - }), + expect(messageList.updateMessageMetadataByToolCallId).toHaveBeenCalledWith( + TOOL_CALL_ID, expect.objectContaining({ backgroundTasks: expect.objectContaining({ [TOOL_CALL_ID]: expect.objectContaining({ @@ -329,6 +324,7 @@ describe('durable tool-call background task dispatch', () => { }), }), ); + expect(messageList.updateToolInvocation).not.toHaveBeenCalled(); }); it('onChunk emits tool-call + tool-result chunks via PubSub on completion', async () => { diff --git a/packages/core/src/agent/durable/workflows/steps/tool-call-provider-fallback.test.ts b/packages/core/src/agent/durable/workflows/steps/tool-call-provider-fallback.test.ts new file mode 100644 index 000000000000..481e808957d8 --- /dev/null +++ b/packages/core/src/agent/durable/workflows/steps/tool-call-provider-fallback.test.ts @@ -0,0 +1,197 @@ +/** + * Durable tool-call: provider-tool fallback resolution. + * + * The non-durable tool-call step resolves a tool by: + * 1. exact name lookup on stepTools + * 2. `findProviderToolByName(stepTools, name)` — for provider-defined tools + * where the LLM-emitted name differs from the JS key (e.g. the JS key is + * `webSearch` but the model calls it `web_search`) + * 3. fallback to mastra-wide registry + * + * The durable tool-call step skipped step (2), causing model-named provider + * tools to surface as ToolNotFoundError. This test guards the fix. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PUBSUB_SYMBOL } from '../../../../workflows/constants'; +import { globalRunRegistry } from '../../run-registry'; +import * as resolveRuntime from '../../utils/resolve-runtime'; +import { createDurableToolCallStep } from './tool-call'; + +vi.mock('../../utils/resolve-runtime', () => ({ + resolveTool: vi.fn(), + toolRequiresApproval: vi.fn().mockResolvedValue(false), +})); + +vi.mock('../../stream-adapter', () => ({ + emitChunkEvent: vi.fn().mockResolvedValue(undefined), + emitSuspendedEvent: vi.fn().mockResolvedValue(undefined), +})); + +const RUN_ID = 'run-provider-tool-1'; + +function mockPubsub() { + return { publish: vi.fn(), subscribe: vi.fn(), unsubscribe: vi.fn(), flush: vi.fn() }; +} + +function makeInitData() { + return { + runId: RUN_ID, + agentId: 'agent-1', + options: { requireToolApproval: false }, + state: { + threadId: 'thread-1', + resourceId: 'user-1', + memoryConfig: undefined, + threadExists: false, + }, + }; +} + +afterEach(() => { + if (globalRunRegistry.has(RUN_ID)) globalRunRegistry.delete(RUN_ID); + vi.clearAllMocks(); +}); + +describe('durable tool-call provider-tool fallback', () => { + it('resolves a provider-defined tool by its model-facing name', async () => { + const executeMock = vi.fn().mockResolvedValue({ snippet: 'result' }); + // Provider-defined tool: JS key `webSearch`, model-facing id `openai.web_search` + // The LLM emits `web_search`, which doesn't match the JS key. + globalRunRegistry.set(RUN_ID, { + tools: { + webSearch: { + type: 'provider-defined', + id: 'openai.web_search', + execute: executeMock, + }, + }, + model: {} as any, + } as any); + + const step = createDurableToolCallStep(); + const result = await (step as any).execute({ + inputData: { + toolCallId: 'call-1', + toolName: 'web_search', + args: { query: 'mastra' }, + }, + mastra: { getLogger: () => undefined }, + suspend: vi.fn(), + resumeData: undefined, + requestContext: new Map(), + getInitData: () => makeInitData(), + [PUBSUB_SYMBOL]: mockPubsub(), + }); + + expect(executeMock).toHaveBeenCalledTimes(1); + expect(result.error).toBeUndefined(); + expect(result.result).toEqual({ snippet: 'result' }); + }); + + it('falls back to resolveTool() against the Mastra-wide registry when not in run registry', async () => { + const executeMock = vi.fn().mockResolvedValue({ ok: true }); + const mastraTool = { + id: 'mastraTool', + description: 'a mastra-wide tool', + execute: executeMock, + }; + vi.mocked(resolveRuntime.resolveTool).mockReturnValueOnce(mastraTool as any); + + // Run registry has no matching tool — resolveTool() should be consulted. + globalRunRegistry.set(RUN_ID, { + tools: {}, + model: {} as any, + } as any); + + const step = createDurableToolCallStep(); + const result = await (step as any).execute({ + inputData: { + toolCallId: 'call-mastra', + toolName: 'mastraTool', + args: { foo: 'bar' }, + }, + mastra: { getLogger: () => undefined, listTools: () => ({}) }, + suspend: vi.fn(), + resumeData: undefined, + requestContext: new Map(), + getInitData: () => makeInitData(), + [PUBSUB_SYMBOL]: mockPubsub(), + }); + + expect(resolveRuntime.resolveTool).toHaveBeenCalledWith('mastraTool', expect.anything()); + expect(executeMock).toHaveBeenCalledTimes(1); + expect(result.error).toBeUndefined(); + expect(result.result).toEqual({ ok: true }); + }); + + it('falls back to a Mastra-wide provider tool when run registry and resolveTool miss', async () => { + const executeMock = vi.fn().mockResolvedValue({ snippet: 'web-result' }); + const mastraTools = { + webSearch: { + type: 'provider-defined', + id: 'openai.web_search', + execute: executeMock, + }, + }; + vi.mocked(resolveRuntime.resolveTool).mockReturnValueOnce(undefined as any); + + globalRunRegistry.set(RUN_ID, { + tools: {}, + model: {} as any, + } as any); + + const step = createDurableToolCallStep(); + const result = await (step as any).execute({ + inputData: { + toolCallId: 'call-provider-mastra', + toolName: 'web_search', + args: { query: 'mastra' }, + }, + mastra: { getLogger: () => undefined, listTools: () => mastraTools }, + suspend: vi.fn(), + resumeData: undefined, + requestContext: new Map(), + getInitData: () => makeInitData(), + [PUBSUB_SYMBOL]: mockPubsub(), + }); + + expect(executeMock).toHaveBeenCalledTimes(1); + expect(result.error).toBeUndefined(); + expect(result.result).toEqual({ snippet: 'web-result' }); + }); + + it('still emits ToolNotFoundError when no provider tool matches', async () => { + globalRunRegistry.set(RUN_ID, { + tools: { + webSearch: { + type: 'provider-defined', + id: 'openai.web_search', + execute: vi.fn(), + }, + }, + model: {} as any, + } as any); + + const step = createDurableToolCallStep(); + const result = await (step as any).execute({ + inputData: { + toolCallId: 'call-1', + toolName: 'definitely_not_a_tool', + args: {}, + }, + mastra: { getLogger: () => undefined }, + suspend: vi.fn(), + resumeData: undefined, + requestContext: new Map(), + getInitData: () => makeInitData(), + [PUBSUB_SYMBOL]: mockPubsub(), + }); + + expect(result.error).toEqual( + expect.objectContaining({ + name: 'ToolNotFoundError', + }), + ); + }); +}); diff --git a/packages/core/src/agent/durable/workflows/steps/tool-call.ts b/packages/core/src/agent/durable/workflows/steps/tool-call.ts index 157c0be74461..237ad1eff031 100644 --- a/packages/core/src/agent/durable/workflows/steps/tool-call.ts +++ b/packages/core/src/agent/durable/workflows/steps/tool-call.ts @@ -8,6 +8,7 @@ import type { MastraMemory } from '../../../../memory/memory'; import type { MemoryConfig } from '../../../../memory/types'; import type { ExportedSpan, SpanType } from '../../../../observability'; import { ChunkFrom } from '../../../../stream/types'; +import { findProviderToolByName } from '../../../../tools/provider-tool-utils'; import { PUBSUB_SYMBOL } from '../../../../workflows/constants'; import type { SuspendOptions } from '../../../../workflows/step'; import { createStep } from '../../../../workflows/workflow'; @@ -189,17 +190,52 @@ export function createDurableToolCallStep() { }; } - // 1. Resolve the tool from global registry first, then Mastra + // 1. Resolve the tool from global registry first, then by provider-tool + // model-facing name (e.g. `web_search` resolves to `webSearch` when the + // provider tool advertises the snake-case name), then by id, then fall + // back to the Mastra-wide tool registry (exact name, provider-tool + // name, then by id). Mirrors the non-durable tool-call step. const registryEntry = globalRunRegistry.get(runId); let tool = registryEntry?.tools?.[toolName]; + let mastraTools: Record<string, any> | undefined; + + if (!tool) { + tool = findProviderToolByName(registryEntry?.tools as any, toolName) as typeof tool; + } + + if (!tool) { + tool = Object.values(registryEntry?.tools ?? {}).find( + (t: any) => t && typeof t === 'object' && 'id' in t && t.id === toolName, + ) as typeof tool; + } if (!tool) { tool = resolveTool(toolName, mastra as Mastra); } + if (!tool && mastra) { + mastraTools = (mastra as Mastra).listTools?.() as Record<string, any> | undefined; + if (mastraTools) { + tool = findProviderToolByName(mastraTools as any, toolName) as typeof tool; + if (!tool) { + tool = Object.values(mastraTools).find( + (t: any) => t && typeof t === 'object' && 'id' in t && t.id === toolName, + ) as typeof tool; + } + } + } + + // Resolve the key the tool is registered under for activeTools filtering. + // Prefer the per-run registryEntry key (exact name then identity match), + // and fall back to the Mastra-wide registry when the tool was resolved + // there. Without this fallback, a globally-registered tool like + // `webSearch` invoked by its model-facing name `web_search` would be + // hidden whenever `activeTools` was set, because the key from + // registryEntry.tools would be `undefined`. const toolKey = registryEntry?.tools?.[toolName] ? toolName - : Object.entries(registryEntry?.tools ?? {}).find(([, registeredTool]) => registeredTool === tool)?.[0]; + : (Object.entries(registryEntry?.tools ?? {}).find(([, registeredTool]) => registeredTool === tool)?.[0] ?? + Object.entries(mastraTools ?? {}).find(([, registeredTool]) => registeredTool === tool)?.[0]); const effectiveActiveTools = activeTools === null ? undefined : (activeTools ?? agentOptions.activeTools); const activeToolKey = toolKey ?? toolName; const isHiddenByActiveTools = effectiveActiveTools !== undefined && !effectiveActiveTools.includes(activeToolKey); @@ -629,26 +665,15 @@ export function createDurableToolCallStep() { onExecution: async (params: any) => { if (!messageList) return; - messageList.updateToolInvocation( - { - type: 'tool-invocation', - toolInvocation: { - state: 'call', - toolCallId: params.toolCallId, - toolName: params.toolName, - args: cleanedArgs, - }, - }, - { - backgroundTasks: { - [params.toolCallId]: { - startedAt: params.startedAt, - suspendedAt: params.suspendedAt, - taskId: params.taskId, - }, + messageList.updateMessageMetadataByToolCallId(params.toolCallId, { + backgroundTasks: { + [params.toolCallId]: { + startedAt: params.startedAt, + suspendedAt: params.suspendedAt, + taskId: params.taskId, }, }, - ); + }); }, onComplete: toolBgConfig?.onComplete ?? bgConfig?.onTaskComplete, diff --git a/packages/core/src/agent/fs-routing/index.test.ts b/packages/core/src/agent/fs-routing/index.test.ts new file mode 100644 index 000000000000..3ed12b7745cd --- /dev/null +++ b/packages/core/src/agent/fs-routing/index.test.ts @@ -0,0 +1,561 @@ +import { describe, it, expect, vi } from 'vitest'; +import { MockMemory } from '../../memory/mock'; +import { RequestContext } from '../../request-context'; +import { createSkill } from '../../skills'; +import type { InlineSkill } from '../../skills/types'; +import { createTool } from '../../tools'; +import { Workspace, LocalFilesystem } from '../../workspace'; +import { Agent } from '../agent'; +import { assembleAgentFromFsEntry, agentConfig } from './index'; +import type { FsAgentToolEntry } from './index'; + +function makeTool(id: string): FsAgentToolEntry { + return { + key: id, + tool: createTool({ + id, + description: `tool ${id}`, + execute: async () => ({ ok: true }), + }), + }; +} + +function makeSkill(name: string): InlineSkill { + return createSkill({ + name, + description: `Use the ${name} skill when relevant.`, + instructions: `# ${name}\nDo the ${name} thing.`, + }); +} + +describe('agentConfig', () => { + it('returns the config unchanged (identity)', () => { + const config = { model: 'openai/gpt-4o' as const }; + expect(agentConfig(config)).toBe(config); + }); +}); + +describe('assembleAgentFromFsEntry', () => { + it('defaults id/name to the directory name when omitted', async () => { + const agent = assembleAgentFromFsEntry({ + name: 'weather', + config: { model: 'openai/gpt-4o' }, + instructionsMd: 'You are the weather agent.', + }); + + expect(agent.id).toBe('weather'); + expect(agent.name).toBe('weather'); + expect(await agent.getInstructions()).toBe('You are the weather agent.'); + }); + + it('respects explicit id/name in config over the directory name', () => { + const agent = assembleAgentFromFsEntry({ + name: 'weather', + config: { model: 'openai/gpt-4o', id: 'wx', name: 'Weather Pro' }, + instructionsMd: 'hi', + }); + + expect(agent.id).toBe('wx'); + expect(agent.name).toBe('Weather Pro'); + }); + + it('uses instructions.md when config has no instructions', async () => { + const agent = assembleAgentFromFsEntry({ + name: 'a', + config: { model: 'openai/gpt-4o' }, + instructionsMd: 'from md', + }); + expect(await agent.getInstructions()).toBe('from md'); + }); + + it('lets instructions.md win over a static config.instructions', async () => { + const agent = assembleAgentFromFsEntry({ + name: 'a', + config: { model: 'openai/gpt-4o', instructions: 'from config' }, + instructionsMd: 'from md', + }); + expect(await agent.getInstructions()).toBe('from md'); + }); + + it('lets a dynamic config.instructions win over instructions.md', async () => { + const agent = assembleAgentFromFsEntry({ + name: 'a', + config: { model: 'openai/gpt-4o', instructions: () => 'dynamic' }, + instructionsMd: 'from md', + }); + expect(await agent.getInstructions()).toBe('dynamic'); + }); + + it('falls back to static config.instructions when no md present', async () => { + const agent = assembleAgentFromFsEntry({ + name: 'a', + config: { model: 'openai/gpt-4o', instructions: 'only config' }, + }); + expect(await agent.getInstructions()).toBe('only config'); + }); + + it('throws when neither instructions.md nor config.instructions present', () => { + expect(() => + assembleAgentFromFsEntry({ + name: 'broken', + config: { model: 'openai/gpt-4o' }, + }), + ).toThrow(/missing instructions/i); + }); + + it('throws when model is missing', () => { + expect(() => + assembleAgentFromFsEntry({ + name: 'broken', + config: {}, + instructionsMd: 'hi', + }), + ).toThrow(/missing model/i); + }); + + it('merges discovered tools into the agent', async () => { + const agent = assembleAgentFromFsEntry({ + name: 'a', + config: { model: 'openai/gpt-4o' }, + instructionsMd: 'hi', + tools: [makeTool('get_weather'), makeTool('get_forecast')], + }); + + const tools = await agent.listTools(); + expect(Object.keys(tools).sort()).toEqual(['get_forecast', 'get_weather']); + }); + + it('lets config.tools win on key collision and warns', async () => { + const onWarn = vi.fn(); + const configTool = createTool({ + id: 'get_weather', + description: 'config version', + execute: async () => ({ ok: true }), + }); + + const agent = assembleAgentFromFsEntry( + { + name: 'a', + config: { model: 'openai/gpt-4o', tools: { get_weather: configTool } }, + instructionsMd: 'hi', + tools: [makeTool('get_weather'), makeTool('get_forecast')], + }, + { onWarn }, + ); + + const tools = await agent.listTools(); + expect(tools.get_weather).toBe(configTool); + expect(Object.keys(tools).sort()).toEqual(['get_forecast', 'get_weather']); + expect(onWarn).toHaveBeenCalledWith(expect.stringContaining('get_weather')); + }); + + it('warns and ignores discovered tools when config.tools is a function', async () => { + const onWarn = vi.fn(); + const dynamicTools = () => ({}); + + assembleAgentFromFsEntry( + { + name: 'a', + config: { model: 'openai/gpt-4o', tools: dynamicTools }, + instructionsMd: 'hi', + tools: [makeTool('get_weather')], + }, + { onWarn }, + ); + + expect(onWarn).toHaveBeenCalledWith(expect.stringContaining('function')); + }); + + it('uses a code-defined Agent (new Agent()) verbatim instead of re-wrapping it', () => { + const coded = new Agent({ + id: 'weather', + name: 'weather', + instructions: 'Code-defined.', + model: 'openai/gpt-4o', + }); + + const result = assembleAgentFromFsEntry({ name: 'weather', config: coded }); + + expect(result).toBe(coded); + }); + + it('warns when a code-defined Agent coexists with instructions.md / tools', () => { + const onWarn = vi.fn(); + const coded = new Agent({ + id: 'weather', + name: 'weather', + instructions: 'Code-defined.', + model: 'openai/gpt-4o', + }); + + assembleAgentFromFsEntry( + { name: 'weather', config: coded, instructionsMd: 'ignored', tools: [makeTool('get_weather')] }, + { onWarn }, + ); + + expect(onWarn).toHaveBeenCalledWith(expect.stringContaining('instructions.md')); + expect(onWarn).toHaveBeenCalledWith(expect.stringContaining('tools')); + }); + + it('merges discovered skills into the agent', async () => { + const agent = assembleAgentFromFsEntry({ + name: 'a', + config: { model: 'openai/gpt-4o' }, + instructionsMd: 'hi', + skills: [makeSkill('review'), makeSkill('testing')], + }); + + const skills = await agent.listSkills(); + expect(skills.map(s => s.name).sort()).toEqual(['review', 'testing']); + }); + + it('lets config.skills win on name collision and warns', async () => { + const onWarn = vi.fn(); + const configSkill = createSkill({ + name: 'review', + description: 'Config version of the review skill.', + instructions: '# review\nconfig version', + }); + + const agent = assembleAgentFromFsEntry( + { + name: 'a', + config: { model: 'openai/gpt-4o', skills: [configSkill] }, + instructionsMd: 'hi', + skills: [makeSkill('review'), makeSkill('testing')], + }, + { onWarn }, + ); + + const skills = await agent.listSkills(); + expect(skills.map(s => s.name).sort()).toEqual(['review', 'testing']); + const review = skills.find(s => s.name === 'review'); + expect(review?.description).toBe('Config version of the review skill.'); + expect(onWarn).toHaveBeenCalledWith(expect.stringContaining('review')); + }); + + it('warns and ignores discovered skills when config.skills is a function', async () => { + const onWarn = vi.fn(); + const dynamicSkills = () => []; + + assembleAgentFromFsEntry( + { + name: 'a', + config: { model: 'openai/gpt-4o', skills: dynamicSkills }, + instructionsMd: 'hi', + skills: [makeSkill('review')], + }, + { onWarn }, + ); + + expect(onWarn).toHaveBeenCalledWith(expect.stringContaining('function')); + }); + + it('warns when a code-defined Agent coexists with discovered skills', () => { + const onWarn = vi.fn(); + const coded = new Agent({ + id: 'weather', + name: 'weather', + instructions: 'Code-defined.', + model: 'openai/gpt-4o', + }); + + assembleAgentFromFsEntry({ name: 'weather', config: coded, skills: [makeSkill('review')] }, { onWarn }); + + expect(onWarn).toHaveBeenCalledWith(expect.stringContaining('skills')); + }); + + describe('workspace', () => { + it('attaches a default workspace when defaultWorkspaceBasePath is provided', async () => { + const agent = assembleAgentFromFsEntry({ + name: 'weather', + config: { model: 'openai/gpt-4o' }, + instructionsMd: 'hi', + defaultWorkspaceBasePath: '/tmp/mastra-fs/weather', + }); + + const workspace = await agent.getWorkspace({ requestContext: new RequestContext() }); + expect(workspace).toBeDefined(); + expect(workspace?.name).toBe('weather-workspace'); + }); + + it('does not attach a workspace when no basePath and no config workspace', async () => { + const agent = assembleAgentFromFsEntry({ + name: 'weather', + config: { model: 'openai/gpt-4o' }, + instructionsMd: 'hi', + }); + + const workspace = await agent.getWorkspace({ requestContext: new RequestContext() }); + expect(workspace).toBeUndefined(); + }); + + it('uses workspace.ts over the default workspace', async () => { + const custom = new Workspace({ + name: 'custom-ws', + filesystem: new LocalFilesystem({ basePath: '/tmp/mastra-fs/custom' }), + }); + + const agent = assembleAgentFromFsEntry({ + name: 'weather', + config: { model: 'openai/gpt-4o' }, + instructionsMd: 'hi', + workspace: custom, + defaultWorkspaceBasePath: '/tmp/mastra-fs/weather', + }); + + const workspace = await agent.getWorkspace({ requestContext: new RequestContext() }); + expect(workspace).toBe(custom); + }); + + it('config.workspace wins over workspace.ts and warns', async () => { + const onWarn = vi.fn(); + const fromConfig = new Workspace({ + name: 'config-ws', + filesystem: new LocalFilesystem({ basePath: '/tmp/mastra-fs/config' }), + }); + const fromFile = new Workspace({ + name: 'file-ws', + filesystem: new LocalFilesystem({ basePath: '/tmp/mastra-fs/file' }), + }); + + const agent = assembleAgentFromFsEntry( + { + name: 'weather', + config: { model: 'openai/gpt-4o', workspace: fromConfig }, + instructionsMd: 'hi', + workspace: fromFile, + defaultWorkspaceBasePath: '/tmp/mastra-fs/weather', + }, + { onWarn }, + ); + + const workspace = await agent.getWorkspace({ requestContext: new RequestContext() }); + expect(workspace).toBe(fromConfig); + expect(onWarn).toHaveBeenCalledWith(expect.stringContaining('config.workspace wins')); + }); + + it('warns when a code-defined Agent coexists with a discovered workspace.ts', () => { + const onWarn = vi.fn(); + const coded = new Agent({ + id: 'weather', + name: 'weather', + instructions: 'Code-defined.', + model: 'openai/gpt-4o', + }); + const fromFile = new Workspace({ + name: 'file-ws', + filesystem: new LocalFilesystem({ basePath: '/tmp/mastra-fs/file' }), + }); + + assembleAgentFromFsEntry({ name: 'weather', config: coded, workspace: fromFile }, { onWarn }); + + expect(onWarn).toHaveBeenCalledWith(expect.stringContaining('workspace.ts is ignored')); + }); + }); + + describe('subagents', () => { + function childEntry(name: string, description: string) { + return { + name, + config: { model: 'openai/gpt-4o' as const, description }, + instructionsMd: `You are the ${name} subagent.`, + }; + } + + it('assembles discovered subagents and wires them into the parent agents map', async () => { + const parent = assembleAgentFromFsEntry({ + name: 'supervisor', + config: { model: 'openai/gpt-4o' }, + instructionsMd: 'Delegate to specialists.', + subagents: [childEntry('researcher', 'Researches topics.'), childEntry('writer', 'Writes drafts.')], + }); + + const agents = await parent.listAgents(); + expect(Object.keys(agents).sort()).toEqual(['researcher', 'writer']); + expect(agents.researcher!.getDescription()).toBe('Researches topics.'); + expect(await (agents.researcher as Agent).getInstructions()).toBe('You are the researcher subagent.'); + }); + + it('throws when a subagent has no description', () => { + expect(() => + assembleAgentFromFsEntry({ + name: 'supervisor', + config: { model: 'openai/gpt-4o' }, + instructionsMd: 'hi', + subagents: [ + { + name: 'researcher', + config: { model: 'openai/gpt-4o' }, + instructionsMd: 'You research.', + }, + ], + }), + ).toThrow(/requires a non-empty 'description'/); + }); + + it('throws when a subagent id collides with a sibling tool key', () => { + expect(() => + assembleAgentFromFsEntry({ + name: 'supervisor', + config: { model: 'openai/gpt-4o' }, + instructionsMd: 'hi', + tools: [makeTool('researcher')], + subagents: [childEntry('researcher', 'Researches topics.')], + }), + ).toThrow(/collides with a tool/); + }); + + it('throws on duplicate subagent ids', () => { + expect(() => + assembleAgentFromFsEntry({ + name: 'supervisor', + config: { model: 'openai/gpt-4o' }, + instructionsMd: 'hi', + subagents: [childEntry('researcher', 'First.'), childEntry('researcher', 'Second.')], + }), + ).toThrow(/duplicate subagent/); + }); + + it('lets config.agents win on id collision and warns', async () => { + const onWarn = vi.fn(); + const configChild = new Agent({ + id: 'researcher', + name: 'researcher', + description: 'Config version of the researcher.', + instructions: 'config researcher', + model: 'openai/gpt-4o', + }); + + const parent = assembleAgentFromFsEntry( + { + name: 'supervisor', + config: { model: 'openai/gpt-4o', agents: { researcher: configChild } }, + instructionsMd: 'hi', + subagents: [childEntry('researcher', 'FS version.')], + }, + { onWarn }, + ); + + const agents = await parent.listAgents(); + expect(agents.researcher).toBe(configChild); + expect(onWarn).toHaveBeenCalledWith(expect.stringContaining('researcher')); + }); + + it('warns and ignores discovered subagents when config.agents is a function', async () => { + const onWarn = vi.fn(); + const dynamicAgents = () => ({}); + + assembleAgentFromFsEntry( + { + name: 'supervisor', + config: { model: 'openai/gpt-4o', agents: dynamicAgents }, + instructionsMd: 'hi', + subagents: [childEntry('researcher', 'FS version.')], + }, + { onWarn }, + ); + + expect(onWarn).toHaveBeenCalledWith(expect.stringContaining('function')); + }); + + it('ignores discovered subagents when config.ts exports a new Agent()', async () => { + const onWarn = vi.fn(); + const coded = new Agent({ + id: 'supervisor', + name: 'supervisor', + instructions: 'Code-defined.', + model: 'openai/gpt-4o', + }); + + const result = assembleAgentFromFsEntry( + { name: 'supervisor', config: coded, subagents: [childEntry('researcher', 'FS version.')] }, + { onWarn }, + ); + + expect(result).toBe(coded); + expect(onWarn).toHaveBeenCalledWith(expect.stringContaining('subagents')); + }); + + it('ignores a nested subagents/ inside a subagent (one level only)', async () => { + const parent = assembleAgentFromFsEntry({ + name: 'supervisor', + config: { model: 'openai/gpt-4o' }, + instructionsMd: 'hi', + subagents: [ + { + ...childEntry('researcher', 'Researches topics.'), + subagents: [childEntry('grandchild', 'Should be ignored.')], + }, + ], + }); + + const agents = await parent.listAgents(); + expect(Object.keys(agents)).toEqual(['researcher']); + const researcher = await (agents.researcher as Agent).listAgents(); + expect(Object.keys(researcher)).toEqual([]); + }); + }); + + describe('memory', () => { + it('wires memory.ts onto the assembled agent', async () => { + const memory = new MockMemory(); + const agent = assembleAgentFromFsEntry({ + name: 'support', + config: { model: 'openai/gpt-4o' }, + instructionsMd: 'hi', + memory, + }); + + expect(agent.hasOwnMemory()).toBe(true); + expect(await agent.getMemory()).toBe(memory); + }); + + it('config.memory wins over memory.ts and warns', async () => { + const onWarn = vi.fn(); + const fromConfig = new MockMemory(); + const fromFile = new MockMemory(); + + const agent = assembleAgentFromFsEntry( + { + name: 'support', + config: { model: 'openai/gpt-4o', memory: fromConfig }, + instructionsMd: 'hi', + memory: fromFile, + }, + { onWarn }, + ); + + expect(await agent.getMemory()).toBe(fromConfig); + expect(onWarn).toHaveBeenCalledWith(expect.stringContaining('config.memory wins')); + }); + + it('warns and ignores memory.ts when config.ts exports a new Agent()', async () => { + const onWarn = vi.fn(); + const coded = new Agent({ + id: 'support', + name: 'support', + instructions: 'Code-defined.', + model: 'openai/gpt-4o', + }); + const fromFile = new MockMemory(); + + const result = assembleAgentFromFsEntry({ name: 'support', config: coded, memory: fromFile }, { onWarn }); + + expect(result).toBe(coded); + expect(result.hasOwnMemory()).toBe(false); + expect(onWarn).toHaveBeenCalledWith(expect.stringContaining('memory.ts is ignored')); + }); + + it('leaves the agent without memory when none is provided', async () => { + const agent = assembleAgentFromFsEntry({ + name: 'support', + config: { model: 'openai/gpt-4o' }, + instructionsMd: 'hi', + }); + + expect(agent.hasOwnMemory()).toBe(false); + expect(await agent.getMemory()).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/agent/fs-routing/index.ts b/packages/core/src/agent/fs-routing/index.ts new file mode 100644 index 000000000000..ed9513d7ebec --- /dev/null +++ b/packages/core/src/agent/fs-routing/index.ts @@ -0,0 +1,461 @@ +import { MastraError, ErrorDomain, ErrorCategory } from '../../error'; +import type { MastraMemory } from '../../memory/memory'; +import type { InlineSkill, SkillInput } from '../../skills/types'; +import { Workspace, LocalFilesystem, LocalSandbox } from '../../workspace'; +import type { AnyWorkspace } from '../../workspace'; +import { Agent } from '../agent'; +import type { AgentConfig, AgentInstructions, ToolsInput } from '../types'; + +/** + * Identity helper for a file-system routed agent config. Returns the provided + * partial config unchanged — its only purpose is to give authors editor types + * for `agents/<name>/config.ts` while letting `instructions`/`model`/`tools` be + * supplied by sibling files (`instructions.md`, `tools/*.ts`). + * + * @example + * ```ts + * // src/mastra/agents/weather/config.ts + * import { agentConfig } from '@mastra/core/agent'; + * + * export default agentConfig({ + * model: 'openai/gpt-4o', + * // instructions omitted -> taken from instructions.md + * // tools omitted -> taken from tools/*.ts + * }); + * ``` + */ +export type FsAgentConfig = Partial<Omit<AgentConfig, 'id' | 'name'>> & { + id?: string; + name?: string; +}; + +export function agentConfig(config: FsAgentConfig): FsAgentConfig { + return config; +} + +/** + * A single tool discovered under `agents/<name>/tools/`. `key` defaults to the + * filename slug; `tool` is the default export of that module. + */ +export interface FsAgentToolEntry { + key: string; + tool: ToolsInput[string]; +} + +export interface FsAgentEntry { + /** Agent directory name. Used as the default `id`/`name`. */ + name: string; + /** + * Default export of `config.ts`, if present. Either an `agentConfig(...)` + * partial or a fully code-defined `Agent` instance (`new Agent({...})`). + */ + config?: FsAgentConfig | Agent; + /** Raw contents of `instructions.md`, if present. */ + instructionsMd?: string; + /** Tools discovered under `tools/`, already loaded. */ + tools?: FsAgentToolEntry[]; + /** + * Skills discovered under `skills/`, already loaded as inline skills + * (the codegen layer inlines each `SKILL.md` + references via `createSkill`). + */ + skills?: InlineSkill[]; + /** + * Default export of `agents/<name>/workspace.ts`, if present. A `Workspace` + * instance that overrides the convention default. + */ + workspace?: AnyWorkspace; + /** + * Default export of `agents/<name>/memory.ts`, if present. A `MastraMemory` + * instance wired into the assembled agent as its `memory`. `config.memory` + * (from `config.ts`) takes precedence on conflict. + */ + memory?: MastraMemory; + /** + * Base path for the convention default workspace. When provided and neither + * `config.workspace` nor `workspace.ts` supplies one, an FS agent gets a + * default `Workspace` (a contained `LocalFilesystem` rooted here plus a + * `LocalSandbox`), giving file-based agents file/shell tools automatically. + * Callers (the deployer codegen layer) pass a per-agent directory here. + */ + defaultWorkspaceBasePath?: string; + /** + * Declared subagents discovered under `agents/<name>/subagents/<childId>/`. + * Each entry is assembled into its own `Agent` and wired into the parent's + * `agents` map under its directory name, becoming a model-visible delegation + * tool. Subagents are one level deep — entries here carry no further + * `subagents` of their own. + */ + subagents?: FsAgentEntry[]; +} + +/** + * Assemble a single `Agent` from already-loaded file-system entries for one + * `agents/<name>/` directory. Performs no filesystem access — callers load the + * modules and pass them in, keeping this unit-testable and runtime-portable. + * + * Precedence rules: + * - `id`/`name` default to the directory name when omitted in config. + * - `instructions`: a dynamic (function) `config.instructions` wins over + * `instructions.md`; otherwise `instructions.md` wins over a static + * `config.instructions`. Missing both is an error. + * - `model` is required (from config); missing is an error. + * - `tools`: discovered `tools/*.ts` are merged with `config.tools`; on key + * collision `config.tools` wins (a warning is surfaced via `onWarn`). + * - `skills`: discovered `skills/*` are merged with `config.skills`; on name + * collision `config.skills` wins (a warning is surfaced via `onWarn`). A + * dynamic (function) `config.skills` wins wholesale and discovered skills are + * ignored with a warning. + * - `memory`: `memory.ts`'s default export is used unless `config.memory` is + * set, in which case `config.memory` wins (a warning is surfaced via + * `onWarn`). Missing both leaves the agent without memory. + * + * If `config` is already an `Agent` instance (the author wrote + * `export default new Agent({...})` in `config.ts`), it is used as-is — no + * partial-config assembly is performed. This lets a folder under `agents/` + * hold either an `agentConfig(...)` partial or a fully code-defined + * `new Agent(...)` without the loader trying to re-wrap the latter. + */ +export function assembleAgentFromFsEntry(entry: FsAgentEntry, options?: { onWarn?: (message: string) => void }): Agent { + const { + name, + config = {}, + instructionsMd, + tools = [], + skills = [], + workspace, + memory, + defaultWorkspaceBasePath, + subagents = [], + } = entry; + const onWarn = options?.onWarn ?? (() => {}); + + // A code-defined agent (`export default new Agent({...})`) is used verbatim. + if (config instanceof Agent) { + if (instructionsMd !== undefined) { + onWarn(`Agent "${name}": config.ts exports a new Agent(), so agents/${name}/instructions.md is ignored.`); + } + if (tools.length > 0) { + onWarn( + `Agent "${name}": config.ts exports a new Agent(), so discovered tools under agents/${name}/tools/ are ignored.`, + ); + } + if (skills.length > 0) { + onWarn( + `Agent "${name}": config.ts exports a new Agent(), so discovered skills under agents/${name}/skills/ are ignored.`, + ); + } + if (workspace !== undefined) { + onWarn( + `Agent "${name}": config.ts exports a new Agent(), so agents/${name}/workspace.ts is ignored. Set the workspace in the Agent config instead.`, + ); + } + if (memory !== undefined) { + onWarn( + `Agent "${name}": config.ts exports a new Agent(), so agents/${name}/memory.ts is ignored. Set the memory in the Agent config instead.`, + ); + } + if (subagents.length > 0) { + onWarn( + `Agent "${name}": config.ts exports a new Agent(), so discovered subagents under agents/${name}/subagents/ are ignored. Set 'agents' in the Agent config instead.`, + ); + } + return config; + } + + const instructions = resolveInstructions(name, config.instructions, instructionsMd); + + if (!config.model) { + throw new MastraError({ + id: 'AGENT_FS_ROUTING_MODEL_REQUIRED', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + details: { agentName: name }, + text: `Agent "${name}": missing model in config.ts and no default. Provide a 'model' in agents/${name}/config.ts.`, + }); + } + + const mergedTools = mergeTools(name, tools, config.tools, onWarn); + const mergedSkills = mergeSkills(name, skills, config.skills, onWarn); + const mergedWorkspace = mergeWorkspace(name, workspace, config.workspace, defaultWorkspaceBasePath, onWarn); + const mergedMemory = mergeMemory(name, memory, config.memory, onWarn); + const mergedAgents = mergeSubAgents(name, subagents, config.agents, mergedTools, options); + + const assembled = { + ...config, + id: config.id ?? name, + name: config.name ?? name, + instructions, + ...(mergedTools !== undefined ? { tools: mergedTools } : {}), + ...(mergedSkills !== undefined ? { skills: mergedSkills } : {}), + ...(mergedWorkspace !== undefined ? { workspace: mergedWorkspace } : {}), + ...(mergedMemory !== undefined ? { memory: mergedMemory } : {}), + ...(mergedAgents !== undefined ? { agents: mergedAgents } : {}), + } as AgentConfig; + + return new Agent(assembled); +} + +function resolveInstructions( + name: string, + configInstructions: FsAgentConfig['instructions'], + instructionsMd: string | undefined, +): FsAgentConfig['instructions'] { + const hasConfigInstructions = configInstructions !== undefined && configInstructions !== null; + const hasMd = instructionsMd !== undefined; + + if (hasConfigInstructions && typeof configInstructions === 'function') { + // Dynamic instructions can't be overridden by static markdown. + return configInstructions; + } + + if (hasMd) { + return instructionsMd as AgentInstructions; + } + + if (hasConfigInstructions) { + return configInstructions; + } + + throw new MastraError({ + id: 'AGENT_FS_ROUTING_INSTRUCTIONS_REQUIRED', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + details: { agentName: name }, + text: `Agent "${name}": missing instructions. Provide agents/${name}/instructions.md or an 'instructions' field in config.ts.`, + }); +} + +function mergeTools( + name: string, + fsTools: FsAgentToolEntry[], + configTools: FsAgentConfig['tools'], + onWarn: (message: string) => void, +): ToolsInput | undefined { + const fromFs: ToolsInput = {}; + for (const { key, tool } of fsTools) { + fromFs[key] = tool; + } + + // Dynamic config.tools (a function) can't be statically merged; it wins + // wholesale and discovered tools are ignored with a warning. + if (typeof configTools === 'function') { + if (fsTools.length > 0) { + onWarn( + `Agent "${name}": config.tools is a function, so discovered tools under agents/${name}/tools/ are ignored.`, + ); + } + return configTools as unknown as ToolsInput; + } + + const fromConfig = (configTools ?? {}) as ToolsInput; + for (const key of Object.keys(fromConfig)) { + if (key in fromFs) { + onWarn(`Agent "${name}": tool "${key}" defined in both config.tools and tools/; config.tools wins.`); + } + } + + const merged: ToolsInput = { ...fromFs, ...fromConfig }; + return Object.keys(merged).length > 0 ? merged : undefined; +} + +function mergeSkills( + name: string, + fsSkills: InlineSkill[], + configSkills: FsAgentConfig['skills'], + onWarn: (message: string) => void, +): SkillInput[] | undefined { + // Dynamic config.skills (a function) can't be statically merged; it wins + // wholesale and discovered skills are ignored with a warning. + if (typeof configSkills === 'function') { + if (fsSkills.length > 0) { + onWarn( + `Agent "${name}": config.skills is a function, so discovered skills under agents/${name}/skills/ are ignored.`, + ); + } + return undefined; + } + + const fromConfig = (configSkills ?? []) as SkillInput[]; + const configNames = new Set(fromConfig.map(skill => (typeof skill === 'string' ? skill : skill.name))); + + // config.skills wins on name collision; drop the fs skill and warn. + const fromFs = fsSkills.filter(skill => { + if (configNames.has(skill.name)) { + onWarn(`Agent "${name}": skill "${skill.name}" defined in both config.skills and skills/; config.skills wins.`); + return false; + } + return true; + }); + + const merged: SkillInput[] = [...fromFs, ...fromConfig]; + return merged.length > 0 ? merged : undefined; +} + +/** + * Assemble discovered subagents and merge them into the parent's `agents` map. + * + * Each discovered subagent is assembled independently via + * `assembleAgentFromFsEntry` (one level deep — its own `subagents` are not + * threaded further). Rules: + * - Each subagent's `config.ts` must resolve a non-empty `description`; + * otherwise a dir-scoped build error is thrown. + * - A subagent id that collides with a resolved tool key on the same parent, or + * a duplicate subagent id, is a build error. + * - `config.agents` takes precedence: a dynamic (function) `config.agents` wins + * wholesale and discovered subagents are ignored with a warning; a static + * `config.agents` wins per-key on id collision with a warning. + */ +function mergeSubAgents( + name: string, + fsSubAgents: FsAgentEntry[], + configAgents: FsAgentConfig['agents'], + mergedTools: ToolsInput | undefined, + options?: { onWarn?: (message: string) => void }, +): FsAgentConfig['agents'] | undefined { + const onWarn = options?.onWarn ?? (() => {}); + + // Dynamic config.agents (a function) can't be statically merged; it wins + // wholesale and discovered subagents are ignored with a warning. + if (typeof configAgents === 'function') { + if (fsSubAgents.length > 0) { + onWarn( + `Agent "${name}": config.agents is a function, so discovered subagents under agents/${name}/subagents/ are ignored.`, + ); + } + return configAgents; + } + + const fromConfig = (configAgents ?? {}) as Record<string, Agent>; + const configKeys = new Set(Object.keys(fromConfig)); + const toolKeys = new Set(Object.keys(mergedTools ?? {})); + + const fromFs: Record<string, Agent> = {}; + for (const childEntry of fsSubAgents) { + const childId = childEntry.name; + + if (childId in fromFs) { + throw new MastraError({ + id: 'AGENT_FS_ROUTING_SUBAGENT_NAME_COLLISION', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + details: { agentName: name, subagentName: childId }, + text: `Agent "${name}": duplicate subagent "${childId}" under agents/${name}/subagents/.`, + }); + } + + if (toolKeys.has(childId)) { + throw new MastraError({ + id: 'AGENT_FS_ROUTING_SUBAGENT_NAME_COLLISION', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + details: { agentName: name, subagentName: childId }, + text: `Agent "${name}": subagent "${childId}" collides with a tool of the same name. Rename agents/${name}/subagents/${childId}/ or the tool.`, + }); + } + + // One level deep: a subagent's own `subagents` are not threaded further. + const child = assembleAgentFromFsEntry({ ...childEntry, subagents: undefined }, options); + + const description = child.getDescription(); + if (!description || description.trim() === '') { + throw new MastraError({ + id: 'AGENT_FS_ROUTING_SUBAGENT_DESCRIPTION_REQUIRED', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + details: { agentName: name, subagentName: childId }, + text: `Agent "${name}": subagent "${childId}" requires a non-empty 'description'. Set one in agents/${name}/subagents/${childId}/config.ts.`, + }); + } + + if (configKeys.has(childId)) { + onWarn( + `Agent "${name}": subagent "${childId}" defined in both config.agents and subagents/; config.agents wins.`, + ); + continue; + } + + fromFs[childId] = child; + } + + const merged = { ...fromFs, ...fromConfig }; + return Object.keys(merged).length > 0 ? (merged as FsAgentConfig['agents']) : undefined; +} + +/** + * Resolve the workspace for a file-based agent. + * + * Precedence (explicit > convention > default): + * - `config.workspace` (from `config.ts`) wins over everything. + * - `workspace.ts`'s default export wins over the convention default. + * - Otherwise, when `defaultWorkspaceBasePath` is provided, a default + * `Workspace` (contained `LocalFilesystem` + `LocalSandbox`) is created so + * file-based agents get file/shell tools automatically (Eve sandbox parity). + * - If none of the above apply, returns `undefined` (no workspace). + */ +function mergeWorkspace( + name: string, + fsWorkspace: AnyWorkspace | undefined, + configWorkspace: FsAgentConfig['workspace'], + defaultWorkspaceBasePath: string | undefined, + onWarn: (message: string) => void, +): FsAgentConfig['workspace'] | undefined { + if (configWorkspace !== undefined) { + if (fsWorkspace !== undefined) { + onWarn(`Agent "${name}": workspace defined in both config.ts and workspace.ts; config.workspace wins.`); + } + return configWorkspace; + } + + if (fsWorkspace !== undefined) { + return fsWorkspace; + } + + if (defaultWorkspaceBasePath !== undefined) { + return createDefaultWorkspace(name, defaultWorkspaceBasePath); + } + + return undefined; +} + +/** + * Build the convention default workspace for a file-based agent: a contained + * `LocalFilesystem` rooted at `basePath` paired with a `LocalSandbox` whose + * working directory is the same path. No filesystem I/O happens here — the + * directory is created lazily when the workspace is initialized at runtime. + */ +function createDefaultWorkspace(name: string, basePath: string): AnyWorkspace { + return new Workspace({ + name: `${name}-workspace`, + filesystem: new LocalFilesystem({ basePath }), + sandbox: new LocalSandbox({ workingDirectory: basePath }), + }); +} + +/** + * Resolve the memory for a file-based agent. + * + * Precedence (explicit > convention): + * - `config.memory` (from `config.ts`) wins over `memory.ts`. A function + * `config.memory` is carried through wholesale (it is an opaque value). + * - Otherwise `memory.ts`'s default export is used. + * - Otherwise returns `undefined` (no memory; current behavior). + */ +function mergeMemory( + name: string, + fsMemory: MastraMemory | undefined, + configMemory: FsAgentConfig['memory'], + onWarn: (message: string) => void, +): FsAgentConfig['memory'] | undefined { + if (configMemory !== undefined) { + if (fsMemory !== undefined) { + onWarn(`Agent "${name}": memory defined in both config.ts and memory.ts; config.memory wins.`); + } + return configMemory; + } + + if (fsMemory !== undefined) { + return fsMemory; + } + + return undefined; +} diff --git a/packages/core/src/agent/heartbeat/api.test.ts b/packages/core/src/agent/heartbeat/api.test.ts new file mode 100644 index 000000000000..02d25a944fe2 --- /dev/null +++ b/packages/core/src/agent/heartbeat/api.test.ts @@ -0,0 +1,222 @@ +import { MockLanguageModelV2 } from '@internal/ai-sdk-v5/test'; +import { describe, expect, it } from 'vitest'; +import { Mastra } from '../../mastra'; +import { MockStore } from '../../storage/mock'; +import { Agent } from '../agent'; +import { HEARTBEAT_SCHEDULE_PREFIX } from './types'; + +function makeAgent(id: string): Agent { + return new Agent({ + id, + name: id, + instructions: 'test', + model: new MockLanguageModelV2(), + }); +} + +function makeMastra(agents: Record<string, Agent>) { + return new Mastra({ logger: false, storage: new MockStore(), agents }); +} + +describe('mastra.heartbeats', () => { + it('creates a threadless heartbeat with a random hb_ id', async () => { + const agent = makeAgent('pinger'); + const mastra = makeMastra({ pinger: agent }); + + const hb = await mastra.heartbeats.create({ + agentId: agent.id, + cron: '*/5 * * * *', + prompt: 'ping', + }); + + expect(hb.id.startsWith(HEARTBEAT_SCHEDULE_PREFIX)).toBe(true); + expect(hb.agentId).toBe('pinger'); + expect(hb.prompt).toBe('ping'); + expect(hb.threadId).toBeUndefined(); + expect(hb.status).toBe('active'); + expect(typeof hb.nextFireAt).toBe('number'); + }); + + it('creates a threaded heartbeat with the threaded knobs', async () => { + const agent = makeAgent('pinger'); + const mastra = makeMastra({ pinger: agent }); + + const hb = await mastra.heartbeats.create({ + agentId: agent.id, + cron: '*/5 * * * *', + prompt: 'check in', + threadId: 't1', + resourceId: 'u1', + signalType: 'system-reminder', + ifActive: { behavior: 'persist' }, + ifIdle: { behavior: 'wake' }, + }); + + expect(hb.threadId).toBe('t1'); + expect(hb.resourceId).toBe('u1'); + expect(hb.signalType).toBe('system-reminder'); + expect(hb.ifActive).toEqual({ behavior: 'persist' }); + expect(hb.ifIdle).toEqual({ behavior: 'wake' }); + }); + + it('supports multiple heartbeats per agent/thread with distinct ids', async () => { + const agent = makeAgent('pinger'); + const mastra = makeMastra({ pinger: agent }); + + const a = await mastra.heartbeats.create({ + agentId: agent.id, + cron: '*/5 * * * *', + prompt: 'a', + name: 'morning', + threadId: 't1', + resourceId: 'u1', + }); + const b = await mastra.heartbeats.create({ + agentId: agent.id, + cron: '*/10 * * * *', + prompt: 'b', + name: 'evening', + threadId: 't1', + resourceId: 'u1', + }); + + expect(a.id).not.toBe(b.id); + const list = await mastra.heartbeats.list({ agentId: agent.id }); + expect(list).toHaveLength(2); + expect(list.map(h => h.name).sort()).toEqual(['evening', 'morning']); + }); + + it('rejects invalid cron', async () => { + const agent = makeAgent('pinger'); + const mastra = makeMastra({ pinger: agent }); + + await expect(mastra.heartbeats.create({ agentId: agent.id, cron: 'not-a-cron', prompt: 'p' })).rejects.toThrow(); + }); + + it('rejects a missing agentId', async () => { + const agent = makeAgent('pinger'); + const mastra = makeMastra({ pinger: agent }); + + await expect(mastra.heartbeats.create({ cron: '*/5 * * * *', prompt: 'p' } as any)).rejects.toThrow(/agentId/); + }); + + it('rejects threadId without resourceId', async () => { + const agent = makeAgent('pinger'); + const mastra = makeMastra({ pinger: agent }); + + await expect( + mastra.heartbeats.create({ agentId: agent.id, cron: '*/5 * * * *', prompt: 'p', threadId: 't1' }), + ).rejects.toThrow(/resourceId/); + }); + + it('rejects thread-only knobs when threadId is omitted', async () => { + const agent = makeAgent('pinger'); + const mastra = makeMastra({ pinger: agent }); + + await expect( + mastra.heartbeats.create({ agentId: agent.id, cron: '*/5 * * * *', prompt: 'p', ifIdle: 'wake' } as any), + ).rejects.toThrow(/threadId/); + }); + + it('rejects updating thread-only knobs on a threadless heartbeat', async () => { + const agent = makeAgent('pinger'); + const mastra = makeMastra({ pinger: agent }); + + const hb = await mastra.heartbeats.create({ agentId: agent.id, cron: '*/5 * * * *', prompt: 'p' }); + + await expect(mastra.heartbeats.update(hb.id, { ifIdle: 'wake' } as any)).rejects.toThrow(/threadId/); + await expect(mastra.heartbeats.update(hb.id, { ifActive: 'queue' } as any)).rejects.toThrow(/threadId/); + await expect(mastra.heartbeats.update(hb.id, { signalType: 'notification' } as any)).rejects.toThrow(/threadId/); + + // A non-thread-scoped patch on the same threadless heartbeat still works. + const patched = await mastra.heartbeats.update(hb.id, { prompt: 'changed' }); + expect(patched.prompt).toBe('changed'); + }); + + it('refuses heartbeats when storage lacks the schedules domain', async () => { + const agent = makeAgent('pinger'); + // Mastra now always backs `new Mastra({})` with an in-memory store (which + // includes the schedules domain), so the guard can only be exercised by a + // storage adapter that genuinely lacks the schedules domain. + const storage = new MockStore(); + delete (storage.stores as Partial<typeof storage.stores>).schedules; + const mastra = new Mastra({ logger: false, storage, agents: { pinger: agent } }); + await expect(mastra.heartbeats.create({ agentId: agent.id, cron: '*/5 * * * *', prompt: 'p' })).rejects.toThrow( + /schedules/, + ); + }); + + it('delete removes the heartbeat by id', async () => { + const agent = makeAgent('pinger'); + const mastra = makeMastra({ pinger: agent }); + + const hb = await mastra.heartbeats.create({ + agentId: agent.id, + cron: '*/5 * * * *', + prompt: 'p', + threadId: 't1', + resourceId: 'u1', + }); + expect(await mastra.heartbeats.get(hb.id)).not.toBeNull(); + + await mastra.heartbeats.delete(hb.id); + expect(await mastra.heartbeats.get(hb.id)).toBeNull(); + }); + + it('delete is a no-op for unknown ids', async () => { + const agent = makeAgent('pinger'); + const mastra = makeMastra({ pinger: agent }); + await expect(mastra.heartbeats.delete('hb_does-not-exist')).resolves.toBeUndefined(); + }); + + it('list filters by agentId', async () => { + const a = makeAgent('a'); + const b = makeAgent('b'); + const mastra = makeMastra({ a, b }); + + await mastra.heartbeats.create({ agentId: 'a', cron: '*/5 * * * *', prompt: 'A' }); + await mastra.heartbeats.create({ agentId: 'a', cron: '*/5 * * * *', prompt: 'A2', threadId: 't', resourceId: 'r' }); + await mastra.heartbeats.create({ agentId: 'b', cron: '*/5 * * * *', prompt: 'B' }); + + const aList = await mastra.heartbeats.list({ agentId: 'a' }); + expect(aList).toHaveLength(2); + expect(aList.every(h => h.agentId === 'a')).toBe(true); + + const bList = await mastra.heartbeats.list({ agentId: 'b' }); + expect(bList).toHaveLength(1); + expect(bList[0]!.agentId).toBe('b'); + }); + + it('list supports filtering by threadId and name', async () => { + const agent = makeAgent('pinger'); + const mastra = makeMastra({ pinger: agent }); + + await mastra.heartbeats.create({ + agentId: agent.id, + cron: '*/5 * * * *', + prompt: 'a', + name: 'morning', + threadId: 't1', + resourceId: 'u1', + }); + await mastra.heartbeats.create({ + agentId: agent.id, + cron: '*/5 * * * *', + prompt: 'b', + name: 'evening', + threadId: 't1', + resourceId: 'u1', + }); + await mastra.heartbeats.create({ + agentId: agent.id, + cron: '*/5 * * * *', + prompt: 'c', + threadId: 't2', + resourceId: 'u1', + }); + + expect(await mastra.heartbeats.list({ agentId: agent.id, threadId: 't1' })).toHaveLength(2); + expect(await mastra.heartbeats.list({ agentId: agent.id, name: 'morning' })).toHaveLength(1); + expect(await mastra.heartbeats.list({ agentId: agent.id, threadId: 't2' })).toHaveLength(1); + }); +}); diff --git a/packages/core/src/agent/heartbeat/heartbeats.test.ts b/packages/core/src/agent/heartbeat/heartbeats.test.ts new file mode 100644 index 000000000000..2bf7dfd77e27 --- /dev/null +++ b/packages/core/src/agent/heartbeat/heartbeats.test.ts @@ -0,0 +1,238 @@ +import { MockLanguageModelV2 } from '@internal/ai-sdk-v5/test'; +import { describe, expect, it, vi } from 'vitest'; +import { Mastra } from '../../mastra'; +import { MockStore } from '../../storage/mock'; +import { Agent } from '../agent'; +import { HEARTBEAT_SCHEDULE_PREFIX } from './types'; + +function makeAgent(id: string): Agent { + return new Agent({ + id, + name: id, + instructions: 'test', + model: new MockLanguageModelV2(), + }); +} + +function makeMastra(agentIds: string[]) { + const agents = Object.fromEntries(agentIds.map(id => [id, makeAgent(id)])) as Record<string, Agent>; + const mastra = new Mastra({ logger: false, storage: new MockStore(), agents }); + return { mastra, agents }; +} + +describe('mastra.heartbeats canonical service', () => { + it('creates heartbeats for any registered agent and gets them back', async () => { + const { mastra } = makeMastra(['a', 'b']); + + const aHb = await mastra.heartbeats.create({ agentId: 'a', cron: '*/5 * * * *', prompt: 'A' }); + const bHb = await mastra.heartbeats.create({ + agentId: 'b', + cron: '*/10 * * * *', + prompt: 'B', + name: 'nightly', + }); + + expect(aHb.id).not.toBe(bHb.id); + expect(aHb.id.startsWith(HEARTBEAT_SCHEDULE_PREFIX)).toBe(true); + expect(bHb.name).toBe('nightly'); + + expect((await mastra.heartbeats.get(aHb.id))?.agentId).toBe('a'); + expect((await mastra.heartbeats.get(bHb.id))?.agentId).toBe('b'); + }); + + it('accepts a custom id, normalizing it to hb_<slug>', async () => { + const { mastra } = makeMastra(['a']); + + const withRaw = await mastra.heartbeats.create({ + agentId: 'a', + cron: '*/5 * * * *', + prompt: 'A', + id: 'Nightly Summary!', + }); + expect(withRaw.id).toBe(`${HEARTBEAT_SCHEDULE_PREFIX}nightly-summary`); + expect((await mastra.heartbeats.get(withRaw.id))?.agentId).toBe('a'); + + const withPrefix = await mastra.heartbeats.create({ + agentId: 'a', + cron: '*/5 * * * *', + prompt: 'B', + id: 'hb_morning-report', + }); + expect(withPrefix.id).toBe(`${HEARTBEAT_SCHEDULE_PREFIX}morning-report`); + }); + + it('resolves lookups by the prefixed stored id and the bare caller id alike', async () => { + const { mastra } = makeMastra(['a']); + + const hb = await mastra.heartbeats.create({ + agentId: 'a', + cron: '*/5 * * * *', + prompt: 'A', + id: 'Nightly Summary!', + }); + expect(hb.id).toBe(`${HEARTBEAT_SCHEDULE_PREFIX}nightly-summary`); + + // The fully-formed stored id resolves verbatim (no re-slugification). + expect((await mastra.heartbeats.get(hb.id))?.id).toBe(hb.id); + // The bare caller id resolves to the same heartbeat. + expect((await mastra.heartbeats.get('Nightly Summary!'))?.id).toBe(hb.id); + }); + + it('throws when creating a heartbeat with an id that already exists', async () => { + const { mastra } = makeMastra(['a']); + await mastra.heartbeats.create({ agentId: 'a', cron: '*/5 * * * *', prompt: 'A', id: 'dupe' }); + + await expect( + mastra.heartbeats.create({ agentId: 'a', cron: '*/5 * * * *', prompt: 'B', id: 'dupe' }), + ).rejects.toThrow(/already exists/); + }); + + it('throws when a custom id is empty after normalization', async () => { + const { mastra } = makeMastra(['a']); + await expect( + mastra.heartbeats.create({ agentId: 'a', cron: '*/5 * * * *', prompt: 'A', id: '!!!' }), + ).rejects.toThrow(/empty after normalization/); + }); + + it('list with no filter returns heartbeats across agents', async () => { + const { mastra } = makeMastra(['a', 'b']); + await mastra.heartbeats.create({ agentId: 'a', cron: '*/5 * * * *', prompt: 'A' }); + await mastra.heartbeats.create({ agentId: 'b', cron: '*/5 * * * *', prompt: 'B' }); + + const all = await mastra.heartbeats.list(); + expect(all).toHaveLength(2); + expect(new Set(all.map(h => h.agentId))).toEqual(new Set(['a', 'b'])); + }); + + it('list filters by agentId, threadId, resourceId, name', async () => { + const { mastra } = makeMastra(['a']); + + await mastra.heartbeats.create({ + agentId: 'a', + cron: '*/5 * * * *', + prompt: 'morning', + name: 'morning', + threadId: 't1', + resourceId: 'u1', + }); + await mastra.heartbeats.create({ + agentId: 'a', + cron: '*/5 * * * *', + prompt: 'evening', + name: 'evening', + threadId: 't1', + resourceId: 'u1', + }); + await mastra.heartbeats.create({ + agentId: 'a', + cron: '*/5 * * * *', + prompt: 'other', + threadId: 't2', + resourceId: 'u2', + }); + + expect(await mastra.heartbeats.list({ agentId: 'a' })).toHaveLength(3); + expect(await mastra.heartbeats.list({ threadId: 't1' })).toHaveLength(2); + expect(await mastra.heartbeats.list({ resourceId: 'u2' })).toHaveLength(1); + expect(await mastra.heartbeats.list({ name: 'morning' })).toHaveLength(1); + expect(await mastra.heartbeats.list({ threadId: 't1', name: 'evening' })).toHaveLength(1); + }); + + it('update patches cron + prompt + name and recomputes nextFireAt for cron changes', async () => { + // Pin the clock mid-hour so the original (*/5) and updated (0 * * * *) + // crons resolve to different next-fire instants. Near the top of the hour + // both crons coincide on the same boundary, which would make this flaky. + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-23T12:30:00.000Z')); + try { + const { mastra } = makeMastra(['a']); + const hb = await mastra.heartbeats.create({ + agentId: 'a', + cron: '*/5 * * * *', + prompt: 'old', + name: 'old-name', + }); + const prevNext = hb.nextFireAt; + + const patched = await mastra.heartbeats.update(hb.id, { + cron: '0 * * * *', + prompt: 'new', + name: 'new-name', + }); + + expect(patched.cron).toBe('0 * * * *'); + expect(patched.prompt).toBe('new'); + expect(patched.name).toBe('new-name'); + expect(patched.nextFireAt).not.toBe(prevNext); + } finally { + vi.useRealTimers(); + } + }); + + it('pause and resume flip status and clear/recompute nextFireAt', async () => { + const { mastra } = makeMastra(['a']); + const hb = await mastra.heartbeats.create({ agentId: 'a', cron: '*/5 * * * *', prompt: 'p' }); + expect(hb.status).toBe('active'); + + const paused = await mastra.heartbeats.pause(hb.id); + expect(paused.status).toBe('paused'); + + const resumed = await mastra.heartbeats.resume(hb.id); + expect(resumed.status).toBe('active'); + expect(typeof resumed.nextFireAt).toBe('number'); + }); + + it('update({ status: active }) on a paused heartbeat recomputes nextFireAt like resume()', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-23T12:30:00.000Z')); + try { + const { mastra } = makeMastra(['a']); + const hb = await mastra.heartbeats.create({ agentId: 'a', cron: '*/5 * * * *', prompt: 'p' }); + + const paused = await mastra.heartbeats.pause(hb.id); + expect(paused.status).toBe('paused'); + const staleNext = paused.nextFireAt; + + // Advance well past the paused nextFireAt so a naive status flip would + // leave a stale (past) fire time and trigger an immediate spurious run. + vi.advanceTimersByTime(30 * 60 * 1000); + + const resumedViaUpdate = await mastra.heartbeats.update(hb.id, { status: 'active' }); + expect(resumedViaUpdate.status).toBe('active'); + // Must be recomputed forward from "now", not the stale paused value. + expect(resumedViaUpdate.nextFireAt).not.toBe(staleNext); + expect(resumedViaUpdate.nextFireAt).toBeGreaterThan(Date.now()); + } finally { + vi.useRealTimers(); + } + }); + + it('update without a resume does not recompute nextFireAt', async () => { + const { mastra } = makeMastra(['a']); + const hb = await mastra.heartbeats.create({ agentId: 'a', cron: '*/5 * * * *', prompt: 'p' }); + const prevNext = hb.nextFireAt; + + const patched = await mastra.heartbeats.update(hb.id, { prompt: 'changed' }); + expect(patched.prompt).toBe('changed'); + expect(patched.nextFireAt).toBe(prevNext); + }); + + it('delete is idempotent', async () => { + const { mastra } = makeMastra(['a']); + const hb = await mastra.heartbeats.create({ agentId: 'a', cron: '*/5 * * * *', prompt: 'p' }); + + await mastra.heartbeats.delete(hb.id); + await expect(mastra.heartbeats.delete(hb.id)).resolves.toBeUndefined(); + expect(await mastra.heartbeats.get(hb.id)).toBeNull(); + }); + + it('get returns null for unknown ids and for non-heartbeat schedule rows', async () => { + const { mastra } = makeMastra(['a']); + expect(await mastra.heartbeats.get('hb_nope')).toBeNull(); + }); + + it('reuses the same Heartbeats instance across getter accesses', () => { + const { mastra } = makeMastra(['a']); + expect(mastra.heartbeats).toBe(mastra.heartbeats); + }); +}); diff --git a/packages/core/src/agent/heartbeat/heartbeats.ts b/packages/core/src/agent/heartbeat/heartbeats.ts new file mode 100644 index 000000000000..8579da38c293 --- /dev/null +++ b/packages/core/src/agent/heartbeat/heartbeats.ts @@ -0,0 +1,487 @@ +import { randomUUID } from 'node:crypto'; +import slugify from '@sindresorhus/slugify'; +import { ErrorCategory, ErrorDomain, MastraError } from '../../error'; +import type { Mastra } from '../../mastra'; +import type { Schedule } from '../../storage/domains/schedules/base'; +import { computeNextFireAt, validateCron } from '../../workflows/scheduler/cron'; +import type { AgentSignalAttributes, AgentSignalType } from '../signals'; +import type { HeartbeatIfActive, HeartbeatIfIdle } from './types'; +import { HEARTBEAT_SCHEDULE_PREFIX } from './types'; + +type HeartbeatTarget = Extract<Schedule['target'], { type: 'heartbeat' }>; + +/** + * Slugify the caller-facing portion of a heartbeat id into the canonical + * `hb_<slug>` shape. The slug part is lowercased and stripped of characters + * that are unsafe in storage keys / URLs; the `hb_` prefix is added only if + * missing so a caller can pass either `nightly-summary` or + * `hb_nightly-summary` and get the same canonical id. Returns an empty string + * when nothing slug-able remains. + */ +function canonicalizeHeartbeatId(rawId: string): string { + const trimmed = rawId.trim(); + const withoutPrefix = trimmed.startsWith(HEARTBEAT_SCHEDULE_PREFIX) + ? trimmed.slice(HEARTBEAT_SCHEDULE_PREFIX.length) + : trimmed; + const slug = slugify(withoutPrefix); + if (!slug) return ''; + return `${HEARTBEAT_SCHEDULE_PREFIX}${slug}`; +} + +/** + * Normalize a caller-supplied heartbeat id for `create`. Throws + * `HEARTBEATS_INVALID_ID` when the id is empty after normalization so callers + * cannot create an unaddressable heartbeat. + */ +function normalizeHeartbeatId(rawId: string): string { + const canonical = canonicalizeHeartbeatId(rawId); + if (!canonical) { + throw new MastraError({ + id: 'HEARTBEATS_INVALID_ID', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + text: `createHeartbeat: id "${rawId}" is empty after normalization. Provide an id with at least one alphanumeric character.`, + }); + } + return canonical; +} + +/** + * Resolve a caller-supplied heartbeat id for read / mutate lookups so callers + * can pass the id in whichever form they used at create time. + * + * An id that already carries the `hb_` prefix is treated as a fully-formed + * stored id and returned verbatim — re-slugifying it would mangle characters + * `create` already accepted (e.g. underscores), making the heartbeat + * unaddressable. A bare caller id is canonicalized to `hb_<slug>` to match what + * `create` persisted; when nothing slug-able remains the raw id is returned so + * the caller gets a `not found` rather than a surprise match. + */ +function resolveHeartbeatId(rawId: string): string { + const trimmed = rawId.trim(); + if (trimmed.startsWith(HEARTBEAT_SCHEDULE_PREFIX)) return trimmed; + return canonicalizeHeartbeatId(trimmed) || rawId; +} + +/** + * Flat heartbeat view returned by the {@link Heartbeats} service. Projects + * the underlying `Schedule` row + `target.type === 'heartbeat'` payload + * onto a single object so callers never have to know about the schedules + * storage shape. + */ +export interface Heartbeat { + id: string; + agentId: string; + name?: string; + threadId?: string; + resourceId?: string; + prompt: string; + cron: string; + timezone?: string; + status: 'active' | 'paused'; + nextFireAt: number; + lastFireAt?: number; + lastRunId?: string; + signalType?: AgentSignalType; + tagName?: string; + attributes?: AgentSignalAttributes; + providerOptions?: Record<string, unknown>; + ifActive?: HeartbeatIfActive; + ifIdle?: HeartbeatIfIdle; + metadata?: Record<string, unknown>; + createdAt: number; + updatedAt: number; +} + +/** Input to {@link Heartbeats.create}. */ +export interface CreateHeartbeatInput { + /** + * Optional stable id. Normalized to `hb_<slug>` (the `hb_` prefix is added + * if missing and the rest is slugified). When omitted, a random + * `hb_<uuid>` id is generated. Creating a heartbeat with an id that already + * exists throws. + */ + id?: string; + agentId: string; + cron: string; + prompt: string; + /** Optional free-form label for distinguishing multiple heartbeats on the same agent/thread. */ + name?: string; + timezone?: string; + threadId?: string; + resourceId?: string; + /** Signal category for the fire. Defaults to `'notification'`. */ + signalType?: AgentSignalType; + /** XML tag the signal renders as. Defaults to `'heartbeat'` (so a fire surfaces as `<heartbeat>…</heartbeat>`). */ + tagName?: string; + /** Attributes rendered onto the signal's XML tag. */ + attributes?: AgentSignalAttributes; + /** Provider options merged into the heartbeat signal payload on every fire. JSON-safe. */ + providerOptions?: Record<string, unknown>; + ifActive?: HeartbeatIfActive; + ifIdle?: HeartbeatIfIdle; + metadata?: Record<string, unknown>; + /** Schedule lifecycle status. Defaults to `'active'`. */ + status?: 'active' | 'paused'; +} + +/** Patch input to {@link Heartbeats.update}. */ +export interface UpdateHeartbeatInput { + cron?: string; + timezone?: string; + prompt?: string; + name?: string; + signalType?: AgentSignalType; + tagName?: string; + attributes?: AgentSignalAttributes; + providerOptions?: Record<string, unknown>; + ifActive?: HeartbeatIfActive; + ifIdle?: HeartbeatIfIdle; + metadata?: Record<string, unknown>; + status?: 'active' | 'paused'; +} + +/** Filter for {@link Heartbeats.list}. */ +export interface ListHeartbeatsFilter { + agentId?: string; + threadId?: string; + resourceId?: string; + name?: string; +} + +/** + * Canonical service for the heartbeat use case. Heartbeats are persisted as + * `Schedule` rows with `target.type === 'heartbeat'`; this class is a typed + * projection over `SchedulesStorage` that knows how to build the target, + * filter by `target.type`, and surface the heartbeat-specific fields on a + * flat {@link Heartbeat} view. + * + * Use via `mastra.heartbeats` (the canonical CRUD surface). To scope to a + * single agent, pass `agentId` to `create` / `list`. + */ +export class Heartbeats { + #mastra: Mastra; + + constructor(mastra: Mastra) { + this.#mastra = mastra; + } + + async #getStore() { + const storage = this.#mastra.getStorage(); + const store = await storage?.getStore('schedules'); + if (!store) { + throw new MastraError({ + id: 'HEARTBEATS_NO_SCHEDULES_STORAGE', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + text: 'Heartbeats require a storage adapter that implements the schedules domain.', + }); + } + return store; + } + + async create(input: CreateHeartbeatInput): Promise<Heartbeat> { + validateCron(input.cron, input.timezone); + + if (!input.agentId) { + throw new MastraError({ + id: 'HEARTBEATS_MISSING_AGENT_ID', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + text: 'createHeartbeat requires `agentId`.', + }); + } + + if (input.threadId && !input.resourceId) { + throw new MastraError({ + id: 'HEARTBEATS_MISSING_RESOURCE_ID', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + text: 'createHeartbeat requires `resourceId` when `threadId` is set.', + }); + } + if (!input.threadId) { + const offenders: string[] = []; + if (input.signalType !== undefined) offenders.push('signalType'); + if (input.ifActive !== undefined) offenders.push('ifActive'); + if (input.ifIdle !== undefined) offenders.push('ifIdle'); + if (input.resourceId !== undefined) offenders.push('resourceId'); + if (offenders.length > 0) { + throw new MastraError({ + id: 'HEARTBEATS_THREADLESS_OPTIONS', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + text: `createHeartbeat: ${offenders.join(', ')} require a threadId.`, + }); + } + } + + const store = await this.#getStore(); + // Make sure the scheduler + heartbeat worker are running. Boot-time + // detection covers existing rows; imperative creates after + // startWorkers() need to flip the request flag and lazily inject. + await this.#mastra.__ensureHeartbeatRuntimeReady(); + + const id = input.id !== undefined ? normalizeHeartbeatId(input.id) : `${HEARTBEAT_SCHEDULE_PREFIX}${randomUUID()}`; + if (input.id !== undefined) { + const existing = await store.getSchedule(id); + if (existing) { + throw new MastraError({ + id: 'HEARTBEATS_ID_EXISTS', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + text: `createHeartbeat: a heartbeat with id "${id}" already exists. Use update() to modify it or choose a different id.`, + }); + } + } + const now = Date.now(); + const nextFireAt = computeNextFireAt(input.cron, { timezone: input.timezone, after: now }); + + const target: HeartbeatTarget = { + type: 'heartbeat', + agentId: input.agentId, + prompt: input.prompt, + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.threadId ? { threadId: input.threadId } : {}), + ...(input.resourceId ? { resourceId: input.resourceId } : {}), + ...(input.signalType ? { signalType: input.signalType } : {}), + ...(input.tagName ? { tagName: input.tagName } : {}), + ...(input.attributes ? { attributes: input.attributes } : {}), + ...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), + ...(input.ifActive ? { ifActive: input.ifActive } : {}), + ...(input.ifIdle ? { ifIdle: input.ifIdle } : {}), + }; + + const schedule: Schedule = { + id, + target, + cron: input.cron, + timezone: input.timezone, + status: input.status ?? 'active', + nextFireAt, + createdAt: now, + updatedAt: now, + ownerType: 'agent', + ownerId: input.agentId, + ...(input.metadata ? { metadata: input.metadata } : {}), + }; + + const created = await store.createSchedule(schedule); + return toHeartbeat(created)!; + } + + async get(id: string): Promise<Heartbeat | null> { + const store = await this.#getStore(); + const resolvedId = resolveHeartbeatId(id); + const schedule = await store.getSchedule(resolvedId); + if (!schedule) return null; + return toHeartbeat(schedule); + } + + async list(filter?: ListHeartbeatsFilter): Promise<Heartbeat[]> { + const store = await this.#getStore(); + const schedules = await store.listSchedules({ + ownerType: 'agent', + ...(filter?.agentId ? { ownerId: filter.agentId } : {}), + }); + const heartbeats = schedules.map(toHeartbeat).filter((h): h is Heartbeat => h !== null); + return heartbeats.filter(h => { + if (filter?.threadId !== undefined && h.threadId !== filter.threadId) return false; + if (filter?.resourceId !== undefined && h.resourceId !== filter.resourceId) return false; + if (filter?.name !== undefined && h.name !== filter.name) return false; + return true; + }); + } + + async update(id: string, patch: UpdateHeartbeatInput): Promise<Heartbeat> { + const store = await this.#getStore(); + const resolvedId = resolveHeartbeatId(id); + const existing = await store.getSchedule(resolvedId); + if (!existing || existing.target?.type !== 'heartbeat') { + throw new MastraError({ + id: 'HEARTBEATS_NOT_FOUND', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + text: `Heartbeat "${id}" not found.`, + }); + } + + const nextCron = patch.cron ?? existing.cron; + const nextTimezone = patch.timezone !== undefined ? patch.timezone : existing.timezone; + if (patch.cron !== undefined || patch.timezone !== undefined) { + validateCron(nextCron, nextTimezone); + } + + const existingTarget = existing.target as HeartbeatTarget; + + // Threadless heartbeats run `agent.generate` in isolation, so thread-scoped + // signal options are meaningless and would be silently ignored on every + // fire. `create()` rejects them upfront; mirror that here so `update()` + // can't sneak the same invalid state onto a threadless heartbeat after the + // fact. `threadId`/`resourceId` are not patchable, so the thread-ness of a + // heartbeat is fixed at create time. + if (!existingTarget.threadId) { + const offenders: string[] = []; + if (patch.signalType !== undefined) offenders.push('signalType'); + if (patch.ifActive !== undefined) offenders.push('ifActive'); + if (patch.ifIdle !== undefined) offenders.push('ifIdle'); + if (offenders.length > 0) { + throw new MastraError({ + id: 'HEARTBEATS_THREADLESS_OPTIONS', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + text: `updateHeartbeat: ${offenders.join(', ')} require a threadId.`, + }); + } + } + + const nextTarget: HeartbeatTarget = { + ...existingTarget, + ...(patch.prompt !== undefined ? { prompt: patch.prompt } : {}), + ...(patch.name !== undefined ? { name: patch.name } : {}), + ...(patch.signalType !== undefined ? { signalType: patch.signalType } : {}), + ...(patch.tagName !== undefined ? { tagName: patch.tagName } : {}), + ...(patch.attributes !== undefined ? { attributes: patch.attributes } : {}), + ...(patch.providerOptions !== undefined ? { providerOptions: patch.providerOptions } : {}), + ...(patch.ifActive !== undefined ? { ifActive: patch.ifActive } : {}), + ...(patch.ifIdle !== undefined ? { ifIdle: patch.ifIdle } : {}), + }; + + // Recompute the next fire when the cadence changes OR when this patch + // resumes a paused heartbeat. Resuming must follow the same semantics as + // resume(): a paused row carries a stale nextFireAt (often in the past), + // so flipping status back to 'active' without recomputing would trigger + // an immediate spurious fire instead of waiting for the next cron tick. + const resuming = patch.status === 'active' && existing.status === 'paused'; + const nextFireAt = + patch.cron !== undefined || patch.timezone !== undefined || resuming + ? computeNextFireAt(nextCron, { timezone: nextTimezone, after: Date.now() }) + : undefined; + + const updated = await store.updateSchedule(resolvedId, { + ...(patch.cron !== undefined ? { cron: patch.cron } : {}), + ...(patch.timezone !== undefined ? { timezone: patch.timezone } : {}), + target: nextTarget, + ...(nextFireAt !== undefined ? { nextFireAt } : {}), + ...(patch.metadata !== undefined ? { metadata: patch.metadata } : {}), + ...(patch.status !== undefined ? { status: patch.status } : {}), + }); + return toHeartbeat(updated)!; + } + + async delete(id: string): Promise<void> { + const store = await this.#getStore(); + const resolvedId = resolveHeartbeatId(id); + const existing = await store.getSchedule(resolvedId); + if (!existing) return; + if (existing.target?.type !== 'heartbeat') { + throw new MastraError({ + id: 'HEARTBEATS_NOT_A_HEARTBEAT', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + text: `Schedule "${id}" is not a heartbeat.`, + }); + } + await store.deleteSchedule(resolvedId); + } + + async pause(id: string): Promise<Heartbeat> { + const store = await this.#getStore(); + const resolvedId = resolveHeartbeatId(id); + const existing = await store.getSchedule(resolvedId); + if (!existing || existing.target?.type !== 'heartbeat') { + throw new MastraError({ + id: 'HEARTBEATS_NOT_FOUND', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + text: `Heartbeat "${id}" not found.`, + }); + } + if (existing.status === 'paused') return toHeartbeat(existing)!; + const updated = await store.updateSchedule(resolvedId, { status: 'paused' }); + return toHeartbeat(updated)!; + } + + async resume(id: string): Promise<Heartbeat> { + const store = await this.#getStore(); + const resolvedId = resolveHeartbeatId(id); + const existing = await store.getSchedule(resolvedId); + if (!existing || existing.target?.type !== 'heartbeat') { + throw new MastraError({ + id: 'HEARTBEATS_NOT_FOUND', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + text: `Heartbeat "${id}" not found.`, + }); + } + if (existing.status === 'active') return toHeartbeat(existing)!; + const nextFireAt = computeNextFireAt(existing.cron, { + timezone: existing.timezone, + after: Date.now(), + }); + const updated = await store.updateSchedule(resolvedId, { status: 'active', nextFireAt }); + return toHeartbeat(updated)!; + } + + async run(id: string): Promise<{ scheduleId: string; claimId: string; scheduledFireAt: number }> { + const store = await this.#getStore(); + const resolvedId = resolveHeartbeatId(id); + const existing = await store.getSchedule(resolvedId); + if (!existing || existing.target?.type !== 'heartbeat') { + throw new MastraError({ + id: 'HEARTBEATS_NOT_FOUND', + domain: ErrorDomain.AGENT, + category: ErrorCategory.USER, + text: `Heartbeat "${id}" not found.`, + }); + } + const target = existing.target as HeartbeatTarget; + const now = Date.now(); + const claimId = `manual_${existing.id}_${now}`; + await this.#mastra.pubsub.publish('heartbeats', { + type: 'heartbeat.fire', + runId: claimId, + data: { + scheduleId: existing.id, + claimId, + scheduledFireAt: now, + target, + triggerKind: 'manual', + }, + }); + return { scheduleId: existing.id, claimId, scheduledFireAt: now }; + } +} + +/** + * Project a `Schedule` row to a flat {@link Heartbeat} view. Returns `null` + * when the schedule is not a heartbeat (`target.type !== 'heartbeat'`), + * allowing callers to filter mixed result sets in one pass. + */ +export function toHeartbeat(schedule: Schedule): Heartbeat | null { + if (schedule.target?.type !== 'heartbeat') return null; + const target = schedule.target as HeartbeatTarget; + return { + id: schedule.id, + agentId: target.agentId, + ...(target.name !== undefined ? { name: target.name } : {}), + ...(target.threadId ? { threadId: target.threadId } : {}), + ...(target.resourceId ? { resourceId: target.resourceId } : {}), + prompt: target.prompt, + cron: schedule.cron, + ...(schedule.timezone ? { timezone: schedule.timezone } : {}), + status: schedule.status, + nextFireAt: schedule.nextFireAt, + ...(schedule.lastFireAt !== undefined ? { lastFireAt: schedule.lastFireAt } : {}), + ...(schedule.lastRunId ? { lastRunId: schedule.lastRunId } : {}), + ...(target.signalType ? { signalType: target.signalType } : {}), + ...(target.tagName ? { tagName: target.tagName } : {}), + ...(target.attributes ? { attributes: target.attributes } : {}), + ...(target.providerOptions ? { providerOptions: target.providerOptions } : {}), + ...(target.ifActive ? { ifActive: target.ifActive } : {}), + ...(target.ifIdle ? { ifIdle: target.ifIdle } : {}), + ...(schedule.metadata ? { metadata: schedule.metadata } : {}), + createdAt: schedule.createdAt, + updatedAt: schedule.updatedAt, + }; +} diff --git a/packages/core/src/agent/heartbeat/hooks.test.ts b/packages/core/src/agent/heartbeat/hooks.test.ts new file mode 100644 index 000000000000..a5afa0dd0f8d --- /dev/null +++ b/packages/core/src/agent/heartbeat/hooks.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Mastra } from '../../mastra'; +import type { ScheduleTarget } from '../../storage/domains/schedules/base'; +import type { HeartbeatHooks } from './types'; +import { executeHeartbeat } from './worker'; + +type HeartbeatTarget = Extract<ScheduleTarget, { type: 'heartbeat' }>; + +// Build a `sendSignal` return matching the `accepted` API: a sync object +// carrying `signal` plus an `accepted` promise that resolves to the routing +// decision. `wake`/`deliver` carry a `runId`; `persist`/`discard` never do. +function signalResult( + decision: + | { action: 'wake'; runId: string } + | { action: 'deliver'; runId: string } + | { action: 'persist' } + | { action: 'discard' }, + extra: { persisted?: Promise<void> } = {}, +): any { + const accepted = decision.action === 'wake' ? { ...decision, output: {} } : decision; + return { signal: {}, accepted: Promise.resolve(accepted), ...extra }; +} + +function makeStorage(deleteSchedule = vi.fn().mockResolvedValue(undefined)) { + return { + getStore: vi.fn(async (name: string) => (name === 'schedules' ? { deleteSchedule } : null)), + deleteSchedule, + }; +} + +function makeMastra(opts: { agent?: any; storage?: ReturnType<typeof makeStorage> } = {}) { + const storage = opts.storage ?? makeStorage(); + return { + storage, + getStorage: () => storage, + getAgentById: vi.fn(() => { + if (!opts.agent) throw new Error('not found'); + return opts.agent; + }), + getLogger: () => ({ debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() }), + heartbeats: { + get: vi.fn(async () => null), + }, + __getHeartbeatHooks: () => opts.agent?.__getHeartbeatHooks?.(), + } as unknown as Mastra; +} + +function makeTarget(overrides: Partial<HeartbeatTarget> = {}): HeartbeatTarget { + return { + type: 'heartbeat', + agentId: 'a1', + prompt: 'row prompt', + ...overrides, + } as HeartbeatTarget; +} + +function makeAgent( + opts: { + hooks?: HeartbeatHooks; + sendSignal?: ReturnType<typeof vi.fn>; + generate?: ReturnType<typeof vi.fn>; + threadExists?: boolean; + } = {}, +) { + return { + sendSignal: opts.sendSignal ?? vi.fn(() => signalResult({ action: 'wake', runId: 'run-x' })), + generate: opts.generate ?? vi.fn(async () => ({ runId: 'gen-run', text: 'ok' })), + getMemory: vi.fn(async () => ({ + getThreadById: vi.fn(async () => (opts.threadExists === false ? null : { id: 't1', updatedAt: new Date(0) })), + })), + __getHeartbeatHooks: () => opts.hooks, + }; +} + +describe('HeartbeatWorker — lifecycle hooks', () => { + it('prepare returning overrides changes effective values used by sendSignal', async () => { + const sendSignal: any = vi.fn(() => signalResult({ action: 'wake', runId: 'r1' })); + const prepare = vi.fn(() => ({ threadId: 'new-thread', resourceId: 'new-res', prompt: 'hooked prompt' })); + const onFinish = vi.fn(); + const agent = makeAgent({ hooks: { prepare, onFinish }, sendSignal }); + const mastra = makeMastra({ agent }); + + const result = await executeHeartbeat(mastra, 'hb1', makeTarget({ prompt: 'row prompt' })); + + expect(result.outcome).toBe('succeeded'); + expect(sendSignal).toHaveBeenCalledTimes(1); + const [signal, target] = sendSignal.mock.calls[0]!; + expect(signal.contents).toBe('hooked prompt'); + expect(target.threadId).toBe('new-thread'); + expect(target.resourceId).toBe('new-res'); + expect(onFinish).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: 'succeeded', + runId: 'r1', + effective: expect.objectContaining({ threadId: 'new-thread', prompt: 'hooked prompt' }), + }), + ); + }); + + it('prepare returning null skips the fire and fires onFinish with outcome=skipped', async () => { + const prepare = vi.fn(async () => null); + const onFinish = vi.fn(); + const sendSignal: any = vi.fn(); + const generate = vi.fn(); + const agent = makeAgent({ hooks: { prepare, onFinish }, sendSignal, generate }); + const mastra = makeMastra({ agent }); + + const result = await executeHeartbeat(mastra, 'hb1', makeTarget({ threadId: 't1', resourceId: 'r1' })); + + expect(result.outcome).toBe('skipped'); + expect(sendSignal).not.toHaveBeenCalled(); + expect(generate).not.toHaveBeenCalled(); + expect(onFinish).toHaveBeenCalledWith(expect.objectContaining({ outcome: 'skipped' })); + }); + + it('prepare returning undefined uses row defaults', async () => { + const sendSignal: any = vi.fn(() => signalResult({ action: 'wake', runId: 'r2' })); + const prepare = vi.fn(() => undefined); + const agent = makeAgent({ hooks: { prepare }, sendSignal }); + const mastra = makeMastra({ agent }); + + await executeHeartbeat(mastra, 'hb1', makeTarget({ threadId: 't1', resourceId: 'r1', prompt: 'row prompt' })); + + const [signal] = sendSignal.mock.calls[0]!; + expect(signal.contents).toBe('row prompt'); + }); + + it('prepare throwing triggers onError(phase: prepare) and returns failed', async () => { + const err = new Error('boom'); + const prepare = vi.fn(async () => { + throw err; + }); + const onError = vi.fn(); + const onFinish = vi.fn(); + const sendSignal: any = vi.fn(); + const agent = makeAgent({ hooks: { prepare, onError, onFinish }, sendSignal }); + const mastra = makeMastra({ agent }); + + const result = await executeHeartbeat(mastra, 'hb1', makeTarget({ threadId: 't1', resourceId: 'r1' })); + + expect(result.outcome).toBe('failed'); + expect(sendSignal).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ phase: 'prepare', error: err })); + expect(onFinish).not.toHaveBeenCalled(); + }); + + it('threaded succeeded path emits onFinish(outcome: succeeded)', async () => { + const onFinish = vi.fn(); + const sendSignal: any = vi.fn(() => signalResult({ action: 'wake', runId: 'r3' })); + const agent = makeAgent({ hooks: { onFinish }, sendSignal }); + const mastra = makeMastra({ agent }); + + await executeHeartbeat(mastra, 'hb1', makeTarget({ threadId: 't1', resourceId: 'r1' })); + + expect(onFinish).toHaveBeenCalledWith(expect.objectContaining({ outcome: 'succeeded', runId: 'r3' })); + }); + + it('passes agentId into the hook context (flat hooks, keyed by ctx.agentId)', async () => { + const prepare = vi.fn(() => undefined); + const onFinish = vi.fn(); + const sendSignal: any = vi.fn(() => signalResult({ action: 'wake', runId: 'r5' })); + const agent = makeAgent({ hooks: { prepare, onFinish }, sendSignal }); + const mastra = makeMastra({ agent }); + + await executeHeartbeat(mastra, 'hb1', makeTarget({ agentId: 'a1', threadId: 't1', resourceId: 'r1' })); + + expect(prepare).toHaveBeenCalledWith(expect.objectContaining({ agentId: 'a1' })); + expect(onFinish).toHaveBeenCalledWith(expect.objectContaining({ agentId: 'a1', outcome: 'succeeded' })); + }); + + it('threaded delivered path emits onFinish(outcome: delivered, joinedExistingRun: true)', async () => { + const onFinish = vi.fn(); + const sendSignal: any = vi.fn(() => signalResult({ action: 'deliver', runId: 'r4' })); + const agent = makeAgent({ hooks: { onFinish }, sendSignal }); + const mastra = makeMastra({ agent }); + + const result = await executeHeartbeat(mastra, 'hb1', makeTarget({ threadId: 't1', resourceId: 'r1' })); + + expect(result.outcome).toBe('delivered'); + expect(onFinish).toHaveBeenCalledWith( + expect.objectContaining({ outcome: 'delivered', runId: 'r4', joinedExistingRun: true }), + ); + }); + + it('threaded persisted path emits onFinish(outcome: persisted)', async () => { + const onFinish = vi.fn(); + const sendSignal: any = vi.fn(() => signalResult({ action: 'persist' }, { persisted: Promise.resolve() })); + const agent = makeAgent({ hooks: { onFinish }, sendSignal }); + const mastra = makeMastra({ agent }); + + const result = await executeHeartbeat(mastra, 'hb1', makeTarget({ threadId: 't1', resourceId: 'r1' })); + + expect(result.outcome).toBe('persisted'); + // `persist` carries no runId under the accepted API — the asymmetric union + // only stamps a runId on `wake`/`deliver`. + expect(onFinish).toHaveBeenCalledWith(expect.objectContaining({ outcome: 'persisted', runId: undefined })); + }); + + it('threaded discarded path emits onFinish(outcome: discarded)', async () => { + const onFinish = vi.fn(); + const sendSignal: any = vi.fn(() => signalResult({ action: 'discard' })); + const agent = makeAgent({ hooks: { onFinish }, sendSignal }); + const mastra = makeMastra({ agent }); + + const result = await executeHeartbeat(mastra, 'hb1', makeTarget({ threadId: 't1', resourceId: 'r1' })); + + expect(result.outcome).toBe('discarded'); + // `discard` carries no runId under the accepted API. + expect(onFinish).toHaveBeenCalledWith(expect.objectContaining({ outcome: 'discarded', runId: undefined })); + }); + + it('threadless succeeded path emits onFinish(outcome: succeeded) with result snapshot', async () => { + const onFinish = vi.fn(); + const generate = vi.fn(async () => ({ + runId: 'gen-1', + text: 'reply', + usage: { promptTokens: 5, completionTokens: 7 }, + finishReason: 'stop', + })); + const agent = makeAgent({ hooks: { onFinish }, generate }); + const mastra = makeMastra({ agent }); + + const result = await executeHeartbeat(mastra, 'hb1', makeTarget()); + + expect(result.outcome).toBe('succeeded'); + expect(onFinish).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: 'succeeded', + runId: 'gen-1', + result: { text: 'reply', usage: { promptTokens: 5, completionTokens: 7 }, finishReason: 'stop' }, + }), + ); + }); + + it('threadless agent.generate throwing triggers onError(phase: run)', async () => { + const onError = vi.fn(); + const err = new Error('llm error'); + const generate = vi.fn(async () => { + throw err; + }); + const agent = makeAgent({ hooks: { onError }, generate }); + const mastra = makeMastra({ agent }); + + const result = await executeHeartbeat(mastra, 'hb1', makeTarget()); + + expect(result.outcome).toBe('failed'); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ phase: 'run', error: err })); + }); + + it('threadless aborted run triggers onAbort, not onError', async () => { + const onAbort = vi.fn(); + const onError = vi.fn(); + const abortErr = Object.assign(new Error('aborted'), { name: 'AbortError' }); + const generate = vi.fn(async () => { + throw abortErr; + }); + const agent = makeAgent({ hooks: { onAbort, onError }, generate }); + const mastra = makeMastra({ agent }); + + const result = await executeHeartbeat(mastra, 'hb1', makeTarget()); + + expect(result.outcome).toBe('aborted'); + expect(onAbort).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); + + it('hook exceptions are logged but never recurse or re-route', async () => { + const loggerError = vi.fn(); + const onFinish = vi.fn(() => { + throw new Error('hook boom'); + }); + const sendSignal: any = vi.fn(() => signalResult({ action: 'wake', runId: 'r7' })); + const agent = makeAgent({ hooks: { onFinish }, sendSignal }); + const mastra = makeMastra({ agent }); + + const result = await executeHeartbeat(mastra, 'hb1', makeTarget({ threadId: 't1', resourceId: 'r1' }), { + logger: { error: loggerError }, + }); + + expect(result.outcome).toBe('succeeded'); + expect(loggerError).toHaveBeenCalled(); + }); + + it('no hooks configured → execution still works (regression guard)', async () => { + const sendSignal: any = vi.fn(() => signalResult({ action: 'wake', runId: 'r8' })); + const agent = { + sendSignal, + generate: vi.fn(), + getMemory: vi.fn(async () => ({ getThreadById: vi.fn(async () => ({ id: 't1', updatedAt: new Date(0) })) })), + // __getHeartbeatHooks intentionally omitted + }; + const mastra = makeMastra({ agent }); + + const result = await executeHeartbeat(mastra, 'hb1', makeTarget({ threadId: 't1', resourceId: 'r1' })); + + expect(result.outcome).toBe('succeeded'); + }); + + it('trigger info carries kind=manual when ctx.triggerKind is manual', async () => { + const prepare = vi.fn(() => undefined); + const onFinish = vi.fn(); + const sendSignal: any = vi.fn(() => signalResult({ action: 'wake', runId: 'r9' })); + const agent = makeAgent({ hooks: { prepare, onFinish }, sendSignal }); + const mastra = makeMastra({ agent }); + + await executeHeartbeat(mastra, 'hb1', makeTarget({ threadId: 't1', resourceId: 'r1' }), { + triggerKind: 'manual', + }); + + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ trigger: expect.objectContaining({ kind: 'manual' }) }), + ); + expect(onFinish).toHaveBeenCalledWith( + expect.objectContaining({ trigger: expect.objectContaining({ kind: 'manual' }) }), + ); + }); +}); diff --git a/packages/core/src/agent/heartbeat/index.ts b/packages/core/src/agent/heartbeat/index.ts new file mode 100644 index 000000000000..447abb609aaa --- /dev/null +++ b/packages/core/src/agent/heartbeat/index.ts @@ -0,0 +1,29 @@ +// Public surface: only types/constants. The worker module is loaded via +// `await import('./worker')` from inside `Mastra.startWorkers` to keep +// this barrel out of the `mastra → workflows/evented → agent` cycle. +export { + HEARTBEAT_SCHEDULE_PREFIX, + HeartbeatInputSchema, + HeartbeatOutputSchema, + type HeartbeatInput, + type HeartbeatOutput, + type HeartbeatRunStatus, + type HeartbeatHooks, + type HeartbeatConfig, + type HeartbeatPrepareContext, + type HeartbeatPrepareResult, + type HeartbeatFinishContext, + type HeartbeatErrorContext, + type HeartbeatAbortContext, + type HeartbeatTriggerInfo, + type HeartbeatEffective, + type HeartbeatRunResultSnapshot, +} from './types'; +export { + Heartbeats, + toHeartbeat, + type CreateHeartbeatInput, + type Heartbeat, + type ListHeartbeatsFilter, + type UpdateHeartbeatInput, +} from './heartbeats'; diff --git a/packages/core/src/agent/heartbeat/integration.test.ts b/packages/core/src/agent/heartbeat/integration.test.ts new file mode 100644 index 000000000000..208c084c4c0b --- /dev/null +++ b/packages/core/src/agent/heartbeat/integration.test.ts @@ -0,0 +1,216 @@ +import { MockLanguageModelV2 } from '@internal/ai-sdk-v5/test'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Mastra } from '../../mastra'; +import { MockStore } from '../../storage/mock'; +import { Agent } from '../agent'; + +// Track every Mastra instance created in a test so it is always shut down, +// even if an assertion throws before the test reaches its own shutdown call. +const activeInstances: Mastra[] = []; +function track(mastra: Mastra): Mastra { + activeInstances.push(mastra); + return mastra; +} +afterEach(async () => { + const instances = activeInstances.splice(0, activeInstances.length); + await Promise.all(instances.map(m => m.shutdown().catch(() => {}))); +}); + +async function waitUntil( + predicate: () => boolean | Promise<boolean>, + timeoutMs = 5000, + intervalMs = 25, +): Promise<void> { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await predicate()) return; + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + throw new Error(`waitUntil predicate did not become true within ${timeoutMs}ms`); +} + +async function waitForScheduler(mastra: Mastra): Promise<void> { + await waitUntil(() => mastra.scheduler?.isRunning === true); +} + +function makeAgent(id: string): Agent { + return new Agent({ + id, + name: id, + instructions: 'test', + model: new MockLanguageModelV2({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + text: 'ok', + content: [{ type: 'text', text: 'ok' }], + warnings: [], + }), + }), + }); +} + +describe('Agent heartbeats — scheduler integration', () => { + it('auto-enables the scheduler when create() is called before startWorkers()', async () => { + const agent = makeAgent('beat'); + const storage = new MockStore(); + const mastra = new Mastra({ + logger: false, + storage, + agents: { beat: agent }, + // Heartbeats are imperative — there is no declarative scheduled + // workflow here. The scheduler should still come up because + // creating a heartbeat signals that the scheduler is needed. + // Disable the built-in notification dispatcher so the scheduler is + // not enabled by an unrelated internal scheduled workflow. + notifications: { dispatch: { enabled: false } }, + scheduler: { tickIntervalMs: 50 }, + }); + track(mastra); + + const hb = await mastra.heartbeats.create({ cron: '* * * * * *', prompt: 'ping', agentId: agent.id }); + await mastra.startWorkers(); + await waitForScheduler(mastra); + + const schedulesStore = (await storage.getStore('schedules'))!; + + const initial = (await schedulesStore.getSchedule(hb.id))!; + await waitUntil(async () => { + const current = await schedulesStore.getSchedule(hb.id); + return !!current && current.nextFireAt !== initial.nextFireAt; + }); + // HeartbeatWorker records the trigger after the agent dispatch + // completes, which races with nextFireAt advancement in the scheduler. + await waitUntil(async () => { + const t = await schedulesStore.listTriggers(hb.id); + return t.length > 0; + }); + + const triggers = await schedulesStore.listTriggers(hb.id); + expect(triggers.length).toBeGreaterThan(0); + expect(triggers[0]!.outcome).toBe('succeeded'); + }, 10_000); + + it('lazily injects + starts the scheduler when create() is called after startWorkers()', async () => { + const agent = makeAgent('beat-late'); + const storage = new MockStore(); + const mastra = new Mastra({ + logger: false, + storage, + agents: { 'beat-late': agent }, + notifications: { dispatch: { enabled: false } }, + scheduler: { tickIntervalMs: 50 }, + }); + track(mastra); + + await mastra.startWorkers(); + // No scheduler should be running yet — no declarative scheduled + // workflows, no heartbeats, no explicit enabled flag. + expect(mastra.scheduler).toBeUndefined(); + + const hb = await mastra.heartbeats.create({ cron: '* * * * * *', prompt: 'ping', agentId: agent.id }); + + // create() should have lazily injected + started the scheduler + // and heartbeat workers via __ensureHeartbeatRuntimeReady(). + await waitForScheduler(mastra); + + const schedulesStore = (await storage.getStore('schedules'))!; + + const initial = (await schedulesStore.getSchedule(hb.id))!; + await waitUntil(async () => { + const current = await schedulesStore.getSchedule(hb.id); + return !!current && current.nextFireAt !== initial.nextFireAt; + }); + await waitUntil(async () => { + const t = await schedulesStore.listTriggers(hb.id); + return t.length > 0; + }); + + const triggers = await schedulesStore.listTriggers(hb.id); + expect(triggers.length).toBeGreaterThan(0); + expect(triggers[0]!.outcome).toBe('succeeded'); + }, 10_000); + + it('auto-starts the scheduler and heartbeat worker on boot when heartbeat schedule rows already exist in storage', async () => { + const storage = new MockStore(); + + // Boot 1: create a heartbeat, then shut down without clearing it. This + // simulates a previous process that left a heartbeat row in storage. + { + const agent = makeAgent('beat-rehydrate'); + const mastra = new Mastra({ + logger: false, + storage, + agents: { 'beat-rehydrate': agent }, + notifications: { dispatch: { enabled: false } }, + scheduler: { tickIntervalMs: 50 }, + }); + track(mastra); + await mastra.heartbeats.create({ cron: '* * * * * *', prompt: 'ping', agentId: agent.id }); + await mastra.startWorkers(); + await waitForScheduler(mastra); + await mastra.shutdown(); + } + + // Boot 2: fresh Mastra instance reusing the same storage. The scheduler + // and heartbeat worker must start automatically because storage already + // has a heartbeat row, without anyone calling create() again. + const agent2 = makeAgent('beat-rehydrate'); + const mastra2 = new Mastra({ + logger: false, + storage, + agents: { 'beat-rehydrate': agent2 }, + notifications: { dispatch: { enabled: false } }, + scheduler: { tickIntervalMs: 50 }, + }); + track(mastra2); + + await mastra2.startWorkers(); + + // Scheduler should be running because storage has a heartbeat target. + await waitForScheduler(mastra2); + }, 10_000); + + it('does not start the scheduler when scheduler is explicitly disabled', async () => { + const agent = makeAgent('beat-off'); + const storage = new MockStore(); + const mastra = new Mastra({ + logger: false, + storage, + agents: { 'beat-off': agent }, + notifications: { dispatch: { enabled: false } }, + scheduler: { enabled: false }, + }); + track(mastra); + + await mastra.startWorkers(); + await mastra.heartbeats.create({ cron: '* * * * * *', prompt: 'ping', agentId: agent.id }); + + // Scheduler stays off because the user explicitly disabled it, + // even though create() would normally signal "scheduler needed". + expect(mastra.scheduler).toBeUndefined(); + }); + + it('does not inject heartbeat/scheduler workers when workers are explicitly disabled', async () => { + const agent = makeAgent('beat-no-workers'); + const storage = new MockStore(); + const mastra = new Mastra({ + logger: false, + storage, + agents: { 'beat-no-workers': agent }, + notifications: { dispatch: { enabled: false } }, + // The user opted out of all event processing in this instance. + // A separate standalone worker is expected to run the scheduler. + workers: false, + }); + track(mastra); + + await mastra.startWorkers(); + // create() still persists the heartbeat row so a standalone worker + // can pick it up, but it must not lazily resurrect the scheduler here. + await mastra.heartbeats.create({ cron: '* * * * * *', prompt: 'ping', agentId: agent.id }); + + expect(mastra.scheduler).toBeUndefined(); + }); +}); diff --git a/packages/core/src/agent/heartbeat/types.ts b/packages/core/src/agent/heartbeat/types.ts new file mode 100644 index 000000000000..710a87c28fd3 --- /dev/null +++ b/packages/core/src/agent/heartbeat/types.ts @@ -0,0 +1,274 @@ +import { z } from 'zod/v4'; +import type { AgentSignalAttributes, AgentSignalType } from '../signals'; +import type { AgentSignalActiveBehavior, AgentSignalIdleBehavior } from '../types'; + +/** + * Serializable subset of `AgentExecutionOptions` that a heartbeat persists and + * applies to the woken run. Heartbeat config is JSON-persisted to schedule + * storage, so only JSON-safe fields are accepted here — non-serializable run + * options (callbacks, abort signals, live handles) are excluded by design. + * + * `requestContext` is stored as a plain object and rehydrated into a + * `RequestContext` by the worker before the wake signal runs. This is how a + * heartbeat-woken run receives request context (e.g. channel render context). + */ +export type HeartbeatStreamOptions = { + /** Request context applied to the woken run, stored as a plain object. */ + requestContext?: Record<string, unknown>; +}; + +/** + * Options applied when the target thread is actively streaming. Threaded only. + * Mirrors the signal runtime's `ifActive` options so heartbeats accept the same + * shape `agent.sendSignal` allows. + */ +export type HeartbeatIfActive = { + behavior?: AgentSignalActiveBehavior; + attributes?: AgentSignalAttributes; +}; + +/** + * Options applied when the target thread is idle. Threaded only. Mirrors the + * signal runtime's `ifIdle` options, but `streamOptions` is restricted to the + * serializable {@link HeartbeatStreamOptions} subset so the config can be + * persisted to schedule storage. + */ +export type HeartbeatIfIdle = { + behavior?: AgentSignalIdleBehavior; + attributes?: AgentSignalAttributes; + streamOptions?: HeartbeatStreamOptions; +}; + +/** Stable schedule id prefix for heartbeats. */ +export const HEARTBEAT_SCHEDULE_PREFIX = 'hb_'; + +/** + * Status reported by a single heartbeat run. The {@link HeartbeatWorker} + * derives the scheduler trigger row's `outcome` (`succeeded`, `delivered`, + * `persisted`, `discarded`, `skipped`, `aborted`, or `failed`) from this; + * the status is also surfaced on the trigger row's metadata. + * + * Distinct from `ScheduleTriggerOutcome` (which describes scheduler-level + * dispatch results); this describes what the heartbeat tick itself did. + */ +export type HeartbeatRunStatus = + | 'fired' + | 'signal-accepted' + | 'skipped-thread-blocked' + | 'thread-missing' + | 'agent-missing' + | 'invalid-input'; + +/** Shared zod for {@link AgentSignalAttributes} (XML tag attribute values). */ +const HeartbeatAttributesSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])); + +/** Serializable stream options applied to a woken run. See {@link HeartbeatStreamOptions}. */ +const HeartbeatStreamOptionsSchema = z.object({ + requestContext: z.record(z.string(), z.unknown()).optional(), +}); + +/** Options applied when the target thread is actively streaming. */ +const HeartbeatIfActiveSchema = z.object({ + behavior: z.enum(['deliver', 'persist', 'discard']).optional(), + attributes: HeartbeatAttributesSchema.optional(), +}); + +/** Options applied when the target thread is idle. */ +const HeartbeatIfIdleSchema = z.object({ + behavior: z.enum(['wake', 'persist', 'discard']).optional(), + attributes: HeartbeatAttributesSchema.optional(), + streamOptions: HeartbeatStreamOptionsSchema.optional(), +}); + +/** + * Input payload persisted in `Schedule.target.inputData` for the built-in + * heartbeat workflow. The scheduler tick rehydrates this on every fire. + */ +export const HeartbeatInputSchema = z.object({ + scheduleId: z.string(), + agentId: z.string(), + prompt: z.string(), + threadId: z.string().optional(), + resourceId: z.string().optional(), + signalType: z.enum(['user', 'state', 'reactive', 'notification', 'user-message', 'system-reminder']).optional(), + /** + * XML tag name the signal renders as. Defaults to `heartbeat`, so a fire + * surfaces to the agent as `<heartbeat>…</heartbeat>`. Override to render a + * different tag. + */ + tagName: z.string().optional(), + /** Attributes rendered onto the signal's XML tag. */ + attributes: HeartbeatAttributesSchema.optional(), + /** + * Provider options merged into the heartbeat signal payload on every fire. + * Stored as a plain JSON object (`MastraProviderMetadata` is JSON-safe) and + * applied regardless of `ifActive` / `ifIdle`. + */ + providerOptions: z.record(z.string(), z.unknown()).optional(), + ifActive: HeartbeatIfActiveSchema.optional(), + ifIdle: HeartbeatIfIdleSchema.optional(), +}); + +export type HeartbeatInput = z.infer<typeof HeartbeatInputSchema>; + +export const HeartbeatOutputSchema = z.object({ + status: z.enum([ + 'fired', + 'signal-accepted', + 'skipped-thread-blocked', + 'thread-missing', + 'agent-missing', + 'invalid-input', + ]), + reason: z.string().optional(), +}); + +export type HeartbeatOutput = z.infer<typeof HeartbeatOutputSchema>; + +// --------------------------------------------------------------------------- +// Lifecycle hooks +// +// User-defined callbacks configured via `new Mastra({ heartbeat: { ... } })`. +// A single hook bundle runs for every heartbeat fire; each context carries +// `agentId` so a hook can branch per agent. Mirror the +// `agent.stream` `onFinish`/`onError`/`onAbort` conventions so users learn one +// mental model. `prepare` lets users compute fire-time parameters (e.g. create +// a Slack thread per fire) or skip the fire entirely by returning null. +// --------------------------------------------------------------------------- + +/** Effective parameters the heartbeat worker uses on a single fire. */ +export type HeartbeatEffective = { + threadId?: string; + resourceId?: string; + prompt: string; + signalType?: AgentSignalType; + tagName?: string; + ifActive?: HeartbeatIfActive; + ifIdle?: HeartbeatIfIdle; + attributes?: AgentSignalAttributes; + providerOptions?: Record<string, unknown>; +}; + +/** Trigger context passed to every hook. */ +export type HeartbeatTriggerInfo = { + kind: 'cron' | 'manual'; + firedAt: Date; +}; + +/** Limited terminal-state snapshot for a heartbeat-driven agent run. */ +export type HeartbeatRunResultSnapshot = { + text?: string; + usage?: Record<string, unknown>; + finishReason?: string; +}; + +/** Forward-declared so this file does not import from `./heartbeats`. */ +interface HeartbeatRef { + id: string; + agentId: string; + name?: string; + [key: string]: unknown; +} + +/** Argument passed to `heartbeat.prepare`. */ +export type HeartbeatPrepareContext<TMastra = unknown> = { + mastra: TMastra; + /** The agent this heartbeat fires. Convenience alias for `heartbeat.agentId`. */ + agentId: string; + heartbeat: HeartbeatRef; + trigger: HeartbeatTriggerInfo; +}; + +/** + * Return value from `heartbeat.prepare`. + * + * - object → merged into the row defaults; missing fields fall back to the row + * - `null` → skip this fire (outcome: 'skipped'); the worker records the trigger + * row and fires `onFinish({ outcome: 'skipped' })` + * - `undefined` → use row defaults verbatim + */ +export type HeartbeatPrepareResult = Partial<HeartbeatEffective>; + +/** Argument passed to `heartbeat.onFinish` for any non-error, non-abort outcome. */ +export type HeartbeatFinishContext<TMastra = unknown> = { + mastra: TMastra; + /** The agent this heartbeat fires. Convenience alias for `heartbeat.agentId`. */ + agentId: string; + heartbeat: HeartbeatRef; + trigger: HeartbeatTriggerInfo; + outcome: 'succeeded' | 'delivered' | 'persisted' | 'discarded' | 'skipped'; + /** Present for `succeeded` and `delivered` outcomes. */ + runId?: string; + /** True when `outcome === 'delivered'` and the signal joined an active run. */ + joinedExistingRun?: boolean; + /** Best-effort terminal snapshot; populated for `succeeded` runs. */ + result?: HeartbeatRunResultSnapshot; + effective: HeartbeatEffective; +}; + +/** Argument passed to `heartbeat.onError` whenever `prepare`, `sendSignal`, or the agent run threw. */ +export type HeartbeatErrorContext<TMastra = unknown> = { + mastra: TMastra; + /** The agent this heartbeat fires. Convenience alias for `heartbeat.agentId`. */ + agentId: string; + heartbeat: HeartbeatRef; + trigger: HeartbeatTriggerInfo; + phase: 'prepare' | 'run'; + error: Error; + runId?: string; + /** Best-effort effective view; may be partial if `prepare` threw before merging. */ + effective?: HeartbeatEffective; +}; + +/** Argument passed to `heartbeat.onAbort` when the run was aborted mid-stream. */ +export type HeartbeatAbortContext<TMastra = unknown> = { + mastra: TMastra; + /** The agent this heartbeat fires. Convenience alias for `heartbeat.agentId`. */ + agentId: string; + heartbeat: HeartbeatRef; + trigger: HeartbeatTriggerInfo; + runId: string; + effective: HeartbeatEffective; +}; + +/** + * Bundle of lifecycle hooks. A single bundle runs for every heartbeat fire; + * each context carries `agentId` so a hook can branch per agent. + * + * `onFinish` fires once per heartbeat trigger when the trigger reached a + * non-error, non-abort terminal state. `onError` fires when `prepare`, + * `sendSignal`, or the agent run threw. `onAbort` fires when the run was + * aborted mid-stream. `prepare` can return overrides, `null` to skip, or + * `undefined` to use row defaults. + * + * Hook exceptions are caught and logged; they never re-route the worker or + * recurse into another hook. + */ +export type HeartbeatHooks<TMastra = unknown> = { + prepare?: ( + ctx: HeartbeatPrepareContext<TMastra>, + ) => Promise<HeartbeatPrepareResult | null | undefined> | HeartbeatPrepareResult | null | undefined; + onFinish?: (ctx: HeartbeatFinishContext<TMastra>) => Promise<void> | void; + onError?: (ctx: HeartbeatErrorContext<TMastra>) => Promise<void> | void; + onAbort?: (ctx: HeartbeatAbortContext<TMastra>) => Promise<void> | void; +}; + +/** + * Heartbeat runtime configuration passed to the Mastra constructor via + * `heartbeat`. Holds a single lifecycle hook bundle that runs for every + * heartbeat fire. Hooks live at the Mastra level so they apply to both + * code-defined and stored agents (stored agents cannot define functions in + * their serialized config). Each hook context carries `agentId`, so branch + * on it when a hook should behave differently per agent. + * + * @example + * ```typescript + * new Mastra({ + * heartbeat: { + * prepare: async ({ agentId, heartbeat }) => ({ threadId: '...' }), + * onFinish: async ({ agentId, trigger }) => { ... }, + * }, + * }); + * ``` + */ +export type HeartbeatConfig<TMastra = unknown> = HeartbeatHooks<TMastra>; diff --git a/packages/core/src/agent/heartbeat/worker.test.ts b/packages/core/src/agent/heartbeat/worker.test.ts new file mode 100644 index 000000000000..a6caa81170cc --- /dev/null +++ b/packages/core/src/agent/heartbeat/worker.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Mastra } from '../../mastra'; +import type { ScheduleTarget } from '../../storage/domains/schedules/base'; +import { executeHeartbeat } from './worker'; + +type HeartbeatTarget = Extract<ScheduleTarget, { type: 'heartbeat' }>; + +// Build a `sendSignal` return matching the `accepted` API: a sync object +// carrying `signal` plus an `accepted` promise that resolves to the routing +// decision. `wake`/`deliver` carry a `runId`; `persist`/`discard` never do. +function signalResult( + decision: + | { action: 'wake'; runId: string } + | { action: 'deliver'; runId: string } + | { action: 'persist' } + | { action: 'discard' } + | { action: 'blocked'; reason: 'thread-blocked'; runId: string }, + extra: { persisted?: Promise<void> } = {}, +): any { + const accepted = decision.action === 'wake' ? { ...decision, output: {} } : decision; + return { signal: {}, accepted: Promise.resolve(accepted), ...extra }; +} + +function makeStorage(deleteSchedule = vi.fn().mockResolvedValue(undefined)) { + return { + getStore: vi.fn(async (name: string) => (name === 'schedules' ? { deleteSchedule } : null)), + deleteSchedule, + }; +} + +function makeMastra( + opts: { + agent?: any; + storage?: ReturnType<typeof makeStorage>; + agentThrows?: boolean; + } = {}, +) { + const storage = opts.storage ?? makeStorage(); + return { + storage, + getStorage: () => storage, + getAgentById: vi.fn(() => { + if (opts.agentThrows) throw new Error('not found'); + if (!opts.agent) throw new Error('not found'); + return opts.agent; + }), + getLogger: () => ({ debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() }), + } as unknown as Mastra; +} + +function makeTarget(overrides: Partial<HeartbeatTarget> = {}): HeartbeatTarget { + return { + type: 'heartbeat', + agentId: 'a1', + prompt: 'check in', + ...overrides, + } as HeartbeatTarget; +} + +describe('HeartbeatWorker — executeHeartbeat', () => { + it('returns agent-missing and self-cleans when the agent is unregistered', async () => { + const storage = makeStorage(); + const mastra = makeMastra({ agentThrows: true, storage }); + + const result = await executeHeartbeat(mastra, 'hb_a1', makeTarget()); + + expect(result.status).toBe('agent-missing'); + expect(storage.deleteSchedule).toHaveBeenCalledWith('hb_a1'); + }); + + it('returns thread-missing and self-cleans when the thread is not found', async () => { + const storage = makeStorage(); + const sendSignal = vi.fn(); + const agent = { + sendSignal, + generate: vi.fn(), + getMemory: vi.fn(async () => ({ + getThreadById: vi.fn(async () => null), + })), + }; + const mastra = makeMastra({ agent, storage }); + + const result = await executeHeartbeat(mastra, 'hb_a1', makeTarget({ threadId: 't1', resourceId: 'r1' })); + + expect(result.status).toBe('thread-missing'); + expect(storage.deleteSchedule).toHaveBeenCalledWith('hb_a1'); + expect(sendSignal).not.toHaveBeenCalled(); + }); + + it('rejects threaded input that omits resourceId', async () => { + const agent = { sendSignal: vi.fn(), generate: vi.fn(), getMemory: vi.fn() }; + const mastra = makeMastra({ agent }); + + const result = await executeHeartbeat(mastra, 'hb_a1', makeTarget({ threadId: 't1' })); + + expect(result.status).toBe('invalid-input'); + expect(agent.sendSignal).not.toHaveBeenCalled(); + }); + + it('calls sendSignal with defaults when threaded', async () => { + const sendSignal: any = vi.fn(() => signalResult({ action: 'wake', runId: 'run-1' })); + const agent = { + sendSignal, + generate: vi.fn(), + getMemory: vi.fn(async () => ({ + getThreadById: vi.fn(async () => ({ id: 't1', updatedAt: new Date(Date.now() - 60_000) })), + })), + }; + const mastra = makeMastra({ agent }); + + const result = await executeHeartbeat( + mastra, + 'hb_a1', + makeTarget({ threadId: 't1', resourceId: 'r1', prompt: 'ping' }), + ); + + expect(result.status).toBe('signal-accepted'); + expect(sendSignal).toHaveBeenCalledTimes(1); + const [signal, target] = sendSignal.mock.calls[0]!; + expect(signal).toMatchObject({ + type: 'notification', + tagName: 'heartbeat', + contents: 'ping', + providerOptions: { mastra: { heartbeat: { scheduleId: 'hb_a1', threadId: 't1' } } }, + }); + expect(target).toMatchObject({ + threadId: 't1', + resourceId: 'r1', + }); + expect(target.ifActive).toBeUndefined(); + expect(target.ifIdle).toBeUndefined(); + }); + + it('forwards signalType, ifActive, ifIdle to sendSignal', async () => { + const sendSignal: any = vi.fn(() => signalResult({ action: 'deliver', runId: 'run-2' })); + const agent = { + sendSignal, + generate: vi.fn(), + getMemory: vi.fn(async () => ({ + getThreadById: vi.fn(async () => ({ id: 't1', updatedAt: new Date(0) })), + })), + }; + const mastra = makeMastra({ agent }); + + await executeHeartbeat( + mastra, + 'hb_a1', + makeTarget({ + threadId: 't1', + resourceId: 'r1', + signalType: 'system-reminder', + ifActive: { behavior: 'deliver', attributes: { source: 'cron' } }, + ifIdle: { behavior: 'persist', attributes: { kind: 'wake' } }, + }), + ); + + const [signal, target] = sendSignal.mock.calls[0]!; + expect(signal.type).toBe('system-reminder'); + expect(signal.tagName).toBe('heartbeat'); + expect(signal.providerOptions).toEqual({ + mastra: { heartbeat: { scheduleId: 'hb_a1', threadId: 't1' } }, + }); + expect(target.ifActive).toEqual({ behavior: 'deliver', attributes: { source: 'cron' } }); + expect(target.ifIdle).toEqual({ behavior: 'persist', attributes: { kind: 'wake' } }); + }); + + it('rehydrates ifIdle.streamOptions.requestContext into a RequestContext', async () => { + const sendSignal: any = vi.fn(() => signalResult({ action: 'wake', runId: 'run-3' })); + const agent = { + sendSignal, + generate: vi.fn(), + getMemory: vi.fn(async () => ({ + getThreadById: vi.fn(async () => ({ id: 't1', updatedAt: new Date(0) })), + })), + }; + const mastra = makeMastra({ agent }); + + await executeHeartbeat( + mastra, + 'hb_a1', + makeTarget({ + threadId: 't1', + resourceId: 'r1', + ifIdle: { behavior: 'wake', streamOptions: { requestContext: { channel: 'slack', foo: 1 } } }, + }), + ); + + const [, target] = sendSignal.mock.calls[0]!; + expect(target.ifIdle.behavior).toBe('wake'); + const rc = target.ifIdle.streamOptions.requestContext; + expect(rc.get('channel')).toBe('slack'); + expect(rc.get('foo')).toBe(1); + }); + + it('forwards stored providerOptions on the signal payload merged with heartbeat run metadata', async () => { + const sendSignal: any = vi.fn(() => signalResult({ action: 'deliver', runId: 'run-4' })); + const agent = { + sendSignal, + generate: vi.fn(), + getMemory: vi.fn(async () => ({ + getThreadById: vi.fn(async () => ({ id: 't1', updatedAt: new Date(0) })), + })), + }; + const mastra = makeMastra({ agent }); + + await executeHeartbeat( + mastra, + 'hb_a1', + makeTarget({ + threadId: 't1', + resourceId: 'r1', + providerOptions: { openai: { store: true } }, + }), + ); + + const [signal] = sendSignal.mock.calls[0]!; + expect(signal.providerOptions).toEqual({ + openai: { store: true }, + mastra: { heartbeat: { scheduleId: 'hb_a1', threadId: 't1' } }, + }); + }); + + it('reports skipped-thread-blocked when the signal targets a suspended thread', async () => { + const sendSignal: any = vi.fn(() => + signalResult({ action: 'blocked', reason: 'thread-blocked', runId: 'run-blocked' }), + ); + const agent = { + sendSignal, + generate: vi.fn(), + getMemory: vi.fn(async () => ({ + getThreadById: vi.fn(async () => ({ id: 't1', updatedAt: new Date(0) })), + })), + }; + const mastra = makeMastra({ agent }); + + const result = await executeHeartbeat(mastra, 'hb_a1', makeTarget({ threadId: 't1', resourceId: 'r1' })); + + expect(result.status).toBe('skipped-thread-blocked'); + expect(result.outcome).toBe('skipped'); + expect(result.runId).toBe('run-blocked'); + }); + + it('calls agent.generate in threadless mode', async () => { + const generate = vi.fn(async () => ({})); + const sendSignal = vi.fn(); + const agent = { sendSignal, generate, getMemory: vi.fn() }; + const mastra = makeMastra({ agent }); + + const result = await executeHeartbeat(mastra, 'hb_a1', makeTarget({ prompt: 'tick' })); + + expect(result.status).toBe('fired'); + const call = generate.mock.calls[0] as any[]; + expect(call[0]).toBe('tick'); + expect(call[1].providerOptions).toEqual({ + mastra: { heartbeat: { scheduleId: 'hb_a1' } }, + }); + expect(sendSignal).not.toHaveBeenCalled(); + }); + + it('does not self-clean on regular outcomes', async () => { + const storage = makeStorage(); + const agent = { sendSignal: vi.fn(), generate: vi.fn(async () => ({})), getMemory: vi.fn() }; + const mastra = makeMastra({ agent, storage }); + + await executeHeartbeat(mastra, 'hb_a1', makeTarget()); + expect(storage.deleteSchedule).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/agent/heartbeat/worker.ts b/packages/core/src/agent/heartbeat/worker.ts new file mode 100644 index 000000000000..cf86e1ac1fb1 --- /dev/null +++ b/packages/core/src/agent/heartbeat/worker.ts @@ -0,0 +1,682 @@ +import type { Event, EventCallback } from '../../events/types'; +import type { Mastra } from '../../mastra'; +import { RequestContext } from '../../request-context'; +import type { ScheduleTarget } from '../../storage/domains/schedules/base'; +import { PullTransport } from '../../worker/transport/pull-transport'; +import type { WorkerTransport } from '../../worker/transport/transport'; +import { MastraWorker } from '../../worker/worker'; +import type { WorkerDeps } from '../../worker/worker'; +import type { AgentSignalIfIdleOptions } from '../types'; +import type { + HeartbeatEffective, + HeartbeatHooks, + HeartbeatIfIdle, + HeartbeatPrepareContext, + HeartbeatPrepareResult, + HeartbeatRunStatus, + HeartbeatTriggerInfo, +} from './types'; + +/** PubSub topic on which the scheduler publishes `heartbeat.fire` events. */ +export const TOPIC_HEARTBEATS = 'heartbeats'; + +const DEFAULT_GROUP = 'mastra-heartbeats'; + +export interface HeartbeatWorkerConfig { + group?: string; +} + +export interface HeartbeatFireEventData { + scheduleId: string; + claimId: string; + scheduledFireAt: number; + target: Extract<ScheduleTarget, { type: 'heartbeat' }>; + /** Defaults to `'schedule-fire'`. `'manual'` for fire-now invocations. */ + triggerKind?: 'schedule-fire' | 'manual'; +} + +/** + * Consumes `heartbeat.fire` events published by the scheduler and runs + * the configured agent — either by `sendSignal` (threaded) or + * `agent.generate` (threadless). Mirrors `OrchestrationWorker`'s + * subscribe-on-start / unsubscribe-on-stop lifecycle. + * + * Records the schedule trigger after dispatching so the trigger row + * carries the agent's runId (not just the scheduler's claim id), + * letting the UI link triggers to real agent runs. + */ +export class HeartbeatWorker extends MastraWorker { + readonly name = 'heartbeat'; + + #config: HeartbeatWorkerConfig; + #transport?: WorkerTransport; + #pushCb?: EventCallback; + #running = false; + + constructor(config: HeartbeatWorkerConfig = {}) { + super(); + this.#config = config; + } + + async init(deps: WorkerDeps): Promise<void> { + await super.init(deps); + + if (!deps.mastra) { + throw new Error('HeartbeatWorker requires Mastra instance'); + } + } + + async start(): Promise<void> { + if (this.#running) return; + if (!this.deps) throw new Error('HeartbeatWorker: call init() before start()'); + + // Push-only pubsubs (EventEmitter, UnixSocketPubSub) don't support the + // grouped pull subscription a PullTransport requires. They deliver every + // event to every in-process subscriber, so subscribe directly without a + // group — mirroring how Mastra.startWorkers handles workflow events for + // push-only transports instead of running the pull-based worker. + const modes = this.deps.pubsub.supportedModes ?? ['pull']; + if (!modes.includes('pull')) { + const cb: EventCallback = (event, ack, nack) => { + void this.#handleEvent(event, ack, nack); + }; + this.#pushCb = cb; + await this.deps.pubsub.subscribe(TOPIC_HEARTBEATS, cb); + this.#running = true; + return; + } + + const group = this.#config.group ?? DEFAULT_GROUP; + this.#transport = new PullTransport({ + pubsub: this.deps.pubsub, + group, + topic: TOPIC_HEARTBEATS, + logger: this.deps.logger, + }); + + await this.#transport.start({ + route: (event, ack, nack) => this.#handleEvent(event, ack, nack), + }); + + this.#running = true; + } + + async stop(): Promise<void> { + if (!this.#running) return; + try { + if (this.#transport) { + await this.#transport.stop(); + this.#transport = undefined; + } + if (this.#pushCb && this.deps) { + await this.deps.pubsub.unsubscribe(TOPIC_HEARTBEATS, this.#pushCb); + this.#pushCb = undefined; + } + } finally { + this.#running = false; + } + } + + get isRunning(): boolean { + return this.#running; + } + + async #handleEvent(event: Event, ack?: () => Promise<void>, nack?: () => Promise<void>): Promise<void> { + if (event.type !== 'heartbeat.fire') { + // Not ours — ack and ignore. + await ack?.(); + return; + } + + const mastra = this.mastra!; + const payload = event.data as HeartbeatFireEventData; + try { + await this.#dispatch(mastra, payload); + await ack?.(); + } catch (err) { + this.deps?.logger?.error('HeartbeatWorker: error processing heartbeat.fire', { + scheduleId: payload?.scheduleId, + claimId: payload?.claimId, + error: err, + }); + await nack?.(); + } + } + + async #dispatch(mastra: Mastra, data: HeartbeatFireEventData): Promise<void> { + const { scheduleId, claimId, scheduledFireAt, target } = data; + const actualFireAt = Date.now(); + + const result = await executeHeartbeat(mastra, scheduleId, target, { + triggerKind: data.triggerKind ?? 'schedule-fire', + firedAt: new Date(actualFireAt), + logger: this.deps?.logger, + }); + + await this.#recordTrigger({ + scheduleId, + claimId, + scheduledFireAt, + actualFireAt, + outcome: result.outcome, + runId: result.runId, + error: result.reason, + triggerKind: data.triggerKind ?? 'schedule-fire', + }); + } + + async #recordTrigger(args: { + scheduleId: string; + claimId: string; + scheduledFireAt: number; + actualFireAt: number; + outcome: HeartbeatTriggerOutcome; + runId?: string; + error?: string; + triggerKind: 'schedule-fire' | 'manual'; + }): Promise<void> { + const store = await this.deps?.storage.getStore('schedules'); + if (!store) return; + try { + await store.recordTrigger({ + scheduleId: args.scheduleId, + runId: args.runId ?? args.claimId, + scheduledFireAt: args.scheduledFireAt, + actualFireAt: args.actualFireAt, + outcome: args.outcome, + error: args.error, + triggerKind: args.triggerKind, + }); + } catch (err) { + this.deps?.logger?.error('HeartbeatWorker: failed to record trigger', { + scheduleId: args.scheduleId, + claimId: args.claimId, + error: err, + }); + } + } +} + +/** Outcome union written to the schedule trigger row for a heartbeat fire. */ +export type HeartbeatTriggerOutcome = + | 'succeeded' + | 'delivered' + | 'persisted' + | 'discarded' + | 'skipped' + | 'aborted' + | 'failed'; + +/** + * Best-effort delete of the schedule row. Self-clean is best-effort — + * an explicit `heartbeats.delete()` may have raced us. Swallow errors. + */ +async function selfClean(mastra: Mastra, scheduleId: string): Promise<void> { + try { + const store = await mastra.getStorage()?.getStore('schedules'); + if (!store) return; + await store.deleteSchedule(scheduleId); + } catch (error) { + mastra.getLogger?.()?.debug?.('heartbeat self-clean failed', { scheduleId, error }); + } +} + +type LooseLogger = { error?: (message: string, ...args: any[]) => void }; + +/** Optional context the `HeartbeatWorker` passes to `executeHeartbeat`. */ +export interface ExecuteHeartbeatContext { + triggerKind?: 'schedule-fire' | 'manual'; + firedAt?: Date; + logger?: LooseLogger; +} + +/** + * Resolves the agent, runs the user `prepare` hook (if any), applies + * idle filters, and either `sendSignal`s into the target + * thread or runs `agent.generate`. The returned `runId` is the agent + * run id from the SDK call (when a run was actually started), suitable + * for trigger-row linkability. + * + * `outcome` is the final outcome for the schedule trigger row and is + * also the one that drove the `onFinish`/`onError`/`onAbort` hook + * selection. `status` is retained for back-compat with existing tests. + */ +export async function executeHeartbeat( + mastra: Mastra, + scheduleId: string, + target: Extract<ScheduleTarget, { type: 'heartbeat' }>, + ctx: ExecuteHeartbeatContext = {}, +): Promise<{ status: HeartbeatRunStatus; outcome: HeartbeatTriggerOutcome; reason?: string; runId?: string }> { + const { agentId } = target; + const trigger: HeartbeatTriggerInfo = { + kind: ctx.triggerKind === 'manual' ? 'manual' : 'cron', + firedAt: ctx.firedAt ?? new Date(), + }; + const log = ctx.logger ?? mastra.getLogger?.(); + + const agent = (() => { + try { + return mastra.getAgentById(agentId); + } catch { + return null; + } + })(); + if (!agent) { + await selfClean(mastra, scheduleId); + return { + status: 'agent-missing', + outcome: 'failed', + reason: `agent "${agentId}" no longer registered`, + }; + } + + const hooks = + ( + mastra as unknown as { + __getHeartbeatHooks?: () => HeartbeatHooks | null | undefined; + } + ).__getHeartbeatHooks?.() ?? undefined; + + // Build a partial `Heartbeat` view for hook contexts. Best-effort — + // pulls from the live schedule row when available, otherwise from the + // event target. Either way the hook gets `id`, `agentId`, and `name`. + const heartbeatRef = await loadHeartbeatRef(mastra, scheduleId, target); + + const rowDefaults: HeartbeatEffective = buildEffectiveFromTarget(target); + + // 1. prepare hook + let prepared: HeartbeatPrepareResult | null | undefined; + if (hooks?.prepare) { + try { + const prepareCtx: HeartbeatPrepareContext = { + mastra, + agentId, + heartbeat: heartbeatRef, + trigger, + }; + prepared = await hooks.prepare(prepareCtx); + } catch (err) { + await safeHookCall(log, () => + hooks.onError?.({ + mastra, + agentId, + heartbeat: heartbeatRef, + trigger, + phase: 'prepare', + error: err instanceof Error ? err : new Error(String(err)), + effective: rowDefaults, + }), + ); + return { + status: 'invalid-input', + outcome: 'failed', + reason: err instanceof Error ? err.message : String(err), + }; + } + } + + if (prepared === null) { + // Hook explicitly asked to skip this fire. + await safeHookCall(log, () => + hooks?.onFinish?.({ + mastra, + agentId, + heartbeat: heartbeatRef, + trigger, + outcome: 'skipped', + effective: rowDefaults, + }), + ); + return { status: 'fired', outcome: 'skipped' }; + } + + const effective: HeartbeatEffective = mergeEffective(rowDefaults, prepared); + + // Run-level marker carried on the signal / agent run so consumers + // (typing status, UI badges) can detect that this run was + // heartbeat-driven. + const heartbeatRunMeta = { + scheduleId, + ...(effective.threadId ? { threadId: effective.threadId } : {}), + }; + + // 2. threaded vs threadless + if (effective.threadId) { + if (!effective.resourceId) { + const reason = 'resourceId required when threadId is set'; + await safeHookCall(log, () => + hooks?.onError?.({ + mastra, + agentId, + heartbeat: heartbeatRef, + trigger, + phase: 'run', + error: new Error(reason), + effective, + }), + ); + return { status: 'invalid-input', outcome: 'failed', reason }; + } + + const memory = await agent.getMemory(); + if (memory) { + const thread = await memory.getThreadById({ threadId: effective.threadId }); + if (!thread) { + await selfClean(mastra, scheduleId); + const reason = `thread "${effective.threadId}" not found`; + await safeHookCall(log, () => + hooks?.onError?.({ + mastra, + agentId, + heartbeat: heartbeatRef, + trigger, + phase: 'run', + error: new Error(reason), + effective, + }), + ); + return { status: 'thread-missing', outcome: 'failed', reason }; + } + } + + let signalResult; + try { + signalResult = agent.sendSignal( + { + type: effective.signalType ?? 'notification', + tagName: effective.tagName ?? 'heartbeat', + contents: effective.prompt, + ...(effective.attributes ? { attributes: effective.attributes } : {}), + providerOptions: mergeProviderOptions(effective.providerOptions, heartbeatRunMeta), + }, + { + resourceId: effective.resourceId, + threadId: effective.threadId, + ...(effective.ifActive ? { ifActive: effective.ifActive } : {}), + ...(effective.ifIdle ? { ifIdle: buildIfIdleOptions(effective.ifIdle) } : {}), + }, + ); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + await safeHookCall(log, () => + hooks?.onError?.({ + mastra, + agentId, + heartbeat: heartbeatRef, + trigger, + phase: 'run', + error, + effective, + }), + ); + return { status: 'invalid-input', outcome: 'failed', reason: error.message }; + } + + // The signal runtime resolves `accepted` at routing-decision time with the + // concrete action it took. It only *rejects* when the signal could not be + // routed at all (e.g. a misconfigured agent) — generation errors on a woken + // run surface through the run's own stream, never by rejecting here. + let settled; + try { + settled = await signalResult.accepted; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + await safeHookCall(log, () => + hooks?.onError?.({ + mastra, + agentId, + heartbeat: heartbeatRef, + trigger, + phase: 'run', + error, + effective, + }), + ); + return { status: 'invalid-input', outcome: 'failed', reason: error.message }; + } + + const action = settled.action; + // `runId` is present on `wake`/`deliver`/`blocked` — the actions that reference a + // concrete agent run. `persist`/`discard` never produce a run id. + const runId = 'runId' in settled ? settled.runId : undefined; + + if (action === 'deliver') { + await safeHookCall(log, () => + hooks?.onFinish?.({ + mastra, + agentId, + heartbeat: heartbeatRef, + trigger, + outcome: 'delivered', + runId, + joinedExistingRun: true, + effective, + }), + ); + return { status: 'signal-accepted', outcome: 'delivered', runId }; + } + if (action === 'persist') { + // Wait briefly for persist write so the trigger row reflects the truth. + if (signalResult.persisted) { + try { + await signalResult.persisted; + } catch { + // Persist write failure is surfaced via the signal's own machinery. + } + } + await safeHookCall(log, () => + hooks?.onFinish?.({ + mastra, + agentId, + heartbeat: heartbeatRef, + trigger, + outcome: 'persisted', + runId, + effective, + }), + ); + return { status: 'signal-accepted', outcome: 'persisted', runId }; + } + if (action === 'discard') { + await safeHookCall(log, () => + hooks?.onFinish?.({ + mastra, + agentId, + heartbeat: heartbeatRef, + trigger, + outcome: 'discarded', + runId, + effective, + }), + ); + return { status: 'signal-accepted', outcome: 'discarded', runId }; + } + + if (action === 'blocked') { + // The thread was suspended and could not accept an idle wake. Nothing ran + // and nothing was stored; report as skipped so the trigger row is truthful. + await safeHookCall(log, () => + hooks?.onFinish?.({ + mastra, + agentId, + heartbeat: heartbeatRef, + trigger, + outcome: 'skipped', + runId, + effective, + }), + ); + return { status: 'skipped-thread-blocked', outcome: 'skipped', runId }; + } + + // action === 'wake' — a new run was started for this signal. The thread-stream + // runtime drives the run's stream to completion on its own (so the active-run + // record and thread lease release without requiring a consumer here); the + // heartbeat is fire-and-forget for trigger-row purposes. + await safeHookCall(log, () => + hooks?.onFinish?.({ + mastra, + agentId, + heartbeat: heartbeatRef, + trigger, + outcome: 'succeeded', + runId, + effective, + }), + ); + return { status: 'signal-accepted', outcome: 'succeeded', runId }; + } + + // 4. threadless path: agent.generate + try { + const result = await agent.generate(effective.prompt, { + providerOptions: mergeProviderOptions(effective.providerOptions, heartbeatRunMeta), + }); + const runId = extractRunId(result); + await safeHookCall(log, () => + hooks?.onFinish?.({ + mastra, + agentId, + heartbeat: heartbeatRef, + trigger, + outcome: 'succeeded', + runId, + result: extractRunSnapshot(result), + effective, + }), + ); + return { status: 'fired', outcome: 'succeeded', runId }; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + if (isAbortError(error)) { + await safeHookCall(log, () => + hooks?.onAbort?.({ + mastra, + agentId, + heartbeat: heartbeatRef, + trigger, + runId: extractRunId(error) ?? scheduleId, + effective, + }), + ); + return { status: 'fired', outcome: 'aborted' }; + } + await safeHookCall(log, () => + hooks?.onError?.({ + mastra, + agentId, + heartbeat: heartbeatRef, + trigger, + phase: 'run', + error, + effective, + }), + ); + return { status: 'invalid-input', outcome: 'failed', reason: error.message }; + } +} + +function buildEffectiveFromTarget(target: Extract<ScheduleTarget, { type: 'heartbeat' }>): HeartbeatEffective { + return { + threadId: target.threadId, + resourceId: target.resourceId, + prompt: target.prompt, + signalType: target.signalType, + tagName: target.tagName, + attributes: target.attributes, + providerOptions: target.providerOptions, + ifActive: target.ifActive, + ifIdle: target.ifIdle, + }; +} + +/** + * Maps the stored, JSON-safe `ifIdle` config onto the signal API's + * `AgentSignalIfIdleOptions`, rehydrating the plain `streamOptions.requestContext` + * object into a live `RequestContext` before the wake signal runs. + */ +function buildIfIdleOptions(ifIdle: HeartbeatIfIdle): AgentSignalIfIdleOptions { + const requestContext = ifIdle.streamOptions?.requestContext; + return { + ...(ifIdle.behavior ? { behavior: ifIdle.behavior } : {}), + ...(ifIdle.attributes ? { attributes: ifIdle.attributes } : {}), + ...(requestContext + ? { streamOptions: { requestContext: new RequestContext(Object.entries(requestContext)) } } + : {}), + }; +} + +function mergeEffective(base: HeartbeatEffective, overrides: HeartbeatPrepareResult | undefined): HeartbeatEffective { + if (!overrides) return base; + return { + ...base, + ...overrides, + }; +} + +function mergeProviderOptions( + fromHook: Record<string, unknown> | undefined, + heartbeatRunMeta: Record<string, unknown>, +): Record<string, any> { + const base = (fromHook ?? {}) as Record<string, any>; + const baseMastra = (base.mastra ?? {}) as Record<string, unknown>; + return { + ...base, + mastra: { + ...baseMastra, + heartbeat: heartbeatRunMeta, + }, + }; +} + +async function loadHeartbeatRef( + mastra: Mastra, + scheduleId: string, + target: Extract<ScheduleTarget, { type: 'heartbeat' }>, +): Promise<{ id: string; agentId: string; name?: string; [key: string]: unknown }> { + try { + const hb = await mastra.heartbeats.get(scheduleId); + if (hb) return { ...hb }; + } catch { + // ignore — fall back to a minimal projection from the event target + } + return { + id: scheduleId, + agentId: target.agentId, + ...(target.name !== undefined ? { name: target.name } : {}), + }; +} + +async function safeHookCall(logger: LooseLogger | undefined, fn: () => unknown): Promise<void> { + try { + await fn(); + } catch (err) { + logger?.error?.('HeartbeatWorker: hook threw, ignoring', { error: err }); + } +} + +function isAbortError(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const name = (err as { name?: unknown }).name; + return name === 'AbortError'; +} + +function extractRunId(value: unknown): string | undefined { + if (value && typeof value === 'object' && 'runId' in value) { + const runId = (value as { runId?: unknown }).runId; + if (typeof runId === 'string') return runId; + } + return undefined; +} + +function extractRunSnapshot( + value: unknown, +): { text?: string; usage?: Record<string, unknown>; finishReason?: string } | undefined { + if (!value || typeof value !== 'object') return undefined; + const v = value as { text?: unknown; usage?: unknown; finishReason?: unknown }; + const snapshot: { text?: string; usage?: Record<string, unknown>; finishReason?: string } = {}; + if (typeof v.text === 'string') snapshot.text = v.text; + if (v.usage && typeof v.usage === 'object') snapshot.usage = v.usage as Record<string, unknown>; + if (typeof v.finishReason === 'string') snapshot.finishReason = v.finishReason; + return Object.keys(snapshot).length > 0 ? snapshot : undefined; +} diff --git a/packages/core/src/agent/index.ts b/packages/core/src/agent/index.ts index 25a0bd83d8f8..c5af5dcb761c 100644 --- a/packages/core/src/agent/index.ts +++ b/packages/core/src/agent/index.ts @@ -5,8 +5,23 @@ export * from './types'; export * from './signals'; export * from '../signals/signal-provider'; export * from '../signals/webhook-signal-provider'; +export { + HEARTBEAT_SCHEDULE_PREFIX, + HeartbeatInputSchema, + HeartbeatOutputSchema, + Heartbeats, + toHeartbeat, + type HeartbeatInput, + type HeartbeatOutput, + type HeartbeatRunStatus, + type Heartbeat, + type CreateHeartbeatInput, + type UpdateHeartbeatInput, + type ListHeartbeatsFilter, +} from './heartbeat'; export * from './agent'; export * from './utils'; +export * from './fs-routing'; // Note: DurableAgent is NOT re-exported here to avoid circular dependencies. // Import from '@mastra/core/agent/durable' instead: diff --git a/packages/core/src/agent/message-list/message-list.ts b/packages/core/src/agent/message-list/message-list.ts index ff0c780e6fed..db30a56da507 100644 --- a/packages/core/src/agent/message-list/message-list.ts +++ b/packages/core/src/agent/message-list/message-list.ts @@ -78,6 +78,27 @@ function mergeSignalDataParts<T extends { role: string; parts: Array<{ type: str return result; } +function isPlainRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function mergeBackgroundTasks( + existingBgTasks?: Record<string, unknown>, + incomingBgTasks?: Record<string, unknown>, +): Record<string, unknown> | undefined { + if (!existingBgTasks && !incomingBgTasks) { + return undefined; + } + + const merged: Record<string, unknown> = { ...(existingBgTasks ?? {}) }; + for (const [toolCallId, incomingTask] of Object.entries(incomingBgTasks ?? {})) { + const existingTask = merged[toolCallId]; + merged[toolCallId] = + isPlainRecord(existingTask) && isPlainRecord(incomingTask) ? { ...existingTask, ...incomingTask } : incomingTask; + } + return merged; +} + type MessageListAddOptions = { merge?: boolean; }; @@ -1132,13 +1153,12 @@ export class MessageList { const incomingMeta = (metadata ?? {}) as Record<string, unknown>; const existingBgTasks = existingMeta.backgroundTasks as Record<string, unknown> | undefined; const incomingBgTasks = incomingMeta.backgroundTasks as Record<string, unknown> | undefined; + const backgroundTasks = mergeBackgroundTasks(existingBgTasks, incomingBgTasks); msg.content.metadata = { ...existingMeta, ...incomingMeta, - ...(existingBgTasks || incomingBgTasks - ? { backgroundTasks: { ...(existingBgTasks ?? {}), ...(incomingBgTasks ?? {}) } } - : {}), + ...(backgroundTasks ? { backgroundTasks } : {}), }; // Move the message to the response source so it gets @@ -1156,6 +1176,47 @@ export class MessageList { return false; } + public updateMessageMetadataByToolCallId(toolCallId: string, metadata: Record<string, unknown>): boolean { + if (!toolCallId) { + return false; + } + + for (let m = this.messages.length - 1; m >= 0; m--) { + const msg = this.messages[m]!; + if (msg.role !== 'assistant' || !msg.content?.parts) continue; + + const hasToolCall = msg.content.parts.some( + part => part?.type === 'tool-invocation' && part.toolInvocation?.toolCallId === toolCallId, + ); + if (!hasToolCall) continue; + + const existingMeta = (msg.content.metadata ?? {}) as Record<string, unknown>; + const incomingMeta = (metadata ?? {}) as Record<string, unknown>; + const existingBgTasks = existingMeta.backgroundTasks as Record<string, unknown> | undefined; + const incomingBgTasks = incomingMeta.backgroundTasks as Record<string, unknown> | undefined; + const backgroundTasks = mergeBackgroundTasks(existingBgTasks, incomingBgTasks); + + msg.content.metadata = { + ...existingMeta, + ...incomingMeta, + ...(backgroundTasks ? { backgroundTasks } : {}), + }; + + this.lastCreatedAt = Math.max(this.lastCreatedAt || 0, Date.now()); + this.updateLastCreatedAt(msg); + + if (!this.stateManager.isResponseMessage(msg)) { + this.stateManager.removeMessage(msg); + this.stateManager.addToSource(msg, 'response'); + } + + return true; + } + + this.logger?.warn(`updateMessageMetadataByToolCallId: no matching tool call found for toolCallId=${toolCallId}`); + return false; + } + /** * Append a `step-start` boundary to the last assistant message. * This marks the beginning of a new loop iteration so that diff --git a/packages/core/src/agent/message-list/tests/update-tool-invocation.test.ts b/packages/core/src/agent/message-list/tests/update-tool-invocation.test.ts index 8824978456ec..7935c612f220 100644 --- a/packages/core/src/agent/message-list/tests/update-tool-invocation.test.ts +++ b/packages/core/src/agent/message-list/tests/update-tool-invocation.test.ts @@ -80,6 +80,131 @@ describe('MessageList.updateToolInvocation', () => { expect(part.toolInvocation.args).toEqual({ topic: 'TypeScript history', detail: true }); }); + it('should update message metadata by toolCallId without changing the tool invocation', () => { + const messageList = new MessageList(); + + const msg = makeAssistantMessage( + [ + { + type: 'tool-invocation', + toolInvocation: { + state: 'result', + toolCallId: 'tc-background', + toolName: 'research', + args: { query: 'background task' }, + result: 'Background task started. Task ID: task-1.', + }, + }, + ], + 'memory-msg', + ); + msg.content.metadata = { + backgroundTasks: { + 'tc-existing': { + taskId: 'task-existing', + }, + 'tc-background': { + taskId: 'task-1', + suspendedAt: '2026-06-27T00:05:00.000Z', + }, + }, + }; + messageList.add(msg, 'memory'); + messageList.drainUnsavedMessages(); + + const updated = messageList.updateMessageMetadataByToolCallId('tc-background', { + backgroundTasks: { + 'tc-background': { + taskId: 'task-1', + startedAt: '2026-06-27T00:00:00.000Z', + }, + }, + }); + + expect(updated).toBe(true); + + const part = msg.content.parts[0] as any; + expect(part.toolInvocation).toEqual({ + state: 'result', + toolCallId: 'tc-background', + toolName: 'research', + args: { query: 'background task' }, + result: 'Background task started. Task ID: task-1.', + }); + expect(msg.content.metadata).toEqual({ + backgroundTasks: { + 'tc-existing': { + taskId: 'task-existing', + }, + 'tc-background': { + taskId: 'task-1', + suspendedAt: '2026-06-27T00:05:00.000Z', + startedAt: '2026-06-27T00:00:00.000Z', + }, + }, + }); + expect(messageList.drainUnsavedMessages()).toEqual([msg]); + }); + + it('should deep-merge background task metadata when updating a tool invocation', () => { + const messageList = new MessageList(); + + const msg = makeAssistantMessage([ + { + type: 'tool-invocation', + toolInvocation: { + state: 'call', + toolCallId: 'tc-background', + toolName: 'research', + args: { query: 'background task' }, + }, + }, + ]); + msg.content.metadata = { + backgroundTasks: { + 'tc-background': { + taskId: 'task-1', + startedAt: '2026-06-27T00:00:00.000Z', + suspendedAt: '2026-06-27T00:05:00.000Z', + }, + }, + }; + messageList.add(msg, 'response'); + + const updated = messageList.updateToolInvocation( + { + type: 'tool-invocation', + toolInvocation: { + state: 'result', + toolCallId: 'tc-background', + toolName: 'research', + args: {}, + result: { summary: 'done' }, + }, + }, + { + backgroundTasks: { + 'tc-background': { + taskId: 'task-1', + completedAt: '2026-06-27T00:10:00.000Z', + }, + }, + }, + ); + + expect(updated).toBe(true); + expect(msg.content.metadata).toEqual({ + backgroundTasks: { + 'tc-background': { + taskId: 'task-1', + startedAt: '2026-06-27T00:00:00.000Z', + suspendedAt: '2026-06-27T00:05:00.000Z', + completedAt: '2026-06-27T00:10:00.000Z', + }, + }, + }); + }); + it('should move a memory message to response source for re-saving', () => { const messageList = new MessageList(); diff --git a/packages/core/src/agent/signals.ts b/packages/core/src/agent/signals.ts index 5ab002dfe781..63fb67e56aac 100644 --- a/packages/core/src/agent/signals.ts +++ b/packages/core/src/agent/signals.ts @@ -85,6 +85,7 @@ export type AgentSignalDataPart = { acceptedAt?: string; attributes?: AgentSignalAttributes; metadata?: Record<string, unknown>; + providerOptions?: MastraProviderMetadata; }; transient: true; }; @@ -438,6 +439,7 @@ function signalToDataPart(signal: ReturnType<typeof normalizeSignal>, parts: Sig ...(signal.acceptedAt ? { acceptedAt: signal.acceptedAt.toISOString() } : {}), ...(signal.attributes ? { attributes: signal.attributes } : {}), ...(signal.metadata ? { metadata: signal.metadata } : {}), + ...(signal.providerOptions ? { providerOptions: signal.providerOptions } : {}), }, transient: true, }; diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 12a6c28679d7..069053e760fa 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -298,19 +298,6 @@ export type SendAgentNotificationSignalOptions<OUTPUT = unknown> = Extract< */ export type AgentNotificationConfig = { deliveryPolicy?: NotificationDeliveryPolicyConfig; - /** - * Resolves stream options for dispatching a deferred notification to an idle - * thread. Called by the notification dispatcher when a previously-deferred - * notification becomes due and the target thread is idle. - * - * Return the same shape you would pass as `streamOptions` inside - * `ifIdle` when calling `sendNotificationSignal` directly — typically - * includes `requestContext`, `memory`, and model settings. - */ - getNotificationStreamOptions?: (target: { - resourceId: string; - threadId: string; - }) => Record<string, unknown> | Promise<Record<string, unknown> | undefined> | undefined; }; /** @@ -389,9 +376,12 @@ export type StructuredOutputOptionsBase<OUTPUT = {}> = { useAgent?: boolean; /** - * Whether to use system prompt injection instead of native response format to coerce the LLM to respond with json text if the LLM does not natively support structured outputs. + * Whether to use prompt injection instead of native response format to coerce the LLM to respond with JSON text. + * true and 'system' inject JSON instructions into the leading system message. + * 'inline' appends JSON instructions to the latest user message. + * false or omitted uses the provider's native response format. */ - jsonPromptInjection?: boolean; + jsonPromptInjection?: boolean | 'system' | 'inline'; /** * Optional logger instance for structured logging diff --git a/packages/core/src/agent/utils.test.ts b/packages/core/src/agent/utils.test.ts index 040b6c65c7cd..6adcb0f7661b 100644 --- a/packages/core/src/agent/utils.test.ts +++ b/packages/core/src/agent/utils.test.ts @@ -56,6 +56,36 @@ describe('tryGenerateWithJsonFallback', () => { expect(generate.mock.calls[1][1].structuredOutput.jsonPromptInjection).toBe(true); }); + it('preserves explicit inline jsonPromptInjection on the retry', async () => { + const generate = vi + .fn() + .mockResolvedValueOnce({ object: undefined }) + .mockResolvedValueOnce({ object: { decision: 'done' } }); + + const options = { + structuredOutput: { schema: z.object({ decision: z.string() }), jsonPromptInjection: 'inline' }, + } as any; + + await tryGenerateWithJsonFallback(makeAgent(generate), 'prompt', options); + + expect(generate.mock.calls[1][1].structuredOutput.jsonPromptInjection).toBe('inline'); + }); + + it('preserves explicit system jsonPromptInjection on the retry', async () => { + const generate = vi + .fn() + .mockResolvedValueOnce({ object: undefined }) + .mockResolvedValueOnce({ object: { decision: 'done' } }); + + const options = { + structuredOutput: { schema: z.object({ decision: z.string() }), jsonPromptInjection: 'system' }, + } as any; + + await tryGenerateWithJsonFallback(makeAgent(generate), 'prompt', options); + + expect(generate.mock.calls[1][1].structuredOutput.jsonPromptInjection).toBe('system'); + }); + it('preserves the rest of the options on the retry', async () => { const generate = vi .fn() diff --git a/packages/core/src/agent/utils.ts b/packages/core/src/agent/utils.ts index 5b8ba9dd09a0..824de72069c9 100644 --- a/packages/core/src/agent/utils.ts +++ b/packages/core/src/agent/utils.ts @@ -57,7 +57,14 @@ export async function tryGenerateWithJsonFallback<OUTPUT>( console.warn('Error in tryGenerateWithJsonFallback. Attempting fallback.', error); return await agent.generate(prompt, { ...options, - structuredOutput: { ...options.structuredOutput, jsonPromptInjection: true }, + structuredOutput: { + ...options.structuredOutput, + jsonPromptInjection: + options.structuredOutput.jsonPromptInjection === 'inline' || + options.structuredOutput.jsonPromptInjection === 'system' + ? options.structuredOutput.jsonPromptInjection + : true, + }, }); } } @@ -98,7 +105,14 @@ export async function tryStreamWithJsonFallback<OUTPUT extends {}>( console.warn('Error in tryStreamWithJsonFallback. Attempting fallback.', error); const result = await agent.stream(prompt, { ...streamOptions, - structuredOutput: { ...streamOptions.structuredOutput, jsonPromptInjection: true }, + structuredOutput: { + ...streamOptions.structuredOutput, + jsonPromptInjection: + streamOptions.structuredOutput.jsonPromptInjection === 'inline' || + streamOptions.structuredOutput.jsonPromptInjection === 'system' + ? streamOptions.structuredOutput.jsonPromptInjection + : true, + }, }); void onStream?.(result as unknown as Awaited<ReturnType<Agent['stream']>>); return result; diff --git a/packages/core/src/coding-agent/__tests__/coding-agent.test.ts b/packages/core/src/coding-agent/__tests__/coding-agent.test.ts new file mode 100644 index 000000000000..a94eef9f95a0 --- /dev/null +++ b/packages/core/src/coding-agent/__tests__/coding-agent.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { Agent } from '../../agent/agent'; +import { DEFAULT_GOAL_JUDGE_PROMPT } from '../../agent/goal/objective'; +import { LocalFilesystem, LocalSandbox, Workspace } from '../../workspace'; +import type { PromptContext } from '../index'; +import { buildBasePrompt, createCodingAgent } from '../index'; + +const MODEL = 'openai/gpt-4o-mini'; + +function baseConfig(overrides: Partial<Parameters<typeof createCodingAgent>[0]> = {}) { + return { + id: 'test-coding-agent', + name: 'Test Coding Agent', + model: MODEL, + instructions: 'You are a helpful coding assistant.', + tools: {}, + ...overrides, + }; +} + +function promptContext(overrides: Partial<PromptContext> = {}): PromptContext { + return { + projectPath: '/repo', + projectName: 'repo', + platform: 'darwin', + date: '2026-06-30', + mode: 'build', + toolGuidance: '', + ...overrides, + }; +} + +describe('createCodingAgent', () => { + it('returns an Agent', () => { + const agent = createCodingAgent(baseConfig()); + expect(agent).toBeInstanceOf(Agent); + expect(agent.id).toBe('test-coding-agent'); + }); + + it('builds a default local workspace when none is provided', async () => { + const agent = createCodingAgent(baseConfig()); + const workspace = await agent.getWorkspace(); + + expect(workspace).toBeInstanceOf(Workspace); + expect(workspace?.filesystem).toBeInstanceOf(LocalFilesystem); + expect(workspace?.sandbox).toBeInstanceOf(LocalSandbox); + }); + + it('roots the default workspace at basePath', async () => { + const agent = createCodingAgent(baseConfig({ basePath: '/custom/base' })); + const workspace = await agent.getWorkspace(); + + expect((workspace?.sandbox as LocalSandbox).workingDirectory).toBe('/custom/base'); + }); + + it('builds no default workspace when workspace is explicitly undefined', async () => { + const agent = createCodingAgent(baseConfig({ workspace: undefined })); + const workspace = await agent.getWorkspace(); + + expect(workspace).toBeUndefined(); + }); + + it('uses a caller-provided workspace verbatim', async () => { + const custom = new Workspace({ + filesystem: new LocalFilesystem({ basePath: '/somewhere' }), + sandbox: new LocalSandbox({ workingDirectory: '/somewhere' }), + }); + + const agent = createCodingAgent(baseConfig({ workspace: custom })); + const workspace = await agent.getWorkspace(); + + expect(workspace).toBe(custom); + }); + + it('defaults the goal prompt when a goal is configured without one', () => { + const agent = createCodingAgent( + baseConfig({ + goal: { judge: MODEL, maxRuns: 5 }, + }), + ); + expect(agent.__getGoalConfig()?.prompt).toBe(DEFAULT_GOAL_JUDGE_PROMPT); + }); + + it('defaults the goal prompt when prompt is explicitly undefined', () => { + const agent = createCodingAgent( + baseConfig({ + goal: { judge: MODEL, maxRuns: 5, prompt: undefined }, + }), + ); + expect(agent.__getGoalConfig()?.prompt).toBe(DEFAULT_GOAL_JUDGE_PROMPT); + }); + + it('accepts caller-provided signals and error processors', () => { + const agent = createCodingAgent( + baseConfig({ + signals: [], + errorProcessors: [], + }), + ); + expect(agent).toBeInstanceOf(Agent); + }); +}); + +describe('buildBasePrompt', () => { + it('defaults the product name to "Mastra Code"', () => { + const prompt = buildBasePrompt(promptContext()); + expect(prompt).toContain('You are Mastra Code, an interactive CLI coding agent'); + expect(prompt).toContain('Co-Authored-By: Mastra Code <noreply@mastra.ai>'); + }); + + it('parameterizes productName and coAuthorName', () => { + const prompt = buildBasePrompt(promptContext({ productName: 'Acme Coder', coAuthorName: 'Acme Bot' })); + expect(prompt).toContain('You are Acme Coder, an interactive CLI coding agent'); + expect(prompt).toContain('Acme Coder has a goal mode'); + expect(prompt).toContain('Co-Authored-By: Acme Bot <noreply@mastra.ai>'); + }); + + it('includes the model id in the Co-Authored-By line when provided', () => { + const prompt = buildBasePrompt(promptContext({ modelId: 'openai/gpt-4o' })); + expect(prompt).toContain('Co-Authored-By: Mastra Code (openai/gpt-4o) <noreply@mastra.ai>'); + }); + + it('parameterizes the Co-Authored-By email', () => { + const prompt = buildBasePrompt(promptContext({ coAuthorName: 'Acme Bot', coAuthorEmail: 'bot@acme.dev' })); + expect(prompt).toContain('Co-Authored-By: Acme Bot <bot@acme.dev>'); + }); +}); diff --git a/packages/core/src/coding-agent/index.ts b/packages/core/src/coding-agent/index.ts new file mode 100644 index 000000000000..6c9073643717 --- /dev/null +++ b/packages/core/src/coding-agent/index.ts @@ -0,0 +1,146 @@ +import { Agent } from '../agent'; +import { DEFAULT_GOAL_JUDGE_PROMPT } from '../agent/goal/objective'; +import type { AgentConfig } from '../agent/types'; +import { + isBadRequestError, + PrefillErrorHandler, + ProviderHistoryCompat, + StreamErrorRetryProcessor, +} from '../processors'; +import { TaskSignalProvider } from '../signals'; +import { LocalFilesystem, LocalSandbox, Workspace } from '../workspace'; + +export { buildBasePrompt, type PromptContext } from './prompt'; + +/** + * Retry policy for transient network resets (e.g. provider sockets dropping + * mid-stream). Applied centrally to every model call via the default + * `StreamErrorRetryProcessor` so all modes/subagents benefit from a short wait + * before retrying an ECONNRESET. Delay uses exponential backoff: + * `initialDelay * 2^retryCount`, capped at `maxDelay`. + */ +const ECONNRESET_MAX_RETRIES = 2; +const ECONNRESET_RETRY_INITIAL_DELAY_MS = 1000; +const ECONNRESET_RETRY_MAX_DELAY_MS = 30000; + +const ECONNRESET_MESSAGE_PATTERN = /econnreset|socket hang up/i; + +/** + * Matcher for transient network-reset failures. Checks the immediate error for + * an `ECONNRESET` code or a `socket hang up` message. Cause-chain traversal is + * handled by `StreamErrorRetryProcessor.isRetryableStreamError`, which calls + * each matcher at every level of the cause chain. + */ +function isECONNRESETError(error: unknown): boolean { + if (!error) return false; + + const code = typeof error === 'object' && 'code' in error ? (error as { code?: unknown }).code : undefined; + if (typeof code === 'string' && code.toUpperCase() === 'ECONNRESET') return true; + + const message = error instanceof Error ? error.message : undefined; + if (typeof message === 'string' && ECONNRESET_MESSAGE_PATTERN.test(message)) return true; + + return false; +} + +/** + * Builds the portable default error processors: an ECONNRESET + bad-request + * retry policy, prefill-error recovery, and provider-history compatibility. + */ +function defaultErrorProcessors(): NonNullable<AgentConfig['errorProcessors']> { + return [ + new StreamErrorRetryProcessor({ + matchers: [ + { match: isBadRequestError, maxRetries: 1, delayMs: 2000 }, + { + match: isECONNRESETError, + maxRetries: ECONNRESET_MAX_RETRIES, + delayMs: ({ retryCount }) => + Math.min(ECONNRESET_RETRY_INITIAL_DELAY_MS * Math.pow(2, retryCount), ECONNRESET_RETRY_MAX_DELAY_MS), + }, + ], + }), + new PrefillErrorHandler(), + new ProviderHistoryCompat(), + ]; +} + +/** + * Builds a portable default workspace from core's local primitives, rooted at + * `basePath` (defaults to `process.cwd()`). Used when the caller passes no + * `workspace`. + */ +function defaultWorkspace(basePath: string): Workspace { + return new Workspace({ + filesystem: new LocalFilesystem({ basePath }), + sandbox: new LocalSandbox({ workingDirectory: basePath }), + }); +} + +/** + * Configuration for {@link createCodingAgent}. + * + * Most fields are passed straight through to the underlying `Agent`. The + * factory fills portable defaults for the pieces a coding agent always needs — + * a local workspace, the task-list signal provider, network-retry error + * processors, and the goal judge prompt — so a caller can get a working coding + * agent by supplying only `model`, `instructions`, and `tools`. + */ +export interface CreateCodingAgentConfig extends AgentConfig { + /** + * Base path for the default workspace built when `workspace` is omitted. + * @default process.cwd() + */ + basePath?: string; +} + +/** + * Creates a coding agent as a Mastra {@link Agent}, applying portable defaults + * for the workspace, task-list signal, network-retry error processors, and goal + * judge prompt. + * + * Caller-provided values always win: + * - `workspace` is used verbatim when provided; otherwise a {@link Workspace} + * backed by {@link LocalFilesystem}/{@link LocalSandbox} rooted at + * `basePath` (default `process.cwd()`) is built. + * - `signals` is used verbatim when provided; otherwise it defaults to a single + * {@link TaskSignalProvider}. + * - `errorProcessors` is used verbatim when provided; otherwise it defaults to + * the ECONNRESET/bad-request retry stack plus prefill + provider-history + * compatibility processors. + * - `goal.prompt` defaults to {@link DEFAULT_GOAL_JUDGE_PROMPT} when a goal is + * configured without one. + * + * @example + * ```typescript + * import { createCodingAgent } from '@mastra/core/coding-agent'; + * + * const agent = createCodingAgent({ + * id: 'my-coding-agent', + * name: 'My Coding Agent', + * model: 'openai/gpt-5', + * instructions: 'You are a helpful coding assistant.', + * tools: {}, + * }); + * ``` + */ +export function createCodingAgent(config: CreateCodingAgentConfig): Agent { + const { basePath, workspace: _workspace, signals, errorProcessors, goal, ...rest } = config; + + // Distinguish an absent `workspace` key (build the default) from an explicit + // `workspace: undefined` (caller opts out — e.g. when the workspace is wired + // elsewhere, such as at a controller/request-context level). + const workspace = 'workspace' in config ? config.workspace : defaultWorkspace(basePath ?? process.cwd()); + + // Treat an explicit `prompt: undefined` the same as an omitted prompt so the + // documented default is preserved. + const resolvedGoal = goal ? { ...goal, prompt: goal.prompt ?? DEFAULT_GOAL_JUDGE_PROMPT } : undefined; + + return new Agent({ + ...rest, + workspace, + signals: signals ?? [new TaskSignalProvider()], + errorProcessors: errorProcessors ?? defaultErrorProcessors(), + ...(resolvedGoal ? { goal: resolvedGoal } : {}), + }); +} diff --git a/mastracode/src/agents/prompts/base.ts b/packages/core/src/coding-agent/prompt.ts similarity index 88% rename from mastracode/src/agents/prompts/base.ts rename to packages/core/src/coding-agent/prompt.ts index 657ca26b2fb8..85ae831f641b 100644 --- a/mastracode/src/agents/prompts/base.ts +++ b/packages/core/src/coding-agent/prompt.ts @@ -1,6 +1,10 @@ /** - * Base system prompt — shared behavioral instructions for all modes. + * Base system prompt — shared behavioral instructions for a coding agent. * This is the "brain" that makes the agent a good coding assistant. + * + * Product-specific strings (the agent's display name and the commit + * `Co-Authored-By` name) are parameterized via `productName` / `coAuthorName` + * and default to "Mastra Code" so existing callers keep identical output. */ export interface PromptContext { @@ -14,12 +18,21 @@ export interface PromptContext { modelId?: string; activePlan?: { title: string; plan: string; approvedAt: string } | null; toolGuidance: string; + /** Display name used in the prompt header. Default: "Mastra Code". */ + productName?: string; + /** Name used in the commit `Co-Authored-By` line. Default: "Mastra Code". */ + coAuthorName?: string; + /** Email used in the commit `Co-Authored-By` line. Default: "noreply@mastra.ai". */ + coAuthorEmail?: string; } export function buildBasePrompt(ctx: PromptContext): string { const commonBinaries = formatCommonBinaries(ctx.commonBinaries); + const productName = ctx.productName ?? 'Mastra Code'; + const coAuthorName = ctx.coAuthorName ?? 'Mastra Code'; + const coAuthorEmail = ctx.coAuthorEmail ?? 'noreply@mastra.ai'; - return `You are Mastra Code, an interactive CLI coding agent that helps users with software engineering tasks. + return `You are ${productName}, an interactive CLI coding agent that helps users with software engineering tasks. # Environment Working directory: ${ctx.projectPath} @@ -45,7 +58,7 @@ ${ctx.toolGuidance} - Identify existing conventions (naming, structure, error handling) and follow them. ## Goal Mode Awareness -- Mastra Code has a goal mode for longer-running work. A goal is a persistent objective that the agent continues pursuing across turns until a judge decides the goal is complete, should continue, should pause, or should wait for user input. +- ${productName} has a goal mode for longer-running work. A goal is a persistent objective that the agent continues pursuing across turns until a judge decides the goal is complete, should continue, should pause, or should wait for user input. - Users can start goal mode directly with /goal <objective>. In plan mode, plans submitted with the submit_plan tool may also be started as a goal if the user selects that option in the approval UI. - Help users create good goals by making objectives concrete, outcome-focused, verifiable, and bounded. Prefer goals that state the desired end state, relevant constraints, and what proof or verification should be produced. - When writing implementation plans, make them goal-ready: structure steps so they can be carried out autonomously after approval, include clear verification criteria, call out risks/blockers, and avoid vague instructions that would leave the goal judge unable to determine completion. @@ -72,7 +85,7 @@ ${ctx.toolGuidance} Don't commit files likely to contain secrets (\`.env\`, \`*.key\`, \`credentials.json\`). Warn if asked. ## Commits -Write commit messages that explain WHY, not just WHAT. Match the repo's existing style. Include \`Co-Authored-By: Mastra Code${ctx.modelId ? ` (${ctx.modelId})` : ''} <noreply@mastra.ai>\` in the message body. +Write commit messages that explain WHY, not just WHAT. Match the repo's existing style. Include \`Co-Authored-By: ${coAuthorName}${ctx.modelId ? ` (${ctx.modelId})` : ''} <${coAuthorEmail}>\` in the message body. ## Pull Requests Use \`gh pr create\`. Include a summary of what changed and a test plan. Word the pull request title/description to explain the entire unit of work being shipped, worded to explain it to someone who doesn't know anything about the work being shipped. Do not add details of fixes that were needed along the way. diff --git a/packages/core/src/features/index.ts b/packages/core/src/features/index.ts index 5e39cc63ddd5..85b3ab31a4e9 100644 --- a/packages/core/src/features/index.ts +++ b/packages/core/src/features/index.ts @@ -26,4 +26,5 @@ export const coreFeatures = new Set<string>([ 'deploy-diagnosis', 'model-inference-span', 'internal-usage-rollup', + 'json-prompt-injection:inline', ]); diff --git a/packages/core/src/harness/index.ts b/packages/core/src/harness/index.ts index b6bf717d990e..18fcdcc81fec 100644 --- a/packages/core/src/harness/index.ts +++ b/packages/core/src/harness/index.ts @@ -63,7 +63,7 @@ export type { AgentControllerSubagent, AgentControllerSubagentHistoryEntry, AgentControllerThread, - HeartbeatHandler, + IntervalHandler, ModelAuthStatus, ModelUseCountProvider, ModelUseCountTracker, diff --git a/packages/core/src/llm/model/capabilities/anthropic.json b/packages/core/src/llm/model/capabilities/anthropic.json index 0df9cc291573..5ecf945cff82 100644 --- a/packages/core/src/llm/model/capabilities/anthropic.json +++ b/packages/core/src/llm/model/capabilities/anthropic.json @@ -16,6 +16,7 @@ "claude-sonnet-4-20250514", "claude-sonnet-4-5", "claude-sonnet-4-5-20250929", - "claude-sonnet-4-6" + "claude-sonnet-4-6", + "claude-sonnet-5" ] } diff --git a/packages/core/src/llm/model/capabilities/deepinfra.json b/packages/core/src/llm/model/capabilities/deepinfra.json index f098fd84e270..2079be4f9c6a 100644 --- a/packages/core/src/llm/model/capabilities/deepinfra.json +++ b/packages/core/src/llm/model/capabilities/deepinfra.json @@ -7,6 +7,7 @@ "google/gemma-4-26B-A4B-it", "google/gemma-4-31B-it", "moonshotai/Kimi-K2.5", - "moonshotai/Kimi-K2.6" + "moonshotai/Kimi-K2.6", + "moonshotai/Kimi-K2.7-Code" ] } diff --git a/packages/core/src/llm/model/capabilities/gmicloud.json b/packages/core/src/llm/model/capabilities/gmicloud.json index f50cac17be97..8a5100e3ec51 100644 --- a/packages/core/src/llm/model/capabilities/gmicloud.json +++ b/packages/core/src/llm/model/capabilities/gmicloud.json @@ -3,6 +3,7 @@ "anthropic/claude-opus-4.6", "anthropic/claude-opus-4.7", "anthropic/claude-sonnet-4.6", - "moonshotai/Kimi-K2.6" + "moonshotai/Kimi-K2.6", + "moonshotai/kimi-k2.7-code-highspeed" ] } diff --git a/packages/core/src/llm/model/capabilities/inceptron.json b/packages/core/src/llm/model/capabilities/inceptron.json index d7d1d1e1d7e3..d5d85189e34c 100644 --- a/packages/core/src/llm/model/capabilities/inceptron.json +++ b/packages/core/src/llm/model/capabilities/inceptron.json @@ -1,3 +1,3 @@ { - "attachment": ["moonshotai/Kimi-K2.6", "nvidia/llama-3.3-70b-instruct-fp8"] + "attachment": ["moonshotai/Kimi-K2.6", "moonshotai/Kimi-K2.6-Fast", "moonshotai/Kimi-K2.7-Code"] } diff --git a/packages/core/src/llm/model/capabilities/llmgateway.json b/packages/core/src/llm/model/capabilities/llmgateway.json index cf87e51a90e5..06a726094087 100644 --- a/packages/core/src/llm/model/capabilities/llmgateway.json +++ b/packages/core/src/llm/model/capabilities/llmgateway.json @@ -5,6 +5,7 @@ "claude-3-opus", "claude-haiku-4-5", "claude-haiku-4-5-20251001", + "claude-haiku-4-5-free", "claude-opus-4-1-20250805", "claude-opus-4-5-20251101", "claude-opus-4-6", @@ -30,7 +31,6 @@ "gemma-4-31b-it", "glm-4.5v", "glm-4.6v", - "glm-4.6v-flash", "glm-4.6v-flashx", "gpt-4", "gpt-4-turbo", diff --git a/packages/core/src/llm/model/capabilities/neuralwatt.json b/packages/core/src/llm/model/capabilities/neuralwatt.json index c3ad9a198228..8b08e734ae99 100644 --- a/packages/core/src/llm/model/capabilities/neuralwatt.json +++ b/packages/core/src/llm/model/capabilities/neuralwatt.json @@ -3,6 +3,8 @@ "Qwen/Qwen3.6-35B-A3B", "kimi-k2.5-fast", "kimi-k2.6-fast", + "kimi-k2.6-flex", + "kimi-k2.7-code-flex", "moonshotai/Kimi-K2.5", "moonshotai/Kimi-K2.6", "moonshotai/Kimi-K2.7-Code", diff --git a/packages/core/src/llm/model/capabilities/openrouter.json b/packages/core/src/llm/model/capabilities/openrouter.json index 9bd764956fd3..fae34affc90b 100644 --- a/packages/core/src/llm/model/capabilities/openrouter.json +++ b/packages/core/src/llm/model/capabilities/openrouter.json @@ -10,7 +10,6 @@ "anthropic/claude-opus-4.1", "anthropic/claude-opus-4.5", "anthropic/claude-opus-4.6", - "anthropic/claude-opus-4.6-fast", "anthropic/claude-opus-4.7", "anthropic/claude-opus-4.7-fast", "anthropic/claude-opus-4.8", diff --git a/packages/core/src/llm/model/capabilities/sakana.json b/packages/core/src/llm/model/capabilities/sakana.json new file mode 100644 index 000000000000..25bc86357800 --- /dev/null +++ b/packages/core/src/llm/model/capabilities/sakana.json @@ -0,0 +1,3 @@ +{ + "attachment": ["fugu", "fugu-ultra", "fugu-ultra-20260615"] +} diff --git a/packages/core/src/llm/model/capabilities/synthetic.json b/packages/core/src/llm/model/capabilities/synthetic.json index 68c845cd3869..7b73a88160a7 100644 --- a/packages/core/src/llm/model/capabilities/synthetic.json +++ b/packages/core/src/llm/model/capabilities/synthetic.json @@ -1,3 +1,8 @@ { - "attachment": ["hf:MiniMaxAI/MiniMax-M3", "hf:moonshotai/Kimi-K2.6"] + "attachment": [ + "hf:MiniMaxAI/MiniMax-M3", + "hf:Qwen/Qwen3.5-397B-A17B", + "hf:Qwen/Qwen3.6-27B", + "hf:moonshotai/Kimi-K2.6" + ] } diff --git a/packages/core/src/llm/model/capabilities/zeldoc.json b/packages/core/src/llm/model/capabilities/zeldoc.json deleted file mode 100644 index 374661a33a56..000000000000 --- a/packages/core/src/llm/model/capabilities/zeldoc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "attachment": ["z-code"] -} diff --git a/packages/core/src/llm/model/gateway-resolver.ts b/packages/core/src/llm/model/gateway-resolver.ts index b3fd82d5c78f..8057b5437a84 100644 --- a/packages/core/src/llm/model/gateway-resolver.ts +++ b/packages/core/src/llm/model/gateway-resolver.ts @@ -23,6 +23,21 @@ export function parseModelRouterId(routerId: string, gatewayPrefix?: string): { }; } + // Provider-equals-gateway: a gateway whose provider id is the same as its + // gateway id (e.g. amazon-bedrock) uses a 2-part router id (gateway/model), + // because there is no separate provider segment to namespace. Catalog ids + // for such gateways are always two parts (model ids contain no slashes). + if (gatewayPrefix && idParts.length === 2 && idParts[0] === gatewayPrefix) { + const modelId = idParts[1]; + if (!modelId) { + throw new Error(`Expected format ${gatewayPrefix}/model, but got ${routerId}`); + } + return { + providerId: gatewayPrefix, + modelId, + }; + } + // Standard 3-part format for other prefixed gateways (Netlify, etc.) if (gatewayPrefix && idParts.length < 3) { throw new Error( diff --git a/packages/core/src/llm/model/gateways/custom-gateway.test.ts b/packages/core/src/llm/model/gateways/custom-gateway.test.ts index 633eddb46a02..a2049fb945eb 100644 --- a/packages/core/src/llm/model/gateways/custom-gateway.test.ts +++ b/packages/core/src/llm/model/gateways/custom-gateway.test.ts @@ -404,9 +404,9 @@ describe('Custom Gateway Integration', () => { it('should handle gateway resolution errors gracefully', () => { const customGateway = new TestCustomGateway(); - // Invalid model ID format (missing parts) + // Invalid model ID format (missing model part) expect(() => { - new ModelRouterLanguageModel('custom/invalid', [customGateway]); + new ModelRouterLanguageModel('custom/', [customGateway]); }).toThrow(); }); @@ -453,4 +453,90 @@ describe('Custom Gateway Integration', () => { expect(model.modelId).toBe('model-1'); }); }); + + describe('Custom gateway dedup regression', () => { + it('uses the custom gateway when it shadows a default gateway id (first-wins)', () => { + // A custom gateway with the same id as the default Netlify gateway. + // Before the GatewayManager dedup fix, the default would override the + // custom gateway, losing the user's override. Now first-wins dedup keeps + // the custom gateway. + const shadowGateway: MastraModelGatewayInterface = { + id: 'netlify', + name: 'custom-netlify-override', + shouldEnable: () => true, + fetchProviders: vi.fn(async () => ({ + 'shadow-provider': { + name: 'Shadow', + models: ['shadow-model'], + apiKeyEnvVar: 'SHADOW_KEY', + gateway: 'netlify', + url: 'https://shadow.example/v1', + }, + })), + buildUrl: () => 'https://shadow.example/v1', + getApiKey: vi.fn(async () => 'shadow-key'), + resolveAuth: vi.fn(() => ({ apiKey: 'shadow-auth-key', source: 'gateway' as const })), + resolveLanguageModel: vi.fn( + () => + ({ + specificationVersion: 'v2', + doGenerate: vi.fn(), + doStream: vi.fn(), + }) as any, + ), + }; + + const model = new ModelRouterLanguageModel('netlify/shadow-provider/shadow-model', [shadowGateway]); + + // The custom gateway wins over the default Netlify gateway. + expect(model.gatewayId).toBe('netlify'); + expect(model.provider).toBe('shadow-provider'); + expect(model.modelId).toBe('shadow-model'); + expect(shadowGateway.resolveAuth).not.toHaveBeenCalled(); // not called at construction time + }); + + it('does not reserve a gateway id from a disabled custom gateway before an enabled default', async () => { + // A disabled custom gateway with the same id as a default should not + // block the enabled default from being used. Use a netlify-prefixed id + // so resolution depends on the netlify gateway (openai/gpt-4o would + // fall back to models.dev and hide a regression here). + const disabledGateway: MastraModelGatewayInterface = { + id: 'netlify', + name: 'disabled-netlify-override', + shouldEnable: () => false, + fetchProviders: vi.fn(async () => ({})), + buildUrl: () => '', + getApiKey: vi.fn(async () => 'should-not-be-used'), + resolveLanguageModel: vi.fn(() => ({}) as any), + }; + + // Ensure the default Netlify gateway's getApiKey throws predictably + // (missing token) instead of attempting a network token exchange. + const prevToken = process.env['NETLIFY_TOKEN']; + const prevSiteId = process.env['NETLIFY_SITE_ID']; + delete process.env['NETLIFY_TOKEN']; + delete process.env['NETLIFY_SITE_ID']; + + try { + const model = new ModelRouterLanguageModel('netlify/openai/gpt-4o', [disabledGateway]); + + expect(model).toBeDefined(); + expect(model.gatewayId).toBe('netlify'); + + // Driving resolution: the default Netlify gateway is retained (its + // getApiKey throws on the missing token), so resolveLanguageModel is + // never reached. If the disabled gateway were incorrectly retained, + // its getApiKey would return 'should-not-be-used' and + // resolveLanguageModel would be called — failing this assertion. + await model.supportedUrls; + expect(disabledGateway.resolveLanguageModel).not.toHaveBeenCalled(); + expect(disabledGateway.getApiKey).not.toHaveBeenCalled(); + } finally { + if (prevToken !== undefined) process.env['NETLIFY_TOKEN'] = prevToken; + else delete process.env['NETLIFY_TOKEN']; + if (prevSiteId !== undefined) process.env['NETLIFY_SITE_ID'] = prevSiteId; + else delete process.env['NETLIFY_SITE_ID']; + } + }); + }); }); diff --git a/packages/core/src/llm/model/gateways/gateway-manager.test.ts b/packages/core/src/llm/model/gateways/gateway-manager.test.ts index 5ae0e45d74b3..bd27ca1c2f15 100644 --- a/packages/core/src/llm/model/gateways/gateway-manager.test.ts +++ b/packages/core/src/llm/model/gateways/gateway-manager.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; +import { MastraError } from '../../../error/index.js'; import type { GatewayAuthRequest, GatewayAuthResult, @@ -76,6 +77,33 @@ describe('GatewayManager', () => { expect(manager.gateways.map(g => g.id)).not.toContain('off-gateway'); expect(manager.gateways.map(g => g.id)).toContain('on-gateway'); }); + + it('deduplicates by gateway id — first (custom) gateway wins', () => { + const custom = createFakeGateway({ id: 'netlify', provider: 'openai', models: ['custom-model'] }); + const defaultLike = createFakeGateway({ id: 'netlify', provider: 'openai', models: ['default-model'] }); + const manager = new GatewayManager([custom, defaultLike]); + expect(manager.gateways).toHaveLength(1); + expect(manager.gateways[0]).toBe(custom); + }); + + it('removes later duplicates when custom appears before defaults', () => { + const custom = createFakeGateway({ id: 'netlify', provider: 'openai', models: ['custom-model'] }); + const other = createFakeGateway({ id: 'models.dev', provider: 'openai' }); + const duplicate = createFakeGateway({ id: 'netlify', provider: 'openai', models: ['dup-model'] }); + const manager = new GatewayManager([custom, other, duplicate]); + expect(manager.gateways.map(g => g.id)).toEqual(['netlify', 'models.dev']); + expect(manager.gateways[0]).toBe(custom); + }); + + it('does not reserve a gateway id from a disabled gateway before an enabled duplicate', () => { + const disabled = createFakeGateway({ id: 'shared', enabled: false, models: ['disabled-model'] }); + const enabled = createFakeGateway({ id: 'shared', models: ['enabled-model'] }); + const manager = new GatewayManager([disabled, enabled]); + // The disabled gateway is filtered out before dedup, so the enabled + // duplicate is kept — not removed by the disabled one's id. + expect(manager.gateways).toHaveLength(1); + expect(manager.gateways[0]).toBe(enabled); + }); }); describe('getPrefix', () => { @@ -237,6 +265,64 @@ describe('GatewayManager', () => { const manager = new GatewayManager([createFakeGateway({ id: 'test-gateway' })]); expect(await manager.hasAuth('unknown/garbage/model')).toBe(false); }); + + it('returns false when resolveAuth throws a MastraError for a missing API key', async () => { + const gateway = createFakeGateway({ + id: 'test-gateway', + provider: 'acme', + resolveAuth: () => { + throw new MastraError({ + id: 'MASTRA_GATEWAY_NO_API_KEY', + domain: 'LLM', + category: 'UNKNOWN', + text: 'Could not find API key', + }); + }, + }); + const manager = new GatewayManager([gateway]); + expect(await manager.hasAuth('test-gateway/acme/sonic-fast')).toBe(false); + }); + + it('returns false when getApiKey throws a plain Error for a missing env var', async () => { + const gateway = createFakeGateway({ + id: 'test-gateway', + provider: 'acme', + apiKey: () => { + throw new Error('Missing OPENAI_API_KEY environment variable'); + }, + }); + const manager = new GatewayManager([gateway]); + expect(await manager.hasAuth('test-gateway/acme/sonic-fast')).toBe(false); + }); + + it('re-throws unexpected gateway failures (e.g. token exchange)', async () => { + const gateway = createFakeGateway({ + id: 'test-gateway', + provider: 'acme', + resolveAuth: () => { + throw new Error('token exchange failed'); + }, + }); + const manager = new GatewayManager([gateway]); + await expect(manager.hasAuth('test-gateway/acme/sonic-fast')).rejects.toThrow('token exchange failed'); + }); + + it('re-throws unexpected MastraError IDs (e.g. token exchange error)', async () => { + const gateway = createFakeGateway({ + id: 'test-gateway', + provider: 'acme', + resolveAuth: () => { + throw new MastraError({ + id: 'NETLIFY_GATEWAY_TOKEN_ERROR', + domain: 'LLM', + category: 'UNKNOWN', + text: 'token exchange failed', + }); + }, + }); + const manager = new GatewayManager([gateway]); + await expect(manager.hasAuth('test-gateway/acme/sonic-fast')).rejects.toThrow('token exchange failed'); + }); }); describe('listProviders', () => { @@ -316,4 +402,83 @@ describe('GatewayManager', () => { expect(models[0].apiKeyEnvVar).toBe('FIRST_KEY'); }); }); + + describe('provider-equals-gateway (two-part router ids)', () => { + // A gateway whose provider id is the same as its gateway id (e.g. a + // standalone amazon-bedrock gateway) emits two-part catalog ids like + // `amazon-bedrock/<model>` rather than `amazon-bedrock/amazon-bedrock/<model>`. + it('parses a two-part router id when gateway id equals provider id', () => { + const gateway = createFakeGateway({ + id: 'amazon-bedrock', + provider: 'amazon-bedrock', + models: ['anthropic.claude-sonnet-4-5'], + }); + const manager = new GatewayManager([gateway]); + expect(manager.parseModelId('amazon-bedrock/anthropic.claude-sonnet-4-5')).toEqual({ + gatewayId: 'amazon-bedrock', + providerId: 'amazon-bedrock', + modelId: 'anthropic.claude-sonnet-4-5', + }); + }); + + it('listAvailableModels emits unprefixed amazon-bedrock/<model> ids', async () => { + const gateway = createFakeGateway({ + id: 'amazon-bedrock', + provider: 'amazon-bedrock', + models: ['anthropic.claude-sonnet-4-5', 'anthropic.claude-haiku-4-5'], + resolveAuth: () => ({ apiKey: 'aws-credential-chain', source: 'gateway' }), + }); + const manager = new GatewayManager([gateway]); + + const models = await manager.listAvailableModels(); + expect(models.map(m => m.id)).toEqual([ + 'amazon-bedrock/anthropic.claude-sonnet-4-5', + 'amazon-bedrock/anthropic.claude-haiku-4-5', + ]); + expect(models[0]).toMatchObject({ + provider: 'amazon-bedrock', + modelName: 'anthropic.claude-sonnet-4-5', + hasApiKey: true, + }); + }); + + it('resolveAuth calls the gateway with the two-part router id', async () => { + const resolveAuth = vi.fn( + (_req: GatewayAuthRequest): GatewayAuthResult => ({ + apiKey: 'aws-credential-chain', + source: 'gateway', + }), + ); + const gateway = createFakeGateway({ + id: 'amazon-bedrock', + provider: 'amazon-bedrock', + models: ['anthropic.claude-sonnet-4-5'], + resolveAuth, + }); + const manager = new GatewayManager([gateway]); + + const auth = await manager.resolveAuth('amazon-bedrock/anthropic.claude-sonnet-4-5'); + expect(auth.apiKey).toBe('aws-credential-chain'); + expect(resolveAuth).toHaveBeenCalledWith({ + gatewayId: 'amazon-bedrock', + providerId: 'amazon-bedrock', + modelId: 'anthropic.claude-sonnet-4-5', + routerId: 'amazon-bedrock/anthropic.claude-sonnet-4-5', + }); + }); + + it('still parses standard three-part gateway/provider/model ids', () => { + const gateway = createFakeGateway({ + id: 'netlify', + provider: 'anthropic', + models: ['claude-sonnet-4-5'], + }); + const manager = new GatewayManager([gateway]); + expect(manager.parseModelId('netlify/anthropic/claude-sonnet-4-5')).toEqual({ + gatewayId: 'netlify', + providerId: 'anthropic', + modelId: 'claude-sonnet-4-5', + }); + }); + }); }); diff --git a/packages/core/src/llm/model/gateways/gateway-manager.ts b/packages/core/src/llm/model/gateways/gateway-manager.ts index a7c8724716d6..f5201588503d 100644 --- a/packages/core/src/llm/model/gateways/gateway-manager.ts +++ b/packages/core/src/llm/model/gateways/gateway-manager.ts @@ -1,7 +1,45 @@ +import { MastraError } from '../../../error/index.js'; import { parseModelRouterId } from '../gateway-resolver.js'; import type { GatewayAuthRequest, GatewayAuthResult, MastraModelGatewayInterface, ProviderConfig } from './base.js'; import { findGatewayForModel, getGatewayId, shouldEnableGateway } from './gateway-helpers.js'; +/** + * MastraError IDs that represent expected "auth not available" states — + * missing credentials, missing gateway config, or no matching gateway. + * These are safe to surface as `hasAuth === false` for catalog/UI checks. + */ +const MISSING_AUTH_ERROR_IDS = new Set<string>([ + 'MODEL_ROUTER_NO_GATEWAY_FOUND', + 'MASTRA_GATEWAY_NO_API_KEY', + 'NETLIFY_GATEWAY_NO_TOKEN', + 'NETLIFY_GATEWAY_NO_SITE_ID', + 'AZURE_ENTRA_ID_AUTH_NOT_CONFIGURED', +]); + +/** + * Returns true for errors that represent an expected "auth not available" + * state (no matching gateway, missing credentials/env vars, missing provider + * config). Real gateway failures (token exchange errors, network bugs, + * malformed auth hooks) are not matched here so {@link hasAuth} can re-throw + * them instead of silently hiding them. + */ +function isExpectedMissingAuthError(error: unknown): boolean { + if (error instanceof MastraError) { + return MISSING_AUTH_ERROR_IDS.has(error.id); + } + if (error instanceof Error) { + const msg = error.message; + return ( + /Missing [^ ]+ environment variable/i.test(msg) || + /Could not find API key/i.test(msg) || + /no api key/i.test(msg) || + /Could not find config for provider/i.test(msg) || + /Could not identify provider/i.test(msg) + ); + } + return false; +} + /** * A model entry in the gateway catalog (without use-count tracking). */ @@ -32,7 +70,17 @@ export class GatewayManager { readonly gateways: MastraModelGatewayInterface[]; constructor(gateways: MastraModelGatewayInterface[] = []) { - this.gateways = gateways.filter(shouldEnableGateway); + // Filter disabled gateways and deduplicate by gateway ID (first wins). + // Callers pass custom gateways before default gateways, so first-wins + // preserves custom-over-default precedence in a single place. + const seen = new Set<string>(); + this.gateways = gateways.filter(gateway => { + if (!shouldEnableGateway(gateway)) return false; + const id = getGatewayId(gateway); + if (seen.has(id)) return false; + seen.add(id); + return true; + }); } /** @@ -62,11 +110,27 @@ export class GatewayManager { return findGatewayForModel(routerId, this.gateways); } - /** Parse a router id into its provider/model/gateway components. */ - parseModelId(routerId: string): { providerId: string; modelId: string; gatewayId: string } { + /** + * Resolve the gateway and parsed provider/model components for a router id + * in a single pass. Centralises gateway selection + id parsing so callers + * (router constructor, doGenerate/doStream, supportedUrls) don't each + * re-derive the gateway and prefix separately. + */ + resolveModelId(routerId: string): { + gateway: MastraModelGatewayInterface; + gatewayId: string; + providerId: string; + modelId: string; + } { const gateway = this.findGatewayForModel(routerId); const gatewayId = getGatewayId(gateway); const { providerId, modelId } = parseModelRouterId(routerId, GatewayManager.getPrefixForId(gatewayId, routerId)); + return { gateway, gatewayId, providerId, modelId }; + } + + /** Parse a router id into its provider/model/gateway components. */ + parseModelId(routerId: string): { providerId: string; modelId: string; gatewayId: string } { + const { gatewayId, providerId, modelId } = this.resolveModelId(routerId); return { providerId, modelId, gatewayId }; } @@ -80,9 +144,7 @@ export class GatewayManager { * from per-instance config on top of the result returned here. */ async resolveAuth(routerId: string): Promise<GatewayAuthResult> { - const gateway = this.findGatewayForModel(routerId); - const gatewayId = getGatewayId(gateway); - const { providerId, modelId } = parseModelRouterId(routerId, GatewayManager.getPrefixForId(gatewayId, routerId)); + const { gateway, gatewayId, providerId, modelId } = this.resolveModelId(routerId); const request: GatewayAuthRequest = { gatewayId, providerId, modelId, routerId }; const rawGatewayAuth = await gateway.resolveAuth?.(request); @@ -106,13 +168,22 @@ export class GatewayManager { }; } - /** Convenience: whether auth is available for a model. Never throws. */ + /** + * Convenience: whether auth is available for a model. + * Returns `false` for expected missing-auth states (no matching gateway, + * missing credentials/env vars) but re-throws unexpected errors (token + * exchange failures, network bugs, malformed auth hooks) so they surface + * instead of being silently hidden. + */ async hasAuth(routerId: string): Promise<boolean> { try { const auth = await this.resolveAuth(routerId); return Boolean(auth.apiKey || auth.bearerToken || auth.headers); - } catch { - return false; + } catch (error) { + if (isExpectedMissingAuthError(error)) { + return false; + } + throw error; } } diff --git a/packages/core/src/llm/model/provider-registry.json b/packages/core/src/llm/model/provider-registry.json index f7befafa0fd1..2b8c161ea647 100644 --- a/packages/core/src/llm/model/provider-registry.json +++ b/packages/core/src/llm/model/provider-registry.json @@ -1470,6 +1470,15 @@ "docUrl": "https://docs.digitalocean.com/products/gradient-ai-platform/details/models/", "gateway": "models.dev" }, + "subconscious": { + "url": "https://api.subconscious.dev/v1", + "apiKeyEnvVar": "SUBCONSCIOUS_API_KEY", + "apiKeyHeader": "Authorization", + "name": "Subconscious", + "models": ["subconscious/tim-qwen3.6-27b"], + "docUrl": "https://docs.subconscious.dev", + "gateway": "models.dev" + }, "lmstudio": { "url": "http://127.0.0.1:1234/v1", "apiKeyEnvVar": "LMSTUDIO_API_KEY", @@ -1789,18 +1798,22 @@ "models": [ "Qwen/Qwen3.5-397B-A17B-FP8", "Qwen/Qwen3.6-35B-A3B", - "glm-5-fast", - "glm-5.1-fast", "glm-5.2", + "glm-5.2-fast", + "glm-5.2-flex", "glm-5.2-short", + "glm-5.2-short-fast", + "glm-5.2-short-fast-flex", + "glm-5.2-short-flex", "kimi-k2.5-fast", "kimi-k2.6-fast", + "kimi-k2.6-flex", + "kimi-k2.7-code-flex", "moonshotai/Kimi-K2.5", "moonshotai/Kimi-K2.6", "moonshotai/Kimi-K2.7-Code", "qwen3.5-397b-fast", - "qwen3.6-35b-fast", - "zai-org/GLM-5.1-FP8" + "qwen3.6-35b-fast" ], "docUrl": "https://portal.neuralwatt.com/docs", "gateway": "models.dev" @@ -2228,6 +2241,7 @@ "claude-3-opus", "claude-haiku-4-5", "claude-haiku-4-5-20251001", + "claude-haiku-4-5-free", "claude-opus-4-1-20250805", "claude-opus-4-5-20251101", "claude-opus-4-6", @@ -2258,16 +2272,13 @@ "glm-4.5", "glm-4.5-air", "glm-4.5-airx", - "glm-4.5-flash", "glm-4.5-x", "glm-4.5v", "glm-4.6", "glm-4.6v", - "glm-4.6v-flash", "glm-4.6v-flashx", "glm-4.7", "glm-4.7-flash", - "glm-4.7-flash-free", "glm-4.7-flashx", "glm-5", "glm-5.1", @@ -2315,7 +2326,6 @@ "grok-build-0-1", "kimi-k2", "kimi-k2-thinking", - "kimi-k2-thinking-turbo", "kimi-k2.5", "kimi-k2.6", "kimi-k2.7-code", @@ -2593,6 +2603,15 @@ "docUrl": "https://docs.morphllm.com/api-reference/introduction", "gateway": "models.dev" }, + "sakana": { + "url": "https://api.sakana.ai/v1", + "apiKeyEnvVar": "SAKANA_API_KEY", + "apiKeyHeader": "Authorization", + "name": "Sakana AI", + "models": ["fugu", "fugu-ultra", "fugu-ultra-20260615"], + "docUrl": "https://console.sakana.ai/models", + "gateway": "models.dev" + }, "deepinfra": { "apiKeyEnvVar": "DEEPINFRA_API_KEY", "name": "Deep Infra", @@ -2615,6 +2634,7 @@ "meta-llama/Llama-4-Scout-17B-16E-Instruct", "moonshotai/Kimi-K2.5", "moonshotai/Kimi-K2.6", + "moonshotai/Kimi-K2.7-Code", "openai/gpt-oss-120b", "openai/gpt-oss-20b", "zai-org/GLM-4.6", @@ -2802,7 +2822,8 @@ "claude-sonnet-4-20250514", "claude-sonnet-4-5", "claude-sonnet-4-5-20250929", - "claude-sonnet-4-6" + "claude-sonnet-4-6", + "claude-sonnet-5" ], "docUrl": "https://docs.anthropic.com/en/docs/about-claude/models", "gateway": "models.dev", @@ -2930,8 +2951,10 @@ "models": [ "MiniMaxAI/MiniMax-M2.5", "moonshotai/Kimi-K2.6", - "nvidia/llama-3.3-70b-instruct-fp8", - "zai-org/GLM-5.1-FP8" + "moonshotai/Kimi-K2.6-Fast", + "moonshotai/Kimi-K2.7-Code", + "zai-org/GLM-5.1-FP8", + "zai-org/GLM-5.2" ], "docUrl": "https://docs.inceptron.io", "gateway": "models.dev" @@ -3321,14 +3344,17 @@ "apiKeyHeader": "Authorization", "name": "GMI Cloud", "models": [ + "Qwen/Qwen3.7-Max", "anthropic/claude-opus-4.6", "anthropic/claude-opus-4.7", "anthropic/claude-sonnet-4.6", "deepseek-ai/DeepSeek-V4-Flash", "deepseek-ai/DeepSeek-V4-Pro", "moonshotai/Kimi-K2.6", + "moonshotai/kimi-k2.7-code-highspeed", "zai-org/GLM-5-FP8", - "zai-org/GLM-5.1-FP8" + "zai-org/GLM-5.1-FP8", + "zai-org/GLM-5.2-FP8" ], "docUrl": "https://docs.gmicloud.ai/inference-engine/api-reference/llm-api-reference", "gateway": "models.dev" @@ -3979,7 +4005,6 @@ "anthropic/claude-opus-4.1", "anthropic/claude-opus-4.5", "anthropic/claude-opus-4.6", - "anthropic/claude-opus-4.6-fast", "anthropic/claude-opus-4.7", "anthropic/claude-opus-4.7-fast", "anthropic/claude-opus-4.8", @@ -4189,7 +4214,6 @@ "openrouter/bodybuilder", "openrouter/free", "openrouter/fusion", - "openrouter/owl-alpha", "openrouter/pareto-code", "perceptron/perceptron-mk1", "perplexity/sonar", @@ -5528,12 +5552,14 @@ "models": [ "hf:MiniMaxAI/MiniMax-M3", "hf:Qwen/Qwen3.5-397B-A17B", + "hf:Qwen/Qwen3.6-27B", "hf:moonshotai/Kimi-K2.6", "hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4", "hf:openai/gpt-oss-120b", "hf:zai-org/GLM-4.7", "hf:zai-org/GLM-4.7-Flash", - "hf:zai-org/GLM-5.1" + "hf:zai-org/GLM-5.1", + "hf:zai-org/GLM-5.2" ], "docUrl": "https://synthetic.new/pricing", "gateway": "models.dev" @@ -5604,6 +5630,7 @@ "anthropic/claude-sonnet-4-5", "anthropic/claude-sonnet-4-5-20250929", "anthropic/claude-sonnet-4-6", + "anthropic/claude-sonnet-5", "gemini/gemini-2.5-flash", "gemini/gemini-2.5-flash-image", "gemini/gemini-2.5-flash-lite", @@ -5612,6 +5639,7 @@ "gemini/gemini-3-pro-image", "gemini/gemini-3.1-flash-image", "gemini/gemini-3.1-flash-lite", + "gemini/gemini-3.1-flash-lite-image", "gemini/gemini-3.1-pro-preview", "gemini/gemini-3.1-pro-preview-customtools", "gemini/gemini-3.5-flash", @@ -6877,6 +6905,7 @@ "stable-diffusion-3.5-large", "wan2-2-t2v-a14b" ], + "subconscious": ["subconscious/tim-qwen3.6-27b"], "lmstudio": ["openai/gpt-oss-20b", "qwen/qwen3-30b-a3b-2507", "qwen/qwen3-coder-30b"], "poolside": ["poolside/laguna-m.1", "poolside/laguna-xs.2"], "zenmux": [ @@ -7136,18 +7165,22 @@ "neuralwatt": [ "Qwen/Qwen3.5-397B-A17B-FP8", "Qwen/Qwen3.6-35B-A3B", - "glm-5-fast", - "glm-5.1-fast", "glm-5.2", + "glm-5.2-fast", + "glm-5.2-flex", "glm-5.2-short", + "glm-5.2-short-fast", + "glm-5.2-short-fast-flex", + "glm-5.2-short-flex", "kimi-k2.5-fast", "kimi-k2.6-fast", + "kimi-k2.6-flex", + "kimi-k2.7-code-flex", "moonshotai/Kimi-K2.5", "moonshotai/Kimi-K2.6", "moonshotai/Kimi-K2.7-Code", "qwen3.5-397b-fast", - "qwen3.6-35b-fast", - "zai-org/GLM-5.1-FP8" + "qwen3.6-35b-fast" ], "siliconflow-cn": [ "ByteDance-Seed/Seed-OSS-36B-Instruct", @@ -7487,6 +7520,7 @@ "claude-3-opus", "claude-haiku-4-5", "claude-haiku-4-5-20251001", + "claude-haiku-4-5-free", "claude-opus-4-1-20250805", "claude-opus-4-5-20251101", "claude-opus-4-6", @@ -7517,16 +7551,13 @@ "glm-4.5", "glm-4.5-air", "glm-4.5-airx", - "glm-4.5-flash", "glm-4.5-x", "glm-4.5v", "glm-4.6", "glm-4.6v", - "glm-4.6v-flash", "glm-4.6v-flashx", "glm-4.7", "glm-4.7-flash", - "glm-4.7-flash-free", "glm-4.7-flashx", "glm-5", "glm-5.1", @@ -7574,7 +7605,6 @@ "grok-build-0-1", "kimi-k2", "kimi-k2-thinking", - "kimi-k2-thinking-turbo", "kimi-k2.5", "kimi-k2.6", "kimi-k2.7-code", @@ -7800,6 +7830,7 @@ "kimi-k2.7-code-highspeed" ], "morph": ["auto", "morph-v3-fast", "morph-v3-large"], + "sakana": ["fugu", "fugu-ultra", "fugu-ultra-20260615"], "deepinfra": [ "MiniMaxAI/MiniMax-M2.5", "Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo", @@ -7819,6 +7850,7 @@ "meta-llama/Llama-4-Scout-17B-16E-Instruct", "moonshotai/Kimi-K2.5", "moonshotai/Kimi-K2.6", + "moonshotai/Kimi-K2.7-Code", "openai/gpt-oss-120b", "openai/gpt-oss-20b", "zai-org/GLM-4.6", @@ -7952,7 +7984,8 @@ "claude-sonnet-4-20250514", "claude-sonnet-4-5", "claude-sonnet-4-5-20250929", - "claude-sonnet-4-6" + "claude-sonnet-4-6", + "claude-sonnet-5" ], "tencent-coding-plan": [ "glm-5", @@ -8038,8 +8071,10 @@ "inceptron": [ "MiniMaxAI/MiniMax-M2.5", "moonshotai/Kimi-K2.6", - "nvidia/llama-3.3-70b-instruct-fp8", - "zai-org/GLM-5.1-FP8" + "moonshotai/Kimi-K2.6-Fast", + "moonshotai/Kimi-K2.7-Code", + "zai-org/GLM-5.1-FP8", + "zai-org/GLM-5.2" ], "llama": [ "cerebras-llama-4-maverick-17b-128e-instruct", @@ -8306,14 +8341,17 @@ "zai-org/GLM-4.6" ], "gmicloud": [ + "Qwen/Qwen3.7-Max", "anthropic/claude-opus-4.6", "anthropic/claude-opus-4.7", "anthropic/claude-sonnet-4.6", "deepseek-ai/DeepSeek-V4-Flash", "deepseek-ai/DeepSeek-V4-Pro", "moonshotai/Kimi-K2.6", + "moonshotai/kimi-k2.7-code-highspeed", "zai-org/GLM-5-FP8", - "zai-org/GLM-5.1-FP8" + "zai-org/GLM-5.1-FP8", + "zai-org/GLM-5.2-FP8" ], "xiaomi-token-plan-cn": [ "mimo-v2-tts", @@ -8839,7 +8877,6 @@ "anthropic/claude-opus-4.1", "anthropic/claude-opus-4.5", "anthropic/claude-opus-4.6", - "anthropic/claude-opus-4.6-fast", "anthropic/claude-opus-4.7", "anthropic/claude-opus-4.7-fast", "anthropic/claude-opus-4.8", @@ -9049,7 +9086,6 @@ "openrouter/bodybuilder", "openrouter/free", "openrouter/fusion", - "openrouter/owl-alpha", "openrouter/pareto-code", "perceptron/perceptron-mk1", "perplexity/sonar", @@ -10170,12 +10206,14 @@ "synthetic": [ "hf:MiniMaxAI/MiniMax-M3", "hf:Qwen/Qwen3.5-397B-A17B", + "hf:Qwen/Qwen3.6-27B", "hf:moonshotai/Kimi-K2.6", "hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4", "hf:openai/gpt-oss-120b", "hf:zai-org/GLM-4.7", "hf:zai-org/GLM-4.7-Flash", - "hf:zai-org/GLM-5.1" + "hf:zai-org/GLM-5.1", + "hf:zai-org/GLM-5.2" ], "iflowcn": [ "deepseek-r1", @@ -10214,6 +10252,7 @@ "anthropic/claude-sonnet-4-5", "anthropic/claude-sonnet-4-5-20250929", "anthropic/claude-sonnet-4-6", + "anthropic/claude-sonnet-5", "gemini/gemini-2.5-flash", "gemini/gemini-2.5-flash-image", "gemini/gemini-2.5-flash-lite", @@ -10222,6 +10261,7 @@ "gemini/gemini-3-pro-image", "gemini/gemini-3.1-flash-image", "gemini/gemini-3.1-flash-lite", + "gemini/gemini-3.1-flash-lite-image", "gemini/gemini-3.1-pro-preview", "gemini/gemini-3.1-pro-preview-customtools", "gemini/gemini-3.5-flash", diff --git a/packages/core/src/llm/model/provider-types.generated.d.ts b/packages/core/src/llm/model/provider-types.generated.d.ts index f9d5906f59ed..1d1305343185 100644 --- a/packages/core/src/llm/model/provider-types.generated.d.ts +++ b/packages/core/src/llm/model/provider-types.generated.d.ts @@ -1229,6 +1229,7 @@ export type ProviderModelsMap = { 'stable-diffusion-3.5-large', 'wan2-2-t2v-a14b', ]; + readonly subconscious: readonly ['subconscious/tim-qwen3.6-27b']; readonly lmstudio: readonly ['openai/gpt-oss-20b', 'qwen/qwen3-30b-a3b-2507', 'qwen/qwen3-coder-30b']; readonly poolside: readonly ['poolside/laguna-m.1', 'poolside/laguna-xs.2']; readonly zenmux: readonly [ @@ -1488,18 +1489,22 @@ export type ProviderModelsMap = { readonly neuralwatt: readonly [ 'Qwen/Qwen3.5-397B-A17B-FP8', 'Qwen/Qwen3.6-35B-A3B', - 'glm-5-fast', - 'glm-5.1-fast', 'glm-5.2', + 'glm-5.2-fast', + 'glm-5.2-flex', 'glm-5.2-short', + 'glm-5.2-short-fast', + 'glm-5.2-short-fast-flex', + 'glm-5.2-short-flex', 'kimi-k2.5-fast', 'kimi-k2.6-fast', + 'kimi-k2.6-flex', + 'kimi-k2.7-code-flex', 'moonshotai/Kimi-K2.5', 'moonshotai/Kimi-K2.6', 'moonshotai/Kimi-K2.7-Code', 'qwen3.5-397b-fast', 'qwen3.6-35b-fast', - 'zai-org/GLM-5.1-FP8', ]; readonly 'siliconflow-cn': readonly [ 'ByteDance-Seed/Seed-OSS-36B-Instruct', @@ -1839,6 +1844,7 @@ export type ProviderModelsMap = { 'claude-3-opus', 'claude-haiku-4-5', 'claude-haiku-4-5-20251001', + 'claude-haiku-4-5-free', 'claude-opus-4-1-20250805', 'claude-opus-4-5-20251101', 'claude-opus-4-6', @@ -1869,16 +1875,13 @@ export type ProviderModelsMap = { 'glm-4.5', 'glm-4.5-air', 'glm-4.5-airx', - 'glm-4.5-flash', 'glm-4.5-x', 'glm-4.5v', 'glm-4.6', 'glm-4.6v', - 'glm-4.6v-flash', 'glm-4.6v-flashx', 'glm-4.7', 'glm-4.7-flash', - 'glm-4.7-flash-free', 'glm-4.7-flashx', 'glm-5', 'glm-5.1', @@ -1926,7 +1929,6 @@ export type ProviderModelsMap = { 'grok-build-0-1', 'kimi-k2', 'kimi-k2-thinking', - 'kimi-k2-thinking-turbo', 'kimi-k2.5', 'kimi-k2.6', 'kimi-k2.7-code', @@ -2152,6 +2154,7 @@ export type ProviderModelsMap = { 'kimi-k2.7-code-highspeed', ]; readonly morph: readonly ['auto', 'morph-v3-fast', 'morph-v3-large']; + readonly sakana: readonly ['fugu', 'fugu-ultra', 'fugu-ultra-20260615']; readonly deepinfra: readonly [ 'MiniMaxAI/MiniMax-M2.5', 'Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo', @@ -2171,6 +2174,7 @@ export type ProviderModelsMap = { 'meta-llama/Llama-4-Scout-17B-16E-Instruct', 'moonshotai/Kimi-K2.5', 'moonshotai/Kimi-K2.6', + 'moonshotai/Kimi-K2.7-Code', 'openai/gpt-oss-120b', 'openai/gpt-oss-20b', 'zai-org/GLM-4.6', @@ -2305,6 +2309,7 @@ export type ProviderModelsMap = { 'claude-sonnet-4-5', 'claude-sonnet-4-5-20250929', 'claude-sonnet-4-6', + 'claude-sonnet-5', ]; readonly 'tencent-coding-plan': readonly [ 'glm-5', @@ -2390,8 +2395,10 @@ export type ProviderModelsMap = { readonly inceptron: readonly [ 'MiniMaxAI/MiniMax-M2.5', 'moonshotai/Kimi-K2.6', - 'nvidia/llama-3.3-70b-instruct-fp8', + 'moonshotai/Kimi-K2.6-Fast', + 'moonshotai/Kimi-K2.7-Code', 'zai-org/GLM-5.1-FP8', + 'zai-org/GLM-5.2', ]; readonly llama: readonly [ 'cerebras-llama-4-maverick-17b-128e-instruct', @@ -2658,14 +2665,17 @@ export type ProviderModelsMap = { 'zai-org/GLM-4.6', ]; readonly gmicloud: readonly [ + 'Qwen/Qwen3.7-Max', 'anthropic/claude-opus-4.6', 'anthropic/claude-opus-4.7', 'anthropic/claude-sonnet-4.6', 'deepseek-ai/DeepSeek-V4-Flash', 'deepseek-ai/DeepSeek-V4-Pro', 'moonshotai/Kimi-K2.6', + 'moonshotai/kimi-k2.7-code-highspeed', 'zai-org/GLM-5-FP8', 'zai-org/GLM-5.1-FP8', + 'zai-org/GLM-5.2-FP8', ]; readonly 'xiaomi-token-plan-cn': readonly [ 'mimo-v2-tts', @@ -3191,7 +3201,6 @@ export type ProviderModelsMap = { 'anthropic/claude-opus-4.1', 'anthropic/claude-opus-4.5', 'anthropic/claude-opus-4.6', - 'anthropic/claude-opus-4.6-fast', 'anthropic/claude-opus-4.7', 'anthropic/claude-opus-4.7-fast', 'anthropic/claude-opus-4.8', @@ -3401,7 +3410,6 @@ export type ProviderModelsMap = { 'openrouter/bodybuilder', 'openrouter/free', 'openrouter/fusion', - 'openrouter/owl-alpha', 'openrouter/pareto-code', 'perceptron/perceptron-mk1', 'perplexity/sonar', @@ -4530,12 +4538,14 @@ export type ProviderModelsMap = { readonly synthetic: readonly [ 'hf:MiniMaxAI/MiniMax-M3', 'hf:Qwen/Qwen3.5-397B-A17B', + 'hf:Qwen/Qwen3.6-27B', 'hf:moonshotai/Kimi-K2.6', 'hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4', 'hf:openai/gpt-oss-120b', 'hf:zai-org/GLM-4.7', 'hf:zai-org/GLM-4.7-Flash', 'hf:zai-org/GLM-5.1', + 'hf:zai-org/GLM-5.2', ]; readonly iflowcn: readonly [ 'deepseek-r1', @@ -4574,6 +4584,7 @@ export type ProviderModelsMap = { 'anthropic/claude-sonnet-4-5', 'anthropic/claude-sonnet-4-5-20250929', 'anthropic/claude-sonnet-4-6', + 'anthropic/claude-sonnet-5', 'gemini/gemini-2.5-flash', 'gemini/gemini-2.5-flash-image', 'gemini/gemini-2.5-flash-lite', @@ -4582,6 +4593,7 @@ export type ProviderModelsMap = { 'gemini/gemini-3-pro-image', 'gemini/gemini-3.1-flash-image', 'gemini/gemini-3.1-flash-lite', + 'gemini/gemini-3.1-flash-lite-image', 'gemini/gemini-3.1-pro-preview', 'gemini/gemini-3.1-pro-preview-customtools', 'gemini/gemini-3.5-flash', diff --git a/packages/core/src/llm/model/router.ts b/packages/core/src/llm/model/router.ts index ce028959a7e0..0b471ab34d66 100644 --- a/packages/core/src/llm/model/router.ts +++ b/packages/core/src/llm/model/router.ts @@ -9,7 +9,6 @@ import type { StreamTransport } from '../../stream/types'; import { AISDKV5LanguageModel } from './aisdk/v5/model'; import { AISDKV6LanguageModel } from './aisdk/v6/model'; import { AISDKV7LanguageModel } from './aisdk/v7/model'; -import { parseModelRouterId } from './gateway-resolver.js'; import { MASTRA_GATEWAY_STREAM_TRANSPORT } from './gateways/base.js'; import type { GatewayAuthResult, @@ -19,7 +18,7 @@ import type { MastraModelGatewayInterface, } from './gateways/base.js'; import { defaultGateways } from './gateways/defaults.js'; -import { GatewayManager, getGatewayId } from './gateways/index.js'; +import { GatewayManager } from './gateways/index.js'; import { createOpenAIWebSocketFetch } from './openai-websocket-fetch.js'; import type { OpenAIWebSocketFetch } from './openai-websocket-fetch.js'; @@ -173,19 +172,17 @@ export class ModelRouterLanguageModel implements MastraLanguageModelV2 { routerId: normalizedConfig.id, }; - // Resolve gateway once using the normalized ID - const allGateways = [...(customGateways ?? []), ...defaultGateways]; - this.#manager = new GatewayManager(allGateways); - this.gateway = this.#manager.findGatewayForModel(normalizedConfig.id); - this.gatewayId = getGatewayId(this.gateway); - // Extract provider from id if present - const gatewayPrefix = GatewayManager.getPrefix(this.gatewayId); - const parsed = parseModelRouterId(normalizedConfig.id, gatewayPrefix); - - this.provider = parsed.providerId || 'openai-compatible'; - - if (parsed.providerId && parsed.modelId !== normalizedConfig.id) { - parsedConfig.id = parsed.modelId as `${string}/${string}`; + // Resolve gateway once using the normalized ID. The manager deduplicates + // the gateway chain (custom-before-default, first-wins) and centralises + // gateway selection + id parsing in a single resolveModelId call. + this.#manager = new GatewayManager([...(customGateways ?? []), ...defaultGateways]); + const resolved = this.#manager.resolveModelId(normalizedConfig.id); + this.gateway = resolved.gateway; + this.gatewayId = resolved.gatewayId; + this.provider = resolved.providerId || 'openai-compatible'; + + if (resolved.providerId && resolved.modelId !== normalizedConfig.id) { + parsedConfig.id = resolved.modelId as `${string}/${string}`; } this.modelId = parsedConfig.id; @@ -226,15 +223,14 @@ export class ModelRouterLanguageModel implements MastraLanguageModelV2 { private async _fetchSupportedUrls(): Promise<Record<string, RegExp[]>> { let apiKey: string; try { - const parsed = parseModelRouterId(this.config.routerId, GatewayManager.getPrefix(this.gatewayId)); - const auth = await this.resolveAuth(parsed.providerId, parsed.modelId); + const resolved = this.#manager.resolveModelId(this.config.routerId); + const auth = await this.resolveAuth(resolved.providerId, resolved.modelId); apiKey = auth.apiKey ?? ''; - const gatewayPrefix = GatewayManager.getPrefix(this.gatewayId); const model = await this.resolveLanguageModel({ apiKey, auth, headers: mergeHeaders(this.config.headers, auth.headers), - ...parseModelRouterId(this.config.routerId, gatewayPrefix), + ...resolved, }); // Get supportedUrls from the underlying model @@ -360,12 +356,12 @@ export class ModelRouterLanguageModel implements MastraLanguageModelV2 { } async doGenerate(options: LanguageModelV2CallOptions): Promise<StreamResult> { + const resolved = this.#manager.resolveModelId(this.config.routerId); let auth: GatewayAuthResult; try { // If custom URL is provided, skip gateway API key resolution // The provider might not be in the registry (e.g., custom providers like ollama) - const parsed = parseModelRouterId(this.config.routerId, GatewayManager.getPrefix(this.gatewayId)); - auth = await this.resolveAuth(parsed.providerId, parsed.modelId); + auth = await this.resolveAuth(resolved.providerId, resolved.modelId); } catch (error) { // Return an error stream instead of throwing return { @@ -381,12 +377,11 @@ export class ModelRouterLanguageModel implements MastraLanguageModelV2 { }; } - const gatewayPrefix = GatewayManager.getPrefix(this.gatewayId); const model = await this.resolveLanguageModel({ apiKey: auth.apiKey ?? '', auth, headers: mergeHeaders(this.config.headers, auth.headers), - ...parseModelRouterId(this.config.routerId, gatewayPrefix), + ...resolved, }); // Handle V2, V3, and V4 models @@ -406,12 +401,12 @@ export class ModelRouterLanguageModel implements MastraLanguageModelV2 { async doStream(options: LanguageModelV2CallOptions): Promise<StreamResult> { // Validate API key and return error stream if validation fails + const resolved = this.#manager.resolveModelId(this.config.routerId); let auth: GatewayAuthResult; try { // If custom URL is provided, skip gateway API key resolution // The provider might not be in the registry (e.g., custom providers like ollama) - const parsed = parseModelRouterId(this.config.routerId, GatewayManager.getPrefix(this.gatewayId)); - auth = await this.resolveAuth(parsed.providerId, parsed.modelId); + auth = await this.resolveAuth(resolved.providerId, resolved.modelId); } catch (error) { // Return an error stream instead of throwing return { @@ -427,11 +422,9 @@ export class ModelRouterLanguageModel implements MastraLanguageModelV2 { }; } - const gatewayPrefix = GatewayManager.getPrefix(this.gatewayId); - const parsedModelId = parseModelRouterId(this.config.routerId, gatewayPrefix); const { transport, websocket } = getOpenAITransport( options.providerOptions as ProviderOptions | undefined, - parsedModelId.providerId, + resolved.providerId, ); const requestedTransport: OpenAITransport = transport === 'auto' ? 'websocket' : transport; const allowWebSocket = @@ -447,7 +440,7 @@ export class ModelRouterLanguageModel implements MastraLanguageModelV2 { headers: mergeHeaders(this.config.headers, auth.headers), transport: resolvedTransport, responsesWebSocket: websocket, - ...parsedModelId, + ...resolved, }); // Handle V2, V3, and V4 models diff --git a/packages/core/src/loop/test-utils/aimock/README.md b/packages/core/src/loop/test-utils/aimock/README.md index 4f8094dd3296..24c7f73d05f3 100644 --- a/packages/core/src/loop/test-utils/aimock/README.md +++ b/packages/core/src/loop/test-utils/aimock/README.md @@ -141,6 +141,32 @@ baseURL })`) so its own loop turns also hit the mock; match the delegated prompt - `pubsub: new InMemoryPubSub()` — attach a PubSub instance to the Mastra backing the agent, enabling the signal API (`subscribeToThread()`, `sendMessage()`, `sendStateSignal()`). Combine with the `agent` returned by `runLoopScenario` to drive thread subscriptions and assert signal metadata. +- `fsRouted: true` — build the agent via file-system routing (`assembleAgentFromFsEntry`) instead of + `new Agent(...)`, then register it through `Mastra.__registerFsAgents` — exactly how the bundler + injects an `agents/<name>/` directory. `instructions` is treated as the `instructions.md` body and + `tools` as the discovered `tools/*` map. Requires a static `instructions` string. This is an alias + for opting a single scenario into the file-routing path; most scenarios get fs coverage for free via + the `'fs'` engine variant below. + +### Engine / agent variants (`describeForAllEngines`) + +`describeForAllEngines(name, factory, { skip })` runs the factory once per `EngineVariant`. The first +three select the _execution engine_; `'fs'` selects the _agent-assembly method_ and runs on the normal +engine: + +- `'normal'` — direct engine, `new Agent(...)`. +- `'evented'` — evented workflow engine (`MASTRA_EVENTED_EXECUTION=true`). +- `'durable'` — `createDurableAgent` wrapper. +- `'fs'` — agent assembled from file-system routing (`instructions.md` body + discovered `tools/*`) and + registered through `Mastra.__registerFsAgents`, then run on the normal engine. Equivalent to setting + `fsRouted: true` for that variant. + +Because `'fs'` is part of `ALL_ENGINE_VARIANTS`, every scenario using `describeForAllEngines` covers the +file-routing path automatically. The `'fs'` variant threads `agents` (subagents), `goal`, `workspace`, and +`workflows` config straight through `assembleAgentFromFsEntry`, so supervisor / agents-as-tools and goal +scenarios run on `'fs'` too. Scenarios whose inputs the file-routing model cannot represent — +dynamic-function `instructions`, `sharedAgent`, `workflows`-as-tool, or durable resume/suspension — opt out +with `{ skip: ['fs'] }` (alongside any engines they already skip). ### Error-state scenarios diff --git a/packages/core/src/loop/test-utils/aimock/aimock-scenario.ts b/packages/core/src/loop/test-utils/aimock/aimock-scenario.ts index 8b4720329eb2..e07acba5844d 100644 --- a/packages/core/src/loop/test-utils/aimock/aimock-scenario.ts +++ b/packages/core/src/loop/test-utils/aimock/aimock-scenario.ts @@ -2,6 +2,7 @@ import { createOpenAI } from '@ai-sdk/openai-v5'; import { LLMock } from '@copilotkit/aimock'; import { afterAll, afterEach, beforeAll, describe } from 'vitest'; import { Agent } from '../../../agent'; +import { assembleAgentFromFsEntry } from '../../../agent/fs-routing'; import { createDurableAgent } from '../../../agent/durable'; import { Mastra } from '../../../mastra'; import { InMemoryStore } from '../../../storage'; @@ -113,6 +114,7 @@ async function buildScenarioAgent({ pubsub, engine, inputProcessors, + fsRouted, }: Pick< RunLoopScenarioOptions, | 'llm' @@ -132,6 +134,7 @@ async function buildScenarioAgent({ | 'pubsub' | 'engine' | 'inputProcessors' + | 'fsRouted' >): Promise<{ agent: any; mastra: any }> { const openai = createOpenAI({ apiKey: 'aimock-test-key', @@ -145,38 +148,82 @@ async function buildScenarioAgent({ // Use dynamic model function if provided, otherwise use default AIMock-backed model const modelConfig = model ?? openai(SCENARIO_MODEL_ID); - const agent = new Agent({ - id: agentId, - name: 'AIMock Loop Scenario Agent', - instructions: instructions ?? 'You are a test agent driven by scripted AIMock responses.', - model: modelConfig, - ...(tools ? { tools } : {}), - ...(signals ? { signals } : {}), - ...(memory ? { memory } : {}), - ...(workspace ? { workspace } : {}), - ...(agents ? { agents } : {}), - ...(workflows ? { workflows } : {}), - ...(agentBackgroundTasks ? { backgroundTasks: agentBackgroundTasks } : {}), - ...(goal ? { goal } : {}), - ...(errorProcessors ? { errorProcessors } : {}), - ...(defaultOptions ? { defaultOptions } : {}), - // For durable engine, inputProcessors must be on the agent constructor - ...(engine === 'durable' && inputProcessors ? { inputProcessors } : {}), - }); + const defaultInstructions = 'You are a test agent driven by scripted AIMock responses.'; + + // The `'fs'` variant assembles the agent via file-system routing; the explicit + // `fsRouted` flag is kept as an alias so a single scenario can opt in without + // running across the whole engine matrix. + const isFs = engine === 'fs' || fsRouted === true; + + let agent: any; + if (isFs) { + // Build the agent exactly as the bundler would for an `agents/<name>/` + // directory: a partial `config.ts` plus `instructions.md` and `tools/*`. + // This proves a file-routed agent runs identically through the real loop. + if (typeof instructions === 'function') { + throw new Error("the 'fs' agent variant requires a static `instructions` string (the instructions.md body)."); + } + const fsTools = tools + ? Object.entries(tools as Record<string, any>).map(([key, tool]) => ({ key, tool })) + : undefined; + agent = assembleAgentFromFsEntry({ + name: agentId, + config: { + model: modelConfig, + ...(signals ? { signals } : {}), + ...(memory ? { memory } : {}), + ...(workspace ? { workspace } : {}), + ...(agents ? { agents } : {}), + ...(workflows ? { workflows } : {}), + ...(agentBackgroundTasks ? { backgroundTasks: agentBackgroundTasks } : {}), + ...(goal ? { goal } : {}), + ...(errorProcessors ? { errorProcessors } : {}), + ...(defaultOptions ? { defaultOptions } : {}), + }, + instructionsMd: (instructions as string | undefined) ?? defaultInstructions, + ...(fsTools ? { tools: fsTools } : {}), + }); + } else { + agent = new Agent({ + id: agentId, + name: 'AIMock Loop Scenario Agent', + instructions: instructions ?? defaultInstructions, + model: modelConfig, + ...(tools ? { tools } : {}), + ...(signals ? { signals } : {}), + ...(memory ? { memory } : {}), + ...(workspace ? { workspace } : {}), + ...(agents ? { agents } : {}), + ...(workflows ? { workflows } : {}), + ...(agentBackgroundTasks ? { backgroundTasks: agentBackgroundTasks } : {}), + ...(goal ? { goal } : {}), + ...(errorProcessors ? { errorProcessors } : {}), + ...(defaultOptions ? { defaultOptions } : {}), + // For durable engine, inputProcessors must be on the agent constructor + ...(engine === 'durable' && inputProcessors ? { inputProcessors } : {}), + }); + } // Wrap with DurableAgent for the durable engine variant const registrableAgent = engine === 'durable' ? createDurableAgent({ agent }) : agent; // Registering the agent on a Mastra instance with storage is required for the // suspended snapshot rows that approveToolCall/declineToolCall resume from. + // For the fs variant, register through the real file-routing path + // (`__registerFsAgents`) instead of the constructor map, so the scenario + // exercises exactly how the bundler injects file-based agents. const mastra = new Mastra({ - agents: { [agentId]: registrableAgent as any }, + agents: isFs ? {} : { [agentId]: registrableAgent as any }, logger: false, storage: new InMemoryStore(), ...(backgroundTasks ? { backgroundTasks } : {}), ...(pubsub ? { pubsub } : {}), }); + if (isFs) { + mastra.__registerFsAgents({ [agentId]: registrableAgent as any }); + } + // Start workers if background tasks are enabled if (backgroundTasks?.enabled) { await mastra.startWorkers(); @@ -247,6 +294,7 @@ export async function runLoopScenario(opts: RunLoopScenarioOptions): Promise<Loo sharedAgent, pubsub, engine = 'normal', + fsRouted, } = opts; fixtures(llm); @@ -282,6 +330,7 @@ export async function runLoopScenario(opts: RunLoopScenarioOptions): Promise<Loo pubsub, engine, inputProcessors, + fsRouted, }); agent = built.agent; mastra = built.mastra; diff --git a/packages/core/src/loop/test-utils/aimock/scenarios/auto-resume-suspended-tools.scenario.test.ts b/packages/core/src/loop/test-utils/aimock/scenarios/auto-resume-suspended-tools.scenario.test.ts index ca675cd4b6a0..7bed908958d8 100644 --- a/packages/core/src/loop/test-utils/aimock/scenarios/auto-resume-suspended-tools.scenario.test.ts +++ b/packages/core/src/loop/test-utils/aimock/scenarios/auto-resume-suspended-tools.scenario.test.ts @@ -215,5 +215,5 @@ describeForAllEngines( expect(toolExecuted).toBe(false); }); }, - { skip: ['durable'] }, + { skip: ['durable', 'fs'] }, ); diff --git a/packages/core/src/loop/test-utils/aimock/scenarios/dynamic-instructions.scenario.test.ts b/packages/core/src/loop/test-utils/aimock/scenarios/dynamic-instructions.scenario.test.ts index d4e27a649dcc..f62802b135ae 100644 --- a/packages/core/src/loop/test-utils/aimock/scenarios/dynamic-instructions.scenario.test.ts +++ b/packages/core/src/loop/test-utils/aimock/scenarios/dynamic-instructions.scenario.test.ts @@ -11,72 +11,76 @@ import { runLoopScenario, useLoopScenarioAimock, describeForAllEngines } from '. * same agent can be re-targeted per request. This pins that resolution path * end-to-end through the loop. */ -describeForAllEngines('AIMock loop scenario: dynamic instructions', engine => { - const getMock = useLoopScenarioAimock(); +describeForAllEngines( + 'AIMock loop scenario: dynamic instructions', + engine => { + const getMock = useLoopScenarioAimock(); - function systemPromptOf(request: JournalEntry): string { - const messages = request.body?.messages ?? []; - const system = messages.filter(message => (message as { role?: string }).role === 'system'); - return JSON.stringify(system); - } + function systemPromptOf(request: JournalEntry): string { + const messages = request.body?.messages ?? []; + const system = messages.filter(message => (message as { role?: string }).role === 'system'); + return JSON.stringify(system); + } - it('resolves instructions from request context into the request system prompt', async () => { - const requestContext = new RequestContext(); - requestContext.set('userTier', 'enterprise'); + it('resolves instructions from request context into the request system prompt', async () => { + const requestContext = new RequestContext(); + requestContext.set('userTier', 'enterprise'); - const { requests } = await runLoopScenario({ - engine, - llm: getMock(), - prompt: 'Hello.', - requestContext, - instructions: ({ requestContext: ctx }) => - `You are serving a ${ctx.get('userTier')} customer. Be extra thorough.`, - fixtures: llm => { - llm.onMessage(/.*/, { content: 'Acknowledged.' }); - }, + const { requests } = await runLoopScenario({ + engine, + llm: getMock(), + prompt: 'Hello.', + requestContext, + instructions: ({ requestContext: ctx }) => + `You are serving a ${ctx.get('userTier')} customer. Be extra thorough.`, + fixtures: llm => { + llm.onMessage(/.*/, { content: 'Acknowledged.' }); + }, + }); + + expect(requests).toHaveLength(1); + const systemPrompt = systemPromptOf(requests[0]!); + expect(systemPrompt).toContain('enterprise customer'); + expect(systemPrompt).not.toContain('free customer'); }); - expect(requests).toHaveLength(1); - const systemPrompt = systemPromptOf(requests[0]!); - expect(systemPrompt).toContain('enterprise customer'); - expect(systemPrompt).not.toContain('free customer'); - }); + it('produces different system prompts for different request contexts', async () => { + const dynamicInstructions = ({ requestContext: ctx }: { requestContext: RequestContext }) => + `You are serving a ${ctx.get('userTier')} customer.`; - it('produces different system prompts for different request contexts', async () => { - const dynamicInstructions = ({ requestContext: ctx }: { requestContext: RequestContext }) => - `You are serving a ${ctx.get('userTier')} customer.`; + const enterprise = new RequestContext(); + enterprise.set('userTier', 'enterprise'); + const free = new RequestContext(); + free.set('userTier', 'free'); - const enterprise = new RequestContext(); - enterprise.set('userTier', 'enterprise'); - const free = new RequestContext(); - free.set('userTier', 'free'); + const enterpriseRun = await runLoopScenario({ + engine, + llm: getMock(), + prompt: 'Hello.', + requestContext: enterprise, + instructions: dynamicInstructions, + fixtures: llm => llm.onMessage(/.*/, { content: 'Hi enterprise.' }), + }); - const enterpriseRun = await runLoopScenario({ - engine, - llm: getMock(), - prompt: 'Hello.', - requestContext: enterprise, - instructions: dynamicInstructions, - fixtures: llm => llm.onMessage(/.*/, { content: 'Hi enterprise.' }), - }); + // afterEach clears the journal between tests, but both runs are in one test, + // so capture the first run's request before the second overwrites nothing + // (requests accumulate within a single test). + const enterprisePrompt = systemPromptOf(enterpriseRun.requests.at(-1)!); + expect(enterprisePrompt).toContain('enterprise customer'); - // afterEach clears the journal between tests, but both runs are in one test, - // so capture the first run's request before the second overwrites nothing - // (requests accumulate within a single test). - const enterprisePrompt = systemPromptOf(enterpriseRun.requests.at(-1)!); - expect(enterprisePrompt).toContain('enterprise customer'); + const freeRun = await runLoopScenario({ + engine, + llm: getMock(), + prompt: 'Hello.', + requestContext: free, + instructions: dynamicInstructions, + fixtures: llm => llm.onMessage(/.*/, { content: 'Hi free.' }), + }); - const freeRun = await runLoopScenario({ - engine, - llm: getMock(), - prompt: 'Hello.', - requestContext: free, - instructions: dynamicInstructions, - fixtures: llm => llm.onMessage(/.*/, { content: 'Hi free.' }), + const freePrompt = systemPromptOf(freeRun.requests.at(-1)!); + expect(freePrompt).toContain('free customer'); + expect(freePrompt).not.toContain('enterprise customer'); }); - - const freePrompt = systemPromptOf(freeRun.requests.at(-1)!); - expect(freePrompt).toContain('free customer'); - expect(freePrompt).not.toContain('enterprise customer'); - }); -}); + }, + { skip: ['fs'] }, +); // dynamic-function instructions cannot be modeled by an instructions.md body diff --git a/packages/core/src/loop/test-utils/aimock/scenarios/fs-routed-agent.scenario.test.ts b/packages/core/src/loop/test-utils/aimock/scenarios/fs-routed-agent.scenario.test.ts new file mode 100644 index 000000000000..305a0b6fe9a1 --- /dev/null +++ b/packages/core/src/loop/test-utils/aimock/scenarios/fs-routed-agent.scenario.test.ts @@ -0,0 +1,91 @@ +/** + * AIMock Scenario: File-routed agent parity (direct comparison) + * + * The `'fs'` engine variant (see {@link EngineVariant}) already runs the entire + * scenario battery through `assembleAgentFromFsEntry` + + * `Mastra.__registerFsAgents`, so per-scenario file-routing coverage is free. + * + * This file keeps the one assertion the variant matrix cannot make on its own: a + * **side-by-side** run of a code-registered `new Agent(...)` and a file-routed + * agent with identical inputs, proving their loop output is byte-for-byte equal. + * It is itself skipped for the `'fs'` variant because it already builds both. + */ + +import { stepCountIs } from '@internal/ai-sdk-v5'; +import { it, expect } from 'vitest'; +import { z } from 'zod/v4'; +import { createTool } from '../../../../tools'; +import { runLoopScenario, useLoopScenarioAimock, describeForAllEngines } from '../aimock-scenario'; + +// Run the direct comparison on every execution engine except `'fs'` itself +// (this file builds both a code and an fs agent, so re-running it under the fs +// variant would be redundant). `'durable'` wraps the agent and is orthogonal to +// the assembly path; normal/evented fully cover loop parity. +describeForAllEngines( + 'AIMock loop scenario: file-routed agent parity', + engine => { + const getMock = useLoopScenarioAimock(); + + it('produces the same loop output as an equivalent code-registered agent', async () => { + const makeTool = () => + createTool({ + id: 'lookup', + description: 'Look up a value', + inputSchema: z.object({ key: z.string() }), + outputSchema: z.object({ value: z.string() }), + execute: async ({ key }: { key: string }) => ({ value: `value-for-${key}` }), + }); + + const instructions = 'You are a lookup assistant.'; + const prompt = 'Look up the answer.'; + + const scriptFixtures = (llm: any) => { + llm.on( + { endpoint: 'chat', hasToolResult: false }, + { toolCalls: [{ id: 'call_l1', name: 'lookup', arguments: { key: 'answer' } }] }, + ); + llm.on({ endpoint: 'chat', hasToolResult: true }, { content: 'The value is value-for-answer.' }); + }; + + // Code-registered agent. + const codeRun = await runLoopScenario({ + engine, + llm: getMock(), + prompt, + instructions, + tools: { lookup: makeTool() }, + stopWhen: stepCountIs(5), + fixtures: scriptFixtures, + }); + const codeRequestCount = codeRun.requests.length; + const codeText = await codeRun.output.text; + const codeResults = await codeRun.output.toolResults; + + // One AIMock server is shared per suite; reset the captured journal so the + // second run's request count is measured independently of the first. + getMock().clearRequests(); + getMock().resetMatchCounts(); + + // File-routed agent, same inputs. + const fsRun = await runLoopScenario({ + engine, + fsRouted: true, + llm: getMock(), + prompt, + instructions, + tools: { lookup: makeTool() }, + stopWhen: stepCountIs(5), + fixtures: scriptFixtures, + }); + const fsText = await fsRun.output.text; + const fsResults = await fsRun.output.toolResults; + + expect(fsText).toBe(codeText); + expect(fsResults.map(r => r.payload?.result)).toEqual(codeResults.map(r => r.payload?.result)); + + // Same number of model turns either way. + expect(fsRun.requests).toHaveLength(codeRequestCount); + }); + }, + { skip: ['durable', 'fs'] }, +); diff --git a/packages/core/src/loop/test-utils/aimock/scenarios/fs-routed-subagent.scenario.test.ts b/packages/core/src/loop/test-utils/aimock/scenarios/fs-routed-subagent.scenario.test.ts new file mode 100644 index 000000000000..6c8cb22580d3 --- /dev/null +++ b/packages/core/src/loop/test-utils/aimock/scenarios/fs-routed-subagent.scenario.test.ts @@ -0,0 +1,117 @@ +/** + * AIMock Scenario: file-routed subagent delegation. + * + * Proves an FS-assembled parent agent that declares an FS-assembled subagent + * (via the `subagents` field of {@link assembleAgentFromFsEntry}) delegates + * exactly like an inline `agents:` map. This is the true file-routed subagent + * path: the child is built from its own directory, not spread in as a + * pre-built `Agent` instance the way the matrix `'fs'` variant does. + * + * Both the parent and the child are built through `assembleAgentFromFsEntry` — + * the same call the bundler emits for `agents/<parent>/subagents/<child>/` — and + * the parent is registered through the real `Mastra.__registerFsAgents` path. + * The child is lowered into a model-visible `agent-<childId>` delegation tool by + * the loop, identical to a code-registered subagent map. + * + * The matrix `'fs'` variant cannot model this on its own (it spreads a code + * `agents:` map), so this is a standalone file. It runs on the normal/evented + * engines; durable wraps the agent and is orthogonal to the assembly path. + */ + +import { createOpenAI } from '@ai-sdk/openai-v5'; +import { stepCountIs } from '@internal/ai-sdk-v5'; +import { it, expect } from 'vitest'; +import { Mastra } from '../../../../mastra'; +import { assembleAgentFromFsEntry } from '../../../../agent/fs-routing'; +import { runLoopScenario, useLoopScenarioAimock, describeForAllEngines } from '../aimock-scenario'; +import { SCENARIO_MODEL_ID } from '../types'; + +describeForAllEngines( + 'AIMock loop scenario: file-routed subagent delegation', + engine => { + const getMock = useLoopScenarioAimock(); + + it('delegates from an FS parent to an FS subagent and feeds the result back', async () => { + const mock = getMock(); + + const openai = createOpenAI({ + apiKey: 'aimock-test-key', + baseURL: `${mock.url.replace(/\/+$/, '')}/v1`, + }); + const model = openai(SCENARIO_MODEL_ID); + + // Parent + child assembled exactly as the bundler emits for + // agents/supervisor/subagents/writer/. + const supervisor = assembleAgentFromFsEntry({ + name: 'supervisor', + config: { model }, + instructionsMd: 'You are a supervisor. Delegate writing to the writer.', + subagents: [ + { + name: 'writer', + config: { model, description: 'Drafts written content' }, + instructionsMd: 'You are a skilled writer subagent.', + }, + ], + }); + + // Register through the real file-routing path so the scenario exercises how + // the bundler injects file-based agents. + const mastra = new Mastra({ agents: {}, logger: false }); + mastra.__registerFsAgents({ supervisor: supervisor as any }); + const parent = mastra.getAgent('supervisor'); + + // The declared subagent is wired into the parent's agents map under its + // bare id and lowered into an `agent-writer` delegation tool. + const childAgents = await parent.listAgents(); + expect(Object.keys(childAgents)).toEqual(['writer']); + expect(childAgents.writer!.getDescription()).toBe('Drafts written content'); + + const { output, requests } = await runLoopScenario({ + engine, + llm: mock, + prompt: 'Ask the writer to draft a tagline.', + sharedAgent: { agent: parent, mastra }, + stopWhen: stepCountIs(5), + fixtures: llm => { + // Supervisor turn 1: delegate to the writer subagent. + llm.on( + { endpoint: 'chat', hasToolResult: false }, + { + toolCalls: [ + { + id: 'call_writer', + name: 'agent-writer', + arguments: { prompt: 'Draft a tagline for a coffee shop.' }, + }, + ], + }, + ); + // Subagent's own loop turn: the writer drafts the tagline. + llm.onMessage(/coffee shop/i, { content: 'Brewed fresh, served warm.' }); + // Supervisor turn 2: the subagent result comes back as a tool result. + llm.on( + { endpoint: 'chat', toolCallId: 'call_writer', hasToolResult: true }, + { content: 'The writer suggested: Brewed fresh, served warm.' }, + ); + }, + }); + + const text = await output.text; + expect(text).toContain('Brewed fresh, served warm.'); + + // The subagent tool result was plumbed back to the supervisor, keyed to the + // original delegation call id. + const supervisorFinalTurn = requests.at(-1)?.body?.messages ?? []; + const toolMessage = supervisorFinalTurn.find(message => (message as { role?: string }).role === 'tool') as + | { tool_call_id?: string; content?: unknown } + | undefined; + expect(toolMessage?.tool_call_id).toBe('call_writer'); + expect(JSON.stringify(toolMessage?.content)).toContain('Brewed fresh, served warm.'); + + // Supervisor delegate, subagent draft, supervisor finalize. + expect(requests.length).toBeGreaterThanOrEqual(3); + }); + }, + { skip: ['durable', 'fs'] }, +); diff --git a/packages/core/src/loop/test-utils/aimock/scenarios/multi-call-thread-persistence.scenario.test.ts b/packages/core/src/loop/test-utils/aimock/scenarios/multi-call-thread-persistence.scenario.test.ts index 6446e8d39ede..f4dfa0063069 100644 --- a/packages/core/src/loop/test-utils/aimock/scenarios/multi-call-thread-persistence.scenario.test.ts +++ b/packages/core/src/loop/test-utils/aimock/scenarios/multi-call-thread-persistence.scenario.test.ts @@ -18,223 +18,227 @@ import { createSharedAgent, runLoopScenario, useLoopScenarioAimock, describeForA * - Message accumulation: second call sees first call's messages * - Resource isolation: different resources maintain separate threads */ -describeForAllEngines('AIMock loop scenario: multi-call thread persistence', engine => { - const getMock = useLoopScenarioAimock(); - - // Helper to extract text content from message - const getContent = (msg: any): string => { - if (typeof msg.content === 'string') { - return msg.content; - } - if (Array.isArray(msg.content)) { - return msg.content.map((part: any) => part.text || '').join(''); - } - return ''; - }; - - it('accumulates messages across multiple calls in same thread', async () => { - const sharedMemory = new MockMemory(); - const shared = await createSharedAgent(getMock(), { - memory: sharedMemory, +describeForAllEngines( + 'AIMock loop scenario: multi-call thread persistence', + engine => { + const getMock = useLoopScenarioAimock(); + + // Helper to extract text content from message + const getContent = (msg: any): string => { + if (typeof msg.content === 'string') { + return msg.content; + } + if (Array.isArray(msg.content)) { + return msg.content.map((part: any) => part.text || '').join(''); + } + return ''; + }; + + it('accumulates messages across multiple calls in same thread', async () => { + const sharedMemory = new MockMemory(); + const shared = await createSharedAgent(getMock(), { + memory: sharedMemory, + }); + + const threadId = 'persistence-thread'; + const resourceId = 'test-resource'; + + // First call: user asks about weather + await runLoopScenario({ + engine, + llm: getMock(), + sharedAgent: shared, + prompt: 'What is the weather in San Francisco?', + memory: sharedMemory, + threadId, + resourceId, + fixtures: llm => { + llm.onMessage(/weather|san francisco/i, { + content: 'The weather in San Francisco is sunny and 72°F.', + }); + }, + collectChunks: false, + }); + + // Clear fixtures for second call + getMock().clearFixtures(); + getMock().clearRequests(); + getMock().resetMatchCounts(); + + // Second call: user asks follow-up question + const { requests: secondRequests } = await runLoopScenario({ + engine, + llm: getMock(), + sharedAgent: shared, + prompt: 'What about tomorrow?', + memory: sharedMemory, + threadId, + resourceId, + fixtures: llm => { + llm.onMessage(/tomorrow/i, { + content: 'Tomorrow in San Francisco will be partly cloudy with a high of 68°F.', + }); + }, + collectChunks: false, + }); + + // The second call should include messages from the first call + expect(secondRequests.length).toBeGreaterThan(0); + const lastRequest = secondRequests[secondRequests.length - 1]; + const messages = lastRequest.body?.messages || []; + + // Should have more than just the current message (includes history) + const userMessages = messages.filter((m: any) => m.role === 'user'); + expect(userMessages.length).toBeGreaterThanOrEqual(2); + + // First user message should be about weather + const firstUserMsg = userMessages.find((m: any) => { + const content = getContent(m); + return content.toLowerCase().includes('weather'); + }); + expect(firstUserMsg).toBeDefined(); + + // Second user message should be about tomorrow + const secondUserMsg = userMessages.find((m: any) => { + const content = getContent(m); + return content.toLowerCase().includes('tomorrow'); + }); + expect(secondUserMsg).toBeDefined(); }); - const threadId = 'persistence-thread'; - const resourceId = 'test-resource'; - - // First call: user asks about weather - await runLoopScenario({ - engine, - llm: getMock(), - sharedAgent: shared, - prompt: 'What is the weather in San Francisco?', - memory: sharedMemory, - threadId, - resourceId, - fixtures: llm => { - llm.onMessage(/weather|san francisco/i, { - content: 'The weather in San Francisco is sunny and 72°F.', - }); - }, - collectChunks: false, + it('maintains thread isolation across different thread IDs', async () => { + const sharedMemory = new MockMemory(); + const shared = await createSharedAgent(getMock(), { + memory: sharedMemory, + }); + + const resourceId = 'test-resource'; + + // Thread A: ask about cats + await runLoopScenario({ + engine, + llm: getMock(), + sharedAgent: shared, + prompt: 'Tell me about cats', + memory: sharedMemory, + threadId: 'thread-a', + resourceId, + fixtures: llm => { + llm.onMessage(/cats/i, { + content: 'Cats are independent and affectionate pets.', + }); + }, + collectChunks: false, + }); + + // Clear fixtures + getMock().clearFixtures(); + getMock().clearRequests(); + getMock().resetMatchCounts(); + + // Thread B: ask about dogs + const { requests: threadBRequests } = await runLoopScenario({ + engine, + llm: getMock(), + sharedAgent: shared, + prompt: 'Tell me about dogs', + memory: sharedMemory, + threadId: 'thread-b', + resourceId, + fixtures: llm => { + llm.onMessage(/dogs/i, { + content: 'Dogs are loyal and energetic companions.', + }); + }, + collectChunks: false, + }); + + // Thread B should NOT see Thread A's messages + expect(threadBRequests.length).toBeGreaterThan(0); + const lastRequest = threadBRequests[threadBRequests.length - 1]; + const messages = lastRequest.body?.messages || []; + + const userMessages = messages.filter((m: any) => m.role === 'user'); + + // Should only have the current message (no history from thread-a) + expect(userMessages.length).toBe(1); + const firstUserContent = getContent(userMessages[0]).toLowerCase(); + expect(firstUserContent).toContain('dogs'); + + // Should NOT contain cats message + const catsMsg = userMessages.find((m: any) => { + const content = getContent(m); + return content.toLowerCase().includes('cats'); + }); + expect(catsMsg).toBeUndefined(); }); - // Clear fixtures for second call - getMock().clearFixtures(); - getMock().clearRequests(); - getMock().resetMatchCounts(); - - // Second call: user asks follow-up question - const { requests: secondRequests } = await runLoopScenario({ - engine, - llm: getMock(), - sharedAgent: shared, - prompt: 'What about tomorrow?', - memory: sharedMemory, - threadId, - resourceId, - fixtures: llm => { - llm.onMessage(/tomorrow/i, { - content: 'Tomorrow in San Francisco will be partly cloudy with a high of 68°F.', - }); - }, - collectChunks: false, + it('maintains resource isolation across different resource IDs', async () => { + const sharedMemory = new MockMemory(); + const shared = await createSharedAgent(getMock(), { + memory: sharedMemory, + }); + + const threadId = 'same-thread'; + + // Resource A: ask about Python + await runLoopScenario({ + engine, + llm: getMock(), + sharedAgent: shared, + prompt: 'Explain Python programming', + memory: sharedMemory, + threadId, + resourceId: 'resource-a', + fixtures: llm => { + llm.onMessage(/python/i, { + content: 'Python is a high-level programming language known for its simplicity.', + }); + }, + collectChunks: false, + }); + + // Clear fixtures + getMock().clearFixtures(); + getMock().clearRequests(); + getMock().resetMatchCounts(); + + // Resource B: ask about JavaScript (same thread ID, different resource) + const { requests: resourceBRequests } = await runLoopScenario({ + engine, + llm: getMock(), + sharedAgent: shared, + prompt: 'Explain JavaScript programming', + memory: sharedMemory, + threadId, + resourceId: 'resource-b', + fixtures: llm => { + llm.onMessage(/javascript/i, { + content: 'JavaScript is the language of the web.', + }); + }, + collectChunks: false, + }); + + // Resource B should NOT see Resource A's messages + expect(resourceBRequests.length).toBeGreaterThan(0); + const lastRequest = resourceBRequests[resourceBRequests.length - 1]; + const messages = lastRequest.body?.messages || []; + + const userMessages = messages.filter((m: any) => m.role === 'user'); + + // Should only have the current message (no history from resource-a) + expect(userMessages.length).toBe(1); + const firstUserContent = getContent(userMessages[0]).toLowerCase(); + expect(firstUserContent).toContain('javascript'); + + // Should NOT contain python message + const pythonMsg = userMessages.find((m: any) => { + const content = getContent(m); + return content.toLowerCase().includes('python'); + }); + expect(pythonMsg).toBeUndefined(); }); - - // The second call should include messages from the first call - expect(secondRequests.length).toBeGreaterThan(0); - const lastRequest = secondRequests[secondRequests.length - 1]; - const messages = lastRequest.body?.messages || []; - - // Should have more than just the current message (includes history) - const userMessages = messages.filter((m: any) => m.role === 'user'); - expect(userMessages.length).toBeGreaterThanOrEqual(2); - - // First user message should be about weather - const firstUserMsg = userMessages.find((m: any) => { - const content = getContent(m); - return content.toLowerCase().includes('weather'); - }); - expect(firstUserMsg).toBeDefined(); - - // Second user message should be about tomorrow - const secondUserMsg = userMessages.find((m: any) => { - const content = getContent(m); - return content.toLowerCase().includes('tomorrow'); - }); - expect(secondUserMsg).toBeDefined(); - }); - - it('maintains thread isolation across different thread IDs', async () => { - const sharedMemory = new MockMemory(); - const shared = await createSharedAgent(getMock(), { - memory: sharedMemory, - }); - - const resourceId = 'test-resource'; - - // Thread A: ask about cats - await runLoopScenario({ - engine, - llm: getMock(), - sharedAgent: shared, - prompt: 'Tell me about cats', - memory: sharedMemory, - threadId: 'thread-a', - resourceId, - fixtures: llm => { - llm.onMessage(/cats/i, { - content: 'Cats are independent and affectionate pets.', - }); - }, - collectChunks: false, - }); - - // Clear fixtures - getMock().clearFixtures(); - getMock().clearRequests(); - getMock().resetMatchCounts(); - - // Thread B: ask about dogs - const { requests: threadBRequests } = await runLoopScenario({ - engine, - llm: getMock(), - sharedAgent: shared, - prompt: 'Tell me about dogs', - memory: sharedMemory, - threadId: 'thread-b', - resourceId, - fixtures: llm => { - llm.onMessage(/dogs/i, { - content: 'Dogs are loyal and energetic companions.', - }); - }, - collectChunks: false, - }); - - // Thread B should NOT see Thread A's messages - expect(threadBRequests.length).toBeGreaterThan(0); - const lastRequest = threadBRequests[threadBRequests.length - 1]; - const messages = lastRequest.body?.messages || []; - - const userMessages = messages.filter((m: any) => m.role === 'user'); - - // Should only have the current message (no history from thread-a) - expect(userMessages.length).toBe(1); - const firstUserContent = getContent(userMessages[0]).toLowerCase(); - expect(firstUserContent).toContain('dogs'); - - // Should NOT contain cats message - const catsMsg = userMessages.find((m: any) => { - const content = getContent(m); - return content.toLowerCase().includes('cats'); - }); - expect(catsMsg).toBeUndefined(); - }); - - it('maintains resource isolation across different resource IDs', async () => { - const sharedMemory = new MockMemory(); - const shared = await createSharedAgent(getMock(), { - memory: sharedMemory, - }); - - const threadId = 'same-thread'; - - // Resource A: ask about Python - await runLoopScenario({ - engine, - llm: getMock(), - sharedAgent: shared, - prompt: 'Explain Python programming', - memory: sharedMemory, - threadId, - resourceId: 'resource-a', - fixtures: llm => { - llm.onMessage(/python/i, { - content: 'Python is a high-level programming language known for its simplicity.', - }); - }, - collectChunks: false, - }); - - // Clear fixtures - getMock().clearFixtures(); - getMock().clearRequests(); - getMock().resetMatchCounts(); - - // Resource B: ask about JavaScript (same thread ID, different resource) - const { requests: resourceBRequests } = await runLoopScenario({ - engine, - llm: getMock(), - sharedAgent: shared, - prompt: 'Explain JavaScript programming', - memory: sharedMemory, - threadId, - resourceId: 'resource-b', - fixtures: llm => { - llm.onMessage(/javascript/i, { - content: 'JavaScript is the language of the web.', - }); - }, - collectChunks: false, - }); - - // Resource B should NOT see Resource A's messages - expect(resourceBRequests.length).toBeGreaterThan(0); - const lastRequest = resourceBRequests[resourceBRequests.length - 1]; - const messages = lastRequest.body?.messages || []; - - const userMessages = messages.filter((m: any) => m.role === 'user'); - - // Should only have the current message (no history from resource-a) - expect(userMessages.length).toBe(1); - const firstUserContent = getContent(userMessages[0]).toLowerCase(); - expect(firstUserContent).toContain('javascript'); - - // Should NOT contain python message - const pythonMsg = userMessages.find((m: any) => { - const content = getContent(m); - return content.toLowerCase().includes('python'); - }); - expect(pythonMsg).toBeUndefined(); - }); -}); + }, + { skip: ['fs'] }, +); // uses sharedAgent across calls; the fs path assembles its own agent diff --git a/packages/core/src/loop/test-utils/aimock/scenarios/resume-after-decline.scenario.test.ts b/packages/core/src/loop/test-utils/aimock/scenarios/resume-after-decline.scenario.test.ts index 373a5376c585..c91468759891 100644 --- a/packages/core/src/loop/test-utils/aimock/scenarios/resume-after-decline.scenario.test.ts +++ b/packages/core/src/loop/test-utils/aimock/scenarios/resume-after-decline.scenario.test.ts @@ -223,5 +223,5 @@ describeForAllEngines( expect(typeof result === 'string' ? result.toLowerCase().includes('not approved') : false).toBe(true); }); }, - { skip: ['durable'] }, + { skip: ['durable', 'fs'] }, ); diff --git a/packages/core/src/loop/test-utils/aimock/scenarios/resume-stream.scenario.test.ts b/packages/core/src/loop/test-utils/aimock/scenarios/resume-stream.scenario.test.ts index 2c0f2d1002b2..8d6400bcd5af 100644 --- a/packages/core/src/loop/test-utils/aimock/scenarios/resume-stream.scenario.test.ts +++ b/packages/core/src/loop/test-utils/aimock/scenarios/resume-stream.scenario.test.ts @@ -174,5 +174,5 @@ describeForAllEngines( expect(toolCompleted).toBe(false); }); }, - { skip: ['durable'] }, + { skip: ['durable', 'fs'] }, ); diff --git a/packages/core/src/loop/test-utils/aimock/scenarios/suspended-snapshot-integrity.scenario.test.ts b/packages/core/src/loop/test-utils/aimock/scenarios/suspended-snapshot-integrity.scenario.test.ts index 15a1ca7d188f..378ab668c8e3 100644 --- a/packages/core/src/loop/test-utils/aimock/scenarios/suspended-snapshot-integrity.scenario.test.ts +++ b/packages/core/src/loop/test-utils/aimock/scenarios/suspended-snapshot-integrity.scenario.test.ts @@ -437,5 +437,5 @@ describeForAllEngines( } }); }, - { skip: ['durable', 'evented'] }, + { skip: ['durable', 'evented', 'fs'] }, ); diff --git a/packages/core/src/loop/test-utils/aimock/types.ts b/packages/core/src/loop/test-utils/aimock/types.ts index 51660f571050..499809786216 100644 --- a/packages/core/src/loop/test-utils/aimock/types.ts +++ b/packages/core/src/loop/test-utils/aimock/types.ts @@ -53,15 +53,29 @@ export interface LoopScenarioResult { } /** - * Execution engine variant for loop scenarios. - * - `'normal'` — default direct engine (no env var, regular Agent). + * Agent / execution variant for loop scenarios. + * + * The first three select the *execution engine* (how the loop runs); `'fs'` + * selects the *agent-assembly method* (how the agent is built) and runs on the + * normal execution path. Treating them as one axis lets every scenario run + * through {@link describeForAllEngines} cover the file-routing path for free. + * + * - `'normal'` — default direct engine (no env var, regular `new Agent(...)`). * - `'evented'` — evented workflow engine via `MASTRA_EVENTED_EXECUTION=true`. * - `'durable'` — durable execution via `createDurableAgent` wrapper. + * - `'fs'` — agent assembled from file-system routing (`assembleAgentFromFsEntry`, + * `instructions.md` body + discovered `tools/*`) and registered through + * `Mastra.__registerFsAgents`, then run on the normal engine. `agents` + * (subagents), `goal`, `workspace`, and `workflows` config are threaded through, + * so supervisor / agents-as-tools and goal scenarios run on `'fs'`. Scenarios + * whose inputs the file-routing path cannot model (dynamic-function + * instructions, `sharedAgent`, `workflows`-as-tool, durable resume/suspension) + * skip this variant via `{ skip: ['fs'] }`. */ -export type EngineVariant = 'normal' | 'evented' | 'durable'; +export type EngineVariant = 'normal' | 'evented' | 'durable' | 'fs'; /** All supported engine variants for parameterised test runs. */ -export const ALL_ENGINE_VARIANTS: readonly EngineVariant[] = ['normal', 'evented', 'durable'] as const; +export const ALL_ENGINE_VARIANTS: readonly EngineVariant[] = ['normal', 'evented', 'durable', 'fs'] as const; export interface RunLoopScenarioOptions { /** Active AIMock handle for the current suite (from {@link useLoopScenarioAimock}). */ @@ -334,6 +348,15 @@ export interface RunLoopScenarioOptions { * the same agent+storage must persist across calls. */ sharedAgent?: { agent: any; mastra: any }; + /** + * Build the agent via file-system routing (`assembleAgentFromFsEntry`) instead + * of `new Agent(...)`, then register it through `Mastra.__registerFsAgents`. + * `instructions` is treated as the `instructions.md` body and `tools` as the + * discovered `tools/*` map, so the exact same scenario runs an FS-assembled + * agent through the real loop. Used to prove file-based agents behave + * identically to code-registered ones. + */ + fsRouted?: boolean; } /** diff --git a/packages/core/src/loop/workflows/agentic-execution/llm-execution-step.ts b/packages/core/src/loop/workflows/agentic-execution/llm-execution-step.ts index 65a2ff2f30a7..8f4992693a42 100644 --- a/packages/core/src/loop/workflows/agentic-execution/llm-execution-step.ts +++ b/packages/core/src/loop/workflows/agentic-execution/llm-execution-step.ts @@ -44,7 +44,7 @@ import { import { findProviderToolByName, inferProviderExecuted } from '../../../tools/provider-tool-utils'; import type { ToolToConvert } from '../../../tools/tool-builder/builder'; import { getProviderToolName, isMastraTool, isProviderTool } from '../../../tools/toolchecks'; -import { makeCoreTool } from '../../../utils'; +import { createMastraProxy, makeCoreTool } from '../../../utils'; import { createStep } from '../../../workflows/workflow'; import type { Workspace } from '../../../workspace/workspace'; import type { RunScopeContext } from '../../run-scope-access'; @@ -1098,6 +1098,10 @@ export function createLLMExecutionStep<TOOLS extends ToolSet = ToolSet, OUTPUT = threadId: readScoped(scopeCtx, THREAD_ID_KEY, 'threadId'), resourceId: readScoped(scopeCtx, RESOURCE_ID_KEY, 'resourceId'), logger, + mastra: mastra + ? createMastraProxy({ mastra, logger: logger || new ConsoleLogger({ level: 'error' }) }) + : undefined, + memory: readScoped(scopeCtx, MEMORY_KEY, 'memory'), agentName: agentId, requestContext: requestContext || new RequestContext(), outputWriter, diff --git a/packages/core/src/loop/workflows/agentic-execution/tool-call-step.ts b/packages/core/src/loop/workflows/agentic-execution/tool-call-step.ts index ec5e6bfc9878..7384fcd0adb8 100644 --- a/packages/core/src/loop/workflows/agentic-execution/tool-call-step.ts +++ b/packages/core/src/loop/workflows/agentic-execution/tool-call-step.ts @@ -553,6 +553,7 @@ export function createToolCallStep<Tools extends ToolSet = ToolSet, OUTPUT = und args: inputData.args, }, __streamState: streamState.serialize(), + __agentId: agentId, }, { resumeLabel: inputData.toolCallId, @@ -674,6 +675,7 @@ export function createToolCallStep<Tools extends ToolSet = ToolSet, OUTPUT = und args: inputData.args, }, __streamState: streamState.serialize(), + __agentId: agentId, // Persist the inner suspended run id in the workflow snapshot, partitioned // per tool call (resumeLabel = toolCallId). The shared per-message // pendingToolApprovals metadata is keyed by toolName and flushed/rehydrated @@ -725,6 +727,7 @@ export function createToolCallStep<Tools extends ToolSet = ToolSet, OUTPUT = und { toolCallSuspended: suspendPayload, __streamState: streamState.serialize(), + __agentId: agentId, toolName: inputData.toolName, resumeLabel: options?.resumeLabel, }, @@ -1161,51 +1164,19 @@ export function createToolCallStep<Tools extends ToolSet = ToolSet, OUTPUT = und } } }, - // Execution injector — updates the existing tool-invocation in the - // message list (keyed by toolCallId) background task startedAt. + // Execution injector — records background task lifecycle metadata on the + // assistant message without changing the model-visible tool result. onExecution: async params => { - const inputTransform = await transformToolPayloadForTargets( - { - phase: 'input-available', - toolName: params.toolName, - toolCallId: params.toolCallId, - input: args, - providerMetadata: inputData.providerMetadata as Record<string, unknown> | undefined, - }, - transformSource, - logger, - ); - const transformCarrier = withToolPayloadTransformMetadata( - { metadata: {} as Record<string, any> }, - inputTransform, - ); - const providerMetadata = withToolPayloadTransformProviderMetadata( - inputData.providerMetadata as ProviderMetadata | undefined, - transformCarrier.metadata, - ) as ProviderMetadata | undefined; - - messageList.updateToolInvocation( - { - type: 'tool-invocation', - toolInvocation: { - state: 'call', - toolCallId: params.toolCallId, - toolName: params.toolName, - args, + messageList.updateMessageMetadataByToolCallId(params.toolCallId, { + mode: 'stream', + backgroundTasks: { + [params.toolCallId]: { + startedAt: params.startedAt, + suspendedAt: params.suspendedAt, + taskId: params.taskId, }, - ...(providerMetadata ? { providerMetadata } : {}), }, - { - mode: 'stream', - backgroundTasks: { - [params.toolCallId]: { - startedAt: params.startedAt, - suspendedAt: params.suspendedAt, - taskId: params.taskId, - }, - }, - }, - ); + }); }, // Per-task callbacks diff --git a/packages/core/src/mastra/fs-and-code-agents.test.ts b/packages/core/src/mastra/fs-and-code-agents.test.ts new file mode 100644 index 000000000000..1d650d767eca --- /dev/null +++ b/packages/core/src/mastra/fs-and-code-agents.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Agent } from '../agent'; +import { assembleAgentFromFsEntry } from '../agent/fs-routing'; +import { createTool } from '../tools'; +import { Mastra } from './index'; + +/** + * End-to-end guarantee that the two agent-registration schemes coexist: an agent + * passed to `new Mastra({ agents })` (code config) and an agent assembled from + * file-system entries via `assembleAgentFromFsEntry` then merged with + * `__registerFsAgents` (fs routing). Both must show up on the instance and keep + * their own configuration. + */ +describe('code-config and fs-routing agents coexist', () => { + it('exposes both a code-registered and an fs-assembled agent', async () => { + const codeAgent = new Agent({ + id: 'support', + name: 'support', + instructions: 'You are the support agent.', + model: 'openai/gpt-4o', + }); + + const mastra = new Mastra({ agents: { support: codeAgent } }); + + const weatherTool = createTool({ + id: 'get_weather', + description: 'get weather', + execute: async () => ({ temp: 70 }), + }); + + const fsAgent = assembleAgentFromFsEntry({ + name: 'weather', + config: { model: 'openai/gpt-4o' }, + instructionsMd: 'You are the weather agent.', + tools: [{ key: 'get_weather', tool: weatherTool }], + }); + + mastra.__registerFsAgents({ weather: fsAgent }); + + const agents = mastra.listAgents(); + expect(Object.keys(agents).sort()).toEqual(['support', 'weather']); + + // Code agent is the exact instance passed in and keeps source "code". + expect(mastra.getAgent('support')).toBe(codeAgent); + expect(mastra.getAgent('support').source).toBe('code'); + + // Fs agent kept its assembled config: instructions from markdown + its tool. + const weather = mastra.getAgent('weather' as 'support'); + expect(weather.source).toBe('fs'); + const instructions = await Promise.resolve(weather.getInstructions()); + expect(instructions).toContain('weather agent'); + const tools = await Promise.resolve(weather.listTools()); + expect(Object.keys(tools)).toContain('get_weather'); + }); + + it('does not let an fs agent override a code agent with the same name', () => { + const codeAgent = new Agent({ + id: 'weather', + name: 'weather', + instructions: 'Code-defined weather agent.', + model: 'openai/gpt-4o', + }); + const mastra = new Mastra({ agents: { weather: codeAgent } }); + + const warn = vi.fn(); + mastra.getLogger().warn = warn; + + const fsAgent = assembleAgentFromFsEntry({ + name: 'weather', + config: { model: 'openai/gpt-4o' }, + instructionsMd: 'Fs-defined weather agent.', + }); + mastra.__registerFsAgents({ weather: fsAgent }); + + // Code agent wins; a warning is surfaced. + expect(mastra.getAgent('weather')).toBe(codeAgent); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('weather')); + }); +}); diff --git a/packages/core/src/mastra/index.ts b/packages/core/src/mastra/index.ts index 1e3f29af0d10..8787e613ca3f 100644 --- a/packages/core/src/mastra/index.ts +++ b/packages/core/src/mastra/index.ts @@ -1,5 +1,7 @@ import { randomUUID } from 'node:crypto'; import type { Agent } from '../agent'; +import { Heartbeats } from '../agent/heartbeat/heartbeats'; +import type { HeartbeatConfig, HeartbeatHooks } from '../agent/heartbeat/types'; import { agentThreadStreamRuntime } from '../agent/thread-stream-runtime'; import type { DurableAgentLike } from '../agent/types'; import { isDurableAgentLike } from '../agent/types'; @@ -65,7 +67,7 @@ import type { MastraWorker, WorkerDeps } from '../worker'; import type { AnyWorkflow, Workflow } from '../workflows'; import { WorkflowEventProcessor } from '../workflows/evented/workflow-event-processor'; import { computeNextFireAt } from '../workflows/scheduler'; -import type { WorkflowScheduleConfig, WorkflowSchedulerConfig, WorkflowScheduler } from '../workflows/scheduler'; +import type { WorkflowScheduleConfig, SchedulerConfig, Scheduler } from '../workflows/scheduler'; import type { AnyWorkspace, RegisteredWorkspace, Workspace } from '../workspace'; import { createOnScorerHook } from './hooks'; import type { RunScope } from './run-scope'; @@ -487,7 +489,7 @@ export interface Config< * `schedule` config or when `scheduler.enabled` is true. It requires a * storage adapter implementing the `schedules` domain (e.g. `@mastra/libsql`). */ - scheduler?: WorkflowSchedulerConfig; + scheduler?: SchedulerConfig; /** * Notification runtime configuration. Notification dispatch is scheduled automatically by default. @@ -496,6 +498,16 @@ export interface Config< dispatch?: NotificationDispatchConfig; }; + /** + * Heartbeat runtime configuration. A single lifecycle-hook bundle runs for + * every heartbeat fire and is invoked by the heartbeat worker around + * heartbeat-driven agent runs; hooks branch per agent via the `agentId` on + * each context. Configuring hooks here (rather than on the Agent) lets both + * code-defined and stored agents share the same hook surface, since stored + * agents cannot define functions in their serialized config. + */ + heartbeat?: HeartbeatConfig<Mastra>; + /** * Platform channels for messaging integrations (Slack, Discord, etc.). * Routes are automatically registered and agents can reference channel configs. @@ -632,7 +644,7 @@ export class Mastra< #pubsub: PubSub; #backgroundTaskConfig?: BackgroundTaskManagerConfig; #backgroundTaskManager?: BackgroundTaskManager; - #schedulerConfig?: WorkflowSchedulerConfig; + #schedulerConfig?: SchedulerConfig; #notificationDispatchConfig?: NotificationDispatchConfig; /** * Tracks whether any registered workflow has declared a `schedule` config. @@ -642,10 +654,34 @@ export class Mastra< #hasScheduledWorkflow = false; #gateways?: Record<string, MastraModelGatewayInterface>; #channels?: TChannels; + #heartbeats?: Heartbeats; + #heartbeatConfig?: HeartbeatConfig<Mastra>; #environment?: string; #toolPayloadTransform?: ToolPayloadTransformPolicy; #workers: MastraWorker[] = []; #workerFilter?: Set<string>; + /** + * Set when the user (or `MASTRA_WORKERS=false`) explicitly disabled all event + * processing in this instance via `workers: false`. Gates lazy scheduler / + * heartbeat worker injection so runtime triggers (e.g. `heartbeats.create()`) + * don't resurrect workers the user opted out of. + */ + #workersDisabled = false; + /** + * Tracks whether `startWorkers()` has already run. Used to decide whether + * lazy scheduler injection (e.g. from `mastra.heartbeats.create()` after boot) + * needs to also `init`/`start` the worker, or whether the normal + * `startWorkers()` path will pick it up. + */ + #workersStarted = false; + /** + * Set when something has signalled that the scheduler is needed at runtime + * (e.g. an agent registered a heartbeat via `__ensureHeartbeatRuntimeReady()`). + * Causes `#shouldEnableScheduler()` to return `true` even when there are no + * declarative scheduled workflows, unless the user explicitly set + * `scheduler: { enabled: false }`. + */ + #schedulerRequested = false; // Lazily-constructed processor used by handleWorkflowEvent(). Shared between // pull-mode workers (OrchestrationWorker) and push-mode entry points // (in-process EventEmitter listener, the /api/workers/events HTTP route). @@ -801,7 +837,7 @@ export class Mastra< * SchedulerWorker (guarded by `#shouldEnableScheduler()`). Use it * to create, pause, resume, or delete schedules imperatively. */ - get scheduler(): WorkflowScheduler | undefined { + get scheduler(): Scheduler | undefined { return this.#findSchedulerWorker()?.scheduler; } @@ -875,6 +911,45 @@ export class Mastra< return (this.#channels ?? {}) as TChannels; } + /** + * Canonical entrypoint for heartbeats — recurring agent runs persisted as + * schedule rows with `target.type === 'heartbeat'`. Use to create, list, + * update, pause/resume, manually fire, or inspect trigger history for + * heartbeats across any agent. + * + * Lazily constructed. Operates against `getStorage()?.getStore('schedules')`. + * + * @example + * ```ts + * const hb = await mastra.heartbeats.create({ + * agentId: 'pinger', + * name: 'morning-checkin', + * cron: '0 9 * * *', + * prompt: 'good morning, anything to report?', + * threadId: 't1', + * resourceId: 'u1', + * }); + * await mastra.heartbeats.list({ agentId: 'pinger' }); + * ``` + */ + public get heartbeats(): Heartbeats { + this.#heartbeats ??= new Heartbeats(this as unknown as Mastra); + return this.#heartbeats; + } + + /** + * Returns the heartbeat lifecycle hook bundle configured via + * `new Mastra({ heartbeat: { ... } })`, if any. A single bundle runs for + * every heartbeat fire; hooks branch per agent via the `agentId` on each + * context. Internal: consumed by the {@link HeartbeatWorker} to invoke + * `prepare`, `onFinish`, `onError`, and `onAbort` around heartbeat-driven runs. + * + * @internal + */ + __getHeartbeatHooks(): HeartbeatHooks<Mastra> | undefined { + return this.#heartbeatConfig; + } + /** * Returns the global version overrides configured on this Mastra instance. * These are used as defaults when resolving sub-agent versions during delegation. @@ -1152,7 +1227,10 @@ export class Mastra< if (workersOption === false) { // Explicitly disabled — no event processing in this instance. - // PubSub still exists for publishing events. + // PubSub still exists for publishing events. Record the opt-out so + // runtime triggers (e.g. heartbeats.create()) don't lazily inject + // scheduler / heartbeat workers behind the user's back. + this.#workersDisabled = true; } else if (Array.isArray(workersOption)) { this.#workers = workersOption; for (const w of this.#workers) { @@ -1300,6 +1378,7 @@ export class Mastra< this.#schedulerConfig = config?.scheduler; this.#notificationDispatchConfig = config?.notifications?.dispatch; + this.#heartbeatConfig = config?.heartbeat; // Initialize all primitive storage objects first, we need to do this before adding primitives to avoid circular dependencies this.#vectors = {} as TVectors; @@ -1583,9 +1662,15 @@ export class Mastra< } #shouldEnableScheduler(): boolean { + // Honour an explicit `workers: false` opt-out — the user disabled all + // event processing in this instance, so never auto-inject scheduler / + // heartbeat workers (even when scheduler.enabled is true or a heartbeat + // is created at runtime). Standalone workers are expected to run the + // scheduler separately. + if (this.#workersDisabled) return false; if (this.#schedulerConfig?.enabled === false) return false; if (this.#schedulerConfig?.enabled === true) return true; - return this.#hasScheduledWorkflow; + return this.#hasScheduledWorkflow || this.#schedulerRequested; } /** @@ -1595,6 +1680,13 @@ export class Mastra< return this.#workers.find((w): w is SchedulerWorker => w.name === 'scheduler') as SchedulerWorker | undefined; } + /** + * Find the HeartbeatWorker from the workers list (if present). + */ + #findHeartbeatWorker(): MastraWorker | undefined { + return this.#workers.find(w => w.name === 'heartbeat'); + } + /** * Sync code-declared schedule configs to the database. Called by * SchedulerWorker during init and by addWorkflow() for late registrations. @@ -2050,6 +2142,14 @@ export class Mastra< // Set the Mastra instance on the durable agent for observability durableAgent.__setMastra?.(this); + // Propagate the definition source (e.g. 'fs') onto both the wrapper and + // the underlying agent. The durable branch returns early below, so it + // never reaches the shared `options?.source` handling. + if (options?.source) { + (durableAgent as unknown as Agent<any>).source = options.source; + underlyingAgent.source = options.source; + } + // Initialize the underlying agent (needed for tools, memory, etc.) underlyingAgent.__setLogger(this.#logger); underlyingAgent.__registerMastra(this); @@ -2183,6 +2283,39 @@ export class Mastra< } } + /** + * Registers a map of file-system routed agents (discovered from + * `agents/<name>/` directories) into this Mastra instance. + * + * Code-registered agents win on name collisions: if an agent with the same + * key already exists, the file-system agent is skipped and a warning is + * logged. Otherwise each agent is added via {@link addAgent} with + * `source: 'fs'`. + * + * Intended to be called by the bundler/dev generated entry, not by user code. + * + * @internal + */ + public __registerFsAgents(fsAgents: Record<string, Agent | ToolLoopAgentLike | DurableAgentLike>): void { + if (!fsAgents) { + return; + } + + const agents = this.#agents as Record<string, Agent<any>>; + for (const [key, agent] of Object.entries(fsAgents)) { + if (agent == null) { + continue; + } + if (agents[key]) { + this.getLogger().warn( + `File-system routed agent "${key}" conflicts with a code-registered agent of the same name. Keeping the code-registered agent.`, + ); + continue; + } + this.addAgent(agent, key, { source: 'fs' }); + } + } + /** * Removes an agent from the Mastra instance by its key or ID. * Used when stored agents are updated/deleted to allow fresh data to be loaded. @@ -3844,6 +3977,82 @@ export class Mastra< } } + /** + * Signal that a heartbeat has been registered imperatively at runtime + * (e.g. `mastra.heartbeats.create()` after `startWorkers()`). Flips the + * scheduler-requested flag and, if workers are already running, + * lazily injects + starts both the scheduler and heartbeat workers. + * + * @internal + */ + async __ensureHeartbeatRuntimeReady(): Promise<void> { + this.#schedulerRequested = true; + if (this.#workersStarted) { + await this.#ensureSchedulingWorkersStarted(); + } + } + + /** + * Lazily inject and start the SchedulerWorker (and HeartbeatWorker when + * needed) after `startWorkers()` has already run. Used by features that + * surface a need for the scheduler at runtime (e.g. + * `mastra.heartbeats.create()`). No-op when the scheduler is disabled, no + * storage is configured, or the workers are already present. + * + * @internal + */ + async #ensureSchedulingWorkersStarted(): Promise<void> { + if (!this.#shouldEnableScheduler()) return; + if (!this.#storage) return; + + const deps: WorkerDeps = { + pubsub: this.#pubsub, + storage: this.#storage, + logger: this.#logger as unknown as IMastraLogger, + mastra: this, + }; + + if (!this.#findSchedulerWorker()) { + const sw = new SchedulerWorker(this.#schedulerConfig); + sw.__registerMastra(this); + this.#workers.push(sw); + await sw.init(deps); + await sw.start(); + } + + if (!this.#findHeartbeatWorker()) { + const { HeartbeatWorker } = await import('../agent/heartbeat/worker'); + const hw = new HeartbeatWorker(); + hw.__registerMastra(this); + this.#workers.push(hw); + await hw.init(deps); + await hw.start(); + } + } + + /** + * Detect heartbeat schedule rows in storage on boot. Used by + * `#shouldEnableScheduler` to flip the scheduler-requested flag when + * imperative heartbeats persisted from a previous process exist — + * without this, a fresh boot with only DB-side heartbeats would skip + * starting the scheduler and heartbeat workers entirely. + * + * @internal + */ + async #detectExistingHeartbeats(): Promise<void> { + if (this.#schedulerRequested) return; + if (!this.#storage) return; + try { + const schedulesStore = await this.#storage.getStore('schedules'); + if (!schedulesStore) return; + const existing = await schedulesStore.listSchedules({ ownerType: 'agent' }); + if (existing.length === 0) return; + this.#schedulerRequested = true; + } catch (err) { + this.#logger?.warn?.('Failed to detect existing heartbeats on boot', err as any); + } + } + private registerStaticWorkflowScorers(workflow: AnyWorkflow): void { for (const step of Object.values(workflow.steps ?? {})) { const scorers = step.scorers; @@ -4504,14 +4713,42 @@ export class Mastra< * user-defined event listeners. */ public async startWorkers(name?: string): Promise<void> { - // Lazily inject the SchedulerWorker if the scheduler should be enabled - // and no scheduler worker is registered yet. This runs after all - // workflows have been registered (unlike the constructor's default-workers - // block), so #hasScheduledWorkflow is accurate. - if (!name && this.#shouldEnableScheduler() && this.#storage && !this.#findSchedulerWorker()) { - const sw = new SchedulerWorker(this.#schedulerConfig); - sw.__registerMastra(this); - this.#workers.push(sw); + // Initialize storage before any read so adapters that open/create their + // stores in init() are ready. The scheduler warm-up tick also persists a + // workflow snapshot on start(), which can race a lazy init() that creates + // `mastra_workflow_snapshot` ("no such table" on SQL stores, see #17905). + // init() is idempotent and a no-op when disabled. + if (this.#storage) { + await this.#storage.init(); + } + + // Flip the scheduler-requested flag if any heartbeat schedule rows + // exist in storage from a previous boot. Without this, a process + // that boots with only DB-side heartbeats (no in-code declarative + // schedules and no imperative `heartbeats.create()` calls yet) would + // skip injecting the scheduler + heartbeat workers entirely. This reads + // the schedules store, so it must run after storage.init() above. + if (!name) { + await this.#detectExistingHeartbeats(); + } + + // Lazily inject the SchedulerWorker + HeartbeatWorker if the + // scheduler should be enabled and they're not already registered. + // This runs after all workflows have been registered (unlike the + // constructor's default-workers block), so #hasScheduledWorkflow is + // accurate. + if (!name && this.#shouldEnableScheduler() && this.#storage) { + if (!this.#findSchedulerWorker()) { + const sw = new SchedulerWorker(this.#schedulerConfig); + sw.__registerMastra(this); + this.#workers.push(sw); + } + if (!this.#findHeartbeatWorker()) { + const { HeartbeatWorker } = await import('../agent/heartbeat/worker'); + const hw = new HeartbeatWorker(); + hw.__registerMastra(this); + this.#workers.push(hw); + } } const deps: WorkerDeps = { @@ -4538,17 +4775,6 @@ export class Mastra< targets = this.#workers; } - // Ensure storage is fully initialized before any worker starts. The - // scheduler worker runs an immediate warm-up tick on start(), which can - // dispatch an internal scheduled workflow (e.g. the notification - // dispatcher) and persist a workflow snapshot. Without awaiting init here, - // that write can race the lazy storage.init() that creates - // `mastra_workflow_snapshot`, producing "no such table" errors on SQL - // stores (see #17905). init() is idempotent and a no-op when disabled. - if (this.#storage) { - await this.#storage.init(); - } - for (const worker of targets) { await worker.init(deps); await worker.start(); @@ -4645,6 +4871,11 @@ export class Mastra< } } } + + // Track that the boot path has executed at least once so subsequent + // runtime signals (e.g. `mastra.heartbeats.create()`) know whether they need + // to lazily inject + start additional workers themselves. + this.#workersStarted = true; } /** @@ -4673,6 +4904,7 @@ export class Mastra< this.#userEventSubscriptions = []; await this.#pubsub.flush(); + this.#workersStarted = false; } /** diff --git a/packages/core/src/mastra/register-fs-agents.test.ts b/packages/core/src/mastra/register-fs-agents.test.ts new file mode 100644 index 000000000000..5b13216182d2 --- /dev/null +++ b/packages/core/src/mastra/register-fs-agents.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Agent } from '../agent'; +import { createDurableAgent } from '../agent/durable'; +import { Mastra } from './index'; + +function makeAgent(name: string) { + return new Agent({ + id: name, + name, + instructions: `You are ${name}`, + model: 'openai/gpt-4o', + }); +} + +describe('Mastra.__registerFsAgents', () => { + it('merges file-system agents into the instance', () => { + const mastra = new Mastra({ + agents: { coded: makeAgent('coded') }, + }); + + mastra.__registerFsAgents({ weather: makeAgent('weather') }); + + expect(mastra.getAgent('coded')).toBeDefined(); + expect(mastra.getAgent('weather')).toBeDefined(); + }); + + it('marks file-system agents with source "fs"', () => { + const mastra = new Mastra({}); + mastra.__registerFsAgents({ weather: makeAgent('weather') }); + expect(mastra.getAgent('weather').source).toBe('fs'); + }); + + it('keeps the code-registered agent on name collision and warns', () => { + const coded = makeAgent('weather'); + const fsAgent = makeAgent('weather'); + const mastra = new Mastra({ agents: { weather: coded } }); + + const warn = vi.fn(); + mastra.getLogger().warn = warn; + + mastra.__registerFsAgents({ weather: fsAgent }); + + expect(mastra.getAgent('weather')).toBe(coded); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('weather')); + }); + + it('stamps source on durable agents registered with a source', () => { + const base = makeAgent('durable-weather'); + const durable = createDurableAgent({ agent: base, id: 'durable-weather', name: 'durable-weather' }); + const mastra = new Mastra({}); + + mastra.addAgent(durable, 'durable-weather', { source: 'fs' }); + + expect(mastra.getAgent('durable-weather').source).toBe('fs'); + }); + + it('skips null entries without throwing', () => { + const mastra = new Mastra({}); + expect(() => mastra.__registerFsAgents({ bad: null as unknown as Agent, good: makeAgent('good') })).not.toThrow(); + expect(mastra.getAgent('good')).toBeDefined(); + }); +}); diff --git a/packages/core/src/mastra/scheduler-integration.test.ts b/packages/core/src/mastra/scheduler-integration.test.ts index 431ce19584db..5e3c3a8a49bd 100644 --- a/packages/core/src/mastra/scheduler-integration.test.ts +++ b/packages/core/src/mastra/scheduler-integration.test.ts @@ -75,7 +75,6 @@ describe('Mastra — workflow scheduler integration', () => { it('does not instantiate the scheduler when no schedules are configured', async () => { const storage = new MockStore(); - const getStoreSpy = vi.spyOn(storage, 'getStore'); const mastra = new Mastra({ logger: false, @@ -86,16 +85,16 @@ describe('Mastra — workflow scheduler integration', () => { await mastra.startWorkers(); await flushAsyncInit(); + // The schedules store may be touched on boot to check for existing + // heartbeat rows (cold-boot rehydration via #detectExistingHeartbeats). + // What matters is that the scheduler itself never spins up. expect(mastra.scheduler).toBeUndefined(); - // Prove the scheduler never touched the schedules domain. - expect(getStoreSpy.mock.calls.some(call => call[0] === 'schedules')).toBe(false); await mastra.shutdown(); }); it('does not instantiate the scheduler when only unscheduled workflows are registered', async () => { const storage = new MockStore(); - const getStoreSpy = vi.spyOn(storage, 'getStore'); const wf = createDefaultWorkflow({ id: 'plain-wf', @@ -121,8 +120,9 @@ describe('Mastra — workflow scheduler integration', () => { await mastra.startWorkers(); await flushAsyncInit(); + // As above, boot-time cold rehydration may probe the schedules store; + // the invariant under test is that no scheduler worker is created. expect(mastra.scheduler).toBeUndefined(); - expect(getStoreSpy.mock.calls.some(call => call[0] === 'schedules')).toBe(false); await mastra.shutdown(); }); diff --git a/packages/core/src/mastra/workers-filter.test.ts b/packages/core/src/mastra/workers-filter.test.ts index c925f3d78fa4..5a80f09c197b 100644 --- a/packages/core/src/mastra/workers-filter.test.ts +++ b/packages/core/src/mastra/workers-filter.test.ts @@ -28,27 +28,31 @@ describe('Mastra workers filter (MASTRA_WORKERS env)', () => { logger: false, }); - // Spy on workers known at construction time. - const preStarts = mastra.workers.map(w => ({ - name: w.name, - spy: vi.spyOn(w, 'start').mockResolvedValue(undefined), - initSpy: vi.spyOn(w, 'init').mockResolvedValue(undefined), - })); - - await mastra.startWorkers(); + // Mock start()/init() on construction-time workers so we can record which + // were started without running real worker side effects. The scheduler + + // heartbeat workers are injected lazily inside startWorkers() — they + // aren't visible here, so their real start() runs and isRunning reflects + // whether they passed the MASTRA_WORKERS filter. + const knownStarted: string[] = []; + const knownWorkers = mastra.workers.map(w => w.name); + for (const w of mastra.workers) { + vi.spyOn(w, 'start').mockImplementation(async () => { + knownStarted.push(w.name); + }); + vi.spyOn(w, 'init').mockResolvedValue(undefined); + } - // SchedulerWorker is injected lazily in startWorkers(), so we must - // also spy-check workers that appeared after the call. - const allStarts = mastra.workers.map(w => { - const pre = preStarts.find(p => p.name === w.name); - return { name: w.name, started: pre ? pre.spy.mock.calls.length > 0 : true }; - }); - const started = allStarts.filter(s => s.started).map(s => s.name); - expect(started.sort()).toEqual(['backgroundTasks', 'scheduler']); + try { + await mastra.startWorkers(); - // orchestration was not started - const orchestration = preStarts.find(s => s.name === 'orchestration'); - expect(orchestration?.spy).not.toHaveBeenCalled(); + // Lazily-injected workers (not in knownWorkers) report via isRunning. + const lazyStarted = mastra.workers.filter(w => !knownWorkers.includes(w.name) && w.isRunning).map(w => w.name); + const started = [...knownStarted, ...lazyStarted]; + expect(started.sort()).toEqual(['backgroundTasks', 'scheduler']); + expect(started).not.toContain('orchestration'); + } finally { + await mastra.stopWorkers(); + } }); it('starts all workers when MASTRA_WORKERS is unset', async () => { diff --git a/packages/core/src/memory/mock.ts b/packages/core/src/memory/mock.ts index 708016d579a5..cde04ede233c 100644 --- a/packages/core/src/memory/mock.ts +++ b/packages/core/src/memory/mock.ts @@ -237,7 +237,7 @@ export class MockMemory extends MastraMemory { public listTools(_config?: MemoryConfigInternal): Record<string, ToolAction<any, any, any>> { const mergedConfig = this.getMergedThreadConfig(_config); - if (!mergedConfig.workingMemory?.enabled) { + if (!mergedConfig.workingMemory?.enabled || mergedConfig.workingMemory.agentManaged === false) { return {}; } diff --git a/packages/core/src/memory/types.ts b/packages/core/src/memory/types.ts index a86df7e71c08..5e56569213b0 100644 --- a/packages/core/src/memory/types.ts +++ b/packages/core/src/memory/types.ts @@ -57,6 +57,8 @@ export type ThreadOMMetadata = { suggestedResponse?: string; /** Observer-generated thread title */ threadTitle?: string; + /** Extracted Observational Memory values keyed by extractor slug */ + extracted?: Record<string, unknown>; /** Timestamp of the last observed message in this thread (ISO string for JSON serialization) */ lastObservedAt?: string; /** Cursor pointing at the last observed message (for replay pruning fallback) */ @@ -194,6 +196,14 @@ type BaseWorkingMemory = { * @see docs/src/content/en/docs/agents/signals.mdx */ useStateSignals?: boolean; + /** + * Whether the main agent manages working memory directly through tool/instruction injection. + * Set to false when another path, such as Observational Memory extractors, + * owns working memory updates. + * + * @default true + */ + agentManaged?: boolean; /** @deprecated The `use` option has been removed. Working memory always uses tool-call mode. */ use?: never; }; @@ -434,6 +444,16 @@ export interface ObservationalMemoryObservationConfig { */ model?: AgentConfig['model']; + /** + * Manage working memory through Observational Memory extraction. + * When enabled alongside `workingMemory.enabled`, Memory supplies defaults that + * disable main-agent working memory management and add the WorkingMemoryExtractor. + * Set `workingMemory.agentManaged: true` to keep main-agent tools/instructions enabled. + * + * @default false + */ + manageWorkingMemory?: boolean; + /** * Token count of unobserved messages that triggers observation. * When unobserved message tokens exceed this, the Observer is called. @@ -1319,6 +1339,9 @@ export type SerializedObservationalMemoryConfig = { export type SerializedObservationalMemoryObservationConfig = { /** Observer model ID */ model?: string; + /** Manage working memory through Observational Memory extraction. */ + manageWorkingMemory?: boolean; + /** Token count threshold that triggers observation */ messageTokens?: number; /** Model settings (temperature, maxOutputTokens, etc.) */ diff --git a/packages/core/src/notifications/dispatcher.ts b/packages/core/src/notifications/dispatcher.ts index ea6d8e8c4bab..d9a53bc21e6e 100644 --- a/packages/core/src/notifications/dispatcher.ts +++ b/packages/core/src/notifications/dispatcher.ts @@ -11,10 +11,6 @@ type NotificationDispatchAgent = { id?: string; getPubSub?: () => PubSub | undefined; sendSignal: (signal: CreatedAgentSignal, target: SendAgentSignalOptions) => SendAgentSignalResult; - getNotificationStreamOptions?: (target: { - resourceId: string; - threadId: string; - }) => Record<string, unknown> | Promise<Record<string, unknown> | undefined> | undefined; }; export type DispatchDueNotificationsInput = { @@ -130,15 +126,7 @@ async function sendNotificationRecord({ deliveredAt: now, lastDeliveryAttemptAt: now, }); - const streamOptions = await agent.getNotificationStreamOptions?.({ - resourceId: current.resourceId, - threadId: current.threadId, - }); - const target: SendAgentSignalOptions = { - resourceId: current.resourceId, - threadId: current.threadId, - ...(streamOptions ? { ifIdle: { streamOptions } } : {}), - }; + const target: SendAgentSignalOptions = { resourceId: current.resourceId, threadId: current.threadId }; const result = agent.sendSignal(signal, target); // `accepted` rejects when the signal could not be routed/started (e.g. a // misconfigured agent). Let that propagate so the caller records the @@ -173,19 +161,9 @@ async function sendNotificationSummary({ const agent = await mastra.getAgentById(first.agentId as never); const summary = summarizeNotifications(records); const signal = createNotificationSummarySignal(summary); - const streamOptions = await (agent as NotificationDispatchAgent).getNotificationStreamOptions?.({ - resourceId: first.resourceId, - threadId: first.threadId, - }); - const allLow = records.every(record => record.priority === 'low'); - const ifIdle: Record<string, unknown> = {}; - if (allLow) ifIdle.behavior = 'persist' as const; - if (streamOptions) ifIdle.streamOptions = streamOptions; - const target: SendAgentSignalOptions = { - resourceId: first.resourceId, - threadId: first.threadId, - ...(Object.keys(ifIdle).length > 0 ? { ifIdle } : {}), - }; + const target: SendAgentSignalOptions = records.every(record => record.priority === 'low') + ? { resourceId: first.resourceId, threadId: first.threadId, ifIdle: { behavior: 'persist' } } + : { resourceId: first.resourceId, threadId: first.threadId }; const result = (agent as NotificationDispatchAgent).sendSignal(signal, target); // `accepted` rejects when the signal could not be routed/started; let it // propagate so the caller records the notifications as failed deliveries. diff --git a/packages/core/src/notifications/notifications.test.ts b/packages/core/src/notifications/notifications.test.ts index 1cb9c6d9abda..e76be8ba53c8 100644 --- a/packages/core/src/notifications/notifications.test.ts +++ b/packages/core/src/notifications/notifications.test.ts @@ -777,145 +777,4 @@ describe('notification inbox', () => { }, ]); }); - - it('passes getNotificationStreamOptions into ifIdle for individual dispatch', async () => { - const storage = new InMemoryNotificationsStorage(); - const now = new Date('2026-05-30T12:00:00Z'); - const sent: any[] = []; - const sendSignal = vi.fn((signal, target) => { - sent.push({ signal, target }); - return { accepted: Promise.resolve({ action: 'deliver', runId: 'run-1' }), signal }; - }); - const getNotificationStreamOptions = vi.fn(async () => ({ - requestContext: { controller: { session: { modelId: 'gpt-4o' } } }, - })); - const mastra = { getAgentById: vi.fn(async () => ({ sendSignal, getNotificationStreamOptions })) } as any; - await storage.createNotification({ - id: 'n1', - agentId: 'agent-1', - resourceId: 'resource-1', - threadId: 'thread-1', - source: 'github', - kind: 'ci-status', - priority: 'high', - summary: 'CI failed', - deliverAt: now, - }); - - const result = await dispatchDueNotifications({ mastra, storage, now }); - - expect(result.failed).toEqual([]); - expect(result.delivered).toMatchObject([{ id: 'n1', status: 'delivered' }]); - expect(getNotificationStreamOptions).toHaveBeenCalledWith({ resourceId: 'resource-1', threadId: 'thread-1' }); - expect(sent[0]?.target).toEqual({ - resourceId: 'resource-1', - threadId: 'thread-1', - ifIdle: { streamOptions: { requestContext: { controller: { session: { modelId: 'gpt-4o' } } } } }, - }); - }); - - it('passes getNotificationStreamOptions into ifIdle for summary dispatch', async () => { - const storage = new InMemoryNotificationsStorage(); - const now = new Date('2026-05-30T12:00:00Z'); - const sendSignal = vi.fn((signal, _target) => ({ - accepted: Promise.resolve({ action: 'deliver', runId: 'run-1' }), - signal, - persisted: Promise.resolve(), - })); - const getNotificationStreamOptions = vi.fn(async () => ({ - requestContext: { controller: { session: { modelId: 'gpt-4o' } } }, - })); - const mastra = { getAgentById: vi.fn(async () => ({ sendSignal, getNotificationStreamOptions })) } as any; - await storage.createNotification({ - id: 'n1', - agentId: 'agent-1', - resourceId: 'resource-1', - threadId: 'thread-1', - source: 'github', - kind: 'mention', - summary: 'Someone mentioned you', - summaryAt: now, - }); - - const result = await dispatchDueNotifications({ mastra, storage, now }); - - expect(result.failed).toEqual([]); - expect(getNotificationStreamOptions).toHaveBeenCalledWith({ resourceId: 'resource-1', threadId: 'thread-1' }); - expect(sendSignal).toHaveBeenCalledWith( - expect.objectContaining({ type: 'notification', tagName: 'notification-summary' }), - { - resourceId: 'resource-1', - threadId: 'thread-1', - ifIdle: { streamOptions: { requestContext: { controller: { session: { modelId: 'gpt-4o' } } } } }, - }, - ); - }); - - it('includes both behavior and streamOptions for low-priority summary with getNotificationStreamOptions', async () => { - const storage = new InMemoryNotificationsStorage(); - const now = new Date('2026-05-30T12:00:00Z'); - const sendSignal = vi.fn((signal, _target) => ({ - accepted: Promise.resolve({ action: 'persist' }), - signal, - persisted: Promise.resolve(), - })); - const getNotificationStreamOptions = vi.fn(async () => ({ - requestContext: { controller: { session: { modelId: 'gpt-4o' } } }, - })); - const mastra = { getAgentById: vi.fn(async () => ({ sendSignal, getNotificationStreamOptions })) } as any; - await storage.createNotification({ - id: 'n1', - agentId: 'agent-1', - resourceId: 'resource-1', - threadId: 'thread-1', - source: 'github', - kind: 'ci-status', - priority: 'low', - summary: 'Low priority update', - summaryAt: now, - }); - - const result = await dispatchDueNotifications({ mastra, storage, now }); - - expect(result.failed).toEqual([]); - expect(sendSignal).toHaveBeenCalledWith( - expect.objectContaining({ type: 'notification', tagName: 'notification-summary' }), - { - resourceId: 'resource-1', - threadId: 'thread-1', - ifIdle: { - behavior: 'persist', - streamOptions: { requestContext: { controller: { session: { modelId: 'gpt-4o' } } } }, - }, - }, - ); - }); - - it('omits ifIdle when getNotificationStreamOptions is not defined on agent', async () => { - const storage = new InMemoryNotificationsStorage(); - const now = new Date('2026-05-30T12:00:00Z'); - const sent: any[] = []; - const sendSignal = vi.fn((signal, target) => { - sent.push({ signal, target }); - return { accepted: Promise.resolve({ action: 'deliver', runId: 'run-1' }), signal }; - }); - const mastra = { getAgentById: vi.fn(async () => ({ sendSignal })) } as any; - await storage.createNotification({ - id: 'n1', - agentId: 'agent-1', - resourceId: 'resource-1', - threadId: 'thread-1', - source: 'github', - kind: 'ci-status', - priority: 'high', - summary: 'CI failed', - deliverAt: now, - }); - - const result = await dispatchDueNotifications({ mastra, storage, now }); - - expect(result.failed).toEqual([]); - expect(result.delivered).toMatchObject([{ id: 'n1', status: 'delivered' }]); - expect(sent[0]?.target).toEqual({ resourceId: 'resource-1', threadId: 'thread-1' }); - }); }); diff --git a/packages/core/src/observability/types/core.ts b/packages/core/src/observability/types/core.ts index 7982e6de45f3..799bead47dfa 100644 --- a/packages/core/src/observability/types/core.ts +++ b/packages/core/src/observability/types/core.ts @@ -123,7 +123,7 @@ export interface ObservabilityContext { // ============================================================================ /** Where a registered definition came from. */ -export type DefinitionSource = 'code' | 'stored'; +export type DefinitionSource = 'code' | 'stored' | 'fs'; /** What kind of scoring flow produced the score. */ export type ScorerScoreSource = 'live' | 'trace' | 'experiment'; diff --git a/packages/core/src/processors/index.ts b/packages/core/src/processors/index.ts index 87f384d42187..da023fd78586 100644 --- a/packages/core/src/processors/index.ts +++ b/packages/core/src/processors/index.ts @@ -1,6 +1,7 @@ import type { LanguageModelV2, LanguageModelV2CallWarning, LanguageModelV2Prompt } from '@ai-sdk/provider-v5'; import type { CoreMessage as CoreMessageV4 } from '@internal/ai-sdk-v4'; import type { CallSettings, StepResult, ToolChoice } from '@internal/ai-sdk-v5'; +import type { Agent } from '../agent'; import type { MessageList, MastraDBMessage } from '../agent/message-list'; import type { AgentSignalInput, AgentStateSignalInput, CreatedAgentSignal } from '../agent/signals'; import type { ApplyStateSignalResult } from '../agent/state-signals'; @@ -58,6 +59,8 @@ export interface ProcessorContext<TTripwireMetadata = unknown> extends Partial<O abort: (reason?: string, options?: TripWireOptions<TTripwireMetadata>) => never; /** Optional runtime context with execution metadata */ requestContext?: RequestContext; + /** Real agent instance when processors are running inside an agent execution. Processor-only workflow contexts may omit it. */ + agent?: Agent<any, any, any, any>; /** * Add a signal to the message list, rotate the response message id when supported, * and emit the signal as a data-* stream part when a writer is available. diff --git a/packages/core/src/processors/processors/structured-output.ts b/packages/core/src/processors/processors/structured-output.ts index 1e4638bb2760..e0e6485e5bb6 100644 --- a/packages/core/src/processors/processors/structured-output.ts +++ b/packages/core/src/processors/processors/structured-output.ts @@ -45,7 +45,7 @@ export class StructuredOutputProcessor<OUTPUT extends {}> implements Processor<' private errorStrategy: 'strict' | 'warn' | 'fallback'; private fallbackValue?: OUTPUT; private isStructuringAgentStreamStarted = false; - private jsonPromptInjection?: boolean; + private jsonPromptInjection?: boolean | 'system' | 'inline'; private providerOptions?: ProviderOptions; private logger?: IMastraLogger; diff --git a/packages/core/src/processors/runner.ts b/packages/core/src/processors/runner.ts index 55e72c7ee941..ed489e86c68f 100644 --- a/packages/core/src/processors/runner.ts +++ b/packages/core/src/processors/runner.ts @@ -1,5 +1,6 @@ import type { LanguageModelV2Prompt, LanguageModelV2CallWarning } from '@ai-sdk/provider-v5'; import type { StepResult } from '@internal/ai-sdk-v5'; +import type { Agent } from '../agent'; import type { MastraDBMessage, MessageInput } from '../agent/message-list'; import { MessageList, messagesAreEqual } from '../agent/message-list'; import type { AgentStateSignalInput } from '../agent/signals'; @@ -267,6 +268,7 @@ export class ProcessorRunner { public readonly errorProcessors: ErrorProcessorOrWorkflow[]; private readonly logger: IMastraLogger; private readonly agentName: string; + private readonly agent?: Agent<any, any, any, any>; /** * Shared processor state that persists across loop iterations. * Used by all processor methods (input and output) to share state. @@ -280,6 +282,7 @@ export class ProcessorRunner { errorProcessors, logger, agentName, + agent, processorStates, }: { inputProcessors?: ProcessorOrWorkflow[]; @@ -287,6 +290,7 @@ export class ProcessorRunner { errorProcessors?: ErrorProcessorOrWorkflow[]; logger: IMastraLogger; agentName: string; + agent?: Agent<any, any, any, any>; processorStates?: Map<string, ProcessorState>; }) { this.inputProcessors = inputProcessors ?? []; @@ -294,6 +298,7 @@ export class ProcessorRunner { this.errorProcessors = errorProcessors ?? []; this.logger = logger; this.agentName = agentName; + this.agent = agent; this.processorStates = processorStates ?? new Map(); } @@ -514,6 +519,7 @@ export class ProcessorRunner { processorStates: this.processorStates, // Pass abortSignal so processors can cancel in-flight work abortSignal, + agent: this.agent, } as ProcessorStepOutput, ...observabilityContext, requestContext, @@ -667,6 +673,7 @@ export class ProcessorRunner { state: processorState.customState, result: result ?? defaultResult, abort, + agent: this.agent, ...createObservabilityContext({ currentSpan: processorSpan }), requestContext, retryCount, @@ -845,6 +852,7 @@ export class ProcessorRunner { part: processedPart as ChunkType, streamParts: state.streamParts as ChunkType[], state: state.customState, + agent: this.agent, abort: <TMetadata = unknown>(reason?: string, options?: TripWireOptions<TMetadata>): never => { throw new TripWire(reason || `Stream part blocked by ${processor.id}`, options, processor.id); }, @@ -1166,6 +1174,7 @@ export class ProcessorRunner { systemMessages: currentSystemMessages, state: processorState.customState, abort, + agent: this.agent, ...createObservabilityContext({ currentSpan: processorSpan }), messageList, requestContext, @@ -1440,6 +1449,7 @@ export class ProcessorRunner { modelSettings: stepInput.modelSettings, structuredOutput: stepInput.structuredOutput, requestContext, + agent: this.agent, }; // Use the current span (the step span) as the parent for processor spans @@ -1499,6 +1509,7 @@ export class ProcessorRunner { retryCount: args.retryCount ?? 0, writer, abortSignal: args.abortSignal, + agent: this.agent, sendSignal: createProcessorSendSignal({ messageList, writer, rotateResponseMessageId }), sendStateSignal: async ( stateSignal: AgentStateSignalInput | (Omit<AgentStateSignalInput, 'id'> & { id?: string }), @@ -1670,6 +1681,7 @@ export class ProcessorRunner { state: processorState.customState, retryCount: args.retryCount ?? 0, requestContext: args.requestContext, + agent: this.agent, abort, abortSignal: args.abortSignal, writer: args.writer, @@ -1755,6 +1767,7 @@ export class ProcessorRunner { fromCache: args.fromCache, retryCount: args.retryCount ?? 0, requestContext: args.requestContext, + agent: this.agent, abort, abortSignal: args.abortSignal, writer: args.writer, @@ -1932,6 +1945,7 @@ export class ProcessorRunner { abort, ...createObservabilityContext({ currentSpan: processorSpan }), requestContext, + agent: this.agent, retryCount, writer, sendSignal: createProcessorSendSignal({ messageList, writer }), @@ -2111,6 +2125,7 @@ export class ProcessorRunner { abort, ...createObservabilityContext({ currentSpan: processorSpan }), requestContext, + agent: this.agent, retryCount, writer, abortSignal, diff --git a/packages/core/src/storage/domains/observability/inmemory-trace-exclusive-filter.test.ts b/packages/core/src/storage/domains/observability/inmemory-trace-exclusive-filter.test.ts new file mode 100644 index 000000000000..4cd0f0727d50 --- /dev/null +++ b/packages/core/src/storage/domains/observability/inmemory-trace-exclusive-filter.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { EntityType, SpanType } from '../../../observability/types'; +import { InMemoryStore } from '../../mock'; + +function makeRootSpan(traceId: string, startedAt: Date) { + return { + traceId, + spanId: `${traceId}-root`, + parentSpanId: null, + name: 'agent-run', + spanType: SpanType.AGENT_RUN, + isEvent: false, + entityType: EntityType.AGENT, + entityId: 'agent-1', + entityName: 'myAgent', + userId: null, + organizationId: null, + resourceId: null, + runId: null, + sessionId: null, + threadId: null, + requestId: null, + environment: 'test', + source: null, + serviceName: 'test-service', + scope: null, + attributes: {}, + metadata: {}, + tags: [], + links: null, + input: null, + output: null, + error: null, + startedAt, + endedAt: null, + } as any; +} + +const T0 = new Date('2026-01-01T00:00:00.000Z'); +const T1 = new Date('2026-01-02T00:00:00.000Z'); + +async function seedTraces() { + const store = new InMemoryStore(); + const obs = (await store.getStore('observability'))!; + await obs.createSpan({ span: makeRootSpan('trace-A', T0) }); + await obs.createSpan({ span: makeRootSpan('trace-B', T1) }); + return obs; +} + +describe('ObservabilityInMemory listTraces startExclusive/endExclusive', () => { + it('startExclusive excludes a trace whose startedAt equals the boundary', async () => { + const obs = await seedTraces(); + + const inclusive = await obs.listTraces({ filters: { startedAt: { start: T1 } } }); + expect(inclusive.spans.map((s: any) => s.traceId)).toContain('trace-B'); + + const exclusive = await obs.listTraces({ filters: { startedAt: { start: T1, startExclusive: true } } }); + expect(exclusive.spans.map((s: any) => s.traceId)).not.toContain('trace-B'); + }); + + it('endExclusive excludes a trace whose startedAt equals the end boundary', async () => { + const obs = await seedTraces(); + + const inclusive = await obs.listTraces({ filters: { startedAt: { end: T0 } } }); + expect(inclusive.spans.map((s: any) => s.traceId)).toContain('trace-A'); + + const exclusive = await obs.listTraces({ filters: { startedAt: { end: T0, endExclusive: true } } }); + expect(exclusive.spans.map((s: any) => s.traceId)).not.toContain('trace-A'); + }); +}); diff --git a/packages/core/src/storage/domains/observability/inmemory.ts b/packages/core/src/storage/domains/observability/inmemory.ts index e1982ed8c691..f088001ba5bb 100644 --- a/packages/core/src/storage/domains/observability/inmemory.ts +++ b/packages/core/src/storage/domains/observability/inmemory.ts @@ -776,10 +776,20 @@ export class ObservabilityInMemory extends ObservabilityStorage { // Date range filters on startedAt (based on root span) if (filters.startedAt) { - if (filters.startedAt.start && rootSpan.startedAt < filters.startedAt.start) { + if ( + filters.startedAt.start && + (filters.startedAt.startExclusive + ? rootSpan.startedAt <= filters.startedAt.start + : rootSpan.startedAt < filters.startedAt.start) + ) { return false; } - if (filters.startedAt.end && rootSpan.startedAt > filters.startedAt.end) { + if ( + filters.startedAt.end && + (filters.startedAt.endExclusive + ? rootSpan.startedAt >= filters.startedAt.end + : rootSpan.startedAt > filters.startedAt.end) + ) { return false; } } @@ -790,10 +800,20 @@ export class ObservabilityInMemory extends ObservabilityStorage { if (rootSpan.endedAt == null) { return false; } - if (filters.endedAt.start && rootSpan.endedAt < filters.endedAt.start) { + if ( + filters.endedAt.start && + (filters.endedAt.startExclusive + ? rootSpan.endedAt <= filters.endedAt.start + : rootSpan.endedAt < filters.endedAt.start) + ) { return false; } - if (filters.endedAt.end && rootSpan.endedAt > filters.endedAt.end) { + if ( + filters.endedAt.end && + (filters.endedAt.endExclusive + ? rootSpan.endedAt >= filters.endedAt.end + : rootSpan.endedAt > filters.endedAt.end) + ) { return false; } } diff --git a/packages/core/src/storage/domains/schedules/base.ts b/packages/core/src/storage/domains/schedules/base.ts index b65685e6d5d2..aa44be8103ae 100644 --- a/packages/core/src/storage/domains/schedules/base.ts +++ b/packages/core/src/storage/domains/schedules/base.ts @@ -1,12 +1,19 @@ +import type { HeartbeatIfActive, HeartbeatIfIdle } from '../../../agent/heartbeat/types'; +import type { AgentSignalAttributes, AgentSignalType } from '../../../agent/signals'; import { StorageDomain } from '../base'; /** * Discriminated union describing what a schedule fires. * - * Only the `workflow` variant is implemented in v1. Future targets - * (e.g. `agent-signal`) can be added without a schema migration. + * `workflow` targets publish a `workflow.start` event on the `workflows` + * pubsub topic and are processed by the orchestration worker. `heartbeat` + * targets publish a `heartbeat.fire` event on the `heartbeats` pubsub + * topic and are processed by the {@link HeartbeatWorker}, which runs the + * referenced agent directly (no workflow indirection). */ -export type ScheduleTarget = { +export type ScheduleTarget = WorkflowScheduleTarget | HeartbeatScheduleTarget; + +export type WorkflowScheduleTarget = { type: 'workflow'; workflowId: string; inputData?: unknown; @@ -14,13 +21,56 @@ export type ScheduleTarget = { requestContext?: Record<string, unknown>; }; +// Heartbeat semantic types are owned by `agent/heartbeat/types.ts` (the +// feature module) and re-exported here so callers describing schedule rows can +// reach them through the storage barrel. +export type { HeartbeatIfActive, HeartbeatIfIdle } from '../../../agent/heartbeat/types'; + +/** + * Schedule target that fires an agent run on a cron. The heartbeat + * worker reads these fields and runs the referenced agent directly — + * either via `sendSignal` (when `threadId` is set) or `agent.generate` + * (threadless). The agent's `runId` is recorded on the trigger row for + * UI linkability into chat / observability traces. + */ +export type HeartbeatScheduleTarget = { + type: 'heartbeat'; + agentId: string; + prompt: string; + /** + * Free-form label for distinguishing multiple heartbeats on the same + * agent/thread (e.g. `'morning-checkin'`). Optional; filterable via + * `mastra.heartbeats.list({ name })`. + */ + name?: string; + /** Threaded heartbeats send a signal into this thread. */ + threadId?: string; + /** Required when `threadId` is set. */ + resourceId?: string; + /** Signal type used by threaded heartbeats. Defaults to `'notification'`. */ + signalType?: AgentSignalType; + /** XML tag the signal renders as. Defaults to `'heartbeat'`. */ + tagName?: string; + /** Signal attributes rendered onto the XML tag. */ + attributes?: AgentSignalAttributes; + /** Provider options merged into the heartbeat signal payload on every fire. JSON-safe. */ + providerOptions?: Record<string, unknown>; + /** Options applied when the target thread is actively streaming. Threaded only. */ + ifActive?: HeartbeatIfActive; + /** Options applied when the target thread is idle (incl. serializable streamOptions). Threaded only. */ + ifIdle?: HeartbeatIfIdle; + /** Arbitrary metadata stored alongside the schedule row. */ + metadata?: Record<string, unknown>; + requestContext?: Record<string, unknown>; +}; + /** Lifecycle status of a schedule row. */ export type ScheduleStatus = 'active' | 'paused'; /** * Polymorphic owner of a schedule. Workflow schedules created via * `createWorkflow({ schedule })` leave both fields null. Heartbeat - * schedules created via `agent.setHeartbeat(...)` set + * schedules created via `mastra.heartbeats.create(...)` set * `ownerType: 'agent'` and `ownerId` to the agent id. Future schedule * types (tenant-owned, workflow-owned, etc.) can use the same shape * without a migration. @@ -55,16 +105,42 @@ export type Schedule = { /** * Outcome of an individual schedule trigger attempt. * - * `published` and `failed` cover scheduler tick fires. Heartbeat - * outcomes (`acked`, `alerted`, `deferred`, `appended-from-queue`, - * `dropped-stale`, `dropped-superseded`, `dropped-busy`, `skipped`) - * are recorded by the heartbeat workflow. `failed` covers any - * unhandled error during a fire or drain. + * Shared across all schedule target types (workflows, heartbeats, …). + * + * Workflow outcomes: + * - `published` — workflow run was successfully dispatched to the workflow + * engine. Write-once at dispatch time; the trigger row is + * not updated when the run later completes. + * - `failed` — dispatch threw (workflow or heartbeat). + * + * Heartbeat outcomes (terminal — written after the run/signal resolves): + * - `succeeded` — the heartbeat agent run finished without error. + * - `delivered` — the heartbeat signal joined an active run on the target + * thread instead of starting a new one (`ifActive: 'deliver'`). + * - `persisted` — the signal was saved to memory without triggering a run + * (`ifActive: 'persist'` or `ifIdle: 'persist'`). + * - `discarded` — the signal was dropped without effect + * (`ifActive: 'discard'` or `ifIdle: 'discard'`). + * - `skipped` — the user `prepare` hook returned `null`, asking the worker + * to skip this fire entirely. + * - `aborted` — the agent run was aborted mid-stream. + * + * Legacy outcomes (no longer written, kept readable for rows persisted by + * older builds so that listing/exhaustive handling does not break): + * - `acked`, `alerted`, `deferred`, `appended-from-queue`, `dropped-stale`, + * `dropped-superseded`, `dropped-busy`. */ export type ScheduleTriggerOutcome = | 'published' - | 'failed' + | 'succeeded' + | 'delivered' + | 'persisted' + | 'discarded' | 'skipped' + | 'aborted' + | 'failed' + // Legacy queue/notification outcomes — never written by current code, but + // older trigger rows may still carry them. Retained so reads stay typed. | 'acked' | 'alerted' | 'deferred' @@ -74,17 +150,25 @@ export type ScheduleTriggerOutcome = | 'dropped-busy'; /** - * Distinguishes a tick-loop schedule fire from a deferred drain event. - * Drain rows reference the original fire via `parentTriggerId`. + * Distinguishes a tick-loop schedule fire from a deferred drain event or a + * manual ("fire now") invocation. Drain rows reference the original fire + * via `parentTriggerId`. */ -export type ScheduleTriggerKind = 'schedule-fire' | 'queue-drain'; +export type ScheduleTriggerKind = 'schedule-fire' | 'queue-drain' | 'manual'; /** Audit record produced for each trigger attempt. */ export type ScheduleTrigger = { /** Stable trigger row id. Generated by storage when omitted on write. */ id?: string; scheduleId: string; - /** May be null for drain rows that have no associated workflow run. */ + /** + * Identifier of the downstream run produced by this fire. + * + * For workflow targets this is the workflow run id (`sched_<scheduleId>_<ts>`). + * For heartbeat targets this is the agent run id recorded by the + * {@link HeartbeatWorker} after the agent run starts. May be null for + * drain rows or fires that failed before producing a run id. + */ runId: string | null; scheduledFireAt: number; actualFireAt: number; @@ -125,7 +209,7 @@ export type ScheduleUpdate = Partial< /** * Abstract storage domain for workflow schedules. * - * Powers the {@link WorkflowScheduler}: the scheduler's tick loop polls + * Powers the {@link Scheduler}: the scheduler's tick loop polls * `listDueSchedules`, atomically advances `nextFireAt` via * `updateScheduleNextFire` (CAS), publishes a `workflow.start` event on * the `workflows` pubsub topic, and records the trigger via `recordTrigger`. diff --git a/packages/core/src/storage/domains/scores/inmemory-list-order.test.ts b/packages/core/src/storage/domains/scores/inmemory-list-order.test.ts new file mode 100644 index 000000000000..c600c4a88591 --- /dev/null +++ b/packages/core/src/storage/domains/scores/inmemory-list-order.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import type { SaveScorePayload } from '../../../evals/types'; +import { InMemoryStore } from '../../mock'; + +function makeScore(scorerId: string, createdAt: Date, runId: string): SaveScorePayload { + return { + scorerId, + runId, + createdAt, + scorer: { name: scorerId }, + source: 'TEST', + entityId: 'entity-1', + entityType: 'AGENT', + score: 1, + input: {}, + output: {}, + } as unknown as SaveScorePayload; +} + +describe('ScoresInMemory listScoresByScorerId ordering', () => { + it('returns scores newest-first (createdAt DESC), matching pg/libsql', async () => { + const store = new InMemoryStore(); + const scores = (await store.getStore('scores'))!; + const scorerId = 'scorer-1'; + + // Saved oldest -> newest (insertion order is ascending by createdAt). + await scores.saveScore(makeScore(scorerId, new Date('2026-01-01T00:00:00.000Z'), 'run-old')); + await scores.saveScore(makeScore(scorerId, new Date('2026-01-02T00:00:00.000Z'), 'run-mid')); + await scores.saveScore(makeScore(scorerId, new Date('2026-01-03T00:00:00.000Z'), 'run-new')); + + const { scores: page } = await scores.listScoresByScorerId({ + scorerId, + pagination: { page: 0, perPage: 10 }, + }); + + const runIds = page.map(s => s.runId); + expect(runIds).toEqual(['run-new', 'run-mid', 'run-old']); + }); + + it('respects DESC order across pagination (first page is the newest)', async () => { + const store = new InMemoryStore(); + const scores = (await store.getStore('scores'))!; + const scorerId = 'scorer-1'; + + await scores.saveScore(makeScore(scorerId, new Date('2026-01-01T00:00:00.000Z'), 'run-old')); + await scores.saveScore(makeScore(scorerId, new Date('2026-01-02T00:00:00.000Z'), 'run-mid')); + await scores.saveScore(makeScore(scorerId, new Date('2026-01-03T00:00:00.000Z'), 'run-new')); + + const { scores: firstPage } = await scores.listScoresByScorerId({ + scorerId, + pagination: { page: 0, perPage: 2 }, + }); + + expect(firstPage.map(s => s.runId)).toEqual(['run-new', 'run-mid']); + }); +}); diff --git a/packages/core/src/storage/domains/scores/inmemory.ts b/packages/core/src/storage/domains/scores/inmemory.ts index bccd215cdb42..cd64570d4dc2 100644 --- a/packages/core/src/storage/domains/scores/inmemory.ts +++ b/packages/core/src/storage/domains/scores/inmemory.ts @@ -57,6 +57,10 @@ export class ScoresInMemory extends ScoresStorage { return baseFilter; }); + // Match the pg/libsql adapters (and the sibling listScoresBySpan), which + // return scores newest-first. + scores.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + const { page, perPage: perPageInput } = pagination; const perPage = normalizePerPage(perPageInput, Number.MAX_SAFE_INTEGER); const { offset: start, perPage: perPageForResponse } = calculatePagination(page, perPageInput, perPage); diff --git a/packages/core/src/storage/domains/workflows/inmemory-get-by-id.test.ts b/packages/core/src/storage/domains/workflows/inmemory-get-by-id.test.ts new file mode 100644 index 000000000000..ce0ecd5136f6 --- /dev/null +++ b/packages/core/src/storage/domains/workflows/inmemory-get-by-id.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import type { WorkflowRunState } from '../../../workflows'; +import { InMemoryStore } from '../../mock'; + +const makeSnapshot = (runId: string, status: WorkflowRunState['status']): WorkflowRunState => + ({ + runId, + status, + value: {}, + context: {}, + activePaths: [], + activeStepsPath: {}, + suspendedPaths: {}, + resumeLabels: {}, + serializedStepGraph: [], + waitingPaths: {}, + timestamp: Date.now(), + }) as WorkflowRunState; + +describe('WorkflowsInMemory getWorkflowRunById', () => { + it('finds a run by runId when workflowName is omitted', async () => { + const store = new InMemoryStore(); + const workflows = (await store.getStore('workflows'))!; + + await workflows.persistWorkflowSnapshot({ + workflowName: 'wf-A', + runId: 'run-1', + snapshot: makeSnapshot('run-1', 'running'), + }); + + // workflowName is optional in the storage contract; the pg/libsql adapters + // match by runId alone when it is omitted. The in-memory store must match. + const run = await workflows.getWorkflowRunById({ runId: 'run-1' }); + + expect(run).not.toBeNull(); + expect(run!.runId).toBe('run-1'); + expect(run!.workflowName).toBe('wf-A'); + }); + + it('still filters by workflowName when one is provided', async () => { + const store = new InMemoryStore(); + const workflows = (await store.getStore('workflows'))!; + + await workflows.persistWorkflowSnapshot({ + workflowName: 'wf-A', + runId: 'run-1', + snapshot: makeSnapshot('run-1', 'running'), + }); + + expect(await workflows.getWorkflowRunById({ runId: 'run-1', workflowName: 'wf-A' })).not.toBeNull(); + expect(await workflows.getWorkflowRunById({ runId: 'run-1', workflowName: 'wf-other' })).toBeNull(); + }); + + it('returns null for an unknown runId', async () => { + const store = new InMemoryStore(); + const workflows = (await store.getStore('workflows'))!; + + expect(await workflows.getWorkflowRunById({ runId: 'missing' })).toBeNull(); + }); +}); diff --git a/packages/core/src/storage/domains/workflows/inmemory.ts b/packages/core/src/storage/domains/workflows/inmemory.ts index 821a106bc9ee..b5a9015949ad 100644 --- a/packages/core/src/storage/domains/workflows/inmemory.ts +++ b/packages/core/src/storage/domains/workflows/inmemory.ts @@ -413,8 +413,12 @@ export class WorkflowsInMemory extends WorkflowsStorage { runId: string; workflowName?: string; }): Promise<WorkflowRun | null> { - const runs = Array.from(this.db.workflows.values()).filter((r: any) => r.run_id === runId); - let run = runs.find((r: any) => r.workflow_name === workflowName); + // `workflowName` is optional in the storage contract. The pg/libsql adapters + // match by `runId` alone when it is omitted and return the most recent run + // (ORDER BY createdAt DESC LIMIT 1), so mirror that here. + const run = Array.from(this.db.workflows.values()) + .filter((r: any) => r.run_id === runId && (!workflowName || r.workflow_name === workflowName)) + .sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]; if (!run) return null; diff --git a/packages/core/src/storage/types.ts b/packages/core/src/storage/types.ts index 0fcaf49f168a..e8157a2d636b 100644 --- a/packages/core/src/storage/types.ts +++ b/packages/core/src/storage/types.ts @@ -1078,6 +1078,10 @@ export interface BufferedObservationChunk { currentTask?: string; /** Optional thread title from observer output */ threadTitle?: string; + /** Values extracted during this buffered observation cycle. */ + extractedValues?: Record<string, unknown>; + /** Extractor failures from this buffered observation cycle. */ + extractionFailures?: Array<{ slug: string; error: string }>; } /** @@ -1102,6 +1106,10 @@ export interface BufferedObservationChunkInput { currentTask?: string; /** Optional thread title from observer output */ threadTitle?: string; + /** Values extracted during this buffered observation cycle. */ + extractedValues?: Record<string, unknown>; + /** Extractor failures from this buffered observation cycle. */ + extractionFailures?: Array<{ slug: string; error: string }>; } /** diff --git a/packages/core/src/stream/aisdk/v5/execute.test.ts b/packages/core/src/stream/aisdk/v5/execute.test.ts index 0b526886e653..19ef7bf3a3eb 100644 --- a/packages/core/src/stream/aisdk/v5/execute.test.ts +++ b/packages/core/src/stream/aisdk/v5/execute.test.ts @@ -1,6 +1,7 @@ import { convertArrayToReadableStream, MockLanguageModelV2 } from '@internal/ai-sdk-v5/test'; import { describe, expect, it } from 'vitest'; import { z } from 'zod/v4'; +import { coreFeatures } from '../../../features'; import { execute } from './execute'; import { testUsage } from './test-utils'; @@ -20,6 +21,146 @@ async function readStream(stream: ReadableStream) { } describe('execute structured output prompt handling', () => { + it('advertises inline JSON prompt injection support', () => { + expect(coreFeatures.has('json-prompt-injection:inline')).toBe(true); + }); + + it('injects direct structured output schema into the leading system message for boolean and system modes', async () => { + const capturedPrompts: unknown[] = []; + const model = new MockLanguageModelV2({ + doStream: async ({ prompt }: any) => { + capturedPrompts.push(prompt); + return { + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: 'id-system', modelId: 'mock-model-id', timestamp: new Date(0) }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: '{"suggestions":["ship"]}' }, + { type: 'text-end', id: 'text-1' }, + { type: 'finish', finishReason: 'stop', usage: testUsage, providerMetadata: undefined }, + ]), + request: { body: '' }, + response: { headers: {} }, + warnings: [] as any[], + }; + }, + }); + + for (const jsonPromptInjection of [true, 'system'] as const) { + const stream = execute({ + runId: `test-run-id-${jsonPromptInjection}`, + model: model as any, + inputMessages, + onResult: () => {}, + methodType: 'stream', + structuredOutput: { + schema, + jsonPromptInjection, + }, + }); + await readStream(stream); + } + + expect(capturedPrompts).toHaveLength(2); + for (const capturedPrompt of capturedPrompts) { + expect((capturedPrompt as any[])[0].role).toBe('system'); + expect(JSON.stringify((capturedPrompt as any[])[0])).toContain('suggestions'); + } + }); + + it('injects direct structured output schema into the latest user message for inline mode', async () => { + let capturedPrompt: unknown; + let capturedResponseFormat: unknown; + const model = new MockLanguageModelV2({ + doStream: async ({ prompt, responseFormat }: any) => { + capturedPrompt = prompt; + capturedResponseFormat = responseFormat; + return { + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: 'id-inline', modelId: 'mock-model-id', timestamp: new Date(0) }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: '{"suggestions":["ship"]}' }, + { type: 'text-end', id: 'text-1' }, + { type: 'finish', finishReason: 'stop', usage: testUsage, providerMetadata: undefined }, + ]), + request: { body: '' }, + response: { headers: {} }, + warnings: [] as any[], + }; + }, + }); + + const messages = [ + { role: 'system' as const, content: 'Keep this prefix stable.' }, + { role: 'user' as const, content: [{ type: 'text' as const, text: 'First request.' }] }, + { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'First response.' }] }, + { role: 'user' as const, content: [{ type: 'text' as const, text: 'Extract now.' }] }, + ]; + + const stream = execute({ + runId: 'test-run-id-inline', + model: model as any, + inputMessages: messages, + onResult: () => {}, + methodType: 'stream', + structuredOutput: { + schema, + jsonPromptInjection: 'inline', + }, + }); + + await readStream(stream); + + expect(capturedResponseFormat).toBeUndefined(); + expect((capturedPrompt as any[])[0]).toEqual(messages[0]); + expect(JSON.stringify((capturedPrompt as any[])[1])).not.toContain( + 'Return your response as JSON matching this schema', + ); + expect(JSON.stringify((capturedPrompt as any[])[3])).toContain('Return your response as JSON matching this schema'); + expect(JSON.stringify((capturedPrompt as any[])[3])).toContain('suggestions'); + expect(JSON.stringify((capturedPrompt as any[])[3])).toContain('Extract now.'); + }); + + it('adds a user message for inline mode when no user message exists', async () => { + let capturedPrompt: unknown; + const model = new MockLanguageModelV2({ + doStream: async ({ prompt }: any) => { + capturedPrompt = prompt; + return { + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: 'id-inline-no-user', modelId: 'mock-model-id', timestamp: new Date(0) }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: '{"suggestions":["ship"]}' }, + { type: 'text-end', id: 'text-1' }, + { type: 'finish', finishReason: 'stop', usage: testUsage, providerMetadata: undefined }, + ]), + request: { body: '' }, + response: { headers: {} }, + warnings: [] as any[], + }; + }, + }); + + const stream = execute({ + runId: 'test-run-id-inline-no-user', + model: model as any, + inputMessages: [{ role: 'system' as const, content: 'System only.' }], + onResult: () => {}, + methodType: 'stream', + structuredOutput: { + schema, + jsonPromptInjection: 'inline', + }, + }); + + await readStream(stream); + + expect((capturedPrompt as any[])[0]).toEqual({ role: 'system', content: 'System only.' }); + expect((capturedPrompt as any[])[1].role).toBe('user'); + expect(JSON.stringify((capturedPrompt as any[])[1])).toContain('Return your response as JSON matching this schema'); + }); it('does not inject processor schema instructions into the main prompt when useAgent is enabled', async () => { let capturedPrompt: unknown; const model = new MockLanguageModelV2({ diff --git a/packages/core/src/stream/aisdk/v5/execute.ts b/packages/core/src/stream/aisdk/v5/execute.ts index 5369b0cce905..fd61722f7e2a 100644 --- a/packages/core/src/stream/aisdk/v5/execute.ts +++ b/packages/core/src/stream/aisdk/v5/execute.ts @@ -13,6 +13,41 @@ import { prepareToolsAndToolChoice } from './compat'; import type { ModelSpecVersion } from './compat'; import { AISDKV5InputStream } from './input'; +function buildJsonInstruction(schema: unknown) { + return `Return your response as JSON matching this schema:\n\n${JSON.stringify(schema)}\n\nReturn only valid JSON. Do not include markdown or explanatory text.`; +} + +function injectJsonInstructionIntoLatestUserMessage({ + messages, + schema, +}: { + messages: LanguageModelV2Prompt; + schema: unknown; +}): LanguageModelV2Prompt { + const instruction = buildJsonInstruction(schema); + const prompt = messages.map(message => ({ + ...message, + content: Array.isArray(message.content) ? [...message.content] : message.content, + })) as LanguageModelV2Prompt; + + for (let i = prompt.length - 1; i >= 0; i--) { + const message = prompt[i]; + if (message?.role !== 'user') { + continue; + } + + message.content = Array.isArray(message.content) + ? [...message.content, { type: 'text', text: instruction }] + : [ + { type: 'text', text: String(message.content ?? '') }, + { type: 'text', text: instruction }, + ]; + return prompt; + } + + return [...prompt, { role: 'user', content: [{ type: 'text', text: instruction }] }] as LanguageModelV2Prompt; +} + function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> { const newObj = { ...obj }; for (const key of keys) { @@ -99,13 +134,21 @@ export function execute<OUTPUT = undefined>({ : undefined; let prompt = inputMessages; + const jsonPromptInjection = structuredOutput?.jsonPromptInjection; + const injectionMode = jsonPromptInjection === true ? 'system' : jsonPromptInjection; // For direct mode (no model provided for structuring agent), inject JSON schema instruction if opting out of native response format with jsonPromptInjection - if (structuredOutputMode === 'direct' && responseFormat?.type === 'json' && structuredOutput?.jsonPromptInjection) { - prompt = injectJsonInstructionIntoMessages({ - messages: inputMessages, - schema: responseFormat.schema, - }); + if (structuredOutputMode === 'direct' && responseFormat?.type === 'json' && injectionMode) { + prompt = + injectionMode === 'inline' + ? injectJsonInstructionIntoLatestUserMessage({ + messages: inputMessages, + schema: responseFormat.schema, + }) + : injectJsonInstructionIntoMessages({ + messages: inputMessages, + schema: responseFormat.schema, + }); } // For processor mode without agent reuse, inject a custom prompt to inform the main agent @@ -130,8 +173,7 @@ export function execute<OUTPUT = undefined>({ * @see https://platform.openai.com/docs/guides/structured-outputs#structured-outputs-vs-json-mode * @see https://ai-sdk.dev/docs/ai-sdk-core/generating-structured-data#accessing-reasoning */ - const isOpenAIStrictMode = - model.provider.startsWith('openai') && responseFormat?.type === 'json' && !structuredOutput?.jsonPromptInjection; + const isOpenAIStrictMode = model.provider.startsWith('openai') && responseFormat?.type === 'json' && !injectionMode; // For OpenAI strict mode, ensure all properties are required and additionalProperties: false if (isOpenAIStrictMode && responseFormat?.schema) { @@ -169,10 +211,7 @@ export function execute<OUTPUT = undefined>({ providerOptions: providerOptionsToUse, abortSignal, includeRawChunks, - responseFormat: - structuredOutputMode === 'direct' && !structuredOutput?.jsonPromptInjection - ? responseFormat - : undefined, + responseFormat: structuredOutputMode === 'direct' && !injectionMode ? responseFormat : undefined, ...filteredModelSettings, headers, }); diff --git a/packages/core/src/tools/tool-builder/builder.ts b/packages/core/src/tools/tool-builder/builder.ts index e9a65cf1c4f5..df973d0f15bf 100644 --- a/packages/core/src/tools/tool-builder/builder.ts +++ b/packages/core/src/tools/tool-builder/builder.ts @@ -649,7 +649,7 @@ export class CoreToolBuilder extends MastraBase { resumeData, threadId, resourceId, - outputWriter: execOptions.outputWriter, + outputWriter: options.outputWriter || execOptions.outputWriter, flushMessages: execOptions.flushMessages, }, }; diff --git a/packages/core/src/worker/transport/pull-transport.ts b/packages/core/src/worker/transport/pull-transport.ts index 27ef1dfe2c77..3a558e9960f1 100644 --- a/packages/core/src/worker/transport/pull-transport.ts +++ b/packages/core/src/worker/transport/pull-transport.ts @@ -8,12 +8,25 @@ const TOPIC_WORKFLOWS = 'workflows'; export class PullTransport implements WorkerTransport { #pubsub: PubSub; #group: string; + #topic: string; #logger?: IMastraLogger; #callbacks: Array<{ topic: string; cb: EventCallback }> = []; - constructor({ pubsub, group, logger }: { pubsub: PubSub; group: string; logger?: IMastraLogger }) { + constructor({ + pubsub, + group, + topic, + logger, + }: { + pubsub: PubSub; + group: string; + /** Pubsub topic to subscribe to. Defaults to the workflows topic. */ + topic?: string; + logger?: IMastraLogger; + }) { this.#pubsub = pubsub; this.#group = group; + this.#topic = topic ?? TOPIC_WORKFLOWS; this.#logger = logger; } @@ -22,7 +35,7 @@ export class PullTransport implements WorkerTransport { this.#logger?.debug('[PullTransport] start() called while already subscribed; ignoring duplicate call'); return; } - const workflowCb: EventCallback = (event, ack, nack) => { + const cb: EventCallback = (event, ack, nack) => { // route() is async; surface unexpected rejections as a nack instead // of an unhandledRejection. The router's own try/catch already turns // expected processing errors into nack — this guard only catches @@ -38,8 +51,8 @@ export class PullTransport implements WorkerTransport { } }); }; - await this.#pubsub.subscribe(TOPIC_WORKFLOWS, workflowCb, { group: this.#group }); - this.#callbacks.push({ topic: TOPIC_WORKFLOWS, cb: workflowCb }); + await this.#pubsub.subscribe(this.#topic, cb, { group: this.#group }); + this.#callbacks.push({ topic: this.#topic, cb }); } async stop(): Promise<void> { diff --git a/packages/core/src/worker/workers/scheduler-worker.ts b/packages/core/src/worker/workers/scheduler-worker.ts index c2277490b387..f03da0ea1c7f 100644 --- a/packages/core/src/worker/workers/scheduler-worker.ts +++ b/packages/core/src/worker/workers/scheduler-worker.ts @@ -1,6 +1,7 @@ import type { IMastraLogger } from '../../logger'; -import { WorkflowScheduler } from '../../workflows/scheduler/scheduler'; -import type { WorkflowSchedulerConfig } from '../../workflows/scheduler/types'; +import type { ScheduleTarget } from '../../storage/domains/schedules/base'; +import { Scheduler } from '../../workflows/scheduler/scheduler'; +import type { SchedulerConfig } from '../../workflows/scheduler/types'; import { MastraWorker } from '../worker'; import type { WorkerDeps } from '../worker'; @@ -16,11 +17,11 @@ import type { WorkerDeps } from '../worker'; export class SchedulerWorker extends MastraWorker { readonly name = 'scheduler'; - #scheduler?: WorkflowScheduler; - #config: WorkflowSchedulerConfig; + #scheduler?: Scheduler; + #config: SchedulerConfig; #running = false; - constructor(config: WorkflowSchedulerConfig = {}) { + constructor(config: SchedulerConfig = {}) { super(); this.#config = config; } @@ -39,26 +40,33 @@ export class SchedulerWorker extends MastraWorker { return; } - // Bind a workflow-existence predicate so the scheduler can reclaim - // schedule rows whose target workflow is no longer registered with - // Mastra (e.g. workflow renamed or deleted in code). `getWorkflowById` - // throws on miss; we adapt that into a boolean. + // Bind a target-existence predicate so the scheduler can reclaim + // schedule rows whose target (workflow id or agent id) is no longer + // registered with Mastra. `getWorkflowById` / `getAgentById` throw on + // miss; we adapt that into a boolean. const mastra = this.mastra; - const isWorkflowRegistered = mastra - ? (workflowId: string) => { + const isTargetReady = mastra + ? (target: ScheduleTarget) => { try { - mastra.getWorkflowById(workflowId); - return true; + if (target.type === 'workflow') { + mastra.getWorkflowById(target.workflowId); + return true; + } + if (target.type === 'heartbeat') { + mastra.getAgentById(target.agentId); + return true; + } + return false; } catch { return false; } } : undefined; - this.#scheduler = new WorkflowScheduler({ + this.#scheduler = new Scheduler({ schedulesStore, pubsub: deps.pubsub, - config: { ...this.#config, isWorkflowRegistered }, + config: { ...this.#config, isTargetReady }, }); this.#scheduler.__setLogger(deps.logger as IMastraLogger); @@ -94,7 +102,7 @@ export class SchedulerWorker extends MastraWorker { } /** Expose the underlying scheduler for direct API access (e.g., schedule management). */ - get scheduler(): WorkflowScheduler | undefined { + get scheduler(): Scheduler | undefined { return this.#scheduler; } } diff --git a/packages/core/src/workflows/create.ts b/packages/core/src/workflows/create.ts index 2227e7b08937..edee7a3ed75e 100644 --- a/packages/core/src/workflows/create.ts +++ b/packages/core/src/workflows/create.ts @@ -13,9 +13,10 @@ * By keeping the Workflow class in `workflow.ts` and the factories here, * neither module needs to import the other's runtime dependencies. */ +import type { InferPublicSchema, PublicSchema } from '../schema'; import { createWorkflow as createEventedWorkflowImpl } from './evented/workflow'; import type { Step } from './step'; -import type { DefaultEngineType, WorkflowConfig } from './types'; +import type { CreateWorkflowParams, DefaultEngineType, InferSchemaOutput } from './types'; import { Workflow } from './workflow'; /** @@ -24,18 +25,34 @@ import { Workflow } from './workflow'; */ export function createWorkflow< TWorkflowId extends string = string, - TState = unknown, - TInput = unknown, - TOutput = unknown, + TInputSchema extends PublicSchema<any> = PublicSchema<any>, + TOutputSchema extends PublicSchema<any> = PublicSchema<any>, + TStateSchema extends PublicSchema<any> | undefined = undefined, TSteps extends Step<string, any, any, any, any, any, DefaultEngineType>[] = Step[], - TRequestContext extends Record<string, any> | unknown = unknown, ->(params: WorkflowConfig<TWorkflowId, TState, TInput, TOutput, TSteps, TRequestContext>) { + TRequestContextSchema extends PublicSchema<any> | undefined = undefined, +>(params: CreateWorkflowParams<TWorkflowId, TStateSchema, TInputSchema, TOutputSchema, TSteps, TRequestContextSchema>) { if (params.schedule) { - return createEventedWorkflowImpl( - params as WorkflowConfig<TWorkflowId, TState, TInput, TOutput, Step[]>, - ) as unknown as Workflow<DefaultEngineType, TSteps, TWorkflowId, TState, TInput, TOutput, TInput, TRequestContext>; + return createEventedWorkflowImpl(params as any) as unknown as Workflow< + DefaultEngineType, + TSteps, + TWorkflowId, + InferSchemaOutput<TStateSchema>, + InferPublicSchema<TInputSchema>, + InferPublicSchema<TOutputSchema>, + InferPublicSchema<TInputSchema>, + InferSchemaOutput<TRequestContextSchema> + >; } - return new Workflow<DefaultEngineType, TSteps, TWorkflowId, TState, TInput, TOutput, TInput, TRequestContext>(params); + return new Workflow< + DefaultEngineType, + TSteps, + TWorkflowId, + InferSchemaOutput<TStateSchema>, + InferPublicSchema<TInputSchema>, + InferPublicSchema<TOutputSchema>, + InferPublicSchema<TInputSchema>, + InferSchemaOutput<TRequestContextSchema> + >(params as any); } /** @@ -46,15 +63,22 @@ export function createWorkflow< */ export function createEventedWorkflow< TWorkflowId extends string = string, - TState = unknown, - TInput = unknown, - TOutput = unknown, + TInputSchema extends PublicSchema<any> = PublicSchema<any>, + TOutputSchema extends PublicSchema<any> = PublicSchema<any>, + TStateSchema extends PublicSchema<any> | undefined = undefined, TSteps extends Step<string, any, any, any, any, any, DefaultEngineType>[] = Step[], - TRequestContext extends Record<string, any> | unknown = unknown, ->(params: WorkflowConfig<TWorkflowId, TState, TInput, TOutput, TSteps, TRequestContext>) { - return createEventedWorkflowImpl( - params as WorkflowConfig<TWorkflowId, TState, TInput, TOutput, Step[]>, - ) as unknown as Workflow<DefaultEngineType, TSteps, TWorkflowId, TState, TInput, TOutput, TInput, TRequestContext>; + TRequestContextSchema extends PublicSchema<any> | undefined = undefined, +>(params: CreateWorkflowParams<TWorkflowId, TStateSchema, TInputSchema, TOutputSchema, TSteps, TRequestContextSchema>) { + return createEventedWorkflowImpl(params as any) as unknown as Workflow< + DefaultEngineType, + TSteps, + TWorkflowId, + InferSchemaOutput<TStateSchema>, + InferPublicSchema<TInputSchema>, + InferPublicSchema<TOutputSchema>, + InferPublicSchema<TInputSchema>, + InferSchemaOutput<TRequestContextSchema> + >; } export function cloneWorkflow< diff --git a/packages/core/src/workflows/evented/workflow.ts b/packages/core/src/workflows/evented/workflow.ts index aab369434696..783d157c5c13 100644 --- a/packages/core/src/workflows/evented/workflow.ts +++ b/packages/core/src/workflows/evented/workflow.ts @@ -64,6 +64,8 @@ import type { ToolStep, DefaultEngineType, StepMetadata, + CreateWorkflowParams, + InferSchemaOutput, } from '../../workflows/types'; import { PUBSUB_SYMBOL, STREAM_FORMAT_SYMBOL } from '../constants'; import { validateCron } from '../scheduler/cron'; @@ -1502,9 +1504,9 @@ function createStepFromProcessor<TProcessorId extends string>( export function createWorkflow< TWorkflowId extends string = string, - TState = unknown, - TInput = unknown, - TOutput = unknown, + TInputSchema extends PublicSchema<any> = PublicSchema<any>, + TOutputSchema extends PublicSchema<any> = PublicSchema<any>, + TStateSchema extends PublicSchema<any> | undefined = undefined, TSteps extends Step<string, any, any, any, any, any, EventedEngineType>[] = Step< string, any, @@ -1514,7 +1516,8 @@ export function createWorkflow< any, EventedEngineType >[], ->(params: WorkflowConfig<TWorkflowId, TState, TInput, TOutput, TSteps>) { + TRequestContextSchema extends PublicSchema<any> | undefined = undefined, +>(params: CreateWorkflowParams<TWorkflowId, TStateSchema, TInputSchema, TOutputSchema, TSteps, TRequestContextSchema>) { if (params.schedule) { const schedules = Array.isArray(params.schedule) ? params.schedule : [params.schedule]; if (Array.isArray(params.schedule)) { @@ -1547,8 +1550,16 @@ export function createWorkflow< onError: params.options?.onError, }, }); - return new EventedWorkflow<EventedEngineType, TSteps, TWorkflowId, TState, TInput, TOutput, TInput>({ - ...params, + return new EventedWorkflow< + EventedEngineType, + TSteps, + TWorkflowId, + InferSchemaOutput<TStateSchema>, + InferPublicSchema<TInputSchema>, + InferPublicSchema<TOutputSchema>, + InferPublicSchema<TInputSchema> + >({ + ...(params as any), executionEngine, }); } diff --git a/packages/core/src/workflows/scheduler/cron.test.ts b/packages/core/src/workflows/scheduler/cron.test.ts index 0cb276f00d1c..e234131c4dec 100644 --- a/packages/core/src/workflows/scheduler/cron.test.ts +++ b/packages/core/src/workflows/scheduler/cron.test.ts @@ -27,6 +27,21 @@ describe('validateCron', () => { it('throws on invalid timezone', () => { expect(() => validateCron('0 9 * * *', 'Not/AZone')).toThrow(); }); + + it('labels timezone failures as timezone errors, not cron errors', () => { + expect(() => validateCron('0 9 * * *', 'Not/AZone')).toThrow('Invalid timezone "Not/AZone"'); + }); + + it('throws a clear error when cron is missing', () => { + // @ts-expect-error - exercising the runtime guard for callers passing undefined + expect(() => validateCron(undefined)).toThrow('expected a non-empty cron string'); + expect(() => validateCron('')).toThrow('expected a non-empty cron string'); + expect(() => validateCron(' ')).toThrow('expected a non-empty cron string'); + }); + + it('wraps croner errors with the offending pattern', () => { + expect(() => validateCron('not a cron')).toThrow('Invalid cron expression "not a cron"'); + }); }); describe('computeNextFireAt', () => { diff --git a/packages/core/src/workflows/scheduler/cron.ts b/packages/core/src/workflows/scheduler/cron.ts index 1a09e541d90f..9672fdd81bbe 100644 --- a/packages/core/src/workflows/scheduler/cron.ts +++ b/packages/core/src/workflows/scheduler/cron.ts @@ -7,10 +7,32 @@ import { Cron } from 'croner'; * @param timezone - Optional IANA timezone (e.g. 'America/New_York'). */ export function validateCron(cron: string, timezone?: string): void { - // Croner throws synchronously on invalid patterns. To also validate the - // timezone (which croner only checks lazily), compute the next run. - const job = new Cron(cron, { timezone }); - job.nextRun(); + if (typeof cron !== 'string' || cron.trim() === '') { + throw new Error( + `Invalid cron expression: expected a non-empty cron string (e.g. "0 * * * *"), but received ${cron === undefined ? 'undefined' : JSON.stringify(cron)}.`, + ); + } + // Croner throws synchronously on an invalid pattern when the job is + // constructed. Validate the pattern on its own first so timezone problems + // (which croner only surfaces lazily) are not mislabeled as cron errors. + let job: Cron; + try { + job = new Cron(cron); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid cron expression "${cron}": ${reason}`); + } + // The timezone is only exercised when a fire time is computed. + if (timezone !== undefined) { + try { + new Cron(cron, { timezone }).nextRun(); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid timezone "${timezone}": ${reason}`); + } + } else { + job.nextRun(); + } } /** diff --git a/packages/core/src/workflows/scheduler/index.ts b/packages/core/src/workflows/scheduler/index.ts index e075d7fc504c..2d457b54c7cd 100644 --- a/packages/core/src/workflows/scheduler/index.ts +++ b/packages/core/src/workflows/scheduler/index.ts @@ -1,3 +1,3 @@ export { computeNextFireAt, validateCron } from './cron'; -export { WorkflowScheduler } from './scheduler'; -export type { WorkflowScheduleConfig, WorkflowScheduleInput, WorkflowSchedulerConfig } from './types'; +export { Scheduler, WorkflowScheduler } from './scheduler'; +export type { WorkflowScheduleConfig, WorkflowScheduleInput, SchedulerConfig, WorkflowSchedulerConfig } from './types'; diff --git a/packages/core/src/workflows/scheduler/scheduler.test.ts b/packages/core/src/workflows/scheduler/scheduler.test.ts index 8ac860a68147..dc2cd2cff756 100644 --- a/packages/core/src/workflows/scheduler/scheduler.test.ts +++ b/packages/core/src/workflows/scheduler/scheduler.test.ts @@ -3,7 +3,7 @@ import { EventEmitterPubSub } from '../../events/event-emitter'; import type { Event } from '../../events/types'; import { InMemoryDB } from '../../storage/domains/inmemory-db'; import { InMemorySchedulesStorage } from '../../storage/domains/schedules/inmemory'; -import { WorkflowScheduler } from './scheduler'; +import { Scheduler } from './scheduler'; function makeStore(): { store: InMemorySchedulesStorage; db: InMemoryDB } { const db = new InMemoryDB(); @@ -19,7 +19,7 @@ function captureWorkflowsTopic(pubsub: EventEmitterPubSub): { events: Event[] } return { events }; } -describe('WorkflowScheduler', () => { +describe('Scheduler', () => { beforeEach(() => { vi.useRealTimers(); }); @@ -32,7 +32,7 @@ describe('WorkflowScheduler', () => { const { store } = makeStore(); const pubsub = new EventEmitterPubSub(); const { events } = captureWorkflowsTopic(pubsub); - const scheduler = new WorkflowScheduler({ schedulesStore: store, pubsub }); + const scheduler = new Scheduler({ schedulesStore: store, pubsub }); const past = Date.now() - 5_000; const created = await store.createSchedule({ @@ -70,7 +70,7 @@ describe('WorkflowScheduler', () => { const { store } = makeStore(); const pubsub = new EventEmitterPubSub(); const { events } = captureWorkflowsTopic(pubsub); - const scheduler = new WorkflowScheduler({ schedulesStore: store, pubsub }); + const scheduler = new Scheduler({ schedulesStore: store, pubsub }); const past = Date.now() - 5_000; await store.createSchedule({ @@ -92,7 +92,7 @@ describe('WorkflowScheduler', () => { const { store } = makeStore(); const pubsub = new EventEmitterPubSub(); const { events } = captureWorkflowsTopic(pubsub); - const scheduler = new WorkflowScheduler({ schedulesStore: store, pubsub }); + const scheduler = new Scheduler({ schedulesStore: store, pubsub }); const future = Date.now() + 60_000; await store.createSchedule({ @@ -114,8 +114,8 @@ describe('WorkflowScheduler', () => { const { store } = makeStore(); const pubsub = new EventEmitterPubSub(); const { events } = captureWorkflowsTopic(pubsub); - const a = new WorkflowScheduler({ schedulesStore: store, pubsub }); - const b = new WorkflowScheduler({ schedulesStore: store, pubsub }); + const a = new Scheduler({ schedulesStore: store, pubsub }); + const b = new Scheduler({ schedulesStore: store, pubsub }); const past = Date.now() - 5_000; await store.createSchedule({ @@ -146,7 +146,7 @@ describe('WorkflowScheduler', () => { return original(topic, event); }); const onError = vi.fn(); - const scheduler = new WorkflowScheduler({ schedulesStore: store, pubsub, config: { onError } }); + const scheduler = new Scheduler({ schedulesStore: store, pubsub, config: { onError } }); const past = Date.now() - 5_000; await store.createSchedule({ @@ -187,7 +187,7 @@ describe('WorkflowScheduler', () => { const onError = vi.fn().mockImplementationOnce(() => { throw new Error('hook exploded'); }); - const scheduler = new WorkflowScheduler({ schedulesStore: store, pubsub, config: { onError } }); + const scheduler = new Scheduler({ schedulesStore: store, pubsub, config: { onError } }); const past = Date.now() - 5_000; await store.createSchedule({ @@ -224,7 +224,7 @@ describe('WorkflowScheduler', () => { const { store } = makeStore(); const pubsub = new EventEmitterPubSub(); const { events } = captureWorkflowsTopic(pubsub); - const scheduler = new WorkflowScheduler({ schedulesStore: store, pubsub }); + const scheduler = new Scheduler({ schedulesStore: store, pubsub }); const past = Date.now() - 5_000; const fireAt = past; @@ -247,7 +247,7 @@ describe('WorkflowScheduler', () => { const { store } = makeStore(); const pubsub = new EventEmitterPubSub(); const { events } = captureWorkflowsTopic(pubsub); - const scheduler = new WorkflowScheduler({ + const scheduler = new Scheduler({ schedulesStore: store, pubsub, config: { tickIntervalMs: 60_000 }, // long enough that the immediate tick is the only one @@ -276,12 +276,12 @@ describe('WorkflowScheduler', () => { const { store } = makeStore(); const pubsub = new EventEmitterPubSub(); const { events } = captureWorkflowsTopic(pubsub); - const scheduler = new WorkflowScheduler({ + const scheduler = new Scheduler({ schedulesStore: store, pubsub, config: { tickIntervalMs: 60_000, - isWorkflowRegistered: () => false, + isTargetReady: () => false, missesBeforeDelete: 3, }, }); @@ -310,12 +310,12 @@ describe('WorkflowScheduler', () => { const { store } = makeStore(); const pubsub = new EventEmitterPubSub(); const { events } = captureWorkflowsTopic(pubsub); - const scheduler = new WorkflowScheduler({ + const scheduler = new Scheduler({ schedulesStore: store, pubsub, config: { tickIntervalMs: 60_000, - isWorkflowRegistered: () => false, + isTargetReady: () => false, missesBeforeDelete: 3, }, }); @@ -348,12 +348,12 @@ describe('WorkflowScheduler', () => { const pubsub = new EventEmitterPubSub(); const { events } = captureWorkflowsTopic(pubsub); let registered = false; - const scheduler = new WorkflowScheduler({ + const scheduler = new Scheduler({ schedulesStore: store, pubsub, config: { tickIntervalMs: 60_000, - isWorkflowRegistered: () => registered, + isTargetReady: () => registered, missesBeforeDelete: 3, }, }); @@ -390,7 +390,7 @@ describe('WorkflowScheduler', () => { const { store } = makeStore(); const pubsub = new EventEmitterPubSub(); const { events } = captureWorkflowsTopic(pubsub); - const scheduler = new WorkflowScheduler({ + const scheduler = new Scheduler({ schedulesStore: store, pubsub, config: { tickIntervalMs: 60_000 }, @@ -419,7 +419,7 @@ describe('WorkflowScheduler', () => { // Simulate a user config where optional fields are present but undefined, // e.g. from destructuring a partial object. - const scheduler = new WorkflowScheduler({ + const scheduler = new Scheduler({ schedulesStore: store, pubsub, config: { enabled: true, tickIntervalMs: undefined, batchSize: undefined }, diff --git a/packages/core/src/workflows/scheduler/scheduler.ts b/packages/core/src/workflows/scheduler/scheduler.ts index c5a149e3ff34..de0972d3aebf 100644 --- a/packages/core/src/workflows/scheduler/scheduler.ts +++ b/packages/core/src/workflows/scheduler/scheduler.ts @@ -3,9 +3,10 @@ import type { PubSub } from '../../events/pubsub'; import { RegisteredLogger } from '../../logger/constants'; import type { Schedule, ScheduleTrigger, SchedulesStorage } from '../../storage/domains/schedules/base'; import { computeNextFireAt } from './cron'; -import type { WorkflowSchedulerConfig } from './types'; +import type { SchedulerConfig } from './types'; const TOPIC_WORKFLOWS = 'workflows'; +export const TOPIC_HEARTBEATS = 'heartbeats'; const DEFAULT_TICK_INTERVAL_MS = 10_000; const DEFAULT_BATCH_SIZE = 100; const DEFAULT_MISSES_BEFORE_DELETE = 3; @@ -24,10 +25,10 @@ const DEFAULT_MISSES_BEFORE_DELETE = 3; * The scheduler does **not** execute workflows. The existing * `WorkflowEventProcessor` consumes `workflow.start` events and runs them. */ -export class WorkflowScheduler extends MastraBase { +export class Scheduler extends MastraBase { #schedulesStore: SchedulesStorage; #pubsub: PubSub; - #config: Required<Pick<WorkflowSchedulerConfig, 'tickIntervalMs' | 'batchSize'>> & WorkflowSchedulerConfig; + #config: Required<Pick<SchedulerConfig, 'tickIntervalMs' | 'batchSize'>> & SchedulerConfig; #intervalHandle?: ReturnType<typeof setInterval>; #inflightTick?: Promise<void>; @@ -49,9 +50,9 @@ export class WorkflowScheduler extends MastraBase { }: { schedulesStore: SchedulesStorage; pubsub: PubSub; - config?: WorkflowSchedulerConfig; + config?: SchedulerConfig; }) { - super({ component: RegisteredLogger.WORKFLOW, name: 'WorkflowScheduler' }); + super({ component: RegisteredLogger.WORKFLOW, name: 'Scheduler' }); this.#schedulesStore = schedulesStore; this.#pubsub = pubsub; this.#config = { @@ -85,7 +86,7 @@ export class WorkflowScheduler extends MastraBase { // already logs its own errors and notifies onError, so we only need a // belt-and-braces logger.error for anything that escapes. void this.#runTick().catch(err => { - this.logger.error('WorkflowScheduler tick crashed', { error: err }); + this.logger.error('Scheduler tick crashed', { error: err }); }); }, this.#config.tickIntervalMs); } catch (err) { @@ -160,27 +161,31 @@ export class WorkflowScheduler extends MastraBase { } /** - * Check whether a schedule's target workflow is registered with the host + * Check whether a schedule's target is registered with the host * Mastra instance. Returns `true` if no predicate is configured (we can't - * verify, so assume the consumer will reject) or if the workflow resolves. + * verify, so assume the consumer will reject) or if the target resolves. * - * When the workflow is missing, we increment an in-memory counter and + * When the target is missing, we increment an in-memory counter and * delete the schedule after `missesBeforeDelete` consecutive misses. The * grace window protects against deploy/startup ordering races where the - * scheduler ticks before workflows finish registering on a fresh process. - * Returns `false` to tell `#fireSchedule` to skip publishing for this tick. + * scheduler ticks before workflows/agents finish registering on a fresh + * process. Returns `false` to tell `#fireSchedule` to skip publishing for + * this tick. */ - async #ensureWorkflowExists(schedule: Schedule): Promise<boolean> { - const predicate = this.#config.isWorkflowRegistered; + async #ensureTargetReady(schedule: Schedule): Promise<boolean> { + const predicate = this.#config.isTargetReady; if (!predicate) return true; - if (schedule.target.type !== 'workflow') return true; - const workflowId = schedule.target.workflowId; - if (predicate(workflowId)) { + if (predicate(schedule.target)) { this.#missingWorkflowCounts.delete(schedule.id); return true; } + const targetSummary = + schedule.target.type === 'workflow' + ? { workflowId: schedule.target.workflowId } + : { agentId: schedule.target.agentId }; + const limit = this.#config.missesBeforeDelete ?? DEFAULT_MISSES_BEFORE_DELETE; const prev = this.#missingWorkflowCounts.get(schedule.id) ?? 0; const next = prev + 1; @@ -188,9 +193,10 @@ export class WorkflowScheduler extends MastraBase { if (next < limit) { this.#missingWorkflowCounts.set(schedule.id, next); if (prev === 0) { - this.logger.warn('Schedule target workflow is not registered; skipping until it appears', { + this.logger.warn('Schedule target is not registered; skipping until it appears', { scheduleId: schedule.id, - workflowId, + targetType: schedule.target.type, + ...targetSummary, missesBeforeDelete: limit, }); } @@ -198,9 +204,10 @@ export class WorkflowScheduler extends MastraBase { } // Hit the grace limit — reclaim the row. - this.logger.error('Deleting schedule whose target workflow has not been registered', { + this.logger.error('Deleting schedule whose target has not been registered', { scheduleId: schedule.id, - workflowId, + targetType: schedule.target.type, + ...targetSummary, consecutiveMisses: next, }); try { @@ -208,7 +215,8 @@ export class WorkflowScheduler extends MastraBase { } catch (err) { this.logger.error('Failed to delete ghost schedule', { scheduleId: schedule.id, - workflowId, + targetType: schedule.target.type, + ...targetSummary, error: err, }); // Keep the counter so we try again next tick rather than reset and @@ -220,7 +228,7 @@ export class WorkflowScheduler extends MastraBase { } async #fireSchedule(schedule: Schedule): Promise<void> { - if (!(await this.#ensureWorkflowExists(schedule))) return; + if (!(await this.#ensureTargetReady(schedule))) return; const actualFireAt = Date.now(); @@ -272,34 +280,41 @@ export class WorkflowScheduler extends MastraBase { let triggerError: string | undefined; try { - await this.#publishWorkflowStart(schedule, runId); + await this.#publishTargetStart(schedule, runId); } catch (err) { triggerStatus = 'failed'; triggerError = err instanceof Error ? err.message : String(err); - this.logger.error('Failed to publish workflow.start for schedule', { + this.logger.error('Failed to publish target.start for schedule', { scheduleId: schedule.id, runId, + targetType: schedule.target.type, error: err, }); this.#notifyError(err, schedule.id); } - try { - await this.#schedulesStore.recordTrigger({ - scheduleId: schedule.id, - runId, - scheduledFireAt: schedule.nextFireAt, - actualFireAt, - outcome: triggerStatus, - error: triggerError, - triggerKind: 'schedule-fire', - }); - } catch (err) { - this.logger.error('Failed to record schedule trigger', { - scheduleId: schedule.id, - runId, - error: err, - }); + // For workflow targets we record the trigger now with the claim id — + // the workflow event processor will reuse the same runId. For + // heartbeat targets the HeartbeatWorker records the trigger itself + // after the agent run starts, so it can write the real agent runId. + if (schedule.target.type === 'workflow' || triggerStatus === 'failed') { + try { + await this.#schedulesStore.recordTrigger({ + scheduleId: schedule.id, + runId, + scheduledFireAt: schedule.nextFireAt, + actualFireAt, + outcome: triggerStatus, + error: triggerError, + triggerKind: 'schedule-fire', + }); + } catch (err) { + this.logger.error('Failed to record schedule trigger', { + scheduleId: schedule.id, + runId, + error: err, + }); + } } } @@ -313,30 +328,59 @@ export class WorkflowScheduler extends MastraBase { try { this.#config.onError(error, { scheduleId }); } catch (callbackError) { - this.logger.error('WorkflowScheduler onError handler threw', { + this.logger.error('Scheduler onError handler threw', { scheduleId, error: callbackError, }); } } - async #publishWorkflowStart(schedule: Schedule, runId: string): Promise<void> { - if (schedule.target.type !== 'workflow') { - throw new Error(`Unsupported schedule target type: ${(schedule.target as { type: string }).type}`); + async #publishTargetStart(schedule: Schedule, claimId: string): Promise<void> { + switch (schedule.target.type) { + case 'workflow': { + const { workflowId, inputData, initialState, requestContext } = schedule.target; + await this.#pubsub.publish(TOPIC_WORKFLOWS, { + type: 'workflow.start', + runId: claimId, + data: { + workflowId, + runId: claimId, + prevResult: { status: 'success', output: inputData ?? {} }, + requestContext: requestContext ?? {}, + initialState: initialState ?? {}, + }, + }); + return; + } + case 'heartbeat': { + await this.#pubsub.publish(TOPIC_HEARTBEATS, { + type: 'heartbeat.fire', + runId: claimId, + data: { + scheduleId: schedule.id, + claimId, + scheduledFireAt: schedule.nextFireAt, + target: schedule.target, + }, + }); + return; + } + default: { + throw new Error(`Unsupported schedule target type: ${(schedule.target as { type: string }).type}`); + } } - - const { workflowId, inputData, initialState, requestContext } = schedule.target; - - await this.#pubsub.publish(TOPIC_WORKFLOWS, { - type: 'workflow.start', - runId, - data: { - workflowId, - runId, - prevResult: { status: 'success', output: inputData ?? {} }, - requestContext: requestContext ?? {}, - initialState: initialState ?? {}, - }, - }); } } + +/** + * @deprecated Renamed to {@link Scheduler}. The scheduler now drives both + * workflow and heartbeat schedules, so the `Workflow`-prefixed name is no longer + * accurate. This alias will be removed in a future major release. + */ +export const WorkflowScheduler = Scheduler; + +/** + * @deprecated Renamed to {@link Scheduler}. This alias will be removed in a + * future major release. + */ +export type WorkflowScheduler = Scheduler; diff --git a/packages/core/src/workflows/scheduler/types.ts b/packages/core/src/workflows/scheduler/types.ts index f0dcc38f28db..9d5578a4437d 100644 --- a/packages/core/src/workflows/scheduler/types.ts +++ b/packages/core/src/workflows/scheduler/types.ts @@ -1,3 +1,5 @@ +import type { ScheduleTarget } from '../../storage/domains/schedules/base'; + /** * Declarative schedule configuration for a workflow. When set on a workflow, * the scheduler will publish a `workflow.start` event on the cron schedule. @@ -60,9 +62,9 @@ export type WorkflowScheduleInput<TInput = unknown, TState = unknown, TRequestCo | WorkflowScheduleConfig<TInput, TState, TRequestContext>[]; /** - * Configuration for the `WorkflowScheduler` component owned by Mastra. + * Configuration for the `Scheduler` component owned by Mastra. */ -export type WorkflowSchedulerConfig = { +export type SchedulerConfig = { /** * Explicitly enable the scheduler even when no declarative schedules * are present. Useful when schedules are managed imperatively. @@ -81,14 +83,17 @@ export type WorkflowSchedulerConfig = { */ onError?: (err: unknown, context: { scheduleId: string }) => void; /** - * Predicate used to check whether a workflow id is currently registered - * with the host Mastra instance. When provided, the scheduler refuses to - * fire schedules whose target workflow is unknown and deletes the row - * after a small number of consecutive misses (see `missesBeforeDelete`). + * Predicate used to check whether a schedule's target is currently + * registered with the host Mastra instance. For workflow targets the + * predicate should resolve the workflow id; for heartbeat targets it + * should resolve the agent id. When provided, the scheduler refuses to + * fire schedules whose target is unknown and deletes the row after a + * small number of consecutive misses (see `missesBeforeDelete`). * - * Wired up by `SchedulerWorker` from `mastra.getWorkflowById(...)`. + * Wired up by `SchedulerWorker` from `mastra.getWorkflowById(...)` and + * `mastra.getAgentById(...)`. */ - isWorkflowRegistered?: (workflowId: string) => boolean; + isTargetReady?: (target: ScheduleTarget) => boolean; /** * Number of consecutive ticks a schedule's target workflow may be missing * before the scheduler deletes the row. Defaults to 3 (≈30s with the @@ -98,3 +103,10 @@ export type WorkflowSchedulerConfig = { */ missesBeforeDelete?: number; }; + +/** + * @deprecated Renamed to {@link SchedulerConfig}. The scheduler now drives both + * workflow and heartbeat schedules, so the `Workflow`-prefixed name is no longer + * accurate. This alias will be removed in a future major release. + */ +export type WorkflowSchedulerConfig = SchedulerConfig; diff --git a/packages/core/src/workflows/types.ts b/packages/core/src/workflows/types.ts index bdddcd7aad5e..1aca34ff3ea1 100644 --- a/packages/core/src/workflows/types.ts +++ b/packages/core/src/workflows/types.ts @@ -606,9 +606,7 @@ export type StepWithComponent = Step<string, any, any, any, any, any> & { steps?: Record<string, StepWithComponent>; }; -type InferParsedPublicSchema<TSchema extends PublicSchema<any>> = TSchema extends { _output: infer Output } - ? Output - : InferPublicSchema<TSchema>; +type InferParsedPublicSchema<TSchema extends PublicSchema<any>> = InferPublicSchema<TSchema>; /** * StepParams with schema-based inference for better type errors. @@ -870,6 +868,50 @@ export type WorkflowConfig< schedule?: WorkflowScheduleInput<NoInfer<TInput>, NoInfer<TState>, NoInfer<TRequestContext>>; }; +/** + * Infers the output type from a schema type that may be `undefined`. + * Returns `unknown` when no schema is provided. + */ +export type InferSchemaOutput<T> = T extends PublicSchema<any> ? InferPublicSchema<T> : unknown; + +/** + * Schema-typed variant of `WorkflowConfig` used by `createWorkflow` factories. + * + * Instead of inferring output types through the `PublicSchema<TOutput>` union + * (which forces TypeScript to distribute across 8+ union members and triggers + * TS2589 "Type instantiation is excessively deep"), this type infers the + * **schema type itself** (shallow inference) and defers output-type extraction + * to `InferSchemaOutput` / `InferPublicSchema` (which use `_output` / `_type` + * / `~standard` fast paths). + */ +export type CreateWorkflowParams< + TWorkflowId extends string = string, + TStateSchema extends PublicSchema<any> | undefined = undefined, + TInputSchema extends PublicSchema<any> = PublicSchema<any>, + TOutputSchema extends PublicSchema<any> = PublicSchema<any>, + TSteps extends Step[] = Step[], + TRequestContextSchema extends PublicSchema<any> | undefined = undefined, +> = { + mastra?: Mastra; + id: TWorkflowId; + description?: string | undefined; + metadata?: Record<string, unknown> | undefined; + inputSchema: TInputSchema; + outputSchema: TOutputSchema; + stateSchema?: TStateSchema; + requestContextSchema?: TRequestContextSchema; + executionEngine?: ExecutionEngine; + steps?: TSteps; + retryConfig?: { attempts?: number; delay?: number }; + options?: WorkflowOptions; + type?: WorkflowType; + schedule?: WorkflowScheduleInput< + NoInfer<InferSchemaOutput<TInputSchema>>, + NoInfer<InferSchemaOutput<TStateSchema>>, + NoInfer<InferSchemaOutput<TRequestContextSchema>> + >; +}; + /** * Utility type to ensure that TStepState is a subset of TState. * This means that all properties in TStepState must exist in TState with compatible types. diff --git a/packages/create-mastra/CHANGELOG.md b/packages/create-mastra/CHANGELOG.md index 2ed1445650be..cd2dadf38828 100644 --- a/packages/create-mastra/CHANGELOG.md +++ b/packages/create-mastra/CHANGELOG.md @@ -1,5 +1,19 @@ # create-mastra +## 1.17.0-alpha.9 + +## 1.17.0-alpha.8 + +## 1.17.0-alpha.7 + +## 1.16.1-alpha.6 + +## 1.16.1-alpha.5 + +## 1.16.1-alpha.4 + +## 1.16.1-alpha.3 + ## 1.16.1-alpha.2 ## 1.16.1-alpha.1 diff --git a/packages/create-mastra/package.json b/packages/create-mastra/package.json index e0eb185ac677..f9ea210972a6 100644 --- a/packages/create-mastra/package.json +++ b/packages/create-mastra/package.json @@ -1,6 +1,6 @@ { "name": "create-mastra", - "version": "1.16.1-alpha.2", + "version": "1.17.0-alpha.9", "description": "Create Mastra apps with one command", "license": "Apache-2.0", "type": "module", diff --git a/packages/deployer/CHANGELOG.md b/packages/deployer/CHANGELOG.md index 8e7c51726c80..752d54af3910 100644 --- a/packages/deployer/CHANGELOG.md +++ b/packages/deployer/CHANGELOG.md @@ -1,5 +1,83 @@ # @mastra/deployer +## 1.48.0-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + - @mastra/server@1.48.0-alpha.9 + +## 1.48.0-alpha.8 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + - @mastra/server@1.48.0-alpha.8 + +## 1.48.0-alpha.7 + +### Minor Changes + +- You can now define agents by file convention instead of registering each one in code: drop a directory under `src/mastra/agents/<name>/`, run a Mastra build/dev, and the agent is bundled and registered onto your Mastra instance automatically. A directory becomes an agent when it has a `config.ts` or `instructions.md`; `tools/*.ts` add tools, `skills/` add skills (a `createSkill()` module, a packaged `SKILL.md` with its `references/`, or a flat `<skill>.md`), and `subagents/<childId>/` (one level deep) add delegatable subagents. Each agent gets a default workspace unless `workspace.ts` / `config.workspace` overrides it, and files committed under `agents/<name>/workspace/` are mirrored into the bundle to seed that workspace at runtime. Projects with no file-based agents are unaffected — the original entry is used unchanged. ([#18609](https://github.com/mastra-ai/mastra/pull/18609)) + + ```text + src/mastra/agents/weather/ + config.ts # export default agentConfig({ model: 'openai/gpt-4o' }) + instructions.md + tools/get_weather.ts + workspace/cities.json # mirrored into the agent's workspace + ``` + +### Patch Changes + +- Fix `ENOENT: .mastra-fs-agents-entry.mjs` when running `mastra dev`/`mastra build` in a project that uses file-based agents. The generated fs-agents wrapper entry was written before `bundler.prepare()` emptied the output directory, so it was wiped before the bundler could read it. Wrapper generation is now split: `prepareFsAgentsEntry` returns the generated source without writing, and the new `writeFsAgentsEntry` writes it after `prepare()` runs. ([#18694](https://github.com/mastra-ai/mastra/pull/18694)) + + ```ts + const fsAgents = await prepareFsAgentsEntry({ entryFile, mastraDir, outputDirectory }); + await bundler.prepare(outputDirectory); // empties output dir + await writeFsAgentsEntry(fsAgents); // wrapper now survives for the bundler + ``` + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + - @mastra/server@1.48.0-alpha.7 + +## 1.48.0-alpha.6 + +### Patch Changes + +- Fix `FileEnvService.setEnvValue` corrupting env values that contain `$` when updating an existing key. Values such as database URLs and passwords that include `$&`, `$$`, or `$1` are now written exactly as provided instead of being mangled by `String.prototype.replace` special patterns. Closes #18633. ([#18672](https://github.com/mastra-ai/mastra/pull/18672)) + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/server@1.48.0-alpha.6 + +## 1.48.0-alpha.5 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/server@1.48.0-alpha.5 + +## 1.48.0-alpha.4 + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + - @mastra/server@1.48.0-alpha.4 + +## 1.48.0-alpha.3 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`e05dade`](https://github.com/mastra-ai/mastra/commit/e05dade21c3e2551893ad939e6028cdafd84a1b2), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/server@1.48.0-alpha.3 + ## 1.48.0-alpha.2 ### Patch Changes diff --git a/packages/deployer/package.json b/packages/deployer/package.json index de329bf442c0..04390980a757 100644 --- a/packages/deployer/package.json +++ b/packages/deployer/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/deployer", - "version": "1.48.0-alpha.2", + "version": "1.48.0-alpha.9", "description": "", "type": "module", "files": [ @@ -112,6 +112,7 @@ "esbuild": "^0.28.0", "find-workspaces": "^0.3.1", "fs-extra": "^11.3.5", + "gray-matter": "^4.0.3", "hono": "^4.12.8", "local-pkg": "^1.1.2", "resolve.exports": "^2.0.3", diff --git a/packages/deployer/src/build/fs-routing/codegen-eval.test.ts b/packages/deployer/src/build/fs-routing/codegen-eval.test.ts new file mode 100644 index 000000000000..a7d0f059adfd --- /dev/null +++ b/packages/deployer/src/build/fs-routing/codegen-eval.test.ts @@ -0,0 +1,210 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { generateFsAgentsModule } from './codegen'; +import type { DiscoveredFsAgent } from './discover'; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'fs-routing-eval-')); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe('generated module evaluation', () => { + it('imports the user entry, assembles agents, and registers them', async () => { + // Stub @mastra/core/agent so we do not need to construct a real Agent. + const coreStub = join(dir, 'core-agent.mjs'); + await writeFile( + coreStub, + `export function assembleAgentFromFsEntry(entry) { + return { id: entry.config?.id ?? entry.name, name: entry.name, __entry: entry }; + }`, + ); + + // Stub user entry exposing a mastra with __registerFsAgents. + const userEntry = join(dir, 'index.mjs'); + await writeFile( + userEntry, + `const registered = {}; + export const mastra = { + registered, + getLogger() { return { warn() {} }; }, + __registerFsAgents(map) { Object.assign(registered, map); }, + }; + export const extra = 'kept';`, + ); + + // Stub config + tool modules for one agent. + const agentDir = join(dir, 'agents', 'weather'); + await mkdir(join(agentDir, 'tools'), { recursive: true }); + await writeFile(join(agentDir, 'config.mjs'), `export default { model: 'm' };`); + await writeFile(join(agentDir, 'tools', 'get_weather.mjs'), `export default { id: 'get_weather' };`); + + const agents: DiscoveredFsAgent[] = [ + { + name: 'weather', + dir: agentDir, + configPath: join(agentDir, 'config.mjs'), + instructionsPath: undefined, + tools: [{ key: 'get_weather', path: join(agentDir, 'tools', 'get_weather.mjs') }], + skills: [], + subagents: [], + }, + ]; + + let source = await generateFsAgentsModule(userEntry, agents); + // Point the @mastra/core/agent import at our stub for evaluation. + source = source.replace(`'@mastra/core/agent'`, JSON.stringify(coreStub)); + + const generated = join(dir, 'wrapper.mjs'); + await writeFile(generated, source); + + const mod = await import(pathToFileURL(generated).href); + + // Re-exports from the user entry are preserved. + expect(mod.extra).toBe('kept'); + // The wrapper exports the same mastra instance. + expect(mod.mastra).toBeTruthy(); + // The agent was assembled and registered. + expect(mod.mastra.registered.weather).toBeTruthy(); + expect(mod.mastra.registered.weather.name).toBe('weather'); + expect(mod.mastra.registered.weather.__entry.tools[0].key).toBe('get_weather'); + }); + + it('inlines a packaged skill via createSkill into the assembled entry', async () => { + const coreStub = join(dir, 'core-agent.mjs'); + await writeFile( + coreStub, + `export function assembleAgentFromFsEntry(entry) { + return { id: entry.name, name: entry.name, __entry: entry }; + }`, + ); + const skillsStub = join(dir, 'core-skills.mjs'); + await writeFile(skillsStub, `export function createSkill(input) { return { __inline: true, ...input }; }`); + + const userEntry = join(dir, 'index.mjs'); + await writeFile( + userEntry, + `const registered = {}; + export const mastra = { + registered, + getLogger() { return { warn() {} }; }, + __registerFsAgents(map) { Object.assign(registered, map); }, + };`, + ); + + const agentDir = join(dir, 'agents', 'weather'); + await mkdir(agentDir, { recursive: true }); + await writeFile(join(agentDir, 'config.mjs'), `export default { model: 'm' };`); + + const agents: DiscoveredFsAgent[] = [ + { + name: 'weather', + dir: agentDir, + configPath: join(agentDir, 'config.mjs'), + instructionsPath: undefined, + tools: [], + skills: [ + { + kind: 'packaged', + name: 'review', + description: 'Use when reviewing.', + instructions: '# Review\nDo it.', + references: { 'checklist.md': '# Checklist' }, + }, + ], + subagents: [], + }, + ]; + + let source = await generateFsAgentsModule(userEntry, agents); + source = source.replace(`'@mastra/core/agent'`, JSON.stringify(coreStub)); + source = source.replace(`'@mastra/core/skills'`, JSON.stringify(skillsStub)); + + const generated = join(dir, 'wrapper-skills.mjs'); + await writeFile(generated, source); + + const mod = await import(pathToFileURL(generated).href); + + const skill = mod.mastra.registered.weather.__entry.skills[0]; + expect(skill).toMatchObject({ __inline: true, name: 'review', description: 'Use when reviewing.' }); + expect(skill.instructions).toContain('Do it.'); + expect(skill.references['checklist.md']).toContain('Checklist'); + }); + + it('assembles a declared subagent and exposes it under its bare id', async () => { + // The stub recursively assembles subagents into a `subagents` map keyed by + // the bare child id, mirroring how real assembly wires `agents`. + const coreStub = join(dir, 'core-agent.mjs'); + await writeFile( + coreStub, + `export function assembleAgentFromFsEntry(entry) { + const subagents = {}; + for (const child of entry.subagents ?? []) { + subagents[child.name] = assembleAgentFromFsEntry(child); + } + return { id: entry.name, name: entry.name, __entry: entry, subagents }; + }`, + ); + + const userEntry = join(dir, 'index.mjs'); + await writeFile( + userEntry, + `const registered = {}; + export const mastra = { + registered, + getLogger() { return { warn() {} }; }, + __registerFsAgents(map) { Object.assign(registered, map); }, + };`, + ); + + const parentDir = join(dir, 'agents', 'supervisor'); + const childDir = join(parentDir, 'subagents', 'writer'); + await mkdir(childDir, { recursive: true }); + await writeFile(join(parentDir, 'config.mjs'), `export default { model: 'm' };`); + await writeFile(join(childDir, 'config.mjs'), `export default { model: 'm', description: 'Writes' };`); + + const agents: DiscoveredFsAgent[] = [ + { + name: 'supervisor', + dir: parentDir, + configPath: join(parentDir, 'config.mjs'), + instructionsPath: undefined, + tools: [], + skills: [], + subagents: [ + { + name: 'writer', + dir: childDir, + configPath: join(childDir, 'config.mjs'), + instructionsPath: undefined, + tools: [], + skills: [], + subagents: [], + }, + ], + }, + ]; + + let source = await generateFsAgentsModule(userEntry, agents); + source = source.replace(`'@mastra/core/agent'`, JSON.stringify(coreStub)); + + const generated = join(dir, 'wrapper-subagent.mjs'); + await writeFile(generated, source); + + const mod = await import(pathToFileURL(generated).href); + + const supervisor = mod.mastra.registered.supervisor; + expect(supervisor).toBeTruthy(); + // The declared subagent is wired in under its bare id. + expect(Object.keys(supervisor.subagents)).toEqual(['writer']); + expect(supervisor.subagents.writer.name).toBe('writer'); + expect(supervisor.subagents.writer.__entry.config.description).toBe('Writes'); + }); +}); diff --git a/packages/deployer/src/build/fs-routing/codegen.ts b/packages/deployer/src/build/fs-routing/codegen.ts new file mode 100644 index 000000000000..53dd7d770f8c --- /dev/null +++ b/packages/deployer/src/build/fs-routing/codegen.ts @@ -0,0 +1,198 @@ +import { readFile } from 'node:fs/promises'; +import type { DiscoveredFsAgent } from './discover'; + +function sanitizeIdentifier(name: string, prefix: string, index: string): string { + const cleaned = name.replace(/[^a-zA-Z0-9_$]/g, '_'); + return `${prefix}_${index}_${cleaned}`; +} + +/** + * Emit the imports for a single discovered agent into `lines` and return the + * source of its `assembleAgentFromFsEntry` entry object (the `{ name, config, + * ... }` argument). `idPath` is a dot-free, unique path index (e.g. `0` for the + * first top-level agent, `0_1` for its second subagent) used to keep generated + * identifiers unique across the parent/child tree. `workspaceName` is the + * slash-joined workspace key (`<parent>/<child>` for subagents) so seed files + * don't collide. When `allowSubagents` is true the agent's discovered subagents + * are emitted as a nested `subagents: [...]` field. + */ +async function emitAgentEntry( + agent: DiscoveredFsAgent, + idPath: string, + workspaceName: string, + allowSubagents: boolean, + lines: string[], +): Promise<string> { + const configIdent = sanitizeIdentifier(agent.name, 'config', idPath); + const toolIdents: { key: string; ident: string }[] = []; + + if (agent.configPath) { + lines.push(`import ${configIdent} from ${JSON.stringify(agent.configPath)};`); + } + + let workspaceIdent: string | undefined; + if (agent.workspacePath) { + workspaceIdent = sanitizeIdentifier(`${agent.name}_workspace`, 'workspace', idPath); + lines.push(`import ${workspaceIdent} from ${JSON.stringify(agent.workspacePath)};`); + } + + let memoryIdent: string | undefined; + if (agent.memoryPath) { + memoryIdent = sanitizeIdentifier(`${agent.name}_memory`, 'memory', idPath); + lines.push(`import ${memoryIdent} from ${JSON.stringify(agent.memoryPath)};`); + } + + for (let t = 0; t < agent.tools.length; t++) { + const tool = agent.tools[t]!; + const ident = sanitizeIdentifier(`${agent.name}_${tool.key}`, 'tool', `${idPath}_${t}`); + lines.push(`import ${ident} from ${JSON.stringify(tool.path)};`); + toolIdents.push({ key: tool.key, ident }); + } + + // Skills: `createSkill(...)` modules are imported and used directly; + // packaged `SKILL.md` skills are inlined via `createSkill({...})`. + const skillExprs: string[] = []; + const agentSkills = agent.skills ?? []; + for (let s = 0; s < agentSkills.length; s++) { + const skill = agentSkills[s]!; + if (skill.kind === 'module') { + const ident = sanitizeIdentifier(`${agent.name}_skill`, 'skill', `${idPath}_${s}`); + lines.push(`import ${ident} from ${JSON.stringify(skill.path)};`); + skillExprs.push(ident); + } else { + const referenceFields = Object.entries(skill.references).map( + ([key, value]) => `${JSON.stringify(key)}: ${JSON.stringify(value)}`, + ); + const skillFields = [ + `name: ${JSON.stringify(skill.name)}`, + `description: ${JSON.stringify(skill.description)}`, + `instructions: ${JSON.stringify(skill.instructions)}`, + ]; + if (referenceFields.length > 0) { + skillFields.push(`references: { ${referenceFields.join(', ')} }`); + } + skillExprs.push(`__createSkill({ ${skillFields.join(', ')} })`); + } + } + + let instructionsMd: string | undefined; + if (agent.instructionsPath) { + instructionsMd = await readFile(agent.instructionsPath, 'utf-8'); + } + + // Declared subagents (one level deep). Each is itself an + // `assembleAgentFromFsEntry` entry object with no further `subagents`. + const subagentExprs: string[] = []; + if (allowSubagents) { + for (let c = 0; c < agent.subagents.length; c++) { + const child = agent.subagents[c]!; + const childExpr = await emitAgentEntry(child, `${idPath}_${c}`, `${workspaceName}/${child.name}`, false, lines); + subagentExprs.push(childExpr); + } + } + + const entryFields: string[] = [`name: ${JSON.stringify(agent.name)}`]; + if (agent.configPath) { + entryFields.push(`config: ${configIdent}`); + } + if (instructionsMd !== undefined) { + entryFields.push(`instructionsMd: ${JSON.stringify(instructionsMd)}`); + } + if (toolIdents.length > 0) { + const toolEntries = toolIdents.map(({ key, ident }) => `{ key: ${JSON.stringify(key)}, tool: ${ident} }`); + entryFields.push(`tools: [${toolEntries.join(', ')}]`); + } + if (skillExprs.length > 0) { + entryFields.push(`skills: [${skillExprs.join(', ')}]`); + } + if (subagentExprs.length > 0) { + entryFields.push(`subagents: [${subagentExprs.join(', ')}]`); + } + if (workspaceIdent) { + entryFields.push(`workspace: ${workspaceIdent}`); + } + if (memoryIdent) { + entryFields.push(`memory: ${memoryIdent}`); + } + // Default-on parity: every FS agent gets a default workspace (file + shell + // tools) rooted at a per-agent `workspace/` dir next to the bundle, unless + // config.ts or workspace.ts supplies one. Assembly applies the explicit > + // convention > default precedence. Subagents nest under `<parent>/<child>` so + // their seed directories never collide with the parent's. + entryFields.push(`defaultWorkspaceBasePath: __workspaceBasePath(${JSON.stringify(workspaceName)})`); + + return `{ ${entryFields.join(', ')} }`; +} + +/** + * Generate the source of a wrapper module that: + * 1. imports the user's real Mastra entry, + * 2. imports each discovered `config.ts`, `tools/*.ts`, `skills/*.ts` + * (`createSkill(...)` modules), `workspace.ts`, and `memory.ts`, inlining + * packaged `SKILL.md` skills, + * 3. assembles `Agent` instances via `assembleAgentFromFsEntry`, wiring any + * declared `subagents/` into the parent (one level deep), + * 4. registers them onto the user's `mastra` instance (code-registered agents + * win on name collisions), and + * 5. re-exports everything from the user's entry so this module is a drop-in + * replacement for the original `#mastra` target. + * + * `instructions.md` contents are inlined at codegen time so no markdown loader + * plugin is required in the bundler graph. + * + * @param userEntry slash-normalized absolute path to the user's mastra entry. + * @param agents discovered fs-routed agents (absolute, slash-normalized paths). + */ +export async function generateFsAgentsModule(userEntry: string, agents: DiscoveredFsAgent[]): Promise<string> { + const lines: string[] = []; + + const hasInlineSkills = agents.some(a => + [a, ...(a.subagents ?? [])].some(x => (x.skills ?? []).some(s => s.kind === 'packaged')), + ); + + lines.push(`import { assembleAgentFromFsEntry } from '@mastra/core/agent';`); + if (hasInlineSkills) { + lines.push(`import { createSkill as __createSkill } from '@mastra/core/skills';`); + } + lines.push(`import { fileURLToPath as __fileURLToPath } from 'node:url';`); + lines.push(`import { dirname as __dirname, join as __join } from 'node:path';`); + lines.push(`import * as __userEntry from ${JSON.stringify(userEntry)};`); + lines.push(`export * from ${JSON.stringify(userEntry)};`); + lines.push(``); + // Resolve workspace base paths relative to this bundled module so they point + // at `<bundle>/workspace/<name>` wherever the bundle is deployed. Seed files + // authored under `agents/<name>/workspace/**` are mirrored there at build time. + // `name` may be a slash-joined path (`<parent>/<child>`) for subagents. + lines.push(`const __bundleDir = __dirname(__fileURLToPath(import.meta.url));`); + lines.push(`const __workspaceBasePath = name => __join(__bundleDir, 'workspace', ...name.split('/'));`); + lines.push(``); + + const entryExprs: string[] = []; + for (let i = 0; i < agents.length; i++) { + const agent = agents[i]!; + const expr = await emitAgentEntry(agent, `${i}`, agent.name, true, lines); + entryExprs.push(expr); + } + + lines.push(``); + lines.push(`const __fsAgentEntries = [`); + for (const expr of entryExprs) { + lines.push(` ${expr},`); + } + lines.push(`];`); + lines.push(``); + lines.push(`const __fsAgents = Object.create(null);`); + lines.push(`for (const __entry of __fsAgentEntries) {`); + lines.push(` __fsAgents[__entry.name] = assembleAgentFromFsEntry(__entry, {`); + lines.push(` onWarn: msg => __userEntry.mastra?.getLogger?.()?.warn?.(msg) ?? console.warn(msg),`); + lines.push(` });`); + lines.push(`}`); + lines.push(``); + lines.push(`if (__userEntry.mastra && typeof __userEntry.mastra.__registerFsAgents === 'function') {`); + lines.push(` __userEntry.mastra.__registerFsAgents(__fsAgents);`); + lines.push(`}`); + lines.push(``); + lines.push(`export const mastra = __userEntry.mastra;`); + + return lines.join('\n'); +} diff --git a/packages/deployer/src/build/fs-routing/discover.ts b/packages/deployer/src/build/fs-routing/discover.ts new file mode 100644 index 000000000000..abe8f3163569 --- /dev/null +++ b/packages/deployer/src/build/fs-routing/discover.ts @@ -0,0 +1,386 @@ +import { lstat, readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import matter from 'gray-matter'; +import { slash } from '../utils'; + +/** + * A file-system routed agent directory discovered under `<mastraDir>/agents/`. + * All paths are absolute and slash-normalized so they can be embedded into + * generated module source on any platform. + */ +export interface DiscoveredFsAgent { + /** Agent directory name. Used as the default `id`/`name`. */ + name: string; + /** Absolute, slash-normalized path to the agent directory. */ + dir: string; + /** Absolute path to `config.ts`/`config.js`, if present. */ + configPath?: string; + /** Absolute path to `instructions.md`, if present. */ + instructionsPath?: string; + /** Absolute path to `workspace.ts`/`workspace.js`, if present. */ + workspacePath?: string; + /** Absolute path to `memory.ts`/`memory.js`, if present. */ + memoryPath?: string; + /** + * Absolute, slash-normalized path to an authored `workspace/` directory of + * seed files, if present. These are mirrored into the deployed workspace at + * build time (Eve parity) so the agent starts with them on disk. + */ + workspaceSeedDir?: string; + /** Tools discovered under `tools/`, in stable (sorted) order. */ + tools: { key: string; path: string }[]; + /** Skills discovered under `skills/`, in stable (sorted) order. */ + skills: DiscoveredFsSkill[]; + /** + * Declared subagents discovered under `subagents/`, in stable (sorted) order. + * Subagents are one level deep only: a discovered subagent never carries its + * own `subagents` (nested `subagents/` directories are ignored with a warning). + */ + subagents: DiscoveredFsAgent[]; +} + +/** + * A skill discovered under `agents/<name>/skills/`. + * + * - `kind: 'module'` — a `.ts`/`.js` file whose default export is a `createSkill(...)` + * result. Codegen imports it directly; `name`/`description`/`instructions` are + * unknown at discovery time and resolved at runtime from the module. + * - `kind: 'packaged'` — a `SKILL.md` (optionally with a `references/` subdir) or a + * flat `<skill>.md`. Codegen inlines it via `createSkill(...)` using the parsed + * fields below so the deployed bundle carries no filesystem dependency. + */ +export type DiscoveredFsSkill = + | { + kind: 'module'; + /** Absolute, slash-normalized path to the `.ts`/`.js` skill module. */ + path: string; + } + | { + kind: 'packaged'; + name: string; + description: string; + instructions: string; + /** Reference file contents keyed by relative path (from `references/`). */ + references: Record<string, string>; + }; + +const CONFIG_BASENAMES = ['config.ts', 'config.js']; +const WORKSPACE_BASENAMES = ['workspace.ts', 'workspace.js']; +const MEMORY_BASENAMES = ['memory.ts', 'memory.js']; +const INSTRUCTIONS_BASENAME = 'instructions.md'; +const TOOL_EXTENSIONS = ['.ts', '.js']; +const SKILL_MODULE_EXTENSIONS = ['.ts', '.js']; +const SKILL_MD_BASENAME = 'SKILL.md'; + +/** + * Presence check that does NOT follow symlinks. Returns `false` for symlinks + * (and broken links) so a symlinked `config.ts`/`instructions.md`/`workspace.ts`/ + * `memory.ts` is never inlined or imported into the generated bundle. + */ +async function exists(path: string): Promise<boolean> { + try { + return !(await lstat(path)).isSymbolicLink(); + } catch { + return false; + } +} + +/** + * Returns the slash-normalized path when `path` is a real directory (not a + * symlink). Symlinked directories are rejected to prevent the build from + * following links out of the project tree during discovery. + */ +async function realDirectory(path: string): Promise<string | undefined> { + try { + const info = await lstat(path); + if (info.isDirectory() && !info.isSymbolicLink()) { + return slash(path); + } + } catch { + // not present + } + return undefined; +} + +async function directoryExists(path: string): Promise<string | undefined> { + return realDirectory(path); +} + +async function firstExisting(dir: string, basenames: string[]): Promise<string | undefined> { + for (const basename of basenames) { + const candidate = join(dir, basename); + if (await exists(candidate)) { + return slash(candidate); + } + } + return undefined; +} + +function isTestFile(basename: string): boolean { + return /\.(test|spec)\.(ts|js)$/.test(basename); +} + +function toolKey(basename: string): string { + return basename.replace(/\.(ts|js)$/, ''); +} + +async function discoverTools(toolsDir: string): Promise<DiscoveredFsAgent['tools']> { + if (!(await exists(toolsDir))) { + return []; + } + + let entries: string[]; + try { + entries = await readdir(toolsDir); + } catch { + return []; + } + + const tools: DiscoveredFsAgent['tools'] = []; + for (const basename of entries.sort()) { + if (isTestFile(basename)) { + continue; + } + if (!TOOL_EXTENSIONS.some(ext => basename.endsWith(ext))) { + continue; + } + const path = join(toolsDir, basename); + // Use lstat so symlinks are detected (not followed). Skip symlinks and + // directories: a symlinked tool file could point anywhere on the build + // machine and be embedded into generated import code. + const stats = await lstat(path); + if (stats.isSymbolicLink() || stats.isDirectory()) { + continue; + } + tools.push({ key: toolKey(basename), path: slash(path) }); + } + + return tools; +} + +async function readReferences(referencesDir: string): Promise<Record<string, string>> { + if (!(await exists(referencesDir))) { + return {}; + } + const references: Record<string, string> = {}; + let entries: string[]; + try { + entries = await readdir(referencesDir); + } catch { + return {}; + } + for (const basename of entries.sort()) { + const path = join(referencesDir, basename); + // Use lstat so symlinks are detected (not followed). Skip symlinks: a + // symlink under `references/` could point anywhere on the build machine and + // silently embed arbitrary file contents into the generated bundle. + const stats = await lstat(path); + if (stats.isSymbolicLink() || stats.isDirectory()) { + continue; + } + references[basename] = await readFile(path, 'utf-8'); + } + return references; +} + +async function parsePackagedSkill( + skillMdPath: string, + fallbackName: string, + references: Record<string, string> = {}, +): Promise<Extract<DiscoveredFsSkill, { kind: 'packaged' }>> { + const raw = await readFile(skillMdPath, 'utf-8'); + const parsed = matter(raw); + const frontmatter = parsed.data as { name?: string; description?: string }; + const name = frontmatter.name ?? fallbackName; + const description = frontmatter.description ?? ''; + const instructions = parsed.content.trim(); + return { kind: 'packaged', name, description, instructions, references }; +} + +function skillModuleName(basename: string): string { + return basename.replace(/\.(ts|js)$/, ''); +} + +async function discoverSkills(skillsDir: string): Promise<DiscoveredFsSkill[]> { + if (!(await exists(skillsDir))) { + return []; + } + + let entries: string[]; + try { + entries = await readdir(skillsDir); + } catch { + return []; + } + + const skills: DiscoveredFsSkill[] = []; + for (const basename of entries.sort()) { + if (isTestFile(basename)) { + continue; + } + const path = join(skillsDir, basename); + // Use lstat so symlinks are detected (not followed). Skip symlinks: a + // symlinked skill module/markdown could point anywhere on the build machine + // and be bundled or inlined into the generated output. + const stats = await lstat(path); + if (stats.isSymbolicLink()) { + continue; + } + const isDir = stats.isDirectory(); + + // Packaged skill directory: <skill>/SKILL.md (+ references/) + if (isDir) { + const skillMd = join(path, SKILL_MD_BASENAME); + if (await exists(skillMd)) { + const references = await readReferences(join(path, 'references')); + skills.push(await parsePackagedSkill(skillMd, skillModuleName(basename), references)); + } + continue; + } + + // createSkill module: <skill>.ts | <skill>.js + if (SKILL_MODULE_EXTENSIONS.some(ext => basename.endsWith(ext))) { + skills.push({ kind: 'module', path: slash(path) }); + continue; + } + + // Flat markdown skill: <skill>.md + if (basename.endsWith('.md')) { + const skill = await parsePackagedSkill(path, basename.replace(/\.md$/, '')); + skills.push(skill); + } + } + + return skills; +} + +/** + * Discover a single agent directory: its `config`/`instructions`/`workspace` + * files plus `tools/`, `skills/`, and (when `allowSubagents`) one level of + * declared `subagents/`. Returns `undefined` when `dir` is not an agent + * directory (no `config.(ts|js)` and no `instructions.md`). + * + * `allowSubagents` is `true` for top-level agents and `false` when discovering a + * subagent, so nested `subagents/` directories are never recursed into. + */ +async function discoverAgentDir( + dir: string, + name: string, + allowSubagents: boolean, + onWarn?: (message: string) => void, +): Promise<DiscoveredFsAgent | undefined> { + const configPath = await firstExisting(dir, CONFIG_BASENAMES); + const instructionsPath = (await exists(join(dir, INSTRUCTIONS_BASENAME))) + ? slash(join(dir, INSTRUCTIONS_BASENAME)) + : undefined; + + // Not an agent directory unless it has a config or instructions file. + if (!configPath && !instructionsPath) { + return undefined; + } + + const workspacePath = await firstExisting(dir, WORKSPACE_BASENAMES); + const memoryPath = await firstExisting(dir, MEMORY_BASENAMES); + const workspaceSeedDir = await directoryExists(join(dir, 'workspace')); + const tools = await discoverTools(join(dir, 'tools')); + const skills = await discoverSkills(join(dir, 'skills')); + const subagents = await discoverSubagents(dir, allowSubagents, onWarn); + + return { + name, + dir: slash(dir), + configPath, + instructionsPath, + workspacePath, + memoryPath, + workspaceSeedDir, + tools, + skills, + subagents, + }; +} + +/** + * Discover declared subagents under `<dir>/subagents/*`. Subagents are one level + * deep only: each discovered subagent is scanned with `allowSubagents: false`, + * so a nested `subagents/` directory inside a subagent is ignored with a warning. + * When `allowSubagents` is `false` (we are already inside a subagent) the whole + * `subagents/` directory is skipped and a warning is emitted if present. + */ +async function discoverSubagents( + parentDir: string, + allowSubagents: boolean, + onWarn?: (message: string) => void, +): Promise<DiscoveredFsAgent[]> { + const subagentsDir = join(parentDir, 'subagents'); + if (!(await exists(subagentsDir))) { + return []; + } + + if (!allowSubagents) { + onWarn?.( + `Ignoring nested subagents in "${slash(subagentsDir)}": subagents are one level deep only, so a subagent cannot declare its own subagents.`, + ); + return []; + } + + let entries: string[]; + try { + entries = await readdir(subagentsDir); + } catch { + return []; + } + + const subagents: DiscoveredFsAgent[] = []; + for (const name of entries.sort()) { + const dir = join(subagentsDir, name); + if (!(await realDirectory(dir))) { + continue; + } + const child = await discoverAgentDir(dir, name, false, onWarn); + if (child) { + subagents.push(child); + } + } + + return subagents; +} + +/** + * Scan `<mastraDir>/agents/*` for file-system routed agents. A directory is + * treated as an agent only when it contains a `config.(ts|js)` or an + * `instructions.md`; other directories are ignored. Each top-level agent may + * declare one level of `subagents/`. Returns descriptors with absolute, + * slash-normalized paths ready for codegen. Performs no module evaluation — + * only filesystem inspection. + */ +export async function discoverFsAgents( + mastraDir: string, + onWarn?: (message: string) => void, +): Promise<DiscoveredFsAgent[]> { + const agentsDir = join(mastraDir, 'agents'); + if (!(await exists(agentsDir))) { + return []; + } + + let entries: string[]; + try { + entries = await readdir(agentsDir); + } catch { + return []; + } + + const discovered: DiscoveredFsAgent[] = []; + for (const name of entries.sort()) { + const dir = join(agentsDir, name); + if (!(await realDirectory(dir))) { + continue; + } + const agent = await discoverAgentDir(dir, name, true, onWarn); + if (agent) { + discovered.push(agent); + } + } + + return discovered; +} diff --git a/packages/deployer/src/build/fs-routing/fs-routing.test.ts b/packages/deployer/src/build/fs-routing/fs-routing.test.ts new file mode 100644 index 000000000000..41d4bb8d30c2 --- /dev/null +++ b/packages/deployer/src/build/fs-routing/fs-routing.test.ts @@ -0,0 +1,732 @@ +import { mkdir, mkdtemp, rm, writeFile, readFile, symlink, access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { generateFsAgentsModule } from './codegen'; +import { discoverFsAgents } from './discover'; +import { mirrorFsAgentWorkspaces } from './mirror'; +import { prepareFsAgentsEntry, writeFsAgentsEntry } from './prepare'; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'fs-routing-')); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +interface AgentFiles { + config?: string; + instructions?: string; + memory?: string; + workspace?: string; + /** Map of relative path under `workspace/` to seed file content. */ + workspaceSeed?: Record<string, string>; + tools?: Record<string, string>; + /** Map of relative path under `skills/` to file content. */ + skills?: Record<string, string>; + /** Declared subagents, written under `subagents/<id>/`. */ + subagents?: Record<string, AgentFiles>; +} + +async function writeAgentDir(agentDir: string, files: AgentFiles) { + await mkdir(agentDir, { recursive: true }); + if (files.config !== undefined) { + await writeFile(join(agentDir, 'config.ts'), files.config); + } + if (files.instructions !== undefined) { + await writeFile(join(agentDir, 'instructions.md'), files.instructions); + } + if (files.memory !== undefined) { + await writeFile(join(agentDir, 'memory.ts'), files.memory); + } + if (files.workspace !== undefined) { + await writeFile(join(agentDir, 'workspace.ts'), files.workspace); + } + if (files.workspaceSeed) { + for (const [relPath, content] of Object.entries(files.workspaceSeed)) { + const target = join(agentDir, 'workspace', relPath); + await mkdir(join(target, '..'), { recursive: true }); + await writeFile(target, content); + } + } + if (files.tools) { + await mkdir(join(agentDir, 'tools'), { recursive: true }); + for (const [basename, content] of Object.entries(files.tools)) { + await writeFile(join(agentDir, 'tools', basename), content); + } + } + if (files.skills) { + for (const [relPath, content] of Object.entries(files.skills)) { + const target = join(agentDir, 'skills', relPath); + await mkdir(join(target, '..'), { recursive: true }); + await writeFile(target, content); + } + } + if (files.subagents) { + for (const [childName, childFiles] of Object.entries(files.subagents)) { + await writeAgentDir(join(agentDir, 'subagents', childName), childFiles); + } + } +} + +async function writeAgent(name: string, files: AgentFiles) { + await writeAgentDir(join(dir, 'agents', name), files); +} + +describe('discoverFsAgents', () => { + it('returns empty when there is no agents directory', async () => { + expect(await discoverFsAgents(dir)).toEqual([]); + }); + + it('discovers an agent with config, instructions, and tools', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'Be helpful.', + tools: { + 'get_weather.ts': `export default {};`, + 'get_forecast.ts': `export default {};`, + }, + }); + + const agents = await discoverFsAgents(dir); + expect(agents).toHaveLength(1); + const agent = agents[0]!; + expect(agent.name).toBe('weather'); + expect(agent.configPath).toMatch(/agents\/weather\/config\.ts$/); + expect(agent.instructionsPath).toMatch(/agents\/weather\/instructions\.md$/); + expect(agent.tools.map(t => t.key).sort()).toEqual(['get_forecast', 'get_weather']); + }); + + it('skips directories without config or instructions', async () => { + await mkdir(join(dir, 'agents', 'not-an-agent'), { recursive: true }); + await writeAgent('real', { instructions: 'hi' }); + + const agents = await discoverFsAgents(dir); + expect(agents.map(a => a.name)).toEqual(['real']); + }); + + it('ignores test files in tools', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + tools: { + 'get_weather.ts': `export default {};`, + 'get_weather.test.ts': `export default {};`, + }, + }); + + const agents = await discoverFsAgents(dir); + expect(agents[0]!.tools.map(t => t.key)).toEqual(['get_weather']); + }); + + it('returns agents sorted by name', async () => { + await writeAgent('zebra', { instructions: 'z' }); + await writeAgent('alpha', { instructions: 'a' }); + + const agents = await discoverFsAgents(dir); + expect(agents.map(a => a.name)).toEqual(['alpha', 'zebra']); + }); + + it('discovers a packaged SKILL.md skill with frontmatter and references', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + skills: { + 'review/SKILL.md': `---\nname: review\ndescription: Use when reviewing.\n---\n\n# Review\nDo the review.`, + 'review/references/checklist.md': `# Checklist\n- correctness`, + }, + }); + + const agents = await discoverFsAgents(dir); + expect(agents[0]!.skills).toHaveLength(1); + const skill = agents[0]!.skills[0]!; + expect(skill).toMatchObject({ + kind: 'packaged', + name: 'review', + description: 'Use when reviewing.', + }); + if (skill.kind === 'packaged') { + expect(skill.instructions).toContain('Do the review.'); + expect(skill.references['checklist.md']).toContain('correctness'); + } + }); + + it('skips symlinked skill references so arbitrary files are not embedded', async () => { + // A secret outside the agent directory the symlink would otherwise leak. + const secret = join(dir, 'secret.txt'); + await writeFile(secret, 'TOP SECRET'); + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + skills: { + 'review/SKILL.md': `---\nname: review\ndescription: Use when reviewing.\n---\n\n# Review`, + 'review/references/ok.md': `# Ok`, + }, + }); + await symlink(secret, join(dir, 'agents', 'weather', 'skills', 'review', 'references', 'leak.md')); + + const skill = (await discoverFsAgents(dir))[0]!.skills[0]!; + if (skill.kind === 'packaged') { + expect(skill.references['ok.md']).toContain('Ok'); + expect(skill.references['leak.md']).toBeUndefined(); + } + }); + + it('skips symlinked tool modules so arbitrary files are not bundled', async () => { + // A file outside the agent dir a symlinked tool would otherwise import. + const secret = join(dir, 'secret.ts'); + await writeFile(secret, `export default { secret: true };`); + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + tools: { 'real.ts': `export default {};` }, + }); + await symlink(secret, join(dir, 'agents', 'weather', 'tools', 'leak.ts')); + + const tools = (await discoverFsAgents(dir))[0]!.tools; + expect(tools.map(t => t.key)).toEqual(['real']); + }); + + it('skips symlinked skill modules so arbitrary files are not bundled', async () => { + const secret = join(dir, 'secret-skill.ts'); + await writeFile(secret, `export default {};`); + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + skills: { 'support.ts': `export default {};` }, + }); + await symlink(secret, join(dir, 'agents', 'weather', 'skills', 'leak.ts')); + + const skills = (await discoverFsAgents(dir))[0]!.skills; + expect(skills).toHaveLength(1); + const skill = skills[0]!; + expect(skill.kind).toBe('module'); + if (skill.kind === 'module') { + expect(skill.path).toMatch(/support\.ts$/); + } + }); + + it('skips symlinked agent directories so discovery cannot escape the project tree', async () => { + // A real agent outside `agents/` that a symlinked entry would point at. + const outside = join(dir, 'outside-agent'); + await mkdir(outside, { recursive: true }); + await writeFile(join(outside, 'instructions.md'), 'leaked'); + await writeAgent('real', { instructions: 'hi' }); + await mkdir(join(dir, 'agents'), { recursive: true }); + await symlink(outside, join(dir, 'agents', 'evil')); + + const agents = await discoverFsAgents(dir); + expect(agents.map(a => a.name)).toEqual(['real']); + }); + + it('skips symlinked subagent directories so discovery cannot escape the project tree', async () => { + const outside = join(dir, 'outside-subagent'); + await mkdir(outside, { recursive: true }); + await writeFile(join(outside, 'instructions.md'), 'leaked'); + await writeAgent('parent', { + instructions: 'hi', + subagents: { real: { config: `export default { description: 'd' };`, instructions: 'child' } }, + }); + await symlink(outside, join(dir, 'agents', 'parent', 'subagents', 'evil')); + + const parent = (await discoverFsAgents(dir))[0]!; + expect(parent.subagents.map(s => s.name)).toEqual(['real']); + }); + + it('skips a symlinked instructions.md so its contents are not inlined', async () => { + const secret = join(dir, 'secret.md'); + await writeFile(secret, 'top secret'); + await writeAgent('weather', { config: `export default { model: 'openai/gpt-4o' };` }); + await symlink(secret, join(dir, 'agents', 'weather', 'instructions.md')); + + const agent = (await discoverFsAgents(dir))[0]!; + expect(agent.instructionsPath).toBeUndefined(); + }); + + it('skips a symlinked config.ts so it is not imported into the bundle', async () => { + const secret = join(dir, 'secret-config.ts'); + await writeFile(secret, `export default { model: 'openai/gpt-4o' };`); + await writeAgent('weather', { instructions: 'hi' }); + await symlink(secret, join(dir, 'agents', 'weather', 'config.ts')); + + const agent = (await discoverFsAgents(dir))[0]!; + expect(agent.configPath).toBeUndefined(); + }); + + it('skips a symlinked memory.ts so it is not imported into the bundle', async () => { + const secret = join(dir, 'secret-memory.ts'); + await writeFile(secret, `export default {};`); + await writeAgent('weather', { config: `export default { model: 'openai/gpt-4o' };`, instructions: 'hi' }); + await symlink(secret, join(dir, 'agents', 'weather', 'memory.ts')); + + const agent = (await discoverFsAgents(dir))[0]!; + expect(agent.memoryPath).toBeUndefined(); + }); + + it('discovers a flat markdown skill, defaulting name to the filename', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + skills: { 'faq.md': `# FAQ\nAnswer questions.` }, + }); + + const skill = (await discoverFsAgents(dir))[0]!.skills[0]!; + expect(skill).toMatchObject({ kind: 'packaged', name: 'faq' }); + }); + + it('discovers a createSkill module as a module skill', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + skills: { 'support.ts': `export default {};` }, + }); + + const skill = (await discoverFsAgents(dir))[0]!.skills[0]!; + expect(skill.kind).toBe('module'); + if (skill.kind === 'module') { + expect(skill.path).toMatch(/agents\/weather\/skills\/support\.ts$/); + } + }); + + it('ignores test files in skills', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + skills: { + 'support.ts': `export default {};`, + 'support.test.ts': `export default {};`, + }, + }); + + expect((await discoverFsAgents(dir))[0]!.skills).toHaveLength(1); + }); + + it('exposes the agent dir and discovers workspace.ts when present', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + workspace: `export default {};`, + }); + + const agent = (await discoverFsAgents(dir))[0]!; + expect(agent.dir).toMatch(/agents\/weather$/); + expect(agent.workspacePath).toMatch(/agents\/weather\/workspace\.ts$/); + }); + + it('leaves workspacePath undefined when there is no workspace file', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + }); + + expect((await discoverFsAgents(dir))[0]!.workspacePath).toBeUndefined(); + }); + + it('discovers memory.ts when present', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + memory: `export default {};`, + }); + + expect((await discoverFsAgents(dir))[0]!.memoryPath).toMatch(/agents\/weather\/memory\.ts$/); + }); + + it('leaves memoryPath undefined when there is no memory file', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + }); + + expect((await discoverFsAgents(dir))[0]!.memoryPath).toBeUndefined(); + }); + + it('discovers a subagent memory.ts', async () => { + await writeAgent('supervisor', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + subagents: { + worker: { + config: `export default { model: 'openai/gpt-4o', description: 'worker' };`, + instructions: 'hi', + memory: `export default {};`, + }, + }, + }); + + const agent = (await discoverFsAgents(dir))[0]!; + expect(agent.subagents[0]!.memoryPath).toMatch(/subagents\/worker\/memory\.ts$/); + }); + + it('discovers an authored workspace/ seed directory', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + workspaceSeed: { 'README.md': '# Seed', 'data/notes.txt': 'note' }, + }); + + const agent = (await discoverFsAgents(dir))[0]!; + expect(agent.workspaceSeedDir).toMatch(/agents\/weather\/workspace$/); + }); + + it('leaves workspaceSeedDir undefined when there is no workspace/ dir', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + }); + + expect((await discoverFsAgents(dir))[0]!.workspaceSeedDir).toBeUndefined(); + }); + + it('does not treat a workspace.ts file as a seed directory', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + workspace: `export default {};`, + }); + + const agent = (await discoverFsAgents(dir))[0]!; + expect(agent.workspacePath).toBeDefined(); + expect(agent.workspaceSeedDir).toBeUndefined(); + }); +}); + +describe('generateFsAgentsModule', () => { + it('imports the user entry and assembles each agent', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'Be a weather assistant.', + tools: { 'get_weather.ts': `export default {};` }, + }); + const agents = await discoverFsAgents(dir); + + const source = await generateFsAgentsModule('/project/src/mastra/index.ts', agents); + + expect(source).toContain(`import { assembleAgentFromFsEntry } from '@mastra/core/agent';`); + expect(source).toContain(`import * as __userEntry from "/project/src/mastra/index.ts";`); + expect(source).toContain(`export * from "/project/src/mastra/index.ts";`); + // instructions.md content is inlined. + expect(source).toContain(JSON.stringify('Be a weather assistant.')); + // tool key preserved. + expect(source).toContain(`key: "get_weather"`); + expect(source).toContain(`mastra.__registerFsAgents`); + expect(source).toContain(`export const mastra = __userEntry.mastra;`); + }); + + it('omits instructionsMd when there is no markdown file', async () => { + await writeAgent('coder', { + config: `export default { model: 'openai/gpt-4o', instructions: 'code' };`, + }); + const agents = await discoverFsAgents(dir); + + const source = await generateFsAgentsModule('/project/index.ts', agents); + expect(source).not.toContain('instructionsMd:'); + }); + + it('inlines packaged skills via createSkill and imports module skills', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + skills: { + 'review/SKILL.md': `---\nname: review\ndescription: Use when reviewing.\n---\n\n# Review\nDo it.`, + 'review/references/checklist.md': `# Checklist`, + 'support.ts': `export default {};`, + }, + }); + const agents = await discoverFsAgents(dir); + + const source = await generateFsAgentsModule('/project/index.ts', agents); + expect(source).toContain(`import { createSkill as __createSkill } from '@mastra/core/skills';`); + expect(source).toContain(`__createSkill({`); + expect(source).toContain(`name: "review"`); + expect(source).toContain(`references: {`); + expect(source).toContain(`"checklist.md"`); + // module skill imported and threaded into skills array + expect(source).toMatch(/import skill_\d+_\w+ from "[^"]*support\.ts";/); + expect(source).toContain(`skills: [`); + }); + + it('does not import createSkill when there are no packaged skills', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + skills: { 'support.ts': `export default {};` }, + }); + const agents = await discoverFsAgents(dir); + + const source = await generateFsAgentsModule('/project/index.ts', agents); + expect(source).not.toContain('__createSkill'); + }); + + it('always emits a defaultWorkspaceBasePath for each agent (default-on parity)', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + }); + const agents = await discoverFsAgents(dir); + + const source = await generateFsAgentsModule('/project/index.ts', agents); + // Base path is resolved at runtime relative to the bundled module so it + // points at `<bundle>/workspace/<name>` wherever the bundle is deployed. + expect(source).toContain('defaultWorkspaceBasePath: __workspaceBasePath("weather")'); + expect(source).toContain('const __bundleDir = __dirname(__fileURLToPath(import.meta.url));'); + expect(source).not.toContain('workspace:'); + }); + + it('imports workspace.ts and threads it into the entry when present', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + workspace: `export default {};`, + }); + const agents = await discoverFsAgents(dir); + + const source = await generateFsAgentsModule('/project/index.ts', agents); + expect(source).toMatch(/import workspace_\d+_\w+ from "[^"]*workspace\.ts";/); + expect(source).toMatch(/workspace: workspace_\d+_\w+/); + expect(source).toContain('defaultWorkspaceBasePath:'); + }); + + it('imports memory.ts and threads it into the entry when present', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + memory: `export default {};`, + }); + const agents = await discoverFsAgents(dir); + + const source = await generateFsAgentsModule('/project/index.ts', agents); + expect(source).toMatch(/import memory_\w+ from "[^"]*memory\.ts";/); + expect(source).toMatch(/memory: memory_\w+/); + }); + + it('imports a subagent memory.ts and threads it into the nested entry', async () => { + await writeAgent('supervisor', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + subagents: { + worker: { + config: `export default { model: 'openai/gpt-4o', description: 'worker' };`, + instructions: 'hi', + memory: `export default {};`, + }, + }, + }); + const agents = await discoverFsAgents(dir); + + const source = await generateFsAgentsModule('/project/index.ts', agents); + expect(source).toMatch(/import memory_\w+ from "[^"]*subagents\/worker\/memory\.ts";/); + expect(source).toMatch(/memory: memory_\w+/); + }); +}); + +describe('prepareFsAgentsEntry', () => { + it('returns the original entry unchanged when there are no fs agents', async () => { + const out = join(dir, '.mastra'); + const result = await prepareFsAgentsEntry(dir, '/project/index.ts', out); + expect(result).toEqual({ entryFile: '/project/index.ts', toolPaths: [], agentCount: 0 }); + }); + + it('returns a wrapper entry path, tool paths, and deferred source without writing', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + tools: { 'get_weather.ts': `export default {};` }, + }); + const out = join(dir, '.mastra'); + + const result = await prepareFsAgentsEntry(dir, join(dir, 'index.ts'), out); + expect(result.agentCount).toBe(1); + expect(result.entryFile).toMatch(/\.mastra-fs-agents-entry\.mjs$/); + expect(result.toolPaths.some(p => p.includes('agents/*/tools'))).toBe(true); + expect(result.moduleSource).toBeTruthy(); + + // The wrapper must NOT be written by prepare(): the bundler empties the + // output dir between prepare() and the actual write. + await expect(access(result.entryFile)).rejects.toThrow(); + }); + + it('writeFsAgentsEntry writes the wrapper after the output dir is emptied', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + }); + const out = join(dir, '.mastra'); + + const result = await prepareFsAgentsEntry(dir, join(dir, 'index.ts'), out); + + // Simulate bundler.prepare() emptying the output directory. + await rm(out, { recursive: true, force: true }); + + await writeFsAgentsEntry(result); + const written = await readFile(result.entryFile, 'utf-8'); + expect(written).toBe(result.moduleSource); + }); + + it('writeFsAgentsEntry is a no-op when there are no fs agents', async () => { + const out = join(dir, '.mastra'); + const result = await prepareFsAgentsEntry(dir, '/project/index.ts', out); + await expect(writeFsAgentsEntry(result)).resolves.toBeUndefined(); + }); +}); + +describe('mirrorFsAgentWorkspaces', () => { + it('mirrors authored workspace/ seeds into <bundle>/workspace/<name>', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + workspaceSeed: { 'README.md': '# Seed', 'data/notes.txt': 'note' }, + }); + const bundleDir = join(dir, 'output'); + + const mirrored = await mirrorFsAgentWorkspaces(dir, bundleDir); + + expect(mirrored).toEqual(['weather']); + expect(await readFile(join(bundleDir, 'workspace', 'weather', 'README.md'), 'utf-8')).toBe('# Seed'); + expect(await readFile(join(bundleDir, 'workspace', 'weather', 'data', 'notes.txt'), 'utf-8')).toBe('note'); + }); + + it('does not mirror symlinked workspace seeds (no sandbox escape)', async () => { + const secret = join(dir, 'secret.txt'); + await writeFile(secret, 'TOP SECRET'); + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + workspaceSeed: { 'README.md': '# Seed' }, + }); + await symlink(secret, join(dir, 'agents', 'weather', 'workspace', 'leak.txt')); + const bundleDir = join(dir, 'output'); + + await mirrorFsAgentWorkspaces(dir, bundleDir); + + expect(await readFile(join(bundleDir, 'workspace', 'weather', 'README.md'), 'utf-8')).toBe('# Seed'); + await expect(access(join(bundleDir, 'workspace', 'weather', 'leak.txt'))).rejects.toThrow(); + }); + + it('mirrors nothing when no agent has a workspace/ seed dir', async () => { + await writeAgent('weather', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + }); + const bundleDir = join(dir, 'output'); + + expect(await mirrorFsAgentWorkspaces(dir, bundleDir)).toEqual([]); + }); +}); + +describe('subagents', () => { + it('discovers one level of subagents under subagents/', async () => { + await writeAgent('supervisor', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'Delegate.', + subagents: { + researcher: { + config: `export default { model: 'openai/gpt-4o', description: 'Researches' };`, + instructions: 'Research.', + tools: { 'search.ts': `export default {};` }, + }, + writer: { + config: `export default { model: 'openai/gpt-4o', description: 'Writes' };`, + instructions: 'Write.', + }, + }, + }); + + const agents = await discoverFsAgents(dir); + expect(agents).toHaveLength(1); + const parent = agents[0]!; + expect(parent.subagents.map(s => s.name)).toEqual(['researcher', 'writer']); + const researcher = parent.subagents.find(s => s.name === 'researcher')!; + expect(researcher.tools.map(t => t.key)).toEqual(['search']); + expect(researcher.instructionsPath).toMatch(/supervisor\/subagents\/researcher\/instructions\.md$/); + }); + + it('skips subagent directories without config or instructions', async () => { + await writeAgent('supervisor', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + }); + // A stray subagents/ dir with no agent files. + await mkdir(join(dir, 'agents', 'supervisor', 'subagents', 'not-an-agent'), { recursive: true }); + + const parent = (await discoverFsAgents(dir))[0]!; + expect(parent.subagents).toEqual([]); + }); + + it('ignores nested subagents (one level only) with a warning', async () => { + await writeAgent('supervisor', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + subagents: { + researcher: { + config: `export default { model: 'openai/gpt-4o', description: 'Researches' };`, + instructions: 'r', + subagents: { + helper: { + config: `export default { model: 'openai/gpt-4o', description: 'Helps' };`, + instructions: 'h', + }, + }, + }, + }, + }); + const warnings: string[] = []; + + const parent = (await discoverFsAgents(dir, m => warnings.push(m)))[0]!; + const researcher = parent.subagents[0]!; + // The grandchild is dropped: a subagent never carries its own subagents. + expect(researcher.subagents).toEqual([]); + expect(warnings.some(w => /one level deep/.test(w))).toBe(true); + }); + + it('emits nested assembleAgentFromFsEntry entries for subagents with inlined instructions', async () => { + await writeAgent('supervisor', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'Delegate.', + subagents: { + writer: { + config: `export default { model: 'openai/gpt-4o', description: 'Writes' };`, + instructions: 'You are the writer subagent.', + tools: { 'draft.ts': `export default {};` }, + }, + }, + }); + const agents = await discoverFsAgents(dir); + + const source = await generateFsAgentsModule('/project/index.ts', agents); + // Parent carries a subagents: [...] field. + expect(source).toContain('subagents: ['); + // Child instructions are inlined. + expect(source).toContain(JSON.stringify('You are the writer subagent.')); + // Child name preserved as the bare delegation key. + expect(source).toContain('name: "writer"'); + // Subagent workspace base path nests under <parent>/<child>. + expect(source).toContain('defaultWorkspaceBasePath: __workspaceBasePath("supervisor/writer")'); + // Generated identifiers are unique across parent/child. + expect(source).toMatch(/import config_0_supervisor from /); + expect(source).toMatch(/import config_0_0_writer from /); + }); + + it('mirrors subagent workspace seeds to <bundle>/workspace/<parent>/<child>', async () => { + await writeAgent('supervisor', { + config: `export default { model: 'openai/gpt-4o' };`, + instructions: 'hi', + workspaceSeed: { 'parent.txt': 'p' }, + subagents: { + writer: { + config: `export default { model: 'openai/gpt-4o', description: 'Writes' };`, + instructions: 'w', + workspaceSeed: { 'child.txt': 'c' }, + }, + }, + }); + const bundleDir = join(dir, 'output'); + + const mirrored = await mirrorFsAgentWorkspaces(dir, bundleDir); + expect(mirrored.sort()).toEqual(['supervisor', 'supervisor/writer']); + expect(await readFile(join(bundleDir, 'workspace', 'supervisor', 'parent.txt'), 'utf-8')).toBe('p'); + expect(await readFile(join(bundleDir, 'workspace', 'supervisor', 'writer', 'child.txt'), 'utf-8')).toBe('c'); + }); +}); diff --git a/packages/deployer/src/build/fs-routing/mirror.ts b/packages/deployer/src/build/fs-routing/mirror.ts new file mode 100644 index 000000000000..7a32b419a491 --- /dev/null +++ b/packages/deployer/src/build/fs-routing/mirror.ts @@ -0,0 +1,59 @@ +import { cp, lstat, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { discoverFsAgents } from './discover'; +import type { DiscoveredFsAgent } from './discover'; + +/** + * Skip symlinks when copying workspace seeds. A symlink under + * `agents/<name>/workspace/` could point outside the workspace and be preserved + * in the bundle, letting the agent read arbitrary files at runtime. We copy only + * regular files and directories. + */ +async function rejectSymlinks(source: string): Promise<boolean> { + const stats = await lstat(source); + return !stats.isSymbolicLink(); +} + +async function mirrorAgentSeeds( + agent: DiscoveredFsAgent, + workspaceName: string, + bundleDir: string, + mirrored: string[], +): Promise<void> { + if (agent.workspaceSeedDir) { + const destination = join(bundleDir, 'workspace', ...workspaceName.split('/')); + await mkdir(destination, { recursive: true }); + await cp(agent.workspaceSeedDir, destination, { recursive: true, filter: rejectSymlinks }); + mirrored.push(workspaceName); + } + + // Subagents nest under `<parent>/<child>`, matching the codegen workspace key. + for (const child of agent.subagents ?? []) { + await mirrorAgentSeeds(child, `${workspaceName}/${child.name}`, bundleDir, mirrored); + } +} + +/** + * Mirror authored `agents/<name>/workspace/**` seed files into the bundled + * output so each fs-routed agent starts with them on disk (Eve parity). Files + * are copied to `<bundleDir>/workspace/<name>`, which is exactly where the + * generated entry roots each agent's default workspace at runtime (resolved + * relative to the bundled module via `import.meta.url`). Declared subagents + * mirror to the nested `<bundleDir>/workspace/<parent>/<child>` path. + * + * Must run AFTER the bundle step, since bundling recreates the output dir. + * + * @param mastraDir The user's `src/mastra` directory (source of seeds). + * @param bundleDir The final bundle directory (e.g. `<outputDirectory>/output`). + * @returns the workspace names whose seeds were mirrored (`<parent>/<child>` for subagents). + */ +export async function mirrorFsAgentWorkspaces(mastraDir: string, bundleDir: string): Promise<string[]> { + const agents = await discoverFsAgents(mastraDir); + const mirrored: string[] = []; + + for (const agent of agents) { + await mirrorAgentSeeds(agent, agent.name, bundleDir, mirrored); + } + + return mirrored; +} diff --git a/packages/deployer/src/build/fs-routing/prepare.ts b/packages/deployer/src/build/fs-routing/prepare.ts new file mode 100644 index 000000000000..f2052c4b4690 --- /dev/null +++ b/packages/deployer/src/build/fs-routing/prepare.ts @@ -0,0 +1,80 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join, posix } from 'node:path'; +import { slash } from '../utils'; +import { generateFsAgentsModule } from './codegen'; +import { discoverFsAgents } from './discover'; + +export interface PrepareFsAgentsEntryResult { + /** + * The entry file that should be fed to the bundler/analyzer. When fs-routed + * agents are found this is a generated wrapper module that registers them onto + * the user's mastra instance; otherwise it is the original entry unchanged. + */ + entryFile: string; + /** + * Glob tool paths for tools defined under `agents/*\/tools` so they are + * bundled alongside the top-level `tools/` directory. + */ + toolPaths: string[]; + /** Number of fs-routed agents discovered. */ + agentCount: number; + /** + * Generated wrapper source to write to {@link entryFile}, or `undefined` when + * there are no fs-routed agents. The write is deferred so callers can run it + * *after* `bundler.prepare()` empties the output directory — otherwise the + * wrapper is wiped before the bundler reads it. + */ + moduleSource?: string; +} + +/** + * Discover fs-routed agents under `<mastraDir>/agents/*` and, if any exist, + * generate a wrapper entry module that registers them onto the user's mastra + * instance. Returns the entry the bundler should use plus extra tool glob paths + * so `agents/*\/tools` are bundled. + * + * This does NOT write the wrapper to disk; call {@link writeFsAgentsEntry} with + * the result after `bundler.prepare()` so the generated file is not wiped when + * the output directory is emptied. + * + * When no fs-routed agents are present the original entry is returned unchanged, + * so existing code-only projects are completely unaffected. + */ +export async function prepareFsAgentsEntry( + mastraDir: string, + entryFile: string, + outputDirectory: string, +): Promise<PrepareFsAgentsEntryResult> { + const agents = await discoverFsAgents(mastraDir); + + if (agents.length === 0) { + return { entryFile, toolPaths: [], agentCount: 0 }; + } + + const moduleSource = await generateFsAgentsModule(slash(entryFile), agents); + const generatedEntry = join(outputDirectory, '.mastra-fs-agents-entry.mjs'); + + const normalizedMastraDir = slash(mastraDir); + const toolPaths = [ + posix.join(normalizedMastraDir, 'agents/*/tools/**/*.{js,ts}'), + `!${posix.join(normalizedMastraDir, 'agents/*/tools/**/*.{test,spec}.{js,ts}')}`, + `!${posix.join(normalizedMastraDir, 'agents/*/tools/**/__tests__/**')}`, + ]; + + return { entryFile: generatedEntry, toolPaths, agentCount: agents.length, moduleSource }; +} + +/** + * Write the generated fs-agents wrapper produced by {@link prepareFsAgentsEntry} + * to its `entryFile`. No-op when there are no fs-routed agents. Call this AFTER + * `bundler.prepare()` (which empties the output directory) so the wrapper + * survives for the bundler/watcher to read. + */ +export async function writeFsAgentsEntry(result: PrepareFsAgentsEntryResult): Promise<void> { + if (!result.moduleSource) { + return; + } + + await mkdir(dirname(result.entryFile), { recursive: true }); + await writeFile(result.entryFile, result.moduleSource, 'utf-8'); +} diff --git a/packages/deployer/src/build/index.ts b/packages/deployer/src/build/index.ts index b604bad3e0ab..736997d69e76 100644 --- a/packages/deployer/src/build/index.ts +++ b/packages/deployer/src/build/index.ts @@ -7,3 +7,9 @@ export { getServerOptions } from './serverOptions'; export { getBundlerOptions } from './bundlerOptions'; export { normalizeStudioBase, detectRuntime, injectStudioHtmlConfig } from './utils'; export type { RuntimePlatform, BundlerPlatform, StudioInjectionConfig } from './utils'; +export { discoverFsAgents } from './fs-routing/discover'; +export type { DiscoveredFsAgent } from './fs-routing/discover'; +export { generateFsAgentsModule } from './fs-routing/codegen'; +export { prepareFsAgentsEntry, writeFsAgentsEntry } from './fs-routing/prepare'; +export type { PrepareFsAgentsEntryResult } from './fs-routing/prepare'; +export { mirrorFsAgentWorkspaces } from './fs-routing/mirror'; diff --git a/packages/deployer/src/services/env.test.ts b/packages/deployer/src/services/env.test.ts new file mode 100644 index 000000000000..6c64bb2d09ff --- /dev/null +++ b/packages/deployer/src/services/env.test.ts @@ -0,0 +1,47 @@ +import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { FileEnvService } from './env'; + +describe('FileEnvService.setEnvValue', () => { + let dir: string; + let file: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'mastra-env-')); + file = join(dir, '.env'); + }); + + afterEach(async () => { + // Cleanup is best-effort; Windows can transiently lock the temp dir. + try { + await rm(dir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + it('preserves $ sequences in the value when replacing an existing key', async () => { + await writeFile(file, 'PWD=old\n', 'utf8'); + const svc = new FileEnvService(file); + + // Values like DB URLs or passwords commonly contain $. + const value = 'a$&b_$$_$1_end'; + await svc.setEnvValue('PWD', value); + + const content = await readFile(file, 'utf8'); + expect(content).toContain(`PWD=${value}`); + expect(await svc.getEnvValue('PWD')).toBe(value); + }); + + it('writes value literally when the key is new', async () => { + await writeFile(file, 'OTHER=1\n', 'utf8'); + const svc = new FileEnvService(file); + + const value = 'x$&y$$z'; + await svc.setEnvValue('NEWKEY', value); + + expect(await svc.getEnvValue('NEWKEY')).toBe(value); + }); +}); diff --git a/packages/deployer/src/services/env.ts b/packages/deployer/src/services/env.ts index af14e6bbd474..b0b5a8b22f4c 100644 --- a/packages/deployer/src/services/env.ts +++ b/packages/deployer/src/services/env.ts @@ -44,7 +44,10 @@ export class FileEnvService extends EnvService { }): Promise<string> { const regex = new RegExp(`^${key}=.*$`, 'm'); if (data.match(regex)) { - data = data.replace(regex, `${key}=${value}`); + // Use a replacement function so `$` sequences in the value (e.g. `$&`, + // `$$`) are written literally instead of being interpreted as + // String.prototype.replace special patterns. + data = data.replace(regex, () => `${key}=${value}`); } else { data += `\n${key}=${value}`; } diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 9692ed1744d5..ddbba35cf437 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -1,5 +1,48 @@ # @mastra/editor +## 0.13.3-alpha.2 + +### Patch Changes + +- Agent Builder agents now default observational memory to `__GATEWAY_OPENAI_MODEL_MINI__` instead of `__GATEWAY_GOOGLE_MODEL__`. Set `OPENAI_API_KEY` in any environment where Builder agents run. Core (non-builder) agents are unaffected and keep the framework default. Admins can still override the model: ([#18650](https://github.com/mastra-ai/mastra/pull/18650)) + + ```typescript + new MastraEditor({ + builder: { + enabled: true, + configuration: { + agent: { + memory: { observationalMemory: { model: '__GATEWAY_OPENAI_MODEL_MINI__' } }, + }, + }, + }, + }); + ``` + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`d0702ee`](https://github.com/mastra-ai/mastra/commit/d0702eedc1594cb2d0d83476440cfc2ec8820adb), [`65a66db`](https://github.com/mastra-ai/mastra/commit/65a66dbe249a0d92d828c605b955e73a983cf3b0), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/schema-compat@1.3.2-alpha.1 + - @mastra/mcp@1.12.1-alpha.0 + - @mastra/memory@1.21.3-alpha.2 + +## 0.13.3-alpha.1 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`c607ece`](https://github.com/mastra-ai/mastra/commit/c607eceeda028a80b24d00ee7dae376db73df526), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/memory@1.21.3-alpha.1 + +## 0.13.3-alpha.0 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`9feeaa0`](https://github.com/mastra-ai/mastra/commit/9feeaa0f9a1af07039e5b4f22b932b0cb18617e8), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/schema-compat@1.3.2-alpha.0 + - @mastra/mcp@1.12.0 + - @mastra/memory@1.21.3-alpha.0 + ## 0.13.2 ### Patch Changes diff --git a/packages/editor/package.json b/packages/editor/package.json index 9e4b68395662..0f335dd93c1a 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/editor", - "version": "0.13.2", + "version": "0.13.3-alpha.2", "description": "Mastra Editor for agent management and instantiation", "main": "dist/index.cjs", "module": "dist/index.js", diff --git a/packages/editor/src/editor.test.ts b/packages/editor/src/editor.test.ts index 6e97974ac079..aad4ea21c220 100644 --- a/packages/editor/src/editor.test.ts +++ b/packages/editor/src/editor.test.ts @@ -1723,6 +1723,43 @@ describe('agent.create with builder defaults', () => { const rawConfig = agent.toRawConfig?.(); expect(rawConfig?.memory).toEqual({ observationalMemory: true }); + + const memory = await agent.getMemory(); + const om = memory?.getConfig().observationalMemory; + expect(typeof om).not.toBe('boolean'); + if (typeof om !== 'boolean' && om) { + expect(om.model).toBe('openai/gpt-5.4-mini'); + } + }); + + it('passes an explicit observational memory model through unchanged (builder default does not override)', async () => { + const storage = new InMemoryStore(); + const editor = new MastraEditor({ + builder: { + enabled: true, + configuration: { + agent: { memory: { observationalMemory: { model: 'openai/gpt-4o-mini' } } }, + }, + }, + }); + new Mastra({ storage, editor }); + + const agent = await editor.agent.create({ + id: 'test-agent-explicit-om-model', + name: 'Test Agent', + instructions: 'Test', + model: { provider: 'openai', name: 'gpt-4' }, + }); + + const rawConfig = agent.toRawConfig?.(); + expect(rawConfig?.memory).toEqual({ observationalMemory: { model: 'openai/gpt-4o-mini' } }); + + const memory = await agent.getMemory(); + const om = memory?.getConfig().observationalMemory; + expect(typeof om).not.toBe('boolean'); + if (typeof om !== 'boolean' && om) { + expect(om.model).toBe('openai/gpt-4o-mini'); + } }); it('applies baseline observational memory when admin pinned other defaults but not memory', async () => { diff --git a/packages/editor/src/namespaces/agent.ts b/packages/editor/src/namespaces/agent.ts index 9b45bb1ebae4..058f6fca7768 100644 --- a/packages/editor/src/namespaces/agent.ts +++ b/packages/editor/src/namespaces/agent.ts @@ -111,6 +111,9 @@ const BUILDER_BASELINE_DEFAULTS: Partial<Record<(typeof BUILDER_DEFAULT_FIELDS)[ memory: { observationalMemory: true } satisfies SerializedMemoryConfig, }; +/** Model used for observational memory when a builder agent stores `observationalMemory: true`. */ +const BUILDER_DEFAULT_OM_MODEL = 'openai/gpt-5.4-mini'; + /** * Apply builder defaults to agent creation input. * Only applies for fields where input is `undefined` (not `null` — null is explicit disable). @@ -1455,7 +1458,12 @@ export class EditorAgentNamespace extends CrudEditorNamespace< if (memoryConfig.observationalMemory) { options = { ...options, - observationalMemory: memoryConfig.observationalMemory, + // A literal `true` means "use the builder default OM model"; an explicit + // object (user/admin choice) passes through untouched. + observationalMemory: + memoryConfig.observationalMemory === true + ? { model: BUILDER_DEFAULT_OM_MODEL } + : memoryConfig.observationalMemory, }; } diff --git a/packages/evals/CHANGELOG.md b/packages/evals/CHANGELOG.md index 376e3ae4646d..2b1fe6225fb1 100644 --- a/packages/evals/CHANGELOG.md +++ b/packages/evals/CHANGELOG.md @@ -1,5 +1,14 @@ # @mastra/evals +## 1.5.1-alpha.0 + +### Patch Changes + +- Eval scorers now receive the original user message for runs started through the agent subscription / `sendMessage` API. Previously `getUserMessageFromRunInput` returned an empty value for these runs, so scorers could not see what the user said (only `agent.stream` and `agent.generate` worked). ([#18546](https://github.com/mastra-ai/mastra/pull/18546)) + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + ## 1.5.0 ### Minor Changes diff --git a/packages/evals/package.json b/packages/evals/package.json index 0b9aae478ddd..9264c64ed2c6 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/evals", - "version": "1.5.0", + "version": "1.5.1-alpha.0", "description": "", "type": "module", "files": [ diff --git a/packages/evals/src/scorers/get-user-message-from-run-input.integration.test.ts b/packages/evals/src/scorers/get-user-message-from-run-input.integration.test.ts new file mode 100644 index 000000000000..d7f587a842b7 --- /dev/null +++ b/packages/evals/src/scorers/get-user-message-from-run-input.integration.test.ts @@ -0,0 +1,165 @@ +import { convertArrayToReadableStream, MockLanguageModelV2 } from '@internal/ai-sdk-v5/test'; +import { Mastra } from '@mastra/core'; +import { Agent } from '@mastra/core/agent'; +import { createScorer } from '@mastra/core/evals'; +import type { ScorerRunInputForAgent } from '@mastra/core/evals'; +import { InMemoryStore } from '@mastra/core/storage'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { getUserMessageFromRunInput } from './utils'; + +/** + * Behaviour: a scorer attached to an agent must receive the original user + * message regardless of how the run was started. + * + * Regression guard for the subscription / sendMessage path: messages sent via + * `agent.subscribeToThread` + `agent.sendMessage` are persisted as `role: 'signal'` + * (carrying the user role on `metadata.signal`), not `role: 'user'`. The earlier + * helper filtered strictly on `role === 'user'`, so scorers on that path saw an + * empty user message. This test drives the real subscription path end-to-end so + * the regression is caught at the actual call site, not just against a hand-built + * message shape. + */ + +function createTextStreamModel(responseText: string) { + return new MockLanguageModelV2({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop' as const, + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + content: [{ type: 'text' as const, text: responseText }], + warnings: [], + }), + doStream: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + warnings: [], + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: 'id-0', modelId: 'mock-model-id', timestamp: new Date(0) }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: responseText }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: 'stop', + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + }, + ]), + }), + }); +} + +/** Reads run parts off a subscription stream until the run finishes. */ +async function drainRun(iterator: AsyncIterator<any>): Promise<void> { + while (true) { + const next = await iterator.next(); + if (next.done) return; + const part = next.value; + if (part.type === 'finish' || part.type === 'error' || part.type === 'abort') { + return; + } + } +} + +async function waitFor(predicate: () => boolean, timeoutMs = 3000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return; + await new Promise(resolve => setTimeout(resolve, 25)); + } + throw new Error(`Condition not met within ${timeoutMs}ms`); +} + +describe('getUserMessageFromRunInput — scorer integration', () => { + const USER_MESSAGE = 'What is the capital of France?'; + + // Captures what the scorer extracts at its real call site. + let capturedInput: ScorerRunInputForAgent | undefined; + let extractedUserMessage: string | undefined; + + beforeEach(() => { + capturedInput = undefined; + extractedUserMessage = undefined; + }); + + // Registers an agent + scorer on a Mastra instance with in-memory storage. + // The Mastra registration is required: it installs the ON_SCORER_RUN hook + // handler that actually executes the scorer pipeline (and needs storage to + // run), and lets the hook resolve the scorer back to this agent. + function buildAgent(id: string) { + const scorerId = `${id}-capture-scorer`; + const captureScorer = createScorer({ + id: scorerId, + name: scorerId, + description: 'Captures the user message the scorer sees from run input', + }).generateScore(({ run }) => { + capturedInput = run.input; + extractedUserMessage = getUserMessageFromRunInput(run.input); + return 1; + }); + + const agent = new Agent({ + id, + name: id, + instructions: 'You are a helpful assistant.', + model: createTextStreamModel('Paris.'), + scorers: { capture: { scorer: captureScorer } }, + }); + + new Mastra({ + logger: false, + storage: new InMemoryStore(), + agents: { [id]: agent }, + scorers: { [scorerId]: captureScorer }, + }); + + return agent; + } + + describe('when a run is started via agent.generate', () => { + it('then the scorer receives the original user message', async () => { + // Given an agent with a scorer that extracts the user message + const agent = buildAgent('generate-agent'); + + // When the agent answers a direct generate() call + await agent.generate(USER_MESSAGE); + + // Then the scorer extracts the original user message + await waitFor(() => extractedUserMessage !== undefined); + expect(extractedUserMessage).toBe(USER_MESSAGE); + }); + }); + + describe('when a run is started via subscribeToThread + sendMessage', () => { + it('then the scorer receives the original user message (not an empty string)', async () => { + // Given an agent subscribed to an idle thread + const agent = buildAgent('subscription-agent'); + const subscription = await agent.subscribeToThread({ + threadId: 'sub-thread', + resourceId: 'sub-user', + }); + const drained = drainRun(subscription.stream[Symbol.asyncIterator]()); + + // When a user message is delivered through sendMessage (persisted as a signal) + const result = agent.sendMessage( + { contents: USER_MESSAGE }, + { + resourceId: 'sub-user', + threadId: 'sub-thread', + ifIdle: { streamOptions: { memory: { resource: 'sub-user', thread: 'sub-thread' } } }, + }, + ); + await result.accepted; + await drained; + + // Then, once the scorer has run on this subscription input... + await waitFor(() => capturedInput !== undefined, 8000); + subscription.unsubscribe(); + + // ...the input is the signal-role shape produced by the subscription path... + expect(capturedInput?.inputMessages?.some(m => (m as { role?: string }).role === 'signal')).toBe(true); + // ...and the helper recovers the original user text from it (the regression + // returned an empty / undefined value here). + expect(extractedUserMessage).toBe(USER_MESSAGE); + }, 15000); + }); +}); diff --git a/packages/evals/src/scorers/utils.test.ts b/packages/evals/src/scorers/utils.test.ts index 8ab45a48a478..03632a91d3fe 100644 --- a/packages/evals/src/scorers/utils.test.ts +++ b/packages/evals/src/scorers/utils.test.ts @@ -178,6 +178,59 @@ describe('Scorer Utils', () => { expect(getUserMessageFromRunInput(input)).toBe('What is the capital of France?'); }); + it('should extract user text from a signal-role message (subscription / sendMessage path)', () => { + // Messages sent through agent.subscribeToThread + agent.sendMessage are persisted + // with role 'signal' and carry their user role on type / metadata.signal, with the + // text living only in content.parts (no flat content.content). + const input: ScorerRunInputForAgent = { + inputMessages: [ + { + id: 'signal-msg-1', + role: 'signal', + type: 'user', + createdAt: new Date(), + content: { + format: 2, + parts: [{ type: 'text', text: 'What is the capital of France?' }], + metadata: { + signal: { id: 'signal-msg-1', type: 'user', tagName: 'user' }, + }, + }, + }, + ] as unknown as ScorerRunInputForAgent['inputMessages'], + rememberedMessages: [], + systemMessages: [], + taggedSystemMessages: {}, + }; + + expect(getUserMessageFromRunInput(input)).toBe('What is the capital of France?'); + }); + + it('should not treat a non-user signal message as the user message', () => { + const input: ScorerRunInputForAgent = { + inputMessages: [ + { + id: 'signal-reminder-1', + role: 'signal', + type: 'system-reminder', + createdAt: new Date(), + content: { + format: 2, + parts: [{ type: 'text', text: 'A reminder, not a user message' }], + metadata: { + signal: { id: 'signal-reminder-1', type: 'system-reminder', tagName: 'system-reminder' }, + }, + }, + }, + ] as unknown as ScorerRunInputForAgent['inputMessages'], + rememberedMessages: [], + systemMessages: [], + taggedSystemMessages: {}, + }; + + expect(getUserMessageFromRunInput(input)).toBeUndefined(); + }); + it('should extract user text from workflow-style input', () => { expect(getUserMessageFromRunInput({ prompt: 'Workflow question' })).toBe('Workflow question'); expect(getUserMessageFromRunInput('String question')).toBe('String question'); diff --git a/packages/evals/src/scorers/utils.ts b/packages/evals/src/scorers/utils.ts index 0131855d7c86..ee4bb7ffc30b 100644 --- a/packages/evals/src/scorers/utils.ts +++ b/packages/evals/src/scorers/utils.ts @@ -124,10 +124,31 @@ export const isScorerRunOutputForAgent = (output: unknown): output is ScorerRunO return Array.isArray(output) && output.every(isMastraDBMessageLike); }; +/** + * Resolves the effective role of a message, accounting for agent signal messages. + * + * Messages delivered through the agent subscription / signal API are persisted with + * `role: 'signal'` and carry their semantic role (e.g. `user`) on `type` and on + * `content.metadata.signal.{type,tagName}`. Treat those as their underlying role so + * helpers like `getUserMessageFromRunInput` can find them. + */ +const getEffectiveMessageRole = (message: Record<string, any>): string | undefined => { + if (message.role !== 'signal') return typeof message.role === 'string' ? message.role : undefined; + + const signalMeta = + isRecord(message.content) && isRecord(message.content.metadata) ? message.content.metadata.signal : undefined; + + const tagName = isRecord(signalMeta) && typeof signalMeta.tagName === 'string' ? signalMeta.tagName : undefined; + const signalType = isRecord(signalMeta) && typeof signalMeta.type === 'string' ? signalMeta.type : undefined; + const topLevelType = typeof message.type === 'string' ? message.type : undefined; + + return tagName ?? signalType ?? topLevelType; +}; + const getTextFromMessages = (messages: unknown, role: string): string | undefined => { if (!Array.isArray(messages)) return undefined; - const message = messages.find(message => isRecord(message) && message.role === role); + const message = messages.find(message => isRecord(message) && getEffectiveMessageRole(message) === role); return message ? getTextFromValue(message) : undefined; }; diff --git a/packages/mcp-docs-server/CHANGELOG.md b/packages/mcp-docs-server/CHANGELOG.md index cd24401da059..faa2e3d1f261 100644 --- a/packages/mcp-docs-server/CHANGELOG.md +++ b/packages/mcp-docs-server/CHANGELOG.md @@ -1,5 +1,55 @@ # @mastra/mcp-docs-server +## 1.2.3-alpha.16 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + +## 1.2.3-alpha.14 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + +## 1.2.3-alpha.13 + +### Patch Changes + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + +## 1.2.3-alpha.12 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`65a66db`](https://github.com/mastra-ai/mastra/commit/65a66dbe249a0d92d828c605b955e73a983cf3b0), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/mcp@1.12.1-alpha.0 + +## 1.2.3-alpha.10 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + +## 1.2.3-alpha.8 + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + +## 1.2.3-alpha.7 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + ## 1.2.3-alpha.5 ### Patch Changes diff --git a/packages/mcp-docs-server/package.json b/packages/mcp-docs-server/package.json index 223053ae3f74..b23263d42114 100644 --- a/packages/mcp-docs-server/package.json +++ b/packages/mcp-docs-server/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/mcp-docs-server", - "version": "1.2.3-alpha.6", + "version": "1.2.3-alpha.16", "description": "MCP server for accessing Mastra.ai documentation, changelogs, and news.", "type": "module", "main": "dist/index.js", diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 2869c1d1590f..677442358147 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,14 @@ # @mastra/mcp +## 1.12.1-alpha.0 + +### Patch Changes + +- Fixed @mastra/mcp crashing Cloudflare Workers at module initialization. MCPClient can now be safely imported on workerd without the Worker failing to start. ([#18664](https://github.com/mastra-ai/mastra/pull/18664)) + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + ## 1.12.0 ### Minor Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 5785d2fa928b..a3ca2f959338 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/mcp", - "version": "1.12.0", + "version": "1.12.1-alpha.0", "description": "", "type": "module", "main": "dist/index.js", diff --git a/packages/mcp/src/client/client.ts b/packages/mcp/src/client/client.ts index 3324475d7e54..ef4563637bbb 100644 --- a/packages/mcp/src/client/client.ts +++ b/packages/mcp/src/client/client.ts @@ -75,7 +75,6 @@ export type { const DEFAULT_SERVER_CONNECT_TIMEOUT_MSEC = 3000; const DEFAULT_INSTRUCTIONS_MAX_LENGTH = 512; -const require = createRequire(import.meta.url); // Per MCP spec, only fallback to SSE for these status codes const SSE_FALLBACK_STATUS_CODES = [400, 404, 405]; @@ -119,7 +118,8 @@ function loadDatadogTracer(): DatadogTracerLike | null { } try { - return require('dd-trace') as DatadogTracerLike; + const req = createRequire(import.meta.url); + return req('dd-trace') as DatadogTracerLike; } catch { return null; } @@ -139,8 +139,9 @@ function isDatadogTracerLikelyLoaded(): boolean { } try { - const resolvedPath = require.resolve('dd-trace'); - return Boolean(require.cache[resolvedPath]); + const req = createRequire(import.meta.url); + const resolvedPath = req.resolve('dd-trace'); + return Boolean(req.cache[resolvedPath]); } catch { return false; } diff --git a/packages/memory/CHANGELOG.md b/packages/memory/CHANGELOG.md index 4145bb31e534..2dd1fca6d850 100644 --- a/packages/memory/CHANGELOG.md +++ b/packages/memory/CHANGELOG.md @@ -1,5 +1,46 @@ # @mastra/memory +## 1.21.3-alpha.2 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`d0702ee`](https://github.com/mastra-ai/mastra/commit/d0702eedc1594cb2d0d83476440cfc2ec8820adb), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/schema-compat@1.3.2-alpha.1 + +## 1.21.3-alpha.1 + +### Patch Changes + +- Expose `providerMetadata` on Observational Memory `ObserveHooks` results ([#18563](https://github.com/mastra-ai/mastra/pull/18563)) + + `onObservationEnd` and `onReflectionEnd` now receive the OM model call's `providerMetadata` alongside `usage`, so you can read per-call provider details — for example the AI Gateway's cost and generation id — straight from the hook instead of wrapping the observer/reflector models in a model-stream middleware: + + ```ts + const hooks: ObserveHooks = { + onObservationEnd: ({ usage, providerMetadata }) => { + const gateway = providerMetadata?.gateway; + recordCost({ tokens: usage?.totalTokens, cost: gateway?.cost, generationId: gateway?.generationId }); + }, + onReflectionEnd: ({ usage, providerMetadata }) => { + recordCost({ tokens: usage?.totalTokens, cost: providerMetadata?.gateway?.cost }); + }, + }; + ``` + + The field is additive and optional, and is omitted entirely when the provider emits no metadata, so existing hook consumers are unaffected. For batched observations and multi-attempt reflections it reflects the last batch/attempt that emitted provider metadata. + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + +## 1.21.3-alpha.0 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`9feeaa0`](https://github.com/mastra-ai/mastra/commit/9feeaa0f9a1af07039e5b4f22b932b0cb18617e8), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/schema-compat@1.3.2-alpha.0 + ## 1.21.2 ### Patch Changes diff --git a/packages/memory/__recordings__/memory-integration-tests-src-observer-thread-title.json b/packages/memory/__recordings__/memory-integration-tests-src-observer-thread-title.json index 17d02759d284..abf083635d0a 100644 --- a/packages/memory/__recordings__/memory-integration-tests-src-observer-thread-title.json +++ b/packages/memory/__recordings__/memory-integration-tests-src-observer-thread-title.json @@ -8,7 +8,7 @@ }, "recordings": [ { - "hash": "9e13b0d1a4b3a2b3", + "hash": "3446e6a2edbaf125", "request": { "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", "method": "POST", diff --git a/packages/memory/integration-tests/src/om-extracted-metadata.test.ts b/packages/memory/integration-tests/src/om-extracted-metadata.test.ts new file mode 100644 index 000000000000..260defcbc743 --- /dev/null +++ b/packages/memory/integration-tests/src/om-extracted-metadata.test.ts @@ -0,0 +1,337 @@ +import { randomUUID } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { MockLanguageModelV2, convertArrayToReadableStream } from '@internal/ai-sdk-v5/test'; +import type { MastraDBMessage } from '@mastra/core/agent'; +import { getThreadOMMetadata, setThreadOMMetadata } from '@mastra/core/memory'; +import { LibSQLStore } from '@mastra/libsql'; +import { Extractor, Memory, WorkingMemoryExtractor } from '@mastra/memory'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +const createMessage = ( + threadId: string, + resourceId: string, + role: 'user' | 'assistant', + text: string, + createdAt: string, +): MastraDBMessage => ({ + id: randomUUID(), + threadId, + resourceId, + role, + createdAt: new Date(createdAt), + content: { + format: 2, + parts: [{ type: 'text', text }], + }, +}); + +describe('Observational Memory extracted metadata persistence', () => { + let dbDir: string; + let memory: Memory; + + beforeEach(async () => { + dbDir = await mkdtemp(join(tmpdir(), 'memory-om-extracted-metadata-')); + const storage = new LibSQLStore({ + id: randomUUID(), + url: `file:${join(dbDir, 'test.db')}`, + }); + await storage.init(); + memory = new Memory({ storage }); + }); + + afterEach(async () => { + await rm(dbDir, { recursive: true, force: true }); + }); + + it('persists extracted values through LibSQL thread metadata helpers', async () => { + const threadId = randomUUID(); + const resourceId = randomUUID(); + const now = new Date(); + + await memory.saveThread({ + thread: { + id: threadId, + resourceId, + title: 'Extracted Metadata Thread', + metadata: setThreadOMMetadata( + { projectId: 'extractors' }, + { + currentTask: 'Build extractor API', + extracted: { + priority: 'high', + profile: { tier: 'pro', region: 'us' }, + }, + }, + ), + createdAt: now, + updatedAt: now, + }, + }); + + const savedThread = await memory.getThreadById({ threadId }); + const savedMetadata = savedThread?.metadata as Record<string, unknown> | undefined; + expect(savedMetadata?.projectId).toBe('extractors'); + expect(getThreadOMMetadata(savedMetadata)).toMatchObject({ + currentTask: 'Build extractor API', + extracted: { + priority: 'high', + profile: { tier: 'pro', region: 'us' }, + }, + }); + + const savedOm = getThreadOMMetadata(savedMetadata); + await memory.updateThread({ + id: threadId, + title: savedThread?.title ?? 'Extracted Metadata Thread', + metadata: setThreadOMMetadata(savedMetadata, { + suggestedResponse: 'Continue with docs and tests.', + extracted: { + ...(savedOm?.extracted ?? {}), + priority: 'medium', + status: 'documented', + }, + }), + }); + + const updatedThread = await memory.getThreadById({ threadId }); + const updatedMetadata = updatedThread?.metadata as Record<string, unknown> | undefined; + expect(updatedMetadata?.projectId).toBe('extractors'); + expect(getThreadOMMetadata(updatedMetadata)).toMatchObject({ + currentTask: 'Build extractor API', + suggestedResponse: 'Continue with docs and tests.', + extracted: { + priority: 'medium', + profile: { tier: 'pro', region: 'us' }, + status: 'documented', + }, + }); + }); + + it('updates markdown working memory from an end-to-end LibSQL observation run', async () => { + const threadId = randomUUID(); + const resourceId = randomUUID(); + const observerOutput = `<observations> +- User shared durable profile details. +</observations> +<working-memory># User Profile +- Name: Tyler +- Location: Seattle +</working-memory>`; + const model = new MockLanguageModelV2({ + doStream: async () => ({ + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: 'obs-wm-1', modelId: 'mock-observer', timestamp: new Date() }, + { type: 'text-start', id: 'text-wm-1' }, + { type: 'text-delta', id: 'text-wm-1', delta: observerOutput }, + { type: 'text-end', id: 'text-wm-1' }, + { type: 'finish', finishReason: 'stop', usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 } }, + ]), + rawCall: { rawPrompt: null, rawSettings: {} }, + warnings: [], + }), + }); + + const workingMemory = new Memory({ + storage: memory.storage, + options: { + workingMemory: { + enabled: true, + template: '# User Profile\n- Name:\n- Location:', + agentManaged: false, + }, + observationalMemory: { + enabled: true, + observation: { + model, + messageTokens: 1, + bufferTokens: false, + previousObserverTokens: 1000, + extract: [new WorkingMemoryExtractor()], + }, + }, + }, + }); + + await workingMemory.createThread({ threadId, resourceId, title: 'Working Memory Markdown' }); + await workingMemory.saveMessages({ + messages: [ + createMessage( + threadId, + resourceId, + 'user', + 'My name is Tyler and I live in Seattle.', + '2026-06-24T18:00:00.000Z', + ), + ], + }); + + const omEngine = await workingMemory.omEngine; + const result = await omEngine!.observe({ threadId, resourceId }); + + expect(result.observed).toBe(true); + await expect(workingMemory.getWorkingMemory({ threadId, resourceId })).resolves.toContain('Name: Tyler'); + await expect(workingMemory.getWorkingMemory({ threadId, resourceId })).resolves.toContain('Location: Seattle'); + }); + + it('replaces schema-backed working memory from an end-to-end LibSQL observation run', async () => { + const threadId = randomUUID(); + const resourceId = randomUUID(); + const observerOutput = `<observations> +- User shared durable schema-backed profile details. +</observations>`; + const model = new MockLanguageModelV2({ + doStream: async () => ({ + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: 'obs-wm-2', modelId: 'mock-observer', timestamp: new Date() }, + { type: 'text-start', id: 'text-wm-2' }, + { type: 'text-delta', id: 'text-wm-2', delta: observerOutput }, + { type: 'text-end', id: 'text-wm-2' }, + { type: 'finish', finishReason: 'stop', usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 } }, + ]), + rawCall: { rawPrompt: null, rawSettings: {} }, + warnings: [], + }), + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { inputTokens: 80, outputTokens: 10, totalTokens: 90 }, + warnings: [], + content: [ + { + type: 'text', + text: JSON.stringify({ 'working-memory': { profile: { location: 'Seattle' }, preferences: ['weather'] } }), + }, + ], + }), + }); + + const workingMemory = new Memory({ + storage: memory.storage, + options: { + workingMemory: { + enabled: true, + schema: z.object({ + profile: z.object({ name: z.string().optional(), location: z.string().optional() }).optional(), + preferences: z.array(z.string()).optional(), + }), + agentManaged: false, + }, + observationalMemory: { + enabled: true, + observation: { + model, + messageTokens: 1, + bufferTokens: false, + previousObserverTokens: 1000, + extract: [new WorkingMemoryExtractor()], + }, + }, + }, + }); + + await workingMemory.createThread({ threadId, resourceId, title: 'Working Memory Schema' }); + await workingMemory.updateWorkingMemory({ + threadId, + resourceId, + workingMemory: JSON.stringify({ profile: { name: 'Tyler' } }), + }); + await workingMemory.saveMessages({ + messages: [ + createMessage( + threadId, + resourceId, + 'user', + 'I live in Seattle and like weather updates.', + '2026-06-24T18:05:00.000Z', + ), + ], + }); + + const omEngine = await workingMemory.omEngine; + const result = await omEngine!.observe({ threadId, resourceId }); + + expect(result.observed).toBe(true); + await expect(workingMemory.getWorkingMemory({ threadId, resourceId })).resolves.toBe( + JSON.stringify({ profile: { location: 'Seattle' }, preferences: ['weather'] }), + ); + }); + + it('persists extracted values from an end-to-end LibSQL observation run', async () => { + const threadId = randomUUID(); + const resourceId = randomUUID(); + const observerOutput = `<observations> +- User is prioritizing the extractor API and needs documentation coverage. +</observations> +<priority>high</priority> +<status>documented</status>`; + const model = new MockLanguageModelV2({ + doStream: async () => ({ + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: 'obs-1', modelId: 'mock-observer', timestamp: new Date() }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: observerOutput }, + { type: 'text-end', id: 'text-1' }, + { type: 'finish', finishReason: 'stop', usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 } }, + ]), + rawCall: { rawPrompt: null, rawSettings: {} }, + warnings: [], + }), + }); + + const extractionMemory = new Memory({ + storage: memory.storage, + options: { + observationalMemory: { + enabled: true, + observation: { + model, + messageTokens: 1, + bufferTokens: false, + previousObserverTokens: 1000, + extract: [ + new Extractor({ name: 'Priority', instructions: 'Extract the current priority.' }), + new Extractor({ name: 'Status', instructions: 'Extract the documentation status.' }), + ], + }, + }, + }, + }); + + await extractionMemory.createThread({ threadId, resourceId, title: 'Extractor E2E' }); + await extractionMemory.saveMessages({ + messages: [ + createMessage( + threadId, + resourceId, + 'user', + 'Priority is high. The extractor documentation status is documented.', + '2026-06-24T17:00:00.000Z', + ), + createMessage( + threadId, + resourceId, + 'assistant', + 'I will track priority and profile details in observational memory.', + '2026-06-24T17:00:05.000Z', + ), + ], + }); + + const omEngine = await extractionMemory.omEngine; + const result = await omEngine!.observe({ threadId, resourceId }); + + expect(result.observed).toBe(true); + + const updatedThread = await extractionMemory.getThreadById({ threadId }); + expect(getThreadOMMetadata(updatedThread?.metadata)?.extracted).toMatchObject({ + priority: 'high', + status: 'documented', + }); + }); +}); diff --git a/packages/memory/package.json b/packages/memory/package.json index d07829684006..8cb495bac9c0 100644 --- a/packages/memory/package.json +++ b/packages/memory/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/memory", - "version": "1.21.2", + "version": "1.21.3-alpha.2", "description": "", "type": "module", "main": "./dist/index.js", @@ -56,7 +56,8 @@ "lru-cache": "^11.2.7", "probe-image-size": "^7.2.3", "tokenx": "^1.3.0", - "xxhash-wasm": "^1.1.0" + "xxhash-wasm": "^1.1.0", + "zod": "catalog:" }, "devDependencies": { "@ai-sdk/openai": "^1.3.24", @@ -77,8 +78,7 @@ "tsx": "catalog:", "typescript": "catalog:", "typescript-eslint": "^8.57.0", - "vitest": "catalog:", - "zod": "catalog:" + "vitest": "catalog:" }, "peerDependencies": { "@mastra/core": ">=1.4.1-0 <2.0.0-0" diff --git a/packages/memory/src/index.test.ts b/packages/memory/src/index.test.ts index 91e2bcb40e61..7aad98b31298 100644 --- a/packages/memory/src/index.test.ts +++ b/packages/memory/src/index.test.ts @@ -54,6 +54,110 @@ describe('Memory', () => { }); }); + describe('listTools', () => { + it('omits working memory tools when agentManaged is false', () => { + const memory = new Memory({ + storage: new InMemoryStore(), + options: { workingMemory: { enabled: true, agentManaged: false } }, + }); + + expect(memory.listTools()).not.toHaveProperty('updateWorkingMemory'); + }); + + it('includes working memory tools by default when working memory is enabled', () => { + const memory = new Memory({ + storage: new InMemoryStore(), + options: { workingMemory: { enabled: true } }, + }); + + expect(memory.listTools()).toHaveProperty('updateWorkingMemory'); + }); + + it('uses manageWorkingMemory to add the working memory extractor and disable agent-managed tools by default', () => { + const memory = new Memory({ + storage: new InMemoryStore(), + options: { + workingMemory: { enabled: true }, + observationalMemory: { enabled: true, observation: { manageWorkingMemory: true } }, + }, + }); + + const config = memory.getMergedThreadConfig() as MemoryConfig & { + workingMemory: MemoryConfig['workingMemory'] & { agentManaged?: boolean; useStateSignals?: boolean }; + }; + const omConfig = config.observationalMemory as Extract<MemoryConfig['observationalMemory'], object> & { + observation?: { extract?: Array<{ slug: string }> }; + }; + expect(config.workingMemory.agentManaged).toBe(false); + expect(config.workingMemory.useStateSignals).toBe(true); + expect(memory.listTools()).not.toHaveProperty('updateWorkingMemory'); + expect(omConfig.observation?.extract?.some(extractor => extractor.slug === 'working-memory')).toBe(true); + }); + + it('keeps explicit useStateSignals false when manageWorkingMemory supplies defaults', () => { + const memory = new Memory({ + storage: new InMemoryStore(), + options: { + workingMemory: { enabled: true, useStateSignals: false }, + observationalMemory: { enabled: true, observation: { manageWorkingMemory: true } }, + }, + }); + + const config = memory.getMergedThreadConfig(); + + expect(config.workingMemory?.useStateSignals).toBe(false); + }); + + it('keeps agent-managed tools when agentManaged explicitly overrides manageWorkingMemory defaults', () => { + const memory = new Memory({ + storage: new InMemoryStore(), + options: { + workingMemory: { enabled: true, agentManaged: true, useStateSignals: false }, + observationalMemory: { enabled: true, observation: { manageWorkingMemory: true } }, + }, + }); + + expect(memory.listTools()).toHaveProperty('updateWorkingMemory'); + }); + }); + + describe('getSystemMessage', () => { + it('renders working memory as context-only when agentManaged is false', async () => { + const memory = new Memory({ + storage: new InMemoryStore(), + options: { workingMemory: { enabled: true, agentManaged: false } }, + }); + const threadId = 'agent-managed-false-thread'; + const resourceId = 'agent-managed-false-resource'; + await memory.createThread({ threadId, resourceId }); + await memory.updateWorkingMemory({ threadId, resourceId, workingMemory: '# User\n- Location: Sooke' }); + + const systemMessage = await memory.getSystemMessage({ threadId, resourceId }); + + expect(systemMessage).toContain('WORKING_MEMORY_SYSTEM_INSTRUCTION (READ-ONLY)'); + expect(systemMessage).toContain('Location: Sooke'); + expect(systemMessage).not.toContain('calling the updateWorkingMemory tool'); + }); + + it('renders update instructions when agentManaged explicitly overrides manageWorkingMemory defaults', async () => { + const memory = new Memory({ + storage: new InMemoryStore(), + options: { + workingMemory: { enabled: true, agentManaged: true, useStateSignals: false }, + observationalMemory: { enabled: true, observation: { manageWorkingMemory: true } }, + }, + }); + const threadId = 'agent-managed-true-thread'; + const resourceId = 'agent-managed-true-resource'; + await memory.createThread({ threadId, resourceId }); + + const systemMessage = await memory.getSystemMessage({ threadId, resourceId }); + + expect(systemMessage).toContain('calling the updateWorkingMemory tool'); + expect(systemMessage).not.toContain('WORKING_MEMORY_SYSTEM_INSTRUCTION (READ-ONLY)'); + }); + }); + describe('updateMessageToHideWorkingMemoryV2', () => { const memory = new TestableMemory(); diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index 60edb3d591d4..498c6b97b7b9 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -46,6 +46,7 @@ import type { JSONSchema7 } from 'json-schema'; import { LRUCache } from 'lru-cache'; import xxhash from 'xxhash-wasm'; import type { ObservationalMemory, ObservationalMemoryConfig } from './processors/observational-memory'; +import { WorkingMemoryExtractor } from './processors/observational-memory/working-memory-extractor'; import { recallTool } from './tools/om-tools'; import { createWorkingMemoryTool, deepMergeWorkingMemory } from './tools/working-memory'; @@ -53,6 +54,14 @@ export { ModelByInputTokens, type ModelByInputTokensConfig, } from './processors/observational-memory/model-by-input-tokens'; +export { + Extractor, + type ExtractorConfig, + type ExtractorOnExtractedContext, + type ExtractorRuntimeContext, + type ExtractorSource, +} from './processors/observational-memory'; +export { WorkingMemoryExtractor } from './processors/observational-memory/working-memory-extractor'; /** * Normalize a `boolean | object` observational memory config. @@ -204,6 +213,12 @@ function normalizeObservationalMemoryConfig( return config as NormalizedObservationalMemoryConfig; } +function hasWorkingMemoryExtractor( + extractors: NonNullable<NonNullable<ObservationalMemoryConfig['observation']>['extract']> | undefined, +): boolean { + return !!extractors?.some(extractor => extractor.slug === 'working-memory'); +} + // Re-export for testing purposes export { deepMergeWorkingMemory }; @@ -253,6 +268,40 @@ export class Memory extends MastraMemory { } } + public override getMergedThreadConfig(config?: MemoryConfigInternal): MemoryConfigInternal { + return this.applyManagedWorkingMemoryDefaults(super.getMergedThreadConfig(config)); + } + + private applyManagedWorkingMemoryDefaults(config: MemoryConfigInternal): MemoryConfigInternal { + const omConfig = normalizeObservationalMemoryConfig( + config.observationalMemory as boolean | MemoryObservationalMemoryOptions | undefined, + ); + if (!omConfig?.observation?.manageWorkingMemory || !config.workingMemory?.enabled) { + return config; + } + + const currentWorkingMemory = config.workingMemory; + const workingMemory = { + ...currentWorkingMemory, + agentManaged: currentWorkingMemory.agentManaged ?? false, + useStateSignals: currentWorkingMemory.useStateSignals ?? true, + }; + const observation = (omConfig.observation ?? {}) as NonNullable<ObservationalMemoryConfig['observation']>; + const extract = observation.extract ?? []; + + return { + ...config, + workingMemory, + observationalMemory: { + ...omConfig, + observation: { + ...observation, + extract: hasWorkingMemoryExtractor(extract) ? extract : [...extract, new WorkingMemoryExtractor()], + }, + }, + } as MemoryConfigInternal; + } + constructor(config: MemoryConstructorConfig = {}) { super({ name: 'Memory', ...config } as { name: string } & SharedMemoryConfig); @@ -1419,8 +1468,10 @@ ${workingMemory}`; return null; } - // In readOnly mode, provide context without tool instructions - if (config?.readOnly) { + const workingMemoryConfig = config.workingMemory; + + // In readOnly or non-agent-managed mode, provide context without tool instructions. + if (config?.readOnly || workingMemoryConfig.agentManaged === false) { return this.getReadOnlyWorkingMemoryInstruction({ template: workingMemoryTemplate, data: workingMemoryData, @@ -1651,6 +1702,7 @@ ${workingMemory}`; return new OMClass({ storage: memoryStore, + memory: this, scope: omConfig.scope, retrieval: omConfig.retrieval, activateAfterIdle: omConfig.activateAfterIdle, @@ -1674,6 +1726,7 @@ ${workingMemory}`; instruction: omConfig.observation.instruction, threadTitle: omConfig.observation.threadTitle, observeAttachments: omConfig.observation.observeAttachments, + extract: omConfig.observation.extract, } : undefined, reflection: omConfig.reflection @@ -1685,6 +1738,7 @@ ${workingMemory}`; bufferActivation: omConfig.reflection.bufferActivation, blockAfter: omConfig.reflection.blockAfter, instruction: omConfig.reflection.instruction, + extract: omConfig.reflection.extract, } : undefined, }); @@ -2139,7 +2193,9 @@ Notes: this.assertWorkingMemoryStateSignalsCompatibility(mergedConfig); const tools: Record<string, ToolAction<any, any, any>> = {}; - if (mergedConfig.workingMemory?.enabled && !mergedConfig.readOnly) { + const workingMemoryConfig = mergedConfig.workingMemory; + + if (workingMemoryConfig?.enabled && workingMemoryConfig.agentManaged !== false && !mergedConfig.readOnly) { const { name, tool } = createWorkingMemoryTool(mergedConfig, { vNext: this.isVNextWorkingMemoryConfig(mergedConfig), }); diff --git a/packages/memory/src/processors/observational-memory/__tests__/extractor.test.ts b/packages/memory/src/processors/observational-memory/__tests__/extractor.test.ts new file mode 100644 index 000000000000..0371981cc131 --- /dev/null +++ b/packages/memory/src/processors/observational-memory/__tests__/extractor.test.ts @@ -0,0 +1,506 @@ +import { MockLanguageModelV2 } from '@internal/ai-sdk-v5/test'; +import { Agent } from '@mastra/core/agent'; +import { coreFeatures } from '@mastra/core/features'; +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { composeObservationExtractors, composeReflectionExtractors } from '../built-in-extractors'; +import { applyExtractorHooks } from '../extracted-values'; +import { extractStructuredValues } from '../extraction-runner'; +import { + Extractor, + buildExtractorOutputSections, + buildExtractorPriorLines, + parseExtractedValues, + parseExtractorValue, + resolveExtractors, + slugifyExtractorName, + stripExtractorSections, + validateExtractorList, +} from '../extractor'; +import { WorkingMemoryExtractor } from '../working-memory-extractor'; + +describe('Extractor', () => { + it('creates an inline string carry-forward extractor when no schema is provided', () => { + const extractor = new Extractor({ name: 'Project Status', instructions: 'Extract the project status.' }); + + expect(extractor.slug).toBe('project-status'); + expect(extractor.mode).toBe('inline'); + expect(extractor.includePreviousExtraction).toBe(true); + expect(extractor.schema.parse('active')).toBe('active'); + }); + + it('creates a structured carry-forward extractor when a schema is provided', () => { + const extractor = new Extractor({ + name: 'Project Status', + instructions: 'Extract the project status.', + schema: z.object({ status: z.string() }), + }); + + expect(extractor.mode).toBe('structured'); + }); + + it('trims repeated slug separators without regex backtracking', () => { + expect(slugifyExtractorName('---Project---Status---')).toBe('project-status'); + }); + + it('rejects empty, duplicate, and reserved slugs', () => { + expect(() => new Extractor({ name: '!!!', instructions: 'No usable slug.' })).toThrow(/non-empty slug/); + expect(() => new Extractor({ name: 'current-task', instructions: 'Reserved.' })).toThrow(/reserved/); + + const first = new Extractor({ name: 'Priority', instructions: 'Extract priority.' }); + const second = new Extractor({ name: 'priority', instructions: 'Extract priority again.' }); + + expect(() => validateExtractorList([first, second])).toThrow(/Duplicate extractor slug "priority"/); + }); + + it('parses combined inline JSON string extractor values', () => { + const mood = new Extractor({ name: 'Mood', instructions: 'Extract mood.' }); + const details = new Extractor({ name: 'Details', instructions: 'Extract details.' }); + + const parsed = parseExtractedValues( + `<extracted-values>\n{"mood":"focused","details":"memory and om"}\n</extracted-values>`, + [mood, details], + ); + + expect(parsed.values).toEqual({ + mood: 'focused', + details: 'memory and om', + }); + expect(parsed.failures).toEqual([]); + }); + + it('parses legacy per-extractor XML tags for schema-less inline values', () => { + const userInfo = new Extractor({ name: 'User info', instructions: 'Extract user details.' }); + + const parsed = parseExtractedValues( + '<observations>User shared their name.</observations>\n<user-info>name: Tyler</user-info>', + [userInfo], + ); + + expect(parsed.values).toEqual({ 'user-info': 'name: Tyler' }); + expect(parsed.failures).toEqual([]); + }); + + it('ignores structured extractors when parsing inline extracted values', () => { + const valid = new Extractor({ name: 'Valid', instructions: 'Extract valid.' }); + const count = new Extractor({ name: 'Count', instructions: 'Extract count.', schema: z.number() }); + + const parsed = parseExtractedValues( + '<extracted-values>\n{"valid":"ok","count":"not-a-number"}\n</extracted-values>', + [valid, count], + ); + + expect(parsed.values).toEqual({ valid: 'ok' }); + expect(parsed.failures).toEqual([]); + }); + + it('strips inline extractor sections before observation parsing', () => { + const status = new Extractor({ name: 'Status', instructions: 'Extract status.' }); + + expect( + stripExtractorSections( + '<observations>Keep me</observations>\n<extracted-values>\n{"status":"done"}\n</extracted-values>\n<status>done</status>', + [status], + ), + ).toBe('<observations>Keep me</observations>\n'); + }); + + it('validates raw values with JSON-first fallback for structured values', () => { + const score = new Extractor({ name: 'Score', instructions: 'Extract score.', schema: z.number() }); + + expect(parseExtractorValue(score, '7')).toBe(7); + expect(() => parseExtractorValue(score, 'seven')).toThrow(/did not match/); + }); + + it('builds per-extractor inline output sections for schema-less string extractors', () => { + const location = new Extractor({ + name: 'Weather Locations', + instructions: 'Extract requested weather locations.', + schema: z.array(z.string()), + }); + const mood = new Extractor({ name: 'Mood', instructions: 'Extract mood.' }); + + const section = buildExtractorOutputSections([location, mood]); + + expect(section).toContain('Additional optional XML sections:'); + expect(section).toContain('If the observations include information relevant to any of these tags'); + expect(section).not.toContain('<weather-locations>'); + expect(section).toContain('<mood>'); + expect(section).toContain('Extract mood.'); + expect(section).toContain('Include this section when the observations contain relevant information for <mood>.'); + expect(section).not.toContain('<extracted-values>'); + }); + + it('ignores copied combined inline output placeholders', () => { + const location = new Extractor({ + name: 'Weather Locations', + instructions: 'Extract requested weather locations.', + }); + + const parsed = parseExtractedValues( + '<extracted-values>\nWrite only the extracted values JSON object here.\n</extracted-values>', + [location], + ); + + expect(parsed.values).toEqual({}); + expect(parsed.failures).toEqual([]); + }); + + it('builds previous extraction prompt sections only for opted-in extractors with values', () => { + const keep = new Extractor({ name: 'Keep', instructions: 'Keep it.' }); + const skip = new Extractor({ name: 'Skip', instructions: 'Skip it.', includePreviousExtraction: false }); + + expect(buildExtractorPriorLines([keep, skip], { keep: 'previous', skip: 'hidden' })).toEqual([ + '<keep>\nprevious\n</keep>', + ]); + }); + + it('resolves dynamic instructions and schemas with runtime context', async () => { + const schema = z.object({ memoryEnabled: z.boolean() }); + const memory = { marker: 'active-memory' } as any; + const extractor = new Extractor({ + name: 'Working Memory Draft', + instructions: context => `Use ${context.memory === memory ? 'active' : 'missing'} memory for ${context.source}.`, + schema: context => (context.memory === memory ? schema : undefined), + }); + + const [resolved] = await resolveExtractors([extractor], { source: 'observer', memory }); + + expect(resolved?.instructions).toBe('Use active memory for observer.'); + expect(resolved?.mode).toBe('structured'); + expect(resolved?.schema).toBe(schema); + expect(buildExtractorOutputSections([resolved!])).toBe(''); + }); + + it('passes the active memory instance to extractor hooks', async () => { + const onExtracted = vi.fn((_context: { memory?: unknown }) => undefined); + const memory = { marker: 'active-memory' } as any; + const extractor = new Extractor({ name: 'Hook', instructions: 'Extract hook.', onExtracted }); + + await applyExtractorHooks({ + source: 'observer', + extractors: [extractor], + values: { hook: 'value' }, + threadId: 'thread-1', + memory, + }); + + expect(onExtracted).toHaveBeenCalledWith(expect.objectContaining({ memory })); + }); + + it('updates markdown working memory from the working memory extractor without persisting OM metadata', async () => { + const memory = { + getMergedThreadConfig: vi.fn(() => ({ workingMemory: { enabled: true } })), + getWorkingMemoryTemplate: vi.fn(async () => ({ format: 'markdown', content: '# User\n' })), + getWorkingMemory: vi.fn(async () => '- Existing fact'), + updateWorkingMemory: vi.fn(async () => undefined), + } as any; + const extractor = new WorkingMemoryExtractor(); + const [resolved] = await resolveExtractors([extractor], { + source: 'observer', + threadId: 'thread-1', + resourceId: 'resource-1', + memory, + }); + + expect(resolved?.instructions).toContain('Current working memory:\n- Existing fact'); + + const result = await applyExtractorHooks({ + source: 'observer', + extractors: [resolved!], + values: { 'working-memory': '# User\n- Existing fact\n- New fact' }, + threadId: 'thread-1', + resourceId: 'resource-1', + memory, + }); + + expect(memory.updateWorkingMemory).toHaveBeenCalledWith({ + threadId: 'thread-1', + resourceId: 'resource-1', + workingMemory: '# User\n- Existing fact\n- New fact', + memoryConfig: undefined, + }); + expect(result.values).toEqual({ 'working-memory': '# User\n- Existing fact\n- New fact' }); + }); + + it('replaces JSON working memory from the working memory extractor', async () => { + const memory = { + getMergedThreadConfig: vi.fn(() => ({ workingMemory: { enabled: true, schema: {} } })), + getWorkingMemoryTemplate: vi.fn(async () => ({ format: 'json', content: '{"type":"object"}' })), + getWorkingMemory: vi.fn(async () => '{"name":"Tyler","likes":["dogs"]}'), + updateWorkingMemory: vi.fn(async () => undefined), + } as any; + const extractor = new WorkingMemoryExtractor(); + const [resolved] = await resolveExtractors([extractor], { + source: 'observer', + threadId: 'thread-1', + resourceId: 'resource-1', + memory, + }); + + expect(resolved?.mode).toBe('structured'); + expect(resolved?.instructions).toContain('Working memory JSON schema:'); + expect(resolved?.schema.parse({ location: 'Toronto' })).toEqual({ location: 'Toronto' }); + expect(resolved?.schema.parse(null)).toBeNull(); + expect(buildExtractorOutputSections([resolved!])).toBe(''); + + const result = await applyExtractorHooks({ + source: 'observer', + extractors: [resolved!], + values: { 'working-memory': { location: 'Toronto' } }, + threadId: 'thread-1', + resourceId: 'resource-1', + memory, + }); + + expect(memory.updateWorkingMemory).toHaveBeenCalledWith({ + threadId: 'thread-1', + resourceId: 'resource-1', + workingMemory: JSON.stringify({ location: 'Toronto' }), + memoryConfig: undefined, + }); + expect(result.values).toEqual({ 'working-memory': { location: 'Toronto' } }); + }); + + it('skips JSON working memory updates when the extractor returns null', async () => { + const memory = { + getMergedThreadConfig: vi.fn(() => ({ workingMemory: { enabled: true, schema: {} } })), + getWorkingMemoryTemplate: vi.fn(async () => ({ format: 'json', content: '{"type":"object"}' })), + getWorkingMemory: vi.fn(async () => '{"name":"Tyler"}'), + updateWorkingMemory: vi.fn(async () => undefined), + } as any; + const extractor = new WorkingMemoryExtractor(); + const [resolved] = await resolveExtractors([extractor], { + source: 'observer', + threadId: 'thread-1', + resourceId: 'resource-1', + memory, + }); + + const result = await applyExtractorHooks({ + source: 'observer', + extractors: [resolved!], + values: { 'working-memory': null }, + threadId: 'thread-1', + resourceId: 'resource-1', + memory, + }); + + expect(memory.updateWorkingMemory).not.toHaveBeenCalled(); + expect(result.values).toBeUndefined(); + }); + + it('returns extractor failures when the structured extraction call fails', async () => { + const priority = new Extractor({ name: 'Priority', instructions: 'Extract priority.', schema: z.string() }); + const profile = new Extractor({ + name: 'Profile', + instructions: 'Extract profile.', + schema: z.object({ tier: z.string() }), + }); + const agent = new Agent({ + id: 'structured-extraction-failure-test', + name: 'Structured Extraction Failure Test', + instructions: 'Extract values.', + model: new MockLanguageModelV2({ + doGenerate: async () => { + throw new Error('structured call failed'); + }, + }), + }); + + const result = await extractStructuredValues({ + agent, + source: 'observer', + extractors: [priority, profile], + }); + + expect(result.values).toEqual({}); + expect(result.failures).toEqual([ + { slug: 'priority', error: 'structured call failed' }, + { slug: 'profile', error: 'structured call failed' }, + ]); + }); + + it('retries structured extraction with inline json prompt injection when native output throws', async () => { + const priority = new Extractor({ name: 'Priority', instructions: 'Extract priority.', schema: z.string() }); + const generate = vi + .fn() + .mockRejectedValueOnce(new Error('native failed')) + .mockResolvedValueOnce({ object: { priority: 'high' } }); + + const result = await extractStructuredValues({ + agent: { generate } as unknown as Agent<any, any, any, any>, + source: 'observer', + extractors: [priority], + }); + + expect(result.values).toEqual({ priority: 'high' }); + expect(generate).toHaveBeenCalledTimes(2); + expect(generate.mock.calls[0][1].structuredOutput.jsonPromptInjection).toBeUndefined(); + expect(generate.mock.calls[1][1].structuredOutput.jsonPromptInjection).toBe('inline'); + }); + + it('retries structured extraction with inline json prompt injection when native output has no object', async () => { + const priority = new Extractor({ name: 'Priority', instructions: 'Extract priority.', schema: z.string() }); + const generate = vi + .fn() + .mockResolvedValueOnce({ object: undefined }) + .mockResolvedValueOnce({ object: { priority: 'medium' } }); + + const result = await extractStructuredValues({ + agent: { generate } as unknown as Agent<any, any, any, any>, + source: 'observer', + extractors: [priority], + }); + + expect(result.values).toEqual({ priority: 'medium' }); + expect(generate).toHaveBeenCalledTimes(2); + expect(generate.mock.calls[1][1].structuredOutput.jsonPromptInjection).toBe('inline'); + }); + + it('falls back to system json prompt injection when inline support is not advertised', async () => { + const priority = new Extractor({ name: 'Priority', instructions: 'Extract priority.', schema: z.string() }); + const generate = vi + .fn() + .mockRejectedValueOnce(new Error('native failed')) + .mockResolvedValueOnce({ object: { priority: 'low' } }); + + coreFeatures.delete('json-prompt-injection:inline'); + try { + const result = await extractStructuredValues({ + agent: { generate } as unknown as Agent<any, any, any, any>, + source: 'observer', + extractors: [priority], + }); + + expect(result.values).toEqual({ priority: 'low' }); + expect(generate.mock.calls[1][1].structuredOutput.jsonPromptInjection).toBe(true); + } finally { + coreFeatures.add('json-prompt-injection:inline'); + } + }); + + it('rethrows abort errors without retrying structured extraction', async () => { + const priority = new Extractor({ name: 'Priority', instructions: 'Extract priority.', schema: z.string() }); + const abortSignal = AbortSignal.abort(); + const generate = vi.fn().mockRejectedValueOnce(new DOMException('aborted', 'AbortError')); + + await expect( + extractStructuredValues({ + agent: { generate } as unknown as Agent<any, any, any, any>, + source: 'observer', + extractors: [priority], + abortSignal, + }), + ).rejects.toThrow(/aborted/); + + expect(generate).toHaveBeenCalledTimes(1); + }); + + it('uses a direct extraction-only prompt for structured observer follow-up calls', async () => { + const priority = new Extractor({ name: 'Priority', instructions: 'Extract priority.', schema: z.string() }); + let prompt = ''; + const agent = new Agent({ + id: 'structured-extraction-memory-test', + name: 'Structured Extraction Memory Test', + instructions: 'Extract values.', + model: new MockLanguageModelV2({ + doGenerate: async ({ prompt: modelPrompt }) => { + prompt = JSON.stringify(modelPrompt); + return { + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + content: [{ type: 'text', text: '{"priority":"high"}' }], + warnings: [], + }; + }, + }), + }); + + const result = await extractStructuredValues({ + agent, + source: 'observer', + extractors: [priority], + }); + + expect(result.values).toEqual({ priority: 'high' }); + expect(prompt).toContain('Extract structured data from the observations you made.'); + expect(prompt).toContain('Do not write observations, XML, markdown, or explanatory text.'); + expect(prompt).not.toContain('previous assistant message'); + expect(prompt).not.toContain('## Source Output'); + expect(prompt).not.toContain('## Parsed Observations'); + expect(prompt).not.toContain('<observations>'); + expect(prompt).not.toContain('<thread-title>'); + }); + + it('uses direct reflection wording for structured reflector follow-up calls', async () => { + const priority = new Extractor({ name: 'Priority', instructions: 'Extract priority.', schema: z.string() }); + let prompt = ''; + const agent = new Agent({ + id: 'structured-reflection-extraction-test', + name: 'Structured Reflection Extraction Test', + instructions: 'Extract values.', + model: new MockLanguageModelV2({ + doGenerate: async ({ prompt: modelPrompt }) => { + prompt = JSON.stringify(modelPrompt); + return { + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + content: [{ type: 'text', text: '{"priority":"high"}' }], + warnings: [], + }; + }, + }), + }); + + const result = await extractStructuredValues({ + agent, + source: 'reflector', + extractors: [priority], + }); + + expect(result.values).toEqual({ priority: 'high' }); + expect(prompt).toContain('Extract structured data from the reflection you made.'); + expect(prompt).not.toContain('previous assistant message'); + expect(prompt).not.toContain('## Source Output'); + expect(prompt).not.toContain('## Parsed Observations'); + }); + + it('applies user hooks, validates returned values, and records hook failures', async () => { + const okHook = vi.fn((context: { current: string }) => context.current.toUpperCase()); + const badHook = vi.fn((_context: { current: string }) => { + throw new Error('hook failed'); + }); + const ok = new Extractor<string>({ name: 'Ok', instructions: 'Extract ok.', onExtracted: okHook }); + const bad = new Extractor<string>({ name: 'Bad', instructions: 'Extract bad.', onExtracted: badHook }); + + const result = await applyExtractorHooks({ + source: 'observer', + extractors: [ok, bad], + values: { ok: 'yes', bad: 'no' }, + previousValues: { ok: 'old' }, + threadId: 'thread-1', + resourceId: 'resource-1', + }); + + expect(okHook).toHaveBeenCalledWith(expect.objectContaining({ previous: 'old', current: 'yes' })); + expect(result.values).toEqual({ ok: 'YES' }); + expect(result.failures).toEqual([{ slug: 'bad', error: 'hook failed' }]); + }); + + it('composes enabled built-ins before user extractors', () => { + const user = new Extractor({ name: 'Preference', instructions: 'Extract preference.' }); + + expect( + composeObservationExtractors({ threadTitle: true, extract: [user] }).map(extractor => extractor.slug), + ).toEqual(['current-task', 'suggested-response', 'thread-title', 'preference']); + expect(composeReflectionExtractors({ extract: [user] }).map(extractor => extractor.slug)).toEqual([ + 'current-task', + 'suggested-response', + 'preference', + ]); + }); +}); diff --git a/packages/memory/src/processors/observational-memory/__tests__/markers.test.ts b/packages/memory/src/processors/observational-memory/__tests__/markers.test.ts index 0e69dec447f6..e9eb5f16b8a2 100644 --- a/packages/memory/src/processors/observational-memory/__tests__/markers.test.ts +++ b/packages/memory/src/processors/observational-memory/__tests__/markers.test.ts @@ -98,6 +98,8 @@ describe('markers', () => { observations: '- User asked about weather\n- User prefers Celsius', currentTask: 'Helping with weather info', suggestedResponse: 'The forecast looks clear', + extractedValues: { priority: 'high' }, + extractionFailures: [{ slug: 'profile', error: 'schema mismatch' }], recordId: 'rec-1', threadId: 'thread-1', }); @@ -110,6 +112,8 @@ describe('markers', () => { expect(marker.data.observations).toBe('- User asked about weather\n- User prefers Celsius'); expect(marker.data.currentTask).toBe('Helping with weather info'); expect(marker.data.suggestedResponse).toBe('The forecast looks clear'); + expect(marker.data.extractedValues).toEqual({ priority: 'high' }); + expect(marker.data.extractionFailures).toEqual([{ slug: 'profile', error: 'schema mismatch' }]); }); it('allows optional fields to be undefined', () => { @@ -229,6 +233,8 @@ describe('markers', () => { recordId: 'rec-1', threadId: 'thread-1', observations: '- Buffered obs 1\n- Buffered obs 2', + extractedValues: { priority: 'medium' }, + extractionFailures: [{ slug: 'status', error: 'missing value' }], }); expect(marker.type).toBe('data-om-buffering-end'); @@ -237,6 +243,8 @@ describe('markers', () => { expect(marker.data.tokensBuffered).toBe(3500); expect(marker.data.bufferedTokens).toBe(7000); expect(marker.data.observations).toBe('- Buffered obs 1\n- Buffered obs 2'); + expect(marker.data.extractedValues).toEqual({ priority: 'medium' }); + expect(marker.data.extractionFailures).toEqual([{ slug: 'status', error: 'missing value' }]); }); it('allows observations to be undefined', () => { diff --git a/packages/memory/src/processors/observational-memory/__tests__/observational-memory-api.test.ts b/packages/memory/src/processors/observational-memory/__tests__/observational-memory-api.test.ts index caf4f50624c5..f436fb7bc93b 100644 --- a/packages/memory/src/processors/observational-memory/__tests__/observational-memory-api.test.ts +++ b/packages/memory/src/processors/observational-memory/__tests__/observational-memory-api.test.ts @@ -12,10 +12,12 @@ import { MockLanguageModelV2, convertArrayToReadableStream } from '@internal/ai-sdk-v5/test'; import type { MastraDBMessage, MastraMessageContentV2 } from '@mastra/core/agent'; +import { getThreadOMMetadata, setThreadOMMetadata } from '@mastra/core/memory'; import { InMemoryMemory, InMemoryDB } from '@mastra/core/storage'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { BufferingCoordinator } from '../buffering-coordinator'; +import { Extractor } from '../extractor'; import { ModelByInputTokens } from '../model-by-input-tokens'; import { ObservationalMemory } from '../observational-memory'; @@ -56,7 +58,10 @@ function createBulkMessages(count: number, threadId: string, startTime?: number) })); } -function createMockObserverModel(observationOverride?: string) { +function createMockObserverModel( + observationOverride?: string, + providerMetadata?: Record<string, Record<string, unknown>>, +) { const observationText = observationOverride ?? `<observations> @@ -77,6 +82,7 @@ Continue helping the user usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, warnings: [], content: [{ type: 'text', text: observationText }], + ...(providerMetadata ? { providerMetadata } : {}), }), doStream: async () => ({ stream: convertArrayToReadableStream([ @@ -85,7 +91,12 @@ Continue helping the user { type: 'text-start', id: 'text-1' }, { type: 'text-delta', id: 'text-1', delta: observationText }, { type: 'text-end', id: 'text-1' }, - { type: 'finish', finishReason: 'stop', usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 } }, + { + type: 'finish', + finishReason: 'stop', + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + ...(providerMetadata ? { providerMetadata } : {}), + }, ]), rawCall: { rawPrompt: null, rawSettings: {} }, warnings: [], @@ -93,7 +104,10 @@ Continue helping the user } as any); } -function createMockReflectorModel(reflectedObservations?: string) { +function createMockReflectorModel( + reflectedObservations?: string, + providerMetadata?: Record<string, Record<string, unknown>>, +) { const text = reflectedObservations ?? `<observations> @@ -107,6 +121,7 @@ function createMockReflectorModel(reflectedObservations?: string) { usage: { inputTokens: 50, outputTokens: 30, totalTokens: 80 }, warnings: [], content: [{ type: 'text', text }], + ...(providerMetadata ? { providerMetadata } : {}), }), doStream: async () => ({ stream: convertArrayToReadableStream([ @@ -115,7 +130,12 @@ function createMockReflectorModel(reflectedObservations?: string) { { type: 'text-start', id: 'text-1' }, { type: 'text-delta', id: 'text-1', delta: text }, { type: 'text-end', id: 'text-1' }, - { type: 'finish', finishReason: 'stop', usage: { inputTokens: 50, outputTokens: 30, totalTokens: 80 } }, + { + type: 'finish', + finishReason: 'stop', + usage: { inputTokens: 50, outputTokens: 30, totalTokens: 80 }, + ...(providerMetadata ? { providerMetadata } : {}), + }, ]), rawCall: { rawPrompt: null, rawSettings: {} }, warnings: [], @@ -132,6 +152,8 @@ function createOM( scope?: 'thread' | 'resource'; observerModel?: any; reflectorModel?: any; + observationExtract?: Extractor<any>[]; + reflectionExtract?: Extractor<any>[]; activateAfterIdle?: number | string; }, ) { @@ -143,10 +165,12 @@ function createOM( model: opts?.observerModel ?? createMockObserverModel(), messageTokens: opts?.messageTokens ?? 100, bufferTokens: opts?.bufferTokens ?? false, + extract: opts?.observationExtract, }, reflection: { model: opts?.reflectorModel ?? createMockReflectorModel(), observationTokens: opts?.observationTokens ?? 50_000, + extract: opts?.reflectionExtract, }, }); } @@ -273,6 +297,52 @@ describe('observe()', () => { expect(result.record.lastObservedAt).toBeDefined(); }); + it('should persist schema-less inline extracted values and call hooks', async () => { + const onExtracted = vi.fn(({ current }) => current); + const userInfo = new Extractor({ + name: 'User info', + instructions: 'Information about the user: name/location/work/etc', + includePreviousExtraction: false, + onExtracted, + }); + const extractOm = createOM(storage, { + observationExtract: [userInfo], + observerModel: createMockObserverModel( + `<observations> +* 🔴 User said their name is Tyler. +</observations> +<user-info> +name: Tyler +</user-info>`, + ), + }); + + await storage.saveThread({ + thread: { + id: threadId, + resourceId: 'observe-resource', + title: 'Observe thread', + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + const messages = createBulkMessages(10, threadId); + await storage.saveMessages({ messages }); + const result = await extractOm.observe({ threadId }); + + expect(result.observed).toBe(true); + expect(onExtracted).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'observer', + threadId, + current: 'name: Tyler', + }), + ); + const thread = await storage.getThreadById({ threadId }); + expect(getThreadOMMetadata(thread?.metadata)?.extracted).toMatchObject({ 'user-info': 'name: Tyler' }); + }); + it('should skip when messages are below threshold', async () => { const messages = [createTestMessage('short')]; const result = await om.observe({ threadId, messages }); @@ -442,6 +512,103 @@ describe('observe()', () => { }); } }); + + it('should pass providerMetadata from the observer model result to onObservationEnd', async () => { + // The AI Gateway exposes per-call economics under providerMetadata.gateway + // (cost, generationId). Stub it on the observer model's finish/getFullOutput + // path — the exact path `usage` travels — and assert it reaches the hook. + const gatewayMetadata = { gateway: { cost: 0.0123, generationId: 'gen-obs-xyz' } }; + const omWithMetadata = createOM(storage, { + observerModel: createMockObserverModel(undefined, gatewayMetadata), + }); + const messages = createBulkMessages(10, threadId); + const hooks = { + onObservationStart: vi.fn(), + onObservationEnd: vi.fn(), + }; + + await omWithMetadata.observe({ threadId, messages, hooks }); + + expect(hooks.onObservationEnd).toHaveBeenCalledOnce(); + expect(hooks.onObservationEnd).toHaveBeenCalledWith( + expect.objectContaining({ + usage: expect.objectContaining({ inputTokens: expect.any(Number), outputTokens: expect.any(Number) }), + providerMetadata: gatewayMetadata, + }), + ); + }); + + it('should leave providerMetadata absent when the observer model does not emit it', async () => { + // Provider-agnostic + zero breaking change: when the model omits providerMetadata, + // the hook result must not invent one. + const messages = createBulkMessages(10, threadId); + const hooks = { + onObservationStart: vi.fn(), + onObservationEnd: vi.fn(), + }; + + await om.observe({ threadId, messages, hooks }); + + expect(hooks.onObservationEnd).toHaveBeenCalledOnce(); + // Absent, not present-with-undefined: the live hook-fire sites conditionally + // spread providerMetadata, so the key is omitted entirely when none is emitted. + expect(hooks.onObservationEnd.mock.calls[0]![0]).not.toHaveProperty('providerMetadata'); + }); + + it('should pass providerMetadata from the reflector model result to onReflectionEnd', async () => { + // Very low reflection threshold so reflection fires; stub gateway economics on + // the reflector model so we can assert it reaches onReflectionEnd. + const gatewayMetadata = { gateway: { cost: 0.0456, generationId: 'gen-ref-xyz' } }; + const omReflect = createOM(storage, { + observationTokens: 5, + reflectorModel: createMockReflectorModel(undefined, gatewayMetadata), + }); + const messages = createBulkMessages(10, threadId); + const hooks = { + onObservationStart: vi.fn(), + onObservationEnd: vi.fn(), + onReflectionStart: vi.fn(), + onReflectionEnd: vi.fn(), + }; + + const result = await omReflect.observe({ threadId, messages, hooks }); + + expect(result.observed).toBe(true); + expect(result.reflected).toBe(true); + expect(hooks.onReflectionEnd).toHaveBeenCalled(); + expect(hooks.onReflectionEnd).toHaveBeenCalledWith( + expect.objectContaining({ + usage: expect.objectContaining({ inputTokens: expect.any(Number), outputTokens: expect.any(Number) }), + providerMetadata: gatewayMetadata, + }), + ); + }); + + it('should pass providerMetadata to onObservationEnd through the batched resource-scoped path', async () => { + // Resource scope routes through callMultiThread() + the lastBatchProviderMetadata + // accumulator — a different code path from the single-thread observer above. + const gatewayMetadata = { gateway: { cost: 0.0321, generationId: 'gen-res-xyz' } }; + const resourceId = 'res-pm'; + await storage.saveThread({ + thread: { id: threadId, resourceId, title: 'pm', metadata: {}, createdAt: new Date(), updatedAt: new Date() }, + }); + const omResource = createOM(storage, { + scope: 'resource', + observerModel: createMockObserverModel(undefined, gatewayMetadata), + }); + const messages = createBulkMessages(10, threadId); + const hooks = { + onObservationStart: vi.fn(), + onObservationEnd: vi.fn(), + }; + + await omResource.observe({ threadId, resourceId, messages, hooks }); + + expect(hooks.onObservationEnd).toHaveBeenCalledOnce(); + expect(hooks.onObservationEnd).toHaveBeenCalledWith( + expect.objectContaining({ providerMetadata: gatewayMetadata }), + ); + }); }); describe('reflected flag', () => { @@ -575,6 +742,43 @@ describe('buffer()', () => { } }); + it('preserves existing OM thread title when buffering only persists extracted values', async () => { + const userInfo = new Extractor({ name: 'User info', instructions: 'Extract user info.' }); + const om = createOM(storage, { + messageTokens: 500, + bufferTokens: 0.2, + observationExtract: [userInfo], + observerModel: createMockObserverModel( + `<observations> +* User said their name is Tyler. +</observations> +<user-info> +name: Tyler +</user-info>`, + ), + }); + await storage.saveThread({ + thread: { + id: threadId, + resourceId: 'buffer-resource', + title: 'Existing thread title', + metadata: setThreadOMMetadata({}, { threadTitle: 'Existing OM title' }), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + await storage.saveMessages({ messages: createBulkMessages(5, threadId) }); + + const result = await om.buffer({ threadId }); + + expect(result.buffered).toBe(true); + const thread = await storage.getThreadById({ threadId }); + expect(getThreadOMMetadata(thread?.metadata)).toMatchObject({ + threadTitle: 'Existing OM title', + extracted: { 'user-info': 'name: Tyler' }, + }); + }); + it('should call beforeBuffer callback with candidate messages', async () => { const om = createOM(storage, { messageTokens: 500, bufferTokens: 0.2 }); await storage.saveMessages({ messages: createBulkMessages(5, threadId) }); @@ -1356,6 +1560,43 @@ describe('reflect()', () => { expect(result.reflected).toBe(true); }); + it('persists extracted values produced by manual reflection', async () => { + await storage.saveThread({ + thread: { + id: threadId, + resourceId: 'reflect-resource', + title: 'Reflect thread', + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + const priority = new Extractor({ + name: 'Priority', + instructions: 'Extract the current priority.', + }); + const reflectOm = createOM(storage, { + reflectionExtract: [priority], + reflectorModel: createMockReflectorModel( + `<observations> +* Condensed: User discussed priority. +</observations> +<extracted-values> +{"priority":"high"} +</extracted-values>`, + ), + }); + + await storage.saveMessages({ messages: createBulkMessages(10, threadId) }); + await reflectOm.observe({ threadId }); + + const result = await reflectOm.reflect(threadId); + + expect(result.reflected).toBe(true); + const thread = await storage.getThreadById({ threadId }); + expect(getThreadOMMetadata(thread?.metadata)?.extracted).toMatchObject({ priority: 'high' }); + }); + it('should preserve history across multiple reflections', async () => { await storage.saveMessages({ messages: createBulkMessages(10, threadId) }); await om.observe({ threadId }); diff --git a/packages/memory/src/processors/observational-memory/__tests__/observational-memory.test.ts b/packages/memory/src/processors/observational-memory/__tests__/observational-memory.test.ts index 4ec035b76c99..ce7358e3c45c 100644 --- a/packages/memory/src/processors/observational-memory/__tests__/observational-memory.test.ts +++ b/packages/memory/src/processors/observational-memory/__tests__/observational-memory.test.ts @@ -1,13 +1,20 @@ -import { MockLanguageModelV2 } from '@internal/ai-sdk-v5/test'; +import { MockLanguageModelV2, convertArrayToReadableStream } from '@internal/ai-sdk-v5/test'; import type { MastraDBMessage, MastraMessageContentV2 } from '@mastra/core/agent'; import { coreFeatures } from '@mastra/core/features'; import { MASTRA_THREAD_ID_KEY, RequestContext } from '@mastra/core/request-context'; import { InMemoryMemory, InMemoryDB } from '@mastra/core/storage'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { z } from 'zod'; import { injectAnchorIds, parseAnchorId, stripEphemeralAnchorIds } from '../anchor-ids'; import { BufferingCoordinator } from '../buffering-coordinator'; +import { + createCurrentTaskExtractor, + createSuggestedResponseExtractor, + createThreadTitleExtractor, +} from '../built-in-extractors'; import { OBSERVATIONAL_MEMORY_DEFAULTS } from '../constants'; +import { Extractor } from '../extractor'; import { filterObservedMessages, getBufferedChunks, @@ -2709,7 +2716,7 @@ describe('Observer Agent Helpers', () => { requestContext, }); - expect(spy).toHaveBeenCalledWith('openai/gpt-4o'); + expect(spy.mock.calls[0][0]).toBe('openai/gpt-4o'); const content = capturedPrompt[1].content as any[]; expect(content.some((part: any) => part.type === 'image')).toBe(true); } finally { @@ -2717,7 +2724,7 @@ describe('Observer Agent Helpers', () => { } }); - it('should inject thread title instructions into the observer request when enabled', async () => { + it('should keep skip-continuation task guidance separate from resolved output sections', async () => { let capturedPrompt: any; const observer = new ObserverRunner({ @@ -2727,6 +2734,12 @@ describe('Observer Agent Helpers', () => { bufferTokens: false, previousObserverTokens: 1000, threadTitle: true, + extractors: [ + createCurrentTaskExtractor(), + createSuggestedResponseExtractor(), + createThreadTitleExtractor(), + new Extractor({ name: 'User info', instructions: 'Extract user information.' }), + ], } as any, observedMessageIds: new Set(), resolveModel: () => ({ model: 'test-model' as any }), @@ -2749,13 +2762,31 @@ describe('Observer Agent Helpers', () => { await observer.call(undefined, [createTestMessage('Need a better title', 'user')], undefined, { priorThreadTitle: 'Old thread title', + skipContinuationHints: true, }); expect(Array.isArray(capturedPrompt)).toBe(true); expect(capturedPrompt).toHaveLength(2); expect(capturedPrompt[0]).toMatchObject({ role: 'user' }); - expect(capturedPrompt[0].content).toContain('Also output a <thread-title>'); + const systemPrompt = buildObserverSystemPrompt(false, undefined, true, [ + createCurrentTaskExtractor(), + createSuggestedResponseExtractor(), + createThreadTitleExtractor(), + new Extractor({ name: 'User info', instructions: 'Extract user information.' }), + ]); + + expect(capturedPrompt[0].content).not.toContain('Also output a <thread-title>'); expect(capturedPrompt[0].content).toContain('- prior thread-title: Old thread title'); + expect(systemPrompt).toContain('<current-task>'); + expect(systemPrompt).toContain('<suggested-response>'); + expect(systemPrompt).toContain('<thread-title>'); + expect(systemPrompt).toContain('<user-info>'); + expect(capturedPrompt[0].content).toContain('Output <observations> every time.'); + expect(capturedPrompt[0].content).not.toContain( + 'If the observations include information relevant to <thread-title>', + ); + expect(capturedPrompt[0].content).not.toContain('Only output <observations> and <thread-title>'); + expect(capturedPrompt[0].content).not.toContain('Do NOT include <current-task> or <suggested-response>'); expect(capturedPrompt[0].content).toContain( 'Use the prior current-task, suggested-response, and thread-title as continuity hints', ); @@ -2814,8 +2845,8 @@ describe('Observer Agent Helpers', () => { expect(observerResolveSpy).toHaveBeenCalledWith(om.getTokenCounter().countMessages(observerMessages)); expect(reflectorResolveSpy).toHaveBeenCalledWith(1); - expect(observerCreateAgentSpy).toHaveBeenCalledWith('openai/gpt-4o'); - expect(reflectorCreateAgentSpy).toHaveBeenCalledWith('openai/gpt-4o-mini'); + expect(observerCreateAgentSpy.mock.calls[0][0]).toBe('openai/gpt-4o'); + expect(reflectorCreateAgentSpy.mock.calls[0][0]).toBe('openai/gpt-4o-mini'); }); }); @@ -2969,6 +3000,30 @@ Here's the implementation... expect(result.observations).not.toContain('<current-task>'); }); + it('should parse thread title and custom inline extractor sections together', () => { + const userInfo = new Extractor({ name: 'User info', instructions: 'Extract user information.' }); + const output = ` +<observations> +- 🔴 User Tyler introduced himself. +</observations> + +<thread-title> +Tyler introduction +</thread-title> + +<user-info> +name: Tyler +</user-info> + `; + + const result = parseObserverOutput(output, [userInfo]); + + expect(result.threadTitle).toBe('Tyler introduction'); + expect(result.extractedValues).toEqual({ 'user-info': 'name: Tyler' }); + expect(result.observations).toContain('User Tyler introduced himself'); + expect(result.observations).not.toContain('<user-info>'); + }); + it('should handle output without continuation hint', () => { const output = '- 🔴 Simple observation'; const result = parseObserverOutput(output); @@ -4076,9 +4131,9 @@ describe('ObservationalMemory Integration', () => { ); const formattedText = formatted.join('\n\n'); - expect(formattedText).toContain('## Group `group-1`'); - expect(formattedText).toContain('_range: `msg-1:msg-2`_'); - expect(formattedText).toContain('recall tool'); + expect(formattedText).toContain('<observation-group id="group-1" range="msg-1:msg-2">'); + expect(formattedText).toContain('- 🔴 User prefers direct answers'); + expect(formattedText).toContain('</observation-group>'); }); it('should default retrieval mode to false', () => { @@ -5191,8 +5246,8 @@ describe('Scenario: Information should be preserved through observation cycle', expect(systemPrompt).not.toContain('<thread-title>'); }); - it('observer system prompt should include thread title instructions when enabled', () => { - const systemPrompt = buildObserverSystemPrompt(false, undefined, true); + it('observer system prompt should include thread title instructions from the extractor list', () => { + const systemPrompt = buildObserverSystemPrompt(false, undefined, true, [createThreadTitleExtractor()]); expect(systemPrompt).toContain('<thread-title>'); expect(systemPrompt).toContain('A short, noun-phrase title for this conversation'); @@ -6032,6 +6087,73 @@ describe('Resource Scope Observation Flow', () => { multiThreadSpy.mockRestore(); }); + it('isolates structured extraction source output per thread in multi-thread observation', async () => { + const structuredPrompts: string[] = []; + const model = new MockLanguageModelV2({ + doStream: async ({ prompt }: { prompt: unknown }) => { + const promptText = JSON.stringify(prompt); + const observerOutput = promptText.includes('Thread one') + ? `<observations>\n- thread-1-secret priority alpha\n</observations>` + : `<observations>\n- thread-2-secret priority beta\n</observations>`; + return { + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: 'obs-1', modelId: 'mock-observer', timestamp: new Date() }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: observerOutput }, + { type: 'text-end', id: 'text-1' }, + { type: 'finish', finishReason: 'stop', usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 } }, + ]), + rawCall: { rawPrompt: null, rawSettings: {} }, + warnings: [], + }; + }, + doGenerate: async ({ prompt }: { prompt: unknown }) => { + const promptText = JSON.stringify(prompt); + structuredPrompts.push(promptText); + const priority = promptText.includes('thread-1-secret') ? 'alpha' : 'beta'; + return { + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + content: [{ type: 'text', text: JSON.stringify({ priority }) }], + warnings: [], + }; + }, + }); + const observer = new ObserverRunner({ + observationConfig: { + model, + messageTokens: 1000, + bufferTokens: false, + previousObserverTokens: 1000, + observeAttachments: 'auto', + extractors: [new Extractor({ name: 'Priority', instructions: 'Extract priority.', schema: z.string() })], + } as any, + observedMessageIds: new Set(), + resolveModel: () => ({ model: model as any }), + tokenCounter: { countMessages: () => 1 } as any, + }); + const results = await observer.callMultiThread( + undefined, + new Map([ + ['thread-1', [createTestMessage('Thread one says alpha.', 'user', 't1-msg-1')]], + ['thread-2', [createTestMessage('Thread two says beta.', 'user', 't2-msg-1')]], + ]), + ['thread-1', 'thread-2'], + ); + + expect(results.results.get('thread-1')?.extractedValues).toEqual({ priority: 'alpha' }); + expect(results.results.get('thread-2')?.extractedValues).toEqual({ priority: 'beta' }); + expect(structuredPrompts).toHaveLength(2); + const threadOnePrompt = structuredPrompts.find(prompt => prompt.includes('thread-1-secret')); + const threadTwoPrompt = structuredPrompts.find(prompt => prompt.includes('thread-2-secret')); + expect(threadOnePrompt).toBeDefined(); + expect(threadOnePrompt).not.toContain('thread-2-secret'); + expect(threadTwoPrompt).toBeDefined(); + expect(threadTwoPrompt).not.toContain('thread-1-secret'); + }); + it('should NOT use thread tags in thread scope mode', async () => { const storage = createInMemoryStorage(); @@ -9851,7 +9973,7 @@ describe('Full Async Buffering Flow', () => { const reflectorCalls: { input: string }[] = []; const mockModel = createStreamCapableMockModel({ - doGenerate: async ({ prompt }) => { + doGenerate: async ({ prompt }: { prompt: unknown }) => { const promptText = JSON.stringify(prompt); // Detect whether this is a reflection call (reflector prompt mentions "consolidate") @@ -10425,12 +10547,12 @@ describe('Full Async Buffering Flow', () => { expect(observerCalls.length).toBeGreaterThan(0); // The mock captures `input: JSON.stringify(prompt).slice(0, 200)`. - // buildObserverPrompt appends "Do NOT include <current-task> or <suggested-response>" - // when skipContinuationHints is true. Since the mock only captures 200 chars - // of the serialized prompt, we can't reliably check the end of the prompt here. - // The important thing: the observer was called (buffering happened), and the - // skipContinuationHints logic is unit-tested in buildObserverPrompt's own tests. - // For this integration test, we verify the async buffering path was exercised. + // buildObserverPrompt appends skipContinuationHints guidance near the end of the prompt. + // Since the mock only captures 200 chars of the serialized prompt, we can't reliably + // check the end of the prompt here. The important thing: the observer was called + // (buffering happened), and the skipContinuationHints logic is unit-tested in + // buildObserverPrompt's own tests. For this integration test, we verify the async + // buffering path was exercised. const lastCall = observerCalls[observerCalls.length - 1]; expect(lastCall).toBeDefined(); expect(lastCall.input.length).toBeGreaterThan(0); @@ -10904,7 +11026,7 @@ describe('Full Async Buffering Flow', () => { const observerCalls: { input: string }[] = []; const mockModel = createStreamCapableMockModel({ - doGenerate: async ({ prompt }) => { + doGenerate: async ({ prompt }: { prompt: unknown }) => { observerCalls.push({ input: JSON.stringify(prompt).slice(0, 200) }); return { rawCall: { rawPrompt: null, rawSettings: {} }, @@ -11089,7 +11211,7 @@ describe('Full Async Buffering Flow', () => { const observerCalls: { input: string }[] = []; const mockModel = createStreamCapableMockModel({ - doGenerate: async ({ prompt }) => { + doGenerate: async ({ prompt }: { prompt: unknown }) => { observerCalls.push({ input: JSON.stringify(prompt).slice(0, 200) }); return { rawCall: { rawPrompt: null, rawSettings: {} }, @@ -11565,7 +11687,7 @@ describe('Full Async Buffering Flow', () => { let _reflectorCallCount = 0; const mockModel = createStreamCapableMockModel({ - doGenerate: async ({ prompt }) => { + doGenerate: async ({ prompt }: { prompt: unknown }) => { const promptText = JSON.stringify(prompt); const isReflection = promptText.includes('consolidat') || promptText.includes('reflect'); if (isReflection) { diff --git a/packages/memory/src/processors/observational-memory/built-in-extractors.ts b/packages/memory/src/processors/observational-memory/built-in-extractors.ts new file mode 100644 index 000000000000..78411ee9cc5c --- /dev/null +++ b/packages/memory/src/processors/observational-memory/built-in-extractors.ts @@ -0,0 +1,89 @@ +import { z } from 'zod'; + +import { Extractor, validateExtractorList } from './extractor'; +import type { ObservationConfig, ReflectionConfig, ResolvedObservationConfig } from './types'; + +const currentTaskInstructions = `State the current task(s) explicitly. Can be single or multiple: +- Primary: What the agent is currently working on +- Secondary: Other pending tasks (mark as "waiting for user" if appropriate) + +If the agent started doing something without user approval, note that it's off-task.`; + +const suggestedResponseInstructions = `Hint for the agent's immediate next message. Examples: +- "I've updated the navigation model. Let me walk you through the changes..." +- "The assistant should wait for the user to respond before continuing." +- Call the view tool on src/example.ts to continue debugging.`; + +const threadTitleInstructions = `A short, noun-phrase title for this conversation (2-5 words). Examples: +- "Auth bug fix" — not "Fixing the auth bug" +- "Dark mode toggle" — not "User wants dark mode toggle added" +- "Deployment pipeline setup" — not "Setting up deployment pipeline for project" +Only update when the topic meaningfully changes.`; + +export function createCurrentTaskExtractor(): Extractor<string> { + return new Extractor( + { + name: 'current-task', + instructions: currentTaskInstructions, + schema: z.string(), + }, + true, + ); +} + +export function createSuggestedResponseExtractor(): Extractor<string> { + return new Extractor( + { + name: 'suggested-response', + instructions: suggestedResponseInstructions, + schema: z.string(), + }, + true, + ); +} + +export function createThreadTitleExtractor(): Extractor<string> { + return new Extractor( + { + name: 'thread-title', + instructions: threadTitleInstructions, + schema: z.string(), + }, + true, + ); +} + +interface ComposeExtractorOptions { + includeContinuationHints?: boolean; + includeThreadTitle?: boolean; + userExtractors?: readonly Extractor<any>[]; +} + +export function composeExtractors(options: ComposeExtractorOptions): Extractor<any>[] { + const extractors: Extractor<any>[] = []; + if (options.includeContinuationHints) { + extractors.push(createCurrentTaskExtractor(), createSuggestedResponseExtractor()); + } + if (options.includeThreadTitle) { + extractors.push(createThreadTitleExtractor()); + } + extractors.push(...(options.userExtractors ?? [])); + return validateExtractorList(extractors); +} + +export function composeObservationExtractors( + config: Pick<ResolvedObservationConfig, 'threadTitle'> & Pick<ObservationConfig, 'extract'>, +): Extractor[] { + return composeExtractors({ + includeContinuationHints: true, + includeThreadTitle: config.threadTitle, + userExtractors: config.extract, + }); +} + +export function composeReflectionExtractors(config: Pick<ReflectionConfig, 'extract'>): Extractor[] { + return composeExtractors({ + includeContinuationHints: true, + userExtractors: config.extract, + }); +} diff --git a/packages/memory/src/processors/observational-memory/extracted-values.ts b/packages/memory/src/processors/observational-memory/extracted-values.ts new file mode 100644 index 000000000000..06f926ab19cd --- /dev/null +++ b/packages/memory/src/processors/observational-memory/extracted-values.ts @@ -0,0 +1,212 @@ +import type { ProcessorContext } from '@mastra/core/processors'; +import type { RequestContext } from '@mastra/core/request-context'; + +import type { Memory } from '../..'; +import type { BuiltInExtractorSlug, Extractor, ExtractorSource } from './extractor'; +import { BUILT_IN_EXTRACTOR_SLUGS, isBuiltInExtractorSlug } from './extractor'; + +export interface ExtractedValueMetadata { + currentTask?: string; + suggestedResponse?: string; + threadTitle?: string; + extracted?: Record<string, unknown>; +} + +export interface ExtractionFailure { + slug: string; + error: string; +} + +export interface ExtractedBuiltInValues { + currentTask?: string; + suggestedContinuation?: string; + threadTitle?: string; +} + +type BuiltInMetadataField = Exclude<keyof ExtractedValueMetadata, 'extracted'>; +type ExtractedBuiltInField = keyof ExtractedBuiltInValues; + +const BUILT_IN_METADATA_FIELDS: Record< + BuiltInExtractorSlug, + { metadataField: BuiltInMetadataField; builtInField: ExtractedBuiltInField } +> = { + 'current-task': { metadataField: 'currentTask', builtInField: 'currentTask' }, + 'suggested-response': { metadataField: 'suggestedResponse', builtInField: 'suggestedContinuation' }, + 'thread-title': { metadataField: 'threadTitle', builtInField: 'threadTitle' }, +}; + +function isPresentExtractedValue(value: unknown): boolean { + return value !== undefined && value !== null && value !== ''; +} + +export function normalizeExtractedValues(values?: Record<string, unknown>): Record<string, unknown> | undefined { + if (!values) { + return undefined; + } + + const normalized = Object.fromEntries(Object.entries(values).filter(([, value]) => isPresentExtractedValue(value))); + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +export function mergeExtractedValues( + ...valueSets: Array<Record<string, unknown> | undefined> +): Record<string, unknown> | undefined { + const merged: Record<string, unknown> = {}; + for (const values of valueSets) { + const normalized = normalizeExtractedValues(values); + if (normalized) { + Object.assign(merged, normalized); + } + } + return Object.keys(merged).length > 0 ? merged : undefined; +} + +export function mergeExtractionFailures( + ...failureSets: Array<ExtractionFailure[] | undefined> +): ExtractionFailure[] | undefined { + const failures = failureSets.flatMap(set => set ?? []); + return failures.length > 0 ? failures : undefined; +} + +function readBuiltInMetadataValues(metadata: ExtractedValueMetadata): Partial<Record<BuiltInExtractorSlug, unknown>> { + const values: Partial<Record<BuiltInExtractorSlug, unknown>> = {}; + for (const slug of BUILT_IN_EXTRACTOR_SLUGS) { + const { metadataField } = BUILT_IN_METADATA_FIELDS[slug]!; + values[slug] = metadata[metadataField]; + } + return values; +} + +export function getPriorExtractedValues(metadata?: ExtractedValueMetadata): Record<string, unknown> | undefined { + if (!metadata) { + return undefined; + } + + return mergeExtractedValues(readBuiltInMetadataValues(metadata), metadata.extracted); +} + +function renderExtractedValue(value: unknown): string { + return typeof value === 'string' ? value : JSON.stringify(value); +} + +export function buildExtractedValueContextSections( + extractors: readonly Extractor<any>[], + values?: Record<string, unknown>, +): string[] { + const normalized = normalizeExtractedValues(values); + if (!normalized) { + return []; + } + + const injectableSlugs = new Set( + extractors + .filter(extractor => !isBuiltInExtractorSlug(extractor.slug) && extractor.includePreviousExtraction) + .map(extractor => extractor.slug), + ); + + return Object.entries(normalized) + .filter(([slug]) => injectableSlugs.has(slug)) + .map(([slug, value]) => `<${slug}>\n${renderExtractedValue(value)}\n</${slug}>`); +} + +function getStringExtractedValue(values: Record<string, unknown> | undefined, slug: string): string | undefined { + const value = values?.[slug]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +export function getBuiltInExtractedValues(values?: Record<string, unknown>): ExtractedBuiltInValues { + const builtIns: ExtractedBuiltInValues = {}; + for (const slug of BUILT_IN_EXTRACTOR_SLUGS) { + const { builtInField } = BUILT_IN_METADATA_FIELDS[slug]!; + builtIns[builtInField] = getStringExtractedValue(values, slug); + } + return builtIns; +} + +export function filterUserExtractedValues(values?: Record<string, unknown>): Record<string, unknown> | undefined { + const normalized = normalizeExtractedValues(values); + if (!normalized) { + return undefined; + } + + const userValues = Object.fromEntries(Object.entries(normalized).filter(([slug]) => !isBuiltInExtractorSlug(slug))); + return Object.keys(userValues).length > 0 ? userValues : undefined; +} + +export function buildThreadMetadataFromExtractedValues(values?: Record<string, unknown>): ExtractedValueMetadata { + const builtIns = getBuiltInExtractedValues(values); + const metadata: ExtractedValueMetadata = { extracted: filterUserExtractedValues(values) }; + for (const slug of BUILT_IN_EXTRACTOR_SLUGS) { + const { metadataField, builtInField } = BUILT_IN_METADATA_FIELDS[slug]!; + metadata[metadataField] = builtIns[builtInField]; + } + return metadata; +} + +export async function applyExtractorHooks(opts: { + source: ExtractorSource; + extractors: readonly Extractor<any>[]; + values?: Record<string, unknown>; + failures?: ExtractionFailure[]; + previousValues?: Record<string, unknown>; + threadId: string; + resourceId?: string; + mainAgent?: ProcessorContext['agent']; + memory?: Memory; + sendSignal?: ProcessorContext['sendSignal']; + requestContext?: RequestContext; +}): Promise<{ values?: Record<string, unknown>; failures?: ExtractionFailure[] }> { + const values = normalizeExtractedValues(opts.values) ?? {}; + const failures: ExtractionFailure[] = [...(opts.failures ?? [])]; + + for (const extractor of opts.extractors) { + if (!Object.prototype.hasOwnProperty.call(values, extractor.slug)) { + continue; + } + + const current = values[extractor.slug]; + if (!isPresentExtractedValue(current)) { + delete values[extractor.slug]; + continue; + } + + if (!extractor.onExtracted || extractor.internal) { + continue; + } + + try { + const hookValue = await extractor.onExtracted({ + source: opts.source, + extractor, + threadId: opts.threadId, + resourceId: opts.resourceId, + previous: opts.previousValues?.[extractor.slug], + current, + mainAgent: opts.mainAgent, + memory: opts.memory, + sendSignal: opts.sendSignal, + requestContext: opts.requestContext, + }); + if (hookValue === undefined) { + // Undefined means the hook handled the side effect and this value should not be persisted as OM metadata. + delete values[extractor.slug]; + continue; + } + const parsed = extractor.schema.safeParse(hookValue); + if (parsed.success) { + values[extractor.slug] = parsed.data; + } else { + delete values[extractor.slug]; + failures.push({ slug: extractor.slug, error: parsed.error.message }); + } + } catch (error) { + delete values[extractor.slug]; + failures.push({ slug: extractor.slug, error: error instanceof Error ? error.message : String(error) }); + } + } + + return { + values: normalizeExtractedValues(values), + failures: mergeExtractionFailures(failures), + }; +} diff --git a/packages/memory/src/processors/observational-memory/extraction-runner.ts b/packages/memory/src/processors/observational-memory/extraction-runner.ts new file mode 100644 index 000000000000..d4f8d7f56b17 --- /dev/null +++ b/packages/memory/src/processors/observational-memory/extraction-runner.ts @@ -0,0 +1,116 @@ +import type { Agent, AgentMemoryOption } from '@mastra/core/agent'; +import { coreFeatures } from '@mastra/core/features'; +import type { ObservabilityContext } from '@mastra/core/observability'; +import type { RequestContext } from '@mastra/core/request-context'; +import { z } from 'zod'; + +import type { Extractor, ExtractorSource } from './extractor'; +import { buildExtractorPriorLines } from './extractor'; + +export interface StructuredExtractionResult { + values: Record<string, unknown>; + failures: Array<{ slug: string; error: string }>; +} + +function isAbortError(error: unknown, abortSignal?: AbortSignal): boolean { + return ( + abortSignal?.aborted === true || + (error instanceof DOMException && error.name === 'AbortError') || + (error instanceof Error && error.name === 'AbortError') + ); +} + +export async function extractStructuredValues(opts: { + agent: Agent<any, any, any, any>; + source: ExtractorSource; + extractors?: readonly Extractor<any>[]; + memory?: AgentMemoryOption; + priorExtractedValues?: Record<string, unknown>; + requestContext?: RequestContext; + observabilityContext?: ObservabilityContext; + abortSignal?: AbortSignal; +}): Promise<StructuredExtractionResult> { + const structuredExtractors = (opts.extractors ?? []).filter(extractor => extractor.mode === 'structured'); + if (structuredExtractors.length === 0) { + return { values: {}, failures: [] }; + } + + const schema = z.object( + Object.fromEntries(structuredExtractors.map(extractor => [extractor.slug, extractor.schema.optional()])) as Record< + string, + z.ZodTypeAny + >, + ); + const priorLines = buildExtractorPriorLines(structuredExtractors, opts.priorExtractedValues); + const extractorInstructions = structuredExtractors + .map(extractor => `- ${extractor.slug}: ${extractor.instructions}`) + .join('\n'); + const subject = opts.source === 'reflector' ? 'reflection' : 'observations'; + const prompt = `Extract structured data from the ${subject} you made. + +Return only the configured structured output object. Do not write observations, XML, markdown, or explanatory text. +Omit any property that is not supported by the ${subject} you made and the conversation context. +If a prior value is still applicable and carry-forward is enabled, return that prior value. + +## Extractors + +${extractorInstructions}${priorLines.length > 0 ? `\n\n## Prior Extracted Values\n\n${priorLines.join('\n\n')}` : ''}`; + + const values: Record<string, unknown> = {}; + const failures: Array<{ slug: string; error: string }> = []; + + const generateWithStructuredOutput = async (jsonPromptInjection?: boolean | 'system' | 'inline') => { + const output = await opts.agent.generate(prompt, { + structuredOutput: { schema, ...(jsonPromptInjection ? { jsonPromptInjection } : {}) }, + ...(opts.memory ? { memory: opts.memory } : {}), + ...(opts.abortSignal ? { abortSignal: opts.abortSignal } : {}), + ...(opts.requestContext ? { requestContext: opts.requestContext } : {}), + ...opts.observabilityContext, + }); + + if (output.object === undefined) { + throw new Error('structuredOutput object is undefined'); + } + + return output.object; + }; + + let object: Record<string, unknown>; + try { + object = await generateWithStructuredOutput(); + } catch (error) { + if (isAbortError(error, opts.abortSignal)) { + throw error; + } + + try { + const fallbackJsonPromptInjection = coreFeatures.has('json-prompt-injection:inline') ? 'inline' : true; + object = await generateWithStructuredOutput(fallbackJsonPromptInjection); + } catch (fallbackError) { + if (isAbortError(fallbackError, opts.abortSignal)) { + throw fallbackError; + } + + const message = fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + return { + values, + failures: structuredExtractors.map(extractor => ({ slug: extractor.slug, error: message })), + }; + } + } + + for (const extractor of structuredExtractors) { + const value = (object as Record<string, unknown>)[extractor.slug]; + if (value === undefined || value === null || value === '') { + continue; + } + const parsed = extractor.schema.safeParse(value); + if (parsed.success) { + values[extractor.slug] = parsed.data; + } else { + failures.push({ slug: extractor.slug, error: parsed.error.message }); + } + } + + return { values, failures }; +} diff --git a/packages/memory/src/processors/observational-memory/extractor.ts b/packages/memory/src/processors/observational-memory/extractor.ts new file mode 100644 index 000000000000..1a25599a2127 --- /dev/null +++ b/packages/memory/src/processors/observational-memory/extractor.ts @@ -0,0 +1,371 @@ +import type { ProcessorContext } from '@mastra/core/processors'; +import type { RequestContext } from '@mastra/core/request-context'; +import { z } from 'zod'; + +import type { Memory } from '../..'; + +type ExtractorMode = 'inline' | 'structured'; +export type ExtractorSource = 'observer' | 'reflector'; + +export interface ExtractorRuntimeContext { + source: ExtractorSource; + threadId?: string; + resourceId?: string; + mainAgent?: ProcessorContext['agent']; + memory?: Memory; + requestContext?: RequestContext; +} + +export interface ExtractorOnExtractedContext<T = unknown> extends ExtractorRuntimeContext { + extractor: Extractor<T>; + threadId: string; + previous?: T; + current: T; + sendSignal?: ProcessorContext['sendSignal']; +} + +type MaybePromise<T> = T | Promise<T>; +type ExtractorConfigValue<TValue> = TValue | ((context: ExtractorRuntimeContext) => MaybePromise<TValue>); + +export interface ExtractorConfig<T = unknown> { + /** Human-readable extractor name. Converted to a stable kebab-case slug for XML tags and metadata keys. */ + name: string; + /** Instructions describing what this extractor should return. */ + instructions: ExtractorConfigValue<string>; + /** Zod schema used for structured extraction. Omit to extract an inline string value from the observer/reflector output. */ + schema?: ExtractorConfigValue<z.ZodType<T> | undefined>; + /** Whether the previous extraction should be shown to the extractor prompt. Defaults to true. */ + includePreviousExtraction?: boolean; + /** Optional lifecycle hook invoked after a value is parsed and before it is persisted. */ + onExtracted?: (context: ExtractorOnExtractedContext<T>) => Promise<T | void | undefined> | T | void | undefined; +} + +const BUILT_IN_SLUGS = new Set(['current-task', 'suggested-response', 'thread-title']); + +const EXTRACTED_VALUES_TAG = 'extracted-values'; + +const RESERVED_XML_TAGS = new Set([ + 'observations', + 'observation', + EXTRACTED_VALUES_TAG, + 'thread', + 'message', + 'messages', + 'conversation', + 'history', + 'system', + 'user', + 'assistant', + 'tool', + ...BUILT_IN_SLUGS, +]); + +export const BUILT_IN_EXTRACTOR_SLUGS = [...BUILT_IN_SLUGS] as const; +export type BuiltInExtractorSlug = (typeof BUILT_IN_EXTRACTOR_SLUGS)[number]; + +export function isBuiltInExtractorSlug(slug: string): slug is BuiltInExtractorSlug { + return BUILT_IN_SLUGS.has(slug); +} + +export function slugifyExtractorName(name: string): string { + let normalized = ''; + let previousWasSeparator = false; + + for (const char of name.trim().toLowerCase()) { + const code = char.charCodeAt(0); + const isLetter = code >= 97 && code <= 122; + const isNumber = code >= 48 && code <= 57; + if (isLetter || isNumber) { + normalized += char; + previousWasSeparator = false; + continue; + } + if (char === "'" || char === '"' || char === '`') { + continue; + } + if (!previousWasSeparator && normalized.length > 0) { + normalized += '-'; + previousWasSeparator = true; + } + } + + return normalized.endsWith('-') ? normalized.slice(0, -1) : normalized; +} + +function assertValidSlug(slug: string, name: string): void { + if (!slug) { + throw new Error(`Extractor name "${name}" must produce a non-empty slug.`); + } + const first = slug.charCodeAt(0); + const last = slug.charCodeAt(slug.length - 1); + const startsWithLetter = first >= 97 && first <= 122; + const endsWithLetterOrNumber = (last >= 97 && last <= 122) || (last >= 48 && last <= 57); + const hasOnlySlugCharacters = [...slug].every(char => { + const code = char.charCodeAt(0); + return (code >= 97 && code <= 122) || (code >= 48 && code <= 57) || char === '-'; + }); + if (!startsWithLetter || !endsWithLetterOrNumber || !hasOnlySlugCharacters) { + throw new Error(`Extractor name "${name}" produced invalid slug "${slug}".`); + } +} + +export class Extractor<T = unknown> { + readonly name: string; + readonly slug: string; + readonly instructions: string; + readonly schema: z.ZodType<T>; + readonly mode: ExtractorMode; + readonly includePreviousExtraction: boolean; + readonly onExtracted?: ExtractorConfig<T>['onExtracted']; + /** @internal */ + readonly internal: boolean; + private readonly instructionsConfig: ExtractorConfigValue<string>; + private readonly schemaConfig?: ExtractorConfigValue<z.ZodType<T> | undefined>; + + constructor(config: ExtractorConfig<T>, internal = false) { + const name = config.name.trim(); + const instructions = typeof config.instructions === 'string' ? config.instructions.trim() : undefined; + const slug = slugifyExtractorName(name); + + if (!name) { + throw new Error('Extractor name is required.'); + } + if (instructions !== undefined && !instructions) { + throw new Error(`Extractor "${name}" must include instructions.`); + } + assertValidSlug(slug, name); + if (!internal && RESERVED_XML_TAGS.has(slug)) { + throw new Error(`Extractor slug "${slug}" is reserved by Observational Memory.`); + } + + this.name = name; + this.slug = slug; + this.instructionsConfig = config.instructions; + this.schemaConfig = config.schema; + this.instructions = instructions ?? ''; + this.schema = (typeof config.schema === 'function' ? z.string() : (config.schema ?? z.string())) as z.ZodType<T>; + this.mode = internal || !config.schema ? 'inline' : 'structured'; + this.includePreviousExtraction = config.includePreviousExtraction ?? true; + this.onExtracted = config.onExtracted; + this.internal = internal; + } + + async resolve(context: ExtractorRuntimeContext): Promise<Extractor<T>> { + const instructions = + typeof this.instructionsConfig === 'function' + ? (await this.instructionsConfig(context)).trim() + : this.instructionsConfig.trim(); + if (!instructions) { + throw new Error(`Extractor "${this.name}" must include instructions.`); + } + + const schema = typeof this.schemaConfig === 'function' ? await this.schemaConfig(context) : this.schemaConfig; + return new Extractor( + { + name: this.name, + instructions, + ...(schema ? { schema } : {}), + includePreviousExtraction: this.includePreviousExtraction, + onExtracted: this.onExtracted, + }, + this.internal, + ); + } +} + +export async function resolveExtractors( + extractors: readonly Extractor<any>[], + context: ExtractorRuntimeContext, +): Promise<Extractor<any>[]> { + return Promise.all(extractors.map(extractor => extractor.resolve(context))); +} + +export function validateExtractorList(extractors: readonly Extractor<any>[]): Extractor<any>[] { + const seen = new Map<string, string>(); + for (const extractor of extractors) { + assertValidSlug(extractor.slug, extractor.name); + if (!extractor.internal && RESERVED_XML_TAGS.has(extractor.slug)) { + throw new Error(`Extractor slug "${extractor.slug}" is reserved by Observational Memory.`); + } + const previous = seen.get(extractor.slug); + if (previous) { + throw new Error(`Duplicate extractor slug "${extractor.slug}" from "${previous}" and "${extractor.name}".`); + } + seen.set(extractor.slug, extractor.name); + } + return [...extractors]; +} + +function isJsonLike(value: string): boolean { + return /^(?:[\[{"-]|\d|true\b|false\b|null\b)/.test(value.trim()); +} + +function candidateValues(raw: string): unknown[] { + const trimmed = raw.trim(); + const candidates: unknown[] = []; + const add = (value: unknown) => { + if (!candidates.some(candidate => Object.is(candidate, value))) { + candidates.push(value); + } + }; + + if (isJsonLike(trimmed)) { + try { + add(JSON.parse(trimmed)); + } catch { + // The value may intentionally be a plain string that starts with a JSON-like character. + } + } + + add(trimmed); + + if (!isJsonLike(trimmed)) { + try { + add(JSON.parse(trimmed)); + } catch { + // Plain strings are valid extractor values. + } + } + + return candidates; +} + +export function parseExtractorValue<T>(extractor: Extractor<T>, raw: string): T { + const failures: string[] = []; + for (const candidate of candidateValues(raw)) { + const parsed = extractor.schema.safeParse(candidate); + if (parsed.success) { + return parsed.data; + } + failures.push(parsed.error.message); + } + throw new Error(`Extractor "${extractor.slug}" output did not match its schema: ${failures[0] ?? 'invalid value'}`); +} + +export interface ParsedExtractedValues { + values: Record<string, unknown>; + failures: Array<{ slug: string; error: string }>; +} + +function parseExtractedValuesObject(raw: string): Record<string, unknown> | undefined { + const trimmed = raw.trim(); + if (!trimmed || trimmed.startsWith('Write only the extracted values JSON object here.')) { + return undefined; + } + + const parsed = JSON.parse(trimmed) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${EXTRACTED_VALUES_TAG} must contain a JSON object.`); + } + return parsed as Record<string, unknown>; +} + +export function parseExtractedValues(output: string, extractors: readonly Extractor<any>[]): ParsedExtractedValues { + const values: Record<string, unknown> = {}; + const failures: Array<{ slug: string; error: string }> = []; + const inlineExtractors = extractors.filter(extractor => extractor.mode === 'inline'); + + const regex = new RegExp(`<${EXTRACTED_VALUES_TAG}>([\\s\\S]*?)<\\/${EXTRACTED_VALUES_TAG}>`, 'gi'); + const matches = [...output.matchAll(regex)]; + for (const match of matches) { + try { + const extractedValues = parseExtractedValuesObject(match[1] ?? ''); + if (!extractedValues) { + continue; + } + + for (const extractor of inlineExtractors) { + if (!Object.prototype.hasOwnProperty.call(extractedValues, extractor.slug)) { + continue; + } + const rawValue = extractedValues[extractor.slug]; + if (rawValue === undefined || rawValue === null || rawValue === '') { + continue; + } + const parsed = extractor.schema.safeParse(rawValue); + if (parsed.success) { + values[extractor.slug] = parsed.data; + } else { + failures.push({ slug: extractor.slug, error: parsed.error.message }); + } + } + } catch (error) { + failures.push({ slug: EXTRACTED_VALUES_TAG, error: error instanceof Error ? error.message : String(error) }); + } + } + + for (const extractor of inlineExtractors) { + if (Object.prototype.hasOwnProperty.call(values, extractor.slug)) { + continue; + } + const tagRegex = new RegExp(`<${extractor.slug}>([\\s\\S]*?)<\\/${extractor.slug}>`, 'gi'); + const tagMatch = [...output.matchAll(tagRegex)].at(-1); + const rawValue = tagMatch?.[1]?.trim(); + if (!rawValue) { + continue; + } + try { + values[extractor.slug] = parseExtractorValue(extractor, rawValue); + } catch (error) { + failures.push({ slug: extractor.slug, error: error instanceof Error ? error.message : String(error) }); + } + } + + return { values, failures }; +} + +export function stripExtractorSections(output: string, extractors: readonly Extractor<any>[]): string { + const inlineExtractors = extractors.filter(extractor => extractor.mode === 'inline'); + let stripped = output.replace( + new RegExp(`[ \\t]*<${EXTRACTED_VALUES_TAG}>[\\s\\S]*?<\\/${EXTRACTED_VALUES_TAG}>\\s*`, 'gi'), + '', + ); + for (const extractor of inlineExtractors) { + stripped = stripped.replace(new RegExp(`[ \\t]*<${extractor.slug}>[\\s\\S]*?<\\/${extractor.slug}>\\s*`, 'gi'), ''); + } + return stripped; +} + +export function buildExtractorOutputSections(extractors: readonly Extractor<any>[]): string { + const inlineExtractors = extractors.filter(extractor => extractor.mode === 'inline'); + if (inlineExtractors.length === 0) { + return ''; + } + + const sections = inlineExtractors + .map( + extractor => `<${extractor.slug}> +${extractor.instructions} +Include this section when the observations contain relevant information for <${extractor.slug}>. Write only that information inside the tag. +</${extractor.slug}>`, + ) + .join('\n\n'); + + return `Additional optional XML sections:\nIf the observations include information relevant to any of these tags, output that tag after <observations> and include the relevant information.\n${sections}`; +} + +function renderPriorValue(value: unknown): string { + return typeof value === 'string' ? value : JSON.stringify(value); +} + +export function buildExtractorPriorLines( + extractors: readonly Extractor<any>[], + priorExtractedValues?: Record<string, unknown>, +): string[] { + if (!priorExtractedValues) { + return []; + } + + const lines: string[] = []; + for (const extractor of extractors) { + if (!extractor.includePreviousExtraction) { + continue; + } + const value = priorExtractedValues[extractor.slug]; + if (value === undefined || value === null || value === '') { + continue; + } + lines.push(`<${extractor.slug}>\n${renderPriorValue(value)}\n</${extractor.slug}>`); + } + return lines; +} diff --git a/packages/memory/src/processors/observational-memory/index.ts b/packages/memory/src/processors/observational-memory/index.ts index 266fbd9c6c7b..faaf02428ab0 100644 --- a/packages/memory/src/processors/observational-memory/index.ts +++ b/packages/memory/src/processors/observational-memory/index.ts @@ -31,6 +31,14 @@ export { getObservationsAsOf } from './observation-utils'; // Types export { ModelByInputTokens, type ModelByInputTokensConfig } from './model-by-input-tokens'; +export { Extractor } from './extractor'; +export type { + ExtractorConfig, + ExtractorOnExtractedContext, + ExtractorRuntimeContext, + ExtractorSource, +} from './extractor'; +export { WorkingMemoryExtractor } from './working-memory-extractor'; export type { ObservationalMemoryConfig, diff --git a/packages/memory/src/processors/observational-memory/markers.ts b/packages/memory/src/processors/observational-memory/markers.ts index 09a39ac0530a..2396c72451fe 100644 --- a/packages/memory/src/processors/observational-memory/markers.ts +++ b/packages/memory/src/processors/observational-memory/markers.ts @@ -50,6 +50,8 @@ export function createObservationEndMarker(params: { observations?: string; currentTask?: string; suggestedResponse?: string; + extractedValues?: Record<string, unknown>; + extractionFailures?: Array<{ slug: string; error: string }>; recordId: string; threadId: string; }): DataOmObservationEndPart { @@ -68,6 +70,8 @@ export function createObservationEndMarker(params: { observations: params.observations, currentTask: params.currentTask, suggestedResponse: params.suggestedResponse, + extractedValues: params.extractedValues, + extractionFailures: params.extractionFailures, recordId: params.recordId, threadId: params.threadId, }, @@ -143,6 +147,8 @@ export function createBufferingEndMarker(params: { recordId: string; threadId: string; observations?: string; + extractedValues?: Record<string, unknown>; + extractionFailures?: Array<{ slug: string; error: string }>; }): DataOmBufferingEndPart { const completedAt = new Date().toISOString(); const durationMs = new Date(completedAt).getTime() - new Date(params.startedAt).getTime(); @@ -159,6 +165,8 @@ export function createBufferingEndMarker(params: { recordId: params.recordId, threadId: params.threadId, observations: params.observations, + extractedValues: params.extractedValues, + extractionFailures: params.extractionFailures, }, }; } diff --git a/packages/memory/src/processors/observational-memory/observation-strategies/async-buffer.ts b/packages/memory/src/processors/observational-memory/observation-strategies/async-buffer.ts index 9035b8c81c8c..e79d42d29ada 100644 --- a/packages/memory/src/processors/observational-memory/observation-strategies/async-buffer.ts +++ b/packages/memory/src/processors/observational-memory/observation-strategies/async-buffer.ts @@ -1,7 +1,8 @@ import type { MastraDBMessage } from '@mastra/core/agent'; -import { setThreadOMMetadata } from '@mastra/core/memory'; +import { getThreadOMMetadata, setThreadOMMetadata } from '@mastra/core/memory'; import { omDebug } from '../debug'; +import { applyExtractorHooks, filterUserExtractedValues, getPriorExtractedValues } from '../extracted-values'; import { createBufferingEndMarker, createBufferingFailedMarker, createThreadUpdateMarker } from '../markers'; import { getBufferedChunks, combineObservationsForBuffering } from '../message-utils'; @@ -14,6 +15,7 @@ import type { ObservationRunOpts, ObserverOutput, ProcessedObservation } from '. export class AsyncBufferObservationStrategy extends ObservationStrategy { private readonly startedAt: string; private readonly cycleId: string; + private priorExtractedValues?: Record<string, unknown>; constructor(deps: StrategyDeps, opts: ObservationRunOpts) { super(deps, opts); @@ -48,11 +50,36 @@ export class AsyncBufferObservationStrategy extends ObservationStrategy { } async observe(existingObservations: string, messages: MastraDBMessage[]) { - return this.deps.observer.call(existingObservations, messages, undefined, { + const thread = await this.storage.getThreadById({ threadId: this.opts.threadId }); + const omMeta = thread ? getThreadOMMetadata(thread.metadata) : undefined; + this.priorExtractedValues = getPriorExtractedValues(omMeta); + + const result = await this.deps.observer.call(existingObservations, messages, undefined, { skipContinuationHints: true, requestContext: this.opts.requestContext, observabilityContext: this.opts.observabilityContext, + priorExtractedValues: this.priorExtractedValues, + resourceId: this.opts.resourceId, + mainAgent: this.opts.agent, + }); + const hookedValues = await applyExtractorHooks({ + source: 'observer', + extractors: this.observationConfig.extractors, + values: result.extractedValues, + failures: result.extractionFailures, + previousValues: this.priorExtractedValues, + threadId: this.opts.threadId, + resourceId: this.opts.resourceId, + mainAgent: this.opts.agent, + memory: this.deps.memory, + sendSignal: this.opts.sendSignal, + requestContext: this.opts.requestContext, }); + return { + ...result, + extractedValues: hookedValues.values, + extractionFailures: hookedValues.failures, + }; } async process(output: ObserverOutput, _existingObservations: string): Promise<ProcessedObservation> { @@ -94,6 +121,8 @@ export class AsyncBufferObservationStrategy extends ObservationStrategy { suggestedContinuation: output.suggestedContinuation, currentTask: output.currentTask, threadTitle: output.threadTitle, + extractedValues: output.extractedValues, + extractionFailures: output.extractionFailures, }; } @@ -114,28 +143,37 @@ export class AsyncBufferObservationStrategy extends ObservationStrategy { suggestedContinuation: processed.suggestedContinuation, currentTask: processed.currentTask, threadTitle: processed.threadTitle, + extractedValues: processed.extractedValues, + extractionFailures: processed.extractionFailures, }, lastBufferedAtTime: processed.lastObservedAt, }); await this.indexObservationGroups(processed.observations, threadId, resourceId, processed.lastObservedAt); - // Update thread title immediately — don't wait for activation. + // Persist extracted values immediately; buffered observation activation is unrelated to extractor state. const newTitle = processed.threadTitle?.trim(); - if (newTitle && newTitle.length >= 3) { + const hasValidThreadTitle = !!newTitle && newTitle.length >= 3; + if (hasValidThreadTitle || processed.extractedValues) { const thread = await this.storage.getThreadById({ threadId }); if (thread) { const oldTitle = thread.title?.trim(); - if (newTitle !== oldTitle) { - const newMetadata = setThreadOMMetadata(thread.metadata, { - threadTitle: processed.threadTitle, - }); - await this.storage.updateThread({ - id: threadId, - title: newTitle, - metadata: newMetadata, - }); - + const shouldUpdateThreadTitle = hasValidThreadTitle && newTitle !== oldTitle; + const previousOmMetadata = getThreadOMMetadata(thread.metadata); + const newMetadata = setThreadOMMetadata(thread.metadata, { + ...(hasValidThreadTitle ? { threadTitle: processed.threadTitle } : {}), + extracted: { + ...(previousOmMetadata?.extracted ?? {}), + ...(filterUserExtractedValues(processed.extractedValues) ?? {}), + }, + }); + await this.storage.updateThread({ + id: threadId, + title: shouldUpdateThreadTitle ? newTitle : (thread.title ?? ''), + metadata: newMetadata, + }); + + if (shouldUpdateThreadTitle) { const marker = createThreadUpdateMarker({ cycleId: this.cycleId, threadId, @@ -167,6 +205,8 @@ export class AsyncBufferObservationStrategy extends ObservationStrategy { recordId: record.id, threadId, observations: processed.observations, + extractedValues: processed.extractedValues, + extractionFailures: processed.extractionFailures, }); if (this.opts.writer) { // Stream OM lifecycle markers as transient so the OutputWriter does not persist standalone data-only messages; OM persists the durable marker explicitly. diff --git a/packages/memory/src/processors/observational-memory/observation-strategies/base.ts b/packages/memory/src/processors/observational-memory/observation-strategies/base.ts index fd8d483095fe..a419acfd6e89 100644 --- a/packages/memory/src/processors/observational-memory/observation-strategies/base.ts +++ b/packages/memory/src/processors/observational-memory/observation-strategies/base.ts @@ -3,6 +3,7 @@ import type { MessageHistory } from '@mastra/core/processors'; import type { MemoryStorage } from '@mastra/core/storage'; import xxhash from 'xxhash-wasm'; +import type { Memory } from '../../..'; import { omDebug, omError } from '../debug'; import { stripThreadTags } from '../message-utils'; import { parseObservationGroups, wrapInObservationGroup } from '../observation-groups'; @@ -28,6 +29,7 @@ const hasherPromise = xxhash(); */ export interface StrategyDeps { storage: MemoryStorage; + memory?: Memory; messageHistory: MessageHistory; tokenCounter: TokenCounter; observationConfig: ResolvedObservationConfig; @@ -112,13 +114,15 @@ export abstract class ObservationStrategy { threadId, writer, abortSignal, + mainAgent: this.opts.agent, + sendSignal: this.opts.sendSignal, reflectionHooks, requestContext, observabilityContext: this.opts.observabilityContext, }); } - return { observed: true, usage: output.usage }; + return { observed: true, usage: output.usage, providerMetadata: output.providerMetadata }; } catch (error) { await this.emitFailedMarkers(cycleId, error); diff --git a/packages/memory/src/processors/observational-memory/observation-strategies/index.ts b/packages/memory/src/processors/observational-memory/observation-strategies/index.ts index eaf80ad8800a..1134f6bedb77 100644 --- a/packages/memory/src/processors/observational-memory/observation-strategies/index.ts +++ b/packages/memory/src/processors/observational-memory/observation-strategies/index.ts @@ -19,6 +19,7 @@ import type { ObservationRunOpts } from './types'; ObservationStrategy.create = ((om: ObservationalMemory, opts: ObservationRunOpts): ObservationStrategy => { const deps: StrategyDeps = { storage: om.getStorage(), + memory: om.getMemory(), messageHistory: om.getMessageHistory(), tokenCounter: om.getTokenCounter(), observationConfig: om.getObservationConfig(), diff --git a/packages/memory/src/processors/observational-memory/observation-strategies/resource-scoped.ts b/packages/memory/src/processors/observational-memory/observation-strategies/resource-scoped.ts index 8e1bafab1fbc..5591b2de7d78 100644 --- a/packages/memory/src/processors/observational-memory/observation-strategies/resource-scoped.ts +++ b/packages/memory/src/processors/observational-memory/observation-strategies/resource-scoped.ts @@ -1,7 +1,9 @@ import type { MastraDBMessage } from '@mastra/core/agent'; import { getThreadOMMetadata, setThreadOMMetadata } from '@mastra/core/memory'; +import type { ProviderMetadata } from '@mastra/core/stream'; import { OBSERVATIONAL_MEMORY_DEFAULTS } from '../constants'; +import { applyExtractorHooks, filterUserExtractedValues, getPriorExtractedValues } from '../extracted-values'; import { createObservationEndMarker, createObservationFailedMarker, @@ -28,17 +30,32 @@ export class ResourceScopedObservationStrategy extends ObservationStrategy { private messagesByThread = new Map<string, MastraDBMessage[]>(); private multiThreadResults = new Map< string, - { observations: string; currentTask?: string; suggestedContinuation?: string; threadTitle?: string } + { + observations: string; + currentTask?: string; + suggestedContinuation?: string; + threadTitle?: string; + extractedValues?: Record<string, unknown>; + extractionFailures?: Array<{ slug: string; error: string }>; + } >(); private totalBatchUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; + private lastBatchProviderMetadata: ProviderMetadata | undefined; private observationResults: Array<{ threadId: string; threadMessages: MastraDBMessage[]; - result: { observations: string; currentTask?: string; suggestedContinuation?: string; threadTitle?: string }; + result: { + observations: string; + currentTask?: string; + suggestedContinuation?: string; + threadTitle?: string; + extractedValues?: Record<string, unknown>; + extractionFailures?: Array<{ slug: string; error: string }>; + }; }> = []; private priorMetadataByThread = new Map< string, - { currentTask?: string; suggestedResponse?: string; threadTitle?: string } + { currentTask?: string; suggestedResponse?: string; threadTitle?: string; extracted?: Record<string, unknown> } >(); constructor(deps: StrategyDeps, opts: ObservationRunOpts) { @@ -65,11 +82,17 @@ export class ResourceScopedObservationStrategy extends ObservationStrategy { for (const thread of allThreads) { const omMetadata = getThreadOMMetadata(thread.metadata); threadMetadataMap.set(thread.id, { lastObservedAt: omMetadata?.lastObservedAt }); - if (omMetadata?.currentTask || omMetadata?.suggestedResponse || omMetadata?.threadTitle) { + if ( + omMetadata?.currentTask || + omMetadata?.suggestedResponse || + omMetadata?.threadTitle || + omMetadata?.extracted + ) { this.priorMetadataByThread.set(thread.id, { currentTask: omMetadata.currentTask, suggestedResponse: omMetadata.suggestedResponse, threadTitle: omMetadata.threadTitle, + extracted: omMetadata.extracted, }); } } @@ -258,11 +281,15 @@ export class ResourceScopedObservationStrategy extends ObservationStrategy { this.totalBatchUsage.outputTokens += batchResult.usage.outputTokens ?? 0; this.totalBatchUsage.totalTokens += batchResult.usage.totalTokens ?? 0; } + if (batchResult.providerMetadata) { + this.lastBatchProviderMetadata = batchResult.providerMetadata; + } } return { observations: '', usage: this.totalBatchUsage.totalTokens > 0 ? this.totalBatchUsage : undefined, + providerMetadata: this.lastBatchProviderMetadata, }; } @@ -277,7 +304,29 @@ export class ResourceScopedObservationStrategy extends ObservationStrategy { const result = this.multiThreadResults.get(threadId); if (!result) continue; - this.observationResults.push({ threadId, threadMessages, result }); + const previousValues = getPriorExtractedValues(this.priorMetadataByThread.get(threadId)); + const hookedValues = await applyExtractorHooks({ + source: 'observer', + extractors: this.observationConfig.extractors, + values: result.extractedValues, + failures: result.extractionFailures, + previousValues, + threadId, + resourceId: this.resourceId, + mainAgent: this.opts.agent, + memory: this.deps.memory, + sendSignal: this.opts.sendSignal, + requestContext: this.opts.requestContext, + }); + this.observationResults.push({ + threadId, + threadMessages, + result: { + ...result, + extractedValues: hookedValues.values, + extractionFailures: hookedValues.failures, + }, + }); } let currentObservations = existingObservations; @@ -304,6 +353,8 @@ export class ResourceScopedObservationStrategy extends ObservationStrategy { suggestedResponse: result.suggestedContinuation, currentTask: result.currentTask, threadTitle: result.threadTitle, + extracted: result.extractedValues, + extractionFailures: result.extractionFailures, lastObservedMessageCursor: getLastObservedMessageCursor(threadMessages), }); @@ -352,11 +403,16 @@ export class ResourceScopedObservationStrategy extends ObservationStrategy { const oldTitle = thread.title?.trim(); const newTitle = update.threadTitle?.trim(); const shouldUpdateThreadTitle = !!newTitle && newTitle.length >= 3 && newTitle !== oldTitle; + const previousOmMetadata = getThreadOMMetadata(thread.metadata); const newMetadata = setThreadOMMetadata(thread.metadata, { lastObservedAt: update.lastObservedAt, suggestedResponse: update.suggestedResponse, currentTask: update.currentTask, threadTitle: update.threadTitle, + extracted: { + ...(previousOmMetadata?.extracted ?? {}), + ...(filterUserExtractedValues(update.extracted) ?? {}), + }, lastObservedMessageCursor: update.lastObservedMessageCursor, }); await this.storage.updateThread({ @@ -421,6 +477,8 @@ export class ResourceScopedObservationStrategy extends ObservationStrategy { observations: result.observations, currentTask: result.currentTask, suggestedResponse: result.suggestedContinuation, + extractedValues: result.extractedValues, + extractionFailures: result.extractionFailures, recordId: this.opts.record.id, threadId, }); diff --git a/packages/memory/src/processors/observational-memory/observation-strategies/sync.ts b/packages/memory/src/processors/observational-memory/observation-strategies/sync.ts index 7589499ea51b..4aca6a78ecf3 100644 --- a/packages/memory/src/processors/observational-memory/observation-strategies/sync.ts +++ b/packages/memory/src/processors/observational-memory/observation-strategies/sync.ts @@ -2,6 +2,7 @@ import type { MastraDBMessage } from '@mastra/core/agent'; import { getThreadOMMetadata, setThreadOMMetadata } from '@mastra/core/memory'; import { omDebug } from '../debug'; +import { applyExtractorHooks, filterUserExtractedValues, getPriorExtractedValues } from '../extracted-values'; import { createObservationEndMarker, createObservationFailedMarker, @@ -21,6 +22,7 @@ export class SyncObservationStrategy extends ObservationStrategy { private cycleId?: string; private tokensToObserve = 0; private observerResult!: ObserverOutput; + private priorExtractedValues?: Record<string, unknown>; constructor(deps: StrategyDeps, opts: ObservationRunOpts) { super(deps, opts); @@ -98,6 +100,7 @@ export class SyncObservationStrategy extends ObservationStrategy { // Fetch prior thread metadata for observer prompt continuity const thread = await this.storage.getThreadById({ threadId: this.opts.threadId }); const omMeta = thread ? getThreadOMMetadata(thread.metadata) : undefined; + this.priorExtractedValues = getPriorExtractedValues(omMeta); const result = await this.deps.observer.call(existingObservations, messages, this.opts.abortSignal, { requestContext: this.opts.requestContext, @@ -105,9 +108,30 @@ export class SyncObservationStrategy extends ObservationStrategy { priorCurrentTask: omMeta?.currentTask, priorSuggestedResponse: omMeta?.suggestedResponse, priorThreadTitle: omMeta?.threadTitle, + priorExtractedValues: this.priorExtractedValues, + resourceId: this.opts.resourceId, + mainAgent: this.opts.agent, }); - this.observerResult = result; - return result; + const hookedValues = await applyExtractorHooks({ + source: 'observer', + extractors: this.observationConfig.extractors, + values: result.extractedValues, + failures: result.extractionFailures, + previousValues: this.priorExtractedValues, + threadId: this.opts.threadId, + resourceId: this.opts.resourceId, + mainAgent: this.opts.agent, + memory: this.deps.memory, + sendSignal: this.opts.sendSignal, + requestContext: this.opts.requestContext, + }); + const output = { + ...result, + extractedValues: hookedValues.values, + extractionFailures: hookedValues.failures, + }; + this.observerResult = output; + return output; } async process(output: ObserverOutput, existingObservations: string): Promise<ProcessedObservation> { @@ -153,6 +177,8 @@ export class SyncObservationStrategy extends ObservationStrategy { suggestedContinuation: output.suggestedContinuation, currentTask: output.currentTask, threadTitle: output.threadTitle, + extractedValues: output.extractedValues, + extractionFailures: output.extractionFailures, }; } @@ -166,10 +192,15 @@ export class SyncObservationStrategy extends ObservationStrategy { const oldTitle = thread.title?.trim(); const newTitle = processed.threadTitle?.trim(); const shouldUpdateThreadTitle = !!newTitle && newTitle.length >= 3 && newTitle !== oldTitle; + const previousOmMetadata = getThreadOMMetadata(thread.metadata); const newMetadata = setThreadOMMetadata(thread.metadata, { suggestedResponse: processed.suggestedContinuation, currentTask: processed.currentTask, threadTitle: processed.threadTitle, + extracted: { + ...(previousOmMetadata?.extracted ?? {}), + ...(filterUserExtractedValues(processed.extractedValues) ?? {}), + }, lastObservedMessageCursor: getLastObservedMessageCursor(messages), }); await this.storage.updateThread({ @@ -215,6 +246,8 @@ export class SyncObservationStrategy extends ObservationStrategy { observations: this.observerResult.observations, currentTask: this.observerResult.currentTask, suggestedResponse: this.observerResult.suggestedContinuation, + extractedValues: this.observerResult.extractedValues, + extractionFailures: this.observerResult.extractionFailures, recordId: this.opts.record.id, threadId: this.opts.threadId, }); diff --git a/packages/memory/src/processors/observational-memory/observation-strategies/types.ts b/packages/memory/src/processors/observational-memory/observation-strategies/types.ts index d2f59a34c5e1..a6af5902e0c5 100644 --- a/packages/memory/src/processors/observational-memory/observation-strategies/types.ts +++ b/packages/memory/src/processors/observational-memory/observation-strategies/types.ts @@ -3,6 +3,7 @@ import type { ObservabilityContext } from '@mastra/core/observability'; import type { ProcessorContext, ProcessorStreamWriter } from '@mastra/core/processors'; import type { RequestContext } from '@mastra/core/request-context'; import type { ObservationalMemoryRecord } from '@mastra/core/storage'; +import type { ProviderMetadata } from '@mastra/core/stream'; import type { ObservationModelContext, ObserveHooks } from '../types'; @@ -21,6 +22,7 @@ export interface ObservationRunOpts { writer?: ProcessorStreamWriter; abortSignal?: AbortSignal; reflectionHooks?: Pick<ObserveHooks, 'onReflectionStart' | 'onReflectionEnd'>; + agent?: ProcessorContext['agent']; sendSignal?: ProcessorContext['sendSignal']; requestContext?: RequestContext; currentModel?: ObservationModelContext; @@ -33,13 +35,17 @@ export interface ObserverOutput { currentTask?: string; suggestedContinuation?: string; threadTitle?: string; + extractedValues?: Record<string, unknown>; + extractionFailures?: Array<{ slug: string; error: string }>; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; + providerMetadata?: ProviderMetadata; } /** Result returned from ObservationStrategy.run(). */ export interface ObservationRunResult { observed: boolean; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; + providerMetadata?: ProviderMetadata; } /** Processed observation ready for persistence. */ @@ -55,9 +61,13 @@ export interface ProcessedObservation { suggestedResponse?: string; currentTask?: string; threadTitle?: string; + extracted?: Record<string, unknown>; + extractionFailures?: Array<{ slug: string; error: string }>; lastObservedMessageCursor?: { createdAt: string; id: string }; }>; suggestedContinuation?: string; currentTask?: string; threadTitle?: string; + extractedValues?: Record<string, unknown>; + extractionFailures?: Array<{ slug: string; error: string }>; } diff --git a/packages/memory/src/processors/observational-memory/observation-turn/turn.ts b/packages/memory/src/processors/observational-memory/observation-turn/turn.ts index 5ef7abb54fe3..70e898c38102 100644 --- a/packages/memory/src/processors/observational-memory/observation-turn/turn.ts +++ b/packages/memory/src/processors/observational-memory/observation-turn/turn.ts @@ -58,6 +58,9 @@ export class ObservationTurn { /** Optional observability context for nested OM spans. */ observabilityContext?: ObservabilityContext; + /** Optional agent that owns this processor turn. */ + agent?: ProcessorContext['agent']; + /** Optional signal sender for processor-originated notifications. */ sendSignal?: ( signal: Parameters<NonNullable<ProcessorContext['sendSignal']>>[0], @@ -74,6 +77,7 @@ export class ObservationTurn { threadId: string; resourceId?: string; messageList: MessageList; + agent?: ProcessorContext['agent']; sendSignal?: ProcessorContext['sendSignal']; requestContext?: RequestContext; observabilityContext?: ObservabilityContext; @@ -83,6 +87,7 @@ export class ObservationTurn { this.threadId = opts.threadId; this.resourceId = opts.resourceId; this.messageList = opts.messageList; + this.agent = opts.agent; this.sendSignal = opts.sendSignal; this.requestContext = opts.requestContext; this.observabilityContext = opts.observabilityContext; @@ -207,6 +212,7 @@ export class ObservationTurn { messages: unobservedMessages, record, writer: this.writer, + agent: this.agent, sendSignal: this.sendSignal, requestContext: this.requestContext, currentModel: this.actorModelContext, diff --git a/packages/memory/src/processors/observational-memory/observational-memory.ts b/packages/memory/src/processors/observational-memory/observational-memory.ts index 308eec67e48b..6391b009a25b 100644 --- a/packages/memory/src/processors/observational-memory/observational-memory.ts +++ b/packages/memory/src/processors/observational-memory/observational-memory.ts @@ -9,10 +9,13 @@ import type { ProcessorContext, ProcessorStreamWriter } from '@mastra/core/proce import { MessageHistory } from '@mastra/core/processors'; import type { RequestContext } from '@mastra/core/request-context'; import type { MemoryStorage, ObservationalMemoryRecord, ObservationalMemoryHistoryOptions } from '@mastra/core/storage'; +import type { ProviderMetadata } from '@mastra/core/stream'; import xxhash from 'xxhash-wasm'; +import type { Memory } from '../..'; import { resolveActivationTTL } from './activation-ttl'; import { BufferingCoordinator } from './buffering-coordinator'; +import { composeObservationExtractors, composeReflectionExtractors } from './built-in-extractors'; import { OBSERVATIONAL_MEMORY_DEFAULTS, OBSERVATION_CONTEXT_PROMPT, @@ -188,6 +191,11 @@ function parseActivationTTL( import { addRelativeTimeToObservations } from './date-utils'; import { omDebug, omError } from './debug'; +import { + buildExtractedValueContextSections, + buildThreadMetadataFromExtractedValues, + getPriorExtractedValues, +} from './extracted-values'; import { createBufferingStartMarker, createActivationMarker } from './markers'; import { findLastCompletedObservationBoundary, @@ -300,6 +308,7 @@ export class ObservationalMemory { private shouldObscureThreadIds = false; private hasher = xxhash(); private mastra?: Mastra; + private memory?: Memory; /** * Track message IDs observed during this instance's lifetime. @@ -381,6 +390,7 @@ export class ObservationalMemory { this.retrieval = Boolean(config.retrieval); this.onIndexObservations = config.onIndexObservations; this.mastra = config.mastra; + this.memory = config.memory; // Resolve "default" to the model default for the agent being configured. const resolveModel = (model: ObservationalMemoryModel | undefined, defaultModel: string) => @@ -505,6 +515,10 @@ export class ObservationalMemory { instruction: config.observation?.instruction, threadTitle: config.observation?.threadTitle ?? false, observeAttachments: config.observation?.observeAttachments ?? true, + extractors: composeObservationExtractors({ + threadTitle: config.observation?.threadTitle ?? false, + extract: config.observation?.extract, + }), }; // Resolve reflection config with defaults @@ -536,6 +550,7 @@ export class ObservationalMemory { config.reflection?.observationTokens ?? OBSERVATIONAL_MEMORY_DEFAULTS.reflection.observationTokens, ), instruction: config.reflection?.instruction, + extractors: composeReflectionExtractors({ extract: config.reflection?.extract }), }; this.tokenCounter = new TokenCounter({ @@ -554,6 +569,7 @@ export class ObservationalMemory { resolveModel: inputTokens => this.resolveObservationModel(inputTokens), tokenCounter: this.tokenCounter, mastra: config.mastra, + memory: this.memory, }); this.buffering = new BufferingCoordinator({ @@ -575,6 +591,7 @@ export class ObservationalMemory { getCompressionStartLevel: rc => this.getCompressionStartLevel(rc), resolveModel: inputTokens => this.resolveReflectionModel(inputTokens), mastra: config.mastra, + memory: this.memory, }); // Validate buffer configuration @@ -1570,6 +1587,7 @@ export class ObservationalMemory { observations: string, currentTask?: string, suggestedResponse?: string, + extractedValues?: Record<string, unknown>, unobservedContextBlocks?: string, currentDate?: Date, retrieval = false, @@ -1609,6 +1627,13 @@ export class ObservationalMemory { messages.push(`<suggested-response>\n${suggestedResponse}\n</suggested-response>`); } + messages.push( + ...buildExtractedValueContextSections( + [...this.observationConfig.extractors, ...this.reflectionConfig.extractors], + extractedValues, + ), + ); + return messages; } @@ -2497,6 +2522,7 @@ ${formattedMessages} record.activeObservations, currentTask, suggestedResponse, + omMetadata?.extracted, unobservedContextBlocks, currentDate, this.retrieval, @@ -2899,6 +2925,7 @@ ${formattedMessages} * before lastBufferedBoundary is set. */ record?: ObservationalMemoryRecord; writer?: ProcessorStreamWriter; + agent?: ProcessorContext['agent']; sendSignal?: ProcessorContext['sendSignal']; requestContext?: RequestContext; currentModel?: ObservationModelContext; @@ -3053,6 +3080,7 @@ ${formattedMessages} cycleId, startedAt, writer, + agent: opts.agent, sendSignal: opts.sendSignal, requestContext, currentModel: opts.currentModel, @@ -3361,6 +3389,7 @@ ${formattedMessages} resourceId?: string; messages?: MastraDBMessage[]; hooks?: ObserveHooks; + agent?: ProcessorContext['agent']; requestContext?: RequestContext; writer?: ProcessorStreamWriter; observabilityContext?: ObservabilityContext; @@ -3377,6 +3406,7 @@ ${formattedMessages} let observed = false; let observationUsage: ObserveHookUsage | undefined; + let observationProviderMetadata: ProviderMetadata | undefined; let generationBefore = -1; await this.withLock(lockKey, async () => { @@ -3409,17 +3439,23 @@ ${formattedMessages} resourceId, messages: unobservedMessages, reflectionHooks, + agent: opts.agent, requestContext, writer: opts.writer, observabilityContext: opts.observabilityContext, }).run(); observed = result.observed; observationUsage = result.usage; + observationProviderMetadata = result.providerMetadata; } catch (error) { observationError = error instanceof Error ? error : new Error(String(error)); throw error; } finally { - hooks?.onObservationEnd?.({ usage: observationUsage, error: observationError }); + hooks?.onObservationEnd?.({ + usage: observationUsage, + error: observationError, + ...(observationProviderMetadata ? { providerMetadata: observationProviderMetadata } : {}), + }); } }); @@ -3461,6 +3497,9 @@ ${formattedMessages} registerOp(record.id, 'reflecting'); try { + const thread = await this.storage.getThreadById({ threadId }); + const previousOmMetadata = getThreadOMMetadata(thread?.metadata); + const priorExtractedValues = getPriorExtractedValues(previousOmMetadata); const reflectThreshold = getMaxThreshold(this.getEffectiveReflectionTokens(record)); const reflectResult = await this.reflector.call( record.activeObservations, @@ -3471,6 +3510,7 @@ ${formattedMessages} undefined, undefined, requestContext, + priorExtractedValues, observabilityContext, undefined, ); @@ -3482,8 +3522,21 @@ ${formattedMessages} tokenCount: reflectionTokenCount, }); - // Note: Thread metadata (currentTask, suggestedResponse) is preserved on each thread - // and doesn't need to be updated during reflection - it was set during observation + if (thread && reflectResult.extractedValues) { + const metadataUpdate = buildThreadMetadataFromExtractedValues(reflectResult.extractedValues); + const newMetadata = setThreadOMMetadata(thread.metadata, { + currentTask: metadataUpdate.currentTask ?? previousOmMetadata?.currentTask, + suggestedResponse: metadataUpdate.suggestedResponse ?? previousOmMetadata?.suggestedResponse, + threadTitle: metadataUpdate.threadTitle ?? previousOmMetadata?.threadTitle, + extracted: { ...(previousOmMetadata?.extracted ?? {}), ...(metadataUpdate.extracted ?? {}) }, + }); + await this.storage.updateThread({ + id: threadId, + title: thread.title ?? '', + metadata: newMetadata, + }); + } + const updatedRecord = await this.getOrCreateRecord(threadId, resourceId); return { reflected: true, record: updatedRecord, usage: reflectResult.usage }; } catch (error) { @@ -3578,6 +3631,13 @@ ${formattedMessages} return this.storage; } + /** + * Get the owning Memory instance when available. + */ + getMemory(): Memory | undefined { + return this.memory; + } + /** * Get the token counter */ @@ -3633,6 +3693,7 @@ ${formattedMessages} threadId: string; resourceId?: string; messageList: MessageList; + agent?: ProcessorContext['agent']; observabilityContext?: ObservabilityContext; hooks?: ObservationTurnHooks; }): ObservationTurn { @@ -3641,6 +3702,7 @@ ${formattedMessages} threadId: opts.threadId, resourceId: opts.resourceId, messageList: opts.messageList, + agent: opts.agent, observabilityContext: opts.observabilityContext, hooks: opts.hooks, }); diff --git a/packages/memory/src/processors/observational-memory/observer-agent.ts b/packages/memory/src/processors/observational-memory/observer-agent.ts index c6033659a07e..9cfad23b7be9 100644 --- a/packages/memory/src/processors/observational-memory/observer-agent.ts +++ b/packages/memory/src/processors/observational-memory/observer-agent.ts @@ -3,6 +3,13 @@ import type { CoreMessage } from '@mastra/core/llm'; import { stripEphemeralAnchorIds } from './anchor-ids'; import { isTemporalGapMarker } from './date-utils'; +import type { Extractor } from './extractor'; +import { + buildExtractorOutputSections, + buildExtractorPriorLines, + parseExtractedValues, + stripExtractorSections, +} from './extractor'; import { safeSlice } from './string-utils'; import { DEFAULT_OBSERVER_TOOL_RESULT_MAX_TOKENS, @@ -292,18 +299,24 @@ Prefer concrete resolved outcomes over abstract workflow status so the assistant */ export const OBSERVER_OUTPUT_FORMAT_BASE = buildObserverOutputFormat(); -export function buildObserverOutputFormat(includeThreadTitle: boolean = false): string { - const threadTitleSection = includeThreadTitle - ? ` -<thread-title> -A short, noun-phrase title for this conversation (2-5 words). Examples: -- "Auth bug fix" — not "Fixing the auth bug" -- "Dark mode toggle" — not "User wants dark mode toggle added" -- "Deployment pipeline setup" — not "Setting up deployment pipeline for project" -Only update when the topic meaningfully changes. -</thread-title>` - : ''; +export function buildObserverOutputFormat(extractors: readonly Extractor<any>[] = []): string { + const extractorSections = buildExtractorOutputSections(extractors); + const legacyContinuationSections = + extractors.length === 0 + ? ` +<current-task> +State the current task(s) explicitly: +- Primary: What the agent is currently working on +- Secondary: Other pending tasks (mark as "waiting for user" if appropriate) +</current-task> +<suggested-response> +Hint for the agent's immediate next message. Examples: +- "I've updated the navigation model. Let me walk you through the changes..." +- "The assistant should wait for the user to respond before continuing." +- Call the view tool on src/example.ts to continue debugging. +</suggested-response>` + : ''; return `Use priority levels: - 🔴 High: explicit user facts, preferences, unresolved goals, critical context - 🟡 Medium: project details, learned information, tool results @@ -329,20 +342,7 @@ Date: Dec 5, 2025 * 🔴 (09:15) Continued work on feature X </observations> -<current-task> -State the current task(s) explicitly. Can be single or multiple: -- Primary: What the agent is currently working on -- Secondary: Other pending tasks (mark as "waiting for user" if appropriate) - -If the agent started doing something without user approval, note that it's off-task. -</current-task> - -<suggested-response> -Hint for the agent's immediate next message. Examples: -- "I've updated the navigation model. Let me walk you through the changes..." -- "The assistant should wait for the user to respond before continuing." -- Call the view tool on src/example.ts to continue debugging. -</suggested-response>${threadTitleSection}`; +${extractorSections || legacyContinuationSections}`; } /** @@ -376,8 +376,9 @@ export function buildObserverSystemPrompt( multiThread: boolean = false, instruction?: string, includeThreadTitle: boolean = false, + extractors: readonly Extractor<any>[] = [], ): string { - const outputFormat = buildObserverOutputFormat(includeThreadTitle); + const outputFormat = buildObserverOutputFormat(extractors); const multiThreadTitleInstruction = includeThreadTitle ? ` Each thread's observations, current-task, suggested-response, and thread-title should be nested inside a <thread id="..."> block within <observations>.` : ` Each thread's observations, current-task, and suggested-response should be nested inside a <thread id="..."> block within <observations>.`; @@ -502,6 +503,12 @@ export interface ObserverResult { /** The suggested thread title (short/concise, for thread metadata) */ threadTitle?: string; + /** Extracted values keyed by extractor slug */ + extractedValues?: Record<string, unknown>; + + /** Extractor failures keyed by extractor slug */ + extractionFailures?: Array<{ slug: string; error: string }>; + /** Raw output from the model (for debugging) */ rawOutput?: string; @@ -1155,9 +1162,13 @@ export function buildMultiThreadObserverHistoryMessage( export function buildMultiThreadObserverTaskPrompt( existingObservations: string | undefined, threadOrder?: string[], - priorMetadataByThread?: Map<string, { currentTask?: string; suggestedResponse?: string; threadTitle?: string }>, + priorMetadataByThread?: Map< + string, + { currentTask?: string; suggestedResponse?: string; threadTitle?: string; extracted?: Record<string, unknown> } + >, wasTruncated?: boolean, includeThreadTitle?: boolean, + extractors: readonly Extractor<any>[] = [], ): string { let prompt = ''; @@ -1171,9 +1182,13 @@ export function buildMultiThreadObserverTaskPrompt( const threadMetadataLines = threadOrder ?.map(threadId => { const metadata = priorMetadataByThread?.get(threadId); + const extractorPriorLines = buildExtractorPriorLines(extractors, metadata?.extracted); const hasRelevantMetadata = - metadata?.currentTask || metadata?.suggestedResponse || (includeThreadTitle && metadata?.threadTitle); - if (!hasRelevantMetadata) { + metadata?.currentTask || + metadata?.suggestedResponse || + (includeThreadTitle && metadata?.threadTitle) || + extractorPriorLines.length > 0; + if (!hasRelevantMetadata || !metadata) { return ''; } @@ -1187,6 +1202,9 @@ export function buildMultiThreadObserverTaskPrompt( if (includeThreadTitle && metadata.threadTitle) { lines.push(` - prior thread-title: ${metadata.threadTitle}`); } + for (const priorLine of extractorPriorLines) { + lines.push(` - prior ${priorLine.replace(/\n/g, '\n ')}`); + } return lines.join('\n'); }) .filter(Boolean) @@ -1233,13 +1251,17 @@ export function buildMultiThreadObserverPrompt( existingObservations: string | undefined, messagesByThread: Map<string, MastraDBMessage[]>, threadOrder: string[], - priorMetadataByThread?: Map<string, { currentTask?: string; suggestedResponse?: string; threadTitle?: string }>, + priorMetadataByThread?: Map< + string, + { currentTask?: string; suggestedResponse?: string; threadTitle?: string; extracted?: Record<string, unknown> } + >, wasTruncated?: boolean, options?: ObserverFormatOptions, includeThreadTitle?: boolean, + extractors: readonly Extractor<any>[] = [], ): string { const formattedMessages = formatMultiThreadMessagesForObserver(messagesByThread, threadOrder, options); - return `## New Message History to Observe\n\nThe following messages are from ${threadOrder.length} different conversation threads. Each thread is wrapped in a <thread id="..."> tag.\n\n${formattedMessages}\n\n---\n\n${buildMultiThreadObserverTaskPrompt(existingObservations, threadOrder, priorMetadataByThread, wasTruncated, includeThreadTitle)}`; + return `## New Message History to Observe\n\nThe following messages are from ${threadOrder.length} different conversation threads. Each thread is wrapped in a <thread id="..."> tag.\n\n${formattedMessages}\n\n---\n\n${buildMultiThreadObserverTaskPrompt(existingObservations, threadOrder, priorMetadataByThread, wasTruncated, includeThreadTitle, extractors)}`; } /** @@ -1257,7 +1279,10 @@ export interface MultiThreadObserverResult { /** * Parse multi-thread Observer output to extract per-thread results. */ -export function parseMultiThreadObserverOutput(output: string): MultiThreadObserverResult { +export function parseMultiThreadObserverOutput( + output: string, + extractors: readonly Extractor<any>[] = [], +): MultiThreadObserverResult { const threads = new Map<string, ObserverResult>(); // Check for degenerate repetition on the whole output @@ -1278,9 +1303,12 @@ export function parseMultiThreadObserverOutput(output: string): MultiThreadObser const threadContent = match[2]; if (!threadId || !threadContent) continue; + const inlineExtractors = extractors.filter(extractor => extractor.mode === 'inline'); + const parsedExtractedValues = parseExtractedValues(threadContent, inlineExtractors); + // Parse this thread's content for observations, current-task, suggested-response // Extract observations (everything except current-task and suggested-response) - let observations = threadContent; + let observations = stripExtractorSections(threadContent, inlineExtractors); // Extract and remove current-task let currentTask: string | undefined; @@ -1311,9 +1339,12 @@ export function parseMultiThreadObserverOutput(output: string): MultiThreadObser threads.set(threadId, { observations, - currentTask, - suggestedContinuation, - threadTitle, + currentTask: currentTask || getStringExtractedValue(parsedExtractedValues.values, 'current-task'), + suggestedContinuation: + suggestedContinuation || getStringExtractedValue(parsedExtractedValues.values, 'suggested-response'), + threadTitle: threadTitle || getStringExtractedValue(parsedExtractedValues.values, 'thread-title'), + extractedValues: parsedExtractedValues.values, + extractionFailures: parsedExtractedValues.failures, rawOutput: threadContent, }); } @@ -1336,6 +1367,8 @@ export function buildObserverTaskPrompt( priorThreadTitle?: string; wasTruncated?: boolean; includeThreadTitle?: boolean; + extractors?: readonly Extractor<any>[]; + priorExtractedValues?: Record<string, unknown>; }, ): string { let prompt = ''; @@ -1357,6 +1390,7 @@ export function buildObserverTaskPrompt( if (options?.includeThreadTitle && options?.priorThreadTitle) { priorMetadataLines.push(`- prior thread-title: ${options.priorThreadTitle}`); } + priorMetadataLines.push(...buildExtractorPriorLines(options?.extractors ?? [], options?.priorExtractedValues)); if (priorMetadataLines.length > 0) { prompt += `## Prior Thread Metadata\n\n${priorMetadataLines.join('\n')}\n\n`; @@ -1371,13 +1405,8 @@ export function buildObserverTaskPrompt( prompt += `## Your Task\n\n`; prompt += `Extract new observations from the message history above. Do not repeat observations that are already in the previous observations. Add your new observations in the format specified in your instructions.`; - // Add thread title guidance (independent of continuation hints) - if (options?.includeThreadTitle) { - prompt += `\n\nAlso output a <thread-title> — a short noun-phrase label for this conversation (2-5 words). Write it like a file name or PR title: "Auth bug fix", "Memory config refactor", "RAG pipeline setup". Avoid verbs/sentences ("Fixing the auth bug"), filler ("Working on stuff"), and generic labels ("Code review"). Only change it from the prior title if the topic meaningfully shifted.`; - } - if (options?.skipContinuationHints) { - prompt += `\n\nIMPORTANT: Do NOT include <current-task> or <suggested-response> sections in your output. Only output <observations>${options?.includeThreadTitle ? ' and <thread-title>' : ''}.`; + prompt += `\n\nOutput <observations> every time.`; } return prompt; @@ -1397,6 +1426,8 @@ export function buildObserverPrompt( priorThreadTitle?: string; wasTruncated?: boolean; includeThreadTitle?: boolean; + extractors?: readonly Extractor<any>[]; + priorExtractedValues?: Record<string, unknown>; }, ): string { const formattedMessages = formatMessagesForObserver(messagesToObserve); @@ -1407,7 +1438,12 @@ export function buildObserverPrompt( * Parse the Observer's output to extract observations, current task, and suggested response. * Uses XML tag parsing for structured extraction. */ -export function parseObserverOutput(output: string): ObserverResult { +function getStringExtractedValue(values: Record<string, unknown>, slug: string): string | undefined { + const value = values[slug]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +export function parseObserverOutput(output: string, extractors: readonly Extractor<any>[] = []): ObserverResult { // Check for degenerate repetition before parsing (operates on raw output) if (detectDegenerateRepetition(output)) { return { @@ -1417,7 +1453,10 @@ export function parseObserverOutput(output: string): ObserverResult { }; } - const parsed = parseMemorySectionXml(output); + const inlineExtractors = extractors.filter(extractor => extractor.mode === 'inline'); + const parsedExtractedValues = parseExtractedValues(output, inlineExtractors); + const strippedOutput = stripExtractorSections(output, inlineExtractors); + const parsed = parseMemorySectionXml(strippedOutput); // Return observations WITHOUT current-task/suggested-response tags // Those are stored separately in thread metadata and injected dynamically @@ -1425,9 +1464,12 @@ export function parseObserverOutput(output: string): ObserverResult { return { observations, - currentTask: parsed.currentTask || undefined, - suggestedContinuation: parsed.suggestedResponse || undefined, - threadTitle: parsed.threadTitle || undefined, + currentTask: parsed.currentTask || getStringExtractedValue(parsedExtractedValues.values, 'current-task'), + suggestedContinuation: + parsed.suggestedResponse || getStringExtractedValue(parsedExtractedValues.values, 'suggested-response'), + threadTitle: parsed.threadTitle || getStringExtractedValue(parsedExtractedValues.values, 'thread-title'), + extractedValues: parsedExtractedValues.values, + extractionFailures: parsedExtractedValues.failures, rawOutput: output, }; } diff --git a/packages/memory/src/processors/observational-memory/observer-runner.ts b/packages/memory/src/processors/observational-memory/observer-runner.ts index 76ba12150475..42de11ebab29 100644 --- a/packages/memory/src/processors/observational-memory/observer-runner.ts +++ b/packages/memory/src/processors/observational-memory/observer-runner.ts @@ -2,10 +2,17 @@ import { Agent } from '@mastra/core/agent'; import type { MastraDBMessage } from '@mastra/core/agent'; import { modelSupportsAttachments } from '@mastra/core/llm'; import type { Mastra } from '@mastra/core/mastra'; +import type { MastraMemory } from '@mastra/core/memory'; import type { ObservabilityContext } from '@mastra/core/observability'; +import type { ProcessorContext } from '@mastra/core/processors'; import type { RequestContext } from '@mastra/core/request-context'; +import type { ProviderMetadata } from '@mastra/core/stream'; +import type { Memory } from '../..'; import { omDebug } from './debug'; +import { getBuiltInExtractedValues, mergeExtractedValues, mergeExtractionFailures } from './extracted-values'; +import { extractStructuredValues } from './extraction-runner'; +import { resolveExtractors } from './extractor'; import { withOmInternalThreadId } from './internal-request-context'; import type { ModelByInputTokens } from './model-by-input-tokens'; import type { ObserverAttachmentFilter } from './observer-agent'; @@ -19,6 +26,7 @@ import { parseMultiThreadObserverOutput, } from './observer-agent'; import { withRetry } from './retry'; +import { createTemporaryOmMemoryContext } from './temporary-memory'; import type { TokenCounter } from './token-counter'; import { withOmTracingSpan } from './tracing'; import type { ResolvedObservationConfig } from './types'; @@ -45,6 +53,8 @@ export interface ObserverExchange { currentTask?: string; suggestedContinuation?: string; threadTitle?: string; + extractedValues?: Record<string, unknown>; + extractionFailures?: Array<{ slug: string; error: string }>; degenerate?: boolean; }; model: string; @@ -53,11 +63,25 @@ export interface ObserverExchange { retriedDueToDegenerate: boolean; } +function filterObserverExtractors( + extractors: ResolvedObservationConfig['extractors'] | undefined, + skipContinuationHints?: boolean, +) { + const configuredExtractors = extractors ?? []; + if (!skipContinuationHints) { + return configuredExtractors; + } + return configuredExtractors.filter( + extractor => extractor.slug !== 'current-task' && extractor.slug !== 'suggested-response', + ); +} + export class ObserverRunner { private readonly observationConfig: ResolvedObservationConfig; private readonly observedMessageIds: Set<string>; private readonly resolveModel: ObservationModelResolver; private readonly tokenCounter: TokenCounter; + private readonly memory?: Memory; private mastra?: Mastra; /** Captured prompt/response from the last observer call (for repro capture). */ @@ -69,19 +93,26 @@ export class ObserverRunner { resolveModel: ObservationModelResolver; tokenCounter: TokenCounter; mastra?: Mastra; + memory?: Memory; }) { this.observationConfig = opts.observationConfig; this.observedMessageIds = opts.observedMessageIds; this.resolveModel = opts.resolveModel; this.tokenCounter = opts.tokenCounter; this.mastra = opts.mastra; + this.memory = opts.memory; } __registerMastra(mastra: Mastra): void { this.mastra = mastra; } - private createAgent(model: ConcreteObservationModel, isMultiThread = false): Agent { + private createAgent( + model: ConcreteObservationModel, + isMultiThread = false, + memory?: MastraMemory, + extractors = this.observationConfig.extractors ?? [], + ): Agent { const agent = new Agent({ id: isMultiThread ? 'multi-thread-observer' : 'observational-memory-observer', name: isMultiThread ? 'multi-thread-observer' : 'Observer', @@ -89,8 +120,10 @@ export class ObserverRunner { isMultiThread, this.observationConfig.instruction, this.observationConfig.threadTitle, + extractors, ), model, + ...(memory ? { memory } : {}), }); if (this.mastra) { agent.__registerMastra(this.mastra); @@ -170,19 +203,41 @@ export class ObserverRunner { priorCurrentTask?: string; priorSuggestedResponse?: string; priorThreadTitle?: string; + priorExtractedValues?: Record<string, unknown>; wasTruncated?: boolean; model?: ConcreteObservationModel; + resourceId?: string; + mainAgent?: ProcessorContext['agent']; }, ): Promise<{ observations: string; currentTask?: string; suggestedContinuation?: string; threadTitle?: string; + extractedValues?: Record<string, unknown>; + extractionFailures?: Array<{ slug: string; error: string }>; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; + providerMetadata?: ProviderMetadata; }> { const inputTokens = this.tokenCounter.countMessages(messagesToObserve); const resolvedModel = options?.model ? { model: options.model } : this.resolveModel(inputTokens); - const agent = this.createAgent(resolvedModel.model); + const activeExtractors = await resolveExtractors( + filterObserverExtractors(this.observationConfig.extractors, options?.skipContinuationHints), + { + source: 'observer', + threadId: messagesToObserve[0]?.threadId, + resourceId: options?.resourceId, + mainAgent: options?.mainAgent, + memory: this.memory, + requestContext: options?.requestContext, + }, + ); + const structuredExtractors = activeExtractors.filter(extractor => extractor.mode === 'structured'); + const temporaryMemory = + structuredExtractors.length > 0 ? await createTemporaryOmMemoryContext('structured-observer') : undefined; + const agent = temporaryMemory + ? this.createAgent(resolvedModel.model, false, temporaryMemory.memory, activeExtractors) + : this.createAgent(resolvedModel.model, false, undefined, activeExtractors); const internalRequestContext = withOmInternalThreadId(options?.requestContext, agent.id); const attachmentFilter = this.resolveAttachmentFilter(resolvedModel.model, options?.requestContext); @@ -193,6 +248,7 @@ export class ObserverRunner { content: buildObserverTaskPrompt(existingObservations, { ...options, includeThreadTitle: this.observationConfig.threadTitle, + extractors: activeExtractors, }), }, buildObserverHistoryMessage(messagesToObserve, { @@ -225,6 +281,7 @@ export class ObserverRunner { const streamResult = await agent.stream(observerMessages, { modelSettings: { ...this.observationConfig.modelSettings }, providerOptions: this.observationConfig.providerOptions as any, + ...(temporaryMemory ? { memory: temporaryMemory.options } : {}), ...(abortSignal ? { abortSignal } : {}), ...(internalRequestContext ? { requestContext: internalRequestContext } : {}), ...childObservabilityContext, @@ -237,13 +294,13 @@ export class ObserverRunner { }; let result = await doGenerate(); - let parsed = parseObserverOutput(result.text); + let parsed = parseObserverOutput(result.text, activeExtractors); let retriedDueToDegenerate = false; if (parsed.degenerate) { omDebug(`[OM:callObserver] degenerate repetition detected, retrying once`); result = await doGenerate(); - parsed = parseObserverOutput(result.text); + parsed = parseObserverOutput(result.text, activeExtractors); retriedDueToDegenerate = true; if (parsed.degenerate) { omDebug(`[OM:callObserver] degenerate repetition on retry, failing`); @@ -251,10 +308,25 @@ export class ObserverRunner { } } + const structuredExtraction = await extractStructuredValues({ + agent, + source: 'observer', + extractors: activeExtractors, + memory: temporaryMemory?.options, + priorExtractedValues: options?.priorExtractedValues, + requestContext: options?.requestContext, + observabilityContext: options?.observabilityContext, + abortSignal, + }); + const extractedValues = mergeExtractedValues(parsed.extractedValues, structuredExtraction.values); + const extractionFailures = mergeExtractionFailures(parsed.extractionFailures, structuredExtraction.failures); + const builtIns = getBuiltInExtractedValues(extractedValues); + const systemPrompt = buildObserverSystemPrompt( false, this.observationConfig.instruction, this.observationConfig.threadTitle, + activeExtractors, ); this.lastExchange = { systemPrompt, @@ -262,9 +334,11 @@ export class ObserverRunner { rawOutput: result.text, parsedResult: { observations: parsed.observations, - currentTask: parsed.currentTask, - suggestedContinuation: parsed.suggestedContinuation, - threadTitle: parsed.threadTitle, + currentTask: builtIns.currentTask ?? parsed.currentTask, + suggestedContinuation: builtIns.suggestedContinuation ?? parsed.suggestedContinuation, + threadTitle: builtIns.threadTitle ?? parsed.threadTitle, + extractedValues, + extractionFailures, degenerate: parsed.degenerate, }, model: String(resolvedModel.model), @@ -277,12 +351,15 @@ export class ObserverRunner { return { observations: parsed.observations, - currentTask: parsed.currentTask, - suggestedContinuation: parsed.suggestedContinuation, - threadTitle: parsed.threadTitle, + currentTask: builtIns.currentTask ?? parsed.currentTask, + suggestedContinuation: builtIns.suggestedContinuation ?? parsed.suggestedContinuation, + threadTitle: builtIns.threadTitle ?? parsed.threadTitle, + extractedValues, + extractionFailures, usage: usage ? { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, totalTokens: usage.totalTokens } : undefined, + providerMetadata: result.providerMetadata, }; } @@ -295,22 +372,84 @@ export class ObserverRunner { threadOrder: string[], abortSignal?: AbortSignal, requestContext?: RequestContext, - priorMetadataByThread?: Map<string, { currentTask?: string; suggestedResponse?: string; threadTitle?: string }>, + priorMetadataByThread?: Map< + string, + { currentTask?: string; suggestedResponse?: string; threadTitle?: string; extracted?: Record<string, unknown> } + >, observabilityContext?: ObservabilityContext, model?: ConcreteObservationModel, ): Promise<{ results: Map< string, - { observations: string; currentTask?: string; suggestedContinuation?: string; threadTitle?: string } + { + observations: string; + currentTask?: string; + suggestedContinuation?: string; + threadTitle?: string; + extractedValues?: Record<string, unknown>; + extractionFailures?: Array<{ slug: string; error: string }>; + } >; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; + providerMetadata?: ProviderMetadata; }> { const inputTokens = Array.from(messagesByThread.values()).reduce( (total, messages) => total + this.tokenCounter.countMessages(messages), 0, ); const resolvedModel = model ? { model } : this.resolveModel(inputTokens); - const agent = this.createAgent(resolvedModel.model, true); + const firstThreadMessages = messagesByThread.get(threadOrder[0] ?? '') ?? []; + const activeExtractors = await resolveExtractors(this.observationConfig.extractors ?? [], { + source: 'observer', + threadId: threadOrder[0], + resourceId: firstThreadMessages[0]?.resourceId, + memory: this.memory, + requestContext, + }); + const structuredExtractors = activeExtractors.filter(extractor => extractor.mode === 'structured'); + + if (structuredExtractors.length > 0) { + const results = new Map< + string, + { + observations: string; + currentTask?: string; + suggestedContinuation?: string; + threadTitle?: string; + extractedValues?: Record<string, unknown>; + extractionFailures?: Array<{ slug: string; error: string }>; + } + >(); + let totalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; + for (const threadId of threadOrder) { + const threadResult = await this.call(existingObservations, messagesByThread.get(threadId) ?? [], abortSignal, { + requestContext, + observabilityContext, + priorCurrentTask: priorMetadataByThread?.get(threadId)?.currentTask, + priorSuggestedResponse: priorMetadataByThread?.get(threadId)?.suggestedResponse, + priorThreadTitle: priorMetadataByThread?.get(threadId)?.threadTitle, + priorExtractedValues: priorMetadataByThread?.get(threadId)?.extracted, + model: resolvedModel.model, + }); + results.set(threadId, { + observations: threadResult.observations, + currentTask: threadResult.currentTask, + suggestedContinuation: threadResult.suggestedContinuation, + threadTitle: threadResult.threadTitle, + extractedValues: threadResult.extractedValues, + extractionFailures: threadResult.extractionFailures, + }); + if (threadResult.usage) { + totalUsage.inputTokens += threadResult.usage.inputTokens ?? 0; + totalUsage.outputTokens += threadResult.usage.outputTokens ?? 0; + totalUsage.totalTokens += threadResult.usage.totalTokens ?? 0; + } + } + return { results, usage: totalUsage }; + } + + let temporaryMemory: Awaited<ReturnType<typeof createTemporaryOmMemoryContext>> | undefined; + const agent = this.createAgent(resolvedModel.model, true, undefined, activeExtractors); const internalRequestContext = withOmInternalThreadId(requestContext, agent.id); const multiThreadAttachmentFilter = this.resolveAttachmentFilter(resolvedModel.model, requestContext); @@ -324,6 +463,7 @@ export class ObserverRunner { priorMetadataByThread, undefined, this.observationConfig.threadTitle, + activeExtractors, ), }, buildMultiThreadObserverHistoryMessage(messagesByThread, threadOrder, { @@ -362,6 +502,7 @@ export class ObserverRunner { const streamResult = await agent.stream(observerMessages, { modelSettings: { ...this.observationConfig.modelSettings }, providerOptions: this.observationConfig.providerOptions as any, + ...(temporaryMemory ? { memory: temporaryMemory.options } : {}), ...(abortSignal ? { abortSignal } : {}), ...(internalRequestContext ? { requestContext: internalRequestContext } : {}), ...childObservabilityContext, @@ -374,13 +515,13 @@ export class ObserverRunner { }; let result = await doGenerate(); - let parsed = parseMultiThreadObserverOutput(result.text); + let parsed = parseMultiThreadObserverOutput(result.text, activeExtractors); let retriedDueToDegenerate = false; if (parsed.degenerate) { omDebug(`[OM:callMultiThreadObserver] degenerate repetition detected, retrying once`); result = await doGenerate(); - parsed = parseMultiThreadObserverOutput(result.text); + parsed = parseMultiThreadObserverOutput(result.text, activeExtractors); retriedDueToDegenerate = true; if (parsed.degenerate) { omDebug(`[OM:callMultiThreadObserver] degenerate repetition on retry, failing`); @@ -388,10 +529,24 @@ export class ObserverRunner { } } + const structuredExtractionByThread = new Map<string, Awaited<ReturnType<typeof extractStructuredValues>>>(); + + const aggregatedExtractedValues = mergeExtractedValues( + ...Array.from(parsed.threads, ([threadId, threadResult]) => + mergeExtractedValues(threadResult.extractedValues, structuredExtractionByThread.get(threadId)?.values), + ), + ); + const aggregatedExtractionFailures = mergeExtractionFailures( + ...Array.from(parsed.threads, ([threadId, threadResult]) => + mergeExtractionFailures(threadResult.extractionFailures, structuredExtractionByThread.get(threadId)?.failures), + ), + ); + const aggregatedBuiltIns = getBuiltInExtractedValues(aggregatedExtractedValues); const systemPrompt = buildObserverSystemPrompt( true, this.observationConfig.instruction, this.observationConfig.threadTitle, + activeExtractors, ); this.lastExchange = { systemPrompt, @@ -401,10 +556,16 @@ export class ObserverRunner { observations: Array.from(parsed.threads.values()) .map(t => t.observations) .join('\n'), - threadTitle: Array.from(parsed.threads.values()) - .map(t => t.threadTitle) - .filter(Boolean) - .join(', '), + currentTask: aggregatedBuiltIns.currentTask, + suggestedContinuation: aggregatedBuiltIns.suggestedContinuation, + threadTitle: + aggregatedBuiltIns.threadTitle ?? + Array.from(parsed.threads.values()) + .map(t => t.threadTitle) + .filter(Boolean) + .join(', '), + extractedValues: aggregatedExtractedValues, + extractionFailures: aggregatedExtractionFailures, degenerate: parsed.degenerate, }, model: String(resolvedModel.model), @@ -415,14 +576,30 @@ export class ObserverRunner { const results = new Map< string, - { observations: string; currentTask?: string; suggestedContinuation?: string; threadTitle?: string } + { + observations: string; + currentTask?: string; + suggestedContinuation?: string; + threadTitle?: string; + extractedValues?: Record<string, unknown>; + extractionFailures?: Array<{ slug: string; error: string }>; + } >(); for (const [threadId, threadResult] of parsed.threads) { + const structuredExtraction = structuredExtractionByThread.get(threadId); + const extractedValues = mergeExtractedValues(threadResult.extractedValues, structuredExtraction?.values); + const extractionFailures = mergeExtractionFailures( + threadResult.extractionFailures, + structuredExtraction?.failures, + ); + const builtIns = getBuiltInExtractedValues(extractedValues); results.set(threadId, { observations: threadResult.observations, - currentTask: threadResult.currentTask, - suggestedContinuation: threadResult.suggestedContinuation, - threadTitle: threadResult.threadTitle, + currentTask: builtIns.currentTask ?? threadResult.currentTask, + suggestedContinuation: builtIns.suggestedContinuation ?? threadResult.suggestedContinuation, + threadTitle: builtIns.threadTitle ?? threadResult.threadTitle, + extractedValues, + extractionFailures, }); } @@ -440,6 +617,7 @@ export class ObserverRunner { usage: usage ? { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, totalTokens: usage.totalTokens } : undefined, + providerMetadata: result.providerMetadata, }; } } diff --git a/packages/memory/src/processors/observational-memory/processor.ts b/packages/memory/src/processors/observational-memory/processor.ts index 8b2615c3a238..f5e7b5d9c6c8 100644 --- a/packages/memory/src/processors/observational-memory/processor.ts +++ b/packages/memory/src/processors/observational-memory/processor.ts @@ -234,6 +234,7 @@ export class ObservationalMemoryProcessor implements Processor<'observational-me threadId, resourceId, messageList, + agent: args.agent, observabilityContext: getOmObservabilityContext(args), hooks: { onBufferChunkSealed: rotateResponseMessageId, @@ -242,6 +243,7 @@ export class ObservationalMemoryProcessor implements Processor<'observational-me }); this.turn.writer = writer; this.turn.sendSignal = args.sendSignal; + this.turn.agent = args.agent; this.turn.requestContext = requestContext; await this.turn.start(this.memory); if (stepNumber === 0 && this.temporalMarkers) { diff --git a/packages/memory/src/processors/observational-memory/reflector-agent.ts b/packages/memory/src/processors/observational-memory/reflector-agent.ts index 524c1c5d24c3..928c7e3e98dd 100644 --- a/packages/memory/src/processors/observational-memory/reflector-agent.ts +++ b/packages/memory/src/processors/observational-memory/reflector-agent.ts @@ -1,9 +1,16 @@ import { stripEphemeralAnchorIds } from './anchor-ids'; +import type { Extractor } from './extractor'; +import { + buildExtractorOutputSections, + buildExtractorPriorLines, + parseExtractedValues, + stripExtractorSections, +} from './extractor'; import { reconcileObservationGroupsFromReflection, stripObservationGroups } from './observation-groups'; import { OBSERVER_EXTRACTION_INSTRUCTIONS, - OBSERVER_OUTPUT_FORMAT_BASE, OBSERVER_GUIDELINES, + buildObserverOutputFormat, sanitizeObservationLines, detectDegenerateRepetition, } from './observer-agent'; @@ -30,7 +37,8 @@ export interface ReflectorResult extends BaseReflectorResult { * * @param instruction - Optional custom instructions to append to the prompt */ -export function buildReflectorSystemPrompt(instruction?: string): string { +export function buildReflectorSystemPrompt(instruction?: string, extractors: readonly Extractor<any>[] = []): string { + const outputFormat = buildObserverOutputFormat(extractors); return `You are the memory consciousness of an AI assistant. Your memory observation reflections will be the ONLY information the assistant has about past interactions with this user. The following instructions were given to another part of your psyche (the observer) to create memories. @@ -41,7 +49,7 @@ ${OBSERVER_EXTRACTION_INSTRUCTIONS} === OUTPUT FORMAT === -${OBSERVER_OUTPUT_FORMAT_BASE} +${outputFormat} === GUIDELINES === @@ -105,25 +113,7 @@ Date: Dec 4, 2025 === OUTPUT FORMAT === -Your output MUST use XML tags to structure the response: - -<observations> -Put all consolidated observations here using the date-grouped format with priority emojis (🔴, 🟡, 🟢). -Group related observations with indentation. -</observations> - -<current-task> -State the current task(s) explicitly: -- Primary: What the agent is currently working on -- Secondary: Other pending tasks (mark as "waiting for user" if appropriate) -</current-task> - -<suggested-response> -Hint for the agent's immediate next message. Examples: -- "I've updated the navigation model. Let me walk you through the changes..." -- "The assistant should wait for the user to respond before continuing." -- Call the view tool on src/example.ts to continue debugging. -</suggested-response> +${outputFormat} User messages are extremely important. If the user asks a question or gives a new task, make it clear in <current-task> that this is the priority. If the assistant needs to respond to the user, indicate in <suggested-response> that it should pause for user reply before continuing other tasks.${instruction ? `\n\n=== CUSTOM INSTRUCTIONS ===\n\n${instruction}` : ''}`; } @@ -238,6 +228,8 @@ export function buildReflectorPrompt( manualPrompt?: string, compressionLevel?: boolean | CompressionLevel, skipContinuationHints?: boolean, + extractors: readonly Extractor<any>[] = [], + priorExtractedValues?: Record<string, unknown>, ): string { // Normalize: boolean `true` maps to level 1 for backwards compat const level: CompressionLevel = typeof compressionLevel === 'number' ? compressionLevel : compressionLevel ? 1 : 0; @@ -266,8 +258,18 @@ ${manualPrompt}`; ${guidance}`; } + const priorLines = buildExtractorPriorLines(extractors, priorExtractedValues); + if (priorLines.length > 0) { + prompt += `\n\n## Prior Extracted Values\n\n${priorLines.join('\n\n')}\n\nUse these as carry-forward hints, then update them only when the observations justify a change.`; + } + + const extractorSections = buildExtractorOutputSections(extractors); + if (extractorSections) { + prompt += `\n\n## Additional Output Sections\n\n${extractorSections}`; + } + if (skipContinuationHints) { - prompt += `\n\nIMPORTANT: Do NOT include <current-task> or <suggested-response> sections in your output. Only output <observations>.`; + prompt += `\n\nOutput <observations> every time.`; } return prompt; @@ -277,7 +279,16 @@ ${guidance}`; * Parse the Reflector's output to extract observations, current task, and suggested response. * Uses XML tag parsing for structured extraction. */ -export function parseReflectorOutput(output: string, sourceObservations?: string): ReflectorResult { +function getStringExtractedValue(values: Record<string, unknown>, slug: string): string | undefined { + const value = values[slug]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +export function parseReflectorOutput( + output: string, + sourceObservations?: string, + extractors: readonly Extractor<any>[] = [], +): ReflectorResult { // Check for degenerate repetition before parsing if (detectDegenerateRepetition(output)) { return { @@ -286,7 +297,10 @@ export function parseReflectorOutput(output: string, sourceObservations?: string }; } - const parsed = parseReflectorSectionXml(output); + const inlineExtractors = extractors.filter(extractor => extractor.mode === 'inline'); + const parsedExtractedValues = parseExtractedValues(output, inlineExtractors); + const strippedOutput = stripExtractorSections(output, inlineExtractors); + const parsed = parseReflectorSectionXml(strippedOutput); const sanitizedObservations = sanitizeObservationLines(stripEphemeralAnchorIds(parsed.observations || '')); const reconciledObservations = sourceObservations ? reconcileObservationGroupsFromReflection(sanitizedObservations, sourceObservations) @@ -294,7 +308,10 @@ export function parseReflectorOutput(output: string, sourceObservations?: string return { observations: reconciledObservations ?? sanitizedObservations, - suggestedContinuation: parsed.suggestedResponse || undefined, + suggestedContinuation: + parsed.suggestedResponse || getStringExtractedValue(parsedExtractedValues.values, 'suggested-response'), + extractedValues: parsedExtractedValues.values, + extractionFailures: parsedExtractedValues.failures, // Note: Reflector's currentTask is not used - thread metadata preserves per-thread tasks }; } diff --git a/packages/memory/src/processors/observational-memory/reflector-runner.ts b/packages/memory/src/processors/observational-memory/reflector-runner.ts index e4a8313d8771..746e04dd2b24 100644 --- a/packages/memory/src/processors/observational-memory/reflector-runner.ts +++ b/packages/memory/src/processors/observational-memory/reflector-runner.ts @@ -1,14 +1,28 @@ import { Agent } from '@mastra/core/agent'; import type { MessageList } from '@mastra/core/agent'; import type { Mastra } from '@mastra/core/mastra'; +import { getThreadOMMetadata, setThreadOMMetadata } from '@mastra/core/memory'; +import type { MastraMemory } from '@mastra/core/memory'; import type { ObservabilityContext } from '@mastra/core/observability'; -import type { ProcessorStreamWriter } from '@mastra/core/processors'; +import type { ProcessorContext, ProcessorStreamWriter } from '@mastra/core/processors'; import type { RequestContext } from '@mastra/core/request-context'; import type { MemoryStorage, ObservationalMemoryRecord } from '@mastra/core/storage'; +import type { ProviderMetadata } from '@mastra/core/stream'; +import type { Memory } from '../..'; import { resolveActivationTTL } from './activation-ttl'; import { BufferingCoordinator } from './buffering-coordinator'; import { omDebug, omError } from './debug'; +import { + applyExtractorHooks, + buildThreadMetadataFromExtractedValues, + getBuiltInExtractedValues, + getPriorExtractedValues, + mergeExtractedValues, + mergeExtractionFailures, +} from './extracted-values'; +import { extractStructuredValues } from './extraction-runner'; +import { resolveExtractors } from './extractor'; import { withOmInternalThreadId } from './internal-request-context'; import { createActivationMarker, @@ -31,6 +45,7 @@ import { } from './reflector-agent'; import type { CompressionLevel } from './reflector-agent'; import { withRetry } from './retry'; +import { createTemporaryOmMemoryContext } from './temporary-memory'; import { getMaxThreshold } from './thresholds'; import type { TokenCounter } from './token-counter'; import { withOmTracingSpan } from './tracing'; @@ -45,6 +60,47 @@ import type { ThresholdRange, } from './types'; +async function getThreadExtractedValues( + storage: MemoryStorage, + threadId?: string | null, +): Promise<Record<string, unknown> | undefined> { + if (!threadId) { + return undefined; + } + + const thread = await storage.getThreadById({ threadId }); + return getPriorExtractedValues(getThreadOMMetadata(thread?.metadata)); +} + +async function persistThreadExtractedValues( + storage: MemoryStorage, + threadId: string | undefined, + values: Record<string, unknown> | undefined, +): Promise<void> { + if (!threadId || !values) { + return; + } + + const metadataUpdate = buildThreadMetadataFromExtractedValues(values); + const thread = await storage.getThreadById({ threadId }); + if (!thread) { + return; + } + + const previousOmMetadata = getThreadOMMetadata(thread.metadata); + const newMetadata = setThreadOMMetadata(thread.metadata, { + currentTask: metadataUpdate.currentTask ?? previousOmMetadata?.currentTask, + suggestedResponse: metadataUpdate.suggestedResponse ?? previousOmMetadata?.suggestedResponse, + threadTitle: metadataUpdate.threadTitle ?? previousOmMetadata?.threadTitle, + extracted: { ...(previousOmMetadata?.extracted ?? {}), ...(metadataUpdate.extracted ?? {}) }, + }); + await storage.updateThread({ + id: threadId, + title: thread.title ?? '', + metadata: newMetadata, + }); +} + function formatModelContext(provider?: string, modelId?: string): string | undefined { if (provider && modelId) { return `${provider}/${modelId}`; @@ -147,6 +203,7 @@ export class ReflectorRunner { resourceId?: string, ) => Promise<void>; private readonly getCompressionStartLevel: (requestContext?: RequestContext) => Promise<CompressionLevel>; + private readonly memory?: Memory; private mastra?: Mastra; constructor(opts: { @@ -171,6 +228,7 @@ export class ReflectorRunner { getCompressionStartLevel: (requestContext?: RequestContext) => Promise<CompressionLevel>; resolveModel: ReflectionModelResolver; mastra?: Mastra; + memory?: Memory; }) { this.reflectionConfig = opts.reflectionConfig; this.observationConfig = opts.observationConfig; @@ -184,18 +242,24 @@ export class ReflectorRunner { this.persistMarkerToMessage = opts.persistMarkerToMessage; this.getCompressionStartLevel = opts.getCompressionStartLevel; this.mastra = opts.mastra; + this.memory = opts.memory; } __registerMastra(mastra: Mastra): void { this.mastra = mastra; } - private createAgent(model: ConcreteReflectionModel): Agent { + private createAgent( + model: ConcreteReflectionModel, + memory?: MastraMemory, + extractors = this.reflectionConfig.extractors, + ): Agent { const agent = new Agent({ id: 'observational-memory-reflector', name: 'Reflector', - instructions: buildReflectorSystemPrompt(this.reflectionConfig.instruction), + instructions: buildReflectorSystemPrompt(this.reflectionConfig.instruction, extractors), model, + ...(memory ? { memory } : {}), }); if (this.mastra) { agent.__registerMastra(this.mastra); @@ -250,20 +314,47 @@ export class ReflectorRunner { skipContinuationHints?: boolean, compressionStartLevel?: CompressionLevel, requestContext?: RequestContext, + priorExtractedValues?: Record<string, unknown>, observabilityContext?: ObservabilityContext, model?: ConcreteReflectionModel, + mainAgent?: ProcessorContext['agent'], + sendSignal?: ProcessorContext['sendSignal'], ): Promise<{ observations: string; suggestedContinuation?: string; + extractedValues?: Record<string, unknown>; + extractionFailures?: Array<{ slug: string; error: string }>; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; + providerMetadata?: ProviderMetadata; }> { const originalTokens = this.tokenCounter.countObservations(observations); const resolvedModel = model ? { model } : this.resolveModel(originalTokens); - const agent = this.createAgent(resolvedModel.model); + const activeExtractors = await resolveExtractors( + (skipContinuationHints + ? this.reflectionConfig.extractors?.filter( + extractor => extractor.slug !== 'current-task' && extractor.slug !== 'suggested-response', + ) + : this.reflectionConfig.extractors) ?? [], + { + source: 'reflector', + threadId: streamContext?.threadId, + resourceId: streamContext?.resourceId, + mainAgent, + memory: this.memory, + requestContext, + }, + ); + const structuredExtractors = activeExtractors.filter(extractor => extractor.mode === 'structured'); + const temporaryMemory = + structuredExtractors.length > 0 ? await createTemporaryOmMemoryContext('structured-reflector') : undefined; + const agent = temporaryMemory + ? this.createAgent(resolvedModel.model, temporaryMemory.memory, activeExtractors) + : this.createAgent(resolvedModel.model, undefined, activeExtractors); const internalRequestContext = withOmInternalThreadId(requestContext, agent.id); const targetThreshold = observationTokensThreshold ?? getMaxThreshold(this.reflectionConfig.observationTokens); let totalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; + let finalProviderMetadata: ProviderMetadata | undefined; const startLevel: CompressionLevel = compressionStartLevel ?? 0; let currentLevel: CompressionLevel = startLevel; @@ -276,7 +367,14 @@ export class ReflectorRunner { attemptNumber++; const isRetry = attemptNumber > 1; - const prompt = buildReflectorPrompt(observations, manualPrompt, currentLevel, skipContinuationHints); + const prompt = buildReflectorPrompt( + observations, + manualPrompt, + currentLevel, + skipContinuationHints, + activeExtractors, + priorExtractedValues, + ); omDebug( `[OM:callReflector] ${isRetry ? `retry #${attemptNumber - 1}` : 'first attempt'}: level=${currentLevel}, originalTokens=${originalTokens}, targetThreshold=${targetThreshold}, promptLen=${prompt.length}, skipContinuationHints=${skipContinuationHints}`, ); @@ -311,6 +409,7 @@ export class ReflectorRunner { ...this.reflectionConfig.modelSettings, }, providerOptions: this.reflectionConfig.providerOptions as any, + ...(temporaryMemory ? { memory: temporaryMemory.options } : {}), ...(abortSignal ? { abortSignal } : {}), ...(internalRequestContext ? { requestContext: internalRequestContext } : {}), ...childObservabilityContext, @@ -361,8 +460,9 @@ export class ReflectorRunner { totalUsage.outputTokens += usage.outputTokens ?? 0; totalUsage.totalTokens += usage.totalTokens ?? 0; } + finalProviderMetadata = result.providerMetadata ?? finalProviderMetadata; - parsed = parseReflectorOutput(result.text, observations); + parsed = parseReflectorOutput(result.text, observations, activeExtractors); if (parsed.degenerate) { omDebug( @@ -421,10 +521,42 @@ export class ReflectorRunner { currentLevel = Math.min(currentLevel + 1, maxLevel) as CompressionLevel; } + const structuredExtraction = await extractStructuredValues({ + agent, + source: 'reflector', + extractors: activeExtractors, + memory: temporaryMemory?.options, + priorExtractedValues, + requestContext, + observabilityContext, + abortSignal, + }); + const parsedExtractedValues = mergeExtractedValues(parsed.extractedValues, structuredExtraction.values); + const parsedExtractionFailures = mergeExtractionFailures(parsed.extractionFailures, structuredExtraction.failures); + const hookedValues = await applyExtractorHooks({ + source: 'reflector', + extractors: activeExtractors, + values: parsedExtractedValues, + failures: parsedExtractionFailures, + previousValues: priorExtractedValues, + threadId: streamContext?.threadId ?? 'unknown', + resourceId: streamContext?.resourceId, + mainAgent, + memory: this.memory, + sendSignal, + requestContext, + }); + const extractedValues = hookedValues.values; + const extractionFailures = hookedValues.failures; + const builtIns = getBuiltInExtractedValues(extractedValues); + return { observations: parsed.observations, - suggestedContinuation: parsed.suggestedContinuation, + suggestedContinuation: builtIns.suggestedContinuation ?? parsed.suggestedContinuation, + extractedValues, + extractionFailures, usage: totalUsage.totalTokens > 0 ? totalUsage : undefined, + providerMetadata: finalProviderMetadata, }; } @@ -439,6 +571,9 @@ export class ReflectorRunner { requestContext?: RequestContext, observabilityContext?: ObservabilityContext, reflectionHooks?: Pick<ObserveHooks, 'onReflectionStart' | 'onReflectionEnd'>, + priorExtractedValues?: Record<string, unknown>, + mainAgent?: ProcessorContext['agent'], + sendSignal?: ProcessorContext['sendSignal'], ): void { const bufferKey = this.buffering.getReflectionBufferKey(lockKey); @@ -454,9 +589,21 @@ export class ReflectorRunner { }); reflectionHooks?.onReflectionStart?.(); - const asyncOp = this.doAsyncBufferedReflection(record, bufferKey, writer, requestContext, observabilityContext) - .then(usage => { - reflectionHooks?.onReflectionEnd?.({ usage }); + const asyncOp = this.doAsyncBufferedReflection( + record, + bufferKey, + writer, + requestContext, + observabilityContext, + priorExtractedValues, + mainAgent, + sendSignal, + ) + .then(outcome => { + reflectionHooks?.onReflectionEnd?.({ + usage: outcome?.usage, + ...(outcome?.providerMetadata ? { providerMetadata: outcome.providerMetadata } : {}), + }); }) .catch(async error => { if (writer) { @@ -503,7 +650,16 @@ export class ReflectorRunner { writer?: ProcessorStreamWriter, requestContext?: RequestContext, observabilityContext?: ObservabilityContext, - ): Promise<{ inputTokens?: number; outputTokens?: number; totalTokens?: number } | undefined> { + priorExtractedValues?: Record<string, unknown>, + mainAgent?: ProcessorContext['agent'], + sendSignal?: ProcessorContext['sendSignal'], + ): Promise< + | { + usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; + providerMetadata?: ProviderMetadata; + } + | undefined + > { const freshRecord = await this.storage.getObservationalMemory(record.threadId, record.resourceId); const currentRecord = freshRecord ?? record; const observationTokens = currentRecord.observationTokenCount ?? 0; @@ -565,7 +721,17 @@ export class ReflectorRunner { true, compressionStartLevel, requestContext, + priorExtractedValues, observabilityContext, + undefined, + mainAgent, + sendSignal, + ); + + await persistThreadExtractedValues( + this.storage, + currentRecord.threadId ?? undefined, + reflectResult.extractedValues, ); const reflectionTokenCount = this.tokenCounter.countObservations(reflectResult.observations); @@ -594,13 +760,15 @@ export class ReflectorRunner { recordId: currentRecord.id, threadId: currentRecord.threadId ?? '', observations: reflectResult.observations, + extractedValues: reflectResult.extractedValues, + extractionFailures: reflectResult.extractionFailures, }); // Stream OM lifecycle markers as transient so the OutputWriter does not persist standalone data-only messages; OM persists the durable marker explicitly. void writer.custom({ ...endMarker, transient: true }).catch(() => {}); await this.persistMarkerToStorage(endMarker, currentRecord.threadId ?? '', currentRecord.resourceId ?? undefined); } - return reflectResult.usage; + return { usage: reflectResult.usage, providerMetadata: reflectResult.providerMetadata }; } /** @@ -783,6 +951,8 @@ export class ReflectorRunner { threadId?: string; writer?: ProcessorStreamWriter; abortSignal?: AbortSignal; + mainAgent?: ProcessorContext['agent']; + sendSignal?: ProcessorContext['sendSignal']; messageList?: MessageList; currentModel?: ObservationModelContext; reflectionHooks?: Pick<ObserveHooks, 'onReflectionStart' | 'onReflectionEnd'>; @@ -795,6 +965,8 @@ export class ReflectorRunner { observationTokens, writer, abortSignal, + mainAgent, + sendSignal, messageList, currentModel, reflectionHooks, @@ -805,6 +977,7 @@ export class ReflectorRunner { } = opts; const lockKey = this.buffering.getLockKey(record.threadId, record.resourceId); const reflectThreshold = getMaxThreshold(this.getEffectiveReflectionTokens(record)); + const priorExtractedValues = await getThreadExtractedValues(this.storage, requestedThreadId ?? record.threadId); // ════════════════════════════════════════════════════════════════════════ // ASYNC BUFFERING: Trigger background reflection at bufferActivation ratio @@ -833,6 +1006,9 @@ export class ReflectorRunner { requestContext, observabilityContext, reflectionHooks, + priorExtractedValues, + mainAgent, + sendSignal, ); } } @@ -927,6 +1103,9 @@ export class ReflectorRunner { requestContext, observabilityContext, reflectionHooks, + priorExtractedValues, + mainAgent, + sendSignal, ); return; } @@ -979,6 +1158,7 @@ export class ReflectorRunner { : undefined; let reflectionUsage: ObserveHookUsage | undefined; + let reflectionProviderMetadata: ProviderMetadata | undefined; let reflectionError: Error | undefined; try { const compressionStartLevel = await this.getCompressionStartLevel(requestContext); @@ -991,9 +1171,15 @@ export class ReflectorRunner { undefined, compressionStartLevel, requestContext, + priorExtractedValues, observabilityContext, + undefined, + mainAgent, + sendSignal, ); reflectionUsage = reflectResult.usage; + reflectionProviderMetadata = reflectResult.providerMetadata; + await persistThreadExtractedValues(this.storage, record.threadId ?? undefined, reflectResult.extractedValues); const reflectionTokenCount = this.tokenCounter.countObservations(reflectResult.observations); await this.storage.createReflectionGeneration({ @@ -1010,6 +1196,8 @@ export class ReflectorRunner { tokensObserved: observationTokens, observationTokens: reflectionTokenCount, observations: reflectResult.observations, + extractedValues: reflectResult.extractedValues, + extractionFailures: reflectResult.extractionFailures, recordId: record.id, threadId, }); @@ -1050,7 +1238,11 @@ export class ReflectorRunner { omError('[OM] Reflection failed', error); } finally { await this.storage.setReflectingFlag(record.id, false); - reflectionHooks?.onReflectionEnd?.({ usage: reflectionUsage, error: reflectionError }); + reflectionHooks?.onReflectionEnd?.({ + usage: reflectionUsage, + error: reflectionError, + ...(reflectionProviderMetadata ? { providerMetadata: reflectionProviderMetadata } : {}), + }); unregisterOp(record.id, 'reflecting'); } } diff --git a/packages/memory/src/processors/observational-memory/temporary-memory.ts b/packages/memory/src/processors/observational-memory/temporary-memory.ts new file mode 100644 index 000000000000..2519b4b7efbe --- /dev/null +++ b/packages/memory/src/processors/observational-memory/temporary-memory.ts @@ -0,0 +1,32 @@ +import { randomUUID } from 'node:crypto'; + +import type { AgentMemoryOption } from '@mastra/core/agent'; +import type { MastraMemory } from '@mastra/core/memory'; +import { InMemoryStore } from '@mastra/core/storage'; +import { Memory } from '../../index'; + +export interface TemporaryOmMemoryContext { + memory: MastraMemory; + options: AgentMemoryOption; +} + +export function createTemporaryOmMemoryContext(prefix: string): TemporaryOmMemoryContext { + const threadId = `${prefix}-${randomUUID()}`; + const resourceId = prefix; + const options: AgentMemoryOption = { + thread: threadId, + resource: resourceId, + options: { + lastMessages: 10, + generateTitle: false, + }, + }; + + return { + memory: new Memory({ + storage: new InMemoryStore(), + options: options.options, + }), + options, + }; +} diff --git a/packages/memory/src/processors/observational-memory/types.ts b/packages/memory/src/processors/observational-memory/types.ts index 4b61e6764009..3e26fd09d3ad 100644 --- a/packages/memory/src/processors/observational-memory/types.ts +++ b/packages/memory/src/processors/observational-memory/types.ts @@ -2,6 +2,9 @@ import type { AgentConfig } from '@mastra/core/agent'; import type { Mastra } from '@mastra/core/mastra'; import type { ObservationalMemoryModelSettings } from '@mastra/core/memory'; import type { MemoryStorage } from '@mastra/core/storage'; +import type { ProviderMetadata } from '@mastra/core/stream'; +import type { Memory } from '../..'; +import type { Extractor } from './extractor'; import type { ModelByInputTokens } from './model-by-input-tokens'; /** @@ -194,6 +197,22 @@ export interface ObservationConfig { */ instruction?: string; + /** + * Manage working memory through Observational Memory extraction. + * When enabled alongside `workingMemory.enabled`, Memory supplies defaults that + * disable main-agent working memory management and add the WorkingMemoryExtractor. + * Set `workingMemory.agentManaged: true` to keep main-agent tools/instructions enabled. + * + * @default false + */ + manageWorkingMemory?: boolean; + + /** + * Additional values to extract from observer output. Built-in OM fields are registered automatically. + * @experimental Extractors are experimental and may change in a future release. + */ + extract?: Extractor<any>[]; + /** * Whether the Observer should suggest thread titles. * When enabled, the Observer will analyze conversation context and @@ -314,6 +333,12 @@ export interface ReflectionConfig { * Use this to customize reflection behavior for specific use cases. */ instruction?: string; + + /** + * Additional values to extract from reflector output. Built-in OM fields are registered automatically. + * @experimental Extractors are experimental and may change in a future release. + */ + extract?: Extractor<any>[]; } /** @@ -325,6 +350,12 @@ export interface ObserverResult { /** Suggested continuation for the Actor */ suggestedContinuation?: string; + + /** Extracted values keyed by extractor slug */ + extractedValues?: Record<string, unknown>; + + /** Extractor failures keyed by extractor slug */ + extractionFailures?: Array<{ slug: string; error: string }>; } /** @@ -339,6 +370,12 @@ export interface ReflectorResult { /** True if the output was detected as degenerate (repetition loop) and should be discarded/retried */ degenerate?: boolean; + + /** Extracted values keyed by extractor slug */ + extractedValues?: Record<string, unknown>; + + /** Extractor failures keyed by extractor slug */ + extractionFailures?: Array<{ slug: string; error: string }>; } /** @@ -430,6 +467,12 @@ export interface DataOmObservationEndPart { /** Suggested response extracted by the Observer */ suggestedResponse?: string; + /** Extracted values keyed by extractor slug */ + extractedValues?: Record<string, unknown>; + + /** Extractor failures keyed by extractor slug */ + extractionFailures?: Array<{ slug: string; error: string }>; + /** The OM record ID */ recordId: string; @@ -609,6 +652,12 @@ export interface DataOmBufferingEndPart { /** The buffered observations/reflection content (for UI expansion) */ observations?: string; + + /** Extracted values keyed by extractor slug */ + extractedValues?: Record<string, unknown>; + + /** Extractor failures keyed by extractor slug */ + extractionFailures?: Array<{ slug: string; error: string }>; }; } @@ -841,6 +890,9 @@ export interface ObservationalMemoryConfig { */ storage: MemoryStorage; + /** Active Memory instance, when Observational Memory is created by Memory. */ + memory?: Memory; + /** * Enable retrieval-mode observation group metadata. * When true, observation groups are treated as durable pointers to raw @@ -982,6 +1034,8 @@ export interface ResolvedObservationConfig { threadTitle?: boolean; /** Filter for attachment parts forwarded to the Observer model */ observeAttachments: 'auto' | boolean | string[]; + /** Resolved observer extractors, including enabled built-ins and user extractors */ + extractors: Extractor<any>[]; } export interface ResolvedReflectionConfig { @@ -1003,6 +1057,8 @@ export interface ResolvedReflectionConfig { blockAfter?: number; /** Custom instructions to append to the Reflector's system prompt */ instruction?: string; + /** Resolved reflector extractors, including enabled built-ins and user extractors */ + extractors: Extractor<any>[]; } export interface ObserveHookUsage { @@ -1013,7 +1069,22 @@ export interface ObserveHookUsage { export interface ObserveHooks { onObservationStart?: () => void; - onObservationEnd?: (result: { usage?: ObserveHookUsage; error?: Error }) => void; + /** + * Fires when an observation cycle ends. `providerMetadata` carries the OM + * observer model call's full provider metadata (e.g. AI Gateway cost and + * generation id under `providerMetadata.gateway`); it is undefined when the + * provider emits none. For batched resource-scoped observations it reflects + * the last batch that emitted provider metadata (per-call values are not + * summed/merged). + */ + onObservationEnd?: (result: { usage?: ObserveHookUsage; error?: Error; providerMetadata?: ProviderMetadata }) => void; onReflectionStart?: () => void; - onReflectionEnd?: (result: { usage?: ObserveHookUsage; error?: Error }) => void; + /** + * Fires when a reflection cycle ends. `providerMetadata` carries the OM + * reflector model call's full provider metadata; it is undefined when the + * provider emits none. Across retry attempts `usage` is summed but + * `providerMetadata` reflects the last attempt that emitted it (per-call + * values are not merged). + */ + onReflectionEnd?: (result: { usage?: ObserveHookUsage; error?: Error; providerMetadata?: ProviderMetadata }) => void; } diff --git a/packages/memory/src/processors/observational-memory/working-memory-extractor.ts b/packages/memory/src/processors/observational-memory/working-memory-extractor.ts new file mode 100644 index 000000000000..151b0720f2ff --- /dev/null +++ b/packages/memory/src/processors/observational-memory/working-memory-extractor.ts @@ -0,0 +1,96 @@ +import { parseMemoryRequestContext } from '@mastra/core/memory'; +import { z } from 'zod'; + +import { Extractor } from './extractor'; +import type { ExtractorRuntimeContext } from './extractor'; + +async function getWorkingMemoryDetails(context: ExtractorRuntimeContext): Promise<{ + template?: string; + current?: string | null; + usesSchema: boolean; +}> { + const memory = context.memory!; + const memoryConfig = parseMemoryRequestContext(context.requestContext)?.memoryConfig; + const config = memory.getMergedThreadConfig(memoryConfig ?? {}); + const workingMemory = config.workingMemory; + if (!workingMemory?.enabled) { + return { usesSchema: false }; + } + + const [template, current] = await Promise.all([ + memory.getWorkingMemoryTemplate({ memoryConfig }), + context.threadId + ? memory.getWorkingMemory({ + threadId: context.threadId, + resourceId: context.resourceId, + memoryConfig, + }) + : Promise.resolve(null), + ]); + + return { + template: typeof template?.content === 'string' ? template.content : JSON.stringify(template?.content), + current, + usesSchema: Boolean(workingMemory.schema), + }; +} + +function buildWorkingMemoryInstructions(details: Awaited<ReturnType<typeof getWorkingMemoryDetails>>): string { + if (details.usesSchema) { + return [ + 'Update working memory with durable facts from the observations you made.', + 'Return the full updated JSON object when working memory should change.', + 'Return null when no working memory update is needed.', + details.template ? `Working memory JSON schema:\n${details.template}` : undefined, + details.current ? `Current working memory JSON:\n${details.current}` : undefined, + ] + .filter(Boolean) + .join('\n\n'); + } + + return [ + 'Update working memory with durable facts from the observations you made.', + 'Return the full updated Markdown working memory. Preserve useful existing content and add or revise only what changed.', + details.template ? `Working memory template:\n${details.template}` : undefined, + details.current ? `Current working memory:\n${details.current}` : undefined, + ] + .filter(Boolean) + .join('\n\n'); +} + +export class WorkingMemoryExtractor extends Extractor<string | Record<string, unknown> | null> { + constructor() { + super({ + name: 'Working Memory', + includePreviousExtraction: false, + instructions: async context => buildWorkingMemoryInstructions(await getWorkingMemoryDetails(context)), + schema: async context => { + const details = await getWorkingMemoryDetails(context); + return details.usesSchema ? z.union([z.record(z.string(), z.unknown()), z.null()]) : undefined; + }, + onExtracted: async ({ current, memory, threadId, resourceId, requestContext }) => { + const memoryConfig = parseMemoryRequestContext(requestContext)?.memoryConfig; + const config = memory!.getMergedThreadConfig(memoryConfig ?? {}); + const isSchemaWorkingMemory = Boolean(config.workingMemory?.schema); + + if (isSchemaWorkingMemory && current === null) { + return undefined; + } + + const workingMemory = typeof current === 'string' ? current : (JSON.stringify(current) ?? ''); + if (!workingMemory.trim()) { + return undefined; + } + + await memory!.updateWorkingMemory({ + threadId, + resourceId, + workingMemory, + memoryConfig, + }); + + return current; + }, + }); + } +} diff --git a/packages/playground-ui/CHANGELOG.md b/packages/playground-ui/CHANGELOG.md index 6edf4d95a756..00c4ac99d7af 100644 --- a/packages/playground-ui/CHANGELOG.md +++ b/packages/playground-ui/CHANGELOG.md @@ -1,5 +1,80 @@ # @mastra/playground-ui +## 38.0.0-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + - @mastra/client-js@1.29.0-alpha.9 + - @mastra/react@1.2.1-alpha.9 + +## 38.0.0-alpha.8 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + - @mastra/client-js@1.29.0-alpha.8 + - @mastra/react@1.2.1-alpha.8 + +## 38.0.0-alpha.7 + +### Patch Changes + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + - @mastra/client-js@1.29.0-alpha.7 + - @mastra/react@1.2.1-alpha.7 + +## 38.0.0-alpha.6 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`6a4a466`](https://github.com/mastra-ai/mastra/commit/6a4a466495279c2add2b0fd0afe6989fe7ae352a), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/client-js@1.29.0-alpha.6 + - @mastra/react@1.2.1-alpha.6 + - @mastra/memory@1.21.3-alpha.2 + +## 38.0.0-alpha.5 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`c607ece`](https://github.com/mastra-ai/mastra/commit/c607eceeda028a80b24d00ee7dae376db73df526), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/client-js@1.29.0-alpha.5 + - @mastra/memory@1.21.3-alpha.1 + - @mastra/react@1.2.1-alpha.5 + +## 37.1.0-alpha.4 + +### Minor Changes + +- Added public subpath entrypoints for shared Playground UI domain components, hooks, resize helpers, primitives, and the playground store. Applications can now import focused APIs such as `TracesLayout` and `usePlaygroundStore` directly from those subpaths. ([#18511](https://github.com/mastra-ai/mastra/pull/18511)) + + ```ts + import { TracesLayout } from '@mastra/playground-ui/domains/traces/components/traces-layout'; + import { usePlaygroundStore } from '@mastra/playground-ui/store/playground-store'; + ``` + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + - @mastra/client-js@1.28.1-alpha.4 + - @mastra/react@1.2.1-alpha.4 + +## 37.0.1-alpha.3 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/client-js@1.28.1-alpha.3 + - @mastra/memory@1.21.3-alpha.0 + - @mastra/react@1.2.1-alpha.3 + ## 37.0.1-alpha.2 ### Patch Changes diff --git a/packages/playground-ui/package.json b/packages/playground-ui/package.json index 8f96777a7c9d..c6e1a4993d7f 100644 --- a/packages/playground-ui/package.json +++ b/packages/playground-ui/package.json @@ -1,7 +1,7 @@ { "name": "@mastra/playground-ui", "type": "module", - "version": "37.0.1-alpha.2", + "version": "38.0.0-alpha.9", "description": "Mastra Playground components", "main": "dist/index.umd.js", "module": "dist/index.es.js", @@ -53,6 +53,56 @@ "default": "./dist/utils/*.cjs.js" } }, + "./domains/*": { + "import": { + "types": "./dist/domains/*.d.ts", + "default": "./dist/domains/*.es.js" + }, + "require": { + "types": "./dist/domains/*.d.ts", + "default": "./dist/domains/*.cjs.js" + } + }, + "./ee/*": { + "import": { + "types": "./dist/ee/*.d.ts", + "default": "./dist/ee/*.es.js" + }, + "require": { + "types": "./dist/ee/*.d.ts", + "default": "./dist/ee/*.cjs.js" + } + }, + "./primitives/*": { + "import": { + "types": "./dist/primitives/*.d.ts", + "default": "./dist/primitives/*.es.js" + }, + "require": { + "types": "./dist/primitives/*.d.ts", + "default": "./dist/primitives/*.cjs.js" + } + }, + "./resize/*": { + "import": { + "types": "./dist/resize/*.d.ts", + "default": "./dist/resize/*.es.js" + }, + "require": { + "types": "./dist/resize/*.d.ts", + "default": "./dist/resize/*.cjs.js" + } + }, + "./store/*": { + "import": { + "types": "./dist/store/*.d.ts", + "default": "./dist/store/*.es.js" + }, + "require": { + "types": "./dist/store/*.d.ts", + "default": "./dist/store/*.cjs.js" + } + }, "./hooks/*": { "import": { "types": "./dist/hooks/*.d.ts", diff --git a/packages/playground-ui/src/domains/traces/index.ts b/packages/playground-ui/src/domains/traces/index.ts index 1e8dc865a1a9..92587f11cdad 100644 --- a/packages/playground-ui/src/domains/traces/index.ts +++ b/packages/playground-ui/src/domains/traces/index.ts @@ -2,8 +2,5 @@ export * from './components'; export * from './hooks'; export * from './utils'; export * from './trace-filters'; -export type { UISpan, UISpanStyle, TraceDatePreset, EntityOptions } from './types'; +export type { UISpan, UISpanStyle, TraceDatePreset, EntityOptions, SpanTab } from './types'; export { CONTEXT_FIELD_IDS } from './types'; - -/** Tab identifier for SpanDataPanelView. */ -export type SpanTab = 'details' | 'scoring' | 'feedback'; diff --git a/packages/playground-ui/src/domains/traces/types.ts b/packages/playground-ui/src/domains/traces/types.ts index 7c9fda436974..bcee60ce9c4b 100644 --- a/packages/playground-ui/src/domains/traces/types.ts +++ b/packages/playground-ui/src/domains/traces/types.ts @@ -28,6 +28,9 @@ export type EntityOptions = export type TraceDatePreset = 'all' | 'last-24h' | 'last-3d' | 'last-7d' | 'last-14d' | 'last-30d' | 'custom'; +/** Tab identifier for SpanDataPanelView. */ +export type SpanTab = 'details' | 'scoring' | 'feedback'; + /** Canonical list of context field IDs used for trace filtering and value extraction */ export const CONTEXT_FIELD_IDS = [ 'environment', diff --git a/packages/playground-ui/vite.config.ts b/packages/playground-ui/vite.config.ts index 1e8da898e75f..21a14ffe7209 100644 --- a/packages/playground-ui/vite.config.ts +++ b/packages/playground-ui/vite.config.ts @@ -1,5 +1,5 @@ import { existsSync, readdirSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { relative, resolve } from 'node:path'; import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import nodeExternals from 'rollup-plugin-node-externals'; @@ -56,6 +56,53 @@ const utilityEntries = Object.fromEntries( }), ); +const createPublicFileEntries = (sourceDir: string, entryPrefix: string) => { + const entries: Array<readonly [string, string]> = []; + + const walk = (currentDir: string) => { + readdirSync(currentDir, { withFileTypes: true }).forEach(dirent => { + if (dirent.isDirectory()) { + if (dirent.name === '__tests__') return; + walk(resolve(currentDir, dirent.name)); + return; + } + + if (!dirent.isFile()) return; + + const fileName = dirent.name; + if ( + !/\.(ts|tsx)$/.test(fileName) || + fileName.endsWith('.test.ts') || + fileName.endsWith('.test.tsx') || + fileName.endsWith('.stories.ts') || + fileName.endsWith('.stories.tsx') + ) { + return; + } + + const file = resolve(currentDir, fileName); + const entryName = relative(sourceDir, file) + .replace(/\\/g, '/') + .replace(/\.(ts|tsx)$/, '') + .replace(/(?:^|\/)index$/, ''); + + if (!entryName) return; + + entries.push([`${entryPrefix}/${entryName}`, file] as const); + }); + }; + + walk(sourceDir); + + return Object.fromEntries(entries); +}; + +const domainEntries = createPublicFileEntries(resolve(__dirname, 'src/domains'), 'domains'); +const eeEntries = createPublicFileEntries(resolve(__dirname, 'src/ee'), 'ee'); +const primitiveEntries = createPublicFileEntries(resolve(__dirname, 'src/ds/primitives'), 'primitives'); +const resizeEntries = createPublicFileEntries(resolve(__dirname, 'src/lib/resize'), 'resize'); +const storeEntries = createPublicFileEntries(resolve(__dirname, 'src/store'), 'store'); + // Public icon subpath entries, exposed as // `@mastra/playground-ui/icons/<IconName>` via the `./icons/*` package export. const iconsDir = resolve(__dirname, 'src/ds/icons'); @@ -114,6 +161,11 @@ const libConfig: UserConfig = { tokens: resolve(__dirname, 'src/ds/tokens/index.ts'), // Slashed keys make Rollup emit nested output: dist/components/<Name>.<format>.js ...utilityEntries, + ...domainEntries, + ...eeEntries, + ...primitiveEntries, + ...resizeEntries, + ...storeEntries, ...iconEntries, ...componentEntries, ...hookEntries, @@ -131,7 +183,7 @@ const libConfig: UserConfig = { rollupOptions: { external: ['motion/react'], output: { - // With ~98 entries, hoisted transitive imports would bloat every entry + // With ~300 entries, hoisted transitive imports would bloat every entry // chunk with empty side-effect imports of shared chunks. hoistTransitiveImports: false, // Pin the global Tailwind stylesheet to a chunk named `index` so its diff --git a/packages/playground/AGENTS.md b/packages/playground/AGENTS.md index 09c0197e1b57..008ea7963f48 100644 --- a/packages/playground/AGENTS.md +++ b/packages/playground/AGENTS.md @@ -12,13 +12,17 @@ Vitest + MSW + typed @mastra/client-js fixtures is the primary test strategy Test-first (TDD): RED failing MSW test → GREEN minimum code → REFACTOR. -BDD-style, lint-enforced via `no-restricted-syntax` in `eslint.config.js`; MSW -runs with `onUnhandledRequest: 'error'`: +BDD-style, lint-enforced in `eslint.config.js`; MSW runs with +`onUnhandledRequest: 'error'`: - Outer `describe` = the unit. - Inner `describe('when …')` = ONE precondition via a real MSW fixture. - Each `it` = ONE outcome. +The SAME BDD shape is required for both MSW tests (`src/**`) and Playwright E2E +specs (`e2e/tests/**`). E2E uses `e2e-bdd/test-needs-when-describe`; see the +`e2e-tests-studio` skill. + Fixtures: nearby `__tests__/fixtures/`, typed with @mastra/client-js response types (no inline types, no `as any`). MSW is wired in `vitest.setup.ts`. diff --git a/packages/playground/CHANGELOG.md b/packages/playground/CHANGELOG.md index 65a86fc5b269..631db6826d09 100644 --- a/packages/playground/CHANGELOG.md +++ b/packages/playground/CHANGELOG.md @@ -1,5 +1,80 @@ # @internal/playground +## 1.17.0-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + - @mastra/client-js@1.29.0-alpha.9 + - @mastra/react@1.2.1-alpha.9 + - @mastra/playground-ui@38.0.0-alpha.9 + +## 1.17.0-alpha.8 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + - @mastra/client-js@1.29.0-alpha.8 + - @mastra/react@1.2.1-alpha.8 + - @mastra/playground-ui@38.0.0-alpha.8 + +## 1.17.0-alpha.7 + +### Patch Changes + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + - @mastra/client-js@1.29.0-alpha.7 + - @mastra/react@1.2.1-alpha.7 + - @mastra/playground-ui@38.0.0-alpha.7 + +## 1.16.1-alpha.6 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`6a4a466`](https://github.com/mastra-ai/mastra/commit/6a4a466495279c2add2b0fd0afe6989fe7ae352a), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`d0702ee`](https://github.com/mastra-ai/mastra/commit/d0702eedc1594cb2d0d83476440cfc2ec8820adb), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/client-js@1.29.0-alpha.6 + - @mastra/schema-compat@1.3.2-alpha.1 + - @mastra/react@1.2.1-alpha.6 + - @mastra/playground-ui@38.0.0-alpha.6 + +## 1.16.1-alpha.5 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/client-js@1.29.0-alpha.5 + - @mastra/react@1.2.1-alpha.5 + - @mastra/playground-ui@38.0.0-alpha.5 + +## 1.16.1-alpha.4 + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`ee7b47a`](https://github.com/mastra-ai/mastra/commit/ee7b47a0331ad78e2297f75617cca56e37095b69), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + - @mastra/playground-ui@37.1.0-alpha.4 + - @mastra/client-js@1.28.1-alpha.4 + - @mastra/react@1.2.1-alpha.4 + +## 1.16.1-alpha.3 + +### Patch Changes + +- Fixed Studio Metrics tab not rendering for PostgresStoreVNext users. The dashboard now appears when the observability store is Postgres v-next, with an advisory banner recommending time-range filters for best performance. ([#18598](https://github.com/mastra-ai/mastra/pull/18598)) + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`9feeaa0`](https://github.com/mastra-ai/mastra/commit/9feeaa0f9a1af07039e5b4f22b932b0cb18617e8), [`c9530b7`](https://github.com/mastra-ai/mastra/commit/c9530b760fa04d835967e24288247284889880b2), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/schema-compat@1.3.2-alpha.0 + - @mastra/ai-sdk@1.6.1-alpha.0 + - @mastra/client-js@1.28.1-alpha.3 + - @mastra/react@1.2.1-alpha.3 + - @mastra/playground-ui@37.0.1-alpha.3 + ## 1.16.1-alpha.2 ### Patch Changes diff --git a/packages/playground/e2e/kitchen-sink/.gitignore b/packages/playground/e2e/kitchen-sink/.gitignore new file mode 100644 index 000000000000..98a9e3cb1141 --- /dev/null +++ b/packages/playground/e2e/kitchen-sink/.gitignore @@ -0,0 +1,3 @@ +# Generated test-storage SQLite DB and editor artifacts created when running the +# live Playwright E2E suite (`pnpm test:e2e`). These are build outputs, not source. +src/mastra/public/ diff --git a/packages/playground/e2e/tests/agent-builder/deterministic-builder.spec.ts b/packages/playground/e2e/tests/agent-builder/deterministic-builder.spec.ts index 45123739a9ff..3859e0d61a18 100644 --- a/packages/playground/e2e/tests/agent-builder/deterministic-builder.spec.ts +++ b/packages/playground/e2e/tests/agent-builder/deterministic-builder.spec.ts @@ -1,6 +1,8 @@ -import { expect, test, type Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; import { resetStorage } from '../__utils__/reset-storage'; -import { selectFixture, type Fixtures } from '../__utils__/select-fixture'; +import { selectFixture } from '../__utils__/select-fixture'; +import type { Fixtures } from '../__utils__/select-fixture'; const starterCases: Array<{ title: string; @@ -24,30 +26,34 @@ test.describe('Agent Builder deterministic flow', () => { }); for (const starter of starterCases) { - test(`builds ${starter.title} from starter with deterministic tool calls`, async ({ page }) => { - await selectFixture(page, starter.fixture); - await page.goto('/agent-builder/agents/create'); + test.describe(`when the ${starter.title} starter is selected`, () => { + test(`builds ${starter.title} from starter with deterministic tool calls`, async ({ page }) => { + await selectFixture(page, starter.fixture); + await page.goto('/agent-builder/agents/create'); - await page.getByRole('button', { name: new RegExp(starter.title, 'i') }).click(); - await expect(page.getByTestId('agent-builder-starter-input')).not.toHaveValue(''); - await expect(page.getByTestId('agent-builder-starter-submit')).toBeEnabled(); - await page.getByTestId('agent-builder-starter-submit').click(); - await page.waitForURL(/\/agent-builder\/agents\/[^/]+\/edit/); + await page.getByRole('button', { name: new RegExp(starter.title, 'i') }).click(); + await expect(page.getByTestId('agent-builder-starter-input')).not.toHaveValue(''); + await expect(page.getByTestId('agent-builder-starter-submit')).toBeEnabled(); + await page.getByTestId('agent-builder-starter-submit').click(); + await page.waitForURL(/\/agent-builder\/agents\/[^/]+\/edit/); - await assertBuilderOutput(page, starter.expectedName); + await assertBuilderOutput(page, starter.expectedName); + }); }); } - test('builds a complex freeform prompt with deterministic tool calls', async ({ page }) => { - await selectFixture(page, 'agent-builder-complex'); - await page.goto('/agent-builder/agents/create'); + test.describe('when a complex freeform prompt is submitted', () => { + test('builds a complex freeform prompt with deterministic tool calls', async ({ page }) => { + await selectFixture(page, 'agent-builder-complex'); + await page.goto('/agent-builder/agents/create'); - await page.getByTestId('agent-builder-starter-input').fill(complexPrompt); - await expect(page.getByTestId('agent-builder-starter-submit')).toBeEnabled(); - await page.getByTestId('agent-builder-starter-submit').click(); - await page.waitForURL(/\/agent-builder\/agents\/[^/]+\/edit/); + await page.getByTestId('agent-builder-starter-input').fill(complexPrompt); + await expect(page.getByTestId('agent-builder-starter-submit')).toBeEnabled(); + await page.getByTestId('agent-builder-starter-submit').click(); + await page.waitForURL(/\/agent-builder\/agents\/[^/]+\/edit/); - await assertBuilderOutput(page, 'Vuln Triage Sentinel'); + await assertBuilderOutput(page, 'Vuln Triage Sentinel'); + }); }); }); diff --git a/packages/playground/e2e/tests/agents/$agentId/browser-stream.spec.ts b/packages/playground/e2e/tests/agents/$agentId/browser-stream.spec.ts index cd4bfd824513..1f6d97c920ad 100644 --- a/packages/playground/e2e/tests/agents/$agentId/browser-stream.spec.ts +++ b/packages/playground/e2e/tests/agents/$agentId/browser-stream.spec.ts @@ -1,4 +1,5 @@ -import { test, expect, type Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; import { resetStorage } from '../../__utils__/reset-storage'; /** @@ -32,71 +33,77 @@ function observeBrowserTraffic(page: Page): ObservedTraffic { return observed; } -test.afterEach(async () => { - await resetStorage(); -}); - -test('agent without browser tools: no WebSocket and no session probe', async ({ page }) => { - const observed = observeBrowserTraffic(page); +test.describe('Browser stream WebSocket gating', () => { + test.afterEach(async () => { + await resetStorage(); + }); - await page.goto('/agents/weather-agent/chat/1234'); + test.describe('when the agent has no browser tools', () => { + test('opens no WebSocket and issues no session probe', async ({ page }) => { + const observed = observeBrowserTraffic(page); - // Wait for the agent page to settle. - await expect(page.locator('h2:has-text("Weather Agent")')).toBeVisible(); - await expect(page.locator('a:has-text("New Chat")')).toBeVisible(); + await page.goto('/agents/weather-agent/chat/1234'); - // Negative assertion: poll for any browser traffic and fail fast if it appears. - // The poll resolves at the timeout with the final count, which we expect to be 0. - await expect - .poll(() => observed.wsUrls.length + observed.probeUrls.length, { - message: 'no browser traffic should occur for agents without browser tools', - timeout: 2000, - }) - .toBe(0); + // Wait for the agent page to settle. + await expect(page.locator('h2:has-text("Weather Agent")')).toBeVisible(); + await expect(page.locator('a:has-text("New Chat")')).toBeVisible(); - const browserStreamWs = observed.wsUrls.filter(url => /\/browser\/[^/]+\/stream/.test(url)); - expect(browserStreamWs, 'no browser-stream WebSocket should be opened').toEqual([]); - expect(observed.probeUrls, 'no /browser/session probe should be issued').toEqual([]); -}); + // Negative assertion: poll for any browser traffic and fail fast if it appears. + // The poll resolves at the timeout with the final count, which we expect to be 0. + await expect + .poll(() => observed.wsUrls.length + observed.probeUrls.length, { + message: 'no browser traffic should occur for agents without browser tools', + timeout: 2000, + }) + .toBe(0); -test('agent with browser tools: session probe is issued', async ({ page }) => { - // Override the agent details response so the client believes weather-agent - // has browser tools. We don't need a real browser implementation — only the - // client-side gate is exercised here; the probe response is also stubbed so - // the server doesn't 404 on a real call. - await page.route('**/api/agents/weather-agent*', async route => { - if (route.request().method() !== 'GET') return route.fallback(); - const response = await route.fetch(); - const body = await response.json(); - await route.fulfill({ - response, - json: { ...body, browserTools: ['browser_goto', 'browser_snapshot'] }, + const browserStreamWs = observed.wsUrls.filter(url => /\/browser\/[^/]+\/stream/.test(url)); + expect(browserStreamWs, 'no browser-stream WebSocket should be opened').toEqual([]); + expect(observed.probeUrls, 'no /browser/session probe should be issued').toEqual([]); }); }); - // Stub the probe so the server (which has no real toolset for weather-agent) - // doesn't return a 404 that the client could fall back from. - await page.route('**/api/agents/weather-agent/browser/session*', route => - route.fulfill({ status: 200, json: { hasSession: false, screencastAvailable: true } }), - ); - - const observed = observeBrowserTraffic(page); - - await page.goto('/agents/weather-agent/chat/1234'); - - await expect(page.locator('h2:has-text("Weather Agent")')).toBeVisible(); - - // The probe should fire once the agent details resolve and the gate flips on. - await expect - .poll(() => observed.probeUrls.length, { - message: 'probe endpoint should be called for an agent with browser tools', - timeout: 5000, - }) - .toBeGreaterThan(0); - - // The probe stub reports `hasSession: false` and the user has not opened the - // browser panel, so no WebSocket should be opened either. This confirms the - // probe result is honored — the gate is not "any browser tool => connect". - const browserStreamWs = observed.wsUrls.filter(url => /\/browser\/[^/]+\/stream/.test(url)); - expect(browserStreamWs, 'WebSocket should not open when probe reports no session').toEqual([]); + test.describe('when the agent has browser tools', () => { + test('issues the session probe', async ({ page }) => { + // Override the agent details response so the client believes weather-agent + // has browser tools. We don't need a real browser implementation — only the + // client-side gate is exercised here; the probe response is also stubbed so + // the server doesn't 404 on a real call. + await page.route('**/api/agents/weather-agent*', async route => { + if (route.request().method() !== 'GET') return route.fallback(); + const response = await route.fetch(); + const body = await response.json(); + await route.fulfill({ + response, + json: { ...body, browserTools: ['browser_goto', 'browser_snapshot'] }, + }); + }); + + // Stub the probe so the server (which has no real toolset for weather-agent) + // doesn't return a 404 that the client could fall back from. + await page.route('**/api/agents/weather-agent/browser/session*', route => + route.fulfill({ status: 200, json: { hasSession: false, screencastAvailable: true } }), + ); + + const observed = observeBrowserTraffic(page); + + await page.goto('/agents/weather-agent/chat/1234'); + + await expect(page.locator('h2:has-text("Weather Agent")')).toBeVisible(); + + // The probe should fire once the agent details resolve and the gate flips on. + await expect + .poll(() => observed.probeUrls.length, { + message: 'probe endpoint should be called for an agent with browser tools', + timeout: 5000, + }) + .toBeGreaterThan(0); + + // The probe stub reports `hasSession: false` and the user has not opened the + // browser panel, so no WebSocket should be opened either. This confirms the + // probe result is honored — the gate is not "any browser tool => connect". + const browserStreamWs = observed.wsUrls.filter(url => /\/browser\/[^/]+\/stream/.test(url)); + expect(browserStreamWs, 'WebSocket should not open when probe reports no session').toEqual([]); + }); + }); }); diff --git a/packages/playground/e2e/tests/agents/$agentId/ime-composition.spec.ts b/packages/playground/e2e/tests/agents/$agentId/ime-composition.spec.ts index d3a7b1b9c45d..b5f4e1de2c05 100644 --- a/packages/playground/e2e/tests/agents/$agentId/ime-composition.spec.ts +++ b/packages/playground/e2e/tests/agents/$agentId/ime-composition.spec.ts @@ -1,6 +1,7 @@ -import { test, expect, Page, BrowserContext } from '@playwright/test'; -import { selectFixture } from '../../__utils__/select-fixture'; +import type { Page, BrowserContext } from '@playwright/test'; +import { test, expect } from '@playwright/test'; import { resetStorage } from '../../__utils__/reset-storage'; +import { selectFixture } from '../../__utils__/select-fixture'; /** * FEATURE: Chat input IME composition handling @@ -37,130 +38,136 @@ test.afterEach(async () => { await resetStorage(); }); -test('Enter during IME composition does not submit, Enter after composition does submit', async () => { - await selectFixture(page, 'text-stream'); - await page.goto(`/agents/weather-agent/chat/new`); - await page.getByTestId('composer-model-settings-trigger').click(); - await page.click('text=Stream'); - await page.keyboard.press('Escape'); - - const chatInput = page.getByPlaceholder('Enter your message...'); - await chatInput.click(); - await chatInput.pressSequentially('hello', { delay: 10 }); - - // Simulate the start of an IME composition session on the focused textarea. - // Real IMEs dispatch compositionstart before the user confirms a candidate. - await page.evaluate(() => { - const el = document.activeElement as HTMLElement | null; - if (!el) throw new Error('No active element to dispatch composition events on'); - el.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true, data: '' })); - }); - - // Press Enter while composing. We use dispatchEvent with isComposing: true - // to mirror what browsers send during IME (Playwright's keyboard.press does - // not flag isComposing on its own). The composer's IME guard short-circuits - // before any submit logic runs, so it does NOT call preventDefault — it just - // returns early. That's the behavior we care about: no submission. - const defaultPreventedDuringComposition = await page.evaluate(() => { - const el = document.activeElement as HTMLTextAreaElement | null; - if (!el) throw new Error('No active textarea'); - const event = new KeyboardEvent('keydown', { - key: 'Enter', - code: 'Enter', - bubbles: true, - cancelable: true, - isComposing: true, +test.describe('Chat input IME composition', () => { + test.describe('when Enter is pressed during an active IME composition', () => { + test('does not submit, then submits after the composition ends', async () => { + await selectFixture(page, 'text-stream'); + await page.goto(`/agents/weather-agent/chat/new`); + await page.getByTestId('composer-model-settings-trigger').click(); + await page.click('text=Stream'); + await page.keyboard.press('Escape'); + + const chatInput = page.getByPlaceholder('Enter your message...'); + await chatInput.click(); + await chatInput.pressSequentially('hello', { delay: 10 }); + + // Simulate the start of an IME composition session on the focused textarea. + // Real IMEs dispatch compositionstart before the user confirms a candidate. + await page.evaluate(() => { + const el = document.activeElement as HTMLElement | null; + if (!el) throw new Error('No active element to dispatch composition events on'); + el.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true, data: '' })); + }); + + // Press Enter while composing. We use dispatchEvent with isComposing: true + // to mirror what browsers send during IME (Playwright's keyboard.press does + // not flag isComposing on its own). The composer's IME guard short-circuits + // before any submit logic runs, so it does NOT call preventDefault — it just + // returns early. That's the behavior we care about: no submission. + const defaultPreventedDuringComposition = await page.evaluate(() => { + const el = document.activeElement as HTMLTextAreaElement | null; + if (!el) throw new Error('No active textarea'); + const event = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + bubbles: true, + cancelable: true, + isComposing: true, + }); + el.dispatchEvent(event); + return event.defaultPrevented; + }); + + // Guard returns early during composition without calling preventDefault. + expect(defaultPreventedDuringComposition).toBe(false); + + // The real user-facing check: the URL should still be /chat/new because no + // submit happened, and the textarea should still hold the in-progress text. + await expect(page).toHaveURL(/\/chat\/new$/); + await expect(chatInput).toHaveValue('hello'); + + // End the composition session, mirroring the user confirming an IME candidate. + await page.evaluate(() => { + const el = document.activeElement as HTMLElement | null; + if (!el) throw new Error('No active element to dispatch composition events on'); + el.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: 'hello' })); + }); + + // Now Enter should submit the message normally, navigating away from /chat/new. + // Wait for the composer to be idle (Send enabled) so the Enter is not dropped + // by the handler's running-thread guard. + await expect(page.getByRole('button', { name: 'Send' })).toBeEnabled({ timeout: 10000 }); + await chatInput.focus(); + await page.keyboard.press('Enter'); + + await expect(page).not.toHaveURL(/\/chat\/new/, { timeout: 20000 }); + await expect(page.getByTestId('pending-signal-message')).not.toBeVisible({ timeout: 20000 }); + await expect(page.getByTestId('thread-wrapper').getByText('hello')).toBeVisible({ timeout: 20000 }); }); - el.dispatchEvent(event); - return event.defaultPrevented; - }); - - // Guard returns early during composition without calling preventDefault. - expect(defaultPreventedDuringComposition).toBe(false); - - // The real user-facing check: the URL should still be /chat/new because no - // submit happened, and the textarea should still hold the in-progress text. - await expect(page).toHaveURL(/\/chat\/new$/); - await expect(chatInput).toHaveValue('hello'); - - // End the composition session, mirroring the user confirming an IME candidate. - await page.evaluate(() => { - const el = document.activeElement as HTMLElement | null; - if (!el) throw new Error('No active element to dispatch composition events on'); - el.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: 'hello' })); }); - // Now Enter should submit the message normally, navigating away from /chat/new. - // Wait for the composer to be idle (Send enabled) so the Enter is not dropped - // by the handler's running-thread guard. - await expect(page.getByRole('button', { name: 'Send' })).toBeEnabled({ timeout: 10000 }); - await chatInput.focus(); - await page.keyboard.press('Enter'); - - await expect(page).not.toHaveURL(/\/chat\/new/, { timeout: 20000 }); - await expect(page.getByTestId('pending-signal-message')).not.toBeVisible({ timeout: 20000 }); - await expect(page.getByTestId('thread-wrapper').getByText('hello')).toBeVisible({ timeout: 20000 }); -}); - -test('Enter after IME switch (no compositionend) still submits — #16464 regression', async () => { - // Repro for the stuck-state bug introduced by the previous fix: - // 1. User starts composing (compositionstart fires). - // 2. User switches input methods (Caps Lock / Cmd+Space) WITHOUT confirming — - // in many browser/OS combinations `compositionend` is never dispatched. - // 3. With the previous ref-based approach, isComposingRef was stuck `true` - // and every subsequent Enter was preventDefaulted, making the chat input - // appear permanently disabled. Reading native `isComposing` instead means - // the next Enter (with isComposing=false) submits normally. - await selectFixture(page, 'text-stream'); - await page.goto(`/agents/weather-agent/chat/new`); - await page.getByTestId('composer-model-settings-trigger').click(); - await page.click('text=Stream'); - await page.keyboard.press('Escape'); - - const chatInput = page.getByPlaceholder('Enter your message...'); - await chatInput.click(); - await chatInput.pressSequentially('hello', { delay: 10 }); - - // Start composition… - await page.evaluate(() => { - const el = document.activeElement as HTMLElement | null; - if (!el) throw new Error('No active element to dispatch composition events on'); - el.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true, data: '' })); - }); - // …and deliberately DO NOT fire compositionend, mimicking the IME-switch case. - - // Wait until the composer is idle with text queued (Send enabled). The - // assistant-ui keydown handler short-circuits before preventDefault when the - // thread is still running, so without this the synthetic Enter below can - // racily observe a running composer and leave defaultPrevented false. - await expect(page.getByRole('button', { name: 'Send' })).toBeEnabled({ timeout: 10000 }); - - // A subsequent Enter with isComposing=false (the IME is no longer active) should - // submit, because the guard reads from the live event, not a stale ref. The - // composer calls preventDefault() before requesting the form submit. - const defaultPrevented = await page.evaluate(() => { - const el = document.activeElement as HTMLTextAreaElement | null; - if (!el) throw new Error('No active textarea'); - const event = new KeyboardEvent('keydown', { - key: 'Enter', - code: 'Enter', - bubbles: true, - cancelable: true, - isComposing: false, + test.describe('when the IME is switched mid-composition without a compositionend', () => { + test('still submits on the next Enter — #16464 regression', async () => { + // Repro for the stuck-state bug introduced by the previous fix: + // 1. User starts composing (compositionstart fires). + // 2. User switches input methods (Caps Lock / Cmd+Space) WITHOUT confirming — + // in many browser/OS combinations `compositionend` is never dispatched. + // 3. With the previous ref-based approach, isComposingRef was stuck `true` + // and every subsequent Enter was preventDefaulted, making the chat input + // appear permanently disabled. Reading native `isComposing` instead means + // the next Enter (with isComposing=false) submits normally. + await selectFixture(page, 'text-stream'); + await page.goto(`/agents/weather-agent/chat/new`); + await page.getByTestId('composer-model-settings-trigger').click(); + await page.click('text=Stream'); + await page.keyboard.press('Escape'); + + const chatInput = page.getByPlaceholder('Enter your message...'); + await chatInput.click(); + await chatInput.pressSequentially('hello', { delay: 10 }); + + // Start composition… + await page.evaluate(() => { + const el = document.activeElement as HTMLElement | null; + if (!el) throw new Error('No active element to dispatch composition events on'); + el.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true, data: '' })); + }); + // …and deliberately DO NOT fire compositionend, mimicking the IME-switch case. + + // Wait until the composer is idle with text queued (Send enabled). The + // assistant-ui keydown handler short-circuits before preventDefault when the + // thread is still running, so without this the synthetic Enter below can + // racily observe a running composer and leave defaultPrevented false. + await expect(page.getByRole('button', { name: 'Send' })).toBeEnabled({ timeout: 10000 }); + + // A subsequent Enter with isComposing=false (the IME is no longer active) should + // submit, because the guard reads from the live event, not a stale ref. The + // composer calls preventDefault() before requesting the form submit. + const defaultPrevented = await page.evaluate(() => { + const el = document.activeElement as HTMLTextAreaElement | null; + if (!el) throw new Error('No active textarea'); + const event = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + bubbles: true, + cancelable: true, + isComposing: false, + }); + el.dispatchEvent(event); + return event.defaultPrevented; + }); + + // Guard let this Enter through to the submit path — preventDefault was + // called as part of requesting the form submit (not as a way to block it). + expect(defaultPrevented).toBe(true); + + // And the submit should go through end-to-end. + await chatInput.focus(); + await page.keyboard.press('Enter'); + + await expect(page).not.toHaveURL(/\/chat\/new/, { timeout: 20000 }); + await expect(page.getByTestId('pending-signal-message')).not.toBeVisible({ timeout: 20000 }); + await expect(page.getByTestId('thread-wrapper').getByText('hello')).toBeVisible({ timeout: 20000 }); }); - el.dispatchEvent(event); - return event.defaultPrevented; }); - - // Guard let this Enter through to the submit path — preventDefault was - // called as part of requesting the form submit (not as a way to block it). - expect(defaultPrevented).toBe(true); - - // And the submit should go through end-to-end. - await chatInput.focus(); - await page.keyboard.press('Enter'); - - await expect(page).not.toHaveURL(/\/chat\/new/, { timeout: 20000 }); - await expect(page.getByTestId('pending-signal-message')).not.toBeVisible({ timeout: 20000 }); - await expect(page.getByTestId('thread-wrapper').getByText('hello')).toBeVisible({ timeout: 20000 }); }); diff --git a/packages/playground/e2e/tests/agents/$agentId/page.spec.ts b/packages/playground/e2e/tests/agents/$agentId/page.spec.ts index 9a8aaf961c1d..d19724809578 100644 --- a/packages/playground/e2e/tests/agents/$agentId/page.spec.ts +++ b/packages/playground/e2e/tests/agents/$agentId/page.spec.ts @@ -2,43 +2,45 @@ import { test, expect } from '@playwright/test'; import { resetStorage } from '../../__utils__/reset-storage'; import { expectRouteDocsLink } from '../../__utils__/route-header'; -test.afterEach(async () => { - await resetStorage(); -}); +test.describe('Agent detail page', () => { + test.afterEach(async () => { + await resetStorage(); + }); -test('overall layout information', async ({ page }) => { - await page.goto('/agents/weather-agent/chat/1234'); - - // Header - await expect(page).toHaveTitle(/Mastra Studio/); - await expectRouteDocsLink(page, 'Agents documentation', 'https://mastra.ai/en/docs/agents/overview'); - const breadcrumb = page.locator('header>nav'); - expect(breadcrumb).toMatchAriaSnapshot(); - - // Thread history (with memory) - const newChatButton = await page.locator('a:has-text("New Chat")'); - await expect(newChatButton).toBeVisible(); - await expect(newChatButton).toHaveAttribute('href', /agents\/weather-agent\/chat\/.*/); - await expect(page.locator('text=Your conversations will appear here once you start chatting!')).toBeVisible(); - - // Agent header and settings overview - await expect(page.locator('h2:has-text("Weather Agent")')).toBeVisible(); - await expect(page.getByTestId('agent-entity-header-copy-id')).toBeVisible(); - - await page.getByTestId('agent-view-header-toggle').click(); - await expect(page).toHaveURL(/\/agents\/weather-agent\/settings$/); - await expect(page.getByTestId('agent-settings-view')).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); - await expect(page.getByRole('heading', { name: 'Tools' })).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('link', { name: 'weatherInfo' })).toHaveAttribute( - 'href', - /\/agents\/weather-agent\/tools\/weatherInfo$/, - ); -}); + test.describe('when an agent chat page is visited', () => { + test('renders the layout, thread history, and links through to agent settings', async ({ page }) => { + await page.goto('/agents/weather-agent/chat/1234'); + + // Header + await expect(page).toHaveTitle(/Mastra Studio/); + await expectRouteDocsLink(page, 'Agents documentation', 'https://mastra.ai/en/docs/agents/overview'); + const breadcrumb = page.locator('header>nav'); + await expect(breadcrumb).toMatchAriaSnapshot(); + + // Thread history (with memory) + const newChatButton = await page.locator('a:has-text("New Chat")'); + await expect(newChatButton).toBeVisible(); + await expect(newChatButton).toHaveAttribute('href', /agents\/weather-agent\/chat\/.*/); + await expect(page.locator('text=Your conversations will appear here once you start chatting!')).toBeVisible(); -test.describe('agent settings', () => { - test.describe('overview', () => { - test('general information', async ({ page }) => { + // Agent header and settings overview + await expect(page.locator('h2:has-text("Weather Agent")')).toBeVisible(); + await expect(page.getByTestId('agent-entity-header-copy-id')).toBeVisible(); + + await page.getByTestId('agent-view-header-toggle').click(); + await expect(page).toHaveURL(/\/agents\/weather-agent\/settings$/); + await expect(page.getByTestId('agent-settings-view')).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('heading', { name: 'Tools' })).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('link', { name: 'weatherInfo' })).toHaveAttribute( + 'href', + /\/agents\/weather-agent\/tools\/weatherInfo$/, + ); + }); + }); + + test.describe('when the agent settings page is visited', () => { + test('shows the general overview tab selected with its details', async ({ page }) => { await page.goto('/agents/weather-agent/settings'); await expect(page.getByTestId('agent-settings-view')).toBeVisible({ timeout: 10000 }); @@ -49,84 +51,84 @@ test.describe('agent settings', () => { await expect(overview).toMatchAriaSnapshot(); }); }); -}); -test.describe('composer model settings', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/agents/weather-agent/chat/new'); - await page.getByTestId('composer-model-settings-trigger').click(); - }); + test.describe('when the composer model settings popover is opened', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/agents/weather-agent/chat/new'); + await page.getByTestId('composer-model-settings-trigger').click(); + }); - test('model trigger modes', async ({ page }) => { - const generateRadio = page.getByRole('radio', { name: 'Generate' }); + test('shows the available model trigger modes with stream subscription as default', async ({ page }) => { + const generateRadio = page.getByRole('radio', { name: 'Generate' }); - await expect(generateRadio).toBeVisible(); - await expect(generateRadio).toHaveAttribute('aria-checked', 'false'); - const streamSubscriptionRadio = page.getByRole('radio', { name: 'Stream subscription (default)' }); - await expect(streamSubscriptionRadio).toBeVisible(); - await expect(streamSubscriptionRadio).toHaveAttribute('aria-checked', 'true'); + await expect(generateRadio).toBeVisible(); + await expect(generateRadio).toHaveAttribute('aria-checked', 'false'); + const streamSubscriptionRadio = page.getByRole('radio', { name: 'Stream subscription (default)' }); + await expect(streamSubscriptionRadio).toBeVisible(); + await expect(streamSubscriptionRadio).toHaveAttribute('aria-checked', 'true'); - const streamRadio = page.getByRole('radio', { name: 'Stream', exact: true }); - await expect(streamRadio).toBeVisible(); - await expect(streamRadio).toHaveAttribute('aria-checked', 'false'); + const streamRadio = page.getByRole('radio', { name: 'Stream', exact: true }); + await expect(streamRadio).toBeVisible(); + await expect(streamRadio).toHaveAttribute('aria-checked', 'false'); - const networkRadio = page.getByRole('radio', { name: 'Network' }); - await expect(networkRadio).toBeVisible(); - }); + const networkRadio = page.getByRole('radio', { name: 'Network' }); + await expect(networkRadio).toBeVisible(); + }); - test('verfied persistent model settings', async ({ page }) => { - // Arrange - await page.isVisible('text=Chat Method'); - await page.click('text=Generate'); - await page.click('text=Advanced Settings'); - await page.getByLabel('Top K').fill('9'); - await page.getByLabel('Frequency Penalty').fill('0.7'); - await page.getByLabel('Presence Penalty').fill('0.6'); - await page.getByLabel('Max Tokens').fill('44'); - await page.getByLabel('Max Steps').fill('3'); - await page.getByLabel('Max Retries').fill('2'); - - // Act - await page.reload(); - await page.getByTestId('composer-model-settings-trigger').click(); - await page.click('text=Advanced Settings'); - - // Assert - await expect(page.getByLabel('Top K')).toHaveValue('9'); - await expect(page.getByLabel('Frequency Penalty')).toHaveValue('0.7'); - await expect(page.getByLabel('Presence Penalty')).toHaveValue('0.6'); - await expect(page.getByLabel('Max Tokens')).toHaveValue('44'); - await expect(page.getByLabel('Max Steps')).toHaveValue('3'); - await expect(page.getByLabel('Max Retries')).toHaveValue('2'); - }); + test('persists model settings across a reload', async ({ page }) => { + // Arrange + await page.isVisible('text=Chat Method'); + await page.click('text=Generate'); + await page.click('text=Advanced Settings'); + await page.getByLabel('Top K').fill('9'); + await page.getByLabel('Frequency Penalty').fill('0.7'); + await page.getByLabel('Presence Penalty').fill('0.6'); + await page.getByLabel('Max Tokens').fill('44'); + await page.getByLabel('Max Steps').fill('3'); + await page.getByLabel('Max Retries').fill('2'); + + // Act + await page.reload(); + await page.getByTestId('composer-model-settings-trigger').click(); + await page.click('text=Advanced Settings'); + + // Assert + await expect(page.getByLabel('Top K')).toHaveValue('9'); + await expect(page.getByLabel('Frequency Penalty')).toHaveValue('0.7'); + await expect(page.getByLabel('Presence Penalty')).toHaveValue('0.6'); + await expect(page.getByLabel('Max Tokens')).toHaveValue('44'); + await expect(page.getByLabel('Max Steps')).toHaveValue('3'); + await expect(page.getByLabel('Max Retries')).toHaveValue('2'); + }); - test('resets the form values when pressing "reset" button', async ({ page }) => { - // Arrange - await page.isVisible('text=Chat Method'); - await page.click('text=Generate'); - await page.click('text=Advanced Settings'); - await page.getByLabel('Top K').fill('9'); - await page.getByLabel('Frequency Penalty').fill('0.7'); - await page.getByLabel('Presence Penalty').fill('0.6'); - await page.getByLabel('Max Tokens').fill('44'); - await page.getByLabel('Max Steps').fill('3'); - await page.getByLabel('Max Retries').fill('2'); - - // Close the Advanced Settings dialog before clicking Reset (Reset lives in the composer popover) - await page.keyboard.press('Escape'); - - // Act - await page.click('text=Reset'); - - // Reopen Advanced Settings to inspect the reset field values - await page.click('text=Advanced Settings'); - - // Assert - values reset to defaults (maxSteps: 15, maxRetries: 2 are fallback defaults) - await expect(page.getByLabel('Top K')).toHaveValue(''); - await expect(page.getByLabel('Frequency Penalty')).toHaveValue(''); - await expect(page.getByLabel('Presence Penalty')).toHaveValue(''); - await expect(page.getByLabel('Max Tokens')).toHaveValue(''); - await expect(page.getByLabel('Max Steps')).toHaveValue('15'); - await expect(page.getByLabel('Max Retries')).toHaveValue('2'); + test('resets the form values when pressing the "reset" button', async ({ page }) => { + // Arrange + await page.isVisible('text=Chat Method'); + await page.click('text=Generate'); + await page.click('text=Advanced Settings'); + await page.getByLabel('Top K').fill('9'); + await page.getByLabel('Frequency Penalty').fill('0.7'); + await page.getByLabel('Presence Penalty').fill('0.6'); + await page.getByLabel('Max Tokens').fill('44'); + await page.getByLabel('Max Steps').fill('3'); + await page.getByLabel('Max Retries').fill('2'); + + // Close the Advanced Settings dialog before clicking Reset (Reset lives in the composer popover) + await page.keyboard.press('Escape'); + + // Act + await page.click('text=Reset'); + + // Reopen Advanced Settings to inspect the reset field values + await page.click('text=Advanced Settings'); + + // Assert - values reset to defaults (maxSteps: 15, maxRetries: 2 are fallback defaults) + await expect(page.getByLabel('Top K')).toHaveValue(''); + await expect(page.getByLabel('Frequency Penalty')).toHaveValue(''); + await expect(page.getByLabel('Presence Penalty')).toHaveValue(''); + await expect(page.getByLabel('Max Tokens')).toHaveValue(''); + await expect(page.getByLabel('Max Steps')).toHaveValue('15'); + await expect(page.getByLabel('Max Retries')).toHaveValue('2'); + }); }); }); diff --git a/packages/playground/e2e/tests/agents/$agentId/page.spec.ts-snapshots/overall-layout-information-1.aria.yml b/packages/playground/e2e/tests/agents/$agentId/page.spec.ts-snapshots/Agent-detail-page-when-an-agent-chat-page-is-v-d64db--history-and-links-through-to-agent-settings-1.aria.yml similarity index 91% rename from packages/playground/e2e/tests/agents/$agentId/page.spec.ts-snapshots/overall-layout-information-1.aria.yml rename to packages/playground/e2e/tests/agents/$agentId/page.spec.ts-snapshots/Agent-detail-page-when-an-agent-chat-page-is-v-d64db--history-and-links-through-to-agent-settings-1.aria.yml index b52823a0ed9b..3f8b694f347f 100644 --- a/packages/playground/e2e/tests/agents/$agentId/page.spec.ts-snapshots/overall-layout-information-1.aria.yml +++ b/packages/playground/e2e/tests/agents/$agentId/page.spec.ts-snapshots/Agent-detail-page-when-an-agent-chat-page-is-v-d64db--history-and-links-through-to-agent-settings-1.aria.yml @@ -1,4 +1,4 @@ -- navigation: +- navigation "Breadcrumb": - list: - listitem: - link "Agents": diff --git a/packages/playground/e2e/tests/agents/$agentId/page.spec.ts-snapshots/Agent-detail-page-when-the-agent-settings-page-71118-eneral-overview-tab-selected-with-its-details-1.aria.yml b/packages/playground/e2e/tests/agents/$agentId/page.spec.ts-snapshots/Agent-detail-page-when-the-agent-settings-page-71118-eneral-overview-tab-selected-with-its-details-1.aria.yml new file mode 100644 index 000000000000..7a35ac194289 --- /dev/null +++ b/packages/playground/e2e/tests/agents/$agentId/page.spec.ts-snapshots/Agent-detail-page-when-the-agent-settings-page-71118-eneral-overview-tab-selected-with-its-details-1.aria.yml @@ -0,0 +1,45 @@ +- tabpanel "General": + - heading "Agents" [level=3]: + - text: '' + - link: + - /url: https://mastra.ai/en/docs/agents/overview + - list: + - listitem: + - link "Sub Agent": + - /url: /agents/subAgent/chat/new + - img + - text: '' + - heading "Tools" [level=3]: + - text: '' + - link: + - /url: https://mastra.ai/en/docs/agents/using-tools-and-mcp + - list: + - listitem: + - link "weatherInfo": + - /url: /agents/weather-agent/tools/weatherInfo + - img + - text: '' + - listitem: + - link "simpleMcpTool": + - /url: /agents/weather-agent/tools/simpleMcpTool + - img + - text: '' + - heading "Workflows" [level=3]: + - text: '' + - link: + - /url: https://mastra.ai/en/docs/workflows/overview + - list: + - listitem: + - link "lessComplexWorkflow": + - /url: /workflows/lessComplexWorkflow + - img + - text: '' + - heading "Skills" [level=3]: + - text: '' + - link: + - /url: https://mastra.ai/en/docs/workspace/skills + - paragraph: No skills + - heading "Scorers" [level=3] + - paragraph: No Scorers + - heading "System Prompt" [level=3] + - textbox diff --git a/packages/playground/e2e/tests/agents/$agentId/page.spec.ts-snapshots/agent-settings-overview-general-information-1.aria.yml b/packages/playground/e2e/tests/agents/$agentId/page.spec.ts-snapshots/agent-settings-overview-general-information-1.aria.yml deleted file mode 100644 index 0c20d46dee55..000000000000 --- a/packages/playground/e2e/tests/agents/$agentId/page.spec.ts-snapshots/agent-settings-overview-general-information-1.aria.yml +++ /dev/null @@ -1,45 +0,0 @@ -- tabpanel "General": - - heading "Agents" [level=3]: - - text: "" - - link: - - /url: https://mastra.ai/en/docs/agents/overview - - list: - - listitem: - - link "Sub Agent": - - /url: /agents/subAgent/chat/new - - img - - text: "" - - heading "Tools" [level=3]: - - text: "" - - link: - - /url: https://mastra.ai/en/docs/agents/using-tools-and-mcp - - list: - - listitem: - - link "weatherInfo": - - /url: /agents/weather-agent/tools/weatherInfo - - img - - text: "" - - listitem: - - link "simpleMcpTool": - - /url: /agents/weather-agent/tools/simpleMcpTool - - img - - text: "" - - heading "Workflows" [level=3]: - - text: "" - - link: - - /url: https://mastra.ai/en/docs/workflows/overview - - list: - - listitem: - - link "lessComplexWorkflow": - - /url: /workflows/lessComplexWorkflow - - img - - text: "" - - heading "Skills" [level=3]: - - text: "" - - link: - - /url: https://mastra.ai/en/docs/workspace/skills - - paragraph: No skills - - heading "Scorers" [level=3] - - paragraph: No Scorers - - heading "System Prompt" [level=3] - - textbox diff --git a/packages/playground/e2e/tests/agents/$agentId/session.spec.ts b/packages/playground/e2e/tests/agents/$agentId/session.spec.ts index e8d8642ee20d..c7ae1c7f71b8 100644 --- a/packages/playground/e2e/tests/agents/$agentId/session.spec.ts +++ b/packages/playground/e2e/tests/agents/$agentId/session.spec.ts @@ -11,39 +11,41 @@ test.afterEach(async () => { await resetStorage(); }); -test.describe('session page - layout', () => { - test('renders chat interface without sidebar and info pane', async ({ page }) => { - await page.goto('/agents/weather-agent/session/1234'); +test.describe('Agent session page', () => { + test.describe('when the session page is visited', () => { + test('renders chat interface without sidebar and info pane', async ({ page }) => { + await page.goto('/agents/weather-agent/session/1234'); - // ASSERT: Page loads with correct title - await expect(page).toHaveTitle(/Mastra Studio/); + // ASSERT: Page loads with correct title + await expect(page).toHaveTitle(/Mastra Studio/); - // ASSERT: Header with Mastra logo and studio title is visible - await expect(page.locator('header')).toBeVisible(); - await expect(page.locator('header').locator('svg')).toBeVisible(); - await expect(page.locator('header').locator('text=Mastra Studio')).toBeVisible(); + // ASSERT: Header with Mastra logo and studio title is visible + await expect(page.locator('header')).toBeVisible(); + await expect(page.locator('header').locator('svg')).toBeVisible(); + await expect(page.locator('header').locator('text=Mastra Studio')).toBeVisible(); - // ASSERT: Chat composer is visible (the message input area) - await expect(page.getByPlaceholder('Enter your message...')).toBeVisible(); + // ASSERT: Chat composer is visible (the message input area) + await expect(page.getByPlaceholder('Enter your message...')).toBeVisible(); - // ASSERT: Left sidebar (thread list with "New Chat" button) is NOT present - await expect(page.locator('a:has-text("New Chat")')).not.toBeVisible(); + // ASSERT: Left sidebar (thread list with "New Chat" button) is NOT present + await expect(page.locator('a:has-text("New Chat")')).not.toBeVisible(); - // ASSERT: Right info pane (agent information with Overview/Model Settings tabs) is NOT present - await expect(page.locator('button:has-text("Overview")')).not.toBeVisible(); - await expect(page.locator('button:has-text("Model Settings")')).not.toBeVisible(); + // ASSERT: Right info pane (agent information with Overview/Model Settings tabs) is NOT present + await expect(page.locator('button:has-text("Overview")')).not.toBeVisible(); + await expect(page.locator('button:has-text("Model Settings")')).not.toBeVisible(); - // ASSERT: Main app sidebar navigation is NOT present - await expect(page.locator('nav:has-text("Agents")')).not.toBeVisible(); + // ASSERT: Main app sidebar navigation is NOT present + await expect(page.locator('nav:has-text("Agents")')).not.toBeVisible(); - // ASSERT: Model switcher (provider/model comboboxes) is NOT present - await expect(page.getByRole('combobox')).not.toBeVisible(); - }); + // ASSERT: Model switcher (provider/model comboboxes) is NOT present + await expect(page.getByRole('combobox')).not.toBeVisible(); + }); - test('does not render preset dropdown when no presets are configured', async ({ page }) => { - await page.goto('/agents/weather-agent/session/1234'); + test('does not render preset dropdown when no presets are configured', async ({ page }) => { + await page.goto('/agents/weather-agent/session/1234'); - // ASSERT: No preset selector dropdown should be visible (since no presets are set by default) - await expect(page.locator('text=Select a preset')).not.toBeVisible(); + // ASSERT: No preset selector dropdown should be visible (since no presets are set by default) + await expect(page.locator('text=Select a preset')).not.toBeVisible(); + }); }); }); diff --git a/packages/playground/e2e/tests/agents/$agentId/stream.spec.ts b/packages/playground/e2e/tests/agents/$agentId/stream.spec.ts index 961c147d3bf0..a49d5230e387 100644 --- a/packages/playground/e2e/tests/agents/$agentId/stream.spec.ts +++ b/packages/playground/e2e/tests/agents/$agentId/stream.spec.ts @@ -1,21 +1,11 @@ -import { test, expect, Page, BrowserContext } from '@playwright/test'; -import { selectFixture } from '../../__utils__/select-fixture'; +import type { Page, BrowserContext } from '@playwright/test'; +import { test, expect } from '@playwright/test'; import { resetStorage } from '../../__utils__/reset-storage'; +import { selectFixture } from '../../__utils__/select-fixture'; let page: Page; let context: BrowserContext; -test.beforeEach(async ({ browser }) => { - await resetStorage(); - context = await browser.newContext(); - page = await context.newPage(); -}); - -test.afterEach(async () => { - await context.close(); - await resetStorage(); -}); - /** * Fill the chat input, click Send, and wait for navigation away from /chat/new. * Uses pressSequentially to work reliably with React controlled inputs. @@ -32,57 +22,6 @@ async function fillAndSend(page: Page, message: string) { await sendButton.click(); } -test('text stream', async () => { - const expectedResult = `I can help you get accurate weather forecasts by providing real-time data for your location. Just tell me your city or location, and I'll give you current conditions and detailed forecasts with temperature, humidity, and wind speed. Whether you're planning a trip or just checking today, I'm here to help! What is your current location?`; - - await selectFixture(page, 'text-stream'); - await page.goto(`/agents/weather-agent/chat/new`); - await page.getByTestId('composer-model-settings-trigger').click(); - await page.click('text=Stream'); - await page.keyboard.press('Escape'); - - await fillAndSend(page, 'Give me the Lorem Ipsum thing'); - - // Assert partial streaming chunks - await expect(page.getByTestId('thread-wrapper').getByText(`I can help you get accurate`)).toBeVisible({ - timeout: 20000, - }); - - await expect(page.getByTestId('thread-wrapper').getByText(expectedResult)).not.toBeVisible({ timeout: 20000 }); - - // Asset streaming result - await expect(page.getByTestId('thread-wrapper').getByText(expectedResult)).toBeVisible({ timeout: 20000 }); - - // Assert thread entry refreshing - await expect(page.getByTestId('thread-list').getByRole('link', { name: expectedResult })).toBeVisible({ - timeout: 20000, - }); - - // Memory - await page.reload(); - await expect(page.getByTestId('thread-list').getByRole('link', { name: expectedResult })).toBeVisible({ - timeout: 20000, - }); - await expect(page.getByTestId('thread-wrapper').getByText(expectedResult)).toBeVisible({ timeout: 20000 }); -}); - -test('tool stream', async () => { - await selectFixture(page, 'tool-stream'); - await page.goto(`/agents/weather-agent/chat/new`); - await page.getByTestId('composer-model-settings-trigger').click(); - await page.click('text=Stream'); - await page.keyboard.press('Escape'); - - await fillAndSend(page, 'Give me the weather in Paris'); - - // Wait for navigation from /chat/new to the actual thread URL - await expect(page).not.toHaveURL(/\/chat\/new/, { timeout: 20000 }); - - await assertToolStream(page); - await page.reload(); - await assertToolStream(page); -}); - async function assertToolStream(page: Page) { const expectedTextResult = `The weather in Paris is sunny, with a temperature of 19°C (66°F). The humidity is at 50%, and there's a light wind blowing at 10 mph. Perfect weather for a lovely day out or a cozy meal at home!`; @@ -106,73 +45,145 @@ async function assertToolStream(page: Page) { await expect(page.getByTestId('tool-result')).toContainText(`"location":`); } -test('workflow stream', async () => { - await selectFixture(page, 'workflow-stream'); - await page.goto(`/agents/weather-agent/chat/new`); - await page.getByTestId('composer-model-settings-trigger').click(); - await page.click('text=Stream'); - await page.keyboard.press('Escape'); +test.describe('Agent chat streaming', () => { + test.beforeEach(async ({ browser }) => { + await resetStorage(); + context = await browser.newContext(); + page = await context.newPage(); + }); - await fillAndSend(page, 'Give me the weather in Paris'); + test.afterEach(async () => { + await context.close(); + await resetStorage(); + }); - // Assert partial streaming chunks - await expect(page.getByTestId('thread-wrapper').getByRole('button', { name: `lessComplexWorkflow` })).toBeVisible({ - timeout: 20000, + test.describe('when the text-stream fixture drives the response', () => { + test('streams the text incrementally and persists it in memory', async () => { + const expectedResult = `I can help you get accurate weather forecasts by providing real-time data for your location. Just tell me your city or location, and I'll give you current conditions and detailed forecasts with temperature, humidity, and wind speed. Whether you're planning a trip or just checking today, I'm here to help! What is your current location?`; + + await selectFixture(page, 'text-stream'); + await page.goto(`/agents/weather-agent/chat/new`); + await page.getByTestId('composer-model-settings-trigger').click(); + await page.click('text=Stream'); + await page.keyboard.press('Escape'); + + await fillAndSend(page, 'Give me the Lorem Ipsum thing'); + + // Assert partial streaming chunks + await expect(page.getByTestId('thread-wrapper').getByText(`I can help you get accurate`)).toBeVisible({ + timeout: 20000, + }); + + await expect(page.getByTestId('thread-wrapper').getByText(expectedResult)).not.toBeVisible({ timeout: 20000 }); + + // Asset streaming result + await expect(page.getByTestId('thread-wrapper').getByText(expectedResult)).toBeVisible({ timeout: 20000 }); + + // Assert thread entry refreshing + await expect(page.getByTestId('thread-list').getByRole('link', { name: expectedResult })).toBeVisible({ + timeout: 20000, + }); + + // Memory + await page.reload(); + await expect(page.getByTestId('thread-list').getByRole('link', { name: expectedResult })).toBeVisible({ + timeout: 20000, + }); + await expect(page.getByTestId('thread-wrapper').getByText(expectedResult)).toBeVisible({ timeout: 20000 }); + }); }); - // Node 9 is the last step. While streaming, it transitions from "idle" to - // "running" to "success". Depending on machine speed it may already be in - // "success" by the time we assert, so accept either transient state — what - // we care about is that it left "idle". - await expect(page.locator('[data-workflow-node]').nth(9)).toHaveAttribute( - 'data-workflow-step-status', - /^(running|success)$/, - ); - - // Workflow checks - await expect(page.locator('[data-workflow-node]').nth(0)).toHaveAttribute('data-workflow-step-status', 'success'); - await expect(page.locator('[data-workflow-node]').nth(1)).toHaveAttribute('data-workflow-step-status', 'success'); - await expect(page.locator('[data-workflow-node]').nth(2)).toHaveAttribute('data-workflow-step-status', 'success'); - await expect(page.locator('[data-workflow-node]').nth(3)).toHaveAttribute('data-workflow-step-status', 'success'); - // 4 and 6 are conditional - - await expect(page.locator('[data-workflow-node]').nth(5)).toHaveAttribute('data-workflow-step-status', 'idle'); - await expect(page.locator('[data-workflow-node]').nth(7)).toHaveAttribute('data-workflow-step-status', 'success'); - await expect(page.locator('[data-workflow-node]').nth(7)).toHaveAttribute('data-workflow-step-status', 'success'); - await expect(page.locator('[data-workflow-node]').nth(8)).toHaveAttribute('data-workflow-step-status', 'success'); - - // Text delta result - await expect( - page - .getByTestId('thread-wrapper') - .getByText(`It looks like the process I ran with "tomato" resulted in a playful transformation: `), - ).toBeVisible({ timeout: 20000 }); - await expect(page.getByTestId('thread-wrapper').getByText('tomatoABtomatoACLABD-ENDED')).toBeVisible({ - timeout: 20000, + test.describe('when the tool-stream fixture drives the response', () => { + test('streams the tool call and result and persists across a reload', async () => { + await selectFixture(page, 'tool-stream'); + await page.goto(`/agents/weather-agent/chat/new`); + await page.getByTestId('composer-model-settings-trigger').click(); + await page.click('text=Stream'); + await page.keyboard.press('Escape'); + + await fillAndSend(page, 'Give me the weather in Paris'); + + // Wait for navigation from /chat/new to the actual thread URL + await expect(page).not.toHaveURL(/\/chat\/new/, { timeout: 20000 }); + + await assertToolStream(page); + await page.reload(); + await assertToolStream(page); + }); }); - // Memory - await expect(page.getByTestId('thread-list').locator('li')).toHaveCount(1); // The new thread - await page.reload(); - await expect(page.locator('[data-workflow-node]').nth(0)).toHaveAttribute('data-workflow-step-status', 'success'); - await expect(page.locator('[data-workflow-node]').nth(1)).toHaveAttribute('data-workflow-step-status', 'success'); - await expect(page.locator('[data-workflow-node]').nth(2)).toHaveAttribute('data-workflow-step-status', 'success'); - await expect(page.locator('[data-workflow-node]').nth(3)).toHaveAttribute('data-workflow-step-status', 'success'); - // 4 and 6 are conditional - - await expect(page.locator('[data-workflow-node]').nth(5)).toHaveAttribute('data-workflow-step-status', 'idle'); - await expect(page.locator('[data-workflow-node]').nth(7)).toHaveAttribute('data-workflow-step-status', 'success'); - await expect(page.locator('[data-workflow-node]').nth(7)).toHaveAttribute('data-workflow-step-status', 'success'); - await expect(page.locator('[data-workflow-node]').nth(8)).toHaveAttribute('data-workflow-step-status', 'success'); - await expect(page.locator('[data-workflow-node]').nth(9)).toHaveAttribute('data-workflow-step-status', 'success'); - - // Text delta result - await expect( - page - .getByTestId('thread-wrapper') - .getByText(`It looks like the process I ran with "tomato" resulted in a playful transformation: `), - ).toBeVisible({ timeout: 20000 }); - await expect(page.getByTestId('thread-wrapper').getByText('tomatoABtomatoACLABD-ENDED')).toBeVisible({ - timeout: 20000, + test.describe('when the workflow-stream fixture drives the response', () => { + test('streams the workflow node statuses and final text and persists across a reload', async () => { + await selectFixture(page, 'workflow-stream'); + await page.goto(`/agents/weather-agent/chat/new`); + await page.getByTestId('composer-model-settings-trigger').click(); + await page.click('text=Stream'); + await page.keyboard.press('Escape'); + + await fillAndSend(page, 'Give me the weather in Paris'); + + // Assert partial streaming chunks + await expect(page.getByTestId('thread-wrapper').getByRole('button', { name: `lessComplexWorkflow` })).toBeVisible( + { + timeout: 20000, + }, + ); + + // Node 9 is the last step. While streaming, it transitions from "idle" to + // "running" to "success". Depending on machine speed it may already be in + // "success" by the time we assert, so accept either transient state — what + // we care about is that it left "idle". + await expect(page.locator('[data-workflow-node]').nth(9)).toHaveAttribute( + 'data-workflow-step-status', + /^(running|success)$/, + ); + + // Workflow checks + await expect(page.locator('[data-workflow-node]').nth(0)).toHaveAttribute('data-workflow-step-status', 'success'); + await expect(page.locator('[data-workflow-node]').nth(1)).toHaveAttribute('data-workflow-step-status', 'success'); + await expect(page.locator('[data-workflow-node]').nth(2)).toHaveAttribute('data-workflow-step-status', 'success'); + await expect(page.locator('[data-workflow-node]').nth(3)).toHaveAttribute('data-workflow-step-status', 'success'); + // 4 and 6 are conditional + + await expect(page.locator('[data-workflow-node]').nth(5)).toHaveAttribute('data-workflow-step-status', 'idle'); + await expect(page.locator('[data-workflow-node]').nth(7)).toHaveAttribute('data-workflow-step-status', 'success'); + await expect(page.locator('[data-workflow-node]').nth(7)).toHaveAttribute('data-workflow-step-status', 'success'); + await expect(page.locator('[data-workflow-node]').nth(8)).toHaveAttribute('data-workflow-step-status', 'success'); + + // Text delta result + await expect( + page + .getByTestId('thread-wrapper') + .getByText(`It looks like the process I ran with "tomato" resulted in a playful transformation: `), + ).toBeVisible({ timeout: 20000 }); + await expect(page.getByTestId('thread-wrapper').getByText('tomatoABtomatoACLABD-ENDED')).toBeVisible({ + timeout: 20000, + }); + + // Memory + await expect(page.getByTestId('thread-list').locator('li')).toHaveCount(1); // The new thread + await page.reload(); + await expect(page.locator('[data-workflow-node]').nth(0)).toHaveAttribute('data-workflow-step-status', 'success'); + await expect(page.locator('[data-workflow-node]').nth(1)).toHaveAttribute('data-workflow-step-status', 'success'); + await expect(page.locator('[data-workflow-node]').nth(2)).toHaveAttribute('data-workflow-step-status', 'success'); + await expect(page.locator('[data-workflow-node]').nth(3)).toHaveAttribute('data-workflow-step-status', 'success'); + // 4 and 6 are conditional + + await expect(page.locator('[data-workflow-node]').nth(5)).toHaveAttribute('data-workflow-step-status', 'idle'); + await expect(page.locator('[data-workflow-node]').nth(7)).toHaveAttribute('data-workflow-step-status', 'success'); + await expect(page.locator('[data-workflow-node]').nth(7)).toHaveAttribute('data-workflow-step-status', 'success'); + await expect(page.locator('[data-workflow-node]').nth(8)).toHaveAttribute('data-workflow-step-status', 'success'); + await expect(page.locator('[data-workflow-node]').nth(9)).toHaveAttribute('data-workflow-step-status', 'success'); + + // Text delta result + await expect( + page + .getByTestId('thread-wrapper') + .getByText(`It looks like the process I ran with "tomato" resulted in a playful transformation: `), + ).toBeVisible({ timeout: 20000 }); + await expect(page.getByTestId('thread-wrapper').getByText('tomatoABtomatoACLABD-ENDED')).toBeVisible({ + timeout: 20000, + }); + }); }); }); diff --git a/packages/playground/e2e/tests/agents/$agentId/tools/$toolId/page.spec.ts b/packages/playground/e2e/tests/agents/$agentId/tools/$toolId/page.spec.ts index d170b9528152..53bdca6a0b14 100644 --- a/packages/playground/e2e/tests/agents/$agentId/tools/$toolId/page.spec.ts +++ b/packages/playground/e2e/tests/agents/$agentId/tools/$toolId/page.spec.ts @@ -5,14 +5,18 @@ test.afterEach(async () => { await resetStorage(); }); -test('verifies a tool s behaviour for agent', async ({ page }) => { - await page.goto('/agents/weather-agent/tools/simpleMcpTool'); +test.describe('Agent tool detail page', () => { + test.describe('when the tool form is submitted', () => { + test('renders the tool name and returns the fixture result', async ({ page }) => { + await page.goto('/agents/weather-agent/tools/simpleMcpTool'); - await expect(page.locator('h2')).toHaveText('simpleMcpTool'); - await expect(page.locator('[data-language="json"]')).toHaveText('{}'); + await expect(page.locator('h2')).toHaveText('simpleMcpTool'); + await expect(page.locator('[data-language="json"]')).toHaveText('{}'); - await page.getByLabel('The name of the person').fill('John Doe'); - await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByLabel('The name of the person').fill('John Doe'); + await page.getByRole('button', { name: 'Submit' }).click(); - await expect(page.locator('[data-language="json"]')).toHaveText('{ "hello": "world", "thisIsA": "fixture"}'); + await expect(page.locator('[data-language="json"]')).toHaveText('{ "hello": "world", "thisIsA": "fixture"}'); + }); + }); }); diff --git a/packages/playground/e2e/tests/agents/observability-tabs.spec.ts b/packages/playground/e2e/tests/agents/observability-tabs.spec.ts index ede215dd9466..4694995d48dd 100644 --- a/packages/playground/e2e/tests/agents/observability-tabs.spec.ts +++ b/packages/playground/e2e/tests/agents/observability-tabs.spec.ts @@ -1,4 +1,5 @@ -import { test, expect, Page } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; import { resetStorage } from '../__utils__/reset-storage'; /** @@ -66,131 +67,145 @@ async function mockTraceLists(page: Page, onRequest?: (url: URL) => void) { }); } -test('requests agent traces when runtime observability is available without package metadata', async ({ page }) => { - await mockSystemPackages(page, true); - - let traceListUrl: URL | undefined; - await mockTraceLists(page, url => (traceListUrl = url)); - - await page.goto('/agents/weather-agent/chat/new'); - await expect(page.getByRole('tab', { name: 'Evaluate' })).toBeVisible(); - await expect(page.getByRole('tab', { name: 'Review' })).toBeVisible(); - await page.getByRole('tab', { name: 'Traces' }).click(); - - // The traces tab navigates to /agents/:id/traces; the page then enriches the URL - // with scope filter params, so we assert the path without anchoring on $. - await expect(page).toHaveURL(/\/agents\/weather-agent\/traces(\?|$)/); - // With the scope filters pre-applied the empty-state copy comes from the list - // view ("filters applied" variant), not the standalone NoTracesInfo screen. - await expect(page.getByText(/No traces found for applied filters/i)).toBeVisible(); - await expect - .poll(() => traceListUrl?.searchParams.get('entityType'), { message: 'trace list request is scoped to agent' }) - .toBe('agent'); - expect(traceListUrl?.searchParams.get('entityId')).toBe('weather-agent'); -}); - -test('keeps agent observability tabs disabled when runtime observability is unavailable', async ({ page }) => { - await mockSystemPackages(page, false); - - await page.goto('/agents/weather-agent/chat/new'); - await page.getByRole('tab', { name: 'Traces' }).hover(); +test.describe('Agent observability tabs', () => { + test.describe('when runtime observability is available without package metadata', () => { + test('requests agent-scoped traces from the Traces tab', async ({ page }) => { + await mockSystemPackages(page, true); + + let traceListUrl: URL | undefined; + await mockTraceLists(page, url => (traceListUrl = url)); + + await page.goto('/agents/weather-agent/chat/new'); + await expect(page.getByRole('tab', { name: 'Evaluate' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Review' })).toBeVisible(); + await page.getByRole('tab', { name: 'Traces' }).click(); + + // The traces tab navigates to /agents/:id/traces; the page then enriches the URL + // with scope filter params, so we assert the path without anchoring on $. + await expect(page).toHaveURL(/\/agents\/weather-agent\/traces(\?|$)/); + // With the scope filters pre-applied the empty-state copy comes from the list + // view ("filters applied" variant), not the standalone NoTracesInfo screen. + await expect(page.getByText(/No traces found for applied filters/i)).toBeVisible(); + await expect + .poll(() => traceListUrl?.searchParams.get('entityType'), { message: 'trace list request is scoped to agent' }) + .toBe('agent'); + expect(traceListUrl?.searchParams.get('entityId')).toBe('weather-agent'); + }); + }); - await expect(page.getByRole('tooltip').getByText('Add @mastra/observability to enable this tab.')).toBeVisible(); -}); + test.describe('when runtime observability is unavailable', () => { + test('keeps the agent observability tabs disabled', async ({ page }) => { + await mockSystemPackages(page, false); -test('agent traces tab pre-fills the agent filter as URL params on first visit', async ({ page }) => { - await mockSystemPackages(page, true); + await page.goto('/agents/weather-agent/chat/new'); + await page.getByRole('tab', { name: 'Traces' }).hover(); - let traceListUrl: URL | undefined; - await mockTraceLists(page, url => (traceListUrl = url)); + await expect(page.getByRole('tooltip').getByText('Add @mastra/observability to enable this tab.')).toBeVisible(); + }); + }); - await page.goto('/agents/weather-agent/traces'); + test.describe('when the agent traces tab is visited for the first time', () => { + test('pre-fills the agent filter as URL params', async ({ page }) => { + await mockSystemPackages(page, true); - // URL should be enriched with the scope filter params so the existing filter - // pills render naturally. - await expect(page).toHaveURL(/rootEntityType=agent/); - await expect(page).toHaveURL(/filterEntityId=weather-agent/); + let traceListUrl: URL | undefined; + await mockTraceLists(page, url => (traceListUrl = url)); - // The API call should reflect those filter params (driven by URL state). - await expect - .poll(() => traceListUrl?.searchParams.get('entityType'), { message: 'trace list request is scoped to agent' }) - .toBe('agent'); - expect(traceListUrl?.searchParams.get('entityId')).toBe('weather-agent'); -}); + await page.goto('/agents/weather-agent/traces'); -test('agent traces tab locks the scope filter pills and hides them from the creator dropdown', async ({ page }) => { - await mockSystemPackages(page, true); - - await mockTraceLists(page); - - await page.goto('/agents/weather-agent/traces'); - - // Scope pills render as locked — read-only, no Remove (×) affordance. - const rootTypePill = page.locator('[data-property-filter-pill="locked"][data-locked-field-id="rootEntityType"]'); - const entityIdPill = page.locator('[data-property-filter-pill="locked"][data-locked-field-id="entityId"]'); - await expect(rootTypePill).toBeVisible(); - await expect(entityIdPill).toBeVisible(); - await expect(rootTypePill.locator('text="Agent"')).toBeVisible(); - await expect(entityIdPill.locator('text="weather-agent"')).toBeVisible(); - await expect(page.getByRole('button', { name: /Remove Primitive Type filter/i })).toHaveCount(0); - await expect(page.getByRole('button', { name: /Remove Primitive ID filter/i })).toHaveCount(0); - - // Opening the Add Filter dropdown must not expose the scope-controlled fields, - // so users cannot recreate the filter and conflict with the scoped view. - await page.getByRole('button', { name: /Add Filter/i }).click(); - await expect(page.getByRole('menuitem', { name: /Primitive Type/i })).toHaveCount(0); - await expect(page.getByRole('menuitem', { name: /Primitive ID/i })).toHaveCount(0); - await expect(page.getByRole('menuitem', { name: /Primitive Name/i })).toHaveCount(0); - // A non-scope field is still listed so the filter dropdown remains useful. - await expect(page.getByRole('menuitem', { name: /Trace ID/i })).toBeVisible(); -}); + // URL should be enriched with the scope filter params so the existing filter + // pills render naturally. + await expect(page).toHaveURL(/rootEntityType=agent/); + await expect(page).toHaveURL(/filterEntityId=weather-agent/); -test('saved filters in an agent-scoped traces tab do not leak to other agents or the global view', async ({ page }) => { - // Why this matters: TracesPage passes a per-agent localStorage key - // (`mastra:traces:saved-filters:agent:<id>`) so that filter preferences saved - // while reviewing weather-agent traces never bleed into another agent's tab - // or the global /observability view. If someone reverts the scoped key (or - // hardcodes the default), this test fails — the regression is otherwise - // silent and only surfaces when two users blame each other for "ghost" - // filters. - await mockSystemPackages(page, true); - await mockTraceLists(page); - - // Land on a page first so we have an origin to seed localStorage against. - await page.goto('/observability'); - await page.evaluate(() => { - localStorage.setItem('mastra:traces:saved-filters:agent:weather-agent', 'filterEnvironment=weather-prod'); + // The API call should reflect those filter params (driven by URL state). + await expect + .poll(() => traceListUrl?.searchParams.get('entityType'), { message: 'trace list request is scoped to agent' }) + .toBe('agent'); + expect(traceListUrl?.searchParams.get('entityId')).toBe('weather-agent'); + }); }); - // Weather-agent should hydrate its own saved filter alongside the scope. - await page.goto('/agents/weather-agent/traces'); - await expect(page).toHaveURL(/filterEnvironment=weather-prod/); - await expect(page).toHaveURL(/filterEntityId=weather-agent/); - - // Another agent must NOT see weather-agent's saved filter. - await page.goto('/agents/om-agent/traces'); - await expect(page).toHaveURL(/filterEntityId=om-agent/); - await expect(page).not.toHaveURL(/filterEnvironment=weather-prod/); + test.describe('when the agent traces tab renders the scope filter', () => { + test('locks the scope pills and hides them from the creator dropdown', async ({ page }) => { + await mockSystemPackages(page, true); + + await mockTraceLists(page); + + await page.goto('/agents/weather-agent/traces'); + + // Scope pills render as locked — read-only, no Remove (×) affordance. + const rootTypePill = page.locator('[data-property-filter-pill="locked"][data-locked-field-id="rootEntityType"]'); + const entityIdPill = page.locator('[data-property-filter-pill="locked"][data-locked-field-id="entityId"]'); + await expect(rootTypePill).toBeVisible(); + await expect(entityIdPill).toBeVisible(); + await expect(rootTypePill.locator('text="Agent"')).toBeVisible(); + await expect(entityIdPill.locator('text="weather-agent"')).toBeVisible(); + await expect(page.getByRole('button', { name: /Remove Primitive Type filter/i })).toHaveCount(0); + await expect(page.getByRole('button', { name: /Remove Primitive ID filter/i })).toHaveCount(0); + + // Opening the Add Filter dropdown must not expose the scope-controlled fields, + // so users cannot recreate the filter and conflict with the scoped view. + await page.getByRole('button', { name: /Add Filter/i }).click(); + await expect(page.getByRole('menuitem', { name: /Primitive Type/i })).toHaveCount(0); + await expect(page.getByRole('menuitem', { name: /Primitive ID/i })).toHaveCount(0); + await expect(page.getByRole('menuitem', { name: /Primitive Name/i })).toHaveCount(0); + // A non-scope field is still listed so the filter dropdown remains useful. + await expect(page.getByRole('menuitem', { name: /Trace ID/i })).toBeVisible(); + }); + }); - // The global view uses the default (unscoped) key, so it must not read the - // agent-scoped saved set either. - await page.goto('/observability'); - await expect(page).not.toHaveURL(/filterEnvironment=weather-prod/); -}); + test.describe('when filters are saved in an agent-scoped traces tab', () => { + test('does not leak the saved filters to other agents or the global view', async ({ page }) => { + // Why this matters: TracesPage passes a per-agent localStorage key + // (`mastra:traces:saved-filters:agent:<id>`) so that filter preferences saved + // while reviewing weather-agent traces never bleed into another agent's tab + // or the global /observability view. If someone reverts the scoped key (or + // hardcodes the default), this test fails — the regression is otherwise + // silent and only surfaces when two users blame each other for "ghost" + // filters. + await mockSystemPackages(page, true); + await mockTraceLists(page); + + // Land on a page first so we have an origin to seed localStorage against. + await page.goto('/observability'); + await page.evaluate(() => { + localStorage.setItem('mastra:traces:saved-filters:agent:weather-agent', 'filterEnvironment=weather-prod'); + }); + + // Weather-agent should hydrate its own saved filter alongside the scope. + await page.goto('/agents/weather-agent/traces'); + await expect(page).toHaveURL(/filterEnvironment=weather-prod/); + await expect(page).toHaveURL(/filterEntityId=weather-agent/); + + // Another agent must NOT see weather-agent's saved filter. + await page.goto('/agents/om-agent/traces'); + await expect(page).toHaveURL(/filterEntityId=om-agent/); + await expect(page).not.toHaveURL(/filterEnvironment=weather-prod/); + + // The global view uses the default (unscoped) key, so it must not read the + // agent-scoped saved set either. + await page.goto('/observability'); + await expect(page).not.toHaveURL(/filterEnvironment=weather-prod/); + }); + }); -test('global /observability traces page keeps the filter pills editable', async ({ page }) => { - await mockSystemPackages(page, true); + test.describe('when the global /observability traces page is visited', () => { + test('keeps the filter pills editable', async ({ page }) => { + await mockSystemPackages(page, true); - await mockTraceLists(page); + await mockTraceLists(page); - await page.goto('/observability'); + await page.goto('/observability'); - // The Add Filter dropdown surfaces the entity-type field that the agent - // scope hides — guards against accidentally hiding it everywhere. - await page.getByRole('button', { name: /Add Filter/i }).click(); - await expect(page.getByRole('menuitem', { name: /Primitive Type/i })).toBeVisible(); - await expect(page.getByRole('menuitem', { name: /Primitive ID/i })).toBeVisible(); + // The Add Filter dropdown surfaces the entity-type field that the agent + // scope hides — guards against accidentally hiding it everywhere. + await page.getByRole('button', { name: /Add Filter/i }).click(); + await expect(page.getByRole('menuitem', { name: /Primitive Type/i })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: /Primitive ID/i })).toBeVisible(); - // No locked pills should ever render in the global view. - await expect(page.locator('[data-property-filter-pill="locked"]')).toHaveCount(0); + // No locked pills should ever render in the global view. + await expect(page.locator('[data-property-filter-pill="locked"]')).toHaveCount(0); + }); + }); }); diff --git a/packages/playground/e2e/tests/agents/observational-memory.spec.ts b/packages/playground/e2e/tests/agents/observational-memory.spec.ts index cbb75c4e063e..ec5c0eae5071 100644 --- a/packages/playground/e2e/tests/agents/observational-memory.spec.ts +++ b/packages/playground/e2e/tests/agents/observational-memory.spec.ts @@ -1,4 +1,5 @@ -import { test, expect, type Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; import { resetStorage } from '../__utils__/reset-storage'; import { selectFixture } from '../__utils__/select-fixture'; @@ -32,7 +33,7 @@ test.describe('Observational Memory - Behavior Tests', () => { await resetStorage(); }); - test.describe('Sidebar Threshold Rendering', () => { + test.describe('when OM is enabled and the memory sidebar is open', () => { /** * BEHAVIOR: OM sidebar shows progress bars for message and observation thresholds * OUTCOME: User can see how close they are to triggering observation/reflection @@ -95,7 +96,7 @@ test.describe('Observational Memory - Behavior Tests', () => { }); }); - test.describe('Chat History Observation Markers', () => { + test.describe('when messages are sent and observation runs', () => { /** * BEHAVIOR: Observation start marker appears when observation begins * OUTCOME: User sees real-time feedback that memory is being updated @@ -177,7 +178,7 @@ test.describe('Observational Memory - Behavior Tests', () => { }); }); - test.describe('Observation Persistence', () => { + test.describe('when the page is reloaded after an observation', () => { test('should persist observations after page reload', async ({ page }) => { // ARRANGE await selectFixture(page, 'om-observation-success'); @@ -238,7 +239,7 @@ test.describe('Observational Memory - Behavior Tests', () => { }); }); - test.describe('Reflection Behavior', () => { + test.describe('when a reflection occurs', () => { test.skip('should show reflection indicator when reflection occurs', async ({ page }) => { // ARRANGE await selectFixture(page, 'om-reflection'); @@ -272,7 +273,7 @@ test.describe('Observational Memory - Behavior Tests', () => { }); }); - test.describe('Adaptive Threshold', () => { + test.describe('when the agent uses an adaptive threshold', () => { /** * BEHAVIOR: Adaptive threshold shows shared budget in progress bars * OUTCOME: User sees that thresholds adjust based on current observation size @@ -313,7 +314,7 @@ test.describe('Observational Memory - Behavior Tests', () => { }); }); - test.describe('Previous Observations Section', () => { + test.describe('when observation history exists', () => { /** * BEHAVIOR: Previous observations section shows history * OUTCOME: User can see past observation generations @@ -353,70 +354,74 @@ test.describe('Observational Memory - Edge Cases', () => { * BEHAVIOR: OM handles stream interruption gracefully * OUTCOME: User sees "interrupted" state, not stuck loading */ - test('should handle interrupted observation gracefully', async ({ page }) => { - // ARRANGE - await selectFixture(page, 'om-observation-success'); - await page.goto('/agents/om-agent/chat/new'); + test.describe('when an observation stream is interrupted by navigation', () => { + test('should handle interrupted observation gracefully', async ({ page }) => { + // ARRANGE + await selectFixture(page, 'om-observation-success'); + await page.goto('/agents/om-agent/chat/new'); - // ACT: Start a message then navigate away - const chatInput = page.locator('textarea[placeholder*="message"]').first(); - await chatInput.fill('Start processing'); - await chatInput.press('Enter'); + // ACT: Start a message then navigate away + const chatInput = page.locator('textarea[placeholder*="message"]').first(); + await chatInput.fill('Start processing'); + await chatInput.press('Enter'); - // Wait briefly then navigate away - await page.waitForTimeout(500); - await page.goto('/agents'); + // Wait briefly then navigate away + await page.waitForTimeout(500); + await page.goto('/agents'); - // Navigate back - await page.goto('/agents/om-agent/chat/new'); + // Navigate back + await page.goto('/agents/om-agent/chat/new'); - // ASSERT: Page should load without stuck loading states - await expect(page.locator('h2')).toContainText('OM Agent', { timeout: 10000 }); + // ASSERT: Page should load without stuck loading states + await expect(page.locator('h2')).toContainText('OM Agent', { timeout: 10000 }); - // Open the live Memory sidebar to see OM status. - await openMemorySidebar(page); + // Open the live Memory sidebar to see OM status. + await openMemorySidebar(page); - // OM section should not show stuck "observing" state - const omSection = page.getByRole('heading', { name: 'Observational Memory' }); - await expect(omSection).toBeVisible({ timeout: 10000 }); + // OM section should not show stuck "observing" state + const omSection = page.getByRole('heading', { name: 'Observational Memory' }); + await expect(omSection).toBeVisible({ timeout: 10000 }); + }); }); /** * BEHAVIOR: OM works correctly when switching between threads * OUTCOME: Each thread has its own observation state */ - test('should maintain separate observation state per thread', async ({ page }) => { - // ARRANGE - await selectFixture(page, 'om-observation-success'); + test.describe('when switching between threads', () => { + test('should maintain separate observation state per thread', async ({ page }) => { + // ARRANGE + await selectFixture(page, 'om-observation-success'); - // Create first thread - await page.goto('/agents/om-agent/chat/new'); - await expect(page.locator('h2')).toContainText('OM Agent'); + // Create first thread + await page.goto('/agents/om-agent/chat/new'); + await expect(page.locator('h2')).toContainText('OM Agent'); - const chatInput = page.locator('textarea[placeholder*="message"]').first(); - await chatInput.fill('Message in thread 1'); - await chatInput.press('Enter'); - await page.waitForTimeout(3000); + const chatInput = page.locator('textarea[placeholder*="message"]').first(); + await chatInput.fill('Message in thread 1'); + await chatInput.press('Enter'); + await page.waitForTimeout(3000); - // Get first thread URL - const thread1Url = page.url(); + // Get first thread URL + const thread1Url = page.url(); - // ACT: Create second thread - await page.goto('/agents/om-agent/chat/new'); - await expect(page.locator('h2')).toContainText('OM Agent'); + // ACT: Create second thread + await page.goto('/agents/om-agent/chat/new'); + await expect(page.locator('h2')).toContainText('OM Agent'); - // Open the live Memory sidebar to see OM status. - await openMemorySidebar(page); + // Open the live Memory sidebar to see OM status. + await openMemorySidebar(page); - // ASSERT: Second thread should start fresh - // Progress bars should be at 0 or initial state - const omSection = page.getByRole('heading', { name: 'Observational Memory' }); - await expect(omSection).toBeVisible({ timeout: 10000 }); + // ASSERT: Second thread should start fresh + // Progress bars should be at 0 or initial state + const omSection = page.getByRole('heading', { name: 'Observational Memory' }); + await expect(omSection).toBeVisible({ timeout: 10000 }); - // Navigate back to first thread - await page.goto(thread1Url); + // Navigate back to first thread + await page.goto(thread1Url); - // First thread should still have its state - await expect(page.locator('h2')).toContainText('OM Agent', { timeout: 10000 }); + // First thread should still have its state + await expect(page.locator('h2')).toContainText('OM Agent', { timeout: 10000 }); + }); }); }); diff --git a/packages/playground/e2e/tests/agents/page.spec.ts b/packages/playground/e2e/tests/agents/page.spec.ts index fd856e7b1286..028a60f61fa8 100644 --- a/packages/playground/e2e/tests/agents/page.spec.ts +++ b/packages/playground/e2e/tests/agents/page.spec.ts @@ -2,26 +2,32 @@ import { test, expect } from '@playwright/test'; import { resetStorage } from '../__utils__/reset-storage'; import { expectCurrentBreadcrumb, expectRouteDocsLink } from '../__utils__/route-header'; -test.afterEach(async () => { - await resetStorage(); -}); +test.describe('Agents list page', () => { + test.afterEach(async () => { + await resetStorage(); + }); -test('has overall information', async ({ page }) => { - await page.goto('/agents'); + test.describe('when the agents page is visited', () => { + test('shows the page header, docs link, and renders the agent list', async ({ page }) => { + await page.goto('/agents'); - await expect(page).toHaveTitle(/Mastra Studio/); - await expectCurrentBreadcrumb(page, 'Agents'); - await expectRouteDocsLink(page, 'Agents documentation', 'https://mastra.ai/en/docs/agents/overview'); + await expect(page).toHaveTitle(/Mastra Studio/); + await expectCurrentBreadcrumb(page, 'Agents'); + await expectRouteDocsLink(page, 'Agents documentation', 'https://mastra.ai/en/docs/agents/overview'); - // Verify agent list renders with at least one agent - await expect(page.locator('.data-list-row').first()).toBeVisible(); -}); + // Verify agent list renders with at least one agent + await expect(page.locator('.data-list-row').first()).toBeVisible(); + }); + }); -test('clicking on the agent row redirects', async ({ page }) => { - await page.goto('/agents'); + test.describe('when an agent row is clicked', () => { + test('navigates to that agent chat page', async ({ page }) => { + await page.goto('/agents'); - const el = page.locator('a:has-text("Weather Agent")'); - await el.click(); + const el = page.locator('a:has-text("Weather Agent")'); + await el.click(); - await expect(page).toHaveURL(/\/agents\/weather-agent\/chat.*/); + await expect(page).toHaveURL(/\/agents\/weather-agent\/chat.*/); + }); + }); }); diff --git a/packages/playground/e2e/tests/auth/admin-role.spec.ts b/packages/playground/e2e/tests/auth/admin-role.spec.ts index 4289d263cdea..67d84df139d4 100644 --- a/packages/playground/e2e/tests/auth/admin-role.spec.ts +++ b/packages/playground/e2e/tests/auth/admin-role.spec.ts @@ -21,7 +21,7 @@ test.describe('Admin Role', () => { await resetStorage(); }); - test.describe('Navigation Access', () => { + test.describe('when an admin user navigates the studio', () => { test('admin sees all navigation items', async ({ page }) => { await setupAdminAuth(page); await page.goto('/agents'); @@ -54,7 +54,7 @@ test.describe('Admin Role', () => { }); }); - test.describe('Agents Access', () => { + test.describe('when an admin user accesses agents', () => { test('admin can view agents list', async ({ page }) => { await setupAdminAuth(page); await page.goto('/agents'); @@ -111,7 +111,7 @@ test.describe('Admin Role', () => { }); }); - test.describe('Workflows Access', () => { + test.describe('when an admin user accesses workflows', () => { test('admin can view workflows list', async ({ page }) => { await setupAdminAuth(page); await page.goto('/workflows'); @@ -166,7 +166,7 @@ test.describe('Admin Role', () => { }); }); - test.describe('Tools Access', () => { + test.describe('when an admin user accesses tools', () => { test('admin can view tools list', async ({ page }) => { await setupAdminAuth(page); await page.goto('/tools'); @@ -213,7 +213,7 @@ test.describe('Admin Role', () => { }); }); - test.describe('MCP Servers Access', () => { + test.describe('when an admin user accesses MCP servers', () => { test('admin can view MCP servers list', async ({ page }) => { await setupAdminAuth(page); await page.goto('/mcps'); @@ -233,7 +233,7 @@ test.describe('Admin Role', () => { }); }); - test.describe('Full Permission Verification', () => { + test.describe('when verifying the admin permission set', () => { test('admin has wildcard permission', async ({ page }) => { // Set up admin with explicit wildcard permission check await setupMockAuth(page, { @@ -278,7 +278,7 @@ test.describe('Admin Role', () => { }); }); - test.describe('Admin vs Other Roles Comparison', () => { + test.describe('when comparing the admin role to other roles', () => { test('admin has more permissions than member', async ({ page }) => { // First, verify admin can access a page await setupAdminAuth(page); diff --git a/packages/playground/e2e/tests/auth/infrastructure.spec.ts b/packages/playground/e2e/tests/auth/infrastructure.spec.ts index 77d1c3f177c3..824f403d8439 100644 --- a/packages/playground/e2e/tests/auth/infrastructure.spec.ts +++ b/packages/playground/e2e/tests/auth/infrastructure.spec.ts @@ -30,7 +30,7 @@ test.describe('Auth Infrastructure', () => { await resetStorage(); }); - test.describe('Auth Capabilities Mocking', () => { + test.describe('when auth capabilities are mocked', () => { test('can mock admin user capabilities', async ({ page }) => { await setupAdminAuth(page); @@ -97,7 +97,7 @@ test.describe('Auth Infrastructure', () => { }); }); - test.describe('Custom Auth Configuration', () => { + test.describe('when a custom auth configuration is applied', () => { test('can mock custom user data', async ({ page }) => { await setupMockAuth(page, { role: 'member', @@ -175,7 +175,7 @@ test.describe('Auth Infrastructure', () => { }); }); - test.describe('Auth Me Endpoint Mocking', () => { + test.describe('when the auth me endpoint is mocked', () => { test('returns user for authenticated state when called from browser', async ({ page }) => { await setupAdminAuth(page); @@ -215,7 +215,7 @@ test.describe('Auth Infrastructure', () => { }); }); - test.describe('buildAuthCapabilities Helper', () => { + test.describe('when using the buildAuthCapabilities helper', () => { test('builds correct admin capabilities', () => { const capabilities = buildAuthCapabilities({ role: 'admin' }); @@ -241,25 +241,29 @@ test.describe('Auth Infrastructure', () => { }); test.describe('Auth Fixtures', () => { - test('ROLE_PERMISSIONS matches PRD specification', () => { - // Verify role permissions match the PRD - expect(ROLE_PERMISSIONS.admin).toEqual(['*']); - expect(ROLE_PERMISSIONS.member).toEqual(['agents:read', 'workflows:*', 'tools:read', 'tools:execute']); - expect(ROLE_PERMISSIONS.viewer).toEqual(['agents:read', 'workflows:read']); - expect(ROLE_PERMISSIONS._default).toEqual([]); + test.describe('when reading the ROLE_PERMISSIONS fixture', () => { + test('ROLE_PERMISSIONS matches PRD specification', () => { + // Verify role permissions match the PRD + expect(ROLE_PERMISSIONS.admin).toEqual(['*']); + expect(ROLE_PERMISSIONS.member).toEqual(['agents:read', 'workflows:*', 'tools:read', 'tools:execute']); + expect(ROLE_PERMISSIONS.viewer).toEqual(['agents:read', 'workflows:read']); + expect(ROLE_PERMISSIONS._default).toEqual([]); + }); }); - test('MOCK_USERS has all required roles', () => { - expect(MOCK_USERS.admin).toBeDefined(); - expect(MOCK_USERS.member).toBeDefined(); - expect(MOCK_USERS.viewer).toBeDefined(); - expect(MOCK_USERS._default).toBeDefined(); - - // Verify each user has required fields - for (const [, user] of Object.entries(MOCK_USERS)) { - expect(user.id).toBeTruthy(); - expect(user.email).toBeTruthy(); - expect(user.name).toBeTruthy(); - } + test.describe('when reading the MOCK_USERS fixture', () => { + test('MOCK_USERS has all required roles', () => { + expect(MOCK_USERS.admin).toBeDefined(); + expect(MOCK_USERS.member).toBeDefined(); + expect(MOCK_USERS.viewer).toBeDefined(); + expect(MOCK_USERS._default).toBeDefined(); + + // Verify each user has required fields + for (const [, user] of Object.entries(MOCK_USERS)) { + expect(user.id).toBeTruthy(); + expect(user.email).toBeTruthy(); + expect(user.name).toBeTruthy(); + } + }); }); }); diff --git a/packages/playground/e2e/tests/auth/login-flow.spec.ts b/packages/playground/e2e/tests/auth/login-flow.spec.ts index 7d4c1fecf399..039bf7a05457 100644 --- a/packages/playground/e2e/tests/auth/login-flow.spec.ts +++ b/packages/playground/e2e/tests/auth/login-flow.spec.ts @@ -21,7 +21,7 @@ test.describe('Login Flow', () => { await resetStorage(); }); - test.describe('Unauthenticated Access Redirect', () => { + test.describe('when an unauthenticated user requests a protected page', () => { test('unauthenticated user sees login prompt on protected page', async ({ page }) => { await setupUnauthenticated(page); await page.goto('/agents'); @@ -95,7 +95,7 @@ test.describe('Login Flow', () => { }); }); - test.describe('Successful Login', () => { + test.describe('when a user logs in with valid credentials', () => { test('successful login shows authenticated content', async ({ page }) => { // Start unauthenticated await setupUnauthenticated(page); @@ -173,7 +173,7 @@ test.describe('Login Flow', () => { }); }); - test.describe('Invalid Credentials', () => { + test.describe('when a user logs in with invalid credentials', () => { test('shows error for invalid credentials login attempt', async ({ page }) => { // Set up credentials login with mock error response await setupMockAuth(page, { @@ -234,7 +234,7 @@ test.describe('Login Flow', () => { }); }); - test.describe('Session Persistence', () => { + test.describe('when a logged-in session is reloaded', () => { test('authenticated state persists after page reload', async ({ page }) => { // Set up authenticated state await setupAdminAuth(page); @@ -281,7 +281,7 @@ test.describe('Login Flow', () => { }); }); - test.describe('Login State in UI', () => { + test.describe('when a user is logged in', () => { test('authenticated user sees main application content', async ({ page }) => { await setupAdminAuth(page); await page.goto('/agents'); @@ -332,7 +332,7 @@ test.describe('Login Flow', () => { }); }); - test.describe('Sign Up Link', () => { + test.describe('when the login page shows the sign up link', () => { test('sign up link is visible when sign up is enabled', async ({ page }) => { await setupMockAuth(page, { authenticated: false, @@ -376,7 +376,7 @@ test.describe('Login Flow', () => { }); }); - test.describe('Auth Not Configured', () => { + test.describe('when auth is not configured', () => { test('shows appropriate message when auth is disabled', async ({ page }) => { await setupMockAuth(page, { enabled: false, diff --git a/packages/playground/e2e/tests/auth/member-role.spec.ts b/packages/playground/e2e/tests/auth/member-role.spec.ts index 2169257a0469..8cff44e30d7f 100644 --- a/packages/playground/e2e/tests/auth/member-role.spec.ts +++ b/packages/playground/e2e/tests/auth/member-role.spec.ts @@ -21,7 +21,7 @@ test.describe('Member Role', () => { await resetStorage(); }); - test.describe('Navigation Access', () => { + test.describe('when a member user navigates the studio', () => { test('member sees main navigation items', async ({ page }) => { await setupMemberAuth(page); await page.goto('/agents'); @@ -52,7 +52,7 @@ test.describe('Member Role', () => { }); }); - test.describe('Agents Access - Read Only', () => { + test.describe('when a member user accesses agents read-only', () => { test('member can view agents list', async ({ page }) => { await setupMemberAuth(page); await page.goto('/agents'); @@ -130,7 +130,7 @@ test.describe('Member Role', () => { }); }); - test.describe('Workflows Access - Full Permissions', () => { + test.describe('when a member user accesses workflows with full permissions', () => { test('member can view workflows list', async ({ page }) => { await setupMemberAuth(page); await page.goto('/workflows'); @@ -191,7 +191,7 @@ test.describe('Member Role', () => { }); }); - test.describe('Tools Access - Read and Execute', () => { + test.describe('when a member user accesses tools to read and execute', () => { test('member can view tools list', async ({ page }) => { await setupMemberAuth(page); await page.goto('/tools'); @@ -237,7 +237,7 @@ test.describe('Member Role', () => { }); }); - test.describe('Permission Verification', () => { + test.describe('when verifying the member permission set', () => { test('member has correct permissions', async ({ page }) => { // Set up member with explicit permission verification await setupMockAuth(page, { @@ -286,7 +286,7 @@ test.describe('Member Role', () => { }); }); - test.describe('Member vs Other Roles Comparison', () => { + test.describe('when comparing the member role to other roles', () => { test('member has fewer permissions than admin', async ({ page }) => { // First, check member view await setupMemberAuth(page); @@ -353,7 +353,7 @@ test.describe('Member Role', () => { }); }); - test.describe('Restricted Actions', () => { + test.describe('when a member user attempts restricted actions', () => { test('member cannot see admin-only settings', async ({ page }) => { await setupMemberAuth(page); await page.goto('/agents'); diff --git a/packages/playground/e2e/tests/auth/viewer-role.spec.ts b/packages/playground/e2e/tests/auth/viewer-role.spec.ts index c94bf53b4728..c86d634220e0 100644 --- a/packages/playground/e2e/tests/auth/viewer-role.spec.ts +++ b/packages/playground/e2e/tests/auth/viewer-role.spec.ts @@ -16,15 +16,15 @@ import { test, expect } from '@playwright/test'; import { setupViewerAuth, setupMockAuth } from '../__utils__/auth'; -import { expectCurrentBreadcrumb } from '../__utils__/route-header'; import { resetStorage } from '../__utils__/reset-storage'; +import { expectCurrentBreadcrumb } from '../__utils__/route-header'; test.describe('Viewer Role', () => { test.afterEach(async () => { await resetStorage(); }); - test.describe('Navigation Access', () => { + test.describe('when a viewer user navigates the studio', () => { // TODO: Re-enable after the viewer RBAC/sidebar expectations are reconciled with // the current Observability section behavior: Metrics stays visible, so the // section header can still render even when Traces is hidden. @@ -64,7 +64,7 @@ test.describe('Viewer Role', () => { }); }); - test.describe('Agents Access - Read Only', () => { + test.describe('when a viewer user accesses agents read-only', () => { test('viewer can view agents list', async ({ page }) => { await setupViewerAuth(page); await page.goto('/agents'); @@ -123,7 +123,7 @@ test.describe('Viewer Role', () => { }); }); - test.describe('Workflows Access - Read Only', () => { + test.describe('when a viewer user accesses workflows read-only', () => { test('viewer can view workflows list', async ({ page }) => { await setupViewerAuth(page); await page.goto('/workflows'); @@ -196,7 +196,7 @@ test.describe('Viewer Role', () => { }); }); - test.describe('Tools Access - No Permission', () => { + test.describe('when a viewer user accesses tools without permission', () => { test('viewer navigating to tools page handles gracefully', async ({ page }) => { await setupViewerAuth(page); await page.goto('/tools'); @@ -225,7 +225,7 @@ test.describe('Viewer Role', () => { }); }); - test.describe('Permission Verification', () => { + test.describe('when verifying the viewer permission set', () => { test('viewer has correct read-only permissions', async ({ page }) => { // Set up viewer with explicit permission verification await setupMockAuth(page, { @@ -272,7 +272,7 @@ test.describe('Viewer Role', () => { }); }); - test.describe('Viewer vs Other Roles Comparison', () => { + test.describe('when comparing the viewer role to other roles', () => { test('viewer has fewer permissions than admin', async ({ page }) => { // First, check viewer view await setupViewerAuth(page); @@ -351,7 +351,7 @@ test.describe('Viewer Role', () => { }); }); - test.describe('Read-Only UI State', () => { + test.describe('when the viewer-only UI state is rendered', () => { test('viewer sees read-only agent chat with disabled input', async ({ page }) => { await setupViewerAuth(page); await page.goto('/agents/weather-agent/chat'); @@ -399,7 +399,7 @@ test.describe('Viewer Role', () => { }); }); - test.describe('Action Buttons Verification', () => { + test.describe('when verifying action buttons for a viewer', () => { test('action buttons are hidden or disabled on agents page', async ({ page }) => { await setupViewerAuth(page); await page.goto('/agents'); diff --git a/packages/playground/e2e/tests/cms/agents/code-agent-override.spec.ts b/packages/playground/e2e/tests/cms/agents/code-agent-override.spec.ts index 3c84fdc175ee..4aaada5ef1d0 100644 --- a/packages/playground/e2e/tests/cms/agents/code-agent-override.spec.ts +++ b/packages/playground/e2e/tests/cms/agents/code-agent-override.spec.ts @@ -13,122 +13,130 @@ test.describe('code-mode agent override', () => { await resetStorage(); }); - test('editable local code agent saves to filesystem and can download JSON', async ({ page, request }) => { - await page.goto('/agents/code-override-editable/editor'); - - // Local code mode exposes a filesystem write, plus Download JSON. Platform - // Open PR is only shown when a platform/GitHub App endpoint is configured. - const downloadButton = page.getByRole('button', { name: /Download JSON/i }); - const saveToFilesystemButton = page.getByRole('button', { name: /Save to filesystem/i }); - await expect(downloadButton).toBeVisible(); - await expect(saveToFilesystemButton).toBeVisible(); - await expect(page.getByRole('button', { name: /Open PR/i })).toHaveCount(0); - - // Save New Version / Publish belong to the db-mode stored-agent flow and must NOT appear. - await expect(page.getByRole('button', { name: /^Save New Version$/i })).toHaveCount(0); - await expect(page.getByRole('button', { name: /^Publish$/i })).toHaveCount(0); - - const getVersionCount = async () => { - const versions = await request - .get('/api/stored/agents/code-override-editable/versions') - .then(r => r.json() as Promise<{ versions: unknown[] }>); - return versions.versions.length; - }; - - const initialVersionCount = await getVersionCount(); - - await page.getByRole('button', { name: /System Prompt/i }).click(); - await page.locator('.cm-content').first().click(); - await page.keyboard.type('\nLocal filesystem save from e2e.'); - await expect(saveToFilesystemButton).toBeEnabled(); - await saveToFilesystemButton.click(); - - // Code mode treats local saves as commit-less drafts: each save should - // overwrite the rolling snapshot rather than grow version history. - await expect.poll(getVersionCount).toBe(initialVersionCount); - - await page.locator('.cm-content').first().click(); - await page.keyboard.type(' Another tweak.'); - await saveToFilesystemButton.click(); - - await expect.poll(getVersionCount).toBe(initialVersionCount); - - const downloadPromise = page.waitForEvent('download'); - await downloadButton.click(); - const download = await downloadPromise; - expect(download.suggestedFilename()).toBe('agents_code-override-editable.json'); - await expect(download.failure()).resolves.toBeNull(); + test.describe('when an editable code-mode agent is opened', () => { + test('editable local code agent saves to filesystem and can download JSON', async ({ page, request }) => { + await page.goto('/agents/code-override-editable/editor'); + + // Local code mode exposes a filesystem write, plus Download JSON. Platform + // Open PR is only shown when a platform/GitHub App endpoint is configured. + const downloadButton = page.getByRole('button', { name: /Download JSON/i }); + const saveToFilesystemButton = page.getByRole('button', { name: /Save to filesystem/i }); + await expect(downloadButton).toBeVisible(); + await expect(saveToFilesystemButton).toBeVisible(); + await expect(page.getByRole('button', { name: /Open PR/i })).toHaveCount(0); + + // Save New Version / Publish belong to the db-mode stored-agent flow and must NOT appear. + await expect(page.getByRole('button', { name: /^Save New Version$/i })).toHaveCount(0); + await expect(page.getByRole('button', { name: /^Publish$/i })).toHaveCount(0); + + const getVersionCount = async () => { + const versions = await request + .get('/api/stored/agents/code-override-editable/versions') + .then(r => r.json() as Promise<{ versions: unknown[] }>); + return versions.versions.length; + }; + + const initialVersionCount = await getVersionCount(); + + await page.getByRole('button', { name: /System Prompt/i }).click(); + await page.locator('.cm-content').first().click(); + await page.keyboard.type('\nLocal filesystem save from e2e.'); + await expect(saveToFilesystemButton).toBeEnabled(); + await saveToFilesystemButton.click(); + + // Code mode treats local saves as commit-less drafts: each save should + // overwrite the rolling snapshot rather than grow version history. + await expect.poll(getVersionCount).toBe(initialVersionCount); + + await page.locator('.cm-content').first().click(); + await page.keyboard.type(' Another tweak.'); + await saveToFilesystemButton.click(); + + await expect.poll(getVersionCount).toBe(initialVersionCount); + + const downloadPromise = page.waitForEvent('download'); + await downloadButton.click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('agents_code-override-editable.json'); + await expect(download.failure()).resolves.toBeNull(); + }); }); - test('export endpoint returns a deterministic JSON payload for code-mode overrides', async ({ request }) => { - // The Download JSON button calls /stored/agents/:id/export. Hit the endpoint - // directly so we can assert on the actual exported payload — that is what - // a user would commit to git when reviewing the change. - const response = await request.post('/api/stored/agents/code-override-editable/export', { - data: { instructions: 'Override instructions from export endpoint.' }, + test.describe('when editable code-mode overrides are exported', () => { + test('export endpoint returns a deterministic JSON payload for code-mode overrides', async ({ request }) => { + // The Download JSON button calls /stored/agents/:id/export. Hit the endpoint + // directly so we can assert on the actual exported payload — that is what + // a user would commit to git when reviewing the change. + const response = await request.post('/api/stored/agents/code-override-editable/export', { + data: { instructions: 'Override instructions from export endpoint.' }, + }); + expect(response.ok()).toBe(true); + + const body = (await response.json()) as { + agentId: string; + fileName: string; + content: string; + config: Record<string, unknown>; + }; + + // Filename is deterministic and includes the source-control agent directory + // so committed JSON files land at the same path used by proposal branches. + expect(body.agentId).toBe('code-override-editable'); + expect(body.fileName).toBe('agents/code-override-editable.json'); + + // Round-tripping content matches config so consumers can use either. + const parsedContent = JSON.parse(body.content) as Record<string, unknown>; + expect(parsedContent).toEqual(body.config); + expect(parsedContent.instructions).toBe('Override instructions from export endpoint.'); + + // Code-mode exports only carry user-editable overrides. `model` and `name` + // are owned by the code definition and must not appear in the committed JSON. + expect(parsedContent).not.toHaveProperty('model'); + expect(parsedContent).not.toHaveProperty('name'); }); - expect(response.ok()).toBe(true); - - const body = (await response.json()) as { - agentId: string; - fileName: string; - content: string; - config: Record<string, unknown>; - }; - - // Filename is deterministic and includes the source-control agent directory - // so committed JSON files land at the same path used by proposal branches. - expect(body.agentId).toBe('code-override-editable'); - expect(body.fileName).toBe('agents/code-override-editable.json'); - - // Round-tripping content matches config so consumers can use either. - const parsedContent = JSON.parse(body.content) as Record<string, unknown>; - expect(parsedContent).toEqual(body.config); - expect(parsedContent.instructions).toBe('Override instructions from export endpoint.'); - - // Code-mode exports only carry user-editable overrides. `model` and `name` - // are owned by the code definition and must not appear in the committed JSON. - expect(parsedContent).not.toHaveProperty('model'); - expect(parsedContent).not.toHaveProperty('name'); }); - test('locked code agent (editor: false) hides override editing entirely', async ({ page }) => { - await page.goto('/agents/code-override-locked/editor'); - - // When editor: false the agent opts out of all overrides — Studio must not - // surface code-mode write/export actions because no field is editable. - await expect(page.getByRole('button', { name: /Download JSON/i })).toHaveCount(0); - await expect(page.getByRole('button', { name: /Save to filesystem/i })).toHaveCount(0); - await expect(page.getByRole('button', { name: /Open PR/i })).toHaveCount(0); - - // Save New Version / Publish belong to the stored-agent flow and are also - // inappropriate here — this is still a code agent, just an immutable one. - await expect(page.getByRole('button', { name: /^Save New Version$/i })).toHaveCount(0); - await expect(page.getByRole('button', { name: /^Publish$/i })).toHaveCount(0); - - // The user-facing Editor tab must be read-only and hide block-level edit controls. - await expect(page.getByText(/Read-only/i)).toBeVisible(); - await page.getByRole('button', { name: /System Prompt/i }).click(); - await expect(page.getByRole('button', { name: /Save as prompt block/i })).toHaveCount(0); - await expect(page.getByRole('button', { name: /Delete block/i })).toHaveCount(0); - - // Tools tab must show the same locked messaging as System Prompt and - // hide add/remove controls — tools are code-owned for editor: false agents. - await page.getByRole('button', { name: /^Tools$/i }).click(); - await expect(page.getByText(/Tools are owned by code/i)).toBeVisible(); - await expect(page.getByRole('button', { name: /Add Tools/i })).toHaveCount(0); + test.describe('when a locked code-mode agent (editor: false) is opened', () => { + test('locked code agent (editor: false) hides override editing entirely', async ({ page }) => { + await page.goto('/agents/code-override-locked/editor'); + + // When editor: false the agent opts out of all overrides — Studio must not + // surface code-mode write/export actions because no field is editable. + await expect(page.getByRole('button', { name: /Download JSON/i })).toHaveCount(0); + await expect(page.getByRole('button', { name: /Save to filesystem/i })).toHaveCount(0); + await expect(page.getByRole('button', { name: /Open PR/i })).toHaveCount(0); + + // Save New Version / Publish belong to the stored-agent flow and are also + // inappropriate here — this is still a code agent, just an immutable one. + await expect(page.getByRole('button', { name: /^Save New Version$/i })).toHaveCount(0); + await expect(page.getByRole('button', { name: /^Publish$/i })).toHaveCount(0); + + // The user-facing Editor tab must be read-only and hide block-level edit controls. + await expect(page.getByText(/Read-only/i)).toBeVisible(); + await page.getByRole('button', { name: /System Prompt/i }).click(); + await expect(page.getByRole('button', { name: /Save as prompt block/i })).toHaveCount(0); + await expect(page.getByRole('button', { name: /Delete block/i })).toHaveCount(0); + + // Tools tab must show the same locked messaging as System Prompt and + // hide add/remove controls — tools are code-owned for editor: false agents. + await page.getByRole('button', { name: /^Tools$/i }).click(); + await expect(page.getByText(/Tools are owned by code/i)).toBeVisible(); + await expect(page.getByRole('button', { name: /Add Tools/i })).toHaveCount(0); + }); }); - test('locked code agent enforces editor: false on the server export endpoint', async ({ request }) => { - // The server must refuse to bake overrides into an export for a code agent - // that declared `editor: false`, even if the request body provides fields. - const response = await request.post('/api/stored/agents/code-override-locked/export', { - data: { instructions: 'Attempted override that should be dropped.' }, + test.describe('when locked code-mode overrides are exported', () => { + test('locked code agent enforces editor: false on the server export endpoint', async ({ request }) => { + // The server must refuse to bake overrides into an export for a code agent + // that declared `editor: false`, even if the request body provides fields. + const response = await request.post('/api/stored/agents/code-override-locked/export', { + data: { instructions: 'Attempted override that should be dropped.' }, + }); + expect(response.ok()).toBe(true); + const body = (await response.json()) as { config: Record<string, unknown> }; + + // The locked agent owns instructions in code — no override survives. + expect(body.config.instructions).toBeUndefined(); }); - expect(response.ok()).toBe(true); - const body = (await response.json()) as { config: Record<string, unknown> }; - - // The locked agent owns instructions in code — no override survives. - expect(body.config.instructions).toBeUndefined(); }); }); diff --git a/packages/playground/e2e/tests/cms/agents/create/page.spec.ts b/packages/playground/e2e/tests/cms/agents/create/page.spec.ts index 9cda80660165..a8a485a5195c 100644 --- a/packages/playground/e2e/tests/cms/agents/create/page.spec.ts +++ b/packages/playground/e2e/tests/cms/agents/create/page.spec.ts @@ -1,6 +1,7 @@ -import { test, expect, Page } from '@playwright/test'; -import { expectCurrentBreadcrumb } from '../../../__utils__/route-header'; +import type { Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; import { resetStorage } from '../../../__utils__/reset-storage'; +import { expectCurrentBreadcrumb } from '../../../__utils__/route-header'; // The legacy `/cms/agents/create` wizard is no longer the live agent-creation // entrypoint — Studio now routes users to `/agent-builder/agents/create` @@ -115,709 +116,727 @@ test.afterEach(async () => { await resetStorage(); }); -test.describe('Page Structure & Initial State', () => { - test('displays page title and header correctly', async ({ page }) => { - await page.goto('/cms/agents/create'); +test.describe('CMS create agent page', () => { + test.describe('when the create page first loads', () => { + test('displays page title and header correctly', async ({ page }) => { + await page.goto('/cms/agents/create'); - await expect(page).toHaveTitle(/Mastra Studio/); - await expectCurrentBreadcrumb(page, 'Create agent'); - }); + await expect(page).toHaveTitle(/Mastra Studio/); + await expectCurrentBreadcrumb(page, 'Create agent'); + }); - test('displays Create agent button disabled until required fields are filled', async ({ page }) => { - await page.goto('/cms/agents/create'); + test('displays Create agent button disabled until required fields are filled', async ({ page }) => { + await page.goto('/cms/agents/create'); - const createButton = page.getByRole('button', { name: 'Create agent' }); - await expect(createButton).toBeVisible(); - // Button should be disabled when form is empty - await expect(createButton).toBeDisabled(); - }); + const createButton = page.getByRole('button', { name: 'Create agent' }); + await expect(createButton).toBeVisible(); + // Button should be disabled when form is empty + await expect(createButton).toBeDisabled(); + }); - test('displays sidebar navigation with all pages', async ({ page }) => { - await page.goto('/cms/agents/create'); + test('displays sidebar navigation with all pages', async ({ page }) => { + await page.goto('/cms/agents/create'); - // Verify each sidebar link exists by exact href - for (const [, suffix] of Object.entries(SIDEBAR_PATHS)) { - const href = `/cms/agents/create${suffix}`; - await expect(page.locator(`a[href="${href}"]`)).toBeVisible(); - } + // Verify each sidebar link exists by exact href + for (const [, suffix] of Object.entries(SIDEBAR_PATHS)) { + const href = `/cms/agents/create${suffix}`; + await expect(page.locator(`a[href="${href}"]`)).toBeVisible(); + } + }); }); -}); -test.describe('Create Button Enable/Disable Behavior', () => { - test('button stays disabled when only partial identity fields are filled', async ({ page }) => { - await page.goto('/cms/agents/create'); + test.describe('when only agent name is filled', () => { + test('button stays disabled', async ({ page }) => { + await page.goto('/cms/agents/create'); + + const createButton = page.getByRole('button', { name: 'Create agent' }); - const createButton = page.getByRole('button', { name: 'Create agent' }); + // Fill only name — missing provider, model, and instructions + const nameInput = page.locator('#agent-name'); + await nameInput.fill('Test Agent'); - // Fill only name — missing provider, model, and instructions - const nameInput = page.locator('#agent-name'); - await nameInput.fill('Test Agent'); + await expect(createButton).toBeDisabled(); + }); + }); - await expect(createButton).toBeDisabled(); + test.describe('when agent name and provider are filled without a model', () => { + test('button stays disabled', async ({ page }) => { + await page.goto('/cms/agents/create'); - // Fill provider but not model - const providerCombobox = page.getByRole('combobox').nth(0); - await providerCombobox.click(); - await page.getByRole('option', { name: 'OpenAI' }).click(); + const createButton = page.getByRole('button', { name: 'Create agent' }); - await expect(createButton).toBeDisabled(); + const nameInput = page.locator('#agent-name'); + await nameInput.fill('Test Agent'); + + const providerCombobox = page.getByRole('combobox').nth(0); + await providerCombobox.click(); + await page.getByRole('option', { name: 'OpenAI' }).click(); + + await expect(createButton).toBeDisabled(); + }); }); - test('button stays disabled when identity is complete but instructions are empty', async ({ page }) => { - await page.goto('/cms/agents/create'); + test.describe('when identity fields are complete but instructions are empty', () => { + test('button stays disabled when identity is complete but instructions are empty', async ({ page }) => { + await page.goto('/cms/agents/create'); - const createButton = page.getByRole('button', { name: 'Create agent' }); + const createButton = page.getByRole('button', { name: 'Create agent' }); - // Fill all identity fields - await fillIdentityFields(page, { name: 'Test Agent' }); + // Fill all identity fields + await fillIdentityFields(page, { name: 'Test Agent' }); - // Button should still be disabled because instructions are empty - await expect(createButton).toBeDisabled(); + // Button should still be disabled because instructions are empty + await expect(createButton).toBeDisabled(); + }); }); - test('button becomes enabled when all required fields are filled', async ({ page }) => { - await page.goto('/cms/agents/create'); + test.describe('when all required fields are filled', () => { + test('button becomes enabled when all required fields are filled', async ({ page }) => { + await page.goto('/cms/agents/create'); - const createButton = page.getByRole('button', { name: 'Create agent' }); + const createButton = page.getByRole('button', { name: 'Create agent' }); - // Initially disabled - await expect(createButton).toBeDisabled(); + // Initially disabled + await expect(createButton).toBeDisabled(); - // Fill all required fields - await fillRequiredFields(page); + // Fill all required fields + await fillRequiredFields(page); - // Now enabled - await expect(createButton).toBeEnabled(); + // Now enabled + await expect(createButton).toBeEnabled(); + }); }); - test('button becomes disabled again when required field is cleared', async ({ page }) => { - await page.goto('/cms/agents/create'); + test.describe('when a required field is cleared after the form is enabled', () => { + test('button becomes disabled again when required field is cleared', async ({ page }) => { + await page.goto('/cms/agents/create'); - const createButton = page.getByRole('button', { name: 'Create agent' }); + const createButton = page.getByRole('button', { name: 'Create agent' }); - // Fill all required fields - await fillRequiredFields(page); - await expect(createButton).toBeEnabled(); + // Fill all required fields + await fillRequiredFields(page); + await expect(createButton).toBeEnabled(); - // Go back to identity and clear the name - await clickSidebarLink(page, 'Identity'); - const nameInput = page.locator('#agent-name'); - await nameInput.clear(); + // Go back to identity and clear the name + await clickSidebarLink(page, 'Identity'); + const nameInput = page.locator('#agent-name'); + await nameInput.clear(); - // Button should be disabled again - await expect(createButton).toBeDisabled(); + // Button should be disabled again + await expect(createButton).toBeDisabled(); + }); }); -}); -test.describe('Agent Creation Persistence - Identity', () => { - test('creates agent with minimal required fields and verifies on edit page', async ({ page }) => { - await page.goto('/cms/agents/create'); + test.describe('when an agent is created with identity fields', () => { + test('creates agent with minimal required fields and verifies on edit page', async ({ page }) => { + await page.goto('/cms/agents/create'); - const agentName = uniqueAgentName('Minimal'); - await fillRequiredFields(page, agentName); + const agentName = uniqueAgentName('Minimal'); + await fillRequiredFields(page, agentName); - const agentId = await createAgentAndGetId(page); + const agentId = await createAgentAndGetId(page); - // On edit page, the version selector precedes provider and model. - await goToEditSubPage(page, agentId); + // On edit page, the version selector precedes provider and model. + await goToEditSubPage(page, agentId); - await expect(page.locator('#agent-name')).toHaveValue(agentName); - await expect(page.getByRole('combobox').nth(1)).toContainText('OpenAI'); - await expect(page.getByRole('combobox').nth(2)).toContainText('gpt-4o-mini'); - }); + await expect(page.locator('#agent-name')).toHaveValue(agentName); + await expect(page.getByRole('combobox').nth(1)).toContainText('OpenAI'); + await expect(page.getByRole('combobox').nth(2)).toContainText('gpt-4o-mini'); + }); - test('persists all identity fields (name, description, provider, model)', async ({ page }) => { - await page.goto('/cms/agents/create'); + test('persists all identity fields (name, description, provider, model)', async ({ page }) => { + await page.goto('/cms/agents/create'); - const agentName = uniqueAgentName('Full Identity'); - const description = 'A comprehensive test agent for E2E testing'; + const agentName = uniqueAgentName('Full Identity'); + const description = 'A comprehensive test agent for E2E testing'; - await fillIdentityFields(page, { name: agentName, description }); + await fillIdentityFields(page, { name: agentName, description }); - // Add instruction block via sidebar - await clickSidebarLink(page, 'Instructions'); - const editor = page.locator('.cm-content').first(); - await editor.click(); - await page.keyboard.type('You are a test agent.'); + // Add instruction block via sidebar + await clickSidebarLink(page, 'Instructions'); + const editor = page.locator('.cm-content').first(); + await editor.click(); + await page.keyboard.type('You are a test agent.'); - const agentId = await createAgentAndGetId(page); + const agentId = await createAgentAndGetId(page); - await goToEditSubPage(page, agentId); + await goToEditSubPage(page, agentId); - await expect(page.locator('#agent-name')).toHaveValue(agentName); - await expect(page.locator('#agent-description')).toHaveValue(description); - await expect(page.getByRole('combobox').nth(1)).toContainText('OpenAI'); - await expect(page.getByRole('combobox').nth(2)).toContainText('gpt-4o-mini'); + await expect(page.locator('#agent-name')).toHaveValue(agentName); + await expect(page.locator('#agent-description')).toHaveValue(description); + await expect(page.getByRole('combobox').nth(1)).toContainText('OpenAI'); + await expect(page.getByRole('combobox').nth(2)).toContainText('gpt-4o-mini'); + }); }); -}); -test.describe('Agent Creation Persistence - Instruction Blocks', () => { - test('persists single instruction block with content', async ({ page }) => { - await page.goto('/cms/agents/create'); + test.describe('when an agent is created with instruction blocks', () => { + test('persists single instruction block with content', async ({ page }) => { + await page.goto('/cms/agents/create'); - const agentName = uniqueAgentName('Single Block'); - const instructionContent = 'You are a helpful assistant that answers questions accurately.'; + const agentName = uniqueAgentName('Single Block'); + const instructionContent = 'You are a helpful assistant that answers questions accurately.'; - await fillIdentityFields(page, { name: agentName }); + await fillIdentityFields(page, { name: agentName }); - // Navigate to instruction blocks via sidebar - await clickSidebarLink(page, 'Instructions'); - const editor = page.locator('.cm-content').first(); - await editor.click(); - await page.keyboard.type(instructionContent); + // Navigate to instruction blocks via sidebar + await clickSidebarLink(page, 'Instructions'); + const editor = page.locator('.cm-content').first(); + await editor.click(); + await page.keyboard.type(instructionContent); - const agentId = await createAgentAndGetId(page); + const agentId = await createAgentAndGetId(page); - // Verify on edit page - await page.goto(`/cms/agents/${agentId}/edit/instruction-blocks`); - await page.waitForTimeout(2000); + // Verify on edit page + await page.goto(`/cms/agents/${agentId}/edit/instruction-blocks`); + await page.waitForTimeout(2000); - await expect(page.locator('.cm-content').first()).toContainText(instructionContent, { timeout: 10000 }); - }); + await expect(page.locator('.cm-content').first()).toContainText(instructionContent, { timeout: 10000 }); + }); - test('persists multiple instruction blocks in order', async ({ page }) => { - await page.goto('/cms/agents/create'); + test('persists multiple instruction blocks in order', async ({ page }) => { + await page.goto('/cms/agents/create'); - const agentName = uniqueAgentName('Multi Block'); - const block1Content = 'You are a helpful assistant.'; - const block2Content = 'Always be polite and concise.'; + const agentName = uniqueAgentName('Multi Block'); + const block1Content = 'You are a helpful assistant.'; + const block2Content = 'Always be polite and concise.'; - await fillIdentityFields(page, { name: agentName }); + await fillIdentityFields(page, { name: agentName }); - // Navigate to instruction blocks via sidebar - await clickSidebarLink(page, 'Instructions'); + // Navigate to instruction blocks via sidebar + await clickSidebarLink(page, 'Instructions'); - // Fill first block - const editor1 = page.locator('.cm-content').first(); - await editor1.click(); - await page.keyboard.type(block1Content); + // Fill first block + const editor1 = page.locator('.cm-content').first(); + await editor1.click(); + await page.keyboard.type(block1Content); - // Add second block — click the add-block dropdown trigger (small + icon button), then select inline option - await page.locator('button[aria-haspopup="menu"]').click({ force: true, timeout: 10000 }); - await page.getByRole('menuitem', { name: 'Write inline block' }).click(); - await page.waitForTimeout(500); + // Add second block — click the add-block dropdown trigger (small + icon button), then select inline option + await page.locator('button[aria-haspopup="menu"]').click({ force: true, timeout: 10000 }); + await page.getByRole('menuitem', { name: 'Write inline block' }).click(); + await page.waitForTimeout(500); - // Fill second block - const editor2 = page.locator('.cm-content').nth(1); - await editor2.click(); - await page.keyboard.type(block2Content); + // Fill second block + const editor2 = page.locator('.cm-content').nth(1); + await editor2.click(); + await page.keyboard.type(block2Content); - const agentId = await createAgentAndGetId(page); + const agentId = await createAgentAndGetId(page); - // Verify on edit page - await page.goto(`/cms/agents/${agentId}/edit/instruction-blocks`); - await page.waitForTimeout(2000); + // Verify on edit page + await page.goto(`/cms/agents/${agentId}/edit/instruction-blocks`); + await page.waitForTimeout(2000); - await expect(page.locator('.cm-content').first()).toContainText(block1Content, { timeout: 10000 }); - await expect(page.locator('.cm-content').nth(1)).toContainText(block2Content, { timeout: 10000 }); + await expect(page.locator('.cm-content').first()).toContainText(block1Content, { timeout: 10000 }); + await expect(page.locator('.cm-content').nth(1)).toContainText(block2Content, { timeout: 10000 }); + }); }); -}); -test.describe('Agent Creation Persistence - Tools', () => { - test('persists selected tools', async ({ page }) => { - await page.goto('/cms/agents/create'); + test.describe('when an agent is created with selected tools', () => { + test('persists selected tools', async ({ page }) => { + await page.goto('/cms/agents/create'); - const agentName = uniqueAgentName('Tools'); - await fillRequiredFields(page, agentName); + const agentName = uniqueAgentName('Tools'); + await fillRequiredFields(page, agentName); - // Navigate to tools page via sidebar - await clickSidebarLink(page, 'Tools'); + // Navigate to tools page via sidebar + await clickSidebarLink(page, 'Tools'); - // Click "Add Tools" to open popover and select weatherInfo - await page.getByRole('button', { name: 'Add Tools' }).click({ timeout: 10000 }); - await page.getByText('weatherInfo').click(); + // Click "Add Tools" to open popover and select weatherInfo + await page.getByRole('button', { name: 'Add Tools' }).click({ timeout: 10000 }); + await page.getByText('weatherInfo').click(); - // Verify it appears in the selected list - await expect(page.getByLabel('Remove weatherInfo')).toBeVisible({ timeout: 5000 }); + // Verify it appears in the selected list + await expect(page.getByLabel('Remove weatherInfo')).toBeVisible({ timeout: 5000 }); - const agentId = await createAgentAndGetId(page); + const agentId = await createAgentAndGetId(page); - // Verify on edit page - await page.goto(`/cms/agents/${agentId}/edit/tools`); - await page.waitForTimeout(2000); + // Verify on edit page + await page.goto(`/cms/agents/${agentId}/edit/tools`); + await page.waitForTimeout(2000); - await expect(page.getByText('weatherInfo')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('weatherInfo')).toBeVisible({ timeout: 10000 }); + }); }); -}); -test.describe('Agent Creation Persistence - MCP Client Tools', () => { - /** - * FEATURE: MCP Client Tool Selection - * USER STORY: As a user, I want to select which MCP tools my agent can use - * so that I can control the agent's capabilities - * BEHAVIOR UNDER TEST: Selected MCP tools persist after agent creation and reload - */ - test('persists selected MCP client tools', async ({ page }) => { - await page.goto('/cms/agents/create'); + test.describe('when an agent is created with MCP client tools', () => { + /** + * FEATURE: MCP Client Tool Selection + * USER STORY: As a user, I want to select which MCP tools my agent can use + * so that I can control the agent's capabilities + * BEHAVIOR UNDER TEST: Selected MCP tools persist after agent creation and reload + */ + test('persists selected MCP client tools', async ({ page }) => { + await page.goto('/cms/agents/create'); - const agentName = uniqueAgentName('MCP Tools'); - await fillRequiredFields(page, agentName); + const agentName = uniqueAgentName('MCP Tools'); + await fillRequiredFields(page, agentName); - // Navigate to tools page via sidebar - await clickSidebarLink(page, 'Tools'); + // Navigate to tools page via sidebar + await clickSidebarLink(page, 'Tools'); - // Click "Add MCP Client" button - await page.getByRole('button', { name: 'Add MCP Client' }).first().click(); + // Click "Add MCP Client" button + await page.getByRole('button', { name: 'Add MCP Client' }).first().click(); - // Wait for the side dialog to open - await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + // Wait for the side dialog to open + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); - // Fill MCP client name - await page.locator('#mcp-client-name').fill('Test MCP Client'); + // Fill MCP client name + await page.locator('#mcp-client-name').fill('Test MCP Client'); - // The kitchen-sink exposes the simple-mcp-server at /api/mcp/simple-mcp-server/mcp - // Fill URL field (HTTP is default) - await page.locator('#mcp-url').fill('http://localhost:4111/api/mcp/simple-mcp-server/mcp'); + // The kitchen-sink exposes the simple-mcp-server at /api/mcp/simple-mcp-server/mcp + // Fill URL field (HTTP is default) + await page.locator('#mcp-url').fill('http://localhost:4111/api/mcp/simple-mcp-server/mcp'); - // Click "Try to connect" button - await page.getByRole('button', { name: /try to connect/i }).click(); + // Click "Try to connect" button + await page.getByRole('button', { name: /try to connect/i }).click(); - // Wait for tools to appear in the preview panel - await expect(page.getByRole('dialog').getByText('simpleMcpTool')).toBeVisible({ timeout: 10000 }); + // Wait for tools to appear in the preview panel + await expect(page.getByRole('dialog').getByText('simpleMcpTool')).toBeVisible({ timeout: 10000 }); - // The tool should have a switch - verify it's initially unchecked (default: unselected) - const toolSwitch = page.getByRole('dialog').getByRole('switch').first(); - await expect(toolSwitch).not.toBeChecked(); + // The tool should have a switch - verify it's initially unchecked (default: unselected) + const toolSwitch = page.getByRole('dialog').getByRole('switch').first(); + await expect(toolSwitch).not.toBeChecked(); - // Toggle the tool ON - await toolSwitch.click(); - await expect(toolSwitch).toBeChecked(); + // Toggle the tool ON + await toolSwitch.click(); + await expect(toolSwitch).toBeChecked(); - // Header should show "1/1 selected" - await expect(page.getByText(/1\/1 selected/)).toBeVisible(); + // Header should show "1/1 selected" + await expect(page.getByText(/1\/1 selected/)).toBeVisible(); - // Click "Create MCP Client" button to confirm - await page.getByRole('button', { name: /create mcp client/i }).click(); + // Click "Create MCP Client" button to confirm + await page.getByRole('button', { name: /create mcp client/i }).click(); - // Wait for dialog to close - await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 }); + // Wait for dialog to close + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 }); - // The MCP client should now appear in the list - await expect(page.getByText('Test MCP Client')).toBeVisible(); + // The MCP client should now appear in the list + await expect(page.getByText('Test MCP Client')).toBeVisible(); - // Create the agent - const agentId = await createAgentAndGetId(page); + // Create the agent + const agentId = await createAgentAndGetId(page); - // Verify on edit page - navigate to tools - await page.goto(`/cms/agents/${agentId}/edit/tools`); - await page.waitForTimeout(2000); + // Verify on edit page - navigate to tools + await page.goto(`/cms/agents/${agentId}/edit/tools`); + await page.waitForTimeout(2000); - // The MCP client should be visible - await expect(page.getByText('Test MCP Client')).toBeVisible({ timeout: 10000 }); + // The MCP client should be visible + await expect(page.getByText('Test MCP Client')).toBeVisible({ timeout: 10000 }); - // Click on the MCP client to view it - await page.getByText('Test MCP Client').click(); + // Click on the MCP client to view it + await page.getByText('Test MCP Client').click(); - // Wait for dialog to open and connect - await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + // Wait for dialog to open and connect + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); - // Wait for tools to load (auto-connects in view mode) - await expect(page.getByRole('dialog').getByText('simpleMcpTool')).toBeVisible({ timeout: 10000 }); + // Wait for tools to load (auto-connects in view mode) + await expect(page.getByRole('dialog').getByText('simpleMcpTool')).toBeVisible({ timeout: 10000 }); - // The tool switch should still be checked (persisted selection) - const persistedSwitch = page.getByRole('dialog').getByRole('switch').first(); - await expect(persistedSwitch).toBeChecked({ timeout: 5000 }); + // The tool switch should still be checked (persisted selection) + const persistedSwitch = page.getByRole('dialog').getByRole('switch').first(); + await expect(persistedSwitch).toBeChecked({ timeout: 5000 }); + }); }); -}); -test.describe('Agent Creation Persistence - Sub-Agents', () => { - test('persists selected sub-agents', async ({ page }) => { - await page.goto('/cms/agents/create'); + test.describe('when an agent is created with sub-agents', () => { + test('persists selected sub-agents', async ({ page }) => { + await page.goto('/cms/agents/create'); - const agentName = uniqueAgentName('SubAgents'); - await fillRequiredFields(page, agentName); + const agentName = uniqueAgentName('SubAgents'); + await fillRequiredFields(page, agentName); - // Navigate to agents page via sidebar - await clickSidebarLink(page, 'Agents'); + // Navigate to agents page via sidebar + await clickSidebarLink(page, 'Agents'); - // Wait for agents list to load - await page.waitForTimeout(1000); + // Wait for agents list to load + await page.waitForTimeout(1000); - // Toggle first available agent - const agentSwitch = page.getByRole('switch').first(); - await expect(agentSwitch).toBeVisible({ timeout: 10000 }); - await agentSwitch.click(); + // Toggle first available agent + const agentSwitch = page.getByRole('switch').first(); + await expect(agentSwitch).toBeVisible({ timeout: 10000 }); + await agentSwitch.click(); - const agentId = await createAgentAndGetId(page); + const agentId = await createAgentAndGetId(page); - // Verify on edit page - await page.goto(`/cms/agents/${agentId}/edit/agents`); - await page.waitForTimeout(2000); + // Verify on edit page + await page.goto(`/cms/agents/${agentId}/edit/agents`); + await page.waitForTimeout(2000); - await expect(page.getByRole('switch').first()).toBeChecked({ timeout: 10000 }); + await expect(page.getByRole('switch').first()).toBeChecked({ timeout: 10000 }); + }); }); -}); -test.describe('Agent Creation Persistence - Scorers', () => { - test('persists selected scorers with sampling configuration', async ({ page }) => { - await page.goto('/cms/agents/create'); + test.describe('when an agent is created with scorers', () => { + test('persists selected scorers with sampling configuration', async ({ page }) => { + await page.goto('/cms/agents/create'); - const agentName = uniqueAgentName('Scorers'); - await fillRequiredFields(page, agentName); + const agentName = uniqueAgentName('Scorers'); + await fillRequiredFields(page, agentName); - // Navigate to scorers page via sidebar - await clickSidebarLink(page, 'Scorers'); + // Navigate to scorers page via sidebar + await clickSidebarLink(page, 'Scorers'); - // Wait for scorers to load - await page.waitForTimeout(1000); + // Wait for scorers to load + await page.waitForTimeout(1000); - // Toggle first scorer - const scorerSwitch = page.getByRole('switch').first(); - await expect(scorerSwitch).toBeVisible({ timeout: 10000 }); - await scorerSwitch.click(); + // Toggle first scorer + const scorerSwitch = page.getByRole('switch').first(); + await expect(scorerSwitch).toBeVisible({ timeout: 10000 }); + await scorerSwitch.click(); - // Configure sampling to ratio - const ratioLabel = page.getByText('Ratio (percentage)').first(); - await expect(ratioLabel).toBeVisible({ timeout: 5000 }); - await ratioLabel.click(); + // Configure sampling to ratio + const ratioLabel = page.getByText('Ratio (percentage)').first(); + await expect(ratioLabel).toBeVisible({ timeout: 5000 }); + await ratioLabel.click(); - // Set sample rate - const rateInput = page.locator('input[type="number"]').first(); - await rateInput.clear(); - await rateInput.fill('0.5'); + // Set sample rate + const rateInput = page.locator('input[type="number"]').first(); + await rateInput.clear(); + await rateInput.fill('0.5'); - const agentId = await createAgentAndGetId(page); + const agentId = await createAgentAndGetId(page); - // Verify on edit page - await page.goto(`/cms/agents/${agentId}/edit/scorers`); - await page.waitForTimeout(2000); + // Verify on edit page + await page.goto(`/cms/agents/${agentId}/edit/scorers`); + await page.waitForTimeout(2000); - // Scorer switch should be checked - await expect(page.getByRole('switch').first()).toBeChecked({ timeout: 10000 }); + // Scorer switch should be checked + await expect(page.getByRole('switch').first()).toBeChecked({ timeout: 10000 }); - // Ratio radio should be selected - await expect(page.getByRole('radio', { name: /ratio/i }).first()).toBeChecked(); + // Ratio radio should be selected + await expect(page.getByRole('radio', { name: /ratio/i }).first()).toBeChecked(); - // Sample rate should be 0.5 - await expect(page.locator('input[type="number"]').first()).toHaveValue('0.5'); + // Sample rate should be 0.5 + await expect(page.locator('input[type="number"]').first()).toHaveValue('0.5'); + }); }); -}); -test.describe('Agent Creation Persistence - Workflows', () => { - test('persists selected workflows', async ({ page }) => { - await page.goto('/cms/agents/create'); + test.describe('when an agent is created with workflows', () => { + test('persists selected workflows', async ({ page }) => { + await page.goto('/cms/agents/create'); - const agentName = uniqueAgentName('Workflows'); - await fillRequiredFields(page, agentName); + const agentName = uniqueAgentName('Workflows'); + await fillRequiredFields(page, agentName); - // Navigate to workflows page via sidebar - await clickSidebarLink(page, 'Workflows'); + // Navigate to workflows page via sidebar + await clickSidebarLink(page, 'Workflows'); - // Wait for workflows to load - await page.waitForTimeout(1000); + // Wait for workflows to load + await page.waitForTimeout(1000); - // Toggle first workflow - const workflowSwitch = page.getByRole('switch').first(); - await expect(workflowSwitch).toBeVisible({ timeout: 10000 }); - await workflowSwitch.click(); + // Toggle first workflow + const workflowSwitch = page.getByRole('switch').first(); + await expect(workflowSwitch).toBeVisible({ timeout: 10000 }); + await workflowSwitch.click(); - const agentId = await createAgentAndGetId(page); + const agentId = await createAgentAndGetId(page); - // Verify on edit page - await page.goto(`/cms/agents/${agentId}/edit/workflows`); - await page.waitForTimeout(2000); + // Verify on edit page + await page.goto(`/cms/agents/${agentId}/edit/workflows`); + await page.waitForTimeout(2000); - await expect(page.getByRole('switch').first()).toBeChecked({ timeout: 10000 }); + await expect(page.getByRole('switch').first()).toBeChecked({ timeout: 10000 }); + }); }); -}); -test.describe('Agent Creation Persistence - Memory', () => { - test('persists memory enabled with lastMessages', async ({ page }) => { - await page.goto('/cms/agents/create'); + test.describe('when an agent is created with memory settings', () => { + test('persists memory enabled with lastMessages', async ({ page }) => { + await page.goto('/cms/agents/create'); - const agentName = uniqueAgentName('Memory'); - await fillRequiredFields(page, agentName); + const agentName = uniqueAgentName('Memory'); + await fillRequiredFields(page, agentName); - // Navigate to memory page via sidebar - await clickSidebarLink(page, 'Memory'); + // Navigate to memory page via sidebar + await clickSidebarLink(page, 'Memory'); - // Memory is disabled by default — click "Enable Memory" button (not a switch) - await page.getByRole('button', { name: 'Enable Memory' }).click(); + // Memory is disabled by default — click "Enable Memory" button (not a switch) + await page.getByRole('button', { name: 'Enable Memory' }).click(); - // Wait for memory fields to appear - await expect(page.locator('#memory-last-messages')).toBeVisible({ timeout: 5000 }); + // Wait for memory fields to appear + await expect(page.locator('#memory-last-messages')).toBeVisible({ timeout: 5000 }); - // Set lastMessages - const lastMessagesInput = page.locator('#memory-last-messages'); - await lastMessagesInput.fill('20'); + // Set lastMessages + const lastMessagesInput = page.locator('#memory-last-messages'); + await lastMessagesInput.fill('20'); - const agentId = await createAgentAndGetId(page); + const agentId = await createAgentAndGetId(page); - // Verify on edit page - await page.goto(`/cms/agents/${agentId}/edit/memory`); - await page.waitForTimeout(2000); + // Verify on edit page + await page.goto(`/cms/agents/${agentId}/edit/memory`); + await page.waitForTimeout(2000); - // Memory should be enabled - await expect(page.getByRole('switch').first()).toBeChecked({ timeout: 10000 }); + // Memory should be enabled + await expect(page.getByRole('switch').first()).toBeChecked({ timeout: 10000 }); - // lastMessages should have value 20 - await expect(page.locator('#memory-last-messages')).toHaveValue('20'); - }); + // lastMessages should have value 20 + await expect(page.locator('#memory-last-messages')).toHaveValue('20'); + }); - test('persists memory with readOnly enabled', async ({ page }) => { - await page.goto('/cms/agents/create'); + test('persists memory with readOnly enabled', async ({ page }) => { + await page.goto('/cms/agents/create'); - const agentName = uniqueAgentName('ReadOnly Memory'); - await fillRequiredFields(page, agentName); + const agentName = uniqueAgentName('ReadOnly Memory'); + await fillRequiredFields(page, agentName); - // Navigate to memory page via sidebar - await clickSidebarLink(page, 'Memory'); + // Navigate to memory page via sidebar + await clickSidebarLink(page, 'Memory'); - // Memory is disabled by default — click "Enable Memory" button (not a switch) - await page.getByRole('button', { name: 'Enable Memory' }).click(); - await expect(page.locator('#memory-last-messages')).toBeVisible({ timeout: 5000 }); + // Memory is disabled by default — click "Enable Memory" button (not a switch) + await page.getByRole('button', { name: 'Enable Memory' }).click(); + await expect(page.locator('#memory-last-messages')).toBeVisible({ timeout: 5000 }); - // The switches after memory enabled are: main=0, OM=1, LastMessages=2, SemanticRecall=3, ReadOnly=4 - const readOnlySwitch = page.getByRole('switch').nth(4); - await readOnlySwitch.click(); + // The switches after memory enabled are: main=0, OM=1, LastMessages=2, SemanticRecall=3, ReadOnly=4 + const readOnlySwitch = page.getByRole('switch').nth(4); + await readOnlySwitch.click(); - const agentId = await createAgentAndGetId(page); + const agentId = await createAgentAndGetId(page); - // Verify on edit page - await page.goto(`/cms/agents/${agentId}/edit/memory`); - await page.waitForTimeout(2000); + // Verify on edit page + await page.goto(`/cms/agents/${agentId}/edit/memory`); + await page.waitForTimeout(2000); - // Memory enabled - await expect(page.getByRole('switch').first()).toBeChecked({ timeout: 10000 }); + // Memory enabled + await expect(page.getByRole('switch').first()).toBeChecked({ timeout: 10000 }); - // Read Only should be checked (5th switch, index 4) - await expect(page.getByRole('switch').nth(4)).toBeChecked(); - }); + // Read Only should be checked (5th switch, index 4) + await expect(page.getByRole('switch').nth(4)).toBeChecked(); + }); - test('persists observational memory settings', async ({ page }) => { - await page.goto('/cms/agents/create'); + test('persists observational memory settings', async ({ page }) => { + await page.goto('/cms/agents/create'); - const agentName = uniqueAgentName('OM Memory'); - await fillRequiredFields(page, agentName); + const agentName = uniqueAgentName('OM Memory'); + await fillRequiredFields(page, agentName); - // Navigate to memory page via sidebar - await clickSidebarLink(page, 'Memory'); + // Navigate to memory page via sidebar + await clickSidebarLink(page, 'Memory'); - // Memory is disabled by default — click "Enable Memory" button (not a switch) - await page.getByRole('button', { name: 'Enable Memory' }).click(); - await expect(page.locator('#memory-last-messages')).toBeVisible({ timeout: 5000 }); + // Memory is disabled by default — click "Enable Memory" button (not a switch) + await page.getByRole('button', { name: 'Enable Memory' }).click(); + await expect(page.locator('#memory-last-messages')).toBeVisible({ timeout: 5000 }); - // Enable Observational Memory (2nd switch: main=0, OM=1, LastMessages=2, SemanticRecall=3, ReadOnly=4) - const omSwitch = page.getByRole('switch').nth(1); - await omSwitch.click(); + // Enable Observational Memory (2nd switch: main=0, OM=1, LastMessages=2, SemanticRecall=3, ReadOnly=4) + const omSwitch = page.getByRole('switch').nth(1); + await omSwitch.click(); - // Wait for OM fields to appear - await expect(page.locator('#memory-om-scope')).toBeVisible({ timeout: 5000 }); + // Wait for OM fields to appear + await expect(page.locator('#memory-om-scope')).toBeVisible({ timeout: 5000 }); - // Set scope to resource - const scopeSelect = page.locator('#memory-om-scope'); - await scopeSelect.click(); - await page.getByRole('option', { name: 'Resource' }).click(); + // Set scope to resource + const scopeSelect = page.locator('#memory-om-scope'); + await scopeSelect.click(); + await page.getByRole('option', { name: 'Resource' }).click(); - // Enable share token budget - const shareBudgetSwitch = page.locator('#memory-om-share-budget'); - await shareBudgetSwitch.click(); + // Enable share token budget + const shareBudgetSwitch = page.locator('#memory-om-share-budget'); + await shareBudgetSwitch.click(); - const agentId = await createAgentAndGetId(page); + const agentId = await createAgentAndGetId(page); - // Verify on edit page - await page.goto(`/cms/agents/${agentId}/edit/memory`); - await page.waitForTimeout(2000); + // Verify on edit page + await page.goto(`/cms/agents/${agentId}/edit/memory`); + await page.waitForTimeout(2000); - // Memory should be enabled - await expect(page.getByRole('switch').first()).toBeChecked({ timeout: 10000 }); + // Memory should be enabled + await expect(page.getByRole('switch').first()).toBeChecked({ timeout: 10000 }); - // OM should be enabled (2nd switch, index 1) - await expect(page.getByRole('switch').nth(1)).toBeChecked(); + // OM should be enabled (2nd switch, index 1) + await expect(page.getByRole('switch').nth(1)).toBeChecked(); - // Scope should be resource - await expect(page.locator('#memory-om-scope')).toContainText('Resource'); + // Scope should be resource + await expect(page.locator('#memory-om-scope')).toContainText('Resource'); - // Share budget should be on - await expect(page.locator('#memory-om-share-budget')).toBeChecked(); + // Share budget should be on + await expect(page.locator('#memory-om-share-budget')).toBeChecked(); + }); }); -}); - -test.describe('Agent Creation Persistence - Variables', () => { - test('persists single variable definition', async ({ page }) => { - await page.goto('/cms/agents/create'); - const agentName = uniqueAgentName('Variables'); - await fillRequiredFields(page, agentName); + test.describe('when an agent is created with variables', () => { + test('persists single variable definition', async ({ page }) => { + await page.goto('/cms/agents/create'); - // Navigate to variables page via sidebar - await clickSidebarLink(page, 'Variables'); + const agentName = uniqueAgentName('Variables'); + await fillRequiredFields(page, agentName); - // Add a variable - await page.getByRole('button', { name: 'Add variable' }).click(); - await page.waitForTimeout(500); + // Navigate to variables page via sidebar + await clickSidebarLink(page, 'Variables'); - const nameInput = page.getByPlaceholder('Variable name').first(); - await expect(nameInput).toBeVisible({ timeout: 5000 }); - await nameInput.fill('userName'); + // Add a variable + await page.getByRole('button', { name: 'Add variable' }).click(); + await page.waitForTimeout(500); - const agentId = await createAgentAndGetId(page); + const nameInput = page.getByPlaceholder('Variable name').first(); + await expect(nameInput).toBeVisible({ timeout: 5000 }); + await nameInput.fill('userName'); - // Verify on edit page - navigate via sidebar so VariablesPage mounts after data is loaded - await goToEditSubPageViaSidebar(page, agentId, 'Variables'); + const agentId = await createAgentAndGetId(page); - await expect(page.getByPlaceholder('Variable name').first()).toHaveValue('userName', { timeout: 10000 }); - }); + // Verify on edit page - navigate via sidebar so VariablesPage mounts after data is loaded + await goToEditSubPageViaSidebar(page, agentId, 'Variables'); - test('persists multiple variables', async ({ page }) => { - await page.goto('/cms/agents/create'); + await expect(page.getByPlaceholder('Variable name').first()).toHaveValue('userName', { timeout: 10000 }); + }); - const agentName = uniqueAgentName('Multi Vars'); - await fillRequiredFields(page, agentName); + test('persists multiple variables', async ({ page }) => { + await page.goto('/cms/agents/create'); - // Navigate to variables page via sidebar - await clickSidebarLink(page, 'Variables'); + const agentName = uniqueAgentName('Multi Vars'); + await fillRequiredFields(page, agentName); - // Add first variable - await page.getByRole('button', { name: 'Add variable' }).click(); - await page.waitForTimeout(500); - await page.getByPlaceholder('Variable name').first().fill('firstName'); + // Navigate to variables page via sidebar + await clickSidebarLink(page, 'Variables'); - // Add second variable - await page.getByRole('button', { name: 'Add variable' }).click(); - await page.waitForTimeout(500); - await page.getByPlaceholder('Variable name').nth(1).fill('age'); + // Add first variable + await page.getByRole('button', { name: 'Add variable' }).click(); + await page.waitForTimeout(500); + await page.getByPlaceholder('Variable name').first().fill('firstName'); - const agentId = await createAgentAndGetId(page); + // Add second variable + await page.getByRole('button', { name: 'Add variable' }).click(); + await page.waitForTimeout(500); + await page.getByPlaceholder('Variable name').nth(1).fill('age'); - // Verify on edit page - navigate via sidebar so VariablesPage mounts after data is loaded - await goToEditSubPageViaSidebar(page, agentId, 'Variables'); + const agentId = await createAgentAndGetId(page); - await expect(page.getByPlaceholder('Variable name').first()).toHaveValue('firstName', { timeout: 10000 }); - await expect(page.getByPlaceholder('Variable name').nth(1)).toHaveValue('age'); - }); -}); + // Verify on edit page - navigate via sidebar so VariablesPage mounts after data is loaded + await goToEditSubPageViaSidebar(page, agentId, 'Variables'); -test.describe('Comprehensive Persistence Test', () => { - test('persists all fields across all pages', async ({ page }) => { - await page.goto('/cms/agents/create'); - - const agentName = uniqueAgentName('Comprehensive'); - const description = 'A comprehensive agent with all fields configured'; - - // === Identity Page === - await fillIdentityFields(page, { name: agentName, description }); - - // === Instruction Blocks === - await clickSidebarLink(page, 'Instructions'); - const editor = page.locator('.cm-content').first(); - await editor.click(); - await page.keyboard.type('You are a comprehensive test agent.'); - - // === Tools === - await clickSidebarLink(page, 'Tools'); - await page.getByRole('button', { name: 'Add Tools' }).click({ timeout: 10000 }); - const firstToolOption = page.locator('[data-slot="popover-content"] button').first(); - await firstToolOption.waitFor({ state: 'visible', timeout: 5000 }); - await firstToolOption.click(); - await expect(page.getByLabel(/^Remove /).first()).toBeVisible({ timeout: 5000 }); - - // === Workflows === - await clickSidebarLink(page, 'Workflows'); - await page.waitForTimeout(1000); - const wfSwitches = page.getByRole('switch'); - if ((await wfSwitches.count()) > 0) { - await wfSwitches.first().click(); - } - - // === Memory === - await clickSidebarLink(page, 'Memory'); - // Memory is disabled by default — click "Enable Memory" button (not a switch) - await page.getByRole('button', { name: 'Enable Memory' }).click(); - await expect(page.locator('#memory-last-messages')).toBeVisible({ timeout: 5000 }); - const lastMsgInput = page.locator('#memory-last-messages'); - await lastMsgInput.fill('25'); - - // === Variables === - await clickSidebarLink(page, 'Variables'); - await page.getByRole('button', { name: 'Add variable' }).click(); - await page.waitForTimeout(500); - await page.getByPlaceholder('Variable name').first().fill('context'); - - // === Create === - const agentId = await createAgentAndGetId(page); - - // === Verify Identity === - await goToEditSubPage(page, agentId); - await expect(page.locator('#agent-name')).toHaveValue(agentName); - await expect(page.locator('#agent-description')).toHaveValue(description); - // On edit page, the version selector precedes provider and model. - await expect(page.getByRole('combobox').nth(1)).toContainText('OpenAI'); - await expect(page.getByRole('combobox').nth(2)).toContainText('gpt-4o-mini'); - - // === Verify Instructions === - await page.goto(`/cms/agents/${agentId}/edit/instruction-blocks`); - await page.waitForTimeout(2000); - await expect(page.locator('.cm-content').first()).toContainText('You are a comprehensive test agent.', { - timeout: 10000, + await expect(page.getByPlaceholder('Variable name').first()).toHaveValue('firstName', { timeout: 10000 }); + await expect(page.getByPlaceholder('Variable name').nth(1)).toHaveValue('age'); }); - - // === Verify Tools === - await page.goto(`/cms/agents/${agentId}/edit/tools`); - await page.waitForTimeout(2000); - await expect(page.getByLabel(/^Remove /).first()).toBeVisible({ timeout: 10000 }); - - // === Verify Workflows === - await page.goto(`/cms/agents/${agentId}/edit/workflows`); - await page.waitForTimeout(2000); - await expect(page.getByRole('switch').first()).toBeChecked({ timeout: 10000 }); - - // === Verify Memory === - await page.goto(`/cms/agents/${agentId}/edit/memory`); - await page.waitForTimeout(2000); - await expect(page.getByRole('switch').first()).toBeChecked({ timeout: 10000 }); - await expect(page.locator('#memory-last-messages')).toHaveValue('25'); - - // === Verify Variables (via sidebar so VariablesPage mounts after data is loaded) === - await goToEditSubPageViaSidebar(page, agentId, 'Variables'); - await expect(page.getByPlaceholder('Variable name').first()).toHaveValue('context', { timeout: 10000 }); }); -}); -test.describe('Error Handling', () => { - test('shows error toast and allows retry on creation failure', async ({ page }) => { - // Intercept stored agent creation requests - await page.route('**/*', route => { - const url = route.request().url(); - if (url.includes('/api/stored/agents') && route.request().method() === 'POST') { - route.fulfill({ - status: 500, - contentType: 'application/json', - body: JSON.stringify({ message: 'Internal server error' }), - }); - } else { - route.continue(); + test.describe('when an agent is created with all fields populated', () => { + test('persists all fields across all pages', async ({ page }) => { + await page.goto('/cms/agents/create'); + + const agentName = uniqueAgentName('Comprehensive'); + const description = 'A comprehensive agent with all fields configured'; + + // === Identity Page === + await fillIdentityFields(page, { name: agentName, description }); + + // === Instruction Blocks === + await clickSidebarLink(page, 'Instructions'); + const editor = page.locator('.cm-content').first(); + await editor.click(); + await page.keyboard.type('You are a comprehensive test agent.'); + + // === Tools === + await clickSidebarLink(page, 'Tools'); + await page.getByRole('button', { name: 'Add Tools' }).click({ timeout: 10000 }); + const firstToolOption = page.locator('[data-slot="popover-content"] button').first(); + await firstToolOption.waitFor({ state: 'visible', timeout: 5000 }); + await firstToolOption.click(); + await expect(page.getByLabel(/^Remove /).first()).toBeVisible({ timeout: 5000 }); + + // === Workflows === + await clickSidebarLink(page, 'Workflows'); + await page.waitForTimeout(1000); + const wfSwitches = page.getByRole('switch'); + if ((await wfSwitches.count()) > 0) { + await wfSwitches.first().click(); } - }); - - await page.goto('/cms/agents/create'); - await fillRequiredFields(page, uniqueAgentName('Error Test')); - - await page.getByRole('button', { name: 'Create agent' }).click(); - - await expect(page.getByText(/Failed to create agent/i)).toBeVisible({ timeout: 10000 }); + // === Memory === + await clickSidebarLink(page, 'Memory'); + // Memory is disabled by default — click "Enable Memory" button (not a switch) + await page.getByRole('button', { name: 'Enable Memory' }).click(); + await expect(page.locator('#memory-last-messages')).toBeVisible({ timeout: 5000 }); + const lastMsgInput = page.locator('#memory-last-messages'); + await lastMsgInput.fill('25'); + + // === Variables === + await clickSidebarLink(page, 'Variables'); + await page.getByRole('button', { name: 'Add variable' }).click(); + await page.waitForTimeout(500); + await page.getByPlaceholder('Variable name').first().fill('context'); + + // === Create === + const agentId = await createAgentAndGetId(page); + + // === Verify Identity === + await goToEditSubPage(page, agentId); + await expect(page.locator('#agent-name')).toHaveValue(agentName); + await expect(page.locator('#agent-description')).toHaveValue(description); + // On edit page, the version selector precedes provider and model. + await expect(page.getByRole('combobox').nth(1)).toContainText('OpenAI'); + await expect(page.getByRole('combobox').nth(2)).toContainText('gpt-4o-mini'); + + // === Verify Instructions === + await page.goto(`/cms/agents/${agentId}/edit/instruction-blocks`); + await page.waitForTimeout(2000); + await expect(page.locator('.cm-content').first()).toContainText('You are a comprehensive test agent.', { + timeout: 10000, + }); + + // === Verify Tools === + await page.goto(`/cms/agents/${agentId}/edit/tools`); + await page.waitForTimeout(2000); + await expect(page.getByLabel(/^Remove /).first()).toBeVisible({ timeout: 10000 }); + + // === Verify Workflows === + await page.goto(`/cms/agents/${agentId}/edit/workflows`); + await page.waitForTimeout(2000); + await expect(page.getByRole('switch').first()).toBeChecked({ timeout: 10000 }); + + // === Verify Memory === + await page.goto(`/cms/agents/${agentId}/edit/memory`); + await page.waitForTimeout(2000); + await expect(page.getByRole('switch').first()).toBeChecked({ timeout: 10000 }); + await expect(page.locator('#memory-last-messages')).toHaveValue('25'); + + // === Verify Variables (via sidebar so VariablesPage mounts after data is loaded) === + await goToEditSubPageViaSidebar(page, agentId, 'Variables'); + await expect(page.getByPlaceholder('Variable name').first()).toHaveValue('context', { timeout: 10000 }); + }); + }); - // Should stay on create sub-page with button still enabled - await expect(page).toHaveURL(/\/cms\/agents\/create/); - await expect(page.getByRole('button', { name: 'Create agent' })).toBeEnabled(); + test.describe('when agent creation fails on the server', () => { + test('shows error toast and allows retry on creation failure', async ({ page }) => { + // Intercept stored agent creation requests + await page.route('**/*', route => { + const url = route.request().url(); + if (url.includes('/api/stored/agents') && route.request().method() === 'POST') { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ message: 'Internal server error' }), + }); + } else { + route.continue(); + } + }); + + await page.goto('/cms/agents/create'); + + await fillRequiredFields(page, uniqueAgentName('Error Test')); + + await page.getByRole('button', { name: 'Create agent' }).click(); + + await expect(page.getByText(/Failed to create agent/i)).toBeVisible({ timeout: 10000 }); + + // Should stay on create sub-page with button still enabled + await expect(page).toHaveURL(/\/cms\/agents\/create/); + await expect(page.getByRole('button', { name: 'Create agent' })).toBeEnabled(); + }); }); -}); -test.describe('Form Reset After Creation', () => { - test('shows clean form when navigating back to create page', async ({ page }) => { - await page.goto('/cms/agents/create'); + test.describe('when navigating back to the create page after a creation', () => { + test('shows clean form when navigating back to create page', async ({ page }) => { + await page.goto('/cms/agents/create'); - const agentName = uniqueAgentName('Reset Test'); - await fillRequiredFields(page, agentName); + const agentName = uniqueAgentName('Reset Test'); + await fillRequiredFields(page, agentName); - await createAgentAndGetId(page); + await createAgentAndGetId(page); - // Navigate back to create page - await page.goto('/cms/agents/create'); + // Navigate back to create page + await page.goto('/cms/agents/create'); - // Form should be empty - await expect(page.locator('#agent-name')).toHaveValue(''); - await expect(page.locator('#agent-description')).toHaveValue(''); + // Form should be empty + await expect(page.locator('#agent-name')).toHaveValue(''); + await expect(page.locator('#agent-description')).toHaveValue(''); + }); }); }); diff --git a/packages/playground/e2e/tests/cms/scorers/create/page.spec.ts b/packages/playground/e2e/tests/cms/scorers/create/page.spec.ts index 6a4d32696291..3af38e3a26d0 100644 --- a/packages/playground/e2e/tests/cms/scorers/create/page.spec.ts +++ b/packages/playground/e2e/tests/cms/scorers/create/page.spec.ts @@ -1,6 +1,7 @@ -import { test, expect, Page } from '@playwright/test'; -import { expectCurrentBreadcrumb } from '../../../__utils__/route-header'; +import type { Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; import { resetStorage } from '../../../__utils__/reset-storage'; +import { expectCurrentBreadcrumb } from '../../../__utils__/route-header'; // Helper to generate unique scorer names function uniqueScorerName(prefix = 'Test Scorer') { @@ -104,278 +105,298 @@ test.afterEach(async () => { await resetStorage(); }); -test.describe('Page Structure & Initial State', () => { - test('displays page title and header correctly', async ({ page }) => { - await page.goto('/cms/scorers/create'); - - await expect(page).toHaveTitle(/Mastra Studio/); - await expectCurrentBreadcrumb(page, 'Create scorer'); - }); +test.describe('CMS create scorer page', () => { + test.describe('when the create page first loads', () => { + test('displays page title and header correctly', async ({ page }) => { + await page.goto('/cms/scorers/create'); - test('displays Create scorer button', async ({ page }) => { - await page.goto('/cms/scorers/create'); + await expect(page).toHaveTitle(/Mastra Studio/); + await expectCurrentBreadcrumb(page, 'Create scorer'); + }); - const createButton = page.getByRole('button', { name: 'Create scorer' }); - await expect(createButton).toBeVisible(); - await expect(createButton).toBeEnabled(); - }); -}); + test('displays Create scorer button', async ({ page }) => { + await page.goto('/cms/scorers/create'); -test.describe('Required Field Validation', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/cms/scorers/create'); + const createButton = page.getByRole('button', { name: 'Create scorer' }); + await expect(createButton).toBeVisible(); + await expect(createButton).toBeEnabled(); + }); }); - test('shows validation error when name is empty', async ({ page }) => { - await fillScorerFields(page, { - description: 'Test description', - provider: 'OpenAI', - model: 'gpt-4o-mini', - instructions: 'Test instructions', + test.describe('when the scorer name is empty', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/cms/scorers/create'); }); - await page.getByRole('button', { name: 'Create scorer' }).click(); + test('shows validation error when name is empty', async ({ page }) => { + await fillScorerFields(page, { + description: 'Test description', + provider: 'OpenAI', + model: 'gpt-4o-mini', + instructions: 'Test instructions', + }); - await expect(page.getByText('Name is required')).toBeVisible(); + await page.getByRole('button', { name: 'Create scorer' }).click(); + + await expect(page.getByText('Name is required')).toBeVisible(); + }); }); - test('shows validation error when provider is not selected', async ({ page }) => { - await fillScorerFields(page, { - name: uniqueScorerName(), - description: 'Test description', - instructions: 'Test instructions', + test.describe('when the scorer provider is not selected', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/cms/scorers/create'); }); - await page.getByRole('button', { name: 'Create scorer' }).click(); + test('shows validation error when provider is not selected', async ({ page }) => { + await fillScorerFields(page, { + name: uniqueScorerName(), + description: 'Test description', + instructions: 'Test instructions', + }); - await expect(page.getByText(/provider is required/i).or(page.getByText(/fill in all required/i))).toBeVisible({ - timeout: 5000, + await page.getByRole('button', { name: 'Create scorer' }).click(); + + await expect(page.getByText(/provider is required/i).or(page.getByText(/fill in all required/i))).toBeVisible({ + timeout: 5000, + }); }); }); - test('shows validation error when model is not selected', async ({ page }) => { - await fillScorerFields(page, { - name: uniqueScorerName(), - description: 'Test description', - provider: 'OpenAI', - instructions: 'Test instructions', + test.describe('when the scorer model is not selected', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/cms/scorers/create'); }); - await page.getByRole('button', { name: 'Create scorer' }).click(); + test('shows validation error when model is not selected', async ({ page }) => { + await fillScorerFields(page, { + name: uniqueScorerName(), + description: 'Test description', + provider: 'OpenAI', + instructions: 'Test instructions', + }); + + await page.getByRole('button', { name: 'Create scorer' }).click(); - await expect(page.getByText(/model is required/i).or(page.getByText(/fill in all required/i))).toBeVisible({ - timeout: 5000, + await expect(page.getByText(/model is required/i).or(page.getByText(/fill in all required/i))).toBeVisible({ + timeout: 5000, + }); }); }); - test('shows error toast when submitting empty form', async ({ page }) => { - await page.getByRole('button', { name: 'Create scorer' }).click(); + test.describe('when the empty scorer form is submitted', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/cms/scorers/create'); + }); - await expect(page.getByText('Please fill in all required fields')).toBeVisible(); - }); -}); + test('shows error toast when submitting empty form', async ({ page }) => { + await page.getByRole('button', { name: 'Create scorer' }).click(); -test.describe('Scorer Creation Persistence', () => { - test('creates scorer and redirects to detail page', async ({ page }) => { - await page.goto('/cms/scorers/create'); + await expect(page.getByText('Please fill in all required fields')).toBeVisible(); + }); + }); - const scorerName = uniqueScorerName('Persistence Test'); - await fillRequiredFields(page, scorerName); + test.describe('when a scorer is created and saved', () => { + test('creates scorer and redirects to detail page', async ({ page }) => { + await page.goto('/cms/scorers/create'); - await page.getByRole('button', { name: 'Create scorer' }).click(); + const scorerName = uniqueScorerName('Persistence Test'); + await fillRequiredFields(page, scorerName); - await expect(page).toHaveURL(/\/scorers\/[a-zA-Z0-9-]+/, { timeout: 15000 }); - await expect(page.getByText('Scorer created successfully')).toBeVisible(); - }); + await page.getByRole('button', { name: 'Create scorer' }).click(); - test('persists all fields and verifies them on edit page', async ({ page }) => { - await page.goto('/cms/scorers/create'); - - const scorerName = uniqueScorerName('Full Persist'); - const description = 'A comprehensive test scorer'; - const instructions = 'Score the response based on accuracy and completeness.'; - - await fillScorerFields(page, { - name: scorerName, - description, - provider: 'OpenAI', - model: 'gpt-4o-mini', - scoreRangeMin: '0', - scoreRangeMax: '10', - samplingType: 'ratio', - samplingRate: '0.5', - instructions, + await expect(page).toHaveURL(/\/scorers\/[a-zA-Z0-9-]+/, { timeout: 15000 }); + await expect(page.getByText('Scorer created successfully')).toBeVisible(); }); - await page.getByRole('button', { name: 'Create scorer' }).click(); + test('persists all fields and verifies them on edit page', async ({ page }) => { + await page.goto('/cms/scorers/create'); - await expect(page).toHaveURL(/\/scorers\/[a-zA-Z0-9-]+/, { timeout: 15000 }); + const scorerName = uniqueScorerName('Full Persist'); + const description = 'A comprehensive test scorer'; + const instructions = 'Score the response based on accuracy and completeness.'; - // Click the Edit button on the detail page - const editLink = page.getByRole('link', { name: 'Edit' }); - await expect(editLink).toBeVisible({ timeout: 10000 }); - await editLink.click(); + await fillScorerFields(page, { + name: scorerName, + description, + provider: 'OpenAI', + model: 'gpt-4o-mini', + scoreRangeMin: '0', + scoreRangeMax: '10', + samplingType: 'ratio', + samplingRate: '0.5', + instructions, + }); - // Wait for the edit page to load - await expect(page).toHaveURL(/\/cms\/scorers\/[a-zA-Z0-9-]+\/edit/, { timeout: 15000 }); + await page.getByRole('button', { name: 'Create scorer' }).click(); - // Verify the route header tracks the scorer being edited - await expectCurrentBreadcrumb(page, scorerName); + await expect(page).toHaveURL(/\/scorers\/[a-zA-Z0-9-]+/, { timeout: 15000 }); - // Verify Publish button is visible - await expect(page.getByRole('button', { name: 'Publish' })).toBeVisible(); + // Click the Edit button on the detail page + const editLink = page.getByRole('link', { name: 'Edit' }); + await expect(editLink).toBeVisible({ timeout: 10000 }); + await editLink.click(); - // Verify name - await expect(page.locator('#scorer-name')).toHaveValue(scorerName); + // Wait for the edit page to load + await expect(page).toHaveURL(/\/cms\/scorers\/[a-zA-Z0-9-]+\/edit/, { timeout: 15000 }); - // Verify description - await expect(page.locator('#scorer-description')).toHaveValue(description); + // Verify the route header tracks the scorer being edited + await expectCurrentBreadcrumb(page, scorerName); - // Verify provider/model selections persisted on the edit page. - await expect(page.getByRole('combobox').nth(1)).not.toContainText('Select provider'); - await expect(page.getByRole('combobox').nth(2)).not.toContainText('Select model'); + // Verify Publish button is visible + await expect(page.getByRole('button', { name: 'Publish' })).toBeVisible(); - // Verify score range - await expect(page.getByPlaceholder('Min')).toHaveValue('0'); - await expect(page.getByPlaceholder('Max')).toHaveValue('10'); + // Verify name + await expect(page.locator('#scorer-name')).toHaveValue(scorerName); - // Verify sampling type is ratio - await expect(page.locator('#sampling-ratio')).toBeChecked(); + // Verify description + await expect(page.locator('#scorer-description')).toHaveValue(description); - // Verify sampling rate - await expect(page.getByPlaceholder('Rate (0-1)')).toHaveValue('0.5'); + // Verify provider/model selections persisted on the edit page. + await expect(page.getByRole('combobox').nth(1)).not.toContainText('Select provider'); + await expect(page.getByRole('combobox').nth(2)).not.toContainText('Select model'); - // Verify instructions - await expect(page.locator('.cm-content')).toContainText(instructions); - }); + // Verify score range + await expect(page.getByPlaceholder('Min')).toHaveValue('0'); + await expect(page.getByPlaceholder('Max')).toHaveValue('10'); - test('persists minimal fields with correct defaults on edit page', async ({ page }) => { - await page.goto('/cms/scorers/create'); + // Verify sampling type is ratio + await expect(page.locator('#sampling-ratio')).toBeChecked(); - const scorerName = uniqueScorerName('Minimal Persist'); - await fillRequiredFields(page, scorerName); + // Verify sampling rate + await expect(page.getByPlaceholder('Rate (0-1)')).toHaveValue('0.5'); - await page.getByRole('button', { name: 'Create scorer' }).click(); + // Verify instructions + await expect(page.locator('.cm-content')).toContainText(instructions); + }); - await expect(page).toHaveURL(/\/scorers\/[a-zA-Z0-9-]+/, { timeout: 15000 }); + test('persists minimal fields with correct defaults on edit page', async ({ page }) => { + await page.goto('/cms/scorers/create'); - // Wait for Edit link to be visible before clicking - const editLink = page.getByRole('link', { name: 'Edit' }); - await expect(editLink).toBeVisible({ timeout: 10000 }); - await editLink.click(); + const scorerName = uniqueScorerName('Minimal Persist'); + await fillRequiredFields(page, scorerName); - await expect(page).toHaveURL(/\/cms\/scorers\/[a-zA-Z0-9-]+\/edit/, { timeout: 15000 }); + await page.getByRole('button', { name: 'Create scorer' }).click(); - // Verify name is set - await expect(page.locator('#scorer-name')).toBeVisible({ timeout: 10000 }); - await expect(page.locator('#scorer-name')).toHaveValue(scorerName); + await expect(page).toHaveURL(/\/scorers\/[a-zA-Z0-9-]+/, { timeout: 15000 }); - // Verify description has the value set by fillRequiredFields - await expect(page.locator('#scorer-description')).toHaveValue('Test scorer description'); + // Wait for Edit link to be visible before clicking + const editLink = page.getByRole('link', { name: 'Edit' }); + await expect(editLink).toBeVisible({ timeout: 10000 }); + await editLink.click(); - // Verify default score range (0-1) - await expect(page.getByPlaceholder('Min')).toHaveValue('0'); - await expect(page.getByPlaceholder('Max')).toHaveValue('1'); + await expect(page).toHaveURL(/\/cms\/scorers\/[a-zA-Z0-9-]+\/edit/, { timeout: 15000 }); - // Verify ratio rate input is not visible (sampling type is none by default) - await expect(page.getByPlaceholder('Rate (0-1)')).not.toBeVisible(); - }); + // Verify name is set + await expect(page.locator('#scorer-name')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#scorer-name')).toHaveValue(scorerName); - test('data persists after page reload on edit page', async ({ page }) => { - await page.goto('/cms/scorers/create'); + // Verify description has the value set by fillRequiredFields + await expect(page.locator('#scorer-description')).toHaveValue('Test scorer description'); - const scorerName = uniqueScorerName('Reload Persist'); - const instructions = 'Evaluate response quality on a scale.'; + // Verify default score range (0-1) + await expect(page.getByPlaceholder('Min')).toHaveValue('0'); + await expect(page.getByPlaceholder('Max')).toHaveValue('1'); - await fillScorerFields(page, { - name: scorerName, - description: 'Test description for reload', - provider: 'OpenAI', - model: 'gpt-4o-mini', - instructions, + // Verify ratio rate input is not visible (sampling type is none by default) + await expect(page.getByPlaceholder('Rate (0-1)')).not.toBeVisible(); }); - await page.getByRole('button', { name: 'Create scorer' }).click(); + test('data persists after page reload on edit page', async ({ page }) => { + await page.goto('/cms/scorers/create'); - await expect(page).toHaveURL(/\/scorers\/[a-zA-Z0-9-]+/, { timeout: 15000 }); + const scorerName = uniqueScorerName('Reload Persist'); + const instructions = 'Evaluate response quality on a scale.'; - // Navigate to edit page - const editLink = page.getByRole('link', { name: 'Edit' }); - await expect(editLink).toBeVisible({ timeout: 10000 }); - await editLink.click(); + await fillScorerFields(page, { + name: scorerName, + description: 'Test description for reload', + provider: 'OpenAI', + model: 'gpt-4o-mini', + instructions, + }); - await expect(page).toHaveURL(/\/cms\/scorers\/[a-zA-Z0-9-]+\/edit/, { timeout: 15000 }); + await page.getByRole('button', { name: 'Create scorer' }).click(); - // Reload the page - await page.reload(); + await expect(page).toHaveURL(/\/scorers\/[a-zA-Z0-9-]+/, { timeout: 15000 }); - // Verify name persists after reload - await expect(page.locator('#scorer-name')).toHaveValue(scorerName, { timeout: 10000 }); + // Navigate to edit page + const editLink = page.getByRole('link', { name: 'Edit' }); + await expect(editLink).toBeVisible({ timeout: 10000 }); + await editLink.click(); - // Verify instructions persist after reload - await expect(page.locator('.cm-content')).toContainText(instructions, { timeout: 10000 }); - }); -}); + await expect(page).toHaveURL(/\/cms\/scorers\/[a-zA-Z0-9-]+\/edit/, { timeout: 15000 }); + + // Reload the page + await page.reload(); -test.describe('Error Handling', () => { - test('shows error toast and allows retry on creation failure', async ({ page }) => { - await page.route('**/stored/scorers', route => { - if (route.request().method() === 'POST') { - route.fulfill({ - status: 500, - contentType: 'application/json', - body: JSON.stringify({ message: 'Internal server error' }), - }); - } else { - route.continue(); - } + // Verify name persists after reload + await expect(page.locator('#scorer-name')).toHaveValue(scorerName, { timeout: 10000 }); + + // Verify instructions persist after reload + await expect(page.locator('.cm-content')).toContainText(instructions, { timeout: 10000 }); }); + }); - await page.goto('/cms/scorers/create'); + test.describe('when scorer creation fails on the server', () => { + test('shows error toast and allows retry on creation failure', async ({ page }) => { + await page.route('**/stored/scorers', route => { + if (route.request().method() === 'POST') { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ message: 'Internal server error' }), + }); + } else { + route.continue(); + } + }); - await fillRequiredFields(page, uniqueScorerName('Error Test')); + await page.goto('/cms/scorers/create'); - await page.getByRole('button', { name: 'Create scorer' }).click(); + await fillRequiredFields(page, uniqueScorerName('Error Test')); - await expect(page.getByText(/Failed to create scorer/i)).toBeVisible({ timeout: 10000 }); + await page.getByRole('button', { name: 'Create scorer' }).click(); - // Should stay on create page with button still enabled - await expect(page).toHaveURL(/\/cms\/scorers\/create/); - await expect(page.getByRole('button', { name: 'Create scorer' })).toBeEnabled(); + await expect(page.getByText(/Failed to create scorer/i)).toBeVisible({ timeout: 10000 }); + + // Should stay on create page with button still enabled + await expect(page).toHaveURL(/\/cms\/scorers\/create/); + await expect(page.getByRole('button', { name: 'Create scorer' })).toBeEnabled(); + }); }); -}); -test.describe('Form Reset After Creation', () => { - test('shows clean form when navigating back to create page', async ({ page }) => { - await page.goto('/cms/scorers/create'); + test.describe('when navigating back to the create page after a creation', () => { + test('shows clean form when navigating back to create page', async ({ page }) => { + await page.goto('/cms/scorers/create'); - const scorerName = uniqueScorerName('Reset Test'); - await fillRequiredFields(page, scorerName); + const scorerName = uniqueScorerName('Reset Test'); + await fillRequiredFields(page, scorerName); - await page.getByRole('button', { name: 'Create scorer' }).click(); - await expect(page).toHaveURL(/\/scorers\/[a-zA-Z0-9-]+/, { timeout: 15000 }); + await page.getByRole('button', { name: 'Create scorer' }).click(); + await expect(page).toHaveURL(/\/scorers\/[a-zA-Z0-9-]+/, { timeout: 15000 }); - // Navigate back to create page - await page.goto('/cms/scorers/create'); + // Navigate back to create page + await page.goto('/cms/scorers/create'); - // Form should be empty - await expect(page.locator('#scorer-name')).toHaveValue(''); - await expect(page.locator('#scorer-description')).toHaveValue(''); + // Form should be empty + await expect(page.locator('#scorer-name')).toHaveValue(''); + await expect(page.locator('#scorer-description')).toHaveValue(''); + }); }); -}); -test.describe('Provider-Model Interaction', () => { - test('provider selection updates available models', async ({ page }) => { - await page.goto('/cms/scorers/create'); + test.describe('when selecting a provider and model', () => { + test('provider selection updates available models', async ({ page }) => { + await page.goto('/cms/scorers/create'); - // Select an available provider from the kitchen-sink fixture. - await selectComboboxOption(page, 0, 'OpenAI'); + // Select an available provider from the kitchen-sink fixture. + await selectComboboxOption(page, 0, 'OpenAI'); - // Open model dropdown - const modelCombobox = page.getByRole('combobox').nth(1); - await modelCombobox.click(); + // Open model dropdown + const modelCombobox = page.getByRole('combobox').nth(1); + await modelCombobox.click(); - // Should have GPT models - await expect(page.getByRole('option', { name: /gpt-4/i }).first()).toBeVisible(); + // Should have GPT models + await expect(page.getByRole('option', { name: /gpt-4/i }).first()).toBeVisible(); + }); }); }); diff --git a/packages/playground/e2e/tests/cms/scorers/edit/page.spec.ts b/packages/playground/e2e/tests/cms/scorers/edit/page.spec.ts index 373781fb12e5..e7426c9090b6 100644 --- a/packages/playground/e2e/tests/cms/scorers/edit/page.spec.ts +++ b/packages/playground/e2e/tests/cms/scorers/edit/page.spec.ts @@ -1,6 +1,7 @@ -import { test, expect, Page } from '@playwright/test'; -import { expectCurrentBreadcrumb } from '../../../__utils__/route-header'; +import type { Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; import { resetStorage } from '../../../__utils__/reset-storage'; +import { expectCurrentBreadcrumb } from '../../../__utils__/route-header'; // Helper to generate unique scorer names function uniqueScorerName(prefix = 'Test Scorer') { @@ -131,409 +132,411 @@ test.afterEach(async () => { await resetStorage(); }); -test.describe('Page Structure & Initial State', () => { - test('displays correct page title and header with scorer name', async ({ page }) => { - const scorerName = uniqueScorerName('Header Test'); - const scorerId = await createScorerAndGetId(page, scorerName); +test.describe('CMS edit scorer page', () => { + test.describe('when the edit page first loads', () => { + test('displays correct page title and header with scorer name', async ({ page }) => { + const scorerName = uniqueScorerName('Header Test'); + const scorerId = await createScorerAndGetId(page, scorerName); - await goToEditPage(page, scorerId); - - await expect(page).toHaveTitle(/Mastra Studio/); - await expectCurrentBreadcrumb(page, scorerName); - }); + await goToEditPage(page, scorerId); - test('displays Update scorer button', async ({ page }) => { - const scorerName = uniqueScorerName('Button Test'); - const scorerId = await createScorerAndGetId(page, scorerName); + await expect(page).toHaveTitle(/Mastra Studio/); + await expectCurrentBreadcrumb(page, scorerName); + }); - await goToEditPage(page, scorerId); + test('displays Update scorer button', async ({ page }) => { + const scorerName = uniqueScorerName('Button Test'); + const scorerId = await createScorerAndGetId(page, scorerName); - const updateButton = page.getByRole('button', { name: 'Publish' }); - await expect(updateButton).toBeVisible(); - await expect(updateButton).toBeEnabled(); - }); + await goToEditPage(page, scorerId); - test('pre-populates form with existing scorer data on load', async ({ page }) => { - const scorerName = uniqueScorerName('Prepopulate Test'); - const description = 'Pre-populated description'; - const instructions = 'Pre-populated instructions for testing.'; - - const scorerId = await createScorerAndGetId(page, scorerName, { - description, - scoreRangeMin: '1', - scoreRangeMax: '5', - samplingType: 'ratio', - samplingRate: '0.7', - instructions, + const updateButton = page.getByRole('button', { name: 'Publish' }); + await expect(updateButton).toBeVisible(); + await expect(updateButton).toBeEnabled(); }); - await goToEditPage(page, scorerId); - - await expect(page.locator('#scorer-name')).toHaveValue(scorerName); - await expect(page.locator('#scorer-description')).toHaveValue(description); - await expect(page.getByRole('combobox').nth(1)).toContainText('OpenAI'); - await expect(page.getByRole('combobox').nth(2)).toContainText('gpt-4o-mini'); - await expect(page.getByPlaceholder('Min')).toHaveValue('1'); - await expect(page.getByPlaceholder('Max')).toHaveValue('5'); - await expect(page.locator('#sampling-ratio')).toBeChecked(); - await expect(page.getByPlaceholder('Rate (0-1)')).toHaveValue('0.7'); - await expect(page.locator('.cm-content')).toContainText(instructions); + test('pre-populates form with existing scorer data on load', async ({ page }) => { + const scorerName = uniqueScorerName('Prepopulate Test'); + const description = 'Pre-populated description'; + const instructions = 'Pre-populated instructions for testing.'; + + const scorerId = await createScorerAndGetId(page, scorerName, { + description, + scoreRangeMin: '1', + scoreRangeMax: '5', + samplingType: 'ratio', + samplingRate: '0.7', + instructions, + }); + + await goToEditPage(page, scorerId); + + await expect(page.locator('#scorer-name')).toHaveValue(scorerName); + await expect(page.locator('#scorer-description')).toHaveValue(description); + await expect(page.getByRole('combobox').nth(1)).toContainText('OpenAI'); + await expect(page.getByRole('combobox').nth(2)).toContainText('gpt-4o-mini'); + await expect(page.getByPlaceholder('Min')).toHaveValue('1'); + await expect(page.getByPlaceholder('Max')).toHaveValue('5'); + await expect(page.locator('#sampling-ratio')).toBeChecked(); + await expect(page.getByPlaceholder('Rate (0-1)')).toHaveValue('0.7'); + await expect(page.locator('.cm-content')).toContainText(instructions); + }); }); -}); - -test.describe('Edit Persistence', () => { - test('updates scorer and redirects to detail page with success toast', async ({ page }) => { - const scorerName = uniqueScorerName('Update Redirect'); - const scorerId = await createScorerAndGetId(page, scorerName); - - await goToEditPage(page, scorerId); - const updatedName = uniqueScorerName('Updated'); - await fillScorerFields(page, { name: updatedName }); + test.describe('when scorer edits are saved', () => { + test('updates scorer and redirects to detail page with success toast', async ({ page }) => { + const scorerName = uniqueScorerName('Update Redirect'); + const scorerId = await createScorerAndGetId(page, scorerName); - await page.getByRole('button', { name: 'Publish' }).click(); + await goToEditPage(page, scorerId); - await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); - await expect(page.getByText('Scorer published')).toBeVisible(); - }); - - test('persists all edited fields when returning to edit page', async ({ page }) => { - const scorerName = uniqueScorerName('Full Edit'); - const scorerId = await createScorerAndGetId(page, scorerName); + const updatedName = uniqueScorerName('Updated'); + await fillScorerFields(page, { name: updatedName }); - await goToEditPage(page, scorerId); + await page.getByRole('button', { name: 'Publish' }).click(); - const updatedName = uniqueScorerName('Fully Updated'); - const updatedDescription = 'Updated description for full edit test'; - const updatedInstructions = 'Updated instructions with new scoring criteria.'; + await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); + await expect(page.getByText('Scorer published')).toBeVisible(); + }); - await fillScorerFields(page, { - name: updatedName, - description: updatedDescription, - scoreRangeMin: '2', - scoreRangeMax: '8', - samplingType: 'ratio', - samplingRate: '0.3', - instructions: updatedInstructions, + test('persists all edited fields when returning to edit page', async ({ page }) => { + const scorerName = uniqueScorerName('Full Edit'); + const scorerId = await createScorerAndGetId(page, scorerName); + + await goToEditPage(page, scorerId); + + const updatedName = uniqueScorerName('Fully Updated'); + const updatedDescription = 'Updated description for full edit test'; + const updatedInstructions = 'Updated instructions with new scoring criteria.'; + + await fillScorerFields(page, { + name: updatedName, + description: updatedDescription, + scoreRangeMin: '2', + scoreRangeMax: '8', + samplingType: 'ratio', + samplingRate: '0.3', + instructions: updatedInstructions, + }); + + await page.getByRole('button', { name: 'Publish' }).click(); + + await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); + await expect(page.getByText('Scorer published')).toBeVisible(); + + // Navigate back to edit page + await goToEditPage(page, scorerId); + + await expect(page.locator('#scorer-name')).toHaveValue(updatedName); + await expect(page.locator('#scorer-description')).toHaveValue(updatedDescription); + await expect(page.getByRole('combobox').nth(1)).toContainText('OpenAI'); + await expect(page.getByRole('combobox').nth(2)).toContainText('gpt-4o-mini'); + await expect(page.getByPlaceholder('Min')).toHaveValue('2'); + await expect(page.getByPlaceholder('Max')).toHaveValue('8'); + await expect(page.locator('#sampling-ratio')).toBeChecked(); + await expect(page.getByPlaceholder('Rate (0-1)')).toHaveValue('0.3'); + await expect(page.locator('.cm-content')).toContainText(updatedInstructions); }); - await page.getByRole('button', { name: 'Publish' }).click(); + test('persists partial field updates', async ({ page }) => { + const scorerName = uniqueScorerName('Partial Edit'); + const scorerId = await createScorerAndGetId(page, scorerName, { + description: 'Original description', + scoreRangeMin: '0', + scoreRangeMax: '10', + }); - await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); - await expect(page.getByText('Scorer published')).toBeVisible(); + await goToEditPage(page, scorerId); - // Navigate back to edit page - await goToEditPage(page, scorerId); + // Only update description and score range max + await fillScorerFields(page, { + description: 'Partially updated description', + scoreRangeMax: '20', + }); - await expect(page.locator('#scorer-name')).toHaveValue(updatedName); - await expect(page.locator('#scorer-description')).toHaveValue(updatedDescription); - await expect(page.getByRole('combobox').nth(1)).toContainText('OpenAI'); - await expect(page.getByRole('combobox').nth(2)).toContainText('gpt-4o-mini'); - await expect(page.getByPlaceholder('Min')).toHaveValue('2'); - await expect(page.getByPlaceholder('Max')).toHaveValue('8'); - await expect(page.locator('#sampling-ratio')).toBeChecked(); - await expect(page.getByPlaceholder('Rate (0-1)')).toHaveValue('0.3'); - await expect(page.locator('.cm-content')).toContainText(updatedInstructions); - }); + await page.getByRole('button', { name: 'Publish' }).click(); - test('persists partial field updates', async ({ page }) => { - const scorerName = uniqueScorerName('Partial Edit'); - const scorerId = await createScorerAndGetId(page, scorerName, { - description: 'Original description', - scoreRangeMin: '0', - scoreRangeMax: '10', - }); + await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); - await goToEditPage(page, scorerId); + await goToEditPage(page, scorerId); - // Only update description and score range max - await fillScorerFields(page, { - description: 'Partially updated description', - scoreRangeMax: '20', + // Changed fields should be updated + await expect(page.locator('#scorer-description')).toHaveValue('Partially updated description'); + await expect(page.getByPlaceholder('Max')).toHaveValue('20'); + + // Unchanged fields should remain the same + await expect(page.locator('#scorer-name')).toHaveValue(scorerName); + await expect(page.getByPlaceholder('Min')).toHaveValue('0'); + await expect(page.getByRole('combobox').nth(1)).toContainText('OpenAI'); + await expect(page.getByRole('combobox').nth(2)).toContainText('gpt-4o-mini'); }); - await page.getByRole('button', { name: 'Publish' }).click(); + test('data persists after page reload on edit page', async ({ page }) => { + const scorerName = uniqueScorerName('Reload Edit'); + const updatedName = uniqueScorerName('After Reload'); + const updatedInstructions = 'Instructions that should survive reload.'; - await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); + const scorerId = await createScorerAndGetId(page, scorerName); - await goToEditPage(page, scorerId); + await goToEditPage(page, scorerId); - // Changed fields should be updated - await expect(page.locator('#scorer-description')).toHaveValue('Partially updated description'); - await expect(page.getByPlaceholder('Max')).toHaveValue('20'); + await fillScorerFields(page, { + name: updatedName, + instructions: updatedInstructions, + }); - // Unchanged fields should remain the same - await expect(page.locator('#scorer-name')).toHaveValue(scorerName); - await expect(page.getByPlaceholder('Min')).toHaveValue('0'); - await expect(page.getByRole('combobox').nth(1)).toContainText('OpenAI'); - await expect(page.getByRole('combobox').nth(2)).toContainText('gpt-4o-mini'); - }); + await page.getByRole('button', { name: 'Publish' }).click(); - test('data persists after page reload on edit page', async ({ page }) => { - const scorerName = uniqueScorerName('Reload Edit'); - const updatedName = uniqueScorerName('After Reload'); - const updatedInstructions = 'Instructions that should survive reload.'; + await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); - const scorerId = await createScorerAndGetId(page, scorerName); + await goToEditPage(page, scorerId); - await goToEditPage(page, scorerId); + // Reload the page + await page.reload(); - await fillScorerFields(page, { - name: updatedName, - instructions: updatedInstructions, + await expect(page.locator('#scorer-name')).toHaveValue(updatedName, { timeout: 10000 }); + await expect(page.locator('.cm-content')).toContainText(updatedInstructions, { timeout: 10000 }); }); + }); - await page.getByRole('button', { name: 'Publish' }).click(); + test.describe('when a single field is updated', () => { + test('updating name persists correctly', async ({ page }) => { + const scorerName = uniqueScorerName('Name Field'); + const scorerId = await createScorerAndGetId(page, scorerName); - await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); + await goToEditPage(page, scorerId); - await goToEditPage(page, scorerId); + const updatedName = uniqueScorerName('Name Updated'); + await fillScorerFields(page, { name: updatedName }); - // Reload the page - await page.reload(); + await page.getByRole('button', { name: 'Publish' }).click(); + await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); - await expect(page.locator('#scorer-name')).toHaveValue(updatedName, { timeout: 10000 }); - await expect(page.locator('.cm-content')).toContainText(updatedInstructions, { timeout: 10000 }); - }); -}); + await goToEditPage(page, scorerId); + await expect(page.locator('#scorer-name')).toHaveValue(updatedName); + await expectCurrentBreadcrumb(page, updatedName); + }); -test.describe('Field-by-Field Update Verification', () => { - test('updating name persists correctly', async ({ page }) => { - const scorerName = uniqueScorerName('Name Field'); - const scorerId = await createScorerAndGetId(page, scorerName); + test('updating description persists correctly', async ({ page }) => { + const scorerName = uniqueScorerName('Desc Field'); + const scorerId = await createScorerAndGetId(page, scorerName); - await goToEditPage(page, scorerId); + await goToEditPage(page, scorerId); - const updatedName = uniqueScorerName('Name Updated'); - await fillScorerFields(page, { name: updatedName }); + await fillScorerFields(page, { description: 'A newly added description' }); - await page.getByRole('button', { name: 'Publish' }).click(); - await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); + await page.getByRole('button', { name: 'Publish' }).click(); + await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); - await goToEditPage(page, scorerId); - await expect(page.locator('#scorer-name')).toHaveValue(updatedName); - await expectCurrentBreadcrumb(page, updatedName); - }); + await goToEditPage(page, scorerId); + await expect(page.locator('#scorer-description')).toHaveValue('A newly added description'); + }); - test('updating description persists correctly', async ({ page }) => { - const scorerName = uniqueScorerName('Desc Field'); - const scorerId = await createScorerAndGetId(page, scorerName); + test('updating score range persists correctly', async ({ page }) => { + const scorerName = uniqueScorerName('Range Field'); + const scorerId = await createScorerAndGetId(page, scorerName); - await goToEditPage(page, scorerId); + await goToEditPage(page, scorerId); - await fillScorerFields(page, { description: 'A newly added description' }); + await fillScorerFields(page, { scoreRangeMin: '5', scoreRangeMax: '100' }); - await page.getByRole('button', { name: 'Publish' }).click(); - await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); + await page.getByRole('button', { name: 'Publish' }).click(); + await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); - await goToEditPage(page, scorerId); - await expect(page.locator('#scorer-description')).toHaveValue('A newly added description'); - }); + await goToEditPage(page, scorerId); + await expect(page.getByPlaceholder('Min')).toHaveValue('5'); + await expect(page.getByPlaceholder('Max')).toHaveValue('100'); + }); - test('updating score range persists correctly', async ({ page }) => { - const scorerName = uniqueScorerName('Range Field'); - const scorerId = await createScorerAndGetId(page, scorerName); + test('changing sampling type from none to ratio persists correctly', async ({ page }) => { + const scorerName = uniqueScorerName('Sampling None-Ratio'); + const scorerId = await createScorerAndGetId(page, scorerName); - await goToEditPage(page, scorerId); + await goToEditPage(page, scorerId); - await fillScorerFields(page, { scoreRangeMin: '5', scoreRangeMax: '100' }); + await fillScorerFields(page, { samplingType: 'ratio', samplingRate: '0.6' }); - await page.getByRole('button', { name: 'Publish' }).click(); - await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); + await page.getByRole('button', { name: 'Publish' }).click(); + await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); - await goToEditPage(page, scorerId); - await expect(page.getByPlaceholder('Min')).toHaveValue('5'); - await expect(page.getByPlaceholder('Max')).toHaveValue('100'); - }); - - test('changing sampling type from none to ratio persists correctly', async ({ page }) => { - const scorerName = uniqueScorerName('Sampling None-Ratio'); - const scorerId = await createScorerAndGetId(page, scorerName); + await goToEditPage(page, scorerId); + await expect(page.locator('#sampling-ratio')).toBeChecked(); + await expect(page.getByPlaceholder('Rate (0-1)')).toHaveValue('0.6'); + }); - await goToEditPage(page, scorerId); + test('changing sampling type from ratio to none persists correctly', async ({ page }) => { + const scorerName = uniqueScorerName('Sampling Ratio-None'); + const scorerId = await createScorerAndGetId(page, scorerName, { + samplingType: 'ratio', + samplingRate: '0.5', + }); - await fillScorerFields(page, { samplingType: 'ratio', samplingRate: '0.6' }); + await goToEditPage(page, scorerId); - await page.getByRole('button', { name: 'Publish' }).click(); - await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); + await fillScorerFields(page, { samplingType: 'none' }); - await goToEditPage(page, scorerId); - await expect(page.locator('#sampling-ratio')).toBeChecked(); - await expect(page.getByPlaceholder('Rate (0-1)')).toHaveValue('0.6'); - }); + await page.getByRole('button', { name: 'Publish' }).click(); + await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); - test('changing sampling type from ratio to none persists correctly', async ({ page }) => { - const scorerName = uniqueScorerName('Sampling Ratio-None'); - const scorerId = await createScorerAndGetId(page, scorerName, { - samplingType: 'ratio', - samplingRate: '0.5', + await goToEditPage(page, scorerId); + await expect(page.locator('#sampling-none')).toBeChecked(); + await expect(page.getByPlaceholder('Rate (0-1)')).not.toBeVisible(); }); - await goToEditPage(page, scorerId); + test('updating instructions persists correctly', async ({ page }) => { + const scorerName = uniqueScorerName('Instructions Field'); + const scorerId = await createScorerAndGetId(page, scorerName); + + await goToEditPage(page, scorerId); - await fillScorerFields(page, { samplingType: 'none' }); + const newInstructions = 'Brand new scoring instructions for this test.'; + await fillScorerFields(page, { instructions: newInstructions }); - await page.getByRole('button', { name: 'Publish' }).click(); - await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); + await page.getByRole('button', { name: 'Publish' }).click(); + await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); - await goToEditPage(page, scorerId); - await expect(page.locator('#sampling-none')).toBeChecked(); - await expect(page.getByPlaceholder('Rate (0-1)')).not.toBeVisible(); + await goToEditPage(page, scorerId); + await expect(page.locator('.cm-content')).toContainText(newInstructions); + }); }); - test('updating instructions persists correctly', async ({ page }) => { - const scorerName = uniqueScorerName('Instructions Field'); - const scorerId = await createScorerAndGetId(page, scorerName); + test.describe('when required fields are cleared on edit', () => { + test('shows validation error when name is cleared', async ({ page }) => { + const scorerName = uniqueScorerName('Validation Name'); + const scorerId = await createScorerAndGetId(page, scorerName); - await goToEditPage(page, scorerId); + await goToEditPage(page, scorerId); - const newInstructions = 'Brand new scoring instructions for this test.'; - await fillScorerFields(page, { instructions: newInstructions }); + const nameInput = page.locator('#scorer-name'); + await nameInput.clear(); - await page.getByRole('button', { name: 'Publish' }).click(); - await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); + await page.getByRole('button', { name: 'Publish' }).click(); - await goToEditPage(page, scorerId); - await expect(page.locator('.cm-content')).toContainText(newInstructions); - }); -}); + await expect(page.getByText('Name is required')).toBeVisible(); + }); -test.describe('Validation on Edit', () => { - test('shows validation error when name is cleared', async ({ page }) => { - const scorerName = uniqueScorerName('Validation Name'); - const scorerId = await createScorerAndGetId(page, scorerName); + test('shows error toast when form has validation errors', async ({ page }) => { + const scorerName = uniqueScorerName('Validation Toast'); + const scorerId = await createScorerAndGetId(page, scorerName); - await goToEditPage(page, scorerId); + await goToEditPage(page, scorerId); - const nameInput = page.locator('#scorer-name'); - await nameInput.clear(); + const nameInput = page.locator('#scorer-name'); + await nameInput.clear(); - await page.getByRole('button', { name: 'Publish' }).click(); + await page.getByRole('button', { name: 'Publish' }).click(); - await expect(page.getByText('Name is required')).toBeVisible(); + await expect(page.getByText('Please fill in all required fields')).toBeVisible(); + }); }); - test('shows error toast when form has validation errors', async ({ page }) => { - const scorerName = uniqueScorerName('Validation Toast'); - const scorerId = await createScorerAndGetId(page, scorerName); + test.describe('when a scorer update fails on the server', () => { + test('shows error toast and allows retry on update failure', async ({ page }) => { + const scorerName = uniqueScorerName('Error Handling'); + const scorerId = await createScorerAndGetId(page, scorerName); - await goToEditPage(page, scorerId); + await goToEditPage(page, scorerId); - const nameInput = page.locator('#scorer-name'); - await nameInput.clear(); + // Intercept PATCH requests to simulate server error (set up after page loads) + await page.route(`**/stored/scorers/${scorerId}**`, route => { + if (route.request().method() === 'PATCH') { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ message: 'Internal server error' }), + }); + } else { + route.continue(); + } + }); - await page.getByRole('button', { name: 'Publish' }).click(); + await fillScorerFields(page, { name: uniqueScorerName('Should Fail') }); - await expect(page.getByText('Please fill in all required fields')).toBeVisible(); - }); -}); - -test.describe('Error Handling', () => { - test('shows error toast and allows retry on update failure', async ({ page }) => { - const scorerName = uniqueScorerName('Error Handling'); - const scorerId = await createScorerAndGetId(page, scorerName); - - await goToEditPage(page, scorerId); - - // Intercept PATCH requests to simulate server error (set up after page loads) - await page.route(`**/stored/scorers/${scorerId}**`, route => { - if (route.request().method() === 'PATCH') { - route.fulfill({ - status: 500, - contentType: 'application/json', - body: JSON.stringify({ message: 'Internal server error' }), - }); - } else { - route.continue(); - } - }); + await page.getByRole('button', { name: 'Publish' }).click(); - await fillScorerFields(page, { name: uniqueScorerName('Should Fail') }); + await expect(page.getByText(/Failed to publish scorer/i)).toBeVisible({ timeout: 15000 }); - await page.getByRole('button', { name: 'Publish' }).click(); + // Should stay on edit page with button still enabled + await expect(page).toHaveURL(/\/cms\/scorers\/[a-z0-9-]+\/edit/); + await expect(page.getByRole('button', { name: 'Publish' })).toBeEnabled(); + }); - await expect(page.getByText(/Failed to publish scorer/i)).toBeVisible({ timeout: 15000 }); + test('stays on edit page when update fails and preserves form data', async ({ page }) => { + const scorerName = uniqueScorerName('Preserve Data'); + const scorerId = await createScorerAndGetId(page, scorerName); - // Should stay on edit page with button still enabled - await expect(page).toHaveURL(/\/cms\/scorers\/[a-z0-9-]+\/edit/); - await expect(page.getByRole('button', { name: 'Publish' })).toBeEnabled(); - }); + await goToEditPage(page, scorerId); - test('stays on edit page when update fails and preserves form data', async ({ page }) => { - const scorerName = uniqueScorerName('Preserve Data'); - const scorerId = await createScorerAndGetId(page, scorerName); - - await goToEditPage(page, scorerId); - - // Intercept PATCH requests to simulate server error (set up after page loads) - await page.route(`**/stored/scorers/${scorerId}**`, route => { - if (route.request().method() === 'PATCH') { - route.fulfill({ - status: 500, - contentType: 'application/json', - body: JSON.stringify({ message: 'Internal server error' }), - }); - } else { - route.continue(); - } - }); + // Intercept PATCH requests to simulate server error (set up after page loads) + await page.route(`**/stored/scorers/${scorerId}**`, route => { + if (route.request().method() === 'PATCH') { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ message: 'Internal server error' }), + }); + } else { + route.continue(); + } + }); - const updatedName = uniqueScorerName('Not Lost'); - const updatedDescription = 'This description should not be lost'; - await fillScorerFields(page, { name: updatedName, description: updatedDescription }); + const updatedName = uniqueScorerName('Not Lost'); + const updatedDescription = 'This description should not be lost'; + await fillScorerFields(page, { name: updatedName, description: updatedDescription }); - await page.getByRole('button', { name: 'Publish' }).click(); + await page.getByRole('button', { name: 'Publish' }).click(); - await expect(page.getByText(/Failed to publish scorer/i)).toBeVisible({ timeout: 15000 }); + await expect(page.getByText(/Failed to publish scorer/i)).toBeVisible({ timeout: 15000 }); - // Form data should still be present - await expect(page.locator('#scorer-name')).toHaveValue(updatedName); - await expect(page.locator('#scorer-description')).toHaveValue(updatedDescription); + // Form data should still be present + await expect(page.locator('#scorer-name')).toHaveValue(updatedName); + await expect(page.locator('#scorer-description')).toHaveValue(updatedDescription); + }); }); -}); -test.describe('Navigation & State', () => { - test('navigating away and back to edit page shows persisted data', async ({ page }) => { - const scorerName = uniqueScorerName('Nav Away'); - const description = 'Description for nav test'; - const scorerId = await createScorerAndGetId(page, scorerName, { description }); + test.describe('when navigating away from the edit page', () => { + test('navigating away and back to edit page shows persisted data', async ({ page }) => { + const scorerName = uniqueScorerName('Nav Away'); + const description = 'Description for nav test'; + const scorerId = await createScorerAndGetId(page, scorerName, { description }); - await goToEditPage(page, scorerId); + await goToEditPage(page, scorerId); - // Verify initial data - await expect(page.locator('#scorer-name')).toHaveValue(scorerName); + // Verify initial data + await expect(page.locator('#scorer-name')).toHaveValue(scorerName); - // Navigate away - await page.goto('/cms/scorers/create'); - await expect(page).toHaveURL(/\/cms\/scorers\/create/); + // Navigate away + await page.goto('/cms/scorers/create'); + await expect(page).toHaveURL(/\/cms\/scorers\/create/); - // Navigate back to edit page - await goToEditPage(page, scorerId); + // Navigate back to edit page + await goToEditPage(page, scorerId); - await expect(page.locator('#scorer-name')).toHaveValue(scorerName); - await expect(page.locator('#scorer-description')).toHaveValue(description); - }); + await expect(page.locator('#scorer-name')).toHaveValue(scorerName); + await expect(page.locator('#scorer-description')).toHaveValue(description); + }); - test('form reflects latest server data after re-navigation', async ({ page }) => { - const scorerName = uniqueScorerName('Latest Data'); - const scorerId = await createScorerAndGetId(page, scorerName); - - // First edit: update the name - await goToEditPage(page, scorerId); - const firstUpdate = uniqueScorerName('First Update'); - await fillScorerFields(page, { name: firstUpdate }); - await page.getByRole('button', { name: 'Publish' }).click(); - await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); - - // Second edit: update again - await goToEditPage(page, scorerId); - const secondUpdate = uniqueScorerName('Second Update'); - await fillScorerFields(page, { name: secondUpdate }); - await page.getByRole('button', { name: 'Publish' }).click(); - await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); - - // Navigate back to edit - should show second update, not first - await goToEditPage(page, scorerId); - await expect(page.locator('#scorer-name')).toHaveValue(secondUpdate); + test('form reflects latest server data after re-navigation', async ({ page }) => { + const scorerName = uniqueScorerName('Latest Data'); + const scorerId = await createScorerAndGetId(page, scorerName); + + // First edit: update the name + await goToEditPage(page, scorerId); + const firstUpdate = uniqueScorerName('First Update'); + await fillScorerFields(page, { name: firstUpdate }); + await page.getByRole('button', { name: 'Publish' }).click(); + await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); + + // Second edit: update again + await goToEditPage(page, scorerId); + const secondUpdate = uniqueScorerName('Second Update'); + await fillScorerFields(page, { name: secondUpdate }); + await page.getByRole('button', { name: 'Publish' }).click(); + await expect(page).toHaveURL(/\/scorers\/[a-z0-9-]+$/, { timeout: 15000 }); + + // Navigate back to edit - should show second update, not first + await goToEditPage(page, scorerId); + await expect(page.locator('#scorer-name')).toHaveValue(secondUpdate); + }); }); }); diff --git a/packages/playground/e2e/tests/datasets/compare-experiments.spec.ts b/packages/playground/e2e/tests/datasets/compare-experiments.spec.ts index 59133e1ffda2..04e20e491326 100644 --- a/packages/playground/e2e/tests/datasets/compare-experiments.spec.ts +++ b/packages/playground/e2e/tests/datasets/compare-experiments.spec.ts @@ -74,35 +74,37 @@ test.afterEach(async () => { * BEHAVIOR UNDER TEST: Selecting compare checkboxes must keep me on the dataset page until I explicitly trigger comparison, * then navigate to the comparison view with both experiment IDs encoded in the URL. */ -test('dataset experiments compare mode keeps checkbox selection on-page and opens comparison view', async ({ - page, -}) => { - const { datasetId, experimentIds } = await seedDatasetWithExperiments(); - const [baselineId, contenderId] = experimentIds; +test.describe('Dataset experiment comparison', () => { + test.describe('when two experiments are selected in compare mode', () => { + test('keeps checkbox selection on-page and opens the comparison view', async ({ page }) => { + const { datasetId, experimentIds } = await seedDatasetWithExperiments(); + const [baselineId, contenderId] = experimentIds; - await page.goto(`/datasets/${datasetId}?tab=experiments`); + await page.goto(`/datasets/${datasetId}?tab=experiments`); - await expect(page.getByText('weather-agent')).toHaveCount(2); + await expect(page.getByText('weather-agent')).toHaveCount(2); - await page.getByRole('button', { name: 'Compare' }).click(); + await page.getByRole('button', { name: 'Compare' }).click(); - const baselineCheckbox = page.getByRole('checkbox', { name: `Select experiment ${baselineId}` }); - const contenderCheckbox = page.getByRole('checkbox', { name: `Select experiment ${contenderId}` }); - const compareButton = page.getByRole('button', { name: 'Compare Experiments' }); + const baselineCheckbox = page.getByRole('checkbox', { name: `Select experiment ${baselineId}` }); + const contenderCheckbox = page.getByRole('checkbox', { name: `Select experiment ${contenderId}` }); + const compareButton = page.getByRole('button', { name: 'Compare Experiments' }); - await baselineCheckbox.click(); - await expect(page).toHaveURL(`${BASE_URL}/datasets/${datasetId}?tab=experiments`); - await expect(baselineCheckbox).toBeChecked(); - await expect(compareButton).toBeDisabled(); + await baselineCheckbox.click(); + await expect(page).toHaveURL(`${BASE_URL}/datasets/${datasetId}?tab=experiments`); + await expect(baselineCheckbox).toBeChecked(); + await expect(compareButton).toBeDisabled(); - await contenderCheckbox.click(); - await expect(contenderCheckbox).toBeChecked(); - await expect(compareButton).toBeEnabled(); + await contenderCheckbox.click(); + await expect(contenderCheckbox).toBeChecked(); + await expect(compareButton).toBeEnabled(); - await compareButton.click(); + await compareButton.click(); - await expect(page).toHaveURL( - `${BASE_URL}/datasets/${datasetId}/experiments?baseline=${baselineId}&contender=${contenderId}`, - ); - await expect(page.getByText('Dataset Experiments Comparison')).toBeVisible(); + await expect(page).toHaveURL( + `${BASE_URL}/datasets/${datasetId}/experiments?baseline=${baselineId}&contender=${contenderId}`, + ); + await expect(page.getByText('Dataset Experiments Comparison')).toBeVisible(); + }); + }); }); diff --git a/packages/playground/e2e/tests/datasets/dataset-items-list.spec.ts b/packages/playground/e2e/tests/datasets/dataset-items-list.spec.ts index 7678a46e40fe..e391e33db22c 100644 --- a/packages/playground/e2e/tests/datasets/dataset-items-list.spec.ts +++ b/packages/playground/e2e/tests/datasets/dataset-items-list.spec.ts @@ -14,128 +14,142 @@ test.afterEach(async () => { * BEHAVIOR UNDER TEST: Items list displays data, supports selection, and enables bulk operations. */ -test.describe('Dataset Items List - Behavior Tests', () => { - test('clicking an item row opens the detail panel showing item metadata', async ({ page }) => { - const { id: datasetId } = await seedDatasetWithItems(3); - - await page.goto(`/datasets/${datasetId}`); - await expect(page.getByText('Test input 1')).toBeVisible(); - - await page.getByRole('button', { name: /Test input 1/ }).click(); - - // Scope to the detail panel's metadata list (a <dl> uniquely identified by its - // "Dataset Id" field, which the list rows don't render), so the timestamp regex - // can't accidentally match a datetime elsewhere on the page. - const itemMetadata = page.locator('dl').filter({ hasText: 'Dataset Id' }); - await expect(itemMetadata).toBeVisible({ timeout: 5000 }); - // The "Created" timestamp renders as "MMM d, yyyy h:mm aaa", e.g. "May 29, 2026 1:08 pm". - await expect(itemMetadata.getByText(/[A-Z][a-z]{2} \d{1,2}, \d{4} \d{1,2}:\d{2} (am|pm)/).first()).toBeVisible(); +test.describe('Dataset items list', () => { + test.describe('when an item row is clicked', () => { + test('opens the detail panel showing item metadata', async ({ page }) => { + const { id: datasetId } = await seedDatasetWithItems(3); + + await page.goto(`/datasets/${datasetId}`); + await expect(page.getByText('Test input 1')).toBeVisible(); + + await page.getByRole('button', { name: /Test input 1/ }).click(); + + // Scope to the detail panel's metadata list (a <dl> uniquely identified by its + // "Dataset Id" field, which the list rows don't render), so the timestamp regex + // can't accidentally match a datetime elsewhere on the page. + const itemMetadata = page.locator('dl').filter({ hasText: 'Dataset Id' }); + await expect(itemMetadata).toBeVisible({ timeout: 5000 }); + // The "Created" timestamp renders as "MMM d, yyyy h:mm aaa", e.g. "May 29, 2026 1:08 pm". + await expect(itemMetadata.getByText(/[A-Z][a-z]{2} \d{1,2}, \d{4} \d{1,2}:\d{2} (am|pm)/).first()).toBeVisible(); + }); }); - test('selecting Delete Items from menu enables selection mode with checkboxes', async ({ page }) => { - const { id: datasetId } = await seedDatasetWithItems(3); + test.describe('when Delete Items is selected from the actions menu', () => { + test('enables selection mode with checkboxes', async ({ page }) => { + const { id: datasetId } = await seedDatasetWithItems(3); - await page.goto(`/datasets/${datasetId}`); - await expect(page.getByText('Test input 1')).toBeVisible(); + await page.goto(`/datasets/${datasetId}`); + await expect(page.getByText('Test input 1')).toBeVisible(); - await page.getByRole('button', { name: 'Actions menu', exact: true }).click(); - await page.getByRole('menuitem', { name: /Delete Items/i }).click(); + await page.getByRole('button', { name: 'Actions menu', exact: true }).click(); + await page.getByRole('menuitem', { name: /Delete Items/i }).click(); - await expect(page.getByRole('checkbox').first()).toBeVisible(); + await expect(page.getByRole('checkbox').first()).toBeVisible(); - const checkbox1 = page.getByRole('checkbox').first(); - await checkbox1.click(); + const checkbox1 = page.getByRole('checkbox').first(); + await checkbox1.click(); - await expect(page.getByText(/selected/i)).toBeVisible(); - await expect(page.getByRole('button', { name: /Delete Items/i })).toBeEnabled(); + await expect(page.getByText(/selected/i)).toBeVisible(); + await expect(page.getByRole('button', { name: /Delete Items/i })).toBeEnabled(); + }); }); - test('bulk delete removes selected items from the list', async ({ page }) => { - const { id: datasetId } = await seedDatasetWithItems(3); + test.describe('when all items are bulk deleted', () => { + test('removes the selected items from the list', async ({ page }) => { + const { id: datasetId } = await seedDatasetWithItems(3); - await page.goto(`/datasets/${datasetId}`); + await page.goto(`/datasets/${datasetId}`); - await expect(page.getByText('Test input 1')).toBeVisible(); - await expect(page.getByText('Test input 2')).toBeVisible(); - await expect(page.getByText('Test input 3')).toBeVisible(); + await expect(page.getByText('Test input 1')).toBeVisible(); + await expect(page.getByText('Test input 2')).toBeVisible(); + await expect(page.getByText('Test input 3')).toBeVisible(); - await page.getByRole('button', { name: 'Actions menu', exact: true }).click(); - await page.getByRole('menuitem', { name: /Delete Items/i }).click(); + await page.getByRole('button', { name: 'Actions menu', exact: true }).click(); + await page.getByRole('menuitem', { name: /Delete Items/i }).click(); - const selectAllCheckbox = page.getByRole('checkbox', { name: /Select all/i }); - await selectAllCheckbox.click(); + const selectAllCheckbox = page.getByRole('checkbox', { name: /Select all/i }); + await selectAllCheckbox.click(); - await page.getByRole('button', { name: /Delete Items/i }).click(); + await page.getByRole('button', { name: /Delete Items/i }).click(); - const confirmButton = page.getByRole('button', { name: /^Delete$/i }); - await confirmButton.click(); + const confirmButton = page.getByRole('button', { name: /^Delete$/i }); + await confirmButton.click(); - await expect(page.getByText('No items yet')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('No items yet')).toBeVisible({ timeout: 10000 }); + }); }); - test('search filters items by input content', async ({ page }) => { - const { id: datasetId } = await seedDatasetWithItems(5); + test.describe('when a search term is entered', () => { + test('filters items by input content', async ({ page }) => { + const { id: datasetId } = await seedDatasetWithItems(5); - await page.goto(`/datasets/${datasetId}`); - await expect(page.getByText('Test input 1')).toBeVisible(); - await expect(page.getByText('Test input 5')).toBeVisible(); + await page.goto(`/datasets/${datasetId}`); + await expect(page.getByText('Test input 1')).toBeVisible(); + await expect(page.getByText('Test input 5')).toBeVisible(); - await page.getByPlaceholder(/Search/i).fill('input 3'); + await page.getByPlaceholder(/Search/i).fill('input 3'); - await expect(page.getByText('Test input 3')).toBeVisible(); - await expect(page.getByText('Test input 1')).not.toBeVisible(); - await expect(page.getByText('Test input 5')).not.toBeVisible(); + await expect(page.getByText('Test input 3')).toBeVisible(); + await expect(page.getByText('Test input 1')).not.toBeVisible(); + await expect(page.getByText('Test input 5')).not.toBeVisible(); + }); }); - test('empty dataset shows empty state with add item actions', async ({ page }) => { - const datasetRes = await fetch(`${BASE_URL}/api/datasets`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ name: 'Empty E2E Dataset' }), + test.describe('when the dataset is empty', () => { + test('shows the empty state with add item actions', async ({ page }) => { + const datasetRes = await fetch(`${BASE_URL}/api/datasets`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Empty E2E Dataset' }), + }); + if (!datasetRes.ok) { + throw new Error(`Failed to create empty dataset: ${datasetRes.status} ${datasetRes.statusText}`); + } + const dataset = (await datasetRes.json()) as { id: string }; + + await page.goto(`/datasets/${dataset.id}`); + + await expect(page.getByText('No items yet')).toBeVisible(); + await expect(page.getByRole('button', { name: /Add Single Item/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /Import CSV/i })).toBeVisible(); }); - if (!datasetRes.ok) { - throw new Error(`Failed to create empty dataset: ${datasetRes.status} ${datasetRes.statusText}`); - } - const dataset = (await datasetRes.json()) as { id: string }; - - await page.goto(`/datasets/${dataset.id}`); - - await expect(page.getByText('No items yet')).toBeVisible(); - await expect(page.getByRole('button', { name: /Add Single Item/i })).toBeVisible(); - await expect(page.getByRole('button', { name: /Import CSV/i })).toBeVisible(); }); - test('select all checkbox selects all visible items in selection mode', async ({ page }) => { - const { id: datasetId } = await seedDatasetWithItems(3); + test.describe('when the select-all checkbox is clicked in selection mode', () => { + test('selects all visible items', async ({ page }) => { + const { id: datasetId } = await seedDatasetWithItems(3); - await page.goto(`/datasets/${datasetId}`); - await expect(page.getByText('Test input 1')).toBeVisible(); + await page.goto(`/datasets/${datasetId}`); + await expect(page.getByText('Test input 1')).toBeVisible(); - await page.getByRole('button', { name: 'Actions menu', exact: true }).click(); - await page.getByRole('menuitem', { name: /Delete Items/i }).click(); + await page.getByRole('button', { name: 'Actions menu', exact: true }).click(); + await page.getByRole('menuitem', { name: /Delete Items/i }).click(); - const selectAllCheckbox = page.getByRole('checkbox', { name: /Select all/i }); - await selectAllCheckbox.click(); + const selectAllCheckbox = page.getByRole('checkbox', { name: /Select all/i }); + await selectAllCheckbox.click(); - await expect(page.getByText('items selected')).toBeVisible(); + await expect(page.getByText('items selected')).toBeVisible(); + }); }); - test('cancel button clears selection and exits selection mode', async ({ page }) => { - const { id: datasetId } = await seedDatasetWithItems(3); + test.describe('when Cancel is clicked in selection mode', () => { + test('clears the selection and exits selection mode', async ({ page }) => { + const { id: datasetId } = await seedDatasetWithItems(3); - await page.goto(`/datasets/${datasetId}`); - await expect(page.getByText('Test input 1')).toBeVisible(); + await page.goto(`/datasets/${datasetId}`); + await expect(page.getByText('Test input 1')).toBeVisible(); - await page.getByRole('button', { name: 'Actions menu', exact: true }).click(); - await page.getByRole('menuitem', { name: /Delete Items/i }).click(); + await page.getByRole('button', { name: 'Actions menu', exact: true }).click(); + await page.getByRole('menuitem', { name: /Delete Items/i }).click(); - const checkbox = page.getByRole('checkbox').nth(1); - await checkbox.click(); - await expect(page.getByText(/selected/i)).toBeVisible(); + const checkbox = page.getByRole('checkbox').nth(1); + await checkbox.click(); + await expect(page.getByText(/selected/i)).toBeVisible(); - await page.getByRole('button', { name: /Cancel/i }).click(); + await page.getByRole('button', { name: /Cancel/i }).click(); - await expect(page.getByText(/selected/i)).not.toBeVisible(); - await expect(page.getByRole('checkbox')).toHaveCount(0); + await expect(page.getByText(/selected/i)).not.toBeVisible(); + await expect(page.getByRole('checkbox')).toHaveCount(0); + }); }); }); diff --git a/packages/playground/e2e/tests/datasets/pagination.spec.ts b/packages/playground/e2e/tests/datasets/pagination.spec.ts index 18b78de26ff1..f665611c12d5 100644 --- a/packages/playground/e2e/tests/datasets/pagination.spec.ts +++ b/packages/playground/e2e/tests/datasets/pagination.spec.ts @@ -5,50 +5,56 @@ test.afterEach(async () => { await resetStorage(); }); -test('datasets page paginates forward and backward across seeded datasets', async ({ page }) => { - // Seed 12 datasets so two pages of 10 per page exist. - // The API lists datasets newest-first, so "E2E Dataset 12" is on page 1 - // and "E2E Dataset 01" & "E2E Dataset 02" are on page 2. - await seedDatasets(12); - - await page.goto('/datasets'); - - // First page: 10 rows, newest first, "Next" is available, "Previous" is not. - await expect(page.getByText('Page 1')).toBeVisible(); - await expect(page.getByRole('link', { name: /E2E Dataset 12/ })).toBeVisible(); - await expect(page.getByRole('link', { name: /E2E Dataset 03/ })).toBeVisible(); - await expect(page.getByRole('link', { name: /E2E Dataset 02/ })).toHaveCount(0); - await expect(page.getByRole('button', { name: 'Previous' })).toHaveCount(0); - - // Click Next: page 2 shows the 2 oldest datasets and "Next" disappears. - await page.getByRole('button', { name: 'Next' }).click(); - - await expect(page.getByText('Page 2')).toBeVisible(); - await expect(page.getByRole('link', { name: /E2E Dataset 02/ })).toBeVisible(); - await expect(page.getByRole('link', { name: /E2E Dataset 01/ })).toBeVisible(); - await expect(page.getByRole('link', { name: /E2E Dataset 12/ })).toHaveCount(0); - await expect(page.getByRole('button', { name: 'Next' })).toHaveCount(0); - - // Click Previous: back to page 1 with the newest datasets visible. - await page.getByRole('button', { name: 'Previous' }).click(); - - await expect(page.getByText('Page 1')).toBeVisible(); - await expect(page.getByRole('link', { name: /E2E Dataset 12/ })).toBeVisible(); - await expect(page.getByRole('link', { name: /E2E Dataset 02/ })).toHaveCount(0); -}); - -test('changing the search input resets pagination to page 1', async ({ page }) => { - await seedDatasets(12); - - await page.goto('/datasets'); - - // Move to page 2 first. - await page.getByRole('button', { name: 'Next' }).click(); - await expect(page.getByText('Page 2')).toBeVisible(); - - // Typing into the search input should drop the user back to page 1. - await page.getByPlaceholder('Filter by dataset name').fill('E2E Dataset 12'); - - await expect(page.getByText('Page 1')).toBeVisible(); - await expect(page.getByRole('link', { name: /E2E Dataset 12/ })).toBeVisible(); +test.describe('Datasets list pagination', () => { + test.describe('when 12 datasets are seeded across two pages', () => { + test('paginates forward and backward across the seeded datasets', async ({ page }) => { + // Seed 12 datasets so two pages of 10 per page exist. + // The API lists datasets newest-first, so "E2E Dataset 12" is on page 1 + // and "E2E Dataset 01" & "E2E Dataset 02" are on page 2. + await seedDatasets(12); + + await page.goto('/datasets'); + + // First page: 10 rows, newest first, "Next" is available, "Previous" is not. + await expect(page.getByText('Page 1')).toBeVisible(); + await expect(page.getByRole('link', { name: /E2E Dataset 12/ })).toBeVisible(); + await expect(page.getByRole('link', { name: /E2E Dataset 03/ })).toBeVisible(); + await expect(page.getByRole('link', { name: /E2E Dataset 02/ })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Previous' })).toHaveCount(0); + + // Click Next: page 2 shows the 2 oldest datasets and "Next" disappears. + await page.getByRole('button', { name: 'Next' }).click(); + + await expect(page.getByText('Page 2')).toBeVisible(); + await expect(page.getByRole('link', { name: /E2E Dataset 02/ })).toBeVisible(); + await expect(page.getByRole('link', { name: /E2E Dataset 01/ })).toBeVisible(); + await expect(page.getByRole('link', { name: /E2E Dataset 12/ })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Next' })).toHaveCount(0); + + // Click Previous: back to page 1 with the newest datasets visible. + await page.getByRole('button', { name: 'Previous' }).click(); + + await expect(page.getByText('Page 1')).toBeVisible(); + await expect(page.getByRole('link', { name: /E2E Dataset 12/ })).toBeVisible(); + await expect(page.getByRole('link', { name: /E2E Dataset 02/ })).toHaveCount(0); + }); + }); + + test.describe('when the search input changes while on page 2', () => { + test('resets pagination to page 1', async ({ page }) => { + await seedDatasets(12); + + await page.goto('/datasets'); + + // Move to page 2 first. + await page.getByRole('button', { name: 'Next' }).click(); + await expect(page.getByText('Page 2')).toBeVisible(); + + // Typing into the search input should drop the user back to page 1. + await page.getByPlaceholder('Filter by dataset name').fill('E2E Dataset 12'); + + await expect(page.getByText('Page 1')).toBeVisible(); + await expect(page.getByRole('link', { name: /E2E Dataset 12/ })).toBeVisible(); + }); + }); }); diff --git a/packages/playground/e2e/tests/mcps/$serverId/page.spec.ts b/packages/playground/e2e/tests/mcps/$serverId/page.spec.ts index 8e00804dc4b7..189a46d73e20 100644 --- a/packages/playground/e2e/tests/mcps/$serverId/page.spec.ts +++ b/packages/playground/e2e/tests/mcps/$serverId/page.spec.ts @@ -2,28 +2,32 @@ import { test, expect } from '@playwright/test'; import { resetStorage } from '../../__utils__/reset-storage'; import { expectBreadcrumbLink, expectRouteDocsLink } from '../../__utils__/route-header'; -test.afterEach(async () => { - await resetStorage(); -}); +test.describe('MCP server detail page', () => { + test.afterEach(async () => { + await resetStorage(); + }); -test('has breadcrumb navigation', async ({ page }) => { - await page.goto('/mcps/simple-mcp-server'); + test.describe('when an MCP server detail page is visited', () => { + test('has breadcrumb navigation back to the servers list', async ({ page }) => { + await page.goto('/mcps/simple-mcp-server'); - await expect(page).toHaveTitle(/Mastra Studio/); + await expect(page).toHaveTitle(/Mastra Studio/); - await expectBreadcrumbLink(page, 'MCP Servers', '/mcps'); -}); + await expectBreadcrumbLink(page, 'MCP Servers', '/mcps'); + }); -test('has documentation link', async ({ page }) => { - await page.goto('/mcps/simple-mcp-server'); + test('has a documentation link', async ({ page }) => { + await page.goto('/mcps/simple-mcp-server'); - await expectRouteDocsLink(page, 'MCP documentation', 'https://mastra.ai/en/docs/tools-mcp/mcp-overview'); -}); + await expectRouteDocsLink(page, 'MCP documentation', 'https://mastra.ai/en/docs/tools-mcp/mcp-overview'); + }); -test('has server combobox for navigation', async ({ page }) => { - await page.goto('/mcps/simple-mcp-server'); + test('has a server combobox for navigation', async ({ page }) => { + await page.goto('/mcps/simple-mcp-server'); - // The MCP server combobox should be visible - const combobox = page.locator('[role="combobox"]'); - await expect(combobox).toBeVisible(); + // The MCP server combobox should be visible + const combobox = page.locator('[role="combobox"]'); + await expect(combobox).toBeVisible(); + }); + }); }); diff --git a/packages/playground/e2e/tests/mcps/$serverId/tools/$toolId/page.spec.ts b/packages/playground/e2e/tests/mcps/$serverId/tools/$toolId/page.spec.ts index a45bf455c532..9430219ebaee 100644 --- a/packages/playground/e2e/tests/mcps/$serverId/tools/$toolId/page.spec.ts +++ b/packages/playground/e2e/tests/mcps/$serverId/tools/$toolId/page.spec.ts @@ -1,19 +1,23 @@ import { test, expect } from '@playwright/test'; import { resetStorage } from '../../../../__utils__/reset-storage'; -test.afterEach(async () => { - await resetStorage(); -}); +test.describe('MCP server tool detail page', () => { + test.afterEach(async () => { + await resetStorage(); + }); -test('verifies a tool s behaviour for mcp server', async ({ page }) => { - await page.goto('/mcps/simple-mcp-server/tools/simpleMcpTool'); + test.describe('when an MCP server tool is executed', () => { + test('returns the tool output for the submitted input', async ({ page }) => { + await page.goto('/mcps/simple-mcp-server/tools/simpleMcpTool'); - await expect(page.locator('[data-language="json"]')).toHaveText('{}'); + await expect(page.locator('[data-language="json"]')).toHaveText('{}'); - await page.getByLabel('The name of the person').fill('John Doe'); - await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByLabel('The name of the person').fill('John Doe'); + await page.getByRole('button', { name: 'Submit' }).click(); - await expect(page.locator('[data-language="json"]')).toHaveText( - '{ "result": { "hello": "world", "thisIsA": "fixture" }}', - ); + await expect(page.locator('[data-language="json"]')).toHaveText( + '{ "result": { "hello": "world", "thisIsA": "fixture" }}', + ); + }); + }); }); diff --git a/packages/playground/e2e/tests/mcps/page.spec.ts b/packages/playground/e2e/tests/mcps/page.spec.ts index d0f7ad594458..01403029033e 100644 --- a/packages/playground/e2e/tests/mcps/page.spec.ts +++ b/packages/playground/e2e/tests/mcps/page.spec.ts @@ -2,26 +2,32 @@ import { test, expect } from '@playwright/test'; import { resetStorage } from '../__utils__/reset-storage'; import { expectCurrentBreadcrumb, expectRouteDocsLink } from '../__utils__/route-header'; -test.afterEach(async () => { - await resetStorage(); -}); +test.describe('MCP servers list page', () => { + test.afterEach(async () => { + await resetStorage(); + }); -test('has overall information', async ({ page }) => { - await page.goto('/mcps'); + test.describe('when the MCP servers page is visited', () => { + test('shows the page header, docs link, and renders the server list', async ({ page }) => { + await page.goto('/mcps'); - await expect(page).toHaveTitle(/Mastra Studio/); - await expectCurrentBreadcrumb(page, 'MCP Servers'); - await expectRouteDocsLink(page, 'MCP documentation', 'https://mastra.ai/en/docs/tools-mcp/mcp-overview'); + await expect(page).toHaveTitle(/Mastra Studio/); + await expectCurrentBreadcrumb(page, 'MCP Servers'); + await expectRouteDocsLink(page, 'MCP documentation', 'https://mastra.ai/en/docs/tools-mcp/mcp-overview'); - // Verify list renders - await expect(page.locator('.data-list-row').first()).toBeVisible(); -}); + // Verify list renders + await expect(page.locator('.data-list-row').first()).toBeVisible(); + }); + }); -test('clicking on the agent row redirects', async ({ page }) => { - await page.goto('/mcps'); + test.describe('when an MCP server row is clicked', () => { + test('navigates to that server detail page', async ({ page }) => { + await page.goto('/mcps'); - const el = page.locator('.data-list-row:has-text("Simple MCP Server")'); - await el.click(); + const el = page.locator('.data-list-row:has-text("Simple MCP Server")'); + await el.click(); - await expect(page).toHaveURL(/\/mcps\/simple-mcp-server.*/); + await expect(page).toHaveURL(/\/mcps\/simple-mcp-server.*/); + }); + }); }); diff --git a/packages/playground/e2e/tests/metrics/drilldown.spec.ts b/packages/playground/e2e/tests/metrics/drilldown.spec.ts index b939e8dd071b..6f9017b5cc64 100644 --- a/packages/playground/e2e/tests/metrics/drilldown.spec.ts +++ b/packages/playground/e2e/tests/metrics/drilldown.spec.ts @@ -1,4 +1,5 @@ -import { test, expect, type Locator, type Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; import { resetStorage } from '../__utils__/reset-storage'; test.afterEach(async () => { @@ -27,65 +28,81 @@ async function gotoMetricsOrSkip(page: Page, url = '/metrics') { ); } -test('Latency card header opens traces filtered to active tab rootEntityType', async ({ page }) => { - await gotoMetricsOrSkip(page); +test.describe('Metrics dashboard drilldown links', () => { + test.describe('when the Latency card is shown on the agents tab', () => { + test('opens traces filtered to the active tab rootEntityType', async ({ page }) => { + await gotoMetricsOrSkip(page); - const latencyCard = cardByTitle(page, 'Latency'); + const latencyCard = cardByTitle(page, 'Latency'); - const openInTraces = latencyCard.getByRole('link', { name: 'View in Traces' }); - await expect(openInTraces).toBeVisible(); + const openInTraces = latencyCard.getByRole('link', { name: 'View in Traces' }); + await expect(openInTraces).toBeVisible(); - const agentHref = await openInTraces.getAttribute('href'); - expect(agentHref).toContain('/observability?'); - expect(agentHref).toContain('datePreset=last-24h'); - expect(agentHref).toContain('rootEntityType=agent'); -}); + const agentHref = await openInTraces.getAttribute('href'); + expect(agentHref).toContain('/observability?'); + expect(agentHref).toContain('datePreset=last-24h'); + expect(agentHref).toContain('rootEntityType=agent'); + }); + }); -test('Latency card header honors the active tab (workflows)', async ({ page }) => { - await gotoMetricsOrSkip(page); + test.describe('when the Latency card Workflows tab is active', () => { + test('honors the active tab in the drilldown link', async ({ page }) => { + await gotoMetricsOrSkip(page); - const latencyCard = cardByTitle(page, 'Latency'); - await latencyCard.getByRole('tab', { name: 'Workflows' }).click(); + const latencyCard = cardByTitle(page, 'Latency'); + await latencyCard.getByRole('tab', { name: 'Workflows' }).click(); - const href = await latencyCard.getByRole('link', { name: 'View in Traces' }).getAttribute('href'); - expect(href).toContain('rootEntityType=workflow_run'); -}); + const href = await latencyCard.getByRole('link', { name: 'View in Traces' }).getAttribute('href'); + expect(href).toContain('rootEntityType=workflow_run'); + }); + }); -test('Trace Volume card exposes both traces and logs drilldown buttons', async ({ page }) => { - await gotoMetricsOrSkip(page); + test.describe('when the Trace Volume card is shown', () => { + test('exposes both traces and logs drilldown buttons', async ({ page }) => { + await gotoMetricsOrSkip(page); - const card = cardByTitle(page, 'Trace Volume'); + const card = cardByTitle(page, 'Trace Volume'); - const tracesLink = card.getByRole('link', { name: 'View in Traces' }); - const logsLink = card.getByRole('link', { name: 'View errors in Logs' }); + const tracesLink = card.getByRole('link', { name: 'View in Traces' }); + const logsLink = card.getByRole('link', { name: 'View errors in Logs' }); - await expect(tracesLink).toBeVisible(); - await expect(logsLink).toBeVisible(); + await expect(tracesLink).toBeVisible(); + await expect(logsLink).toBeVisible(); - const logsHref = await logsLink.getAttribute('href'); - expect(logsHref).toContain('/logs?'); - expect(logsHref).toContain('filterLevel=error'); - expect(logsHref).toContain('rootEntityType=agent'); -}); + const logsHref = await logsLink.getAttribute('href'); + expect(logsHref).toContain('/logs?'); + expect(logsHref).toContain('filterLevel=error'); + expect(logsHref).toContain('rootEntityType=agent'); + }); + }); -test('drilldown preserves dashboard dimensional filters (filterEnvironment=prod)', async ({ page }) => { - await gotoMetricsOrSkip(page, '/metrics?filterEnvironment=prod'); + test.describe('when the dashboard has a dimensional filter applied', () => { + test('preserves the dashboard dimensional filters in the drilldown link', async ({ page }) => { + await gotoMetricsOrSkip(page, '/metrics?filterEnvironment=prod'); - const latencyCard = cardByTitle(page, 'Latency'); - const href = await latencyCard.getByRole('link', { name: 'View in Traces' }).getAttribute('href'); - expect(href).toContain('filterEnvironment=prod'); -}); + const latencyCard = cardByTitle(page, 'Latency'); + const href = await latencyCard.getByRole('link', { name: 'View in Traces' }).getAttribute('href'); + expect(href).toContain('filterEnvironment=prod'); + }); + }); -test('drilldown propagates a 7-day metrics preset as last-7d', async ({ page }) => { - await gotoMetricsOrSkip(page, '/metrics?period=7d'); + test.describe('when the dashboard uses a 7-day metrics preset', () => { + test('propagates the preset to the drilldown link as last-7d', async ({ page }) => { + await gotoMetricsOrSkip(page, '/metrics?period=7d'); - const latencyCard = cardByTitle(page, 'Latency'); - const href = await latencyCard.getByRole('link', { name: 'View in Traces' }).getAttribute('href'); - expect(href).toContain('datePreset=last-7d'); -}); + const latencyCard = cardByTitle(page, 'Latency'); + const href = await latencyCard.getByRole('link', { name: 'View in Traces' }).getAttribute('href'); + expect(href).toContain('datePreset=last-7d'); + }); + }); -test('Model Usage card exposes traces drilldown button', async ({ page }) => { - await gotoMetricsOrSkip(page); + test.describe('when the Model Usage card is shown', () => { + test('exposes a traces drilldown button', async ({ page }) => { + await gotoMetricsOrSkip(page); - await expect(cardByTitle(page, 'Model Usage & Cost').getByRole('link', { name: 'View in Traces' })).toBeAttached(); + await expect( + cardByTitle(page, 'Model Usage & Cost').getByRole('link', { name: 'View in Traces' }), + ).toBeAttached(); + }); + }); }); diff --git a/packages/playground/e2e/tests/metrics/filter-persistence.spec.ts b/packages/playground/e2e/tests/metrics/filter-persistence.spec.ts index 961a06590726..327a340b682b 100644 --- a/packages/playground/e2e/tests/metrics/filter-persistence.spec.ts +++ b/packages/playground/e2e/tests/metrics/filter-persistence.spec.ts @@ -17,23 +17,27 @@ test.afterEach(async ({ page }) => { * USER STORY: As a user, I want Metrics, Traces, and Logs to each remember their own saved filters. * BEHAVIOR UNDER TEST: Each observability page hydrates only from its own localStorage key when the URL is clean. */ -test('saved filters hydrate separately for metrics, traces, and logs pages', async ({ page }) => { - await page.goto('/metrics'); - await page.evaluate(([metricsKey, tracesKey, logsKey]) => { - localStorage.setItem(metricsKey, 'filterEnvironment=metrics-env&filterEntityName=MetricsAgent'); - localStorage.setItem(tracesKey, 'filterEnvironment=traces-env&filterEntityName=TracesAgent'); - localStorage.setItem(logsKey, 'filterEnvironment=logs-env&filterEntityName=LogsAgent'); - }, STORAGE_KEYS); +test.describe('Observability filter persistence', () => { + test.describe('when each page has its own saved filters in localStorage', () => { + test('hydrates saved filters separately for metrics, traces, and logs pages', async ({ page }) => { + await page.goto('/metrics'); + await page.evaluate(([metricsKey, tracesKey, logsKey]) => { + localStorage.setItem(metricsKey, 'filterEnvironment=metrics-env&filterEntityName=MetricsAgent'); + localStorage.setItem(tracesKey, 'filterEnvironment=traces-env&filterEntityName=TracesAgent'); + localStorage.setItem(logsKey, 'filterEnvironment=logs-env&filterEntityName=LogsAgent'); + }, STORAGE_KEYS); - await page.goto('/metrics'); - await expect(page).toHaveURL(/filterEnvironment=metrics-env/); - await expect(page).toHaveURL(/filterEntityName=MetricsAgent/); + await page.goto('/metrics'); + await expect(page).toHaveURL(/filterEnvironment=metrics-env/); + await expect(page).toHaveURL(/filterEntityName=MetricsAgent/); - await page.goto('/observability'); - await expect(page).toHaveURL(/filterEnvironment=traces-env/); - await expect(page).toHaveURL(/filterEntityName=TracesAgent/); + await page.goto('/observability'); + await expect(page).toHaveURL(/filterEnvironment=traces-env/); + await expect(page).toHaveURL(/filterEntityName=TracesAgent/); - await page.goto('/logs'); - await expect(page).toHaveURL(/filterEnvironment=logs-env/); - await expect(page).toHaveURL(/filterEntityName=LogsAgent/); + await page.goto('/logs'); + await expect(page).toHaveURL(/filterEnvironment=logs-env/); + await expect(page).toHaveURL(/filterEntityName=LogsAgent/); + }); + }); }); diff --git a/packages/playground/e2e/tests/metrics/page.spec.ts b/packages/playground/e2e/tests/metrics/page.spec.ts index a16c93aa1ca1..f24c04991c77 100644 --- a/packages/playground/e2e/tests/metrics/page.spec.ts +++ b/packages/playground/e2e/tests/metrics/page.spec.ts @@ -6,52 +6,62 @@ test.afterEach(async () => { await resetStorage(); }); -test('renders metrics dashboard with title and date preset', async ({ page }) => { - await page.goto('/metrics'); +test.describe('Metrics dashboard page', () => { + test.describe('when the metrics page is opened', () => { + test('renders the dashboard with title and date preset', async ({ page }) => { + await page.goto('/metrics'); - await expect(page).toHaveTitle(/Mastra Studio/); - await expectCurrentBreadcrumb(page, 'Metrics'); - await expect(page.getByRole('button', { name: 'Last 24 hours' })).toBeVisible(); -}); + await expect(page).toHaveTitle(/Mastra Studio/); + await expectCurrentBreadcrumb(page, 'Metrics'); + await expect(page.getByRole('button', { name: 'Last 24 hours' })).toBeVisible(); + }); + }); + + test.describe('when memory metrics are available', () => { + test('renders the Memory card with thread/resource tabs', async ({ page }) => { + await page.goto('/metrics'); + + const unsupportedStorageNotice = page.getByRole('heading', { + name: 'Metrics are not available with your current storage', + }); + await page + .getByRole('heading', { name: /^(Memory|Metrics are not available with your current storage)$/ }) + .first() + .waitFor(); + test.skip( + await unsupportedStorageNotice.isVisible(), + 'Metrics are not available with the current kitchen-sink storage', + ); + + await expect(page.getByRole('heading', { name: 'Memory' })).toBeVisible(); -test('renders Memory card with thread/resource tabs when metrics are available', async ({ page }) => { - await page.goto('/metrics'); + await expect(page.getByRole('tab', { name: 'Threads' })).toBeVisible(); + const resourcesTab = page.getByRole('tab', { name: 'Resources' }); + await expect(resourcesTab).toBeVisible(); - const unsupportedStorageNotice = page.getByRole('heading', { - name: 'Metrics are not available with your current storage', + await resourcesTab.click(); + await expect(resourcesTab).toHaveAttribute('aria-selected', 'true'); + }); }); - await page - .getByRole('heading', { name: /^(Memory|Metrics are not available with your current storage)$/ }) - .first() - .waitFor(); - test.skip( - await unsupportedStorageNotice.isVisible(), - 'Metrics are not available with the current kitchen-sink storage', - ); - - await expect(page.getByRole('heading', { name: 'Memory' })).toBeVisible(); - - await expect(page.getByRole('tab', { name: 'Threads' })).toBeVisible(); - const resourcesTab = page.getByRole('tab', { name: 'Resources' }); - await expect(resourcesTab).toBeVisible(); - - await resourcesTab.click(); - await expect(resourcesTab).toHaveAttribute('aria-selected', 'true'); -}); -test('persists dimensional filter as URL param', async ({ page }) => { - await page.goto('/metrics?filterEnvironment=production'); + test.describe('when a dimensional filter is present in the URL', () => { + test('persists the dimensional filter as a URL param', async ({ page }) => { + await page.goto('/metrics?filterEnvironment=production'); - await expect(page).toHaveURL(/filterEnvironment=production/); - // The toolbar should show the active filter pill - await expect(page.getByText('production')).toBeVisible(); -}); + await expect(page).toHaveURL(/filterEnvironment=production/); + // The toolbar should show the active filter pill + await expect(page.getByText('production')).toBeVisible(); + }); + }); -test('changing date preset updates URL', async ({ page }) => { - await page.goto('/metrics'); + test.describe('when the date preset is changed', () => { + test('updates the URL with the new period', async ({ page }) => { + await page.goto('/metrics'); - await page.getByRole('button', { name: 'Last 24 hours' }).click(); - await page.getByRole('menuitem', { name: 'Last 7 days' }).click(); + await page.getByRole('button', { name: 'Last 24 hours' }).click(); + await page.getByRole('menuitem', { name: 'Last 7 days' }).click(); - await expect(page).toHaveURL(/period=7d/); + await expect(page).toHaveURL(/period=7d/); + }); + }); }); diff --git a/packages/playground/e2e/tests/observability/page.spec.ts b/packages/playground/e2e/tests/observability/page.spec.ts index f516d7dccbe0..b8b7f3ea11ad 100644 --- a/packages/playground/e2e/tests/observability/page.spec.ts +++ b/packages/playground/e2e/tests/observability/page.spec.ts @@ -2,63 +2,72 @@ import { test, expect } from '@playwright/test'; import { resetStorage } from '../__utils__/reset-storage'; import { expectCurrentBreadcrumb, expectRouteDocsLink } from '../__utils__/route-header'; -test.afterEach(async () => { - await resetStorage(); -}); - -test('has overall information', async ({ page }) => { - await page.goto('/observability'); - - await expect(page).toHaveTitle(/Mastra Studio/); - await expectCurrentBreadcrumb(page, 'Traces'); - await expectRouteDocsLink(page, 'Traces documentation', 'https://mastra.ai/en/docs/observability/tracing/overview'); -}); - -test('has filter dropdown', async ({ page }) => { - await page.goto('/observability'); - - // The unified filter dropdown button should be present - const filterButton = page.getByRole('button', { name: 'Filter' }); - await expect(filterButton).toBeVisible(); -}); - -test('renders empty state or traces list', async ({ page }) => { - await page.goto('/observability'); - - // We check that the page has loaded and the traces tools are visible - // The date preset dropdown defaults to "Last 24 hours" - await expect(page.getByRole('button', { name: 'Last 24 hours' })).toBeVisible(); -}); - -test.skip('scorer links from observability open the scorer detail page focused on the selected score', async ({ - page, -}) => { - await page.goto('/observability'); - - const firstTraceRow = page.locator('main li button').first(); - await expect(firstTraceRow).toBeVisible(); - await firstTraceRow.click(); - - await expect(page.getByRole('dialog')).toBeVisible(); - - const scoringButton = page.getByRole('button', { name: 'Scoring' }); - await expect(scoringButton).toBeVisible(); - await scoringButton.click(); - - const firstScoreRow = page.locator('[role="dialog"] li button').first(); - await expect(firstScoreRow).toBeVisible(); - await firstScoreRow.click(); - - const scoreDialog = page.getByRole('dialog', { name: 'Scorer Score' }); - await expect(scoreDialog).toBeVisible(); - - const scoreId = await scoreDialog.getByText(/^scr_/).first().textContent(); - expect(scoreId).toBeTruthy(); - - await scoreDialog.getByRole('link', { name: 'Response Quality Scorer' }).click(); - - const expectedUrl = new RegExp(`/scorers/response-quality\\?entity=.*&scoreId=${scoreId}`); - await expect(page).toHaveURL(expectedUrl); - await expect(page.getByRole('dialog', { name: 'Scorer Score' })).toBeVisible(); - await expect(page.getByText(scoreId!, { exact: true })).toBeVisible(); +test.describe('Observability traces page', () => { + test.afterEach(async () => { + await resetStorage(); + }); + + test.describe('when the observability page is visited', () => { + test('shows the page header and docs link', async ({ page }) => { + await page.goto('/observability'); + + await expect(page).toHaveTitle(/Mastra Studio/); + await expectCurrentBreadcrumb(page, 'Traces'); + await expectRouteDocsLink( + page, + 'Traces documentation', + 'https://mastra.ai/en/docs/observability/tracing/overview', + ); + }); + + test('shows the filter dropdown', async ({ page }) => { + await page.goto('/observability'); + + // The unified filter dropdown button should be present + const filterButton = page.getByRole('button', { name: 'Filter' }); + await expect(filterButton).toBeVisible(); + }); + + test('renders the empty state or traces list with the default date preset', async ({ page }) => { + await page.goto('/observability'); + + // We check that the page has loaded and the traces tools are visible + // The date preset dropdown defaults to "Last 24 hours" + await expect(page.getByRole('button', { name: 'Last 24 hours' })).toBeVisible(); + }); + }); + + test.describe('when a scorer link is followed from a trace', () => { + test.skip('opens the scorer detail page focused on the selected score', async ({ page }) => { + await page.goto('/observability'); + + const firstTraceRow = page.locator('main li button').first(); + await expect(firstTraceRow).toBeVisible(); + await firstTraceRow.click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + + const scoringButton = page.getByRole('button', { name: 'Scoring' }); + await expect(scoringButton).toBeVisible(); + await scoringButton.click(); + + const firstScoreRow = page.locator('[role="dialog"] li button').first(); + await expect(firstScoreRow).toBeVisible(); + await firstScoreRow.click(); + + const scoreDialog = page.getByRole('dialog', { name: 'Scorer Score' }); + await expect(scoreDialog).toBeVisible(); + + const scoreId = await scoreDialog.getByText(/^scr_/).first().textContent(); + expect(scoreId).toBeTruthy(); + + await scoreDialog.getByRole('link', { name: 'Response Quality Scorer' }).click(); + + const escapedScoreId = scoreId!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const expectedUrl = new RegExp(`/scorers/response-quality\\?entity=.*&scoreId=${escapedScoreId}`); + await expect(page).toHaveURL(expectedUrl); + await expect(page.getByRole('dialog', { name: 'Scorer Score' })).toBeVisible(); + await expect(page.getByText(scoreId!, { exact: true })).toBeVisible(); + }); + }); }); diff --git a/packages/playground/e2e/tests/processors/$processorId/page.spec.ts b/packages/playground/e2e/tests/processors/$processorId/page.spec.ts index 6f58171372ca..328d763b7c4b 100644 --- a/packages/playground/e2e/tests/processors/$processorId/page.spec.ts +++ b/packages/playground/e2e/tests/processors/$processorId/page.spec.ts @@ -2,28 +2,32 @@ import { test, expect } from '@playwright/test'; import { resetStorage } from '../../__utils__/reset-storage'; import { expectBreadcrumbLink, expectRouteDocsLink } from '../../__utils__/route-header'; -test.afterEach(async () => { - await resetStorage(); -}); +test.describe('Processor detail page', () => { + test.afterEach(async () => { + await resetStorage(); + }); -test('has breadcrumb navigation', async ({ page }) => { - await page.goto('/processors/logging-processor'); + test.describe('when a processor detail page is visited', () => { + test('has breadcrumb navigation back to the processors list', async ({ page }) => { + await page.goto('/processors/logging-processor'); - await expect(page).toHaveTitle(/Mastra Studio/); + await expect(page).toHaveTitle(/Mastra Studio/); - await expectBreadcrumbLink(page, 'Processors', '/processors'); -}); + await expectBreadcrumbLink(page, 'Processors', '/processors'); + }); -test('has processor combobox for navigation', async ({ page }) => { - await page.goto('/processors/logging-processor'); + test('has a processor combobox for navigation', async ({ page }) => { + await page.goto('/processors/logging-processor'); - // The processor combobox should allow navigation between processors - const combobox = page.getByRole('combobox').filter({ hasText: 'Logging Processor' }); - await expect(combobox).toBeVisible(); -}); + // The processor combobox should allow navigation between processors + const combobox = page.getByRole('combobox').filter({ hasText: 'Logging Processor' }); + await expect(combobox).toBeVisible(); + }); -test('has documentation link', async ({ page }) => { - await page.goto('/processors/logging-processor'); + test('has a documentation link', async ({ page }) => { + await page.goto('/processors/logging-processor'); - await expectRouteDocsLink(page, 'Processors documentation', 'https://mastra.ai/en/docs/agents/processors'); + await expectRouteDocsLink(page, 'Processors documentation', 'https://mastra.ai/en/docs/agents/processors'); + }); + }); }); diff --git a/packages/playground/e2e/tests/processors/page.spec.ts b/packages/playground/e2e/tests/processors/page.spec.ts index b105ff3867c1..a440b79d868f 100644 --- a/packages/playground/e2e/tests/processors/page.spec.ts +++ b/packages/playground/e2e/tests/processors/page.spec.ts @@ -2,23 +2,29 @@ import { test, expect } from '@playwright/test'; import { resetStorage } from '../__utils__/reset-storage'; import { expectCurrentBreadcrumb, expectRouteDocsLink } from '../__utils__/route-header'; -test.afterEach(async () => { - await resetStorage(); -}); +test.describe('Processors list page', () => { + test.afterEach(async () => { + await resetStorage(); + }); -test('has overall information', async ({ page }) => { - await page.goto('/processors'); + test.describe('when the processors page is visited', () => { + test('shows the page header and docs link', async ({ page }) => { + await page.goto('/processors'); - await expect(page).toHaveTitle(/Mastra Studio/); - await expectCurrentBreadcrumb(page, 'Processors'); - await expectRouteDocsLink(page, 'Processors documentation', 'https://mastra.ai/en/docs/agents/processors'); -}); + await expect(page).toHaveTitle(/Mastra Studio/); + await expectCurrentBreadcrumb(page, 'Processors'); + await expectRouteDocsLink(page, 'Processors documentation', 'https://mastra.ai/en/docs/agents/processors'); + }); + }); -test('clicking on the processor row redirects to detail page', async ({ page }) => { - await page.goto('/processors'); + test.describe('when a processor row is clicked', () => { + test('navigates to that processor detail page', async ({ page }) => { + await page.goto('/processors'); - const el = page.locator('.data-list-row:has-text("Logging Processor")'); - await el.click(); + const el = page.locator('.data-list-row:has-text("Logging Processor")'); + await el.click(); - await expect(page).toHaveURL(/\/processors\/logging-processor$/); + await expect(page).toHaveURL(/\/processors\/logging-processor$/); + }); + }); }); diff --git a/packages/playground/e2e/tests/request-context/page.spec.ts b/packages/playground/e2e/tests/request-context/page.spec.ts index e87eb1d07a19..568746257791 100644 --- a/packages/playground/e2e/tests/request-context/page.spec.ts +++ b/packages/playground/e2e/tests/request-context/page.spec.ts @@ -2,22 +2,26 @@ import { test, expect } from '@playwright/test'; import { resetStorage } from '../__utils__/reset-storage'; import { expectCurrentBreadcrumb } from '../__utils__/route-header'; -test.afterEach(async () => { - await resetStorage(); -}); +test.describe('Request Context page', () => { + test.afterEach(async () => { + await resetStorage(); + }); -test('has page title', async ({ page }) => { - await page.goto('/request-context'); + test.describe('when the request-context page is visited', () => { + test('shows the page title and breadcrumb', async ({ page }) => { + await page.goto('/request-context'); - await expect(page).toHaveTitle(/Mastra Studio/); - await expectCurrentBreadcrumb(page, 'Request Context'); -}); + await expect(page).toHaveTitle(/Mastra Studio/); + await expectCurrentBreadcrumb(page, 'Request Context'); + }); -test('renders RequestContext component', async ({ page }) => { - await page.goto('/request-context'); + test('renders the RequestContext component', async ({ page }) => { + await page.goto('/request-context'); - // The RequestContext component should be rendered within the page - // Check for the main content area - const mainContent = page.locator('main'); - await expect(mainContent).toBeVisible(); + // The RequestContext component should be rendered within the page + // Check for the main content area + const mainContent = page.locator('main'); + await expect(mainContent).toBeVisible(); + }); + }); }); diff --git a/packages/playground/e2e/tests/root.spec.ts b/packages/playground/e2e/tests/root.spec.ts index d9525c27ccb9..5b57cd2a3397 100644 --- a/packages/playground/e2e/tests/root.spec.ts +++ b/packages/playground/e2e/tests/root.spec.ts @@ -1,11 +1,15 @@ import { test, expect } from '@playwright/test'; import { resetStorage } from './__utils__/reset-storage'; -test.afterEach(async () => { - await resetStorage(); -}); +test.describe('Root path', () => { + test.afterEach(async () => { + await resetStorage(); + }); -test('root path redirects to agents', async ({ page }) => { - await page.goto('/'); - await expect(page).toHaveURL(/\/agents$/); + test.describe('when the root path is visited', () => { + test('redirects to agents', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveURL(/\/agents$/); + }); + }); }); diff --git a/packages/playground/e2e/tests/scorers/$scorerId/page.spec.ts b/packages/playground/e2e/tests/scorers/$scorerId/page.spec.ts index 31d7f63c0c8f..8c7059a2cfb2 100644 --- a/packages/playground/e2e/tests/scorers/$scorerId/page.spec.ts +++ b/packages/playground/e2e/tests/scorers/$scorerId/page.spec.ts @@ -2,54 +2,62 @@ import { test, expect } from '@playwright/test'; import { resetStorage } from '../../__utils__/reset-storage'; import { expectBreadcrumbLink, expectCurrentBreadcrumb, expectRouteDocsLink } from '../../__utils__/route-header'; -test.afterEach(async () => { - await resetStorage(); -}); - -test('has breadcrumb navigation', async ({ page }) => { - await page.goto('/scorers/response-quality'); +test.describe('Scorer detail page', () => { + test.afterEach(async () => { + await resetStorage(); + }); - await expect(page).toHaveTitle(/Mastra Studio/); + test.describe('when a scorer detail page is visited', () => { + test('has breadcrumb navigation back to the scorers list', async ({ page }) => { + await page.goto('/scorers/response-quality'); - await expectBreadcrumbLink(page, 'Scorers', '/scorers'); -}); + await expect(page).toHaveTitle(/Mastra Studio/); -test('displays scorer name and has documentation link', async ({ page }) => { - await page.goto('/scorers/response-quality'); + await expectBreadcrumbLink(page, 'Scorers', '/scorers'); + }); - await expectCurrentBreadcrumb(page, 'Response Quality Scorer'); - await expectRouteDocsLink(page, 'Scorers documentation', 'https://mastra.ai/en/docs/evals/overview'); -}); + test('displays the scorer name and a documentation link', async ({ page }) => { + await page.goto('/scorers/response-quality'); -test('hides entity filter dropdown when no filter is applied and there are no scores', async ({ page }) => { - await page.goto('/scorers/response-quality'); + await expectCurrentBreadcrumb(page, 'Response Quality Scorer'); + await expectRouteDocsLink(page, 'Scorers documentation', 'https://mastra.ai/en/docs/evals/overview'); + }); - await expect(page.locator('main').getByRole('combobox')).toHaveCount(0); -}); + test('has a scorer combobox for navigation', async ({ page }) => { + await page.goto('/scorers/response-quality'); -test('shows entity filter dropdown when a filter is applied via URL', async ({ page }) => { - // Stub the scorer response so the scorer reports weather-agent as a linked entity; - // the kitchen-sink scorer fixture is not wired to any agent by default. - await page.route('**/scores/scorers/response-quality', async route => { - const response = await route.fetch(); - const body = await response.json(); - await route.fulfill({ - response, - json: { ...body, agentIds: ['weather-agent'], agentNames: ['Weather Agent'] }, + const combobox = page.locator('nav').getByRole('combobox').first(); + await expect(combobox).toBeVisible(); + await expect(combobox).toContainText('Response Quality Scorer'); }); }); - await page.goto('/scorers/response-quality?entity=weather-agent'); - - const entityFilter = page.locator('main').getByRole('combobox').first(); - await expect(entityFilter).toBeVisible(); - await expect(entityFilter).toContainText('Weather Agent'); -}); + test.describe('when no entity filter is applied and there are no scores', () => { + test('hides the entity filter dropdown', async ({ page }) => { + await page.goto('/scorers/response-quality'); -test('has scorer combobox for navigation', async ({ page }) => { - await page.goto('/scorers/response-quality'); + await expect(page.locator('main').getByRole('combobox')).toHaveCount(0); + }); + }); - const combobox = page.locator('nav').getByRole('combobox').first(); - await expect(combobox).toBeVisible(); - await expect(combobox).toContainText('Response Quality Scorer'); + test.describe('when an entity filter is applied via URL', () => { + test('shows the entity filter dropdown with the linked entity', async ({ page }) => { + // Stub the scorer response so the scorer reports weather-agent as a linked entity; + // the kitchen-sink scorer fixture is not wired to any agent by default. + await page.route('**/scores/scorers/response-quality', async route => { + const response = await route.fetch(); + const body = await response.json(); + await route.fulfill({ + response, + json: { ...body, agentIds: ['weather-agent'], agentNames: ['Weather Agent'] }, + }); + }); + + await page.goto('/scorers/response-quality?entity=weather-agent'); + + const entityFilter = page.locator('main').getByRole('combobox').first(); + await expect(entityFilter).toBeVisible(); + await expect(entityFilter).toContainText('Weather Agent'); + }); + }); }); diff --git a/packages/playground/e2e/tests/scorers/page.spec.ts b/packages/playground/e2e/tests/scorers/page.spec.ts index 461ab8f1a193..73a6685ebfde 100644 --- a/packages/playground/e2e/tests/scorers/page.spec.ts +++ b/packages/playground/e2e/tests/scorers/page.spec.ts @@ -1,22 +1,28 @@ import { test, expect } from '@playwright/test'; import { resetStorage } from '../__utils__/reset-storage'; -test.afterEach(async () => { - await resetStorage(); -}); +test.describe('Scorers list page', () => { + test.afterEach(async () => { + await resetStorage(); + }); -test('shows scorers in the evaluation dashboard', async ({ page }) => { - await page.goto('/scorers'); + test.describe('when the scorers page is visited', () => { + test('shows the scorers in the evaluation dashboard', async ({ page }) => { + await page.goto('/scorers'); - await expect(page).toHaveTitle(/Mastra Studio/); - await expect(page.getByRole('searchbox', { name: 'Search scorers' })).toBeVisible(); - await expect(page.getByRole('link', { name: /Response Quality Scorer/i })).toBeVisible(); -}); + await expect(page).toHaveTitle(/Mastra Studio/); + await expect(page.getByRole('searchbox', { name: 'Search scorers' })).toBeVisible(); + await expect(page.getByRole('link', { name: /Response Quality Scorer/i })).toBeVisible(); + }); + }); -test('clicking on the scorer row redirects to detail page', async ({ page }) => { - await page.goto('/scorers'); + test.describe('when a scorer row is clicked', () => { + test('navigates to that scorer detail page', async ({ page }) => { + await page.goto('/scorers'); - await page.getByRole('link', { name: /Response Quality Scorer/i }).click(); + await page.getByRole('link', { name: /Response Quality Scorer/i }).click(); - await expect(page).toHaveURL(/\/scorers\/response-quality$/); + await expect(page).toHaveURL(/\/scorers\/response-quality$/); + }); + }); }); diff --git a/packages/playground/e2e/tests/settings/api-prefix.spec.ts b/packages/playground/e2e/tests/settings/api-prefix.spec.ts index aaff956be2ff..1e161c2dd47d 100644 --- a/packages/playground/e2e/tests/settings/api-prefix.spec.ts +++ b/packages/playground/e2e/tests/settings/api-prefix.spec.ts @@ -1,4 +1,5 @@ -import { test, expect, Page } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; import { resetStorage } from '../__utils__/reset-storage'; /** @@ -23,7 +24,7 @@ async function stubCapabilitiesAuthDisabled(page: Page): Promise<void> { }); } -test.describe('Settings - API prefix persistence', () => { +test.describe('Settings API prefix persistence', () => { test.beforeEach(async ({ page }) => { await resetStorage(); await stubCapabilitiesAuthDisabled(page); @@ -33,54 +34,60 @@ test.describe('Settings - API prefix persistence', () => { await resetStorage(); }); - test('displays the default API prefix value', async ({ page }) => { - await page.goto('/settings'); + test.describe('when the settings page is first opened', () => { + test('displays the default API prefix value', async ({ page }) => { + await page.goto('/settings'); - const apiPrefixInput = page.locator('input[name="apiPrefix"]'); - await expect(apiPrefixInput).toBeVisible(); - await expect(apiPrefixInput).toHaveValue('/api'); + const apiPrefixInput = page.locator('input[name="apiPrefix"]'); + await expect(apiPrefixInput).toBeVisible(); + await expect(apiPrefixInput).toHaveValue('/api'); + }); }); - test('persists custom API prefix after saving and reloading', async ({ page }) => { - await page.goto('/settings'); + test.describe('when a custom API prefix is saved and the page reloaded', () => { + test('persists the custom API prefix', async ({ page }) => { + await page.goto('/settings'); - const apiPrefixInput = page.locator('input[name="apiPrefix"]'); - await expect(apiPrefixInput).toBeVisible(); + const apiPrefixInput = page.locator('input[name="apiPrefix"]'); + await expect(apiPrefixInput).toBeVisible(); - await apiPrefixInput.clear(); - await apiPrefixInput.fill('/custom-prefix'); + await apiPrefixInput.clear(); + await apiPrefixInput.fill('/custom-prefix'); - await page.getByRole('button', { name: 'Save Configuration' }).click(); + await page.getByRole('button', { name: 'Save Configuration' }).click(); - await page.reload(); + await page.reload(); - await expect(page.locator('input[name="apiPrefix"]')).toHaveValue('/custom-prefix'); + await expect(page.locator('input[name="apiPrefix"]')).toHaveValue('/custom-prefix'); + }); }); - test('preserves API prefix when saving other settings', async ({ page }) => { - await page.goto('/settings'); + test.describe('when other settings are saved after a custom prefix', () => { + test('preserves the API prefix', async ({ page }) => { + await page.goto('/settings'); - const apiPrefixInput = page.locator('input[name="apiPrefix"]'); - await apiPrefixInput.clear(); - await apiPrefixInput.fill('/mastra'); + const apiPrefixInput = page.locator('input[name="apiPrefix"]'); + await apiPrefixInput.clear(); + await apiPrefixInput.fill('/mastra'); - await page.getByRole('button', { name: 'Save Configuration' }).click(); - await page.reload(); + await page.getByRole('button', { name: 'Save Configuration' }).click(); + await page.reload(); - // Change another setting (Mastra instance URL) but don't touch apiPrefix - const urlInput = page.locator('input[name="url"]'); - await urlInput.clear(); - await urlInput.fill('http://localhost:5555'); + // Change another setting (Mastra instance URL) but don't touch apiPrefix + const urlInput = page.locator('input[name="url"]'); + await urlInput.clear(); + await urlInput.fill('http://localhost:5555'); - await page.getByRole('button', { name: 'Save Configuration' }).click(); - await page.reload(); + await page.getByRole('button', { name: 'Save Configuration' }).click(); + await page.reload(); - // API prefix should still be /mastra - await expect(page.locator('input[name="apiPrefix"]')).toHaveValue('/mastra'); + // API prefix should still be /mastra + await expect(page.locator('input[name="apiPrefix"]')).toHaveValue('/mastra'); + }); }); }); -test.describe('Settings - invalid API error state', () => { +test.describe('Settings invalid API error state', () => { test.beforeEach(async () => { await resetStorage(); }); @@ -89,34 +96,36 @@ test.describe('Settings - invalid API error state', () => { await resetStorage(); }); - test('shows the failed-to-load error screen when the configured API is unreachable', async ({ page }) => { - // Stub capabilities ONLY for the initial load so we can reach the Settings form - // to enter a bad URL. Once a bad instance URL is saved, the post-reload - // capabilities request targets that dead origin and is not stubbed. - await page.route('**/auth/capabilities', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ enabled: false, login: null }), + test.describe('when the configured API is unreachable after saving', () => { + test('shows the failed-to-load error screen', async ({ page }) => { + // Stub capabilities ONLY for the initial load so we can reach the Settings form + // to enter a bad URL. Once a bad instance URL is saved, the post-reload + // capabilities request targets that dead origin and is not stubbed. + await page.route('**/auth/capabilities', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ enabled: false, login: null }), + }); }); - }); - await page.goto('/settings'); + await page.goto('/settings'); - const urlInput = page.locator('input[name="url"]'); - await expect(urlInput).toBeVisible(); - await urlInput.clear(); - // Unroutable address: connection fails fast, capabilities query errors. - await urlInput.fill('http://127.0.0.1:1'); + const urlInput = page.locator('input[name="url"]'); + await expect(urlInput).toBeVisible(); + await urlInput.clear(); + // Unroutable address: connection fails fast, capabilities query errors. + await urlInput.fill('http://127.0.0.1:1'); - await page.getByRole('button', { name: 'Save Configuration' }).click(); + await page.getByRole('button', { name: 'Save Configuration' }).click(); - // Drop the stub so the post-reload capabilities request really hits the dead - // origin and fails, tripping the gate's error screen. - await page.unroute('**/auth/capabilities'); - await page.reload(); + // Drop the stub so the post-reload capabilities request really hits the dead + // origin and fails, tripping the gate's error screen. + await page.unroute('**/auth/capabilities'); + await page.reload(); - await expect(page.getByText('Failed to load studio')).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('button', { name: 'Reset Studio Configuration' })).toBeVisible(); + await expect(page.getByText('Failed to load studio')).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('button', { name: 'Reset Studio Configuration' })).toBeVisible(); + }); }); }); diff --git a/packages/playground/e2e/tests/settings/page.spec.ts b/packages/playground/e2e/tests/settings/page.spec.ts index 423754c2e0e1..07746e972d16 100644 --- a/packages/playground/e2e/tests/settings/page.spec.ts +++ b/packages/playground/e2e/tests/settings/page.spec.ts @@ -2,65 +2,73 @@ import { test, expect } from '@playwright/test'; import { resetStorage } from '../__utils__/reset-storage'; import { expectCurrentBreadcrumb } from '../__utils__/route-header'; -test.beforeEach(async () => { - await resetStorage(); -}); +test.describe('Settings page', () => { + test.beforeEach(async () => { + await resetStorage(); + }); -test.afterEach(async () => { - await resetStorage(); -}); + test.afterEach(async () => { + await resetStorage(); + }); -test('has page title', async ({ page }) => { - await page.goto('/settings'); + test.describe('when the settings page is visited', () => { + test('shows the page title and breadcrumb', async ({ page }) => { + await page.goto('/settings'); - await expect(page).toHaveTitle(/Mastra Studio/); - await expectCurrentBreadcrumb(page, 'Settings'); -}); + await expect(page).toHaveTitle(/Mastra Studio/); + await expectCurrentBreadcrumb(page, 'Settings'); + }); -test('renders settings form', async ({ page }) => { - await page.goto('/settings'); + test('renders the settings form', async ({ page }) => { + await page.goto('/settings'); - const form = page.locator('form'); - await expect(form).toBeVisible(); -}); + const form = page.locator('form'); + await expect(form).toBeVisible(); + }); -test('shows theme selector with dark default', async ({ page }) => { - await page.goto('/settings'); + test('shows the theme selector defaulting to dark', async ({ page }) => { + await page.goto('/settings'); - const selector = page.getByLabel('Theme mode'); + const selector = page.getByLabel('Theme mode'); - await expect(selector).toBeVisible(); - await expect(selector).toContainText('Dark'); -}); + await expect(selector).toBeVisible(); + await expect(selector).toContainText('Dark'); + }); + }); -test('applies selected light theme', async ({ page }) => { - await page.goto('/settings'); + test.describe('when the light theme is selected', () => { + test('applies the light theme and persists it across reloads', async ({ page }) => { + await page.goto('/settings'); - const selector = page.getByLabel('Theme mode'); + const selector = page.getByLabel('Theme mode'); - await selector.click(); - await page.getByRole('option', { name: 'Light' }).click(); + await selector.click(); + await page.getByRole('option', { name: 'Light' }).click(); - await expect(selector).toContainText('Light'); - await expect(page.locator('html')).toHaveClass(/light/); + await expect(selector).toContainText('Light'); + await expect(page.locator('html')).toHaveClass(/light/); - await page.reload(); + await page.reload(); - await expect(page.locator('html')).toHaveClass(/light/); - await expect(page.getByLabel('Theme mode')).toContainText('Light'); -}); + await expect(page.locator('html')).toHaveClass(/light/); + await expect(page.getByLabel('Theme mode')).toContainText('Light'); + }); + }); -test('persists system theme mode', async ({ page }) => { - await page.goto('/settings'); + test.describe('when the system theme mode is selected', () => { + test('persists the system theme mode across reloads', async ({ page }) => { + await page.goto('/settings'); - const selector = page.getByLabel('Theme mode'); + const selector = page.getByLabel('Theme mode'); - await selector.click(); - await page.getByRole('option', { name: 'System' }).click(); + await selector.click(); + await page.getByRole('option', { name: 'System' }).click(); - await expect(selector).toContainText('System'); + await expect(selector).toContainText('System'); - await page.reload(); + await page.reload(); - await expect(page.getByLabel('Theme mode')).toContainText('System'); + await expect(page.getByLabel('Theme mode')).toContainText('System'); + }); + }); }); diff --git a/packages/playground/e2e/tests/sidebar/cold-load-layout.spec.ts b/packages/playground/e2e/tests/sidebar/cold-load-layout.spec.ts index 52f7e70f3446..d11c181e2c09 100644 --- a/packages/playground/e2e/tests/sidebar/cold-load-layout.spec.ts +++ b/packages/playground/e2e/tests/sidebar/cold-load-layout.spec.ts @@ -1,4 +1,5 @@ -import { test, expect, Page } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; import { buildAuthCapabilities, buildCurrentUserResponse } from '../__utils__/auth'; import type { MockAuthConfig } from '../__utils__/auth'; import { resetStorage } from '../__utils__/reset-storage'; @@ -72,43 +73,45 @@ test.describe('Studio Layout - Cold-Load Stability', () => { await resetStorage(); }); - test('gate holds the layout behind a spinner until auth resolves (no cold-load layout jump)', async ({ page }) => { - // ARRANGE: Stall the auth routes so the page renders its first paint with auth - // still in flight. Auth disabled so the gate resolves to children without an - // RBAC permission-patterns request. - const releaseAuth = await gateAuth(page, { enabled: false }); - await page.goto('/agents'); + test.describe('when auth is still in flight on cold load', () => { + test('holds the layout behind a spinner until auth resolves (no cold-load layout jump)', async ({ page }) => { + // ARRANGE: Stall the auth routes so the page renders its first paint with auth + // still in flight. Auth disabled so the gate resolves to children without an + // RBAC permission-patterns request. + const releaseAuth = await gateAuth(page, { enabled: false }); + await page.goto('/agents'); - const sidebar = page.locator('.sidebar-layout').first(); + const sidebar = page.locator('.sidebar-layout').first(); - // ASSERT 1 (pre-resolution): The gate is showing its spinner and the sidebar - // has NOT been mounted yet. This is the boundary that prevents a half-resolved - // layout from painting and then snapping. - await expect(page.getByRole('status', { name: 'Loading' })).toBeVisible({ timeout: 5000 }); - await expect(sidebar).toHaveCount(0); + // ASSERT 1 (pre-resolution): The gate is showing its spinner and the sidebar + // has NOT been mounted yet. This is the boundary that prevents a half-resolved + // layout from painting and then snapping. + await expect(page.getByRole('status', { name: 'Loading' })).toBeVisible({ timeout: 5000 }); + await expect(sidebar).toHaveCount(0); - // ACT: Release the auth response. Register the waiter BEFORE calling release() - // so the response cannot be flushed before we are listening for it. - const responsePromise = page.waitForResponse('**/api/auth/capabilities'); - releaseAuth(); - await responsePromise; + // ACT: Release the auth response. Register the waiter BEFORE calling release() + // so the response cannot be flushed before we are listening for it. + const responsePromise = page.waitForResponse('**/api/auth/capabilities'); + releaseAuth(); + await responsePromise; - // ASSERT 2 (post-resolution): The sidebar now mounts at a real width. - // MainSidebarProvider hydrates width synchronously from localStorage (default - // 240px); anything smaller would mean the sidebar collapsed or was unmounted. - await expect(sidebar).toBeVisible({ timeout: 5000 }); - const boxBefore = await sidebar.boundingBox(); - expect(boxBefore).not.toBeNull(); - expect(boxBefore!.width).toBeGreaterThan(100); + // ASSERT 2 (post-resolution): The sidebar now mounts at a real width. + // MainSidebarProvider hydrates width synchronously from localStorage (default + // 240px); anything smaller would mean the sidebar collapsed or was unmounted. + await expect(sidebar).toBeVisible({ timeout: 5000 }); + const boxBefore = await sidebar.boundingBox(); + expect(boxBefore).not.toBeNull(); + expect(boxBefore!.width).toBeGreaterThan(100); - // Flush one frame so any subsequent React commit has been painted before we - // re-measure, then prove the sidebar position/width is unchanged within - // sub-pixel tolerance — i.e. it mounted once, in its final position. - await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => resolve()))); + // Flush one frame so any subsequent React commit has been painted before we + // re-measure, then prove the sidebar position/width is unchanged within + // sub-pixel tolerance — i.e. it mounted once, in its final position. + await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => resolve()))); - const boxAfter = await sidebar.boundingBox(); - expect(boxAfter).not.toBeNull(); - expect(Math.abs(boxAfter!.x - boxBefore!.x)).toBeLessThanOrEqual(LAYOUT_TOLERANCE_PX); - expect(Math.abs(boxAfter!.width - boxBefore!.width)).toBeLessThanOrEqual(LAYOUT_TOLERANCE_PX); + const boxAfter = await sidebar.boundingBox(); + expect(boxAfter).not.toBeNull(); + expect(Math.abs(boxAfter!.x - boxBefore!.x)).toBeLessThanOrEqual(LAYOUT_TOLERANCE_PX); + expect(Math.abs(boxAfter!.width - boxBefore!.width)).toBeLessThanOrEqual(LAYOUT_TOLERANCE_PX); + }); }); }); diff --git a/packages/playground/e2e/tests/sidebar/navigation-scroll.spec.ts b/packages/playground/e2e/tests/sidebar/navigation-scroll.spec.ts index 620c9d3301c1..918d59356b7e 100644 --- a/packages/playground/e2e/tests/sidebar/navigation-scroll.spec.ts +++ b/packages/playground/e2e/tests/sidebar/navigation-scroll.spec.ts @@ -14,61 +14,63 @@ test.describe('Sidebar Navigation - Scroll Behavior', () => { await resetStorage(); }); - test('should scroll to reveal all navigation sections when viewport height is small', async ({ page }) => { - // ARRANGE: Set viewport to desktop width but constrained height - await page.setViewportSize({ width: 1280, height: 400 }); - await page.goto('/agents'); + test.describe('when the viewport height is constrained', () => { + test('scrolls to reveal all navigation sections', async ({ page }) => { + // ARRANGE: Set viewport to desktop width but constrained height + await page.setViewportSize({ width: 1280, height: 400 }); + await page.goto('/agents'); - // Wait for sidebar scope to be visible and expanded - await expect(page.locator('[data-sidebar-state="default"]')).toBeAttached({ timeout: 10000 }); + // Wait for sidebar scope to be visible and expanded + await expect(page.locator('[data-sidebar-state="default"]')).toBeAttached({ timeout: 10000 }); - // Locate nav links by role — these span from top (Agents) to bottom (Settings) - const sidebar = page.locator('[data-sidebar-state="default"]'); - const agentsLink = sidebar.getByRole('link', { name: 'Agents', exact: true }); - const settingsLink = sidebar.getByRole('link', { name: 'Settings', exact: true }); + // Locate nav links by role — these span from top (Agents) to bottom (Settings) + const sidebar = page.locator('[data-sidebar-state="default"]'); + const agentsLink = sidebar.getByRole('link', { name: 'Agents', exact: true }); + const settingsLink = sidebar.getByRole('link', { name: 'Settings', exact: true }); - // All nav links should exist in DOM - await expect(agentsLink).toBeAttached(); - await expect(settingsLink).toBeAttached(); + // All nav links should exist in DOM + await expect(agentsLink).toBeAttached(); + await expect(settingsLink).toBeAttached(); - // ACT & ASSERT: Scroll to Settings link at bottom and verify it becomes visible - await settingsLink.scrollIntoViewIfNeeded(); - await expect(settingsLink).toBeVisible(); + // ACT & ASSERT: Scroll to Settings link at bottom and verify it becomes visible + await settingsLink.scrollIntoViewIfNeeded(); + await expect(settingsLink).toBeVisible(); - // Scroll back to top and verify Agents is visible - await agentsLink.scrollIntoViewIfNeeded(); - await expect(agentsLink).toBeVisible(); - }); + // Scroll back to top and verify Agents is visible + await agentsLink.scrollIntoViewIfNeeded(); + await expect(agentsLink).toBeVisible(); + }); - test('should allow navigation to bottom section items after scrolling', async ({ page }) => { - // ARRANGE: Constrained viewport - await page.setViewportSize({ width: 1280, height: 400 }); - await page.goto('/agents'); - await expect(page.locator('[data-sidebar-state="default"]')).toBeAttached({ timeout: 10000 }); + test('should allow navigation to bottom section items after scrolling', async ({ page }) => { + // ARRANGE: Constrained viewport + await page.setViewportSize({ width: 1280, height: 400 }); + await page.goto('/agents'); + await expect(page.locator('[data-sidebar-state="default"]')).toBeAttached({ timeout: 10000 }); - // ACT: Scroll to and click Settings link - const sidebar = page.locator('[data-sidebar-state="default"]'); - const settingsLink = sidebar.getByRole('link', { name: 'Settings', exact: true }); - await settingsLink.scrollIntoViewIfNeeded(); - await settingsLink.click(); + // ACT: Scroll to and click Settings link + const sidebar = page.locator('[data-sidebar-state="default"]'); + const settingsLink = sidebar.getByRole('link', { name: 'Settings', exact: true }); + await settingsLink.scrollIntoViewIfNeeded(); + await settingsLink.click(); - // ASSERT: Navigation succeeded - URL changed and settings page loaded - await expect(page).toHaveURL(/\/settings/); - }); + // ASSERT: Navigation succeeded - URL changed and settings page loaded + await expect(page).toHaveURL(/\/settings/); + }); - test('should allow navigation from bottom to top sections after scrolling', async ({ page }) => { - // ARRANGE: Constrained viewport, start at settings - await page.setViewportSize({ width: 1280, height: 400 }); - await page.goto('/settings'); - await expect(page.locator('[data-sidebar-state="default"]')).toBeAttached({ timeout: 10000 }); + test('should allow navigation from bottom to top sections after scrolling', async ({ page }) => { + // ARRANGE: Constrained viewport, start at settings + await page.setViewportSize({ width: 1280, height: 400 }); + await page.goto('/settings'); + await expect(page.locator('[data-sidebar-state="default"]')).toBeAttached({ timeout: 10000 }); - // ACT: Scroll to Agents link (top of nav) and click - const sidebar = page.locator('[data-sidebar-state="default"]'); - const agentsLink = sidebar.getByRole('link', { name: 'Agents', exact: true }); - await agentsLink.scrollIntoViewIfNeeded(); - await agentsLink.click(); + // ACT: Scroll to Agents link (top of nav) and click + const sidebar = page.locator('[data-sidebar-state="default"]'); + const agentsLink = sidebar.getByRole('link', { name: 'Agents', exact: true }); + await agentsLink.scrollIntoViewIfNeeded(); + await agentsLink.click(); - // ASSERT: Navigation works from any scroll position - await expect(page).toHaveURL(/\/agents/); + // ASSERT: Navigation works from any scroll position + await expect(page).toHaveURL(/\/agents/); + }); }); }); diff --git a/packages/playground/e2e/tests/templates/$templateSlug/page.spec.ts b/packages/playground/e2e/tests/templates/$templateSlug/page.spec.ts index 62b6eb739511..fd54ce196f80 100644 --- a/packages/playground/e2e/tests/templates/$templateSlug/page.spec.ts +++ b/packages/playground/e2e/tests/templates/$templateSlug/page.spec.ts @@ -1,24 +1,28 @@ import { test, expect } from '@playwright/test'; import { resetStorage } from '../../__utils__/reset-storage'; -test.afterEach(async () => { - await resetStorage(); -}); +test.describe('Template detail page', () => { + test.afterEach(async () => { + await resetStorage(); + }); -test('has breadcrumb navigation', async ({ page }) => { - // Use a mock template slug - the page should still render breadcrumbs - await page.goto('/templates/test-template'); + test.describe('when a template detail page is visited', () => { + test('has breadcrumb navigation back to the templates list', async ({ page }) => { + // Use a mock template slug - the page should still render breadcrumbs + await page.goto('/templates/test-template'); - await expect(page).toHaveTitle(/Mastra Studio/); + await expect(page).toHaveTitle(/Mastra Studio/); - const breadcrumb = page.locator('nav a:has-text("Templates")').first(); - await expect(breadcrumb).toHaveAttribute('href', '/templates'); -}); + const breadcrumb = page.locator('nav a:has-text("Templates")').first(); + await expect(breadcrumb).toHaveAttribute('href', '/templates'); + }); -test('renders template page structure', async ({ page }) => { - await page.goto('/templates/test-template'); + test('renders the template page structure', async ({ page }) => { + await page.goto('/templates/test-template'); - // The page should have the main content area - const mainContent = page.locator('main'); - await expect(mainContent).toBeVisible(); + // The page should have the main content area + const mainContent = page.locator('main'); + await expect(mainContent).toBeVisible(); + }); + }); }); diff --git a/packages/playground/e2e/tests/templates/page.spec.ts b/packages/playground/e2e/tests/templates/page.spec.ts index fe1b15fefe7a..5231927612f4 100644 --- a/packages/playground/e2e/tests/templates/page.spec.ts +++ b/packages/playground/e2e/tests/templates/page.spec.ts @@ -2,21 +2,25 @@ import { test, expect } from '@playwright/test'; import { resetStorage } from '../__utils__/reset-storage'; import { expectCurrentBreadcrumb } from '../__utils__/route-header'; -test.afterEach(async () => { - await resetStorage(); -}); +test.describe('Templates list page', () => { + test.afterEach(async () => { + await resetStorage(); + }); -test('has page title', async ({ page }) => { - await page.goto('/templates'); + test.describe('when the templates page is visited', () => { + test('shows the page title and breadcrumb', async ({ page }) => { + await page.goto('/templates'); - await expect(page).toHaveTitle(/Mastra Studio/); - await expectCurrentBreadcrumb(page, 'Templates'); -}); + await expect(page).toHaveTitle(/Mastra Studio/); + await expectCurrentBreadcrumb(page, 'Templates'); + }); -test('has filter controls', async ({ page }) => { - await page.goto('/templates'); + test('renders the filter controls', async ({ page }) => { + await page.goto('/templates'); - // Wait for the page to load and check for filter UI elements - // The page should have tag and provider filter dropdowns - await expect(page.locator('main')).toBeVisible(); + // Wait for the page to load and check for filter UI elements + // The page should have tag and provider filter dropdowns + await expect(page.locator('main')).toBeVisible(); + }); + }); }); diff --git a/packages/playground/e2e/tests/tools/$toolId/page.spec.ts b/packages/playground/e2e/tests/tools/$toolId/page.spec.ts index 6cc0749524fc..6d7bb708807f 100644 --- a/packages/playground/e2e/tests/tools/$toolId/page.spec.ts +++ b/packages/playground/e2e/tests/tools/$toolId/page.spec.ts @@ -1,26 +1,32 @@ import { test, expect } from '@playwright/test'; import { resetStorage } from '../../__utils__/reset-storage'; -test.afterEach(async () => { - await resetStorage(); -}); +test.describe('Tool detail page', () => { + test.afterEach(async () => { + await resetStorage(); + }); -test('verifies a tool s behaviour', async ({ page }) => { - await page.goto('/tools/simpleMcpTool'); + test.describe('when a tool is executed from its detail page', () => { + test('returns the tool output for the submitted input', async ({ page }) => { + await page.goto('/tools/simpleMcpTool'); - await expect(page.locator('h2')).toHaveText('simpleMcpTool'); - await expect(page.locator('[data-language="json"]')).toHaveText('{}'); + await expect(page.locator('h2')).toHaveText('simpleMcpTool'); + await expect(page.locator('[data-language="json"]')).toHaveText('{}'); - await page.getByLabel('The name of the person').fill('John Doe'); - await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByLabel('The name of the person').fill('John Doe'); + await page.getByRole('button', { name: 'Submit' }).click(); - await expect(page.locator('[data-language="json"]')).toHaveText('{ "hello": "world", "thisIsA": "fixture"}'); -}); + await expect(page.locator('[data-language="json"]')).toHaveText('{ "hello": "world", "thisIsA": "fixture"}'); + }); + }); -test('exposes breadcrumb navigation for a standalone tool route', async ({ page }) => { - await page.goto('/tools/simpleMcpTool'); + test.describe('when a standalone tool route is opened', () => { + test('exposes breadcrumb navigation back to the tools list', async ({ page }) => { + await page.goto('/tools/simpleMcpTool'); - const breadcrumb = page.locator('header nav').first(); - await expect(breadcrumb.getByRole('link', { name: 'Tools' })).toHaveAttribute('href', '/tools'); - await expect(breadcrumb.locator('[aria-current="page"]')).toContainText('simpleMcpTool'); + const breadcrumb = page.locator('header nav').first(); + await expect(breadcrumb.getByRole('link', { name: 'Tools' })).toHaveAttribute('href', '/tools'); + await expect(breadcrumb.locator('[aria-current="page"]')).toContainText('simpleMcpTool'); + }); + }); }); diff --git a/packages/playground/e2e/tests/tools/page.spec.ts b/packages/playground/e2e/tests/tools/page.spec.ts index 17dd8f21abfd..8afe2e8727a1 100644 --- a/packages/playground/e2e/tests/tools/page.spec.ts +++ b/packages/playground/e2e/tests/tools/page.spec.ts @@ -1,16 +1,20 @@ import { test, expect } from '@playwright/test'; import { resetStorage } from '../__utils__/reset-storage'; -test.afterEach(async () => { - await resetStorage(); -}); +test.describe('Tools list page', () => { + test.afterEach(async () => { + await resetStorage(); + }); -test('clicking on the tool box redirects to the tool page', async ({ page }) => { - await page.goto('/tools'); + test.describe('when a registered tool is clicked', () => { + test('navigates to that tool detail page and shows its name as the heading', async ({ page }) => { + await page.goto('/tools'); - const el = await page.locator('text=Get current weather for a location'); - await el.click(); + const el = await page.locator('text=Get current weather for a location'); + await el.click(); - await expect(page).toHaveURL(/\/tools\/weatherInfo$/); - await expect(page.locator('h2')).toHaveText('weatherInfo'); + await expect(page).toHaveURL(/\/tools\/weatherInfo$/); + await expect(page.locator('h2')).toHaveText('weatherInfo'); + }); + }); }); diff --git a/packages/playground/e2e/tests/traces/$traceId/page.spec.ts b/packages/playground/e2e/tests/traces/$traceId/page.spec.ts index a4af36bd9e95..a39c26e224cf 100644 --- a/packages/playground/e2e/tests/traces/$traceId/page.spec.ts +++ b/packages/playground/e2e/tests/traces/$traceId/page.spec.ts @@ -1,4 +1,5 @@ -import { test, expect, type Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; import { resetStorage } from '../../__utils__/reset-storage'; import { expectBreadcrumbLink, expectCurrentBreadcrumb, expectRouteDocsLink } from '../../__utils__/route-header'; @@ -23,69 +24,87 @@ async function mockTraceResponse(page: Page, status: number, body: unknown = { e }); } -test.afterEach(async () => { - await resetStorage(); -}); +test.describe('Trace detail page', () => { + test.afterEach(async () => { + await resetStorage(); + }); -test('shows page title with trace id', async ({ page }) => { - await page.goto(`/traces/${FAKE_TRACE_ID}`); + test.describe('when the trace detail page is opened', () => { + test('shows page title with trace id', async ({ page }) => { + await page.goto(`/traces/${FAKE_TRACE_ID}`); - await expect(page).toHaveTitle(/Mastra Studio/); - await expectCurrentBreadcrumb(page, 'trace'); -}); + await expect(page).toHaveTitle(/Mastra Studio/); + await expectCurrentBreadcrumb(page, 'trace'); + }); -test('has breadcrumb link pointing back to observability', async ({ page }) => { - await page.goto(`/traces/${FAKE_TRACE_ID}`); + test('has breadcrumb link pointing back to observability', async ({ page }) => { + await page.goto(`/traces/${FAKE_TRACE_ID}`); - await expectBreadcrumbLink(page, 'Traces', '/observability'); -}); + await expectBreadcrumbLink(page, 'Traces', '/observability'); + }); -test('clicking the Traces breadcrumb navigates to observability', async ({ page }) => { - await page.goto(`/traces/${FAKE_TRACE_ID}`); + test('has Traces documentation link', async ({ page }) => { + await page.goto(`/traces/${FAKE_TRACE_ID}`); - await page.getByLabel('Breadcrumb').getByRole('link', { name: 'Traces' }).click(); - await expect(page).toHaveURL(/\/observability$/); - await expectCurrentBreadcrumb(page, 'Traces'); -}); + await expectRouteDocsLink( + page, + 'Traces documentation', + 'https://mastra.ai/en/docs/observability/tracing/overview', + ); + }); + }); -test('has Traces documentation link', async ({ page }) => { - await page.goto(`/traces/${FAKE_TRACE_ID}`); + test.describe('when the Traces breadcrumb is clicked', () => { + test('navigates to observability', async ({ page }) => { + await page.goto(`/traces/${FAKE_TRACE_ID}`); - await expectRouteDocsLink(page, 'Traces documentation', 'https://mastra.ai/en/docs/observability/tracing/overview'); -}); + await page.getByLabel('Breadcrumb').getByRole('link', { name: 'Traces' }).click(); + await expect(page).toHaveURL(/\/observability$/); + await expectCurrentBreadcrumb(page, 'Traces'); + }); + }); -test('renders without crashing when spanId, tab and scoreId query params are provided on mount', async ({ page }) => { - await page.goto(`/traces/${FAKE_TRACE_ID}?spanId=span-x&tab=scoring&scoreId=score-y`); + test.describe('when spanId, tab and scoreId query params are provided on mount', () => { + test('renders the page shell without crashing', async ({ page }) => { + await page.goto(`/traces/${FAKE_TRACE_ID}?spanId=span-x&tab=scoring&scoreId=score-y`); - // Page shell still renders - the panels themselves depend on server data that may not exist. - await expectCurrentBreadcrumb(page, 'trace'); - await expectBreadcrumbLink(page, 'Traces', '/observability'); -}); + // Page shell still renders - the panels themselves depend on server data that may not exist. + await expectCurrentBreadcrumb(page, 'trace'); + await expectBreadcrumbLink(page, 'Traces', '/observability'); + }); + }); -test('shows session-expired state when the trace request returns 401', async ({ page }) => { - await mockTraceResponse(page, 401, { error: 'Unauthorized' }); - await page.goto(`/traces/${FAKE_TRACE_ID}`); + test.describe('when the trace request returns 401', () => { + test('shows the session-expired state', async ({ page }) => { + await mockTraceResponse(page, 401, { error: 'Unauthorized' }); + await page.goto(`/traces/${FAKE_TRACE_ID}`); - await expect(page.getByText('Session Expired')).toBeVisible(); - // Shared top area still renders in the error state. - await expectBreadcrumbLink(page, 'Traces', '/observability'); -}); + await expect(page.getByText('Session Expired')).toBeVisible(); + // Shared top area still renders in the error state. + await expectBreadcrumbLink(page, 'Traces', '/observability'); + }); + }); -test('shows permission-denied state when the trace request returns 403', async ({ page }) => { - await mockTraceResponse(page, 403, { error: 'Forbidden' }); - await page.goto(`/traces/${FAKE_TRACE_ID}`); + test.describe('when the trace request returns 403', () => { + test('shows the permission-denied state', async ({ page }) => { + await mockTraceResponse(page, 403, { error: 'Forbidden' }); + await page.goto(`/traces/${FAKE_TRACE_ID}`); - await expect(page.getByText('Permission Denied')).toBeVisible(); - await expect(page.getByText(/You don't have permission to access traces/)).toBeVisible(); - await expectBreadcrumbLink(page, 'Traces', '/observability'); -}); + await expect(page.getByText('Permission Denied')).toBeVisible(); + await expect(page.getByText(/You don't have permission to access traces/)).toBeVisible(); + await expectBreadcrumbLink(page, 'Traces', '/observability'); + }); + }); -test('shows generic error state when the trace request fails (non-auth error)', async ({ page }) => { - // 404 is non-retryable (per `shouldRetryQuery`/`isNonRetryableError`) and neither 401 nor 403, - // so it hits the generic-error branch without waiting on retry backoffs. - await mockTraceResponse(page, 404, { error: 'Not found' }); - await page.goto(`/traces/${FAKE_TRACE_ID}`); + test.describe('when the trace request fails with a non-auth error', () => { + test('shows the generic error state', async ({ page }) => { + // 404 is non-retryable (per `shouldRetryQuery`/`isNonRetryableError`) and neither 401 nor 403, + // so it hits the generic-error branch without waiting on retry backoffs. + await mockTraceResponse(page, 404, { error: 'Not found' }); + await page.goto(`/traces/${FAKE_TRACE_ID}`); - await expect(page.getByText('Failed to load trace')).toBeVisible(); - await expectBreadcrumbLink(page, 'Traces', '/observability'); + await expect(page.getByText('Failed to load trace')).toBeVisible(); + await expectBreadcrumbLink(page, 'Traces', '/observability'); + }); + }); }); diff --git a/packages/playground/e2e/tests/workflows/$workflowId/debug-step-by-step-conditional-run-page.spec.ts b/packages/playground/e2e/tests/workflows/$workflowId/debug-step-by-step-conditional-run-page.spec.ts index c7291a718627..e489969b231a 100644 --- a/packages/playground/e2e/tests/workflows/$workflowId/debug-step-by-step-conditional-run-page.spec.ts +++ b/packages/playground/e2e/tests/workflows/$workflowId/debug-step-by-step-conditional-run-page.spec.ts @@ -1,4 +1,5 @@ -import { test, expect, Page } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; import { resetStorage } from '../../__utils__/reset-storage'; /** @@ -52,266 +53,278 @@ async function runNextStep(page: Page) { await expect(button).toBeEnabled({ timeout: 20000 }); } -test('takes the condition-selected branch after reloading a paused run on its :runId page', async ({ page }) => { - // ARRANGE: start a per-step run with a LONG input so the conditional must take long-text. - await page.goto('/workflows/complexWorkflow/graph'); - await page.getByRole('textbox', { name: 'Text' }).fill('HELLO'); - await page.getByRole('switch', { name: 'Debug' }).click(); - await expect(page.getByRole('switch', { name: 'Debug' })).toBeChecked(); - - await runButton(page).click(); - await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); - - // Advance up to (but not into) the conditional: add-letter, the parallel block, then the map. - await runNextStep(page); - await expectStepSuccess(page, 0); // add-letter - - await runNextStep(page); - await expectStepSuccess(page, 1); // add-letter-b - await expectStepSuccess(page, 2); // add-letter-c - - await runNextStep(page); - await expectStepSuccess(page, 3); // map -> single text field; next step is the undecided branch - - // The run is now paused right before the conditional: neither branch arm has run yet. - await expect(stepNode(page, 'short-text')).toHaveAttribute('data-workflow-step-status', 'idle'); - await expect(stepNode(page, 'long-text')).toHaveAttribute('data-workflow-step-status', 'idle'); - - // Capture the paused run id and navigate AWAY then BACK to it (the user's exact repro). - const recentRunLink = page.locator('a[href*="/workflows/complexWorkflow/graph/"]').first(); - await expect(recentRunLink).toBeVisible({ timeout: 20000 }); - const href = await recentRunLink.getAttribute('href'); - const runId = href?.split('/').pop(); - expect(runId, 'expected a runId in the recent-runs link href').toBeTruthy(); - - await page.goto('/workflows'); - await page.goto(`/workflows/complexWorkflow/graph/${runId}`); - - // The per-step controls come back purely from the paused status (debug flag is OFF here). - await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); - await expectStepSuccess(page, 3); - - // ACT: advance the undecided conditional from the rehydrated snapshot. - await runNextStepButton(page).click(); - - // ASSERT: the engine-selected arm (long-text) runs; the guessed first arm (short-text) - // must NOT run. This is the branch-selection bug lock. - await expect(stepNode(page, 'long-text')).toHaveAttribute('data-workflow-step-status', 'success', { - timeout: 20000, +test.describe('Workflow debug conditional branch selection on the run-detail page', () => { + test.describe('when a paused run is reopened by its :runId URL before the conditional', () => { + test('takes the condition-selected branch after reloading the paused run', async ({ page }) => { + // ARRANGE: start a per-step run with a LONG input so the conditional must take long-text. + await page.goto('/workflows/complexWorkflow/graph'); + await page.getByRole('textbox', { name: 'Text' }).fill('HELLO'); + await page.getByRole('switch', { name: 'Debug' }).click(); + await expect(page.getByRole('switch', { name: 'Debug' })).toBeChecked(); + + await runButton(page).click(); + await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); + + // Advance up to (but not into) the conditional: add-letter, the parallel block, then the map. + await runNextStep(page); + await expectStepSuccess(page, 0); // add-letter + + await runNextStep(page); + await expectStepSuccess(page, 1); // add-letter-b + await expectStepSuccess(page, 2); // add-letter-c + + await runNextStep(page); + await expectStepSuccess(page, 3); // map -> single text field; next step is the undecided branch + + // The run is now paused right before the conditional: neither branch arm has run yet. + await expect(stepNode(page, 'short-text')).toHaveAttribute('data-workflow-step-status', 'idle'); + await expect(stepNode(page, 'long-text')).toHaveAttribute('data-workflow-step-status', 'idle'); + + // Capture the paused run id and navigate AWAY then BACK to it (the user's exact repro). + const recentRunLink = page.locator('a[href*="/workflows/complexWorkflow/graph/"]').first(); + await expect(recentRunLink).toBeVisible({ timeout: 20000 }); + const href = await recentRunLink.getAttribute('href'); + const runId = href?.split('/').pop(); + expect(runId, 'expected a runId in the recent-runs link href').toBeTruthy(); + + await page.goto('/workflows'); + await page.goto(`/workflows/complexWorkflow/graph/${runId}`); + + // The per-step controls come back purely from the paused status (debug flag is OFF here). + await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); + await expectStepSuccess(page, 3); + + // ACT: advance the undecided conditional from the rehydrated snapshot. + await runNextStepButton(page).click(); + + // ASSERT: the engine-selected arm (long-text) runs; the guessed first arm (short-text) + // must NOT run. This is the branch-selection bug lock. + await expect(stepNode(page, 'long-text')).toHaveAttribute('data-workflow-step-status', 'success', { + timeout: 20000, + }); + await expect(stepNode(page, 'short-text')).not.toHaveAttribute('data-workflow-step-status', 'success'); + }); }); - await expect(stepNode(page, 'short-text')).not.toHaveAttribute('data-workflow-step-status', 'success'); -}); -test('takes the condition-selected branch after a HARD reload of the paused :runId page', async ({ page }) => { - // The user's exact repro: pause right before the conditional, HARD-refresh the run page so - // ALL state is snapshot-derived, then click "Run next step" exactly once. The run must take - // the condition-selected arm (long-text), not the first arm in graph order (short-text). - await page.goto('/workflows/complexWorkflow/graph'); - await page.getByRole('textbox', { name: 'Text' }).fill('HELLO'); - await page.getByRole('switch', { name: 'Debug' }).click(); - await expect(page.getByRole('switch', { name: 'Debug' })).toBeChecked(); - - await runButton(page).click(); - await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); - - await runNextStep(page); - await expectStepSuccess(page, 0); // add-letter - - await runNextStep(page); - await expectStepSuccess(page, 1); // add-letter-b - await expectStepSuccess(page, 2); // add-letter-c - - await runNextStep(page); - await expectStepSuccess(page, 3); // map -> paused right before the conditional - - await expect(stepNode(page, 'short-text')).toHaveAttribute('data-workflow-step-status', 'idle'); - await expect(stepNode(page, 'long-text')).toHaveAttribute('data-workflow-step-status', 'idle'); - - // Capture the run id, navigate to its :runId URL, then HARD reload so nothing is in memory. - const recentRunLink = page.locator('a[href*="/workflows/complexWorkflow/graph/"]').first(); - await expect(recentRunLink).toBeVisible({ timeout: 20000 }); - const href = await recentRunLink.getAttribute('href'); - const runId = href?.split('/').pop(); - expect(runId, 'expected a runId in the recent-runs link href').toBeTruthy(); - - // Navigate to the paused run page, advance the conditional, then HARD reload so the run - // page rehydrates purely from the persisted snapshot. - await page.goto(`/workflows/complexWorkflow/graph/${runId}`); - await runNextStep(page); - await page.reload(); - - // The engine-selected arm (long-text) must be the one that ran; short-text never ran. - await expect(stepNode(page, 'long-text')).toHaveAttribute('data-workflow-step-status', 'success', { - timeout: 20000, + test.describe('when the paused :runId page is hard-reloaded before the conditional', () => { + test('takes the condition-selected branch after a HARD reload of the paused run', async ({ page }) => { + // The user's exact repro: pause right before the conditional, HARD-refresh the run page so + // ALL state is snapshot-derived, then click "Run next step" exactly once. The run must take + // the condition-selected arm (long-text), not the first arm in graph order (short-text). + await page.goto('/workflows/complexWorkflow/graph'); + await page.getByRole('textbox', { name: 'Text' }).fill('HELLO'); + await page.getByRole('switch', { name: 'Debug' }).click(); + await expect(page.getByRole('switch', { name: 'Debug' })).toBeChecked(); + + await runButton(page).click(); + await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); + + await runNextStep(page); + await expectStepSuccess(page, 0); // add-letter + + await runNextStep(page); + await expectStepSuccess(page, 1); // add-letter-b + await expectStepSuccess(page, 2); // add-letter-c + + await runNextStep(page); + await expectStepSuccess(page, 3); // map -> paused right before the conditional + + await expect(stepNode(page, 'short-text')).toHaveAttribute('data-workflow-step-status', 'idle'); + await expect(stepNode(page, 'long-text')).toHaveAttribute('data-workflow-step-status', 'idle'); + + // Capture the run id, navigate to its :runId URL, then HARD reload so nothing is in memory. + const recentRunLink = page.locator('a[href*="/workflows/complexWorkflow/graph/"]').first(); + await expect(recentRunLink).toBeVisible({ timeout: 20000 }); + const href = await recentRunLink.getAttribute('href'); + const runId = href?.split('/').pop(); + expect(runId, 'expected a runId in the recent-runs link href').toBeTruthy(); + + // Navigate to the paused run page, advance the conditional, then HARD reload so the run + // page rehydrates purely from the persisted snapshot. + await page.goto(`/workflows/complexWorkflow/graph/${runId}`); + await runNextStep(page); + await page.reload(); + + // The engine-selected arm (long-text) must be the one that ran; short-text never ran. + await expect(stepNode(page, 'long-text')).toHaveAttribute('data-workflow-step-status', 'success', { + timeout: 20000, + }); + await expect(stepNode(page, 'short-text')).not.toHaveAttribute('data-workflow-step-status', 'success'); + + // The un-taken arm's incoming edge must stay neutral: short-text was skipped, so the run + // never flowed through it even though the snapshot carries a status for the step. + const skippedArmEdge = page.locator('[data-edge-to="short-text"]').first(); + await expect(skippedArmEdge).toHaveAttribute('data-edge-status', 'idle'); + }); }); - await expect(stepNode(page, 'short-text')).not.toHaveAttribute('data-workflow-step-status', 'success'); - - // The un-taken arm's incoming edge must stay neutral: short-text was skipped, so the run - // never flowed through it even though the snapshot carries a status for the step. - const skippedArmEdge = page.locator('[data-edge-to="short-text"]').first(); - await expect(skippedArmEdge).toHaveAttribute('data-edge-status', 'idle'); -}); -test('takes the condition-selected branch when the conditional is reloaded then advanced', async ({ page }) => { - // The user's "even better" repro: pause right before the conditional, navigate to the run - // page, HARD reload, advance once (long-text), then HARD reload AGAIN and advance once more. - // Each advance off a freshly rehydrated snapshot must keep taking the condition-selected arm. - await page.goto('/workflows/complexWorkflow/graph'); - await page.getByRole('textbox', { name: 'Text' }).fill('HELLO'); - await page.getByRole('switch', { name: 'Debug' }).click(); - await expect(page.getByRole('switch', { name: 'Debug' })).toBeChecked(); - - await runButton(page).click(); - await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); - - await runNextStep(page); - await expectStepSuccess(page, 0); // add-letter - - await runNextStep(page); - await expectStepSuccess(page, 1); // add-letter-b - await expectStepSuccess(page, 2); // add-letter-c - - await runNextStep(page); - await expectStepSuccess(page, 3); // map -> paused right before the conditional - - const recentRunLink = page.locator('a[href*="/workflows/complexWorkflow/graph/"]').first(); - await expect(recentRunLink).toBeVisible({ timeout: 20000 }); - const href = await recentRunLink.getAttribute('href'); - const runId = href?.split('/').pop(); - expect(runId, 'expected a runId in the recent-runs link href').toBeTruthy(); - - // Land on the run page and HARD reload before advancing the conditional. - await page.goto(`/workflows/complexWorkflow/graph/${runId}`); - await page.reload(); - await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); - await expectStepSuccess(page, 3); - - // First advance off the rehydrated snapshot -> must take long-text. - await runNextStepButton(page).click(); - await expect(stepNode(page, 'long-text')).toHaveAttribute('data-workflow-step-status', 'success', { - timeout: 20000, + test.describe('when the paused :runId page is reloaded before and after the conditional', () => { + test('takes the condition-selected branch when the conditional is reloaded then advanced', async ({ page }) => { + // The user's "even better" repro: pause right before the conditional, navigate to the run + // page, HARD reload, advance once (long-text), then HARD reload AGAIN and advance once more. + // Each advance off a freshly rehydrated snapshot must keep taking the condition-selected arm. + await page.goto('/workflows/complexWorkflow/graph'); + await page.getByRole('textbox', { name: 'Text' }).fill('HELLO'); + await page.getByRole('switch', { name: 'Debug' }).click(); + await expect(page.getByRole('switch', { name: 'Debug' })).toBeChecked(); + + await runButton(page).click(); + await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); + + await runNextStep(page); + await expectStepSuccess(page, 0); // add-letter + + await runNextStep(page); + await expectStepSuccess(page, 1); // add-letter-b + await expectStepSuccess(page, 2); // add-letter-c + + await runNextStep(page); + await expectStepSuccess(page, 3); // map -> paused right before the conditional + + const recentRunLink = page.locator('a[href*="/workflows/complexWorkflow/graph/"]').first(); + await expect(recentRunLink).toBeVisible({ timeout: 20000 }); + const href = await recentRunLink.getAttribute('href'); + const runId = href?.split('/').pop(); + expect(runId, 'expected a runId in the recent-runs link href').toBeTruthy(); + + // Land on the run page and HARD reload before advancing the conditional. + await page.goto(`/workflows/complexWorkflow/graph/${runId}`); + await page.reload(); + await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); + await expectStepSuccess(page, 3); + + // First advance off the rehydrated snapshot -> must take long-text. + await runNextStepButton(page).click(); + await expect(stepNode(page, 'long-text')).toHaveAttribute('data-workflow-step-status', 'success', { + timeout: 20000, + }); + await expect(stepNode(page, 'short-text')).not.toHaveAttribute('data-workflow-step-status', 'success'); + + // HARD reload AGAIN, now paused right AFTER the conditional, and advance once more. The + // condition selection persisted in the snapshot must survive the reload + next click. + await page.reload(); + await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); + await expect(stepNode(page, 'long-text')).toHaveAttribute('data-workflow-step-status', 'success', { + timeout: 20000, + }); + await expect(stepNode(page, 'short-text')).not.toHaveAttribute('data-workflow-step-status', 'success'); + + await runNextStepButton(page).click(); + + // After advancing past the conditional, short-text must STILL never have run. + await expect(stepNode(page, 'short-text')).not.toHaveAttribute('data-workflow-step-status', 'success', { + timeout: 20000, + }); + }); }); - await expect(stepNode(page, 'short-text')).not.toHaveAttribute('data-workflow-step-status', 'success'); - - // HARD reload AGAIN, now paused right AFTER the conditional, and advance once more. The - // condition selection persisted in the snapshot must survive the reload + next click. - await page.reload(); - await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); - await expect(stepNode(page, 'long-text')).toHaveAttribute('data-workflow-step-status', 'success', { - timeout: 20000, - }); - await expect(stepNode(page, 'short-text')).not.toHaveAttribute('data-workflow-step-status', 'success'); - - await runNextStepButton(page).click(); - // After advancing past the conditional, short-text must STILL never have run. - await expect(stepNode(page, 'short-text')).not.toHaveAttribute('data-workflow-step-status', 'success', { - timeout: 20000, - }); -}); + test.describe('when the conditional is advanced on the live graph page', () => { + test('takes the condition-selected branch on the live graph page (no reload)', async ({ page }) => { + // Same long input, but advance the conditional on the LIVE graph page without navigating + // away. This isolates whether the conditional re-evaluation works in the live stream path, + // separate from snapshot rehydration on the :runId page. + await page.goto('/workflows/complexWorkflow/graph'); + await page.getByRole('textbox', { name: 'Text' }).fill('HELLO'); + await page.getByRole('switch', { name: 'Debug' }).click(); + await expect(page.getByRole('switch', { name: 'Debug' })).toBeChecked(); -test('takes the condition-selected branch on the live graph page (no reload)', async ({ page }) => { - // Same long input, but advance the conditional on the LIVE graph page without navigating - // away. This isolates whether the conditional re-evaluation works in the live stream path, - // separate from snapshot rehydration on the :runId page. - await page.goto('/workflows/complexWorkflow/graph'); - await page.getByRole('textbox', { name: 'Text' }).fill('HELLO'); - await page.getByRole('switch', { name: 'Debug' }).click(); - await expect(page.getByRole('switch', { name: 'Debug' })).toBeChecked(); + await runButton(page).click(); + await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); - await runButton(page).click(); - await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); + await runNextStep(page); + await expectStepSuccess(page, 0); // add-letter - await runNextStep(page); - await expectStepSuccess(page, 0); // add-letter + await runNextStep(page); + await expectStepSuccess(page, 1); // add-letter-b + await expectStepSuccess(page, 2); // add-letter-c - await runNextStep(page); - await expectStepSuccess(page, 1); // add-letter-b - await expectStepSuccess(page, 2); // add-letter-c + await runNextStep(page); + await expectStepSuccess(page, 3); // map - await runNextStep(page); - await expectStepSuccess(page, 3); // map + await expect(stepNode(page, 'short-text')).toHaveAttribute('data-workflow-step-status', 'idle'); + await expect(stepNode(page, 'long-text')).toHaveAttribute('data-workflow-step-status', 'idle'); - await expect(stepNode(page, 'short-text')).toHaveAttribute('data-workflow-step-status', 'idle'); - await expect(stepNode(page, 'long-text')).toHaveAttribute('data-workflow-step-status', 'idle'); + // ACT: advance the undecided conditional live. + await runNextStepButton(page).click(); - // ACT: advance the undecided conditional live. - await runNextStepButton(page).click(); + // ASSERT: long-text runs, short-text does not. + await expect(stepNode(page, 'long-text')).toHaveAttribute('data-workflow-step-status', 'success', { + timeout: 20000, + }); + await expect(stepNode(page, 'short-text')).not.toHaveAttribute('data-workflow-step-status', 'success'); - // ASSERT: long-text runs, short-text does not. - await expect(stepNode(page, 'long-text')).toHaveAttribute('data-workflow-step-status', 'success', { - timeout: 20000, + await runNextStep(page); + }); }); - await expect(stepNode(page, 'short-text')).not.toHaveAttribute('data-workflow-step-status', 'success'); - - await runNextStep(page); -}); -test('check edges', async ({ page }) => { - // Drive complexWorkflow per-step all the way to a successful finish, then verify - // both the post-branch map -> nested edge AND the boundary edge into the End node - // are colored green. The End edge has no step ids, so it can only light once the - // whole run reaches `success` (after the suspend boundary is resumed). - await page.goto('/workflows/complexWorkflow/graph'); - await page.getByRole('textbox', { name: 'Text' }).fill('HELLO'); - await page.getByRole('switch', { name: 'Debug' }).click(); - await expect(page.getByRole('switch', { name: 'Debug' })).toBeChecked(); - - await runButton(page).click(); - await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); - - await runNextStep(page); // add-letter - await expectStepSuccess(page, 0); - - await runNextStep(page); // parallel: add-letter-b + add-letter-c - await expectStepSuccess(page, 1); - await expectStepSuccess(page, 2); - - await runNextStep(page); // post-parallel map - await expectStepSuccess(page, 3); - - await runNextStep(page); // conditional arm (long-text for "HELLO") - await expect(stepNode(page, 'long-text')).toHaveAttribute('data-workflow-step-status', 'success', { - timeout: 20000, + test.describe('when a debug run finishes after taking the condition-selected branch', () => { + test('marks branch and final edges successful', async ({ page }) => { + // Drive complexWorkflow per-step all the way to a successful finish, then verify + // both the post-branch map -> nested edge AND the boundary edge into the End node + // are colored green. The End edge has no step ids, so it can only light once the + // whole run reaches `success` (after the suspend boundary is resumed). + await page.goto('/workflows/complexWorkflow/graph'); + await page.getByRole('textbox', { name: 'Text' }).fill('HELLO'); + await page.getByRole('switch', { name: 'Debug' }).click(); + await expect(page.getByRole('switch', { name: 'Debug' })).toBeChecked(); + + await runButton(page).click(); + await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); + + await runNextStep(page); // add-letter + await expectStepSuccess(page, 0); + + await runNextStep(page); // parallel: add-letter-b + add-letter-c + await expectStepSuccess(page, 1); + await expectStepSuccess(page, 2); + + await runNextStep(page); // post-parallel map + await expectStepSuccess(page, 3); + + await runNextStep(page); // conditional arm (long-text for "HELLO") + await expect(stepNode(page, 'long-text')).toHaveAttribute('data-workflow-step-status', 'success', { + timeout: 20000, + }); + + await runNextStep(page); // post-branch map + await expectStepSuccess(page, 8); + + // Nested workflow runs atomically; this advance also runs the doUntil body and + // stops at the suspend boundary (step 12). + const nestedButton = runNextStepButton(page); + await expect(nestedButton).toBeEnabled({ timeout: 20000 }); + await nestedButton.click(); + await expectStepSuccess(page, 9); // nested-text-processor + await expectStepSuccess(page, 10); // add-letter-with-count + await expect(nodes(page).nth(12)).toHaveAttribute('data-workflow-step-status', 'suspended', { timeout: 20000 }); + + // Resume the suspended step so the run can finish. + const suspendedSteps = page.getByTestId('workflow-suspended-steps'); + await suspendedSteps.getByRole('textbox', { name: 'User Input' }).fill('Hello'); + await suspendedSteps.getByRole('button', { name: 'Resume' }).click(); + await expectStepSuccess(page, 12); // suspend-resume + + // Final step finishes the whole run. + const finalButton = runNextStepButton(page); + await expect(finalButton).toBeEnabled({ timeout: 20000 }); + await finalButton.click(); + await expectStepSuccess(page, 13); // final-step + + // The post-branch map step serializes to a synthetic `mapping_<uuid>` id that + // changes per build, so resolve it from the DOM. complexWorkflow has two `.map()` + // steps; the SECOND mapping node is the post-branch join that feeds the nested workflow. + const mappingNodes = page.locator('[data-workflow-node][data-workflow-step-key^="mapping_"]'); + await expect(mappingNodes).toHaveCount(2, { timeout: 20000 }); + const mapBranch = await mappingNodes.nth(1).getAttribute('data-workflow-step-key'); + expect(mapBranch).toBeTruthy(); + + const edgeMap = page.locator(`[data-edge-from="${mapBranch}"][data-edge-to="nested-text-processor"]`).first(); + // The workflow-output boundary edge carries no step ids, so target it by its + // domain-prefixed React Flow id: edge-boundary-<sourceNodeId>-boundary-end. + const finalEdge = page.locator('[id="edge-boundary-node-final-step-boundary-end"]').first(); + + await expect(edgeMap).toHaveAttribute('data-edge-status', 'success', { timeout: 20000 }); + await expect(finalEdge).toHaveAttribute('data-edge-status', 'success', { timeout: 20000 }); + }); }); - - await runNextStep(page); // post-branch map - await expectStepSuccess(page, 8); - - // Nested workflow runs atomically; this advance also runs the doUntil body and - // stops at the suspend boundary (step 12). - const nestedButton = runNextStepButton(page); - await expect(nestedButton).toBeEnabled({ timeout: 20000 }); - await nestedButton.click(); - await expectStepSuccess(page, 9); // nested-text-processor - await expectStepSuccess(page, 10); // add-letter-with-count - await expect(nodes(page).nth(12)).toHaveAttribute('data-workflow-step-status', 'suspended', { timeout: 20000 }); - - // Resume the suspended step so the run can finish. - const suspendedSteps = page.getByTestId('workflow-suspended-steps'); - await suspendedSteps.getByRole('textbox', { name: 'User Input' }).fill('Hello'); - await suspendedSteps.getByRole('button', { name: 'Resume' }).click(); - await expectStepSuccess(page, 12); // suspend-resume - - // Final step finishes the whole run. - const finalButton = runNextStepButton(page); - await expect(finalButton).toBeEnabled({ timeout: 20000 }); - await finalButton.click(); - await expectStepSuccess(page, 13); // final-step - - // The post-branch map step serializes to a synthetic `mapping_<uuid>` id that - // changes per build, so resolve it from the DOM. complexWorkflow has two `.map()` - // steps; the SECOND mapping node is the post-branch join that feeds the nested workflow. - const mappingNodes = page.locator('[data-workflow-node][data-workflow-step-key^="mapping_"]'); - await expect(mappingNodes).toHaveCount(2, { timeout: 20000 }); - const mapBranch = await mappingNodes.nth(1).getAttribute('data-workflow-step-key'); - expect(mapBranch).toBeTruthy(); - - const edgeMap = page.locator(`[data-edge-from="${mapBranch}"][data-edge-to="nested-text-processor"]`).first(); - // The workflow-output boundary edge carries no step ids, so target it by its - // domain-prefixed React Flow id: edge-boundary-<sourceNodeId>-boundary-end. - const finalEdge = page.locator('[id="edge-boundary-node-final-step-boundary-end"]').first(); - - await expect(edgeMap).toHaveAttribute('data-edge-status', 'success', { timeout: 20000 }); - await expect(finalEdge).toHaveAttribute('data-edge-status', 'success', { timeout: 20000 }); }); diff --git a/packages/playground/e2e/tests/workflows/$workflowId/debug-step-by-step-edges.spec.ts b/packages/playground/e2e/tests/workflows/$workflowId/debug-step-by-step-edges.spec.ts index 9e2db7857a4f..a62800e55015 100644 --- a/packages/playground/e2e/tests/workflows/$workflowId/debug-step-by-step-edges.spec.ts +++ b/packages/playground/e2e/tests/workflows/$workflowId/debug-step-by-step-edges.spec.ts @@ -1,6 +1,8 @@ -import { test, expect, Page } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; import { resetStorage } from '../../__utils__/reset-storage'; -import { expectExactEdgeStatuses, type EdgeExpectation } from '../../__utils__/workflow-edges'; +import { expectExactEdgeStatuses } from '../../__utils__/workflow-edges'; +import type { EdgeExpectation } from '../../__utils__/workflow-edges'; /** * FEATURE: Workflow debug mode "Run next step" — deterministic edge activation. @@ -139,39 +141,45 @@ async function driveFullRun(page: Page, takenArm: 'short-text' | 'long-text') { await expectStepSuccess(page, 13); // final-step } -test('every edge of the short-text branch run is deterministically colored', async ({ page }) => { - await page.goto('/workflows/complexWorkflow/graph'); - - // ARRANGE: input "A" keeps text short, so the conditional takes short-text. - await page.getByRole('textbox', { name: 'Text' }).fill('A'); - await page.getByRole('switch', { name: 'Debug' }).click(); - await expect(page.getByRole('switch', { name: 'Debug' })).toBeChecked(); - - // ACT: start per-step, then drive every step to completion. - await runButton(page).click(); - await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); - await driveFullRun(page, 'short-text'); - - // ASSERT: the COMPLETE edge map for the short-text path. - await expect(page.locator('[data-edge-to="add-letter"]')).toHaveAttribute('data-edge-status', 'success'); - const { mapParallel, mapBranch } = await resolveMappingIds(page); - await expectExactEdgeStatuses(page, expectedEdges(mapParallel, mapBranch, 'short-text', 'long-text')); -}); - -test('every edge of the long-text branch run is deterministically colored', async ({ page }) => { - await page.goto('/workflows/complexWorkflow/graph'); - - // ARRANGE: input "HELLO" grows past 10 chars by the conditional -> long-text arm. - await page.getByRole('textbox', { name: 'Text' }).fill('HELLO'); - await page.getByRole('switch', { name: 'Debug' }).click(); - await expect(page.getByRole('switch', { name: 'Debug' })).toBeChecked(); - - await runButton(page).click(); - await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); - await driveFullRun(page, 'long-text'); - - // ASSERT: the COMPLETE edge map for the long-text path (mirror of short-text). - await expect(page.locator('[data-edge-to="add-letter"]')).toHaveAttribute('data-edge-status', 'success'); - const { mapParallel, mapBranch } = await resolveMappingIds(page); - await expectExactEdgeStatuses(page, expectedEdges(mapParallel, mapBranch, 'long-text', 'short-text')); +test.describe('Workflow debug edge coloring', () => { + test.describe('when a debug run takes the short-text branch', () => { + test('colors every edge of the short-text branch run deterministically', async ({ page }) => { + await page.goto('/workflows/complexWorkflow/graph'); + + // ARRANGE: input "A" keeps text short, so the conditional takes short-text. + await page.getByRole('textbox', { name: 'Text' }).fill('A'); + await page.getByRole('switch', { name: 'Debug' }).click(); + await expect(page.getByRole('switch', { name: 'Debug' })).toBeChecked(); + + // ACT: start per-step, then drive every step to completion. + await runButton(page).click(); + await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); + await driveFullRun(page, 'short-text'); + + // ASSERT: the COMPLETE edge map for the short-text path. + await expect(page.locator('[data-edge-to="add-letter"]')).toHaveAttribute('data-edge-status', 'success'); + const { mapParallel, mapBranch } = await resolveMappingIds(page); + await expectExactEdgeStatuses(page, expectedEdges(mapParallel, mapBranch, 'short-text', 'long-text')); + }); + }); + + test.describe('when a debug run takes the long-text branch', () => { + test('colors every edge of the long-text branch run deterministically', async ({ page }) => { + await page.goto('/workflows/complexWorkflow/graph'); + + // ARRANGE: input "HELLO" grows past 10 chars by the conditional -> long-text arm. + await page.getByRole('textbox', { name: 'Text' }).fill('HELLO'); + await page.getByRole('switch', { name: 'Debug' }).click(); + await expect(page.getByRole('switch', { name: 'Debug' })).toBeChecked(); + + await runButton(page).click(); + await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); + await driveFullRun(page, 'long-text'); + + // ASSERT: the COMPLETE edge map for the long-text path (mirror of short-text). + await expect(page.locator('[data-edge-to="add-letter"]')).toHaveAttribute('data-edge-status', 'success'); + const { mapParallel, mapBranch } = await resolveMappingIds(page); + await expectExactEdgeStatuses(page, expectedEdges(mapParallel, mapBranch, 'long-text', 'short-text')); + }); + }); }); diff --git a/packages/playground/e2e/tests/workflows/$workflowId/debug-step-by-step-run-page.spec.ts b/packages/playground/e2e/tests/workflows/$workflowId/debug-step-by-step-run-page.spec.ts index 18210da7152f..3486e3e92964 100644 --- a/packages/playground/e2e/tests/workflows/$workflowId/debug-step-by-step-run-page.spec.ts +++ b/packages/playground/e2e/tests/workflows/$workflowId/debug-step-by-step-run-page.spec.ts @@ -1,4 +1,5 @@ -import { test, expect, Page } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; import { resetStorage } from '../../__utils__/reset-storage'; /** @@ -52,80 +53,84 @@ async function runNextStep(page: Page) { await expect(button).toBeEnabled({ timeout: 20000 }); } -test('shows per-step controls and advances a paused run when landing on its :runId page', async ({ page }) => { - // ARRANGE: start a per-step run on the graph page and advance one step so the run - // is genuinely paused mid-flow (add-letter done, parallel block still pending). - await page.goto('/workflows/complexWorkflow/graph'); - await page.getByRole('textbox', { name: 'Text' }).fill('A'); - await page.getByRole('switch', { name: 'Debug' }).click(); - await expect(page.getByRole('switch', { name: 'Debug' })).toBeChecked(); - - await runButton(page).click(); - await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); - - // Advance the first step so the run has visible progress to verify after reload. - await runNextStep(page); - await expectStepSuccess(page, 0); - - // Capture the paused run's id from the recent-runs link, then navigate directly to - // its :runId page in a fresh navigation (no leftover in-memory debug toggle). - const recentRunLink = page.locator('a[href*="/workflows/complexWorkflow/graph/"]').first(); - await expect(recentRunLink).toBeVisible({ timeout: 20000 }); - const href = await recentRunLink.getAttribute('href'); - const runId = href?.split('/').pop(); - expect(runId, 'expected a runId in the recent-runs link href').toBeTruthy(); - - // ACT: land on the paused run's page directly. Debug mode is OFF in memory here. - await page.goto(`/workflows/complexWorkflow/graph/${runId}`); - - // ASSERT: the per-step controls appear purely from the paused status — no toggle needed. - await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); - - // The run-input form is collapsed/read-only while viewing a paused run: only the - // "Run input" button is present, not the editable text field / "Run" button. - await expect(page.getByRole('button', { name: 'Run input' })).toBeVisible({ timeout: 20000 }); - await expect(runButton(page)).toHaveCount(0); - - // The first step's success is still reflected on the run page. - await expectStepSuccess(page, 0); - - // ASSERT: advancing from the run page works just like the graph page. - // Steps 1 & 2: the parallel block (add-letter-b, add-letter-c). - await runNextStep(page); - await expectStepSuccess(page, 1); - await expectStepSuccess(page, 2); - - // Step 3: map back to single text field. - await runNextStep(page); - await expectStepSuccess(page, 3); - - // Step 5: short-text branch (input "A" -> short path). - await runNextStep(page); - await expectStepSuccess(page, 5); - - // Step 8: map back after the branch. - await runNextStep(page); - await expectStepSuccess(page, 8); - - // Step 9: nested-text-processor runs atomically and carries through the doUntil body, - // stopping at the suspend/resume step (the human-in-the-loop boundary). - const advanceToSuspend = runNextStepButton(page); - await expect(advanceToSuspend).toBeEnabled({ timeout: 20000 }); - await advanceToSuspend.click(); - await expectStepSuccess(page, 9); - await expectStepSuccess(page, 10); - - // ...and stops at suspend-resume, which suspends the run for user input. - await expect(nodes(page).nth(12)).toHaveAttribute('data-workflow-step-status', 'suspended', { timeout: 20000 }); - - // BUG LOCK: on the :runId page the suspend dialog must surface so the user can enter - // resume data. The stored run snapshot lags behind the live suspended status, so the - // overlay must key off the live result — otherwise the dialog never appears here. - const suspendedSteps = page.getByTestId('workflow-suspended-steps'); - await expect(suspendedSteps).toBeVisible({ timeout: 20000 }); - - // Resuming from the run page completes the suspended step just like the graph page. - await suspendedSteps.getByRole('textbox', { name: 'User Input' }).fill('Hello'); - await suspendedSteps.getByRole('button', { name: 'Resume' }).click(); - await expectStepSuccess(page, 12); +test.describe('Workflow debug per-step controls on the run-detail page', () => { + test.describe("when landing directly on a paused run's :runId page", () => { + test('shows per-step controls and advances the paused run', async ({ page }) => { + // ARRANGE: start a per-step run on the graph page and advance one step so the run + // is genuinely paused mid-flow (add-letter done, parallel block still pending). + await page.goto('/workflows/complexWorkflow/graph'); + await page.getByRole('textbox', { name: 'Text' }).fill('A'); + await page.getByRole('switch', { name: 'Debug' }).click(); + await expect(page.getByRole('switch', { name: 'Debug' })).toBeChecked(); + + await runButton(page).click(); + await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); + + // Advance the first step so the run has visible progress to verify after reload. + await runNextStep(page); + await expectStepSuccess(page, 0); + + // Capture the paused run's id from the recent-runs link, then navigate directly to + // its :runId page in a fresh navigation (no leftover in-memory debug toggle). + const recentRunLink = page.locator('a[href*="/workflows/complexWorkflow/graph/"]').first(); + await expect(recentRunLink).toBeVisible({ timeout: 20000 }); + const href = await recentRunLink.getAttribute('href'); + const runId = href?.split('/').pop(); + expect(runId, 'expected a runId in the recent-runs link href').toBeTruthy(); + + // ACT: land on the paused run's page directly. Debug mode is OFF in memory here. + await page.goto(`/workflows/complexWorkflow/graph/${runId}`); + + // ASSERT: the per-step controls appear purely from the paused status — no toggle needed. + await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); + + // The run-input form is collapsed/read-only while viewing a paused run: only the + // "Run input" button is present, not the editable text field / "Run" button. + await expect(page.getByRole('button', { name: 'Run input' })).toBeVisible({ timeout: 20000 }); + await expect(runButton(page)).toHaveCount(0); + + // The first step's success is still reflected on the run page. + await expectStepSuccess(page, 0); + + // ASSERT: advancing from the run page works just like the graph page. + // Steps 1 & 2: the parallel block (add-letter-b, add-letter-c). + await runNextStep(page); + await expectStepSuccess(page, 1); + await expectStepSuccess(page, 2); + + // Step 3: map back to single text field. + await runNextStep(page); + await expectStepSuccess(page, 3); + + // Step 5: short-text branch (input "A" -> short path). + await runNextStep(page); + await expectStepSuccess(page, 5); + + // Step 8: map back after the branch. + await runNextStep(page); + await expectStepSuccess(page, 8); + + // Step 9: nested-text-processor runs atomically and carries through the doUntil body, + // stopping at the suspend/resume step (the human-in-the-loop boundary). + const advanceToSuspend = runNextStepButton(page); + await expect(advanceToSuspend).toBeEnabled({ timeout: 20000 }); + await advanceToSuspend.click(); + await expectStepSuccess(page, 9); + await expectStepSuccess(page, 10); + + // ...and stops at suspend-resume, which suspends the run for user input. + await expect(nodes(page).nth(12)).toHaveAttribute('data-workflow-step-status', 'suspended', { timeout: 20000 }); + + // BUG LOCK: on the :runId page the suspend dialog must surface so the user can enter + // resume data. The stored run snapshot lags behind the live suspended status, so the + // overlay must key off the live result — otherwise the dialog never appears here. + const suspendedSteps = page.getByTestId('workflow-suspended-steps'); + await expect(suspendedSteps).toBeVisible({ timeout: 20000 }); + + // Resuming from the run page completes the suspended step just like the graph page. + await suspendedSteps.getByRole('textbox', { name: 'User Input' }).fill('Hello'); + await suspendedSteps.getByRole('button', { name: 'Resume' }).click(); + await expectStepSuccess(page, 12); + }); + }); }); diff --git a/packages/playground/e2e/tests/workflows/$workflowId/debug-step-by-step.spec.ts b/packages/playground/e2e/tests/workflows/$workflowId/debug-step-by-step.spec.ts index a53b29d775cc..532a6fa664fc 100644 --- a/packages/playground/e2e/tests/workflows/$workflowId/debug-step-by-step.spec.ts +++ b/packages/playground/e2e/tests/workflows/$workflowId/debug-step-by-step.spec.ts @@ -1,4 +1,5 @@ -import { test, expect, Page } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; import { resetStorage } from '../../__utils__/reset-storage'; /** @@ -49,73 +50,77 @@ async function runNextStep(page: Page) { await expect(button).toBeEnabled({ timeout: 20000 }); } -test('runs complexWorkflow one step at a time in debug mode until it finishes', async ({ page }) => { - await page.goto('/workflows/complexWorkflow/graph'); - - // ARRANGE: put input + activate debug mode. - await page.getByRole('textbox', { name: 'Text' }).fill('A'); - await page.getByRole('switch', { name: 'Debug' }).click(); - await expect(page.getByRole('switch', { name: 'Debug' })).toBeChecked(); - - // ACT: start execution. With debug mode on this runs per-step and pauses immediately. - await runButton(page).click(); - - // The per-step controls appear once the run is paused. - await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); - - // Step 0: add-letter - await runNextStep(page); - await expectStepSuccess(page, 0); - - // Steps 1 & 2: the parallel block (add-letter-b, add-letter-c) - await runNextStep(page); - await expectStepSuccess(page, 1); - await expectStepSuccess(page, 2); - - // Step 3: map back to single text field - await runNextStep(page); - await expectStepSuccess(page, 3); - - // Step 5: short-text branch (input "A" -> short path) - await runNextStep(page); - await expectStepSuccess(page, 5); - - // Step 8: map back after the branch - await runNextStep(page); - await expectStepSuccess(page, 8); - - // Step 9: nested-text-processor. - // A nested workflow can't be paused inside per-step mode without core support, so we - // run it atomically. This single advance completes the nested workflow AND any following - // top-level steps (the doUntil body) until the run reaches the next natural pause boundary - // — the suspend/resume step. We therefore expect the nested step, step 10, and the suspend - // all to settle from one click here. - const button = runNextStepButton(page); - await expect(button).toBeEnabled({ timeout: 20000 }); - await button.click(); - - // The atomic advance runs through the nested workflow and the doUntil body... - await expectStepSuccess(page, 9); - await expectStepSuccess(page, 10); - - // ...and stops at suspend-resume, which suspends the run for user input. - await expect(nodes(page).nth(12)).toHaveAttribute('data-workflow-step-status', 'suspended', { timeout: 20000 }); - - // Resume the suspended step to continue the per-step run. - const suspendedSteps = page.getByTestId('workflow-suspended-steps'); - await suspendedSteps.getByRole('textbox', { name: 'User Input' }).fill('Hello'); - await suspendedSteps.getByRole('button', { name: 'Resume' }).click(); - await expectStepSuccess(page, 12); - - // Step 13: final-step — the very end. This is the last step, so advancing it FINISHES the - // whole run (rather than pausing again) so the user can see the run's end output. We click - // once and assert the final step succeeds. - const finalButton = runNextStepButton(page); - await expect(finalButton).toBeEnabled({ timeout: 20000 }); - await finalButton.click(); - await expectStepSuccess(page, 13); - - // ASSERT: the run finished end-to-end. The final step output carries the "-ENDED" suffix. - await page.getByRole('button', { name: 'Run output' }).click(); - await expect(page.getByRole('dialog')).toContainText('-ENDED'); +test.describe('Workflow debug "Run next step"', () => { + test.describe('when complexWorkflow is driven one step at a time in debug mode', () => { + test('advances through every topology until the run finishes', async ({ page }) => { + await page.goto('/workflows/complexWorkflow/graph'); + + // ARRANGE: put input + activate debug mode. + await page.getByRole('textbox', { name: 'Text' }).fill('A'); + await page.getByRole('switch', { name: 'Debug' }).click(); + await expect(page.getByRole('switch', { name: 'Debug' })).toBeChecked(); + + // ACT: start execution. With debug mode on this runs per-step and pauses immediately. + await runButton(page).click(); + + // The per-step controls appear once the run is paused. + await expect(page.locator(DEBUG_CONTROLS)).toBeVisible({ timeout: 20000 }); + + // Step 0: add-letter + await runNextStep(page); + await expectStepSuccess(page, 0); + + // Steps 1 & 2: the parallel block (add-letter-b, add-letter-c) + await runNextStep(page); + await expectStepSuccess(page, 1); + await expectStepSuccess(page, 2); + + // Step 3: map back to single text field + await runNextStep(page); + await expectStepSuccess(page, 3); + + // Step 5: short-text branch (input "A" -> short path) + await runNextStep(page); + await expectStepSuccess(page, 5); + + // Step 8: map back after the branch + await runNextStep(page); + await expectStepSuccess(page, 8); + + // Step 9: nested-text-processor. + // A nested workflow can't be paused inside per-step mode without core support, so we + // run it atomically. This single advance completes the nested workflow AND any following + // top-level steps (the doUntil body) until the run reaches the next natural pause boundary + // — the suspend/resume step. We therefore expect the nested step, step 10, and the suspend + // all to settle from one click here. + const button = runNextStepButton(page); + await expect(button).toBeEnabled({ timeout: 20000 }); + await button.click(); + + // The atomic advance runs through the nested workflow and the doUntil body... + await expectStepSuccess(page, 9); + await expectStepSuccess(page, 10); + + // ...and stops at suspend-resume, which suspends the run for user input. + await expect(nodes(page).nth(12)).toHaveAttribute('data-workflow-step-status', 'suspended', { timeout: 20000 }); + + // Resume the suspended step to continue the per-step run. + const suspendedSteps = page.getByTestId('workflow-suspended-steps'); + await suspendedSteps.getByRole('textbox', { name: 'User Input' }).fill('Hello'); + await suspendedSteps.getByRole('button', { name: 'Resume' }).click(); + await expectStepSuccess(page, 12); + + // Step 13: final-step — the very end. This is the last step, so advancing it FINISHES the + // whole run (rather than pausing again) so the user can see the run's end output. We click + // once and assert the final step succeeds. + const finalButton = runNextStepButton(page); + await expect(finalButton).toBeEnabled({ timeout: 20000 }); + await finalButton.click(); + await expectStepSuccess(page, 13); + + // ASSERT: the run finished end-to-end. The final step output carries the "-ENDED" suffix. + await page.getByRole('button', { name: 'Run output' }).click(); + await expect(page.getByRole('dialog')).toContainText('-ENDED'); + }); + }); }); diff --git a/packages/playground/e2e/tests/workflows/$workflowId/nested-graph.spec.ts b/packages/playground/e2e/tests/workflows/$workflowId/nested-graph.spec.ts index 2040a29325fb..470cc94a26a1 100644 --- a/packages/playground/e2e/tests/workflows/$workflowId/nested-graph.spec.ts +++ b/packages/playground/e2e/tests/workflows/$workflowId/nested-graph.spec.ts @@ -7,22 +7,26 @@ import { resetStorage } from '../../__utils__/reset-storage'; // BEHAVIOR UNDER TEST: Triggering "View nested graph" mounts the step detail panel // showing the nested workflow. -test.afterEach(async () => { - await resetStorage(); -}); +test.describe('Workflow nested graph', () => { + test.afterEach(async () => { + await resetStorage(); + }); -test.beforeEach(async ({ page }) => { - await page.goto('/workflows/complexWorkflow/graph'); -}); + test.beforeEach(async ({ page }) => { + await page.goto('/workflows/complexWorkflow/graph'); + }); -test('opens the nested graph view', async ({ page }) => { - const nestedNode = page.locator('[data-workflow-node]').filter({ hasText: 'nested-text-processor' }); - await expect(nestedNode).toBeVisible(); + test.describe('when "View nested graph" is selected on a nested step', () => { + test('opens the nested graph view in the step detail panel', async ({ page }) => { + const nestedNode = page.locator('[data-workflow-node]').filter({ hasText: 'nested-text-processor' }); + await expect(nestedNode).toBeVisible(); - await nestedNode.getByRole('button', { name: 'Step actions' }).click(); - await page.getByRole('menuitem', { name: 'View nested graph' }).click(); + await nestedNode.getByRole('button', { name: 'Step actions' }).click(); + await page.getByRole('menuitem', { name: 'View nested graph' }).click(); - const panel = page.getByTestId('workflow-step-detail-panel'); - await expect(panel).toBeVisible({ timeout: 15000 }); - await expect(panel).toContainText('Workflow'); + const panel = page.getByTestId('workflow-step-detail-panel'); + await expect(panel).toBeVisible({ timeout: 15000 }); + await expect(panel).toContainText('Workflow'); + }); + }); }); diff --git a/packages/playground/e2e/tests/workflows/$workflowId/page.spec.ts b/packages/playground/e2e/tests/workflows/$workflowId/page.spec.ts index 8a69dd66ac06..ded5c935e94d 100644 --- a/packages/playground/e2e/tests/workflows/$workflowId/page.spec.ts +++ b/packages/playground/e2e/tests/workflows/$workflowId/page.spec.ts @@ -1,127 +1,144 @@ -import { test, expect, Page } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; import { resetStorage } from '../../__utils__/reset-storage'; import { expectRouteDocsLink } from '../../__utils__/route-header'; -test.afterEach(async () => { - await resetStorage(); -}); - -test.beforeEach(async ({ page }) => { - await page.goto('/workflows/complexWorkflow/graph'); -}); - -test('overall layout information', async ({ page }) => { - // Header - await expect(page).toHaveTitle(/Mastra Studio/); - await expectRouteDocsLink(page, 'Workflows documentation', 'https://mastra.ai/en/docs/workflows/overview'); - const breadcrumb = page.locator('header>nav'); - expect(breadcrumb).toMatchAriaSnapshot(); - - // Information side panel - await expect(page.getByText('complex-workflow').first()).toBeVisible(); - await expect(page.getByRole('combobox').filter({ hasText: 'complex-workflow' })).toBeVisible(); - await expect(page.getByRole('radio', { name: 'Form' })).toBeChecked(); - await expect(page.getByRole('radio', { name: 'JSON' })).not.toBeChecked(); - - // Shows the dynamic form when FORM is selected (default) - await expect(page.getByRole('textbox', { name: 'Text' })).toBeVisible(); - await expect(getRunButton(page)).toBeVisible(); - - // Shows the JSON input when JSON is selected - await page.getByRole('radio', { name: 'JSON' }).click(); - const codeEditor = await page.locator('[contenteditable="true"]'); - await expect(codeEditor).toBeVisible(); - await expect(codeEditor).toHaveText('{}'); - await expect(codeEditor).toHaveAttribute('data-language', 'json'); -}); - -test('initial workflow run state', async ({ page }) => { - const nodes = await page.locator('[data-workflow-node]'); - await expect(nodes).toHaveCount(14); - - // Check node ordering - await expect(nodes.nth(0)).toContainText('add-letter'); - await expect(nodes.nth(1)).toContainText('add-letter-b'); - await expect(nodes.nth(2)).toContainText('add-letter-c'); - await expect(nodes.nth(3).getByRole('img', { name: 'Map step' })).toBeVisible(); - await expect(nodes.nth(4).getByRole('img', { name: 'When condition' })).toBeVisible(); - await expect(nodes.nth(5)).toContainText('short-text'); // condition short path - await expect(nodes.nth(6).getByRole('img', { name: 'When condition' })).toBeVisible(); - await expect(nodes.nth(7)).toContainText('long-text'); // condition long path - await expect(nodes.nth(8).getByRole('img', { name: 'Map step' })).toBeVisible(); - await expect(nodes.nth(9)).toContainText('nested-text-processor'); - await expect(nodes.nth(10)).toContainText('add-letter-with-count'); - await expect(nodes.nth(11).getByRole('img', { name: 'Do until condition' })).toBeVisible(); - await expect(nodes.nth(12)).toContainText('suspend-resume'); - await expect(nodes.nth(13)).toContainText('final-step'); -}); - -test('running the workflow (form) - short condition', async ({ page }) => { - await page.getByRole('textbox', { name: 'Text' }).fill('A'); - await getRunButton(page).click(); - - await runWorkflow(page); - await checkShortPath(page); -}); - -test('running the workflow (form) - long condition', async ({ page }) => { - await page.getByRole('textbox', { name: 'Text' }).fill('SuperLongTextToStartWith'); - await getRunButton(page).click(); - - await runWorkflow(page); - await checkLongPath(page); -}); - -test('running the workflow (json) - short condition', async ({ page }) => { - await page.getByRole('radio', { name: 'JSON' }).click(); - await page.locator('.cm-content').fill('{"text":"A"}'); - await getRunButton(page).click(); - - await runWorkflow(page); - await checkShortPath(page); -}); - -test('running the workflow (json) - long condition', async ({ page }) => { - await page.getByRole('radio', { name: 'JSON' }).click(); - await page.locator('.cm-content').fill('{"text":"SuperLongTextToStartWith"}'); - await getRunButton(page).click(); - - await runWorkflow(page); - await checkLongPath(page); -}); - -test('running a workflow with an enum input uses the selected form value', async ({ page }) => { - // FEATURE: Workflow enum input forms - // USER STORY: As a Studio user, I want enum dropdown choices to update run input so workflows execute with my selection. - // BEHAVIOR UNDER TEST: Selecting a non-default enum option persists in the form and reaches the workflow output. - await page.goto('/workflows/enumWorkflow/graph'); - - await page.getByRole('combobox', { name: 'Mode' }).click(); - await page.getByRole('option', { name: 'b' }).click(); - - await expect(page.getByRole('combobox', { name: 'Mode' })).toContainText('b'); - - await getRunButton(page).click(); - - const nodes = page.locator('[data-workflow-node]'); - await expect(nodes.nth(0)).toHaveAttribute('data-workflow-step-status', 'success', { timeout: 20000 }); - - await page.getByRole('button', { name: 'Run output' }).click(); - await expect(page.getByRole('dialog')).toContainText('"mode": "b"'); -}); - -test('resuming a workflow', async ({ page }) => { - await page.getByRole('textbox', { name: 'Text' }).fill('A'); - await getRunButton(page).click(); - await runWorkflow(page); - - const suspendedSteps = page.getByTestId('workflow-suspended-steps'); - await suspendedSteps.getByRole('textbox', { name: 'User Input' }).fill('Hello'); - await suspendedSteps.getByRole('button', { name: 'Resume' }).click(); - const nodes = await page.locator('[data-workflow-node]'); - - await expect(nodes.nth(12)).toHaveAttribute('data-workflow-step-status', 'success', { timeout: 20000 }); - await expect(nodes.nth(13)).toHaveAttribute('data-workflow-step-status', 'success'); +test.describe('Workflow graph detail page', () => { + test.afterEach(async () => { + await resetStorage(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto('/workflows/complexWorkflow/graph'); + }); + + test.describe('when the complex-workflow graph is opened', () => { + test('overall layout information', async ({ page }) => { + // Header + await expect(page).toHaveTitle(/Mastra Studio/); + await expectRouteDocsLink(page, 'Workflows documentation', 'https://mastra.ai/en/docs/workflows/overview'); + const breadcrumb = page.locator('header>nav'); + await expect(breadcrumb).toMatchAriaSnapshot(); + + // Information side panel + await expect(page.getByText('complex-workflow').first()).toBeVisible(); + await expect(page.getByRole('combobox').filter({ hasText: 'complex-workflow' })).toBeVisible(); + await expect(page.getByRole('radio', { name: 'Form' })).toBeChecked(); + await expect(page.getByRole('radio', { name: 'JSON' })).not.toBeChecked(); + + // Shows the dynamic form when FORM is selected (default) + await expect(page.getByRole('textbox', { name: 'Text' })).toBeVisible(); + await expect(getRunButton(page)).toBeVisible(); + + // Shows the JSON input when JSON is selected + await page.getByRole('radio', { name: 'JSON' }).click(); + const codeEditor = await page.locator('[contenteditable="true"]'); + await expect(codeEditor).toBeVisible(); + await expect(codeEditor).toHaveText('{}'); + await expect(codeEditor).toHaveAttribute('data-language', 'json'); + }); + + test('initial workflow run state', async ({ page }) => { + const nodes = await page.locator('[data-workflow-node]'); + await expect(nodes).toHaveCount(14); + + // Check node ordering + await expect(nodes.nth(0)).toContainText('add-letter'); + await expect(nodes.nth(1)).toContainText('add-letter-b'); + await expect(nodes.nth(2)).toContainText('add-letter-c'); + await expect(nodes.nth(3).getByRole('img', { name: 'Map step' })).toBeVisible(); + await expect(nodes.nth(4).getByRole('img', { name: 'When condition' })).toBeVisible(); + await expect(nodes.nth(5)).toContainText('short-text'); // condition short path + await expect(nodes.nth(6).getByRole('img', { name: 'When condition' })).toBeVisible(); + await expect(nodes.nth(7)).toContainText('long-text'); // condition long path + await expect(nodes.nth(8).getByRole('img', { name: 'Map step' })).toBeVisible(); + await expect(nodes.nth(9)).toContainText('nested-text-processor'); + await expect(nodes.nth(10)).toContainText('add-letter-with-count'); + await expect(nodes.nth(11).getByRole('img', { name: 'Do until condition' })).toBeVisible(); + await expect(nodes.nth(12)).toContainText('suspend-resume'); + await expect(nodes.nth(13)).toContainText('final-step'); + }); + }); + + test.describe('when the workflow is run via the form with a short-condition input', () => { + test('takes the short path and suspends for user input', async ({ page }) => { + await page.getByRole('textbox', { name: 'Text' }).fill('A'); + await getRunButton(page).click(); + + await runWorkflow(page); + await checkShortPath(page); + }); + }); + + test.describe('when the workflow is run via the form with a long-condition input', () => { + test('takes the long path and suspends for user input', async ({ page }) => { + await page.getByRole('textbox', { name: 'Text' }).fill('SuperLongTextToStartWith'); + await getRunButton(page).click(); + + await runWorkflow(page); + await checkLongPath(page); + }); + }); + + test.describe('when the workflow is run via JSON with a short-condition input', () => { + test('takes the short path and suspends for user input', async ({ page }) => { + await page.getByRole('radio', { name: 'JSON' }).click(); + await page.locator('.cm-content').fill('{"text":"A"}'); + await getRunButton(page).click(); + + await runWorkflow(page); + await checkShortPath(page); + }); + }); + + test.describe('when the workflow is run via JSON with a long-condition input', () => { + test('takes the long path and suspends for user input', async ({ page }) => { + await page.getByRole('radio', { name: 'JSON' }).click(); + await page.locator('.cm-content').fill('{"text":"SuperLongTextToStartWith"}'); + await getRunButton(page).click(); + + await runWorkflow(page); + await checkLongPath(page); + }); + }); + + test.describe('when a workflow with an enum input is run with a selected option', () => { + test('uses the selected form value in the workflow output', async ({ page }) => { + // FEATURE: Workflow enum input forms + // USER STORY: As a Studio user, I want enum dropdown choices to update run input so workflows execute with my selection. + // BEHAVIOR UNDER TEST: Selecting a non-default enum option persists in the form and reaches the workflow output. + await page.goto('/workflows/enumWorkflow/graph'); + + await page.getByRole('combobox', { name: 'Mode' }).click(); + await page.getByRole('option', { name: 'b' }).click(); + + await expect(page.getByRole('combobox', { name: 'Mode' })).toContainText('b'); + + await getRunButton(page).click(); + + const nodes = page.locator('[data-workflow-node]'); + await expect(nodes.nth(0)).toHaveAttribute('data-workflow-step-status', 'success', { timeout: 20000 }); + + await page.getByRole('button', { name: 'Run output' }).click(); + await expect(page.getByRole('dialog')).toContainText('"mode": "b"'); + }); + }); + + test.describe('when a suspended workflow is resumed with user input', () => { + test('completes the suspended and final steps', async ({ page }) => { + await page.getByRole('textbox', { name: 'Text' }).fill('A'); + await getRunButton(page).click(); + await runWorkflow(page); + + const suspendedSteps = page.getByTestId('workflow-suspended-steps'); + await suspendedSteps.getByRole('textbox', { name: 'User Input' }).fill('Hello'); + await suspendedSteps.getByRole('button', { name: 'Resume' }).click(); + const nodes = await page.locator('[data-workflow-node]'); + + await expect(nodes.nth(12)).toHaveAttribute('data-workflow-step-status', 'success', { timeout: 20000 }); + await expect(nodes.nth(13)).toHaveAttribute('data-workflow-step-status', 'success'); + }); + }); }); function getRunButton(page: Page) { diff --git a/packages/playground/e2e/tests/workflows/$workflowId/page.spec.ts-snapshots/Workflow-graph-detail-page-when-the-complex-workflow-graph-is-opened-overall-layout-information-1.aria.yml b/packages/playground/e2e/tests/workflows/$workflowId/page.spec.ts-snapshots/Workflow-graph-detail-page-when-the-complex-workflow-graph-is-opened-overall-layout-information-1.aria.yml new file mode 100644 index 000000000000..589f902a6c31 --- /dev/null +++ b/packages/playground/e2e/tests/workflows/$workflowId/page.spec.ts-snapshots/Workflow-graph-detail-page-when-the-complex-workflow-graph-is-opened-overall-layout-information-1.aria.yml @@ -0,0 +1,13 @@ +- navigation "Breadcrumb": + - list: + - listitem: + - link "Workflows": + - /url: /workflows + - img + - text: '' + - separator: + - img + - listitem: + - combobox: + - text: complex-workflow + - img diff --git a/packages/playground/e2e/tests/workflows/$workflowId/page.spec.ts-snapshots/overall-layout-information-1.aria.yml b/packages/playground/e2e/tests/workflows/$workflowId/page.spec.ts-snapshots/overall-layout-information-1.aria.yml deleted file mode 100644 index 16ddb5779f67..000000000000 --- a/packages/playground/e2e/tests/workflows/$workflowId/page.spec.ts-snapshots/overall-layout-information-1.aria.yml +++ /dev/null @@ -1,6 +0,0 @@ -- navigation: - - list: - - listitem: - - link "Workflows": - - /url: /workflows - - img diff --git a/packages/playground/e2e/tests/workflows/page.spec.ts b/packages/playground/e2e/tests/workflows/page.spec.ts index ce8c52437b49..790823f22e18 100644 --- a/packages/playground/e2e/tests/workflows/page.spec.ts +++ b/packages/playground/e2e/tests/workflows/page.spec.ts @@ -5,22 +5,28 @@ test.afterEach(async () => { await resetStorage(); }); -test('has valid links', async ({ page }) => { - await page.goto('/workflows'); +test.describe('Workflows list page', () => { + test.describe('when the complex-workflow link is clicked', () => { + test('navigates to the complex-workflow graph page', async ({ page }) => { + await page.goto('/workflows'); - const el = page.locator('text=complex-workflow'); - await el.click(); + const el = page.locator('text=complex-workflow'); + await el.click(); - await expect(page).toHaveURL(/\/workflows\/complexWorkflow\/graph$/); - await expect(page.getByText('complex-workflow').first()).toBeVisible(); -}); + await expect(page).toHaveURL(/\/workflows\/complexWorkflow\/graph$/); + await expect(page.getByText('complex-workflow').first()).toBeVisible(); + }); + }); -test('clicking on the complex-workflow row redirects', async ({ page }) => { - await page.goto('/workflows'); + test.describe('when the complex-workflow row is clicked', () => { + test('navigates to the complex-workflow graph page', async ({ page }) => { + await page.goto('/workflows'); - const el = page.locator('.data-list-row:has-text("complex-workflow")'); - await el.click(); + const el = page.locator('.data-list-row:has-text("complex-workflow")'); + await el.click(); - await expect(page).toHaveURL(/\/workflows\/complexWorkflow\/graph$/); - await expect(page.getByText('complex-workflow').first()).toBeVisible(); + await expect(page).toHaveURL(/\/workflows\/complexWorkflow\/graph$/); + await expect(page.getByText('complex-workflow').first()).toBeVisible(); + }); + }); }); diff --git a/packages/playground/e2e/tests/workflows/schedules.spec.ts b/packages/playground/e2e/tests/workflows/schedules.spec.ts index d5874cc8da16..0ae17149edf6 100644 --- a/packages/playground/e2e/tests/workflows/schedules.spec.ts +++ b/packages/playground/e2e/tests/workflows/schedules.spec.ts @@ -18,116 +18,137 @@ test.afterEach(async () => { await resetStorage(); }); -test('schedules link on /workflows navigates to dedicated /workflows/schedules route', async ({ page }) => { - await page.goto('/workflows'); - - // The Schedules entry point is rendered next to the docs button on the workflows index. - const schedulesLink = page.getByRole('link', { name: /Schedules/ }); - await expect(schedulesLink.first()).toBeVisible(); - - await schedulesLink.first().click(); - - await expect(page).toHaveURL(/\/workflows\/schedules$/); -}); - -test('/workflows/schedules lists every declared schedule across workflows', async ({ page }) => { - await page.goto('/workflows/schedules'); - - // Both single-form and array-form schedules from kitchen-sink should be listed. - // scheduledWorkflow contributes 1 row, multiScheduledWorkflow contributes 2. - await expect(page.locator('text=scheduledWorkflow').first()).toBeVisible(); - await expect(page.locator('text=multiScheduledWorkflow').first()).toBeVisible(); - - // Array-form schedule ids (morning / evening) must each render a row. - await expect(page.locator('text=morning').first()).toBeVisible(); - await expect(page.locator('text=evening').first()).toBeVisible(); - - // Cron expressions render so the user can confirm the schedule. - await expect(page.locator('text=0 9 * * *').first()).toBeVisible(); - await expect(page.locator('text=0 8 * * *').first()).toBeVisible(); - await expect(page.locator('text=0 20 * * *').first()).toBeVisible(); -}); - -test('/workflows/schedules is deep-linkable and survives reload', async ({ page }) => { - await page.goto('/workflows/schedules'); - await expect(page.locator('text=scheduledWorkflow').first()).toBeVisible(); - - await page.reload(); - - await expect(page).toHaveURL(/\/workflows\/schedules$/); - await expect(page.locator('text=scheduledWorkflow').first()).toBeVisible(); -}); - -test('per-workflow schedules sub-route filters rows to that workflow', async ({ page }) => { - await page.goto('/workflows/schedules?workflowId=multiScheduledWorkflow'); - - // The two schedules declared by multiScheduledWorkflow are shown. - await expect(page.locator('text=morning').first()).toBeVisible(); - await expect(page.locator('text=evening').first()).toBeVisible(); - - // The unrelated single-form schedule is filtered out. - await expect(page.locator('text=scheduledWorkflow__default')).toHaveCount(0); -}); - -test('clicking a schedule row navigates to the dedicated schedule detail page', async ({ page }) => { - await page.goto('/workflows/schedules'); - - // Click the row for the single-form schedule (whose id contains scheduledWorkflow). - await page.locator('text=scheduledWorkflow').first().click(); - - await expect(page).toHaveURL(/\/workflows\/schedules\/[^/]+$/); - - // Detail page renders trigger history panel + a back link to the schedules list. - await expect(page.getByTestId('schedule-triggers-panel')).toBeVisible(); - await expect(page.getByRole('link', { name: /Back to schedules/ })).toBeVisible(); -}); - -test('pausing a schedule from the detail page persists across reload', async ({ page }) => { - await page.goto('/workflows/schedules'); - - // Open the detail page for the single-form schedule. - await page.locator('text=scheduledWorkflow').first().click(); - await expect(page).toHaveURL(/\/workflows\/schedules\/[^/]+$/); - - const toggle = page.getByTestId('schedule-toggle-button'); - await expect(toggle).toContainText(/Pause/); - - await toggle.click(); - - // After pausing the button label flips to Resume and stays that way after reload — - // proves the status was actually persisted to storage, not just toggled in UI state. - await expect(toggle).toContainText(/Resume/); - await page.reload(); - await expect(page.getByTestId('schedule-toggle-button')).toContainText(/Resume/); - - // Resume the schedule. Button label flips back, persists across reload. - await page.getByTestId('schedule-toggle-button').click(); - await expect(page.getByTestId('schedule-toggle-button')).toContainText(/Pause/); - await page.reload(); - await expect(page.getByTestId('schedule-toggle-button')).toContainText(/Pause/); -}); - -test('workflow header shows Schedules link only when the workflow has schedules', async ({ page }) => { - await page.goto('/workflows/scheduledWorkflow/graph'); - - // scheduledWorkflow has exactly one schedule, so the header link goes straight - // to that schedule's detail page (smart routing: 1 schedule → detail page). - const scheduledHeaderLink = page.getByRole('link', { name: /Schedules/ }); - await expect(scheduledHeaderLink).toBeVisible(); - - await scheduledHeaderLink.click(); - await expect(page).toHaveURL(/\/workflows\/schedules\/[^/]+$/); - - // multiScheduledWorkflow has 2 schedules, so the header link goes to the - // filtered list view (?workflowId=...) instead of a detail page. - await page.goto('/workflows/multiScheduledWorkflow/graph'); - const multiHeaderLink = page.getByRole('link', { name: /Schedules/ }); - await expect(multiHeaderLink).toBeVisible(); - - await multiHeaderLink.click(); - await expect(page).toHaveURL(/\/workflows\/schedules\?workflowId=multiScheduledWorkflow$/); - - // A workflow without any declared schedules should NOT render the link. - await page.goto('/workflows/complexWorkflow/graph'); - await expect(page.getByRole('link', { name: /Schedules/ })).toHaveCount(0); +test.describe('Workflow schedules', () => { + test.describe('when the Schedules link on /workflows is clicked', () => { + test('navigates to the dedicated /workflows/schedules route', async ({ page }) => { + await page.goto('/workflows'); + + // The Schedules entry point is rendered next to the docs button on the workflows index. + const schedulesLink = page.getByRole('link', { name: /Schedules/ }); + await expect(schedulesLink.first()).toBeVisible(); + + await schedulesLink.first().click(); + + await expect(page).toHaveURL(/\/workflows\/schedules$/); + }); + }); + + test.describe('when the /workflows/schedules route is visited', () => { + test('lists every declared schedule across workflows', async ({ page }) => { + await page.goto('/workflows/schedules'); + + // Both single-form and array-form schedules from kitchen-sink should be listed. + // scheduledWorkflow contributes 1 row, multiScheduledWorkflow contributes 2. + await expect(page.locator('text=scheduledWorkflow').first()).toBeVisible(); + await expect(page.locator('text=multiScheduledWorkflow').first()).toBeVisible(); + + // Array-form schedule ids (morning / evening) must each render a row. + await expect(page.locator('text=morning').first()).toBeVisible(); + await expect(page.locator('text=evening').first()).toBeVisible(); + + // Cron expressions render so the user can confirm the schedule. + await expect(page.locator('text=0 9 * * *').first()).toBeVisible(); + await expect(page.locator('text=0 8 * * *').first()).toBeVisible(); + await expect(page.locator('text=0 20 * * *').first()).toBeVisible(); + }); + }); + + test.describe('when the /workflows/schedules route is reloaded', () => { + test('is deep-linkable and survives reload', async ({ page }) => { + await page.goto('/workflows/schedules'); + await expect(page.locator('text=scheduledWorkflow').first()).toBeVisible(); + + await page.reload(); + + await expect(page).toHaveURL(/\/workflows\/schedules$/); + await expect(page.locator('text=scheduledWorkflow').first()).toBeVisible(); + }); + }); + + test.describe('when a per-workflow schedules sub-route is visited', () => { + test('filters rows to that workflow', async ({ page }) => { + await page.goto('/workflows/schedules?workflowId=multiScheduledWorkflow'); + + // The two schedules declared by multiScheduledWorkflow are shown. + await expect(page.locator('text=morning').first()).toBeVisible(); + await expect(page.locator('text=evening').first()).toBeVisible(); + + // The unrelated single-form schedule is filtered out. + await expect(page.locator('text=scheduledWorkflow__default')).toHaveCount(0); + }); + }); + + test.describe('when a schedule row is clicked', () => { + test('navigates to the dedicated schedule detail page', async ({ page }) => { + await page.goto('/workflows/schedules'); + + // Click the row for the single-form schedule (whose id contains scheduledWorkflow). + await page.locator('text=scheduledWorkflow').first().click(); + + await expect(page).toHaveURL(/\/workflows\/schedules\/[^/]+$/); + + // Detail page renders trigger history panel + a back link to the schedules list. + await expect(page.getByTestId('schedule-triggers-panel')).toBeVisible(); + await expect(page.getByRole('link', { name: /Back to schedules/ })).toBeVisible(); + }); + }); + + test.describe('when a schedule is paused from the detail page', () => { + test('persists the paused state across reload', async ({ page }) => { + await page.goto('/workflows/schedules'); + + // Open the detail page for the single-form schedule. + await page.locator('text=scheduledWorkflow').first().click(); + await expect(page).toHaveURL(/\/workflows\/schedules\/[^/]+$/); + + const toggle = page.getByTestId('schedule-toggle-button'); + await expect(toggle).toContainText(/Pause/); + + await toggle.click(); + + // After pausing the button label flips to Resume and stays that way after reload — + // proves the status was actually persisted to storage, not just toggled in UI state. + await expect(toggle).toContainText(/Resume/); + await page.reload(); + await expect(page.getByTestId('schedule-toggle-button')).toContainText(/Resume/); + + // Resume the schedule. Button label flips back, persists across reload. + await page.getByTestId('schedule-toggle-button').click(); + await expect(page.getByTestId('schedule-toggle-button')).toContainText(/Pause/); + await page.reload(); + await expect(page.getByTestId('schedule-toggle-button')).toContainText(/Pause/); + }); + }); + + test.describe('when a workflow graph has one schedule', () => { + test('routes the Schedules link to the schedule detail page', async ({ page }) => { + await page.goto('/workflows/scheduledWorkflow/graph'); + + // scheduledWorkflow has exactly one schedule, so the header link goes straight + // to that schedule's detail page (smart routing: 1 schedule → detail page). + const scheduledHeaderLink = page.getByRole('link', { name: /Schedules/ }); + await expect(scheduledHeaderLink).toBeVisible(); + + await scheduledHeaderLink.click(); + await expect(page).toHaveURL(/\/workflows\/schedules\/[^/]+$/); + }); + }); + + test.describe('when a workflow graph has multiple schedules', () => { + test('routes the Schedules link to the filtered schedules list', async ({ page }) => { + await page.goto('/workflows/multiScheduledWorkflow/graph'); + const multiHeaderLink = page.getByRole('link', { name: /Schedules/ }); + await expect(multiHeaderLink).toBeVisible(); + + await multiHeaderLink.click(); + await expect(page).toHaveURL(/\/workflows\/schedules\?workflowId=multiScheduledWorkflow$/); + }); + }); + + test.describe('when a workflow graph has no schedules', () => { + test('does not render the Schedules link', async ({ page }) => { + await page.goto('/workflows/complexWorkflow/graph'); + await expect(page.getByRole('link', { name: /Schedules/ })).toHaveCount(0); + }); + }); }); diff --git a/packages/playground/eslint.config.js b/packages/playground/eslint.config.js index 8493b51f08c8..a3e2439d609f 100644 --- a/packages/playground/eslint.config.js +++ b/packages/playground/eslint.config.js @@ -766,6 +766,34 @@ const restrictedPlaygroundUiBarrelImportSpecifiers = [ ]), ); +const PLAYGROUND_UI_ROOT_IMPORT_MESSAGE = + 'Import from an exact @mastra/playground-ui subpath instead of the root barrel.'; + +const restrictedPlaygroundUiRootSelectors = [ + { + selector: 'ImportDeclaration[source.value="@mastra/playground-ui"]', + message: PLAYGROUND_UI_ROOT_IMPORT_MESSAGE, + }, + { + selector: 'ExportNamedDeclaration[source.value="@mastra/playground-ui"]', + message: PLAYGROUND_UI_ROOT_IMPORT_MESSAGE, + }, + { + selector: 'ExportAllDeclaration[source.value="@mastra/playground-ui"]', + message: PLAYGROUND_UI_ROOT_IMPORT_MESSAGE, + }, + { + selector: + 'CallExpression[callee.object.name="vi"][callee.property.name="mock"] > Literal[value="@mastra/playground-ui"]:first-child', + message: PLAYGROUND_UI_ROOT_IMPORT_MESSAGE, + }, + { + selector: + 'CallExpression[callee.object.name="vi"][callee.property.name="importActual"] > Literal[value="@mastra/playground-ui"]:first-child', + message: PLAYGROUND_UI_ROOT_IMPORT_MESSAGE, + }, +]; + // Enforce the playground testing contract (packages/playground/AGENTS.md + the // `playground-msw-tests` skill): drive the real @mastra/client-js + React Query // stack and ONLY mock the network. Mocking our own data hooks/services/auth @@ -797,6 +825,104 @@ const prohibitedMockModulePatterns = [ '^@mastra\\/react$', ]; +// Enforce the Playwright E2E BDD shape, including modifier forms like `test.skip('...')`. +const E2E_BDD_MESSAGE = + "E2E BDD: every test()/it() must live inside a test.describe('when …') precondition block. " + + "Outer test.describe = the unit, inner test.describe('when …') = ONE precondition, each test = ONE outcome. " + + 'See the e2e-tests-studio skill.'; + +const testFunctionNames = new Set(['test', 'it']); +const testDeclarationModifiers = new Set(['skip', 'only', 'fixme', 'fail', 'slow']); + +function isStaticTestTitle(node) { + return ( + (node.type === 'Literal' && typeof node.value === 'string') || + (node.type === 'TemplateLiteral' && node.quasis.length >= 1) + ); +} + +function isTestDeclarationCall(node) { + if (node.type !== 'CallExpression') return false; + + const callee = node.callee; + if (callee.type === 'Identifier' && testFunctionNames.has(callee.name)) return true; + + if ( + callee.type === 'MemberExpression' && + callee.property.type === 'Identifier' && + testDeclarationModifiers.has(callee.property.name) && + callee.object.type === 'Identifier' && + testFunctionNames.has(callee.object.name) + ) { + // Guard-style annotations like `test.skip(true, 'reason')` do not declare test cases. + return isStaticTestTitle(node.arguments[0]); + } + + return false; +} + +/** True when a CallExpression is a `describe(...)`, `test.describe(...)`, or `it.describe(...)` call. */ +function isDescribeCall(node) { + if (node.type !== 'CallExpression') return false; + const callee = node.callee; + if (callee.type === 'Identifier' && callee.name === 'describe') return true; + if ( + callee.type === 'MemberExpression' && + callee.property.type === 'Identifier' && + callee.property.name === 'describe' && + callee.object.type === 'Identifier' && + (callee.object.name === 'test' || callee.object.name === 'it') + ) { + return true; + } + return false; +} + +/** + * Extract the leading static text of a describe() first argument, or null. + * For template literals with interpolation (e.g. `when the ${name} …`) we only + * need the leading static quasi to verify the title starts with "when". + */ +function describeTitle(node) { + const arg = node.arguments[0]; + if (!arg) return null; + if (arg.type === 'Literal' && typeof arg.value === 'string') return arg.value; + if (arg.type === 'TemplateLiteral' && arg.quasis.length >= 1) return arg.quasis[0].value.cooked; + return null; +} + +const e2eBddPlugin = { + rules: { + 'test-needs-when-describe': { + meta: { + type: 'problem', + docs: { description: 'Require test()/it() to be nested in a describe("when …") block.' }, + schema: [], + }, + create(context) { + return { + CallExpression(node) { + if (!isTestDeclarationCall(node)) return; + // Walk ancestors to find the nearest enclosing describe. + const ancestors = context.sourceCode.getAncestors(node); + let nearestDescribe = null; + for (let i = ancestors.length - 1; i >= 0; i--) { + if (isDescribeCall(ancestors[i])) { + nearestDescribe = ancestors[i]; + break; + } + } + const title = nearestDescribe && describeTitle(nearestDescribe); + if (!nearestDescribe || title == null || !/^when\b/.test(title)) { + context.report({ node, message: E2E_BDD_MESSAGE }); + } + }, + }; + }, + }, + }, +}; + const restrictedTestMockSelectors = [ { selector: prohibitedMockModulePatterns @@ -819,7 +945,19 @@ const restrictedTestMockSelectors = [ /** @type {import("eslint").Linter.Config[]} */ export default [ - { ignores: ['e2e/**'] }, + // Only the Playwright spec files under e2e/tests are linted (for BDD + // structure enforcement below). The kitchen-sink app, test utils, config, + // scripts, and build output under e2e remain unlinted as before. + { + ignores: [ + 'e2e/kitchen-sink/**', + 'e2e/scripts/**', + 'e2e/playwright-report/**', + 'e2e/test-results/**', + 'e2e/playwright.config.ts', + 'e2e/tests/__utils__/**', + ], + }, ...config, { plugins: { @@ -830,7 +968,11 @@ export default [ 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn', 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], - 'no-restricted-syntax': ['error', ...restrictedPlaygroundUiBarrelImportSpecifiers], + 'no-restricted-syntax': [ + 'error', + ...restrictedPlaygroundUiRootSelectors, + ...restrictedPlaygroundUiBarrelImportSpecifiers, + ], }, }, { @@ -838,9 +980,33 @@ export default [ rules: { 'no-restricted-syntax': [ 'error', + ...restrictedPlaygroundUiRootSelectors, ...restrictedPlaygroundUiBarrelImportSpecifiers, ...restrictedTestMockSelectors, ], }, }, + { + // Playwright E2E specs: enforce the BDD structure described in the + // e2e-tests-studio skill (every test()/it() nested in a describe('when …')). + // These files are not part of the type-aware tsconfig program, so disable + // the TypeScript project service here and only run the syntactic BDD rule. + files: ['e2e/tests/**/*.spec.{js,jsx,ts,tsx}'], + languageOptions: { + parserOptions: { + projectService: false, + project: false, + }, + }, + plugins: { + 'e2e-bdd': e2eBddPlugin, + }, + rules: { + // These specs are not part of a type-aware tsconfig program, so disable + // the @typescript-eslint rules that require type information. + '@typescript-eslint/no-misused-promises': 'off', + '@typescript-eslint/no-floating-promises': 'off', + 'e2e-bdd/test-needs-when-describe': 'error', + }, + }, ]; diff --git a/packages/playground/package.json b/packages/playground/package.json index 8d8ea6b1d353..e1d6d2604960 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -1,7 +1,7 @@ { "name": "@internal/playground", "private": true, - "version": "1.16.1-alpha.2", + "version": "1.17.0-alpha.9", "type": "module", "main": "./src/exports.ts", "types": "./src/exports.ts", diff --git a/packages/playground/src/domains/agent-builder/components/agent-edit/__tests__/delete-agent-action.test.tsx b/packages/playground/src/domains/agent-builder/components/agent-edit/__tests__/delete-agent-action.test.tsx index 526b4e564bfe..abf4ef96adf0 100644 --- a/packages/playground/src/domains/agent-builder/components/agent-edit/__tests__/delete-agent-action.test.tsx +++ b/packages/playground/src/domains/agent-builder/components/agent-edit/__tests__/delete-agent-action.test.tsx @@ -1,4 +1,3 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { DropdownMenu } from '@mastra/playground-ui/components/DropdownMenu'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; import { MastraReactProvider } from '@mastra/react'; @@ -20,15 +19,9 @@ vi.mock('react-router', async () => { useNavigate: () => navigate, }; }); - -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - usePlaygroundStore: () => ({ requestContext: undefined }), - }; -}); +vi.mock('@mastra/playground-ui/store/playground-store', () => ({ + usePlaygroundStore: () => ({ requestContext: undefined }), +})); vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, diff --git a/packages/playground/src/domains/agent-builder/components/agent-edit/__tests__/publish-channel-dialogs.test.tsx b/packages/playground/src/domains/agent-builder/components/agent-edit/__tests__/publish-channel-dialogs.test.tsx index 6c0088b3ef9b..2a6e550ca9cd 100644 --- a/packages/playground/src/domains/agent-builder/components/agent-edit/__tests__/publish-channel-dialogs.test.tsx +++ b/packages/playground/src/domains/agent-builder/components/agent-edit/__tests__/publish-channel-dialogs.test.tsx @@ -1,4 +1,3 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -12,17 +11,6 @@ import { server } from '@/test/msw-server'; const toastSuccessMock = vi.fn(); const toastErrorMock = vi.fn(); -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { - success: (...args: unknown[]) => toastSuccessMock(...args), - error: (...args: unknown[]) => toastErrorMock(...args), - }, - }; -}); - vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: (...args: unknown[]) => toastSuccessMock(...args), diff --git a/packages/playground/src/domains/agent-builder/components/agent-starter/__tests__/agent-builder-starter.test.tsx b/packages/playground/src/domains/agent-builder/components/agent-starter/__tests__/agent-builder-starter.test.tsx index c4ba93cf69df..cfa8a44d02c8 100644 --- a/packages/playground/src/domains/agent-builder/components/agent-starter/__tests__/agent-builder-starter.test.tsx +++ b/packages/playground/src/domains/agent-builder/components/agent-starter/__tests__/agent-builder-starter.test.tsx @@ -1,5 +1,5 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; +import { usePlaygroundStore } from '@mastra/playground-ui/store/playground-store'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'; @@ -20,16 +20,6 @@ vi.mock('react-router', async () => { useNavigate: () => navigateMock, }; }); - -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - usePlaygroundStore: () => ({ requestContext: undefined }), - }; -}); - vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); @@ -55,6 +45,7 @@ const renderStarter = () => { describe('AgentBuilderStarter', () => { beforeEach(() => { + usePlaygroundStore.setState({ requestContext: {} }); // The starter pulls builder settings + provider models so it can pick a // model that the admin policy allows. Stub the bare minimum: no policy and // an empty provider list, which yields the hard-coded fallback model. diff --git a/packages/playground/src/domains/agent-builder/components/skill-edit/__tests__/delete-skill-action.test.tsx b/packages/playground/src/domains/agent-builder/components/skill-edit/__tests__/delete-skill-action.test.tsx index 86c3f131d1d7..9307e023f6f6 100644 --- a/packages/playground/src/domains/agent-builder/components/skill-edit/__tests__/delete-skill-action.test.tsx +++ b/packages/playground/src/domains/agent-builder/components/skill-edit/__tests__/delete-skill-action.test.tsx @@ -1,4 +1,3 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { DropdownMenu } from '@mastra/playground-ui/components/DropdownMenu'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; import { MastraReactProvider } from '@mastra/react'; @@ -20,15 +19,9 @@ vi.mock('react-router', async () => { useNavigate: () => navigate, }; }); - -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - usePlaygroundStore: () => ({ requestContext: undefined }), - }; -}); +vi.mock('@mastra/playground-ui/store/playground-store', () => ({ + usePlaygroundStore: () => ({ requestContext: undefined }), +})); vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, diff --git a/packages/playground/src/domains/agent-builder/components/skill-starter/__tests__/skill-builder-starter.test.tsx b/packages/playground/src/domains/agent-builder/components/skill-starter/__tests__/skill-builder-starter.test.tsx index 6fb0acd66042..7557fb847c8e 100644 --- a/packages/playground/src/domains/agent-builder/components/skill-starter/__tests__/skill-builder-starter.test.tsx +++ b/packages/playground/src/domains/agent-builder/components/skill-starter/__tests__/skill-builder-starter.test.tsx @@ -1,5 +1,5 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; +import { usePlaygroundStore } from '@mastra/playground-ui/store/playground-store'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'; @@ -19,16 +19,6 @@ vi.mock('react-router', async () => { useNavigate: () => navigateMock, }; }); - -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - usePlaygroundStore: () => ({ requestContext: undefined }), - }; -}); - vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); @@ -54,6 +44,7 @@ const renderStarter = () => { describe('SkillBuilderStarter', () => { beforeEach(() => { + usePlaygroundStore.setState({ requestContext: {} }); // The starter pulls builder settings + stored workspaces so it can choose a // default workspace. Stub both: builder enabled with no agent-workspace // pin, and an empty workspace list so workspaceId stays undefined. diff --git a/packages/playground/src/domains/agent-builder/hooks/__tests__/use-autosave-agent.test.tsx b/packages/playground/src/domains/agent-builder/hooks/__tests__/use-autosave-agent.test.tsx index 63dec9471e4c..859b745b55cc 100644 --- a/packages/playground/src/domains/agent-builder/hooks/__tests__/use-autosave-agent.test.tsx +++ b/packages/playground/src/domains/agent-builder/hooks/__tests__/use-autosave-agent.test.tsx @@ -1,4 +1,4 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; +import { usePlaygroundStore } from '@mastra/playground-ui/store/playground-store'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook, act, waitFor } from '@testing-library/react'; @@ -11,15 +11,6 @@ import { useAutosaveAgent } from '../use-autosave-agent'; import { authEnabledCapabilities } from './fixtures/auth'; import { server } from '@/test/msw-server'; -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - usePlaygroundStore: () => ({ requestContext: undefined }), - }; -}); - vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); @@ -84,6 +75,7 @@ const waitForCapabilitiesSettled = (queryClient: QueryClient) => describe('useAutosaveAgent', () => { beforeEach(() => { + usePlaygroundStore.setState({ requestContext: {} }); // The hook resolves a default visibility via the real auth-capabilities // query; drive it through MSW instead of mocking the hook. server.use(http.get(`${BASE_URL}/api/auth/capabilities`, () => HttpResponse.json(authEnabledCapabilities))); diff --git a/packages/playground/src/domains/agent-builder/hooks/__tests__/use-channel-connect-toast.test.tsx b/packages/playground/src/domains/agent-builder/hooks/__tests__/use-channel-connect-toast.test.tsx index b4f3f72c5033..2e1b03ffc74c 100644 --- a/packages/playground/src/domains/agent-builder/hooks/__tests__/use-channel-connect-toast.test.tsx +++ b/packages/playground/src/domains/agent-builder/hooks/__tests__/use-channel-connect-toast.test.tsx @@ -1,4 +1,3 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { cleanup, render } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -7,17 +6,6 @@ import { useChannelConnectToast } from '../use-channel-connect-toast'; const successMock = vi.fn(); const errorMock = vi.fn(); -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { - success: (...args: unknown[]) => successMock(...args), - error: (...args: unknown[]) => errorMock(...args), - }, - }; -}); - vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: (...args: unknown[]) => successMock(...args), diff --git a/packages/playground/src/domains/agent-builder/hooks/__tests__/use-copy-skill.test.tsx b/packages/playground/src/domains/agent-builder/hooks/__tests__/use-copy-skill.test.tsx index 1573ada1d8f1..1c4ef4b91d0a 100644 --- a/packages/playground/src/domains/agent-builder/hooks/__tests__/use-copy-skill.test.tsx +++ b/packages/playground/src/domains/agent-builder/hooks/__tests__/use-copy-skill.test.tsx @@ -1,4 +1,3 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; @@ -10,14 +9,6 @@ import { useCopySkill } from '../use-copy-skill'; import { makeStoredSkill } from './fixtures/stored-skills'; import { server } from '@/test/msw-server'; -vi.mock('@mastra/playground-ui', async importOriginal => { - const actual = await importOriginal<typeof PlaygroundUi>(); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - }; -}); - vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); diff --git a/packages/playground/src/domains/agents/components/__tests__/agent-chat-shell.msw.test.tsx b/packages/playground/src/domains/agents/components/__tests__/agent-chat-shell.msw.test.tsx index e5f2539ac31a..1b2b86f23b5d 100644 --- a/packages/playground/src/domains/agents/components/__tests__/agent-chat-shell.msw.test.tsx +++ b/packages/playground/src/domains/agents/components/__tests__/agent-chat-shell.msw.test.tsx @@ -1,5 +1,3 @@ -// @vitest-environment jsdom -import type * as PlaygroundUi from '@mastra/playground-ui'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -16,14 +14,6 @@ import { MemoryTimelineProvider } from '@/domains/agents/context/memory-timeline import { LinkComponentProvider } from '@/lib/framework'; import { server } from '@/test/msw-server'; -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - }; -}); - vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); diff --git a/packages/playground/src/domains/agents/components/__tests__/agent-page-tabs.msw.test.tsx b/packages/playground/src/domains/agents/components/__tests__/agent-page-tabs.msw.test.tsx index baf7d4eceb21..6a55aa5d2ac6 100644 --- a/packages/playground/src/domains/agents/components/__tests__/agent-page-tabs.msw.test.tsx +++ b/packages/playground/src/domains/agents/components/__tests__/agent-page-tabs.msw.test.tsx @@ -1,4 +1,3 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -14,14 +13,6 @@ import { v2Agent } from './fixtures/composer-model-settings'; import { LinkComponentProvider } from '@/lib/framework'; import { server } from '@/test/msw-server'; -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - }; -}); - vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); diff --git a/packages/playground/src/domains/agents/components/__tests__/resizable-layouts.test.tsx b/packages/playground/src/domains/agents/components/__tests__/resizable-layouts.test.tsx index 55750456c9a9..0482836f022c 100644 --- a/packages/playground/src/domains/agents/components/__tests__/resizable-layouts.test.tsx +++ b/packages/playground/src/domains/agents/components/__tests__/resizable-layouts.test.tsx @@ -1,4 +1,3 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { cleanup, render, screen, waitFor } from '@testing-library/react'; import type { ReactNode, Ref } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -73,19 +72,17 @@ vi.mock('../../context', async () => { }; }); -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); +vi.mock('@mastra/playground-ui/resize/collapsible-panel', () => ({ + CollapsiblePanel: ({ id, className, children }: { id?: string; className?: string; children: ReactNode }) => ( + <aside data-testid={`collapsible-${id}`} className={className}> + {children} + </aside> + ), +})); - return { - ...actual, - CollapsiblePanel: ({ id, className, children }: { id?: string; className?: string; children: ReactNode }) => ( - <aside data-testid={`collapsible-${id}`} className={className}> - {children} - </aside> - ), - PanelSeparator: () => <div data-testid="panel-separator" />, - }; -}); +vi.mock('@mastra/playground-ui/resize/separator', () => ({ + PanelSeparator: () => <div data-testid="panel-separator" />, +})); afterEach(() => { cleanup(); diff --git a/packages/playground/src/domains/agents/components/agent-channels/__tests__/agent-channels.msw.test.tsx b/packages/playground/src/domains/agents/components/agent-channels/__tests__/agent-channels.msw.test.tsx index ea81ae1df56f..e6ba9b05c32f 100644 --- a/packages/playground/src/domains/agents/components/agent-channels/__tests__/agent-channels.msw.test.tsx +++ b/packages/playground/src/domains/agents/components/agent-channels/__tests__/agent-channels.msw.test.tsx @@ -1,4 +1,3 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -18,14 +17,6 @@ import { AgentChannels } from '../agent-channels'; import { v2Agent } from '@/domains/agents/components/__tests__/fixtures/composer-model-settings'; import { server } from '@/test/msw-server'; -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - }; -}); - vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); diff --git a/packages/playground/src/domains/agents/components/agent-cms-blocks/agent-cms-blocks.stories.tsx b/packages/playground/src/domains/agents/components/agent-cms-blocks/agent-cms-blocks.stories.tsx index 496d70cb2e94..92b2dd72a51c 100644 --- a/packages/playground/src/domains/agents/components/agent-cms-blocks/agent-cms-blocks.stories.tsx +++ b/packages/playground/src/domains/agents/components/agent-cms-blocks/agent-cms-blocks.stories.tsx @@ -1,5 +1,5 @@ -import { complexSchema } from '@mastra/playground-ui'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; +import type { JsonSchema } from '@mastra/playground-ui/utils/json-schema'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; @@ -19,6 +19,49 @@ const meta: Meta<typeof AgentCMSBlocks> = { export default meta; type Story = StoryObj<typeof AgentCMSBlocks>; +const complexSchema: JsonSchema = { + type: 'object', + properties: { + user: { + type: 'object', + title: 'User', + properties: { + email: { type: 'string', title: 'Email' }, + roles: { + type: 'array', + title: 'Roles', + items: { + type: 'object', + properties: { + name: { type: 'string', title: 'Role Name' }, + permissions: { type: 'string', title: 'Permissions' }, + }, + }, + }, + address: { + type: 'object', + title: 'Address', + properties: { + street: { type: 'string', title: 'Street' }, + city: { type: 'string', title: 'City' }, + country: { type: 'string', title: 'Country' }, + zipCode: { type: 'string', title: 'Zip Code' }, + }, + }, + }, + }, + metadata: { + type: 'object', + title: 'Metadata', + properties: { + createdAt: { type: 'string', title: 'Created At' }, + updatedAt: { type: 'string', title: 'Updated At' }, + version: { type: 'number', title: 'Version' }, + }, + }, + }, +}; + const InteractiveExample = () => { const [items, setItems] = useState<Array<InstructionBlock>>([ createInstructionBlock('You are a helpful assistant that answers questions about programming.'), diff --git a/packages/playground/src/domains/agents/components/agent-layout.tsx b/packages/playground/src/domains/agents/components/agent-layout.tsx index b66c30b82449..e140b2153fdf 100644 --- a/packages/playground/src/domains/agents/components/agent-layout.tsx +++ b/packages/playground/src/domains/agents/components/agent-layout.tsx @@ -1,5 +1,6 @@ -import { PanelDrawer, PanelSeparator } from '@mastra/playground-ui'; import { useIsMobile } from '@mastra/playground-ui/hooks/use-is-mobile'; +import { PanelDrawer } from '@mastra/playground-ui/resize/panel-drawer'; +import { PanelSeparator } from '@mastra/playground-ui/resize/separator'; import { useEffect, useRef } from 'react'; import { Panel, useDefaultLayout, Group } from 'react-resizable-panels'; import type { PanelImperativeHandle } from 'react-resizable-panels'; diff --git a/packages/playground/src/domains/agents/components/memory-sidebar/memory-detail-view.tsx b/packages/playground/src/domains/agents/components/memory-sidebar/memory-detail-view.tsx index 073d84b5fd0b..59d620782edf 100644 --- a/packages/playground/src/domains/agents/components/memory-sidebar/memory-detail-view.tsx +++ b/packages/playground/src/domains/agents/components/memory-sidebar/memory-detail-view.tsx @@ -1,4 +1,6 @@ -import { MemoryStudioPanel, useMemoryThreadMessages, useObservationalMemory } from '@mastra/playground-ui'; +import { MemoryStudioPanel } from '@mastra/playground-ui/domains/memory/components/memory-studio-panel'; +import { useMemoryThreadMessages } from '@mastra/playground-ui/domains/memory/hooks/use-memory-thread-messages'; +import { useObservationalMemory } from '@mastra/playground-ui/domains/memory/hooks/use-observational-memory'; import { useEffect } from 'react'; import { getObservationWindowTokens } from './lib/observation-window'; diff --git a/packages/playground/src/domains/agents/hooks/__tests__/use-update-skill.test.tsx b/packages/playground/src/domains/agents/hooks/__tests__/use-update-skill.test.tsx index c2f3363ec3a9..2bbdc79d7a3a 100644 --- a/packages/playground/src/domains/agents/hooks/__tests__/use-update-skill.test.tsx +++ b/packages/playground/src/domains/agents/hooks/__tests__/use-update-skill.test.tsx @@ -1,4 +1,3 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, renderHook } from '@testing-library/react'; @@ -17,14 +16,6 @@ const { toastSuccess, toastError } = vi.hoisted(() => ({ toastError: vi.fn(), })); -vi.mock('@mastra/playground-ui', async importOriginal => { - const actual = await importOriginal<typeof PlaygroundUi>(); - return { - ...actual, - toast: { success: toastSuccess, error: toastError }, - }; -}); - vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: toastSuccess, error: toastError }, })); diff --git a/packages/playground/src/domains/auth/components/__tests__/login-page.test.tsx b/packages/playground/src/domains/auth/components/__tests__/login-page.test.tsx index 32383b648821..a31c3673ec2e 100644 --- a/packages/playground/src/domains/auth/components/__tests__/login-page.test.tsx +++ b/packages/playground/src/domains/auth/components/__tests__/login-page.test.tsx @@ -1,4 +1,3 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -11,15 +10,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { Login } from '@/pages/login'; import { SignUp } from '@/pages/signup'; import { server } from '@/test/msw-server'; - -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - usePlaygroundStore: () => ({ requestContext: undefined }), - }; -}); +vi.mock('@mastra/playground-ui/store/playground-store', () => ({ + usePlaygroundStore: () => ({ requestContext: undefined }), +})); vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, diff --git a/packages/playground/src/domains/datasets/components/__tests__/add-item-dialog.test.tsx b/packages/playground/src/domains/datasets/components/__tests__/add-item-dialog.test.tsx index f042482f0cb7..ee6932e6ffe0 100644 --- a/packages/playground/src/domains/datasets/components/__tests__/add-item-dialog.test.tsx +++ b/packages/playground/src/domains/datasets/components/__tests__/add-item-dialog.test.tsx @@ -4,7 +4,7 @@ import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; -import type { ButtonHTMLAttributes, ChangeEvent, PropsWithChildren } from 'react'; +import type { ChangeEvent, PropsWithChildren } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { AddItemDialog } from '../add-item-dialog'; @@ -13,20 +13,16 @@ import { server } from '@/test/msw-server'; const BASE_URL = 'http://localhost:4111'; -// Thin stubs for playground-ui atoms so this test focuses on the real client + mutation behavior. -vi.mock('@mastra/playground-ui', () => { +// Thin stub for the heavy Dialog atom so this test focuses on the real client + mutation behavior. +vi.mock('@mastra/playground-ui/components/Dialog', () => { const Dialog = ({ open, children }: PropsWithChildren<{ open: boolean }>) => (open ? <div>{children}</div> : null); return { - Button: ({ variant: _variant, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string }) => ( - <button {...props} /> - ), Dialog, DialogContent: ({ children }: PropsWithChildren) => <div>{children}</div>, DialogHeader: ({ children }: PropsWithChildren) => <div>{children}</div>, DialogTitle: ({ children }: PropsWithChildren) => <h2>{children}</h2>, DialogBody: ({ children }: PropsWithChildren) => <div>{children}</div>, - toast: { error: vi.fn(), success: vi.fn() }, }; }); diff --git a/packages/playground/src/domains/datasets/components/__tests__/create-dataset-from-items-dialog.test.tsx b/packages/playground/src/domains/datasets/components/__tests__/create-dataset-from-items-dialog.test.tsx index a45b7898066e..6a35457dab0f 100644 --- a/packages/playground/src/domains/datasets/components/__tests__/create-dataset-from-items-dialog.test.tsx +++ b/packages/playground/src/domains/datasets/components/__tests__/create-dataset-from-items-dialog.test.tsx @@ -3,7 +3,7 @@ import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; -import type { ButtonHTMLAttributes, HTMLAttributes, InputHTMLAttributes, PropsWithChildren } from 'react'; +import type { PropsWithChildren } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { CreateDatasetFromItemsDialog } from '../create-dataset-from-items-dialog'; @@ -12,24 +12,16 @@ import { server } from '@/test/msw-server'; const BASE_URL = 'http://localhost:4111'; -// Thin stubs for playground-ui atoms so this test focuses on the real client + mutation behavior. -vi.mock('@mastra/playground-ui', () => { +// Thin stub for the heavy Dialog atom so this test focuses on the real client + mutation behavior. +vi.mock('@mastra/playground-ui/components/Dialog', () => { const Dialog = ({ open, children }: PropsWithChildren<{ open: boolean }>) => (open ? <div>{children}</div> : null); return { - Button: ({ variant: _variant, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string }) => ( - <button {...props} /> - ), Dialog, DialogContent: ({ children }: PropsWithChildren) => <div>{children}</div>, DialogHeader: ({ children }: PropsWithChildren) => <div>{children}</div>, DialogTitle: ({ children }: PropsWithChildren) => <h2>{children}</h2>, DialogBody: ({ children }: PropsWithChildren) => <div>{children}</div>, - Input: (props: InputHTMLAttributes<HTMLInputElement>) => <input {...props} />, - Label: ({ children, ...props }: PropsWithChildren<HTMLAttributes<HTMLLabelElement>>) => ( - <label {...props}>{children}</label> - ), - toast: { error: vi.fn(), success: vi.fn() }, }; }); diff --git a/packages/playground/src/domains/datasets/components/__tests__/save-as-dataset-item-dialog.test.tsx b/packages/playground/src/domains/datasets/components/__tests__/save-as-dataset-item-dialog.test.tsx index fc5394640236..c3226d21d613 100644 --- a/packages/playground/src/domains/datasets/components/__tests__/save-as-dataset-item-dialog.test.tsx +++ b/packages/playground/src/domains/datasets/components/__tests__/save-as-dataset-item-dialog.test.tsx @@ -2,14 +2,7 @@ import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; -import type { - ButtonHTMLAttributes, - ChangeEvent, - HTMLAttributes, - PropsWithChildren, - ReactNode, - SelectHTMLAttributes, -} from 'react'; +import type { ChangeEvent, HTMLAttributes, PropsWithChildren, ReactNode, SelectHTMLAttributes } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SaveAsDatasetItemDialog } from '../save-as-dataset-item-dialog'; @@ -28,32 +21,15 @@ type CodeEditorProps = { // thin seam so this suite can focus on the dialog's async-seeding logic. The // data hooks below are driven through the real @mastra/client-js + React Query // stack via MSW. -vi.mock('@mastra/playground-ui', () => { - const SideDialogRoot = ({ isOpen, children }: PropsWithChildren<{ isOpen: boolean }>) => - isOpen ? <div>{children}</div> : null; - - const SideDialog = Object.assign(SideDialogRoot, { - Top: ({ children }: PropsWithChildren) => <div>{children}</div>, - Content: ({ children }: PropsWithChildren) => <div>{children}</div>, - Header: ({ children }: PropsWithChildren) => <div>{children}</div>, - Heading: ({ children }: PropsWithChildren) => <h2>{children}</h2>, - }); - - return { - Button: ({ variant: _variant, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string }) => ( - <button {...props} /> - ), - Select: ({ children }: PropsWithChildren<SelectHTMLAttributes<HTMLSelectElement>>) => <div>{children}</div>, - SelectTrigger: ({ children }: PropsWithChildren<HTMLAttributes<HTMLButtonElement>>) => ( - <button type="button">{children}</button> - ), - SelectValue: ({ placeholder }: { placeholder?: string }) => <span>{placeholder}</span>, - SelectContent: ({ children }: PropsWithChildren) => <div>{children}</div>, - SelectItem: ({ children }: PropsWithChildren<{ value: string }>) => <div>{children}</div>, - SideDialog, - toast: { error: vi.fn(), success: vi.fn() }, - }; -}); +vi.mock('@mastra/playground-ui/components/Select', () => ({ + Select: ({ children }: PropsWithChildren<SelectHTMLAttributes<HTMLSelectElement>>) => <div>{children}</div>, + SelectTrigger: ({ children }: PropsWithChildren<HTMLAttributes<HTMLButtonElement>>) => ( + <button type="button">{children}</button> + ), + SelectValue: ({ placeholder }: { placeholder?: string }) => <span>{placeholder}</span>, + SelectContent: ({ children }: PropsWithChildren) => <div>{children}</div>, + SelectItem: ({ children }: PropsWithChildren<{ value: string }>) => <div>{children}</div>, +})); vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, @@ -80,12 +56,6 @@ vi.mock('@mastra/playground-ui/components/CodeEditor', () => ({ ), })); -vi.mock('@mastra/playground-ui/components/Label', () => ({ - Label: ({ children, ...props }: PropsWithChildren<HTMLAttributes<HTMLLabelElement>>) => ( - <label {...props}>{children}</label> - ), -})); - vi.mock('@mastra/playground-ui/components/Text', () => ({ TextAndIcon: ({ children }: PropsWithChildren) => <span>{children}</span>, })); diff --git a/packages/playground/src/domains/datasets/components/dataset-health-card.tsx b/packages/playground/src/domains/datasets/components/dataset-health-card.tsx index b19aea3c0f3b..46c2de1535cc 100644 --- a/packages/playground/src/domains/datasets/components/dataset-health-card.tsx +++ b/packages/playground/src/domains/datasets/components/dataset-health-card.tsx @@ -1,7 +1,7 @@ import type { DatasetExperiment } from '@mastra/client-js'; -import { CHART_COLORS } from '@mastra/playground-ui'; import { HorizontalBars } from '@mastra/playground-ui/components/HorizontalBars'; import { MetricsCard } from '@mastra/playground-ui/components/MetricsCard'; +import { CHART_COLORS } from '@mastra/playground-ui/domains/metrics/components/metrics-utils'; import { useMemo } from 'react'; interface DatasetHealthCardProps { diff --git a/packages/playground/src/domains/experiments/components/experiment-page-tabs.tsx b/packages/playground/src/domains/experiments/components/experiment-page-tabs.tsx index 943b2974416b..ba1bdf6ef28b 100644 --- a/packages/playground/src/domains/experiments/components/experiment-page-tabs.tsx +++ b/packages/playground/src/domains/experiments/components/experiment-page-tabs.tsx @@ -2,11 +2,14 @@ import type { DatasetExperimentResult } from '@mastra/client-js'; import type { ExperimentStatus } from '@mastra/core/storage'; -import { SpanDataPanelView, TraceDataPanelView, useSpanDetail, useTraceSpanNavigation } from '@mastra/playground-ui'; import { Button } from '@mastra/playground-ui/components/Button'; import { Chip } from '@mastra/playground-ui/components/Chip'; import { Tabs, Tab, TabList, TabContent } from '@mastra/playground-ui/components/Tabs'; import { Txt } from '@mastra/playground-ui/components/Txt'; +import { SpanDataPanelView } from '@mastra/playground-ui/domains/traces/components/span-data-panel-view'; +import { TraceDataPanelView } from '@mastra/playground-ui/domains/traces/components/trace-data-panel-view'; +import { useSpanDetail } from '@mastra/playground-ui/domains/traces/hooks/use-span-detail'; +import { useTraceSpanNavigation } from '@mastra/playground-ui/domains/traces/hooks/use-trace-span-navigation'; import { Icon } from '@mastra/playground-ui/icons/Icon'; import { cn } from '@mastra/playground-ui/utils/cn'; import { toast } from '@mastra/playground-ui/utils/toast'; diff --git a/packages/playground/src/domains/experiments/components/experiment-result-span-pane.tsx b/packages/playground/src/domains/experiments/components/experiment-result-span-pane.tsx index 8ad2c308d0bc..bedff3a9333a 100644 --- a/packages/playground/src/domains/experiments/components/experiment-result-span-pane.tsx +++ b/packages/playground/src/domains/experiments/components/experiment-result-span-pane.tsx @@ -1,11 +1,10 @@ 'use client'; - -import { useSpanDetail } from '@mastra/playground-ui'; import { Button } from '@mastra/playground-ui/components/Button'; import { Column } from '@mastra/playground-ui/components/Columns'; import { MainHeader } from '@mastra/playground-ui/components/MainHeader'; import { PrevNextNav } from '@mastra/playground-ui/components/PrevNextNav'; import { getShortId } from '@mastra/playground-ui/components/Text'; +import { useSpanDetail } from '@mastra/playground-ui/domains/traces/hooks/use-span-detail'; import { BracesIcon, XIcon } from 'lucide-react'; import { ExperimentTraceSpanDetails } from './experiment-trace-span-details'; diff --git a/packages/playground/src/domains/metrics/components/latency-card.tsx b/packages/playground/src/domains/metrics/components/latency-card.tsx index bae4b1241e88..2ad7774e131c 100644 --- a/packages/playground/src/domains/metrics/components/latency-card.tsx +++ b/packages/playground/src/domains/metrics/components/latency-card.tsx @@ -1,6 +1,9 @@ import { EntityType } from '@mastra/core/observability'; -import { LatencyCardView, OpenInTracesButton, useDrilldown, useLatencyMetrics } from '@mastra/playground-ui'; -import type { LatencyTab } from '@mastra/playground-ui'; +import { OpenInTracesButton } from '@mastra/playground-ui/domains/metrics/components/card-action-buttons'; +import { LatencyCardView } from '@mastra/playground-ui/domains/metrics/components/latency-card-view'; +import type { LatencyTab } from '@mastra/playground-ui/domains/metrics/components/latency-card-view'; +import { useDrilldown } from '@mastra/playground-ui/domains/metrics/hooks/use-drilldown'; +import { useLatencyMetrics } from '@mastra/playground-ui/domains/metrics/hooks/use-latency-metrics'; import { useNavigate } from 'react-router'; import { useLinkComponent } from '@/lib/framework'; diff --git a/packages/playground/src/domains/metrics/components/memory-card.tsx b/packages/playground/src/domains/metrics/components/memory-card.tsx index 25a4f807a109..99618bc24eed 100644 --- a/packages/playground/src/domains/metrics/components/memory-card.tsx +++ b/packages/playground/src/domains/metrics/components/memory-card.tsx @@ -1,9 +1,7 @@ -import { - MemoryCardView, - useDrilldown, - useTopActiveThreadsMetrics, - useTopResourcesByThreadsMetrics, -} from '@mastra/playground-ui'; +import { MemoryCardView } from '@mastra/playground-ui/domains/metrics/components/memory-card-view'; +import { useDrilldown } from '@mastra/playground-ui/domains/metrics/hooks/use-drilldown'; +import { useTopActiveThreadsMetrics } from '@mastra/playground-ui/domains/metrics/hooks/use-top-active-threads-metrics'; +import { useTopResourcesByThreadsMetrics } from '@mastra/playground-ui/domains/metrics/hooks/use-top-resources-by-threads-metrics'; import { useLinkComponent } from '@/lib/framework'; export function MemoryCard() { diff --git a/packages/playground/src/domains/metrics/components/metrics-kpi-cards.tsx b/packages/playground/src/domains/metrics/components/metrics-kpi-cards.tsx index 843d28c225f3..b9f9328b7c13 100644 --- a/packages/playground/src/domains/metrics/components/metrics-kpi-cards.tsx +++ b/packages/playground/src/domains/metrics/components/metrics-kpi-cards.tsx @@ -1,13 +1,10 @@ -import { - KpiCardView, - formatCompact, - formatCost, - useActiveResourcesKpiMetrics, - useActiveThreadsKpiMetrics, - useAgentRunsKpiMetrics, - useModelCostKpiMetrics, - useTotalTokensKpiMetrics, -} from '@mastra/playground-ui'; +import { KpiCardView } from '@mastra/playground-ui/domains/metrics/components/kpi-card-view'; +import { formatCompact, formatCost } from '@mastra/playground-ui/domains/metrics/components/metrics-utils'; +import { useActiveResourcesKpiMetrics } from '@mastra/playground-ui/domains/metrics/hooks/use-active-resources-kpi-metrics'; +import { useActiveThreadsKpiMetrics } from '@mastra/playground-ui/domains/metrics/hooks/use-active-threads-kpi-metrics'; +import { useAgentRunsKpiMetrics } from '@mastra/playground-ui/domains/metrics/hooks/use-agent-runs-kpi-metrics'; +import { useModelCostKpiMetrics } from '@mastra/playground-ui/domains/metrics/hooks/use-model-cost-kpi-metrics'; +import { useTotalTokensKpiMetrics } from '@mastra/playground-ui/domains/metrics/hooks/use-total-tokens-kpi-metrics'; export function AgentRunsKpiCard() { const { data, isLoading, isError } = useAgentRunsKpiMetrics(); diff --git a/packages/playground/src/domains/metrics/components/model-usage-cost-card.tsx b/packages/playground/src/domains/metrics/components/model-usage-cost-card.tsx index e18d7f750b41..5f21a7b9c58b 100644 --- a/packages/playground/src/domains/metrics/components/model-usage-cost-card.tsx +++ b/packages/playground/src/domains/metrics/components/model-usage-cost-card.tsx @@ -1,10 +1,8 @@ import { EntityType } from '@mastra/core/observability'; -import { - ModelUsageCostCardView, - OpenInTracesButton, - useDrilldown, - useModelUsageCostMetrics, -} from '@mastra/playground-ui'; +import { OpenInTracesButton } from '@mastra/playground-ui/domains/metrics/components/card-action-buttons'; +import { ModelUsageCostCardView } from '@mastra/playground-ui/domains/metrics/components/model-usage-cost-card-view'; +import { useDrilldown } from '@mastra/playground-ui/domains/metrics/hooks/use-drilldown'; +import { useModelUsageCostMetrics } from '@mastra/playground-ui/domains/metrics/hooks/use-model-usage-cost-metrics'; import { useLinkComponent } from '@/lib/framework'; export function ModelUsageCostCard() { diff --git a/packages/playground/src/domains/metrics/components/token-usage-by-agent-card.tsx b/packages/playground/src/domains/metrics/components/token-usage-by-agent-card.tsx index 154843f0b546..5019ec06c31b 100644 --- a/packages/playground/src/domains/metrics/components/token-usage-by-agent-card.tsx +++ b/packages/playground/src/domains/metrics/components/token-usage-by-agent-card.tsx @@ -1,10 +1,8 @@ import { EntityType } from '@mastra/core/observability'; -import { - OpenInTracesButton, - TokenUsageByAgentCardView, - useDrilldown, - useTokenUsageByAgentMetrics, -} from '@mastra/playground-ui'; +import { OpenInTracesButton } from '@mastra/playground-ui/domains/metrics/components/card-action-buttons'; +import { TokenUsageByAgentCardView } from '@mastra/playground-ui/domains/metrics/components/token-usage-by-agent-card-view'; +import { useDrilldown } from '@mastra/playground-ui/domains/metrics/hooks/use-drilldown'; +import { useTokenUsageByAgentMetrics } from '@mastra/playground-ui/domains/metrics/hooks/use-token-usage-by-agent-metrics'; import { useLinkComponent } from '@/lib/framework'; export function TokenUsageByAgentCard() { diff --git a/packages/playground/src/domains/metrics/components/token-usage-timeline-card.tsx b/packages/playground/src/domains/metrics/components/token-usage-timeline-card.tsx index a8fcb24a2cdd..72fb7d812304 100644 --- a/packages/playground/src/domains/metrics/components/token-usage-timeline-card.tsx +++ b/packages/playground/src/domains/metrics/components/token-usage-timeline-card.tsx @@ -1,9 +1,7 @@ -import { - OpenInTracesButton, - TokenUsageTimelineCardView, - useDrilldown, - useTokenUsageTimeSeries, -} from '@mastra/playground-ui'; +import { OpenInTracesButton } from '@mastra/playground-ui/domains/metrics/components/card-action-buttons'; +import { TokenUsageTimelineCardView } from '@mastra/playground-ui/domains/metrics/components/token-usage-timeline-card-view'; +import { useDrilldown } from '@mastra/playground-ui/domains/metrics/hooks/use-drilldown'; +import { useTokenUsageTimeSeries } from '@mastra/playground-ui/domains/metrics/hooks/use-token-usage-timeseries'; import { useLinkComponent } from '@/lib/framework'; export function TokenUsageTimelineCard() { diff --git a/packages/playground/src/domains/metrics/components/traces-volume-card.tsx b/packages/playground/src/domains/metrics/components/traces-volume-card.tsx index 7d167dc456fa..023b45b44c27 100644 --- a/packages/playground/src/domains/metrics/components/traces-volume-card.tsx +++ b/packages/playground/src/domains/metrics/components/traces-volume-card.tsx @@ -2,11 +2,11 @@ import { EntityType } from '@mastra/core/observability'; import { OpenErrorsInLogsButton, OpenInTracesButton, - TracesVolumeCardView, - useDrilldown, - useTraceVolumeMetrics, -} from '@mastra/playground-ui'; -import type { VolumeTab } from '@mastra/playground-ui'; +} from '@mastra/playground-ui/domains/metrics/components/card-action-buttons'; +import { TracesVolumeCardView } from '@mastra/playground-ui/domains/metrics/components/traces-volume-card-view'; +import type { VolumeTab } from '@mastra/playground-ui/domains/metrics/components/traces-volume-card-view'; +import { useDrilldown } from '@mastra/playground-ui/domains/metrics/hooks/use-drilldown'; +import { useTraceVolumeMetrics } from '@mastra/playground-ui/domains/metrics/hooks/use-trace-volume-metrics'; import { useLinkComponent } from '@/lib/framework'; const TAB_TO_ROOT_ENTITY: Record<VolumeTab, EntityType> = { diff --git a/packages/playground/src/domains/observability/components/trace-as-item-dialog.tsx b/packages/playground/src/domains/observability/components/trace-as-item-dialog.tsx index 001f2179ad2a..0fc5bb272740 100644 --- a/packages/playground/src/domains/observability/components/trace-as-item-dialog.tsx +++ b/packages/playground/src/domains/observability/components/trace-as-item-dialog.tsx @@ -2,9 +2,9 @@ import type { SpanRecord } from '@mastra/core/storage'; import { collectToolMocks } from '@mastra/core/utils/collect-tool-mocks'; -import { useSpanDetail } from '@mastra/playground-ui'; import type { SideDialogRootProps } from '@mastra/playground-ui/components/SideDialog'; import { TextAndIcon, getShortId } from '@mastra/playground-ui/components/Text'; +import { useSpanDetail } from '@mastra/playground-ui/domains/traces/hooks/use-span-detail'; import { useMastraClient } from '@mastra/react'; import { useQuery } from '@tanstack/react-query'; import { EyeIcon } from 'lucide-react'; diff --git a/packages/playground/src/domains/tool-providers/components/toolkit-list.tsx b/packages/playground/src/domains/tool-providers/components/toolkit-list.tsx index 54e1cad862a7..2909112d64ee 100644 --- a/packages/playground/src/domains/tool-providers/components/toolkit-list.tsx +++ b/packages/playground/src/domains/tool-providers/components/toolkit-list.tsx @@ -1,6 +1,6 @@ -import { transitions } from '@mastra/playground-ui'; import { ScrollArea } from '@mastra/playground-ui/components/ScrollArea'; import { Skeleton } from '@mastra/playground-ui/components/Skeleton'; +import { transitions } from '@mastra/playground-ui/primitives/transitions'; import { cn } from '@mastra/playground-ui/utils/cn'; import { useToolkits } from '../hooks/use-toolkits'; diff --git a/packages/playground/src/domains/workflows/components/workflow-layout.tsx b/packages/playground/src/domains/workflows/components/workflow-layout.tsx index f083e8d02dc0..650aa52926b6 100644 --- a/packages/playground/src/domains/workflows/components/workflow-layout.tsx +++ b/packages/playground/src/domains/workflows/components/workflow-layout.tsx @@ -1,5 +1,7 @@ -import { CollapsiblePanel, PanelDrawer, PanelSeparator } from '@mastra/playground-ui'; import { useIsMobile } from '@mastra/playground-ui/hooks/use-is-mobile'; +import { CollapsiblePanel } from '@mastra/playground-ui/resize/collapsible-panel'; +import { PanelDrawer } from '@mastra/playground-ui/resize/panel-drawer'; +import { PanelSeparator } from '@mastra/playground-ui/resize/separator'; import { useState } from 'react'; import type { CSSProperties } from 'react'; import { Panel, useDefaultLayout, Group } from 'react-resizable-panels'; diff --git a/packages/playground/src/domains/workflows/workflow/workflow-nested-graph.tsx b/packages/playground/src/domains/workflows/workflow/workflow-nested-graph.tsx index 7bd614e6eea8..0ba33687217b 100644 --- a/packages/playground/src/domains/workflows/workflow/workflow-nested-graph.tsx +++ b/packages/playground/src/domains/workflows/workflow/workflow-nested-graph.tsx @@ -26,9 +26,10 @@ export function WorkflowNestedGraph({ stepGraph, open, workflowName }: WorkflowN useEffect(() => { if (open) { - setTimeout(() => { + const timer = setTimeout(() => { setIsMounted(true); }, 500); // Delay to ensure modal is fully rendered + return () => clearTimeout(timer); } }, [open]); diff --git a/packages/playground/src/ee/signals/__tests__/index.test.tsx b/packages/playground/src/ee/signals/__tests__/index.test.tsx index a10e3edd95e1..3aa62e96ef0e 100644 --- a/packages/playground/src/ee/signals/__tests__/index.test.tsx +++ b/packages/playground/src/ee/signals/__tests__/index.test.tsx @@ -20,13 +20,19 @@ vi.mock('react-router', async importOriginal => { }; }); -vi.mock('@mastra/playground-ui', () => ({ +vi.mock('@mastra/playground-ui/ee/signals/components/signal-details-utils', () => ({ getSignalName: (signalId: string) => (signalId === 'tasks' ? 'Tasks' : signalId), +})); + +vi.mock('@mastra/playground-ui/ee/signals/components/signals-overview-page', () => ({ SignalsOverviewPage: ({ onSignalSelect }: { onSignalSelect: (signal: { id: string }) => void }) => ( <button type="button" onClick={() => onSignalSelect({ id: 'tasks' })}> Select signal </button> ), +})); + +vi.mock('@mastra/playground-ui/ee/signals/components/signal-details-page', () => ({ SignalDetailsPage: ({ signalId, selectedTraceId, diff --git a/packages/playground/src/ee/signals/signal-crumb.tsx b/packages/playground/src/ee/signals/signal-crumb.tsx index 40ee335e1092..b58eaf83a11b 100644 --- a/packages/playground/src/ee/signals/signal-crumb.tsx +++ b/packages/playground/src/ee/signals/signal-crumb.tsx @@ -1,4 +1,4 @@ -import { getSignalName } from '@mastra/playground-ui'; +import { getSignalName } from '@mastra/playground-ui/ee/signals/components/signal-details-utils'; import { useParams } from 'react-router'; export function SignalCrumb() { diff --git a/packages/playground/src/ee/signals/signal-details-page.tsx b/packages/playground/src/ee/signals/signal-details-page.tsx index 4dbdc4f0a4f3..8825e7c43ea3 100644 --- a/packages/playground/src/ee/signals/signal-details-page.tsx +++ b/packages/playground/src/ee/signals/signal-details-page.tsx @@ -1,4 +1,7 @@ -import { SignalDetailsPage as SignalDetailsPageContent, SignalTraceDetailsPanel } from '@mastra/playground-ui'; +import { + SignalDetailsPage as SignalDetailsPageContent, + SignalTraceDetailsPanel, +} from '@mastra/playground-ui/ee/signals/components/signal-details-page'; import { useState } from 'react'; import { useNavigate, useParams } from 'react-router'; diff --git a/packages/playground/src/ee/signals/signals-overview-page.tsx b/packages/playground/src/ee/signals/signals-overview-page.tsx index 96ccc5d602d1..6c12b6b87fc6 100644 --- a/packages/playground/src/ee/signals/signals-overview-page.tsx +++ b/packages/playground/src/ee/signals/signals-overview-page.tsx @@ -1,11 +1,11 @@ -import { SignalsOverviewPage as SignalsOverviewPageContent } from '@mastra/playground-ui'; -import type { Signal } from '@mastra/playground-ui'; +import { SignalsOverviewPage as SignalsOverviewPageContent } from '@mastra/playground-ui/ee/signals/components/signals-overview-page'; +import type { SignalsOverviewPageProps } from '@mastra/playground-ui/ee/signals/components/signals-overview-page'; import { useNavigate } from 'react-router'; export function SignalsOverviewPage() { const navigate = useNavigate(); - const handleSignalSelect = (signal: Signal) => { + const handleSignalSelect: SignalsOverviewPageProps['onSignalSelect'] = signal => { void navigate(`/signals/${signal.id}`, { viewTransition: true }); }; diff --git a/packages/playground/src/exports.ts b/packages/playground/src/exports.ts index 974680c39ed2..b6d08567cc07 100644 --- a/packages/playground/src/exports.ts +++ b/packages/playground/src/exports.ts @@ -10,8 +10,7 @@ export { } from './lib/framework'; export { PlaygroundQueryClient } from './lib/tanstack-query'; - -export { usePlaygroundStore } from '@mastra/playground-ui'; +export { usePlaygroundStore } from '@mastra/playground-ui/store/playground-store'; export { useTheme, type Theme, type ResolvedTheme } from '@mastra/playground-ui/components/ThemeProvider'; export { isWorkspaceV1Supported } from '@mastra/playground-ui/utils'; diff --git a/packages/playground/src/lib/ai-ui/chat/__tests__/chat-provider.test.tsx b/packages/playground/src/lib/ai-ui/chat/__tests__/chat-provider.test.tsx index 1dfaf7b16f26..3fdbf97bf89c 100644 --- a/packages/playground/src/lib/ai-ui/chat/__tests__/chat-provider.test.tsx +++ b/packages/playground/src/lib/ai-ui/chat/__tests__/chat-provider.test.tsx @@ -1,4 +1,5 @@ -import { useObservationalMemory, useMemoryThreadMessages } from '@mastra/playground-ui'; +import { useMemoryThreadMessages } from '@mastra/playground-ui/domains/memory/hooks/use-memory-thread-messages'; +import { useObservationalMemory } from '@mastra/playground-ui/domains/memory/hooks/use-observational-memory'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, cleanup, render } from '@testing-library/react'; diff --git a/packages/playground/src/lib/ai-ui/chat/chat-provider.tsx b/packages/playground/src/lib/ai-ui/chat/chat-provider.tsx index 4237facb7e9e..6a2136d7cc51 100644 --- a/packages/playground/src/lib/ai-ui/chat/chat-provider.tsx +++ b/packages/playground/src/lib/ai-ui/chat/chat-provider.tsx @@ -1,6 +1,8 @@ import type { MastraDBMessage } from '@mastra/core/agent/message-list'; import { RequestContext } from '@mastra/core/di'; -import { observationalMemoryQueryKey, memoryThreadMessagesQueryKey, memoryStatusQueryKey } from '@mastra/playground-ui'; +import { memoryStatusQueryKey } from '@mastra/playground-ui/domains/memory/hooks/use-memory-status'; +import { memoryThreadMessagesQueryKey } from '@mastra/playground-ui/domains/memory/hooks/use-memory-thread-messages'; +import { observationalMemoryQueryKey } from '@mastra/playground-ui/domains/memory/hooks/use-observational-memory'; import { useChat } from '@mastra/react'; import { useQueryClient } from '@tanstack/react-query'; import { useState, useMemo, useRef, useEffect, useCallback } from 'react'; diff --git a/packages/playground/src/lib/ai-ui/chat/use-chat-send-handler.ts b/packages/playground/src/lib/ai-ui/chat/use-chat-send-handler.ts index 06e27c966c6c..2517483e4bbe 100644 --- a/packages/playground/src/lib/ai-ui/chat/use-chat-send-handler.ts +++ b/packages/playground/src/lib/ai-ui/chat/use-chat-send-handler.ts @@ -1,6 +1,8 @@ import type { MastraDBMessage } from '@mastra/core/agent/message-list'; import { RequestContext } from '@mastra/core/di'; -import { observationalMemoryQueryKey, memoryThreadMessagesQueryKey, memoryStatusQueryKey } from '@mastra/playground-ui'; +import { memoryStatusQueryKey } from '@mastra/playground-ui/domains/memory/hooks/use-memory-status'; +import { memoryThreadMessagesQueryKey } from '@mastra/playground-ui/domains/memory/hooks/use-memory-thread-messages'; +import { observationalMemoryQueryKey } from '@mastra/playground-ui/domains/memory/hooks/use-observational-memory'; import { useMastraClient } from '@mastra/react'; import { useQueryClient } from '@tanstack/react-query'; import { useCallback, useRef } from 'react'; diff --git a/packages/playground/src/lib/ai-ui/tools/__tests__/agent-badge-routing-decision.test.tsx b/packages/playground/src/lib/ai-ui/tools/__tests__/agent-badge-routing-decision.test.tsx index d3b93c6a1d25..06e5dcaf95ee 100644 --- a/packages/playground/src/lib/ai-ui/tools/__tests__/agent-badge-routing-decision.test.tsx +++ b/packages/playground/src/lib/ai-ui/tools/__tests__/agent-badge-routing-decision.test.tsx @@ -13,8 +13,11 @@ vi.mock('../badges/tool-approval-buttons', () => ({ ToolApprovalButtons: mockToolApprovalButtons, })); -vi.mock('@mastra/playground-ui', () => ({ +vi.mock('@mastra/playground-ui/components/CodeEditor', () => ({ CodeEditor: () => null, +})); + +vi.mock('@mastra/playground-ui/icons/AgentIcon', () => ({ AgentIcon: () => null, })); diff --git a/packages/playground/src/pages/agent-builder/agents/__tests__/create.test.tsx b/packages/playground/src/pages/agent-builder/agents/__tests__/create.test.tsx index 5756ef2c278c..02691c1d3a0f 100644 --- a/packages/playground/src/pages/agent-builder/agents/__tests__/create.test.tsx +++ b/packages/playground/src/pages/agent-builder/agents/__tests__/create.test.tsx @@ -1,3 +1,4 @@ +import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; @@ -27,18 +28,9 @@ vi.mock('@/domains/agent-builder/components/agent-starter/agent-builder-starter' AgentBuilderStarter: () => <div data-testid="agent-builder-starter" />, })); -vi.mock('@mastra/playground-ui', async importOriginal => { - const actual = (await importOriginal()) as Record<string, unknown>; - return { - ...actual, - Button: ({ children, onClick, tooltip, ...rest }: any) => ( - <button onClick={onClick} aria-label={tooltip} {...rest}> - {children} - </button> - ), - usePlaygroundStore: () => ({ requestContext: undefined }), - }; -}); +vi.mock('@mastra/playground-ui/store/playground-store', () => ({ + usePlaygroundStore: () => ({ requestContext: undefined }), +})); vi.mock('react-router', async importOriginal => { const actual = (await importOriginal()) as Record<string, unknown>; @@ -115,9 +107,11 @@ const renderCreate = () => { const result = render( <MastraReactProvider baseUrl={BASE_URL}> <QueryClientProvider client={queryClient}> - <MemoryRouter> - <AgentBuilderCreate /> - </MemoryRouter> + <TooltipProvider> + <MemoryRouter> + <AgentBuilderCreate /> + </MemoryRouter> + </TooltipProvider> </QueryClientProvider> </MastraReactProvider>, ); diff --git a/packages/playground/src/pages/agent-builder/agents/__tests__/edit-msw.test.tsx b/packages/playground/src/pages/agent-builder/agents/__tests__/edit-msw.test.tsx index 23fc5ab984aa..e591ec48da6e 100644 --- a/packages/playground/src/pages/agent-builder/agents/__tests__/edit-msw.test.tsx +++ b/packages/playground/src/pages/agent-builder/agents/__tests__/edit-msw.test.tsx @@ -1,4 +1,3 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -12,18 +11,9 @@ import AgentBuilderAgentEdit from '../edit'; import { authEnabledNoRbacCapabilities, currentUser } from './fixtures/auth'; import { LinkComponentProvider } from '@/lib/framework'; import { server } from '@/test/msw-server'; - -// toast/store are allowed presentational seams. The gating hooks -// (useCurrentUser, useBuilderAgentAccess, useBuilderAgentFeatures) run for real -// against the MSW auth/settings handlers below. -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - usePlaygroundStore: () => ({ requestContext: undefined }), - }; -}); +vi.mock('@mastra/playground-ui/store/playground-store', () => ({ + usePlaygroundStore: () => ({ requestContext: undefined }), +})); vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, diff --git a/packages/playground/src/pages/agent-builder/agents/__tests__/edit-onboarding.msw.test.tsx b/packages/playground/src/pages/agent-builder/agents/__tests__/edit-onboarding.msw.test.tsx index 5026a70c1024..c675e4f16587 100644 --- a/packages/playground/src/pages/agent-builder/agents/__tests__/edit-onboarding.msw.test.tsx +++ b/packages/playground/src/pages/agent-builder/agents/__tests__/edit-onboarding.msw.test.tsx @@ -1,4 +1,3 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -17,15 +16,9 @@ import { import { useDebouncedRunning } from '@/domains/agent-builder/hooks/use-debounced-running'; import { LinkComponentProvider } from '@/lib/framework'; import { server } from '@/test/msw-server'; - -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - usePlaygroundStore: () => ({ requestContext: undefined }), - }; -}); +vi.mock('@mastra/playground-ui/store/playground-store', () => ({ + usePlaygroundStore: () => ({ requestContext: undefined }), +})); vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, diff --git a/packages/playground/src/pages/agent-builder/agents/__tests__/edit-wizard-tools-gating.msw.test.tsx b/packages/playground/src/pages/agent-builder/agents/__tests__/edit-wizard-tools-gating.msw.test.tsx index 3d8726c660d2..275d671c56c4 100644 --- a/packages/playground/src/pages/agent-builder/agents/__tests__/edit-wizard-tools-gating.msw.test.tsx +++ b/packages/playground/src/pages/agent-builder/agents/__tests__/edit-wizard-tools-gating.msw.test.tsx @@ -1,5 +1,4 @@ import type { GetAgentResponse, ListToolProvidersResponse } from '@mastra/client-js'; -import type * as PlaygroundUi from '@mastra/playground-ui'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -14,15 +13,9 @@ import { authEnabledNoRbacCapabilities, currentUser } from './fixtures/auth'; import { emptyAgents, oneOtherAgent, settingsAgentsOnly } from './fixtures/builder'; import { LinkComponentProvider } from '@/lib/framework'; import { server } from '@/test/msw-server'; - -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - usePlaygroundStore: () => ({ requestContext: undefined }), - }; -}); +vi.mock('@mastra/playground-ui/store/playground-store', () => ({ + usePlaygroundStore: () => ({ requestContext: undefined }), +})); vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, diff --git a/packages/playground/src/pages/agent-builder/agents/__tests__/edit.test.tsx b/packages/playground/src/pages/agent-builder/agents/__tests__/edit.test.tsx index dce7856821d0..fb96bf2ce4f8 100644 --- a/packages/playground/src/pages/agent-builder/agents/__tests__/edit.test.tsx +++ b/packages/playground/src/pages/agent-builder/agents/__tests__/edit.test.tsx @@ -1,5 +1,4 @@ import type { StoredAgentResponse } from '@mastra/client-js'; -import type * as PlaygroundUi from '@mastra/playground-ui'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -19,15 +18,9 @@ import { } from './fixtures/tool-providers'; import { LinkComponentProvider } from '@/lib/framework'; import { server } from '@/test/msw-server'; - -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - usePlaygroundStore: () => ({ requestContext: undefined }), - }; -}); +vi.mock('@mastra/playground-ui/store/playground-store', () => ({ + usePlaygroundStore: () => ({ requestContext: undefined }), +})); vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, diff --git a/packages/playground/src/pages/agent-builder/agents/__tests__/index.test.tsx b/packages/playground/src/pages/agent-builder/agents/__tests__/index.test.tsx index c20d7a5b9aad..45bda1554d11 100644 --- a/packages/playground/src/pages/agent-builder/agents/__tests__/index.test.tsx +++ b/packages/playground/src/pages/agent-builder/agents/__tests__/index.test.tsx @@ -1,5 +1,4 @@ import type { BuilderAvailableModelsResponse, BuilderSettingsResponse } from '@mastra/client-js'; -import type * as PlaygroundUi from '@mastra/playground-ui'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -25,15 +24,9 @@ const unauthenticatedCapabilities = { enabled: true, login: { type: 'credentials' as const }, } satisfies AuthCapabilities; - -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - usePlaygroundStore: () => ({ requestContext: undefined }), - }; -}); +vi.mock('@mastra/playground-ui/store/playground-store', () => ({ + usePlaygroundStore: () => ({ requestContext: undefined }), +})); vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, diff --git a/packages/playground/src/pages/agent-builder/agents/__tests__/view.test.tsx b/packages/playground/src/pages/agent-builder/agents/__tests__/view.test.tsx index 9e34b63f63b8..81b5e93a53ba 100644 --- a/packages/playground/src/pages/agent-builder/agents/__tests__/view.test.tsx +++ b/packages/playground/src/pages/agent-builder/agents/__tests__/view.test.tsx @@ -1,4 +1,3 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -12,17 +11,9 @@ import AgentBuilderAgentView from '../view'; import { authDisabledCapabilities, builderSettingsDisabled, currentUser } from './fixtures/auth'; import { LinkComponentProvider } from '@/lib/framework'; import { server } from '@/test/msw-server'; - -// toast/store are allowed presentational seams; the gating hooks below are NOT -// mocked — they run for real against the auth/settings MSW handlers. -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - usePlaygroundStore: () => ({ requestContext: undefined }), - }; -}); +vi.mock('@mastra/playground-ui/store/playground-store', () => ({ + usePlaygroundStore: () => ({ requestContext: undefined }), +})); vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, diff --git a/packages/playground/src/pages/agent-builder/favorite/__tests__/favorite-page.test.tsx b/packages/playground/src/pages/agent-builder/favorite/__tests__/favorite-page.test.tsx index d66c75f72bba..0b7d91f668de 100644 --- a/packages/playground/src/pages/agent-builder/favorite/__tests__/favorite-page.test.tsx +++ b/packages/playground/src/pages/agent-builder/favorite/__tests__/favorite-page.test.tsx @@ -1,4 +1,3 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -10,15 +9,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import AgentBuilderFavoritePage from '..'; import { LinkComponentProvider } from '@/lib/framework'; import { server } from '@/test/msw-server'; - -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - usePlaygroundStore: () => ({ requestContext: undefined }), - }; -}); +vi.mock('@mastra/playground-ui/store/playground-store', () => ({ + usePlaygroundStore: () => ({ requestContext: undefined }), +})); vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, diff --git a/packages/playground/src/pages/agent-builder/library/__tests__/library-page.test.tsx b/packages/playground/src/pages/agent-builder/library/__tests__/library-page.test.tsx index 250f0fb1f90a..a46f17dd020f 100644 --- a/packages/playground/src/pages/agent-builder/library/__tests__/library-page.test.tsx +++ b/packages/playground/src/pages/agent-builder/library/__tests__/library-page.test.tsx @@ -1,4 +1,3 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, render, screen, waitFor } from '@testing-library/react'; @@ -9,15 +8,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import AgentBuilderLibraryPage from '..'; import { LinkComponentProvider } from '@/lib/framework'; import { server } from '@/test/msw-server'; - -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - usePlaygroundStore: () => ({ requestContext: undefined }), - }; -}); +vi.mock('@mastra/playground-ui/store/playground-store', () => ({ + usePlaygroundStore: () => ({ requestContext: undefined }), +})); vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, diff --git a/packages/playground/src/pages/agent-builder/skills/__tests__/create.msw.test.tsx b/packages/playground/src/pages/agent-builder/skills/__tests__/create.msw.test.tsx index 4a1752acdf3c..767e700662ef 100644 --- a/packages/playground/src/pages/agent-builder/skills/__tests__/create.msw.test.tsx +++ b/packages/playground/src/pages/agent-builder/skills/__tests__/create.msw.test.tsx @@ -1,4 +1,3 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -13,17 +12,9 @@ import type { AuthCapabilities } from '@/domains/auth/types'; import { server } from '@/test/msw-server'; const BASE_URL = 'http://localhost:4111'; - -// `toast` and the playground store are app-shell singletons, not data hooks. -// Stubbing them is an allowed thin seam. -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - usePlaygroundStore: () => ({ requestContext: undefined }), - }; -}); +vi.mock('@mastra/playground-ui/store/playground-store', () => ({ + usePlaygroundStore: () => ({ requestContext: undefined }), +})); vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, diff --git a/packages/playground/src/pages/agent-builder/skills/__tests__/edit-autosave.msw.test.tsx b/packages/playground/src/pages/agent-builder/skills/__tests__/edit-autosave.msw.test.tsx index c3991ab52a2b..65e95410a190 100644 --- a/packages/playground/src/pages/agent-builder/skills/__tests__/edit-autosave.msw.test.tsx +++ b/packages/playground/src/pages/agent-builder/skills/__tests__/edit-autosave.msw.test.tsx @@ -1,4 +1,3 @@ -import type * as PlaygroundUi from '@mastra/playground-ui'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -11,15 +10,9 @@ import { server } from '@/test/msw-server'; const BASE_URL = 'http://localhost:4111'; const SKILL_ID = 'skill-test-123'; - -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - usePlaygroundStore: () => ({ requestContext: undefined }), - }; -}); +vi.mock('@mastra/playground-ui/store/playground-store', () => ({ + usePlaygroundStore: () => ({ requestContext: undefined }), +})); vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, diff --git a/packages/playground/src/pages/agent-builder/skills/__tests__/view.msw.test.tsx b/packages/playground/src/pages/agent-builder/skills/__tests__/view.msw.test.tsx index 4ca55e0db6c0..c64839354592 100644 --- a/packages/playground/src/pages/agent-builder/skills/__tests__/view.msw.test.tsx +++ b/packages/playground/src/pages/agent-builder/skills/__tests__/view.msw.test.tsx @@ -1,6 +1,6 @@ import type { StoredSkillResponse } from '@mastra/client-js'; -import type * as PlaygroundUi from '@mastra/playground-ui'; import { TooltipProvider } from '@mastra/playground-ui/components/Tooltip'; +import { usePlaygroundStore } from '@mastra/playground-ui/store/playground-store'; import { MastraReactProvider } from '@mastra/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, render, screen, waitFor } from '@testing-library/react'; @@ -14,17 +14,6 @@ import { server } from '@/test/msw-server'; const BASE_URL = 'http://localhost:4111'; -// `toast` and the playground store are app-shell singletons, not data hooks. -// Stubbing them is an allowed thin seam. -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual<typeof PlaygroundUi>('@mastra/playground-ui'); - return { - ...actual, - toast: { success: vi.fn(), error: vi.fn() }, - usePlaygroundStore: () => ({ requestContext: undefined }), - }; -}); - vi.mock('@mastra/playground-ui/utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); @@ -70,6 +59,7 @@ const renderPage = (skillId: string) => { }; beforeEach(() => { + usePlaygroundStore.setState({ requestContext: {} }); setCurrentUser({ id: 'viewer-1' }); server.use( http.get(`${BASE_URL}/api/stored/skills`, () => diff --git a/packages/playground/src/pages/logs/index.tsx b/packages/playground/src/pages/logs/index.tsx index ca3477f7aae1..d061e76aa4e4 100644 --- a/packages/playground/src/pages/logs/index.tsx +++ b/packages/playground/src/pages/logs/index.tsx @@ -1,29 +1,29 @@ +import { DateTimeRangePicker } from '@mastra/playground-ui/components/DateTimeRangePicker'; +import { PageLayout } from '@mastra/playground-ui/components/PageLayout'; +import { PropertyFilterCreator } from '@mastra/playground-ui/components/PropertyFilter'; +import { LogDetailsView } from '@mastra/playground-ui/domains/logs/components/log-details-view'; +import { LogsErrorContent } from '@mastra/playground-ui/domains/logs/components/logs-error-content'; +import { LogsLayout } from '@mastra/playground-ui/domains/logs/components/logs-layout'; +import { LogsListView } from '@mastra/playground-ui/domains/logs/components/logs-list-view'; +import { LogsToolbar } from '@mastra/playground-ui/domains/logs/components/logs-toolbar'; +import { NoLogsInfo } from '@mastra/playground-ui/domains/logs/components/no-logs-info'; +import { useLogs } from '@mastra/playground-ui/domains/logs/hooks/use-logs'; +import { useLogsFilterPersistence } from '@mastra/playground-ui/domains/logs/hooks/use-logs-filter-persistence'; +import { useLogsListNavigation } from '@mastra/playground-ui/domains/logs/hooks/use-logs-list-navigation'; +import { useLogsUrlState } from '@mastra/playground-ui/domains/logs/hooks/use-logs-url-state'; import { - LogDetailsView, - LogsErrorContent, - LogsLayout, - LogsListView, - LogsToolbar, - NoLogsInfo, - SpanDetailsView, - TraceDetailsView, buildLogsListFilters, createLogsPropertyFilterFields, neutralizeLogsFilterTokens, - useEntityNames, - useEnvironments, - useLogs, - useLogsFilterPersistence, - useLogsListNavigation, - useLogsUrlState, - useServiceNames, - useSpanDetail, - useTags, - useTraceLightSpans, -} from '@mastra/playground-ui'; -import { DateTimeRangePicker } from '@mastra/playground-ui/components/DateTimeRangePicker'; -import { PageLayout } from '@mastra/playground-ui/components/PageLayout'; -import { PropertyFilterCreator } from '@mastra/playground-ui/components/PropertyFilter'; +} from '@mastra/playground-ui/domains/logs/log-filters'; +import { SpanDetailsView } from '@mastra/playground-ui/domains/traces/components/span-details-view'; +import { TraceDetailsView } from '@mastra/playground-ui/domains/traces/components/trace-details-view'; +import { useEntityNames } from '@mastra/playground-ui/domains/traces/hooks/use-entity-names'; +import { useEnvironments } from '@mastra/playground-ui/domains/traces/hooks/use-environments'; +import { useServiceNames } from '@mastra/playground-ui/domains/traces/hooks/use-service-names'; +import { useSpanDetail } from '@mastra/playground-ui/domains/traces/hooks/use-span-detail'; +import { useTags } from '@mastra/playground-ui/domains/traces/hooks/use-tags'; +import { useTraceLightSpans } from '@mastra/playground-ui/domains/traces/hooks/use-trace-light-spans'; import { useCallback, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router'; diff --git a/packages/playground/src/pages/metrics/index.tsx b/packages/playground/src/pages/metrics/index.tsx index 435af1912568..5881d839ca17 100644 --- a/packages/playground/src/pages/metrics/index.tsx +++ b/packages/playground/src/pages/metrics/index.tsx @@ -1,22 +1,3 @@ -import type { DatePreset, DateRange } from '@mastra/playground-ui'; -import { - DateRangeSelector, - MetricsProvider, - applyMetricsPropertyFilterTokens, - clearSavedMetricsFilters, - createMetricsPropertyFilterFields, - getMetricsPropertyFilterTokens, - hasAnyMetricsFilterParams, - isValidPreset, - loadMetricsFiltersFromStorage, - saveMetricsFiltersToStorage, - useAgentRunsKpiMetrics, - useMetrics, - useEntityNames, - useEnvironments, - useServiceNames, - useTags, -} from '@mastra/playground-ui'; import { Button } from '@mastra/playground-ui/components/Button'; import { EmptyState } from '@mastra/playground-ui/components/EmptyState'; import { ErrorState } from '@mastra/playground-ui/components/ErrorState'; @@ -27,6 +8,23 @@ import { PermissionDenied } from '@mastra/playground-ui/components/PermissionDen import { PropertyFilterCreator } from '@mastra/playground-ui/components/PropertyFilter'; import type { PropertyFilterToken } from '@mastra/playground-ui/components/PropertyFilter'; import { SessionExpired } from '@mastra/playground-ui/components/SessionExpired'; +import { DateRangeSelector } from '@mastra/playground-ui/domains/metrics/components/date-range-selector'; +import { useAgentRunsKpiMetrics } from '@mastra/playground-ui/domains/metrics/hooks/use-agent-runs-kpi-metrics'; +import { MetricsProvider, isValidPreset, useMetrics } from '@mastra/playground-ui/domains/metrics/hooks/use-metrics'; +import type { DatePreset, DateRange } from '@mastra/playground-ui/domains/metrics/hooks/use-metrics'; +import { + applyMetricsPropertyFilterTokens, + clearSavedMetricsFilters, + createMetricsPropertyFilterFields, + getMetricsPropertyFilterTokens, + hasAnyMetricsFilterParams, + loadMetricsFiltersFromStorage, + saveMetricsFiltersToStorage, +} from '@mastra/playground-ui/domains/metrics/metrics-filters'; +import { useEntityNames } from '@mastra/playground-ui/domains/traces/hooks/use-entity-names'; +import { useEnvironments } from '@mastra/playground-ui/domains/traces/hooks/use-environments'; +import { useServiceNames } from '@mastra/playground-ui/domains/traces/hooks/use-service-names'; +import { useTags } from '@mastra/playground-ui/domains/traces/hooks/use-tags'; import { is401UnauthorizedError, is403ForbiddenError } from '@mastra/playground-ui/utils/errors'; import { toast } from '@mastra/playground-ui/utils/toast'; import { CircleSlashIcon, ExternalLinkIcon } from 'lucide-react'; @@ -53,6 +51,7 @@ const ANALYTICS_OBSERVABILITY_TYPES = new Set([ 'ObservabilityStorageDuckDB', 'ObservabilityInMemory', 'ObservabilitySpanner', + 'ObservabilityStoragePostgresVNext', ]); const PERIOD_PARAM = 'period'; @@ -317,7 +316,7 @@ function MetricsContent() { <EmptyState iconSlot={<CircleSlashIcon />} titleSlot="Metrics are not available with your current storage" - descriptionSlot="Metrics require ClickHouse, DuckDB, Spanner, or in-memory storage for observability. Relational databases (PostgreSQL, LibSQL) do not support metrics collection. To enable metrics on an existing project, switch the observability storage in the Mastra configuration." + descriptionSlot="Metrics require ClickHouse, DuckDB, Postgres v-next, Spanner, or in-memory storage for observability. Other relational databases (LibSQL, MSSQL) and document stores (MongoDB) do not support metrics collection. To enable metrics on an existing project, switch the observability storage in the Mastra configuration." actionSlot={ <Button variant="ghost" @@ -337,7 +336,7 @@ function MetricsContent() { <Notice variant="info" title="Metrics are not persisted"> <Notice.Message> This project uses in-memory storage for observability. Metrics will be lost on every server restart. For - persistent metrics, switch the observability storage to ClickHouse, DuckDB, or Spanner. + persistent metrics, switch the observability storage to ClickHouse, DuckDB, Postgres v-next, or Spanner. </Notice.Message> </Notice> )} diff --git a/packages/playground/src/pages/traces/index.tsx b/packages/playground/src/pages/traces/index.tsx index 2e8db3bc4929..90104f114f85 100644 --- a/packages/playground/src/pages/traces/index.tsx +++ b/packages/playground/src/pages/traces/index.tsx @@ -1,28 +1,4 @@ import { EntityType } from '@mastra/core/observability'; -import { - NoTracesInfo, - SpanDataPanelView, - TraceDataPanelView, - TracesErrorContent, - TracesLayout, - TracesListView, - TracesToolbar, - buildTraceListFilters, - createTracePropertyFilterFields, - neutralizeFilterTokens, - useEntityNames, - useEnvironments, - useServiceNames, - useSpanDetail, - useTags, - useTraceFilterPersistence, - useTraceListNavigation, - useTraceOrBranchSpans, - useTraceSpanNavigation, - useTraceUrlState, - useTraces, -} from '@mastra/playground-ui'; -import type { SpanTab } from '@mastra/playground-ui'; import { Button } from '@mastra/playground-ui/components/Button'; import { DateTimeRangePicker } from '@mastra/playground-ui/components/DateTimeRangePicker'; import { Label } from '@mastra/playground-ui/components/Label'; @@ -30,6 +6,30 @@ import { Notice } from '@mastra/playground-ui/components/Notice'; import { PageLayout } from '@mastra/playground-ui/components/PageLayout'; import { PropertyFilterCreator } from '@mastra/playground-ui/components/PropertyFilter'; import { Switch } from '@mastra/playground-ui/components/Switch'; +import { NoTracesInfo } from '@mastra/playground-ui/domains/traces/components/no-traces-info'; +import { SpanDataPanelView } from '@mastra/playground-ui/domains/traces/components/span-data-panel-view'; +import { TraceDataPanelView } from '@mastra/playground-ui/domains/traces/components/trace-data-panel-view'; +import { TracesErrorContent } from '@mastra/playground-ui/domains/traces/components/traces-error-content'; +import { TracesLayout } from '@mastra/playground-ui/domains/traces/components/traces-layout'; +import { TracesListView } from '@mastra/playground-ui/domains/traces/components/traces-list-view'; +import { TracesToolbar } from '@mastra/playground-ui/domains/traces/components/traces-toolbar'; +import { useEntityNames } from '@mastra/playground-ui/domains/traces/hooks/use-entity-names'; +import { useEnvironments } from '@mastra/playground-ui/domains/traces/hooks/use-environments'; +import { useServiceNames } from '@mastra/playground-ui/domains/traces/hooks/use-service-names'; +import { useSpanDetail } from '@mastra/playground-ui/domains/traces/hooks/use-span-detail'; +import { useTags } from '@mastra/playground-ui/domains/traces/hooks/use-tags'; +import { useTraceFilterPersistence } from '@mastra/playground-ui/domains/traces/hooks/use-trace-filter-persistence'; +import { useTraceListNavigation } from '@mastra/playground-ui/domains/traces/hooks/use-trace-list-navigation'; +import { useTraceOrBranchSpans } from '@mastra/playground-ui/domains/traces/hooks/use-trace-or-branch-spans'; +import { useTraceSpanNavigation } from '@mastra/playground-ui/domains/traces/hooks/use-trace-span-navigation'; +import { useTraceUrlState } from '@mastra/playground-ui/domains/traces/hooks/use-trace-url-state'; +import { useTraces } from '@mastra/playground-ui/domains/traces/hooks/use-traces'; +import { + buildTraceListFilters, + createTracePropertyFilterFields, + neutralizeFilterTokens, +} from '@mastra/playground-ui/domains/traces/trace-filters'; +import type { SpanTab } from '@mastra/playground-ui/domains/traces/types'; import { isBranchesNotSupportedError } from '@mastra/playground-ui/utils/errors'; import { CircleSlash2, RefreshCw } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; diff --git a/packages/playground/src/pages/traces/trace/index.tsx b/packages/playground/src/pages/traces/trace/index.tsx index 2eb9efc29fec..5cda7a7d9d0d 100644 --- a/packages/playground/src/pages/traces/trace/index.tsx +++ b/packages/playground/src/pages/traces/trace/index.tsx @@ -1,18 +1,16 @@ import type { ScoreRowData } from '@mastra/core/evals'; import { EntityType } from '@mastra/core/observability'; -import { - SpanDataPanelView, - TraceDataPanelView, - TraceKeysAndValues, - TracesErrorContent, - useSpanDetail, - useTraceLightSpans, - useTraceSpanNavigation, -} from '@mastra/playground-ui'; -import type { SpanTab } from '@mastra/playground-ui'; import { Button } from '@mastra/playground-ui/components/Button'; import { ButtonsGroup } from '@mastra/playground-ui/components/ButtonsGroup'; import { PageLayout } from '@mastra/playground-ui/components/PageLayout'; +import { SpanDataPanelView } from '@mastra/playground-ui/domains/traces/components/span-data-panel-view'; +import { TraceDataPanelView } from '@mastra/playground-ui/domains/traces/components/trace-data-panel-view'; +import { TraceKeysAndValues } from '@mastra/playground-ui/domains/traces/components/trace-keys-and-values'; +import { TracesErrorContent } from '@mastra/playground-ui/domains/traces/components/traces-error-content'; +import { useSpanDetail } from '@mastra/playground-ui/domains/traces/hooks/use-span-detail'; +import { useTraceLightSpans } from '@mastra/playground-ui/domains/traces/hooks/use-trace-light-spans'; +import { useTraceSpanNavigation } from '@mastra/playground-ui/domains/traces/hooks/use-trace-span-navigation'; +import type { SpanTab } from '@mastra/playground-ui/domains/traces/types'; import { cn } from '@mastra/playground-ui/utils/cn'; import { CircleGaugeIcon, SaveIcon } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; diff --git a/packages/playground/src/store/playground-store.ts b/packages/playground/src/store/playground-store.ts index 0bc917789eaf..32438ef615de 100644 --- a/packages/playground/src/store/playground-store.ts +++ b/packages/playground/src/store/playground-store.ts @@ -1,2 +1,2 @@ -export { usePlaygroundStore } from '@mastra/playground-ui'; +export { usePlaygroundStore } from '@mastra/playground-ui/store/playground-store'; export { useTheme, type Theme, type ResolvedTheme } from '@mastra/playground-ui/components/ThemeProvider'; diff --git a/packages/playground/vitest.setup.ts b/packages/playground/vitest.setup.ts index 9e44e498fa5b..49c65a5e4bd7 100644 --- a/packages/playground/vitest.setup.ts +++ b/packages/playground/vitest.setup.ts @@ -12,8 +12,7 @@ import { server } from './src/test/msw-server'; // it for a plain div that preserves the public API (className, viewPortClassName, // viewportRef, children). Tests still see their content; the overlay-scrollbar // internals are simply not exercised. -vi.mock('@mastra/playground-ui', async () => { - const actual = await vi.importActual('@mastra/playground-ui'); +vi.mock('@mastra/playground-ui/components/ScrollArea', () => { const ScrollArea = React.forwardRef< HTMLDivElement, { @@ -55,7 +54,7 @@ vi.mock('@mastra/playground-ui', async () => { }, ); ScrollArea.displayName = 'ScrollArea'; - return { ...actual, ScrollArea }; + return { ScrollArea }; }); // React reads this global to decide whether `act(...)` is supported. Vitest's diff --git a/packages/rag/CHANGELOG.md b/packages/rag/CHANGELOG.md index 9ad8e17810bc..0d1be709ab19 100644 --- a/packages/rag/CHANGELOG.md +++ b/packages/rag/CHANGELOG.md @@ -1,5 +1,16 @@ # @mastra/rag +## 2.4.0-alpha.0 + +### Minor Changes + +- Added MongoDBConfig to DatabaseConfig, exposing numCandidates for MongoDB Atlas Vector Search queries via the RAG tool layer. ([#18393](https://github.com/mastra-ai/mastra/pull/18393)) + +### Patch Changes + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + ## 2.3.0 ### Minor Changes diff --git a/packages/rag/package.json b/packages/rag/package.json index 2869a6c6a32f..08b4bd0874ed 100644 --- a/packages/rag/package.json +++ b/packages/rag/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/rag", - "version": "2.3.0", + "version": "2.4.0-alpha.0", "description": "", "type": "module", "main": "dist/index.js", diff --git a/packages/rag/src/tools/types.ts b/packages/rag/src/tools/types.ts index 6854f6167dee..ee7f3f1f8495 100644 --- a/packages/rag/src/tools/types.ts +++ b/packages/rag/src/tools/types.ts @@ -74,11 +74,23 @@ export interface ChromaConfig { whereDocument?: WhereDocument; } +export interface MongoDBConfig { + /** + * Number of candidates the HNSW graph considers before selecting the top-K + * results. Higher values improve recall at the cost of query latency. + * Must be >= topK. Defaults to 20 * topK, capped at 10 000. + * This is efSearch in the HNSW paper. + * See: https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-stage/ + */ + numCandidates?: number; +} + // Union type for all database-specific configs export type DatabaseConfig = { pinecone?: PineconeConfig; pgvector?: PgVectorConfig; chroma?: ChromaConfig; + mongodb?: MongoDBConfig; // Add other database configs as needed [key: string]: any; // Allow for future database extensions }; diff --git a/packages/rag/src/tools/vector-query-database-config.test.ts b/packages/rag/src/tools/vector-query-database-config.test.ts index 2085c4cd6b21..b862c1a25e0e 100644 --- a/packages/rag/src/tools/vector-query-database-config.test.ts +++ b/packages/rag/src/tools/vector-query-database-config.test.ts @@ -172,6 +172,40 @@ describe('createVectorQueryTool with database-specific configurations', () => { ); }); + it('should pass MongoDB configuration to vectorQuerySearch', async () => { + const databaseConfig: DatabaseConfig = { + mongodb: { + numCandidates: 500, + }, + }; + + const tool = createVectorQueryTool({ + vectorStoreName: 'mongodb', + indexName: 'testIndex', + model: mockModel, + databaseConfig, + }); + + const requestContext = new RequestContext(); + + await tool.execute( + { + queryText: 'test query', + topK: 5, + }, + { + mastra: mockMastra as any, + requestContext, + }, + ); + + expect(vectorQuerySearch).toHaveBeenCalledWith( + expect.objectContaining({ + databaseConfig, + }), + ); + }); + it('should handle multiple database configurations', async () => { const databaseConfig: DatabaseConfig = { pinecone: { diff --git a/packages/rag/src/utils/vector-search.ts b/packages/rag/src/utils/vector-search.ts index 84ceafbbe8ea..1caad46c21be 100644 --- a/packages/rag/src/utils/vector-search.ts +++ b/packages/rag/src/utils/vector-search.ts @@ -29,6 +29,7 @@ enum DatabaseType { Pinecone = 'pinecone', PgVector = 'pgvector', Chroma = 'chroma', + MongoDB = 'mongodb', } const DATABASE_TYPE_MAP = Object.keys(DatabaseType); @@ -187,6 +188,13 @@ const databaseSpecificParams = (databaseConfig: DatabaseConfig) => { } } + // MongoDB-specific configurations + if (databaseConfig.mongodb) { + if (databaseConfig.mongodb.numCandidates !== undefined) { + databaseSpecificParams.numCandidates = databaseConfig.mongodb.numCandidates; + } + } + // Handle any additional database configs Object.keys(databaseConfig).forEach(dbName => { if (!DATABASE_TYPE_MAP.includes(dbName)) { diff --git a/packages/schema-compat/CHANGELOG.md b/packages/schema-compat/CHANGELOG.md index 8086d86bd7ad..34d762412304 100644 --- a/packages/schema-compat/CHANGELOG.md +++ b/packages/schema-compat/CHANGELOG.md @@ -1,5 +1,19 @@ # @mastra/schema-compat +## 1.3.2-alpha.1 + +### Patch Changes + +- Fix the Zod v4 string handler silently dropping unrecognized `string_format` checks. Formats without a textual description (such as `ipv4`, `ipv6`, `datetime`, `date`, `time`, `base64`, `cuid2`, `ulid`, `nanoid`, `jwt`) are now preserved as validation instead of being removed, so schemas using them keep rejecting invalid input. Closes #18634. ([#18673](https://github.com/mastra-ai/mastra/pull/18673)) + +## 1.3.2-alpha.0 + +### Patch Changes + +- Fix inverted date constraint descriptions in the Zod v4 schema handler. `z.date().min()` and `z.date().max()` were described with their bounds swapped (a lower bound was labelled "older than" and an upper bound "newer than"), so the schema sent to the model stated the opposite and impossible constraint. The handler now matches Zod semantics and the existing v3 handler. Closes #18581. ([#18582](https://github.com/mastra-ai/mastra/pull/18582)) + +- Fixed 'Type instantiation is excessively deep' (TS2589) errors that occurred when defining workflows with Zod schemas. Workflow and step type inference is now significantly faster and no longer causes TypeScript to crash or report depth errors. ([#18608](https://github.com/mastra-ai/mastra/pull/18608)) + ## 1.3.1 ### Patch Changes diff --git a/packages/schema-compat/package.json b/packages/schema-compat/package.json index 705261f92ffb..df5adc058cd1 100644 --- a/packages/schema-compat/package.json +++ b/packages/schema-compat/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/schema-compat", - "version": "1.3.1", + "version": "1.3.2-alpha.1", "description": "Tool schema compatibility layer for Mastra.ai", "type": "module", "main": "dist/index.js", diff --git a/packages/schema-compat/src/schema-compatibility-v4-string-format.test.ts b/packages/schema-compat/src/schema-compatibility-v4-string-format.test.ts new file mode 100644 index 000000000000..631cff9e8e61 --- /dev/null +++ b/packages/schema-compat/src/schema-compatibility-v4-string-format.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { OpenAIReasoningSchemaCompatLayer } from './provider-compats/openai-reasoning'; +import type { ModelInformation } from './types'; + +const modelInfo: ModelInformation = { + provider: 'openai', + modelId: 'o3-mini', + supportsStructuredOutputs: true, +}; + +describe('defaultZodStringHandler string_format preservation', () => { + const layer = new OpenAIReasoningSchemaCompatLayer(modelInfo); + + it('keeps datetime validation (a string format the handler does not turn into a description)', () => { + const result = layer.defaultZodStringHandler(z.string().datetime()); + + // Valid datetime still passes. + expect(result.safeParse('2026-01-01T00:00:00.000Z').success).toBe(true); + // Invalid datetime must still be rejected: the format check should not be dropped. + expect(result.safeParse('not-a-datetime').success).toBe(false); + }); + + it('still passes through a plain min_length string', () => { + const result = layer.defaultZodStringHandler(z.string().min(3)); + expect(result.safeParse('abcd').success).toBe(true); + }); +}); diff --git a/packages/schema-compat/src/schema-compatibility-v4.test.ts b/packages/schema-compat/src/schema-compatibility-v4.test.ts new file mode 100644 index 000000000000..a42c460a9a00 --- /dev/null +++ b/packages/schema-compat/src/schema-compatibility-v4.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { OpenAIReasoningSchemaCompatLayer } from './provider-compats/openai-reasoning'; +import type { ModelInformation } from './types'; + +const modelInfo: ModelInformation = { + provider: 'openai', + modelId: 'o3-mini', + supportsStructuredOutputs: true, +}; + +describe('defaultZodDateHandler (zod v4)', () => { + const layer = new OpenAIReasoningSchemaCompatLayer(modelInfo); + + it('describes a min() bound as "newer than" and a max() bound as "older than"', () => { + const schema = z.date().min(new Date('2020-01-01')).max(new Date('2030-01-01')); + + const result = layer.defaultZodDateHandler(schema); + const description = result.description ?? ''; + + // z.date().min(d) means the date must be >= d, i.e. newer than d. + expect(description).toContain('Date must be newer than 2020-01-01T00:00:00.000Z (ISO)'); + // z.date().max(d) means the date must be <= d, i.e. older than d. + expect(description).toContain('Date must be older than 2030-01-01T00:00:00.000Z (ISO)'); + + // The inverted (buggy) descriptions must not appear. + expect(description).not.toContain('Date must be older than 2020-01-01T00:00:00.000Z (ISO)'); + expect(description).not.toContain('Date must be newer than 2030-01-01T00:00:00.000Z (ISO)'); + }); + + it('handles a lower bound only (min)', () => { + const schema = z.date().min(new Date('2020-01-01')); + + const description = layer.defaultZodDateHandler(schema).description ?? ''; + + expect(description).toContain('Date must be newer than 2020-01-01T00:00:00.000Z (ISO)'); + expect(description).not.toContain('older than 2020-01-01T00:00:00.000Z'); + }); + + it('handles an upper bound only (max)', () => { + const schema = z.date().max(new Date('2030-01-01')); + + const description = layer.defaultZodDateHandler(schema).description ?? ''; + + expect(description).toContain('Date must be older than 2030-01-01T00:00:00.000Z (ISO)'); + expect(description).not.toContain('newer than 2030-01-01T00:00:00.000Z'); + }); +}); diff --git a/packages/schema-compat/src/schema-compatibility-v4.ts b/packages/schema-compat/src/schema-compatibility-v4.ts index ee7bf9aaff4a..44b9158d42ca 100644 --- a/packages/schema-compat/src/schema-compatibility-v4.ts +++ b/packages/schema-compat/src/schema-compatibility-v4.ts @@ -496,6 +496,12 @@ export class SchemaCompatLayer { // @ts-expect-error - fix later constraints.push(`input must match this regex ${check._zod.def.pattern}`); break; + default: + // Formats without a textual description (ipv4, datetime, + // base64, etc.) must be preserved as real validation instead + // of being silently dropped. + newChecks.push(check); + break; } } break; @@ -611,18 +617,20 @@ export class SchemaCompatLayer { if (checks) { for (const check of checks) { switch (check._zod.def.check) { + // `.max(d)` lowers the upper bound, stored as a `less_than` check: the date must be older than `d`. case 'less_than': // @ts-expect-error - fix later - const minDate = new Date(check._zod.def.value); - if (!isNaN(minDate.getTime())) { - constraints.push(`Date must be newer than ${minDate.toISOString()} (ISO)`); + const maxDate = new Date(check._zod.def.value); + if (!isNaN(maxDate.getTime())) { + constraints.push(`Date must be older than ${maxDate.toISOString()} (ISO)`); } break; + // `.min(d)` raises the lower bound, stored as a `greater_than` check: the date must be newer than `d`. case 'greater_than': // @ts-expect-error - fix later - const maxDate = new Date(check._zod.def.value); - if (!isNaN(maxDate.getTime())) { - constraints.push(`Date must be older than ${maxDate.toISOString()} (ISO)`); + const minDate = new Date(check._zod.def.value); + if (!isNaN(minDate.getTime())) { + constraints.push(`Date must be newer than ${minDate.toISOString()} (ISO)`); } break; default: diff --git a/packages/schema-compat/src/schema.types.ts b/packages/schema-compat/src/schema.types.ts index 420570c2a87d..ca12a5ef8c27 100644 --- a/packages/schema-compat/src/schema.types.ts +++ b/packages/schema-compat/src/schema.types.ts @@ -28,4 +28,12 @@ export type PublicSchema<Output = unknown, Input = Output> = | StandardSchemaWithJSON<Input, Output> | AISdkSchemaLike<Output>; -export type InferPublicSchema<T extends PublicSchema> = T extends PublicSchema<infer Output> ? Output : never; +export type InferPublicSchema<T extends PublicSchema> = T extends { _output: infer Output } + ? Output + : T extends { _type: infer Output } + ? Output + : T extends { '~standard': { types: { output: infer O } } } + ? O + : T extends PublicSchema<infer Output> + ? Output + : never; diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index 908549de8430..70765bcbe4ae 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -1,5 +1,164 @@ # @mastra/server +## 1.48.0-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + +## 1.48.0-alpha.8 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + +## 1.48.0-alpha.7 + +### Patch Changes + +- Allow `'fs'` as an agent/scorer definition source in the server handlers and response schemas. File-based agents are registered with `source: 'fs'`, and the scorer/agent list endpoints now surface and validate that value instead of failing schema validation. ([#18609](https://github.com/mastra-ai/mastra/pull/18609)) + + ```ts + // GET /api/agents now returns file-based agents alongside code/stored ones: + { + "weather": { "name": "weather", "source": "fs" /* was rejected before */ } + } + ``` + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + +## 1.48.0-alpha.6 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + +## 1.48.0-alpha.5 + +### Minor Changes + +- **Added** heartbeats: schedule an agent to run on a recurring cron, either inside an existing conversation thread or on its own. ([#18184](https://github.com/mastra-ai/mastra/pull/18184)) + + A heartbeat fires a prompt to an agent on a schedule. When it has a thread, the run is delivered into that thread as a normal agent signal, so anything watching the thread sees it like any other message; without a thread, the agent just runs in isolation. Each heartbeat has its own id and an optional `name`, so one agent or thread can have several heartbeats with different schedules and prompts. The id is generated for you, or you can pass your own `id` to `create` for a stable handle (it's normalized to `hb_<slug>`). Heartbeats are persisted, so they keep firing across process restarts with no extra setup. + + ```ts + const hb = await mastra.heartbeats.create({ + agentId: 'chef', + name: 'morning-checkin', + threadId, + resourceId, + cron: '*/5 * * * *', + prompt: 'Check in on the user', + ifActive: { behavior: 'discard' }, // skip if the user is mid-conversation + ifIdle: { behavior: 'wake' }, // wake the agent if the thread is idle + }); + + // Threadless: run the agent on a cron with no conversation. + await mastra.heartbeats.create({ + agentId: 'chef', + cron: '0 * * * *', + prompt: 'Run the hourly summary', + }); + + await mastra.heartbeats.list({ agentId: 'chef' }); + await mastra.heartbeats.get(hb.id); + await mastra.heartbeats.update(hb.id, { prompt: 'check in gently' }); + await mastra.heartbeats.pause(hb.id); + await mastra.heartbeats.resume(hb.id); + await mastra.heartbeats.run(hb.id); // fire once now + await mastra.heartbeats.delete(hb.id); + ``` + + The same CRUD is available over HTTP through `@mastra/server` (under `/api/heartbeats`) and as top-level methods on the `@mastra/client-js` client (`client.createHeartbeat`, `client.getHeartbeat`, `client.listHeartbeats`, etc.). + + **Lifecycle hooks** + + React to heartbeat runs via `heartbeat` on the `Mastra` constructor. It's a single hook bundle that runs for every agent's heartbeats; each hook receives the firing `agentId` so you can branch on it. `prepare` resolves fire-time parameters (for example, creating a fresh thread per fire), and `onFinish` / `onError` / `onAbort` mirror `agent.stream`. + + ```ts + new Mastra({ + // ... + heartbeat: { + // Return overrides, `null` to skip this fire, or `undefined` to use defaults. + prepare: async ({ agentId, heartbeat }) => { + if (agentId === 'chef' && heartbeat.name === 'daily-digest') { + return { threadId: await createDailyThread(), resourceId: 'slack:U095PUH0FKL' }; + } + }, + onFinish: ({ agentId, outcome, result, heartbeat }) => { + metrics.record({ agentId, heartbeat: heartbeat.name, outcome }); + }, + onError: ({ agentId, error, phase, heartbeat }) => { + alerts.send(`heartbeat ${agentId}/${heartbeat.name} failed in ${phase}: ${error.message}`); + }, + }, + }); + ``` + + **Signal shaping** + + A heartbeat fire surfaces to the agent as a signal. By default it uses the `notification` type and renders as `<heartbeat>…</heartbeat>`; override `signalType` and `tagName` to change either. `ifActive` and `ifIdle` mirror the `agent.sendSignal` options shape (`{ behavior, attributes }`, plus `streamOptions` on `ifIdle`) and stay JSON-serializable so they persist with the schedule. `ifIdle.streamOptions` currently accepts `requestContext`, which is rehydrated onto the woken run. Top-level `attributes` are rendered on the signal tag, and top-level `providerOptions` are merged into the signal payload on every fire. + + ```ts + await mastra.heartbeats.create({ + agentId: 'chef', + threadId, + resourceId, + cron: '*/5 * * * *', + prompt: 'Check in on the user', + tagName: 'check-in', // renders as <check-in>…</check-in> + attributes: { source: 'cron' }, + providerOptions: { openai: { store: false } }, + ifIdle: { + behavior: 'wake', + streamOptions: { requestContext: { locale: 'en-US' } }, + }, + }); + ``` + +- Added storage-backed discovery of suspended agent runs, so human-in-the-loop approval UIs can recover a pending run after a page refresh or server restart. ([#17898](https://github.com/mastra-ai/mastra/pull/17898)) + + `agent.listSuspendedRuns()` lists runs waiting on a tool-call approval or on a tool that called `suspend()`. Unlike the in-memory `getActiveThreadRunId()`, it reads from storage, so it works after a restart and across multiple server instances: + + ```ts + const { runs, total } = await agent.listSuspendedRuns({ threadId, resourceId }); + if (runs[0]) { + // runs[0].toolCalls -> [{ toolCallId, toolName, args, requiresApproval }] + await agent.approveToolCall({ runId: runs[0].runId, toolCallId: runs[0].toolCalls[0].toolCallId }); + } + ``` + + Supports `threadId`/`resourceId`/date filters and pagination, mirroring `listWorkflowRuns()`. The same surface is exposed over HTTP as `GET /agents/:agentId/suspended-runs` and on the client SDK as `agent.listSuspendedRuns()`; server-enforced request-context values take precedence over client query parameters, so clients cannot list runs outside their scope. + + `sendToolApproval()` now falls back to this storage-backed discovery when no active run is found in memory for the thread, so approvals keep working after a restart. If several suspended runs match, it throws an error asking for a `toolCallId` to disambiguate. + + **Why:** approval UIs previously had no public way to recover a suspended run after a refresh or restart, forcing apps to parse internal workflow snapshots. + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + +## 1.48.0-alpha.4 + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + +## 1.48.0-alpha.3 + +### Patch Changes + +- Fixed inline skills (created via createSkill()) not appearing in the Dev Portal. The server now uses agent.listSkills() and agent.getSkill() which return both inline and workspace skills, instead of only querying workspace skills. ([#18569](https://github.com/mastra-ai/mastra/pull/18569)) + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + ## 1.48.0-alpha.2 ### Patch Changes diff --git a/packages/server/package.json b/packages/server/package.json index 363db05c9bcb..b8e591c79afa 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/server", - "version": "1.48.0-alpha.2", + "version": "1.48.0-alpha.9", "description": "", "type": "module", "files": [ diff --git a/packages/server/src/server/handlers/agents.test.ts b/packages/server/src/server/handlers/agents.test.ts index 5e4d95b874b4..02b41f444ee8 100644 --- a/packages/server/src/server/handlers/agents.test.ts +++ b/packages/server/src/server/handlers/agents.test.ts @@ -31,6 +31,7 @@ import { STREAM_GENERATE_ROUTE, RESUME_STREAM_ROUTE, SEND_TOOL_APPROVAL_ROUTE, + LIST_SUSPENDED_RUNS_ROUTE, QUEUE_AGENT_MESSAGE_ROUTE, SEND_AGENT_MESSAGE_ROUTE, SEND_AGENT_SIGNAL_ROUTE, @@ -1552,6 +1553,124 @@ describe('Agent Routes Authorization', () => { ); }); + it('should list suspended runs with filters passed through', async () => { + const run = { + runId: 'run-123', + status: 'suspended', + threadId: 'thread-123', + resourceId: 'resource-123', + suspendedAt: new Date(), + toolCalls: [ + { toolCallId: 'tool-call-123', toolName: 'findUserTool', args: { name: 'Dero' }, requiresApproval: true }, + ], + }; + (mockAgent as any).listSuspendedRuns = vi.fn(async () => ({ runs: [run], total: 1 })); + await mockMemory.createThread({ + threadId: 'thread-123', + resourceId: 'resource-123', + title: 'Thread 123', + }); + + const fromDate = new Date('2026-01-01'); + const result = await LIST_SUSPENDED_RUNS_ROUTE.handler({ + mastra, + agentId: 'test-agent', + requestContext: new RequestContext(), + threadId: 'thread-123', + resourceId: 'resource-123', + fromDate, + perPage: 10, + page: 0, + } as any); + + expect(result).toEqual({ runs: [run], total: 1 }); + expect((mockAgent as any).listSuspendedRuns).toHaveBeenCalledWith({ + threadId: 'thread-123', + resourceId: 'resource-123', + fromDate, + toDate: undefined, + perPage: 10, + page: 0, + }); + }); + + it('should scope suspended-run listing to context resource and thread values', async () => { + (mockAgent as any).listSuspendedRuns = vi.fn(async () => ({ runs: [], total: 0 })); + await mockMemory.createThread({ + threadId: 'thread-a', + resourceId: 'user-a', + title: 'Thread A', + }); + + await LIST_SUSPENDED_RUNS_ROUTE.handler({ + mastra, + agentId: 'test-agent', + requestContext: createContextWithReservedKeys({ resourceId: 'user-a', threadId: 'thread-a' }), + threadId: 'client-thread-ignored', + resourceId: 'user-b', + } as any); + + expect((mockAgent as any).listSuspendedRuns).toHaveBeenCalledWith( + expect.objectContaining({ threadId: 'thread-a', resourceId: 'user-a' }), + ); + }); + + it('should return 403 when listing suspended runs for a thread owned by a different resource', async () => { + (mockAgent as any).listSuspendedRuns = vi.fn(async () => ({ runs: [], total: 0 })); + await mockMemory.createThread({ + threadId: 'suspended-thread-owned-by-b', + resourceId: 'user-b', + title: 'Thread B', + }); + + await expect( + LIST_SUSPENDED_RUNS_ROUTE.handler({ + mastra, + agentId: 'test-agent', + requestContext: createContextWithReservedKeys({ resourceId: 'user-a' }), + threadId: 'suspended-thread-owned-by-b', + } as any), + ).rejects.toThrow(new HTTPException(403, { message: 'Access denied: thread belongs to a different resource' })); + + expect((mockAgent as any).listSuspendedRuns).not.toHaveBeenCalled(); + }); + + it('should return 403 when a thread filter is requested but the agent has no memory', async () => { + (mockAgent as any).listSuspendedRuns = vi.fn(async () => ({ runs: [], total: 0 })); + const getMemorySpy = vi.spyOn(mockAgent, 'getMemory').mockResolvedValue(undefined as any); + + await expect( + LIST_SUSPENDED_RUNS_ROUTE.handler({ + mastra, + agentId: 'test-agent', + requestContext: createContextWithReservedKeys({ resourceId: 'user-a' }), + threadId: 'some-thread', + } as any), + ).rejects.toThrow( + new HTTPException(403, { + message: 'Access denied: agent has no memory configured to validate thread ownership', + }), + ); + + expect((mockAgent as any).listSuspendedRuns).not.toHaveBeenCalled(); + getMemorySpy.mockRestore(); + }); + + it('should return 403 when a thread filter is requested but the thread does not exist', async () => { + (mockAgent as any).listSuspendedRuns = vi.fn(async () => ({ runs: [], total: 0 })); + + await expect( + LIST_SUSPENDED_RUNS_ROUTE.handler({ + mastra, + agentId: 'test-agent', + requestContext: createContextWithReservedKeys({ resourceId: 'user-a' }), + threadId: 'nonexistent-thread', + } as any), + ).rejects.toThrow(new HTTPException(403, { message: 'Access denied: thread not found' })); + + expect((mockAgent as any).listSuspendedRuns).not.toHaveBeenCalled(); + }); + it('should send a signal using context resource and thread values', async () => { await mockMemory.createThread({ threadId: 'signal-thread-from-context', diff --git a/packages/server/src/server/handlers/agents.ts b/packages/server/src/server/handlers/agents.ts index 96fdc4c234db..99d24ba4ba2a 100644 --- a/packages/server/src/server/handlers/agents.ts +++ b/packages/server/src/server/handlers/agents.ts @@ -46,6 +46,8 @@ import { toolCallResponseSchema, sendToolApprovalBodySchema, sendToolApprovalResponseSchema, + listSuspendedRunsQuerySchema, + listSuspendedRunsResponseSchema, updateAgentModelBodySchema, reorderAgentModelListBodySchema, updateAgentModelInModelListBodySchema, @@ -277,7 +279,7 @@ export interface SerializedAgent { /** Serialized JSON schema for request context validation */ requestContextSchema?: string; - source?: 'code' | 'stored'; + source?: 'code' | 'stored' | 'fs'; status?: 'draft' | 'published' | 'archived'; activeVersionId?: string; hasDraft?: boolean; @@ -355,20 +357,15 @@ export function getSerializedProcessors( } /** - * Extract skills from agent's workspace. - * Uses agent.getWorkspace() to get the workspace and then workspace.skills.list(). + * Extract skills from an agent (both inline and workspace skills). + * Uses agent.listSkills() which merges agent-level and workspace-level skills. */ export async function getSerializedSkillsFromAgent( agent: Agent, requestContext?: RequestContext, ): Promise<SerializedSkill[]> { try { - const workspace = await agent.getWorkspace({ requestContext }); - if (!workspace?.skills) { - return []; - } - - const skillsList = await workspace.skills.list(); + const skillsList = await agent.listSkills({ requestContext }); return skillsList.map(skill => ({ name: skill.name, description: skill.description, @@ -2466,6 +2463,70 @@ export const SEND_TOOL_APPROVAL_ROUTE = createRoute({ }, }); +export const LIST_SUSPENDED_RUNS_ROUTE = createRoute({ + method: 'GET', + path: '/agents/:agentId/suspended-runs', + responseType: 'json' as const, + pathParamSchema: agentIdPathParams, + queryParamSchema: listSuspendedRunsQuerySchema, + responseSchema: listSuspendedRunsResponseSchema, + summary: 'List suspended runs', + description: + 'Lists suspended agent runs from storage — runs waiting on a tool-call approval or on a tool that suspended. Works after a server restart and across instances.', + tags: ['Agents', 'Tools'], + requiresAuth: true, + handler: async ({ mastra, agentId, requestContext, ...query }) => { + try { + const agent = await getAgentFromSystem({ + mastra, + agentId, + versionOptions: extractVersionOptions(requestContext), + }); + + // Honor server-enforced thread/resource scoping from the request context + // so clients cannot list suspended runs outside their own scope. + const effectiveResourceId = getEffectiveResourceId(requestContext, query.resourceId); + const effectiveThreadId = getEffectiveThreadId(requestContext, query.threadId); + + // Validate ownership/FGA before honoring a thread filter — without this a + // caller could probe another user's suspended approvals (including + // tool-call args) by guessing a threadId. Reject when ownership cannot be + // verified (no memory configured, or the thread does not exist) so a + // thread-scoped query is never honored unchecked. + if (effectiveThreadId) { + const memory = await agent.getMemory({ requestContext }); + if (!memory) { + throw new HTTPException(403, { + message: 'Access denied: agent has no memory configured to validate thread ownership', + }); + } + const thread = await memory.getThreadById({ threadId: effectiveThreadId }); + if (!thread) { + throw new HTTPException(403, { message: 'Access denied: thread not found' }); + } + await enforceThreadAccess({ + mastra, + requestContext, + threadId: effectiveThreadId, + thread, + effectiveResourceId, + }); + } + + return await agent.listSuspendedRuns({ + threadId: effectiveThreadId, + resourceId: effectiveResourceId, + fromDate: query.fromDate, + toDate: query.toDate, + perPage: query.perPage, + page: query.page, + }); + } catch (error) { + return handleError(error, 'error listing suspended runs'); + } + }, +}); + export const DECLINE_TOOL_CALL_ROUTE = createRoute({ method: 'POST', path: '/agents/:agentId/decline-tool-call', @@ -3262,7 +3323,7 @@ export const GET_AGENT_SKILL_ROUTE = createRoute({ queryParamSchema: skillDisambiguationQuerySchema, responseSchema: getAgentSkillResponseSchema, summary: 'Get agent skill', - description: 'Returns details for a specific skill available to the agent via its workspace', + description: 'Returns details for a specific skill available to the agent (inline or workspace)', tags: ['Agents', 'Skills'], handler: async ({ mastra, agentId, skillName, path, requestContext }) => { try { @@ -3271,17 +3332,11 @@ export const GET_AGENT_SKILL_ROUTE = createRoute({ throw new HTTPException(404, { message: 'Agent not found' }); } - // Get the agent's workspace - const workspace = await agent.getWorkspace({ requestContext }); - if (!workspace?.skills) { - throw new HTTPException(404, { message: 'Agent does not have skills configured' }); - } - // Use the optional ?path= query param for disambiguation, otherwise fall back to name const identifier = path ? decodeURIComponent(path) : skillName; - // Get the skill from the workspace - const skill = await workspace.skills.get(identifier); + // Get the skill from the agent (searches both inline and workspace skills) + const skill = await agent.getSkill(identifier, { requestContext }); if (!skill) { throw new HTTPException(404, { message: `Skill "${identifier}" not found` }); } diff --git a/packages/server/src/server/handlers/heartbeats.test.ts b/packages/server/src/server/handlers/heartbeats.test.ts new file mode 100644 index 000000000000..8093bd503f27 --- /dev/null +++ b/packages/server/src/server/handlers/heartbeats.test.ts @@ -0,0 +1,311 @@ +import { Agent, HEARTBEAT_SCHEDULE_PREFIX } from '@mastra/core/agent'; +import { Mastra } from '@mastra/core/mastra'; +import { RequestContext } from '@mastra/core/request-context'; +import type { Schedule } from '@mastra/core/storage'; +import { MockStore } from '@mastra/core/storage'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { HTTPException } from '../http-exception'; +import { + CREATE_HEARTBEAT_ROUTE, + DELETE_HEARTBEAT_ROUTE, + GET_HEARTBEAT_ROUTE, + LIST_HEARTBEATS_ROUTE, + PAUSE_HEARTBEAT_ROUTE, + RESUME_HEARTBEAT_ROUTE, + UPDATE_HEARTBEAT_ROUTE, +} from './heartbeats'; + +const baseCtx = () => ({ + requestContext: new RequestContext(), + abortSignal: new AbortController().signal, +}); + +const makeHeartbeatSchedule = (overrides: Partial<Schedule> = {}): Schedule => ({ + id: overrides.id ?? `${HEARTBEAT_SCHEDULE_PREFIX}agent-1_thread-1`, + ownerType: 'agent', + ownerId: 'agent-1', + target: { + type: 'heartbeat', + agentId: 'agent-1', + threadId: 'thread-1', + resourceId: 'resource-1', + prompt: 'Check in', + ...((overrides.target as any) ?? {}), + }, + cron: '0 * * * *', + status: 'active', + nextFireAt: 1_000_000, + createdAt: 100, + updatedAt: 100, + ...overrides, +}); + +describe('Heartbeats handlers', () => { + let storage: InstanceType<typeof MockStore>; + let mastra: Mastra; + let agent: Agent; + + beforeEach(async () => { + storage = new MockStore(); + agent = new Agent({ + id: 'agent-1', + name: 'agent-1', + instructions: 'test', + model: {} as any, + }); + mastra = new Mastra({ + agents: { 'agent-1': agent }, + storage, + logger: false, + }); + }); + + describe('LIST_HEARTBEATS_ROUTE', () => { + it('returns empty list when no heartbeats exist', async () => { + const result = await LIST_HEARTBEATS_ROUTE.handler({ mastra, ...baseCtx() } as any); + expect(result).toEqual({ heartbeats: [] }); + }); + + it('returns heartbeats across agents', async () => { + const schedulesStore = (await storage.getStore('schedules'))!; + await schedulesStore.createSchedule(makeHeartbeatSchedule()); + await schedulesStore.createSchedule( + makeHeartbeatSchedule({ + id: `${HEARTBEAT_SCHEDULE_PREFIX}agent-2_t`, + ownerId: 'agent-2', + target: { type: 'heartbeat', agentId: 'agent-2', prompt: 'Hi' }, + }), + ); + + const result = await LIST_HEARTBEATS_ROUTE.handler({ mastra, ...baseCtx() } as any); + expect(result.heartbeats).toHaveLength(2); + expect(result.heartbeats.map(h => h.agentId).sort()).toEqual(['agent-1', 'agent-2']); + }); + + it('filters by agentId', async () => { + const schedulesStore = (await storage.getStore('schedules'))!; + await schedulesStore.createSchedule(makeHeartbeatSchedule()); + await schedulesStore.createSchedule( + makeHeartbeatSchedule({ + id: `${HEARTBEAT_SCHEDULE_PREFIX}agent-2_t`, + ownerId: 'agent-2', + target: { type: 'heartbeat', agentId: 'agent-2', prompt: 'Hi' }, + }), + ); + + const result = await LIST_HEARTBEATS_ROUTE.handler({ + mastra, + agentId: 'agent-1', + ...baseCtx(), + } as any); + expect(result.heartbeats).toHaveLength(1); + expect(result.heartbeats[0].agentId).toBe('agent-1'); + }); + + it('excludes schedules that are not heartbeats', async () => { + const schedulesStore = (await storage.getStore('schedules'))!; + await schedulesStore.createSchedule(makeHeartbeatSchedule()); + await schedulesStore.createSchedule({ + id: 'wf_other', + ownerType: 'agent', + ownerId: 'agent-1', + target: { type: 'workflow', workflowId: 'something-else' }, + cron: '0 * * * *', + status: 'active', + nextFireAt: 1_000_000, + createdAt: 100, + updatedAt: 100, + }); + + const result = await LIST_HEARTBEATS_ROUTE.handler({ mastra, ...baseCtx() } as any); + expect(result.heartbeats).toHaveLength(1); + expect(result.heartbeats[0].id).toMatch(/^hb_/); + }); + }); + + describe('GET_HEARTBEAT_ROUTE', () => { + it('returns the heartbeat by id', async () => { + const schedulesStore = (await storage.getStore('schedules'))!; + const schedule = makeHeartbeatSchedule(); + await schedulesStore.createSchedule(schedule); + + const result = await GET_HEARTBEAT_ROUTE.handler({ + mastra, + agentId: 'agent-1', + heartbeatId: schedule.id, + ...baseCtx(), + } as any); + expect(result.id).toBe(schedule.id); + expect(result.agentId).toBe('agent-1'); + expect(result.threadId).toBe('thread-1'); + expect(result.prompt).toBe('Check in'); + }); + + it('404s when the heartbeat does not exist', async () => { + await expect( + GET_HEARTBEAT_ROUTE.handler({ + mastra, + agentId: 'agent-1', + heartbeatId: `${HEARTBEAT_SCHEDULE_PREFIX}missing`, + ...baseCtx(), + } as any), + ).rejects.toThrow(HTTPException); + }); + }); + + describe('DELETE_HEARTBEAT_ROUTE', () => { + it('deletes the heartbeat and removes the schedule row', async () => { + const schedulesStore = (await storage.getStore('schedules'))!; + const schedule = makeHeartbeatSchedule(); + await schedulesStore.createSchedule(schedule); + + const result = await DELETE_HEARTBEAT_ROUTE.handler({ + mastra, + agentId: 'agent-1', + heartbeatId: schedule.id, + ...baseCtx(), + } as any); + + expect(result).toEqual({ message: 'Heartbeat deleted' }); + expect(await schedulesStore.getSchedule(schedule.id)).toBeFalsy(); + }); + + it('404s when the heartbeat does not exist', async () => { + await expect( + DELETE_HEARTBEAT_ROUTE.handler({ + mastra, + agentId: 'agent-1', + heartbeatId: `${HEARTBEAT_SCHEDULE_PREFIX}missing`, + ...baseCtx(), + } as any), + ).rejects.toThrow(HTTPException); + }); + }); + + describe('PAUSE_HEARTBEAT_ROUTE / RESUME_HEARTBEAT_ROUTE', () => { + it('pauses an active heartbeat and resume sets it back to active', async () => { + const schedulesStore = (await storage.getStore('schedules'))!; + const schedule = makeHeartbeatSchedule(); + await schedulesStore.createSchedule(schedule); + + const paused = await PAUSE_HEARTBEAT_ROUTE.handler({ + mastra, + agentId: 'agent-1', + heartbeatId: schedule.id, + ...baseCtx(), + } as any); + expect(paused.status).toBe('paused'); + + const resumed = await RESUME_HEARTBEAT_ROUTE.handler({ + mastra, + agentId: 'agent-1', + heartbeatId: schedule.id, + ...baseCtx(), + } as any); + expect(resumed.status).toBe('active'); + // resume recomputes nextFireAt from "now" so it must move forward + expect(resumed.nextFireAt).toBeGreaterThan(schedule.nextFireAt); + }); + + it('pause is idempotent on already-paused heartbeats', async () => { + const schedulesStore = (await storage.getStore('schedules'))!; + const schedule = makeHeartbeatSchedule({ status: 'paused' }); + await schedulesStore.createSchedule(schedule); + + const result = await PAUSE_HEARTBEAT_ROUTE.handler({ + mastra, + agentId: 'agent-1', + heartbeatId: schedule.id, + ...baseCtx(), + } as any); + expect(result.status).toBe('paused'); + expect(result.nextFireAt).toBe(schedule.nextFireAt); + }); + }); + + describe('UPDATE_HEARTBEAT_ROUTE', () => { + it('updates prompt without touching cron/nextFireAt', async () => { + const schedulesStore = (await storage.getStore('schedules'))!; + const schedule = makeHeartbeatSchedule(); + await schedulesStore.createSchedule(schedule); + + const result = await UPDATE_HEARTBEAT_ROUTE.handler({ + mastra, + agentId: 'agent-1', + heartbeatId: schedule.id, + prompt: 'New prompt', + ...baseCtx(), + } as any); + expect(result.prompt).toBe('New prompt'); + expect(result.cron).toBe(schedule.cron); + expect(result.nextFireAt).toBe(schedule.nextFireAt); + }); + + it('updates cron and recomputes nextFireAt', async () => { + const schedulesStore = (await storage.getStore('schedules'))!; + const schedule = makeHeartbeatSchedule(); + await schedulesStore.createSchedule(schedule); + + const result = await UPDATE_HEARTBEAT_ROUTE.handler({ + mastra, + agentId: 'agent-1', + heartbeatId: schedule.id, + cron: '*/5 * * * *', + ...baseCtx(), + } as any); + expect(result.cron).toBe('*/5 * * * *'); + expect(result.nextFireAt).toBeGreaterThan(schedule.nextFireAt); + }); + + it('rejects invalid cron', async () => { + const schedulesStore = (await storage.getStore('schedules'))!; + const schedule = makeHeartbeatSchedule(); + await schedulesStore.createSchedule(schedule); + + await expect( + UPDATE_HEARTBEAT_ROUTE.handler({ + mastra, + agentId: 'agent-1', + heartbeatId: schedule.id, + cron: 'not-a-cron', + ...baseCtx(), + } as any), + ).rejects.toThrow(); + }); + }); + + describe('CREATE_HEARTBEAT_ROUTE', () => { + it('creates a heartbeat via mastra.heartbeats.create', async () => { + const result = await CREATE_HEARTBEAT_ROUTE.handler({ + mastra, + agentId: 'agent-1', + cron: '0 * * * *', + prompt: 'Hello', + threadId: 'thread-2', + resourceId: 'resource-2', + ...baseCtx(), + } as any); + + expect(result.agentId).toBe('agent-1'); + expect(result.threadId).toBe('thread-2'); + expect(result.prompt).toBe('Hello'); + + const schedulesStore = (await storage.getStore('schedules'))!; + const created = await schedulesStore.getSchedule(result.id); + expect(created).toBeDefined(); + expect(created!.ownerId).toBe('agent-1'); + }); + + it('404s when the agent does not exist', async () => { + await expect( + CREATE_HEARTBEAT_ROUTE.handler({ + mastra, + agentId: 'unknown-agent', + cron: '0 * * * *', + prompt: 'Hello', + ...baseCtx(), + } as any), + ).rejects.toThrow(); + }); + }); +}); diff --git a/packages/server/src/server/handlers/heartbeats.ts b/packages/server/src/server/handlers/heartbeats.ts new file mode 100644 index 000000000000..744d5a9c661d --- /dev/null +++ b/packages/server/src/server/handlers/heartbeats.ts @@ -0,0 +1,219 @@ +import type { Mastra } from '@mastra/core'; +import { HTTPException } from '../http-exception'; +import { + createHeartbeatBodySchema, + deleteHeartbeatResponseSchema, + heartbeatPathParams, + heartbeatSchema, + listHeartbeatsQuerySchema, + listHeartbeatsResponseSchema, + runHeartbeatResponseSchema, + updateHeartbeatBodySchema, +} from '../schemas/heartbeats'; +import { createRoute } from '../server-adapter/routes/route-builder'; + +/** + * Lazily access `mastra.heartbeats`. The Heartbeats service may not exist on + * older `@mastra/core` versions; in that case the handler returns 404 so + * older cores degrade gracefully when paired with this `@mastra/server`. + */ +function getHeartbeats(mastra: Mastra): any { + const svc = (mastra as unknown as { heartbeats?: unknown }).heartbeats; + if (!svc) { + throw new HTTPException(404, { message: 'Heartbeats not supported by this server' }); + } + return svc; +} + +/** + * Resolve a heartbeat by its globally-unique `hb_<uuid>` id. Returns 404 for + * missing rows. Heartbeats are addressed by id (consistent with + * `/schedules/:scheduleId`); the owning `agentId` is a property, not a key. + */ +async function loadHeartbeat(mastra: Mastra, heartbeatId: string) { + const heartbeats = getHeartbeats(mastra); + const heartbeat = await heartbeats.get(heartbeatId); + if (!heartbeat) { + throw new HTTPException(404, { message: 'Heartbeat not found' }); + } + return heartbeat; +} + +export const LIST_HEARTBEATS_ROUTE = createRoute({ + method: 'GET', + path: '/heartbeats', + responseType: 'json' as const, + queryParamSchema: listHeartbeatsQuerySchema, + responseSchema: listHeartbeatsResponseSchema, + summary: 'List heartbeats across all agents', + description: 'Returns the configured heartbeats, optionally filtered by agentId/threadId/resourceId/name.', + tags: ['Heartbeats'], + requiresAuth: true, + handler: async ({ mastra, agentId, threadId, resourceId, name }) => { + const heartbeats = getHeartbeats(mastra); + const filter: Record<string, string> = {}; + if (agentId) filter.agentId = agentId; + if (threadId) filter.threadId = threadId; + if (resourceId) filter.resourceId = resourceId; + if (name) filter.name = name; + const rows = await heartbeats.list(filter); + return { heartbeats: rows }; + }, +}); + +export const GET_HEARTBEAT_ROUTE = createRoute({ + method: 'GET', + path: '/heartbeats/:heartbeatId', + responseType: 'json' as const, + pathParamSchema: heartbeatPathParams, + responseSchema: heartbeatSchema, + summary: 'Get a heartbeat by ID', + description: 'Returns a single heartbeat by its id.', + tags: ['Heartbeats'], + requiresAuth: true, + handler: async ({ mastra, heartbeatId }) => { + return await loadHeartbeat(mastra, heartbeatId); + }, +}); + +export const CREATE_HEARTBEAT_ROUTE = createRoute({ + method: 'POST', + path: '/heartbeats', + responseType: 'json' as const, + bodySchema: createHeartbeatBodySchema, + responseSchema: heartbeatSchema, + summary: 'Create a heartbeat', + description: + 'Creates a new heartbeat owned by the agent named in `agentId`. Multiple heartbeats per agent/thread are supported; each gets a random `hb_<uuid>` id. Use `name` to label distinct heartbeats on the same agent/thread.', + tags: ['Heartbeats'], + requiresAuth: true, + handler: async ({ mastra, agentId, ...body }) => { + // getAgentById throws a MastraError (status 404) when the agent is unknown; + // translate that into a clean HTTP 404 instead of letting it surface as 500. + try { + mastra.getAgentById(agentId); + } catch { + throw new HTTPException(404, { message: `Agent "${agentId}" not found` }); + } + const heartbeats = getHeartbeats(mastra); + return await heartbeats.create({ + agentId, + cron: body.cron, + prompt: body.prompt, + ...(body.id ? { id: body.id } : {}), + ...(body.name ? { name: body.name } : {}), + ...(body.timezone ? { timezone: body.timezone } : {}), + ...(body.threadId ? { threadId: body.threadId } : {}), + ...(body.resourceId ? { resourceId: body.resourceId } : {}), + ...(body.signalType ? { signalType: body.signalType } : {}), + ...(body.tagName ? { tagName: body.tagName } : {}), + ...(body.attributes ? { attributes: body.attributes } : {}), + ...(body.ifActive ? { ifActive: body.ifActive } : {}), + ...(body.ifIdle ? { ifIdle: body.ifIdle } : {}), + ...(body.providerOptions ? { providerOptions: body.providerOptions } : {}), + ...(body.metadata ? { metadata: body.metadata } : {}), + }); + }, +}); + +export const UPDATE_HEARTBEAT_ROUTE = createRoute({ + method: 'PATCH', + path: '/heartbeats/:heartbeatId', + responseType: 'json' as const, + pathParamSchema: heartbeatPathParams, + bodySchema: updateHeartbeatBodySchema, + responseSchema: heartbeatSchema, + summary: 'Update a heartbeat', + description: + 'Partial update of a heartbeat. `threadId` and `resourceId` are part of the heartbeat identity and cannot be changed — to re-target, delete and recreate. Editing `cron` (or `timezone`) recomputes `nextFireAt`.', + tags: ['Heartbeats'], + requiresAuth: true, + handler: async ({ mastra, heartbeatId, ...body }) => { + await loadHeartbeat(mastra, heartbeatId); + const heartbeats = getHeartbeats(mastra); + return await heartbeats.update(heartbeatId, { + ...(body.cron !== undefined ? { cron: body.cron } : {}), + ...(body.timezone !== undefined ? { timezone: body.timezone } : {}), + ...(body.prompt !== undefined ? { prompt: body.prompt } : {}), + ...(body.name !== undefined ? { name: body.name } : {}), + ...(body.signalType !== undefined ? { signalType: body.signalType } : {}), + ...(body.tagName !== undefined ? { tagName: body.tagName } : {}), + ...(body.attributes !== undefined ? { attributes: body.attributes } : {}), + ...(body.ifActive !== undefined ? { ifActive: body.ifActive } : {}), + ...(body.ifIdle !== undefined ? { ifIdle: body.ifIdle } : {}), + ...(body.providerOptions !== undefined ? { providerOptions: body.providerOptions } : {}), + ...(body.metadata !== undefined ? { metadata: body.metadata } : {}), + }); + }, +}); + +export const DELETE_HEARTBEAT_ROUTE = createRoute({ + method: 'DELETE', + path: '/heartbeats/:heartbeatId', + responseType: 'json' as const, + pathParamSchema: heartbeatPathParams, + responseSchema: deleteHeartbeatResponseSchema, + summary: 'Delete a heartbeat', + description: 'Permanently deletes the heartbeat. 404 when the heartbeat does not exist.', + tags: ['Heartbeats'], + requiresAuth: true, + handler: async ({ mastra, heartbeatId }) => { + await loadHeartbeat(mastra, heartbeatId); + const heartbeats = getHeartbeats(mastra); + await heartbeats.delete(heartbeatId); + return { message: 'Heartbeat deleted' }; + }, +}); + +export const PAUSE_HEARTBEAT_ROUTE = createRoute({ + method: 'POST', + path: '/heartbeats/:heartbeatId/pause', + responseType: 'json' as const, + pathParamSchema: heartbeatPathParams, + responseSchema: heartbeatSchema, + summary: 'Pause a heartbeat', + description: 'Marks the heartbeat as paused. The scheduler tick loop will skip paused heartbeats. Idempotent.', + tags: ['Heartbeats'], + requiresAuth: true, + handler: async ({ mastra, heartbeatId }) => { + await loadHeartbeat(mastra, heartbeatId); + const heartbeats = getHeartbeats(mastra); + return await heartbeats.pause(heartbeatId); + }, +}); + +export const RESUME_HEARTBEAT_ROUTE = createRoute({ + method: 'POST', + path: '/heartbeats/:heartbeatId/resume', + responseType: 'json' as const, + pathParamSchema: heartbeatPathParams, + responseSchema: heartbeatSchema, + summary: 'Resume a paused heartbeat', + description: + 'Marks the heartbeat as active and recomputes nextFireAt from "now" so a long-paused heartbeat does not fire a backlog. Idempotent.', + tags: ['Heartbeats'], + requiresAuth: true, + handler: async ({ mastra, heartbeatId }) => { + await loadHeartbeat(mastra, heartbeatId); + const heartbeats = getHeartbeats(mastra); + return await heartbeats.resume(heartbeatId); + }, +}); + +export const RUN_HEARTBEAT_ROUTE = createRoute({ + method: 'POST', + path: '/heartbeats/:heartbeatId/run', + responseType: 'json' as const, + pathParamSchema: heartbeatPathParams, + responseSchema: runHeartbeatResponseSchema, + summary: 'Fire a heartbeat now', + description: + 'Manually triggers a single heartbeat run out-of-band from the cron schedule. Records a trigger row with `triggerKind: "manual"`. Does not advance `nextFireAt`.', + tags: ['Heartbeats'], + requiresAuth: true, + handler: async ({ mastra, heartbeatId }) => { + await loadHeartbeat(mastra, heartbeatId); + const heartbeats = getHeartbeats(mastra); + return await heartbeats.run(heartbeatId); + }, +}); diff --git a/packages/server/src/server/handlers/schedules-workflows-shim.ts b/packages/server/src/server/handlers/schedules-workflows-shim.ts index fb8ce3bf8b6a..ac5556a6f954 100644 --- a/packages/server/src/server/handlers/schedules-workflows-shim.ts +++ b/packages/server/src/server/handlers/schedules-workflows-shim.ts @@ -20,13 +20,22 @@ import * as coreWorkflows from '@mastra/core/workflows'; -const exported = (coreWorkflows as Record<string, unknown>).computeNextFireAt; +const exportedNext = (coreWorkflows as Record<string, unknown>).computeNextFireAt; +const exportedValidate = (coreWorkflows as Record<string, unknown>).validateCron; export const computeNextFireAt: any = - exported ?? + exportedNext ?? (() => { throw new Error( '`computeNextFireAt` is not available in this version of @mastra/core. ' + 'Schedules require @mastra/core >= 1.32.0.', ); }); + +export const validateCron: any = + exportedValidate ?? + (() => { + throw new Error( + '`validateCron` is not available in this version of @mastra/core. ' + 'Schedules require @mastra/core >= 1.32.0.', + ); + }); diff --git a/packages/server/src/server/handlers/scores.ts b/packages/server/src/server/handlers/scores.ts index c011e5d65f8b..919186b342fc 100644 --- a/packages/server/src/server/handlers/scores.ts +++ b/packages/server/src/server/handlers/scores.ts @@ -36,7 +36,7 @@ async function listScorersFromSystem({ agentNames: string[]; workflowIds: string[]; isRegistered: boolean; - source: 'code' | 'stored'; + source: 'code' | 'stored' | 'fs'; } >(); diff --git a/packages/server/src/server/schemas/agents.ts b/packages/server/src/server/schemas/agents.ts index 3e41b3cd451a..15dc238bde5f 100644 --- a/packages/server/src/server/schemas/agents.ts +++ b/packages/server/src/server/schemas/agents.ts @@ -212,7 +212,7 @@ export const serializedAgentSchema = z.object({ defaultOptions: defaultOptionsSchema.optional(), defaultGenerateOptionsLegacy: z.record(z.string(), z.any()).optional(), defaultStreamOptionsLegacy: z.record(z.string(), z.any()).optional(), - source: z.enum(['code', 'stored']).optional(), + source: z.enum(['code', 'stored', 'fs']).optional(), status: z.enum(['draft', 'published', 'archived']).optional(), activeVersionId: z.string().optional(), hasDraft: z.boolean().optional(), @@ -476,6 +476,49 @@ export const sendToolApprovalResponseSchema = z.object({ toolCallId: z.string().optional(), }); +/** + * Query schema for listing suspended agent runs + */ +export const listSuspendedRunsQuerySchema = z + .object({ + threadId: z.string().optional(), + resourceId: z.string().optional(), + fromDate: z.coerce.date().optional(), + toDate: z.coerce.date().optional(), + perPage: z.coerce.number().int().positive().optional(), + // page is zero-indexed, so 0 is valid + page: z.coerce.number().int().nonnegative().optional(), + }) + .refine(data => !data.fromDate || !data.toDate || data.fromDate <= data.toDate, { + message: 'fromDate must be less than or equal to toDate', + path: ['fromDate'], + }); + +/** + * Response schema for listing suspended agent runs + */ +export const listSuspendedRunsResponseSchema = z.object({ + runs: z.array( + z.object({ + runId: z.string(), + status: z.literal('suspended'), + threadId: z.string().optional(), + resourceId: z.string().optional(), + suspendedAt: z.date(), + toolCalls: z.array( + z.object({ + toolCallId: z.string().optional(), + toolName: z.string().optional(), + args: z.unknown().optional(), + requiresApproval: z.boolean(), + suspendPayload: z.unknown().optional(), + }), + ), + }), + ), + total: z.number().int().nonnegative(), +}); + // ============================================================================ // Resume Stream Schema // ============================================================================ diff --git a/packages/server/src/server/schemas/heartbeats.ts b/packages/server/src/server/schemas/heartbeats.ts new file mode 100644 index 000000000000..e67b9a35a7fd --- /dev/null +++ b/packages/server/src/server/schemas/heartbeats.ts @@ -0,0 +1,130 @@ +import { z } from 'zod'; +import { scheduleRunSummarySchema } from './schedules'; + +/** Attributes rendered onto the signal's XML tag. */ +const signalAttributesSchema = z.record( + z.string(), + z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(), +); + +/** Behavior + attributes applied when the thread is already streaming. */ +const ifActiveSchema = z.object({ + behavior: z.enum(['deliver', 'persist', 'discard']).optional(), + attributes: signalAttributesSchema.optional(), +}); + +/** + * Behavior + attributes applied when the thread is idle, plus a serializable + * subset of stream options forwarded to the woken run. + */ +const ifIdleSchema = z.object({ + behavior: z.enum(['wake', 'persist', 'discard']).optional(), + attributes: signalAttributesSchema.optional(), + streamOptions: z + .object({ + requestContext: z.record(z.string(), z.unknown()).optional(), + }) + .optional(), +}); + +/** + * Public Heartbeat view model. + * + * Heartbeats are persisted as `Schedule` rows with a dedicated + * `target.type === 'heartbeat'` variant. The HTTP surface flattens that + * representation to the fields a user cares about (cron, prompt, threading, + * status, lifecycle) without exposing the schedule plumbing or internal + * identifier prefix. + */ +export const heartbeatSchema = z.object({ + id: z.string(), + agentId: z.string(), + name: z.string().optional(), + threadId: z.string().optional(), + resourceId: z.string().optional(), + prompt: z.string(), + cron: z.string(), + timezone: z.string().optional(), + status: z.enum(['active', 'paused']), + nextFireAt: z.number(), + lastFireAt: z.number().optional(), + lastRunId: z.string().optional(), + lastRun: scheduleRunSummarySchema.optional(), + signalType: z.string().optional(), + tagName: z.string().optional(), + attributes: signalAttributesSchema.optional(), + ifActive: ifActiveSchema.optional(), + ifIdle: ifIdleSchema.optional(), + providerOptions: z.record(z.string(), z.unknown()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + createdAt: z.number(), + updatedAt: z.number(), +}); + +export const listHeartbeatsResponseSchema = z.object({ + heartbeats: z.array(heartbeatSchema), +}); + +export const listHeartbeatsQuerySchema = z.object({ + agentId: z.string().optional(), + threadId: z.string().optional(), + resourceId: z.string().optional(), + name: z.string().optional(), +}); + +export const heartbeatPathParams = z.object({ + heartbeatId: z.string(), +}); + +/** Body for POST /heartbeats — creates a heartbeat. */ +export const createHeartbeatBodySchema = z.object({ + /** Optional stable id; normalized to `hb_<slug>`. A random id is generated when omitted. */ + id: z.string().optional(), + agentId: z.string(), + cron: z.string(), + timezone: z.string().optional(), + prompt: z.string(), + name: z.string().optional(), + threadId: z.string().optional(), + resourceId: z.string().optional(), + signalType: z.string().optional(), + tagName: z.string().optional(), + attributes: signalAttributesSchema.optional(), + ifActive: ifActiveSchema.optional(), + ifIdle: ifIdleSchema.optional(), + providerOptions: z.record(z.string(), z.unknown()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +/** + * Body for PATCH /heartbeats/:heartbeatId — partial update. + * + * `threadId` / `resourceId` are intentionally not editable; they are part of + * the heartbeat's identity. To re-target, delete and recreate. + */ +export const updateHeartbeatBodySchema = z.object({ + cron: z.string().optional(), + timezone: z.string().optional(), + prompt: z.string().optional(), + name: z.string().optional(), + signalType: z.string().optional(), + tagName: z.string().optional(), + attributes: signalAttributesSchema.optional(), + ifActive: ifActiveSchema.optional(), + ifIdle: ifIdleSchema.optional(), + providerOptions: z.record(z.string(), z.unknown()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +export const deleteHeartbeatResponseSchema = z.object({ + message: z.string(), +}); + +/** Response for POST /heartbeats/:heartbeatId/run. */ +export const runHeartbeatResponseSchema = z.object({ + scheduleId: z.string(), + claimId: z.string(), + scheduledFireAt: z.number(), +}); + +export type HeartbeatResponse = z.infer<typeof heartbeatSchema>; diff --git a/packages/server/src/server/schemas/schedules.ts b/packages/server/src/server/schemas/schedules.ts index 2bb02b60f065..340983876134 100644 --- a/packages/server/src/server/schemas/schedules.ts +++ b/packages/server/src/server/schemas/schedules.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const scheduleStatusSchema = z.enum(['active', 'paused']); -export const scheduleTargetSchema = z.object({ +const workflowScheduleTargetSchema = z.object({ type: z.literal('workflow'), workflowId: z.string(), inputData: z.unknown().optional(), @@ -10,6 +10,46 @@ export const scheduleTargetSchema = z.object({ requestContext: z.record(z.string(), z.unknown()).optional(), }); +const signalAttributesSchema = z.record( + z.string(), + z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(), +); + +const ifActiveSchema = z.object({ + behavior: z.enum(['deliver', 'persist', 'discard']).optional(), + attributes: signalAttributesSchema.optional(), +}); + +const ifIdleSchema = z.object({ + behavior: z.enum(['wake', 'persist', 'discard']).optional(), + attributes: signalAttributesSchema.optional(), + streamOptions: z + .object({ + requestContext: z.record(z.string(), z.unknown()).optional(), + }) + .optional(), +}); + +const heartbeatScheduleTargetSchema = z.object({ + type: z.literal('heartbeat'), + agentId: z.string(), + prompt: z.string(), + threadId: z.string().optional(), + resourceId: z.string().optional(), + signalType: z.string().optional(), + tagName: z.string().optional(), + attributes: signalAttributesSchema.optional(), + ifActive: ifActiveSchema.optional(), + ifIdle: ifIdleSchema.optional(), + providerOptions: z.record(z.string(), z.unknown()).optional(), + requestContext: z.record(z.string(), z.unknown()).optional(), +}); + +export const scheduleTargetSchema = z.discriminatedUnion('type', [ + workflowScheduleTargetSchema, + heartbeatScheduleTargetSchema, +]); + export const workflowRunStatusSchema = z.enum([ 'running', 'success', @@ -51,8 +91,17 @@ export const scheduleResponseSchema = z.object({ export const scheduleTriggerOutcomeSchema = z.enum([ 'published', - 'failed', + 'succeeded', + 'delivered', + 'persisted', + 'discarded', 'skipped', + 'aborted', + 'failed', + // Legacy queue/notification outcomes — no longer written, but trigger rows + // persisted by older builds may still carry them. Kept readable so the + // response validator does not reject historical rows. Mirrors the core + // ScheduleTriggerOutcome union. 'acked', 'alerted', 'deferred', @@ -62,7 +111,7 @@ export const scheduleTriggerOutcomeSchema = z.enum([ 'dropped-busy', ]); -export const scheduleTriggerKindSchema = z.enum(['schedule-fire', 'queue-drain']); +export const scheduleTriggerKindSchema = z.enum(['schedule-fire', 'queue-drain', 'manual']); export const scheduleTriggerResponseSchema = z.object({ id: z.string().optional(), diff --git a/packages/server/src/server/schemas/scores.ts b/packages/server/src/server/schemas/scores.ts index 7cbd7cda311b..ad45d3e444f5 100644 --- a/packages/server/src/server/schemas/scores.ts +++ b/packages/server/src/server/schemas/scores.ts @@ -36,7 +36,7 @@ export const scorerEntrySchema = z.object({ agentNames: z.array(z.string()), workflowIds: z.array(z.string()), isRegistered: z.boolean(), - source: z.enum(['code', 'stored']), + source: z.enum(['code', 'stored', 'fs']), }); /** diff --git a/packages/server/src/server/server-adapter/routes/agents.ts b/packages/server/src/server/server-adapter/routes/agents.ts index 5bc046ad045a..2875b8021348 100644 --- a/packages/server/src/server/server-adapter/routes/agents.ts +++ b/packages/server/src/server/server-adapter/routes/agents.ts @@ -16,6 +16,7 @@ import { GET_PROVIDERS_ROUTE, APPROVE_TOOL_CALL_ROUTE, SEND_TOOL_APPROVAL_ROUTE, + LIST_SUSPENDED_RUNS_ROUTE, DECLINE_TOOL_CALL_ROUTE, RESUME_STREAM_ROUTE, APPROVE_TOOL_CALL_GENERATE_ROUTE, @@ -87,6 +88,7 @@ export const AGENTS_ROUTES: readonly ServerRoute[] = [ EXECUTE_AGENT_TOOL_ROUTE, APPROVE_TOOL_CALL_ROUTE, SEND_TOOL_APPROVAL_ROUTE, + LIST_SUSPENDED_RUNS_ROUTE, DECLINE_TOOL_CALL_ROUTE, RESUME_STREAM_ROUTE, APPROVE_TOOL_CALL_GENERATE_ROUTE, @@ -164,6 +166,7 @@ export type AgentRoutes = readonly [ typeof EXECUTE_AGENT_TOOL_ROUTE, typeof APPROVE_TOOL_CALL_ROUTE, typeof SEND_TOOL_APPROVAL_ROUTE, + typeof LIST_SUSPENDED_RUNS_ROUTE, typeof DECLINE_TOOL_CALL_ROUTE, typeof RESUME_STREAM_ROUTE, typeof RESUME_STREAM_UNTIL_IDLE_ROUTE, diff --git a/packages/server/src/server/server-adapter/routes/heartbeats.ts b/packages/server/src/server/server-adapter/routes/heartbeats.ts new file mode 100644 index 000000000000..cd0ead227515 --- /dev/null +++ b/packages/server/src/server/server-adapter/routes/heartbeats.ts @@ -0,0 +1,22 @@ +import { + CREATE_HEARTBEAT_ROUTE, + DELETE_HEARTBEAT_ROUTE, + GET_HEARTBEAT_ROUTE, + LIST_HEARTBEATS_ROUTE, + PAUSE_HEARTBEAT_ROUTE, + RESUME_HEARTBEAT_ROUTE, + RUN_HEARTBEAT_ROUTE, + UPDATE_HEARTBEAT_ROUTE, +} from '../../handlers/heartbeats'; +import type { ServerRoute } from '.'; + +export const HEARTBEATS_ROUTES: ServerRoute<any, any, any>[] = [ + LIST_HEARTBEATS_ROUTE, + GET_HEARTBEAT_ROUTE, + CREATE_HEARTBEAT_ROUTE, + UPDATE_HEARTBEAT_ROUTE, + DELETE_HEARTBEAT_ROUTE, + PAUSE_HEARTBEAT_ROUTE, + RESUME_HEARTBEAT_ROUTE, + RUN_HEARTBEAT_ROUTE, +]; diff --git a/packages/server/src/server/server-adapter/routes/index.ts b/packages/server/src/server/server-adapter/routes/index.ts index 69486bf0e8a7..857e4be75518 100644 --- a/packages/server/src/server/server-adapter/routes/index.ts +++ b/packages/server/src/server/server-adapter/routes/index.ts @@ -17,6 +17,7 @@ import { CHANNELS_ROUTES } from './channels'; import { CONVERSATIONS_ROUTES } from './conversations'; import { DATASETS_ROUTES } from './datasets'; import { EDITOR_BUILDER_ROUTES } from './editor-builder'; +import { HEARTBEATS_ROUTES } from './heartbeats'; import { LEGACY_ROUTES } from './legacy'; import { LOGS_ROUTES } from './logs'; import { MCP_ROUTES } from './mcp'; @@ -192,6 +193,7 @@ export const SERVER_ROUTES: readonly ServerRoute[] = [ ...EDITOR_BUILDER_ROUTES, ...AGENT_BUILDER_ROUTES, ...SCHEDULES_ROUTES, + ...HEARTBEATS_ROUTES, ...CHANNELS_ROUTES, ...AGENT_CONTROLLER_ROUTES, ]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05fdd0d23f1e..d959fdae06b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -438,7 +438,7 @@ importers: dependencies: better-auth: specifier: ^1.4.18 - version: 1.6.11(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(mongodb@7.2.0(@aws-sdk/credential-providers@3.1006.0)(socks@2.8.7))(mysql2@3.22.4(@types/node@22.19.15))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8) + version: 1.6.11(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260511.1)(@libsql/client@0.17.4(bufferutil@4.1.0))(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(@upstash/redis@1.38.0)(kysely@0.28.17)(mysql2@3.22.4(@types/node@22.19.15))(pg@8.21.0)(sql.js@1.14.1)(sqlite3@5.1.7))(mongodb@7.2.0(@aws-sdk/credential-providers@3.1006.0)(@mongodb-js/zstd@7.0.0)(socks@2.8.7))(mysql2@3.22.4(@types/node@22.19.15))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8) hono: specifier: ^4.0.0 version: 4.12.25 @@ -1858,16 +1858,16 @@ importers: dependencies: '@ai-sdk/google': specifier: ^2.0.62 - version: 2.0.72(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76) + version: 2.0.72(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76) '@ai-sdk/openai': specifier: ^2.0.99 - version: 2.0.106(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76) + version: 2.0.106(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76) '@ai-sdk/provider': specifier: ^2.0.1 version: 2.0.3 '@ai-sdk/provider-utils': specifier: ^3.0.22 - version: 3.0.25(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76) + version: 3.0.25(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76) '@huggingface/hub': specifier: ^0.15.2 version: 0.15.2 @@ -1940,7 +1940,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(msw@2.14.6(@types/node@22.19.15)(typescript@6.0.3))(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(msw@2.14.6(@types/node@22.19.15)(typescript@6.0.3))(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) integrations/brightdata: devDependencies: @@ -2057,6 +2057,9 @@ importers: '@agentclientprotocol/sdk': specifier: ^0.21.0 version: 0.21.0(zod@4.4.3) + '@ai-sdk/amazon-bedrock': + specifier: ^3.0.102 + version: 3.0.102(zod@4.4.3) '@ai-sdk/anthropic': specifier: ^3.0.82 version: 3.0.82(zod@4.4.3) @@ -2069,6 +2072,9 @@ importers: '@ast-grep/napi': specifier: ^0.42.0 version: 0.42.2 + '@aws-sdk/credential-providers': + specifier: ^3.864.0 + version: 3.1006.0 '@earendil-works/pi-tui': specifier: ^0.79.4 version: 0.79.4 @@ -2078,6 +2084,9 @@ importers: '@mastra/agent-browser': specifier: workspace:* version: link:../browser/agent-browser + '@mastra/auth-workos': + specifier: workspace:* + version: link:../auth/workos '@mastra/core': specifier: workspace:* version: link:../packages/core @@ -2108,6 +2117,12 @@ importers: '@mastra/pg': specifier: workspace:* version: link:../stores/pg + '@mastra/railway': + specifier: workspace:* + version: link:../workspaces/railway + '@mastra/react': + specifier: workspace:* + version: link:../client-sdks/react '@mastra/schema-compat': specifier: workspace:* version: link:../packages/schema-compat @@ -2120,9 +2135,24 @@ importers: '@mastra/tavily': specifier: workspace:* version: link:../integrations/tavily + '@mastra/voice-deepgram': + specifier: workspace:* + version: link:../voice/deepgram + '@mastra/voice-openai': + specifier: workspace:* + version: link:../voice/openai + '@octokit/auth-app': + specifier: ^8.0.0 + version: 8.2.0 + '@octokit/rest': + specifier: ^22.0.1 + version: 22.0.1 '@tanstack/react-query': specifier: ^5.90.21 - version: 5.90.21(react@18.3.1) + version: 5.90.21(react@19.2.6) + '@tursodatabase/api': + specifier: 2.0.4 + version: 2.0.4 ai: specifier: ^6.0.176 version: 6.0.177(zod@4.4.3) @@ -2132,6 +2162,9 @@ importers: cli-highlight: specifier: ^2.1.11 version: 2.1.11 + drizzle-orm: + specifier: ^0.45.0 + version: 0.45.2(@cloudflare/workers-types@4.20260511.1)(@libsql/client@0.17.4(bufferutil@4.1.0))(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(@upstash/redis@1.38.0)(kysely@0.28.17)(mysql2@3.22.4(@types/node@22.19.15))(pg@8.21.0)(sql.js@1.14.1)(sqlite3@5.1.7) execa: specifier: ^9.6.1 version: 9.6.1 @@ -2144,6 +2177,9 @@ importers: partial-json: specifier: ^0.1.7 version: 0.1.7 + pg: + specifier: ^8.21.0 + version: 8.21.0 posthog-node: specifier: ^5.37.0 version: 5.37.0(rxjs@7.8.2) @@ -2186,19 +2222,22 @@ importers: version: 6.9.1 '@testing-library/react': specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.1) '@types/node': specifier: 22.19.15 version: 22.19.15 + '@types/pg': + specifier: ^8.18.0 + version: 8.20.0 '@types/react': - specifier: ^18.3.12 - version: 18.3.31 + specifier: ^19.2.14 + version: 19.2.14 '@types/react-dom': - specifier: ^18.3.1 - version: 18.3.7(@types/react@18.3.31) + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.7.0(vite@6.4.3(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) @@ -2214,6 +2253,9 @@ importers: concurrently: specifier: ^9.1.2 version: 9.2.3 + drizzle-kit: + specifier: ^0.31.0 + version: 0.31.10 eslint: specifier: ^10.4.1 version: 10.5.0(jiti@2.7.0) @@ -2230,11 +2272,11 @@ importers: specifier: ^2.12.11 version: 2.14.6(@types/node@22.19.15)(typescript@6.0.3) react: - specifier: ^18.3.1 - version: 18.3.1 + specifier: ^19.2.5 + version: 19.2.6 react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) + specifier: ^19.2.5 + version: 19.2.6(react@19.2.6) tsup: specifier: ^8.5.1 version: 8.5.1(patch_hash=78092cc873f6c3c61ff4846b4b326faa7db54bb3c1506c510614d708601e2b7f)(@microsoft/api-extractor@7.58.9(@types/node@22.19.15))(@swc/core@1.15.7(@swc/helpers@0.5.17))(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) @@ -4258,6 +4300,9 @@ importers: fs-extra: specifier: ^11.3.5 version: 11.3.5 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 hono: specifier: ^4.12.8 version: 4.12.25 @@ -4836,6 +4881,9 @@ importers: xxhash-wasm: specifier: ^1.1.0 version: 1.1.0 + zod: + specifier: 'catalog:' + version: 4.4.3 devDependencies: '@ai-sdk/openai': specifier: ^1.3.24 @@ -4894,9 +4942,6 @@ importers: vitest: specifier: 'catalog:' version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(msw@2.14.6(@types/node@22.19.15)(typescript@6.0.3))(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) - zod: - specifier: 'catalog:' - version: 4.4.3 packages/playground: dependencies: @@ -6815,7 +6860,7 @@ importers: version: 5.2.0(encoding@0.1.13) mongodb: specifier: ^7.2.0 - version: 7.2.0(@aws-sdk/credential-providers@3.1006.0)(socks@2.8.7) + version: 7.2.0(@aws-sdk/credential-providers@3.1006.0)(@mongodb-js/zstd@7.0.0)(socks@2.8.7) devDependencies: '@internal/lint': specifier: workspace:* @@ -8433,7 +8478,7 @@ importers: dependencies: agentfs-sdk: specifier: ^0.6.2 - version: 0.6.4 + version: 0.6.4(just-bash@2.14.5) devDependencies: '@internal/lint': specifier: workspace:* @@ -8858,6 +8903,40 @@ importers: specifier: 'catalog:' version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(msw@2.14.6(@types/node@22.19.15)(typescript@6.0.3))(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) + workspaces/mesa: + dependencies: + '@mesadev/sdk': + specifier: 0.38.0 + version: 0.38.0 + devDependencies: + '@internal/lint': + specifier: workspace:* + version: link:../../packages/_config + '@internal/types-builder': + specifier: workspace:* + version: link:../../packages/_types-builder + '@internal/workspace-test-utils': + specifier: workspace:* + version: link:../_test-utils + '@mastra/core': + specifier: workspace:* + version: link:../../packages/core + '@types/node': + specifier: 22.19.15 + version: 22.19.15 + eslint: + specifier: ^10.4.1 + version: 10.5.0(jiti@2.7.0) + tsup: + specifier: ^8.5.1 + version: 8.5.1(patch_hash=78092cc873f6c3c61ff4846b4b326faa7db54bb3c1506c510614d708601e2b7f)(@microsoft/api-extractor@7.58.9(@types/node@22.19.15))(@swc/core@1.15.7(@swc/helpers@0.5.17))(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(msw@2.14.6(@types/node@22.19.15)(typescript@6.0.3))(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) + workspaces/modal: dependencies: modal: @@ -9072,6 +9151,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/amazon-bedrock@3.0.102': + resolution: {integrity: sha512-CeNdbx2gmtwnWdRoHQNbLbly2n43hgytqM4J1OqsBLLEkuSgPDaf+ZrFcQBgxZKv9xQX606yaNN43FGS4VSIpQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/amazon-bedrock@3.0.99': resolution: {integrity: sha512-d/WsYOlqjQeEwTewawjrlhoWfHt3q1vRT5/XdFJ6U+KYd/3HnAlrA3rg0+T7xMk98XmctaILJb45Ct/8zrGxSA==} engines: {node: '>=18'} @@ -9096,6 +9181,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/anthropic@2.0.82': + resolution: {integrity: sha512-bMiG4rUwdgQ6+E2klFSaJ1BHO65zCrfHRqC/hkdhA19tmx8FqLoAmXpx92dcWsADFMtAzQd3Q9kRJ8zk4/PpnA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/anthropic@3.0.82': resolution: {integrity: sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A==} engines: {node: '>=18'} @@ -9294,6 +9385,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@3.0.26': + resolution: {integrity: sha512-dNciNI4knep6z3cqDNng7yORCcBnEDBHZYj8rJLcLn9pLzEtNVkf6WLg9HR6AnVDDRxHsUeGAEqyF8M+FAtRgg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.27': resolution: {integrity: sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==} engines: {node: '>=18'} @@ -11862,6 +11959,9 @@ packages: resolution: {integrity: sha512-lBSBiRruFurFKXr5Hbsl2thmGweAPmddhF3jb99U4EMDA5L+e5Y1rAkOS07Nvrup7HUMBDrCV45meaxZnt28nQ==} engines: {node: '>=20.0'} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@duckdb/node-api@1.5.2-r.2': resolution: {integrity: sha512-IqFeTarPL9sImt8R6Y/NbNjTR95c7fksk9uFCpFLV0hxbIzbNCCF//xy2BgBqVJtU7/gk2eIpTJTvtE6FS2eog==} @@ -11942,6 +12042,14 @@ packages: '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + '@esbuild/aix-ppc64@0.25.11': resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} engines: {node: '>=18'} @@ -11972,6 +12080,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.11': resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==} engines: {node: '>=18'} @@ -12002,6 +12116,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.11': resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==} engines: {node: '>=18'} @@ -12032,6 +12152,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.11': resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==} engines: {node: '>=18'} @@ -12062,6 +12188,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.11': resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==} engines: {node: '>=18'} @@ -12092,6 +12224,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.11': resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==} engines: {node: '>=18'} @@ -12122,6 +12260,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.11': resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==} engines: {node: '>=18'} @@ -12152,6 +12296,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.11': resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==} engines: {node: '>=18'} @@ -12182,6 +12332,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.11': resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==} engines: {node: '>=18'} @@ -12212,6 +12368,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.11': resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==} engines: {node: '>=18'} @@ -12242,6 +12404,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.11': resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==} engines: {node: '>=18'} @@ -12272,6 +12440,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.11': resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==} engines: {node: '>=18'} @@ -12302,6 +12476,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.11': resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==} engines: {node: '>=18'} @@ -12332,6 +12512,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.11': resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==} engines: {node: '>=18'} @@ -12362,6 +12548,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.11': resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==} engines: {node: '>=18'} @@ -12392,6 +12584,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.11': resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==} engines: {node: '>=18'} @@ -12422,6 +12620,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.11': resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==} engines: {node: '>=18'} @@ -12482,6 +12686,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.11': resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==} engines: {node: '>=18'} @@ -12542,6 +12752,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.11': resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==} engines: {node: '>=18'} @@ -12602,6 +12818,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.11': resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==} engines: {node: '>=18'} @@ -12632,6 +12854,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.11': resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==} engines: {node: '>=18'} @@ -12662,6 +12890,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.11': resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==} engines: {node: '>=18'} @@ -12692,6 +12926,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.11': resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==} engines: {node: '>=18'} @@ -13385,6 +13625,21 @@ packages: resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jitl/quickjs-ffi-types@0.32.0': + resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + resolution: {integrity: sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==} + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + resolution: {integrity: sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==} + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + resolution: {integrity: sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==} + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + resolution: {integrity: sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==} + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.1': resolution: {integrity: sha512-J4BaTocTOYFkMHIra1JDWrMWpNmBl4EkplIwHEsV8aeUOtdWjwSnln9U7twjMFTAEB7mptNtSKyVi1Y2W9sDJw==} peerDependencies: @@ -13814,6 +14069,45 @@ packages: resolution: {integrity: sha512-+uvDktesJmVtiwxMtimq+3f5bKlsan4T7TokxOI7DbxFkApwrRNss5GYEXbInveMTz8LpGth/9Ch5BTwCqrpfA==} engines: {node: '>=22.0.0'} + '@mesadev/mesafs-napi-darwin-arm64@0.38.0': + resolution: {integrity: sha512-FODr4To0DHCmwK1RM4CpagNWGr4kJdaThXbBruuNLMAKj5cJBlBVDsvJfAV2BDw2X74e4JUnEJ2cpbJHmhlnTw==} + cpu: [arm64] + os: [darwin] + + '@mesadev/mesafs-napi-linux-arm64-gnu@0.38.0': + resolution: {integrity: sha512-DYjfgTCBCiKd0M45bi4B3N6d2odjkPxRTRjL0ZdVaksVY4qfeteZc+T3buwH1vrThItZUb/jvJcVsklc8+dQBA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@mesadev/mesafs-napi-linux-arm64-musl@0.38.0': + resolution: {integrity: sha512-+dyormuEUVO+D88/fQuF4PVq+lwxZDD1a4GFO1QPtJ+X9bjOZa1DlYpSFUE8lqQ/WrXLMdwrgtO2QQBtUCejNg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@mesadev/mesafs-napi-linux-x64-gnu@0.38.0': + resolution: {integrity: sha512-YY2ryOkvoJxfUCpC3ts8ULwsVpZfEL1v/+h63uPKKZsPu3jx6rEhBt9QZ1RR+QO9vMnY83nghI2p+mbHO6uG4Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@mesadev/mesafs-napi-linux-x64-musl@0.38.0': + resolution: {integrity: sha512-R5RZnVWiCR67SCunYQvZWW0ZK2CiUu8ezKRJQT8L8YAzaXM39vxSBUC6J2NJXSyqwCyeIRcCVdlzXzjKy1kcHw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@mesadev/mesafs-napi@0.38.0': + resolution: {integrity: sha512-40G5kwVOwVouAMZbFnwhhTKWfpsI3b+W0Z+Lpd6v0LJWmZVYEwL88DWv0d5lbliObxFY360yrPJqfRnVBepIqg==} + + '@mesadev/rest@0.38.0': + resolution: {integrity: sha512-FeXHcSaHA9+GkG2tKP4rdmXaSjFPJUWc0I3snKr/cL+Wd+rb9rqP73lWsfS/BlYfi59oHJaaQDP4bXUkkf4WBw==} + + '@mesadev/sdk@0.38.0': + resolution: {integrity: sha512-BPIV97tUVTCpntW/eGgmn9Div0pEmQUNEuicYXgmGytpU/5YuaA+pjow9xLWnKXL3FPeR8nA40xMWdHkV2wK0Q==} + engines: {node: '>=18'} + '@microsoft/api-extractor-model@7.33.8': resolution: {integrity: sha512-aIcoQggPyer3B6Ze3usz0YWC/oBwUHfRH5ETUsr+oT2BRA6SfTJl7IKPcPZkX4UR+PohowzW4uMxsvjrn8vm+w==} @@ -13827,6 +14121,9 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@modelcontextprotocol/ext-apps@1.7.1': resolution: {integrity: sha512-J3WdG1A4JSSKnSWKyU+895dBVYBV2Utgtf7fUsUK45mlkETm53a/1DR6Pm3hUGKqLLQthZLmpxOg8VPzJi/lyg==} engines: {node: '>=20'} @@ -13872,6 +14169,10 @@ packages: '@mongodb-js/saslprep@1.3.2': resolution: {integrity: sha512-QgA5AySqB27cGTXBFmnpifAi7HxoGUeezwo6p9dI03MuDB6Pp33zgclqVb6oVK3j6I9Vesg0+oojW2XxB59SGg==} + '@mongodb-js/zstd@7.0.0': + resolution: {integrity: sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA==} + engines: {node: '>= 20.19.0'} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} cpu: [arm64] @@ -14228,6 +14529,22 @@ packages: resolution: {integrity: sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ==} engines: {node: ^16.14.0 || >=18.0.0} + '@octokit/auth-app@8.2.0': + resolution: {integrity: sha512-vVjdtQQwomrZ4V46B9LaCsxsySxGoHsyw6IYBov/TqJVROrlYdyNgw5q6tQbB7KZt53v1l1W53RiqTvpzL907g==} + engines: {node: '>= 20'} + + '@octokit/auth-oauth-app@9.0.3': + resolution: {integrity: sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg==} + engines: {node: '>= 20'} + + '@octokit/auth-oauth-device@8.0.3': + resolution: {integrity: sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw==} + engines: {node: '>= 20'} + + '@octokit/auth-oauth-user@6.0.2': + resolution: {integrity: sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A==} + engines: {node: '>= 20'} + '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} engines: {node: '>= 20'} @@ -14244,6 +14561,14 @@ packages: resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} engines: {node: '>= 20'} + '@octokit/oauth-authorization-url@8.0.0': + resolution: {integrity: sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==} + engines: {node: '>= 20'} + + '@octokit/oauth-methods@6.0.2': + resolution: {integrity: sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng==} + engines: {node: '>= 20'} + '@octokit/openapi-types@27.0.0': resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} @@ -17812,6 +18137,10 @@ packages: resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==} engines: {node: '>=18'} + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} @@ -17886,6 +18215,9 @@ packages: '@turbopuffer/turbopuffer@0.10.18': resolution: {integrity: sha512-3AuyjumzcQ0iLNU6hRTbOt8OeZoVJaHqNQOzIMzM6h290OGEtdCJ6L0L5Hs9gmrpANlaTPN1kUC2fe+/BMpRDw==} + '@tursodatabase/api@2.0.4': + resolution: {integrity: sha512-jgWNzy9kmOqZDaTblWUb3fqhl9qfmB2GuTLq7DVL8xQ5AAEkwKR2W6A/XZsqt8kS/CZ6qCH9yeDy2PhQvOYoDg==} + '@tursodatabase/database-common@0.4.4': resolution: {integrity: sha512-iuSYWgBQEETjwfWOmi3E4Q++jQTb+VOPXeaKozQqW8fXtgU6ZaYUMmWbqQVMGSS9sAWZlZi9qyPVz0rkT8zoqQ==} @@ -18230,11 +18562,6 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/react-dom@18.3.7': - resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} - peerDependencies: - '@types/react': ^18.0.0 - '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -18252,9 +18579,6 @@ packages: '@types/react-syntax-highlighter@15.5.13': resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==} - '@types/react@18.3.31': - resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} - '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -20235,6 +20559,10 @@ packages: resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} engines: {node: '>= 6'} + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} @@ -21060,6 +21388,102 @@ packages: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dset@3.1.4: resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} engines: {node: '>=4'} @@ -21239,6 +21663,11 @@ packages: peerDependencies: esbuild: '>=0.12 <1' + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.11: resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} engines: {node: '>=18'} @@ -21695,6 +22124,10 @@ packages: resolution: {integrity: sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==} engines: {node: '>=18'} + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -23199,6 +23632,10 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + just-bash@2.14.5: + resolution: {integrity: sha512-MCBGnRlDeZ/MM7mcw+ZuSGFMBsggajrmKz6e/hrOAN7syvVZkjiY+Vh2wyCwN/CdcnAX5SxbiQB51n5nrQuX+g==} + hasBin: true + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -24497,6 +24934,11 @@ packages: node-jose@2.2.0: resolution: {integrity: sha512-XPCvJRr94SjLrSIm4pbYHKLEaOsDvJCpyFw/6V/KK/IXmyZ6SFBzAUDO9HQf4DB/nTEFcRGH87mNciOP23kFjw==} + node-liblzma@2.2.0: + resolution: {integrity: sha512-s0KzNOWwOJJgPG6wxg6cKohnAl9Wk/oW1KrQaVzJBjQwVcUGPQCzpR46Ximygjqj/3KhOrtJXnYMp/xYAXp75g==} + engines: {node: '>=16.0.0'} + hasBin: true + node-releases@2.0.47: resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} @@ -25905,6 +26347,13 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + quickjs-emscripten-core@0.32.0: + resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==} + + quickjs-emscripten@0.32.0: + resolution: {integrity: sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==} + engines: {node: '>=16.0.0'} + quote-unquote@1.0.0: resolution: {integrity: sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==} @@ -25943,6 +26392,9 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + re2js@1.3.3: + resolution: {integrity: sha512-s/I5zEAo79SUK0Qw4dpZKpiMwbQ6Gz0KU2NRr7eaO4x/p2g7Vvmn3hdeXDg8VsaUjfj/ora+e9oi27LX/C9+mw==} + react-day-picker@8.10.1: resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==} peerDependencies: @@ -26686,6 +27138,10 @@ packages: secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + seek-bzip@2.0.0: + resolution: {integrity: sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==} + hasBin: true + select-hose@2.0.0: resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==} @@ -27000,6 +27456,9 @@ packages: resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + sql.js@1.14.1: + resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==} + sqlite3@5.1.7: resolution: {integrity: sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==} @@ -27679,6 +28138,10 @@ packages: resolution: {integrity: sha512-BQqXRr4UoWI3UPFrtznCLykYHxwxWh53iCB57x092jPMjIlW1wnm3N895g5irpiXmnxUhREBB0n6+y8BHhs4nw==} hasBin: true + turndown@7.2.4: + resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} + engines: {node: '>=18', npm: '>=9'} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -27927,6 +28390,9 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universal-github-app-jwt@2.2.2: + resolution: {integrity: sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==} + universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} @@ -28533,6 +28999,9 @@ packages: engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -28905,6 +29374,16 @@ snapshots: '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) zod: 4.4.3 + '@ai-sdk/amazon-bedrock@3.0.102(zod@4.4.3)': + dependencies: + '@ai-sdk/anthropic': 2.0.82(zod@4.4.3) + '@ai-sdk/provider': 2.0.3 + '@ai-sdk/provider-utils': 3.0.26(zod@4.4.3) + '@smithy/eventstream-codec': 4.2.14 + '@smithy/util-utf8': 4.2.2 + aws4fetch: 1.0.20 + zod: 4.4.3 + '@ai-sdk/amazon-bedrock@3.0.99(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@4.4.3)': dependencies: '@ai-sdk/anthropic': 2.0.79(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@4.4.3) @@ -28977,6 +29456,12 @@ snapshots: - typescript - vite + '@ai-sdk/anthropic@2.0.82(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 2.0.3 + '@ai-sdk/provider-utils': 3.0.26(zod@4.4.3) + zod: 4.4.3 + '@ai-sdk/anthropic@3.0.82(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.10 @@ -29215,10 +29700,10 @@ snapshots: '@ai-sdk/provider-utils': 2.2.8(zod@4.4.3) zod: 4.4.3 - '@ai-sdk/google@2.0.72(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76)': + '@ai-sdk/google@2.0.72(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.3 - '@ai-sdk/provider-utils': 3.0.25(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76) + '@ai-sdk/provider-utils': 3.0.25(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76) zod: 3.25.76 transitivePeerDependencies: - '@edge-runtime/vm' @@ -29375,6 +29860,26 @@ snapshots: '@ai-sdk/provider-utils': 2.2.8(zod@4.4.3) zod: 4.4.3 + '@ai-sdk/openai@2.0.106(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 2.0.3 + '@ai-sdk/provider-utils': 3.0.25(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76) + zod: 3.25.76 + transitivePeerDependencies: + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@types/node' + - '@vitest/browser-playwright' + - '@vitest/browser-preview' + - '@vitest/browser-webdriverio' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - happy-dom + - jsdom + - typescript + - vite + '@ai-sdk/openai@2.0.106(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.3 @@ -29553,6 +30058,13 @@ snapshots: - typescript - vite + '@ai-sdk/provider-utils@3.0.26(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 2.0.3 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.8 + zod: 4.4.3 + '@ai-sdk/provider-utils@4.0.27(zod@3.25.76)': dependencies: '@ai-sdk/provider': 3.0.10 @@ -31663,10 +32175,12 @@ snapshots: '@cloudflare/workers-types': 4.20260511.1 '@opentelemetry/api': 1.9.1 - '@better-auth/drizzle-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + '@better-auth/drizzle-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260511.1)(@libsql/client@0.17.4(bufferutil@4.1.0))(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(@upstash/redis@1.38.0)(kysely@0.28.17)(mysql2@3.22.4(@types/node@22.19.15))(pg@8.21.0)(sql.js@1.14.1)(sqlite3@5.1.7))': dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 + optionalDependencies: + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260511.1)(@libsql/client@0.17.4(bufferutil@4.1.0))(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(@upstash/redis@1.38.0)(kysely@0.28.17)(mysql2@3.22.4(@types/node@22.19.15))(pg@8.21.0)(sql.js@1.14.1)(sqlite3@5.1.7) '@better-auth/kysely-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)': dependencies: @@ -31680,12 +32194,12 @@ snapshots: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - '@better-auth/mongo-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(mongodb@7.2.0(@aws-sdk/credential-providers@3.1006.0)(socks@2.8.7))': + '@better-auth/mongo-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(mongodb@7.2.0(@aws-sdk/credential-providers@3.1006.0)(@mongodb-js/zstd@7.0.0)(socks@2.8.7))': dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 optionalDependencies: - mongodb: 7.2.0(@aws-sdk/credential-providers@3.1006.0)(socks@2.8.7) + mongodb: 7.2.0(@aws-sdk/credential-providers@3.1006.0)(@mongodb-js/zstd@7.0.0)(socks@2.8.7) '@better-auth/prisma-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: @@ -33659,6 +34173,8 @@ snapshots: - uglify-js - webpack-cli + '@drizzle-team/brocli@0.10.2': {} + '@duckdb/node-api@1.5.2-r.2': dependencies: '@duckdb/node-bindings': 1.5.2-r.2 @@ -33755,6 +34271,16 @@ snapshots: '@epic-web/invariant@1.0.0': {} + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.13.0 + '@esbuild/aix-ppc64@0.25.11': optional: true @@ -33770,6 +34296,9 @@ snapshots: '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/android-arm64@0.18.20': + optional: true + '@esbuild/android-arm64@0.25.11': optional: true @@ -33785,6 +34314,9 @@ snapshots: '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm@0.18.20': + optional: true + '@esbuild/android-arm@0.25.11': optional: true @@ -33800,6 +34332,9 @@ snapshots: '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-x64@0.18.20': + optional: true + '@esbuild/android-x64@0.25.11': optional: true @@ -33815,6 +34350,9 @@ snapshots: '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.18.20': + optional: true + '@esbuild/darwin-arm64@0.25.11': optional: true @@ -33830,6 +34368,9 @@ snapshots: '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-x64@0.18.20': + optional: true + '@esbuild/darwin-x64@0.25.11': optional: true @@ -33845,6 +34386,9 @@ snapshots: '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.18.20': + optional: true + '@esbuild/freebsd-arm64@0.25.11': optional: true @@ -33860,6 +34404,9 @@ snapshots: '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.18.20': + optional: true + '@esbuild/freebsd-x64@0.25.11': optional: true @@ -33875,6 +34422,9 @@ snapshots: '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/linux-arm64@0.18.20': + optional: true + '@esbuild/linux-arm64@0.25.11': optional: true @@ -33890,6 +34440,9 @@ snapshots: '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm@0.18.20': + optional: true + '@esbuild/linux-arm@0.25.11': optional: true @@ -33905,6 +34458,9 @@ snapshots: '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-ia32@0.18.20': + optional: true + '@esbuild/linux-ia32@0.25.11': optional: true @@ -33920,6 +34476,9 @@ snapshots: '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-loong64@0.18.20': + optional: true + '@esbuild/linux-loong64@0.25.11': optional: true @@ -33935,6 +34494,9 @@ snapshots: '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-mips64el@0.18.20': + optional: true + '@esbuild/linux-mips64el@0.25.11': optional: true @@ -33950,6 +34512,9 @@ snapshots: '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-ppc64@0.18.20': + optional: true + '@esbuild/linux-ppc64@0.25.11': optional: true @@ -33965,6 +34530,9 @@ snapshots: '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.18.20': + optional: true + '@esbuild/linux-riscv64@0.25.11': optional: true @@ -33980,6 +34548,9 @@ snapshots: '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-s390x@0.18.20': + optional: true + '@esbuild/linux-s390x@0.25.11': optional: true @@ -33995,6 +34566,9 @@ snapshots: '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-x64@0.18.20': + optional: true + '@esbuild/linux-x64@0.25.11': optional: true @@ -34025,6 +34599,9 @@ snapshots: '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.18.20': + optional: true + '@esbuild/netbsd-x64@0.25.11': optional: true @@ -34055,6 +34632,9 @@ snapshots: '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.18.20': + optional: true + '@esbuild/openbsd-x64@0.25.11': optional: true @@ -34085,6 +34665,9 @@ snapshots: '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/sunos-x64@0.18.20': + optional: true + '@esbuild/sunos-x64@0.25.11': optional: true @@ -34100,6 +34683,9 @@ snapshots: '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/win32-arm64@0.18.20': + optional: true + '@esbuild/win32-arm64@0.25.11': optional: true @@ -34115,6 +34701,9 @@ snapshots: '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-ia32@0.18.20': + optional: true + '@esbuild/win32-ia32@0.25.11': optional: true @@ -34130,6 +34719,9 @@ snapshots: '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-x64@0.18.20': + optional: true + '@esbuild/win32-x64@0.25.11': optional: true @@ -34935,6 +35527,24 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 + '@jitl/quickjs-ffi-types@0.32.0': {} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.1(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0))': dependencies: glob: 10.5.0 @@ -35449,6 +36059,40 @@ snapshots: transitivePeerDependencies: - debug + '@mesadev/mesafs-napi-darwin-arm64@0.38.0': + optional: true + + '@mesadev/mesafs-napi-linux-arm64-gnu@0.38.0': + optional: true + + '@mesadev/mesafs-napi-linux-arm64-musl@0.38.0': + optional: true + + '@mesadev/mesafs-napi-linux-x64-gnu@0.38.0': + optional: true + + '@mesadev/mesafs-napi-linux-x64-musl@0.38.0': + optional: true + + '@mesadev/mesafs-napi@0.38.0': + optionalDependencies: + '@mesadev/mesafs-napi-darwin-arm64': 0.38.0 + '@mesadev/mesafs-napi-linux-arm64-gnu': 0.38.0 + '@mesadev/mesafs-napi-linux-arm64-musl': 0.38.0 + '@mesadev/mesafs-napi-linux-x64-gnu': 0.38.0 + '@mesadev/mesafs-napi-linux-x64-musl': 0.38.0 + + '@mesadev/rest@0.38.0': {} + + '@mesadev/sdk@0.38.0': + dependencies: + '@mesadev/mesafs-napi': 0.38.0 + '@mesadev/rest': 0.38.0 + just-bash: 2.14.5 + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + '@microsoft/api-extractor-model@7.33.8(@types/node@22.19.15)': dependencies: '@microsoft/tsdoc': 0.16.0 @@ -35484,6 +36128,8 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} + '@mixmark-io/domino@2.2.0': {} + '@modelcontextprotocol/ext-apps@1.7.1(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) @@ -35579,6 +36225,12 @@ snapshots: dependencies: sparse-bitfield: 3.0.3 + '@mongodb-js/zstd@7.0.0': + dependencies: + node-addon-api: 8.5.0 + prebuild-install: 7.1.3 + optional: true + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': optional: true @@ -35892,6 +36544,40 @@ snapshots: dependencies: which: 4.0.0 + '@octokit/auth-app@8.2.0': + dependencies: + '@octokit/auth-oauth-app': 9.0.3 + '@octokit/auth-oauth-user': 6.0.2 + '@octokit/request': 10.0.6 + '@octokit/request-error': 7.0.2 + '@octokit/types': 16.0.0 + toad-cache: 3.7.0 + universal-github-app-jwt: 2.2.2 + universal-user-agent: 7.0.3 + + '@octokit/auth-oauth-app@9.0.3': + dependencies: + '@octokit/auth-oauth-device': 8.0.3 + '@octokit/auth-oauth-user': 6.0.2 + '@octokit/request': 10.0.6 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/auth-oauth-device@8.0.3': + dependencies: + '@octokit/oauth-methods': 6.0.2 + '@octokit/request': 10.0.6 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/auth-oauth-user@6.0.2': + dependencies: + '@octokit/auth-oauth-device': 8.0.3 + '@octokit/oauth-methods': 6.0.2 + '@octokit/request': 10.0.6 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + '@octokit/auth-token@6.0.0': {} '@octokit/core@7.0.6': @@ -35915,6 +36601,15 @@ snapshots: '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 + '@octokit/oauth-authorization-url@8.0.0': {} + + '@octokit/oauth-methods@6.0.2': + dependencies: + '@octokit/oauth-authorization-url': 8.0.0 + '@octokit/request': 10.0.6 + '@octokit/request-error': 7.0.2 + '@octokit/types': 16.0.0 + '@octokit/openapi-types@27.0.0': {} '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': @@ -38908,7 +39603,6 @@ snapshots: '@smithy/types': 4.14.3 '@smithy/util-hex-encoding': 4.2.2 tslib: 2.8.1 - optional: true '@smithy/fetch-http-handler@5.4.6': dependencies: @@ -39847,11 +40541,6 @@ snapshots: '@tanstack/query-core@5.90.20': {} - '@tanstack/react-query@5.90.21(react@18.3.1)': - dependencies: - '@tanstack/query-core': 5.90.20 - react: 18.3.1 - '@tanstack/react-query@5.90.21(react@19.2.6)': dependencies: '@tanstack/query-core': 5.90.20 @@ -39990,16 +40679,6 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.29.7 - '@testing-library/dom': 10.4.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.29.7 @@ -40039,6 +40718,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + '@tokenizer/token@0.3.0': {} '@tootallnate/once@3.0.1': {} @@ -40106,6 +40792,10 @@ snapshots: pako: 2.1.0 undici: 7.25.0 + '@tursodatabase/api@2.0.4': + dependencies: + whatwg-fetch: 3.6.20 + '@tursodatabase/database-common@0.4.4': {} '@tursodatabase/database-darwin-arm64@0.4.4': @@ -40524,10 +41214,6 @@ snapshots: '@types/range-parser@1.2.7': {} - '@types/react-dom@18.3.7(@types/react@18.3.31)': - dependencies: - '@types/react': 18.3.31 - '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -40553,11 +41239,6 @@ snapshots: dependencies: '@types/react': 19.2.14 - '@types/react@18.3.31': - dependencies: - '@types/prop-types': 15.7.15 - csstype: 3.2.3 - '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -41181,7 +41862,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@26.1.0(bufferutil@4.1.0))(msw@2.14.6(@types/node@22.19.15)(typescript@6.0.3))(vite@6.4.3(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(msw@2.14.6(@types/node@22.19.15)(typescript@6.0.3))(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/eslint-plugin@1.6.19(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.8)': dependencies: @@ -41274,7 +41955,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@26.1.0(bufferutil@4.1.0))(msw@2.14.6(@types/node@22.19.15)(typescript@6.0.3))(vite@6.4.3(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@27.4.0(@noble/hashes@2.2.0)(bufferutil@4.1.0))(msw@2.14.6(@types/node@22.19.15)(typescript@6.0.3))(vite@7.3.5(@types/node@22.19.15)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/utils@3.2.4': dependencies: @@ -41650,11 +42331,13 @@ snapshots: - supports-color - utf-8-validate - agentfs-sdk@0.6.4: + agentfs-sdk@0.6.4(just-bash@2.14.5): dependencies: '@tursodatabase/database': 0.4.4 '@tursodatabase/database-common': 0.4.4 buffer: 6.0.3 + optionalDependencies: + just-bash: 2.14.5 agentkeepalive@4.6.0: dependencies: @@ -42070,8 +42753,7 @@ snapshots: aws4@1.13.2: {} - aws4fetch@1.0.20: - optional: true + aws4fetch@1.0.20: {} axe-core@4.11.4: {} @@ -42197,13 +42879,13 @@ snapshots: caseless: 0.12.0 is-stream: 2.0.1 - better-auth@1.6.11(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(mongodb@7.2.0(@aws-sdk/credential-providers@3.1006.0)(socks@2.8.7))(mysql2@3.22.4(@types/node@22.19.15))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8): + better-auth@1.6.11(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260511.1)(@libsql/client@0.17.4(bufferutil@4.1.0))(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(@upstash/redis@1.38.0)(kysely@0.28.17)(mysql2@3.22.4(@types/node@22.19.15))(pg@8.21.0)(sql.js@1.14.1)(sqlite3@5.1.7))(mongodb@7.2.0(@aws-sdk/credential-providers@3.1006.0)(@mongodb-js/zstd@7.0.0)(socks@2.8.7))(mysql2@3.22.4(@types/node@22.19.15))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8): dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/drizzle-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/drizzle-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260511.1)(@libsql/client@0.17.4(bufferutil@4.1.0))(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(@upstash/redis@1.38.0)(kysely@0.28.17)(mysql2@3.22.4(@types/node@22.19.15))(pg@8.21.0)(sql.js@1.14.1)(sqlite3@5.1.7)) '@better-auth/kysely-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) '@better-auth/memory-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/mongo-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(mongodb@7.2.0(@aws-sdk/credential-providers@3.1006.0)(socks@2.8.7)) + '@better-auth/mongo-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(mongodb@7.2.0(@aws-sdk/credential-providers@3.1006.0)(@mongodb-js/zstd@7.0.0)(socks@2.8.7)) '@better-auth/prisma-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) '@better-auth/telemetry': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) '@better-auth/utils': 0.4.0 @@ -42217,7 +42899,9 @@ snapshots: nanostores: 1.3.0 zod: 4.4.3 optionalDependencies: - mongodb: 7.2.0(@aws-sdk/credential-providers@3.1006.0)(socks@2.8.7) + drizzle-kit: 0.31.10 + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260511.1)(@libsql/client@0.17.4(bufferutil@4.1.0))(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(@upstash/redis@1.38.0)(kysely@0.28.17)(mysql2@3.22.4(@types/node@22.19.15))(pg@8.21.0)(sql.js@1.14.1)(sqlite3@5.1.7) + mongodb: 7.2.0(@aws-sdk/credential-providers@3.1006.0)(@mongodb-js/zstd@7.0.0)(socks@2.8.7) mysql2: 3.22.4(@types/node@22.19.15) pg: 8.21.0 react: 19.2.6 @@ -42952,6 +43636,8 @@ snapshots: commander@5.1.0: {} + commander@6.2.1: {} + commander@7.2.0: {} commander@8.3.0: {} @@ -43798,6 +44484,26 @@ snapshots: dotenv@17.4.2: {} + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.11 + tsx: 4.22.4 + + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260511.1)(@libsql/client@0.17.4(bufferutil@4.1.0))(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(@upstash/redis@1.38.0)(kysely@0.28.17)(mysql2@3.22.4(@types/node@22.19.15))(pg@8.21.0)(sql.js@1.14.1)(sqlite3@5.1.7): + optionalDependencies: + '@cloudflare/workers-types': 4.20260511.1 + '@libsql/client': 0.17.4(bufferutil@4.1.0) + '@opentelemetry/api': 1.9.1 + '@types/pg': 8.20.0 + '@upstash/redis': 1.38.0 + kysely: 0.28.17 + mysql2: 3.22.4(@types/node@22.19.15) + pg: 8.21.0 + sql.js: 1.14.1 + sqlite3: 5.1.7 + dset@3.1.4: {} duplexer@0.1.2: {} @@ -43998,6 +44704,31 @@ snapshots: transitivePeerDependencies: - supports-color + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + esbuild@0.25.11: optionalDependencies: '@esbuild/aix-ppc64': 0.25.11 @@ -44739,6 +45470,15 @@ snapshots: transitivePeerDependencies: - supports-color + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + file-uri-to-path@1.0.0: {} files-sdk@1.6.0(@anthropic-ai/claude-agent-sdk@0.3.169(@anthropic-ai/sdk@0.39.0(encoding@0.1.13))(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(zod@4.4.3))(@aws-sdk/client-s3@3.1059.0)(@aws-sdk/lib-storage@3.997.0(@aws-sdk/client-s3@3.1059.0))(@azure/core-auth@1.10.1)(@azure/identity@4.13.0)(@azure/storage-blob@12.31.0)(@cfworker/json-schema@4.1.1)(@google-cloud/storage@7.19.0(encoding@0.1.13))(@openai/agents@0.11.6(@cfworker/json-schema@4.1.1)(bufferutil@4.1.0)(ws@8.21.0(bufferutil@4.1.0))(zod@4.4.3))(@supabase/storage-js@2.108.0)(ai@6.0.177(zod@4.4.3))(convex@1.38.0(bufferutil@4.1.0)(react@19.2.6))(google-auth-library@10.6.2)(openai@6.41.0(ws@8.21.0(bufferutil@4.1.0))(zod@4.4.3))(zod@4.4.3): @@ -46492,6 +47232,29 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + just-bash@2.14.5: + dependencies: + diff: 8.0.3 + fast-xml-parser: 5.7.3 + file-type: 21.3.4 + ini: 6.0.0 + minimatch: 10.2.5 + modern-tar: 0.7.6 + papaparse: 5.5.3 + quickjs-emscripten: 0.32.0 + re2js: 1.3.3 + seek-bzip: 2.0.0 + smol-toml: 1.6.1 + sprintf-js: 1.1.3 + sql.js: 1.14.1 + turndown: 7.2.4 + yaml: 2.9.0 + optionalDependencies: + '@mongodb-js/zstd': 7.0.0 + node-liblzma: 2.2.0 + transitivePeerDependencies: + - supports-color + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -48015,13 +48778,14 @@ snapshots: '@types/whatwg-url': 13.0.0 whatwg-url: 14.2.0 - mongodb@7.2.0(@aws-sdk/credential-providers@3.1006.0)(socks@2.8.7): + mongodb@7.2.0(@aws-sdk/credential-providers@3.1006.0)(@mongodb-js/zstd@7.0.0)(socks@2.8.7): dependencies: '@mongodb-js/saslprep': 1.3.2 bson: 7.2.0 mongodb-connection-string-url: 7.0.0 optionalDependencies: '@aws-sdk/credential-providers': 3.1006.0 + '@mongodb-js/zstd': 7.0.0 socks: 2.8.7 mri@1.2.0: {} @@ -48290,6 +49054,12 @@ snapshots: process: 0.11.10 uuid: 9.0.1 + node-liblzma@2.2.0: + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + optional: true + node-releases@2.0.47: {} node-simctl@7.7.5: @@ -49896,6 +50666,18 @@ snapshots: quick-lru@5.1.1: {} + quickjs-emscripten-core@0.32.0: + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + quickjs-emscripten@0.32.0: + dependencies: + '@jitl/quickjs-wasmfile-debug-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-debug-sync': 0.32.0 + '@jitl/quickjs-wasmfile-release-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-release-sync': 0.32.0 + quickjs-emscripten-core: 0.32.0 + quote-unquote@1.0.0: {} raf-schd@4.0.3: {} @@ -49938,6 +50720,8 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + re2js@1.3.3: {} + react-day-picker@8.10.1(date-fns@4.4.0)(react@19.2.6): dependencies: date-fns: 4.4.0 @@ -51133,6 +51917,10 @@ snapshots: secure-json-parse@4.1.0: {} + seek-bzip@2.0.0: + dependencies: + commander: 6.2.1 + select-hose@2.0.0: {} selfsigned@5.5.0: @@ -51533,6 +52321,8 @@ snapshots: sql-escaper@1.3.3: {} + sql.js@1.14.1: {} + sqlite3@5.1.7: dependencies: bindings: 1.5.0 @@ -52308,6 +53098,10 @@ snapshots: '@turbo/windows-64': 2.9.14 '@turbo/windows-arm64': 2.9.14 + turndown@7.2.4: + dependencies: + '@mixmark-io/domino': 2.2.0 + tw-animate-css@1.4.0: {} tweetnacl@0.14.5: {} @@ -52600,6 +53394,8 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universal-github-app-jwt@2.2.2: {} + universal-user-agent@7.0.3: {} universalify@0.1.2: {} @@ -53513,6 +54309,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-fetch@3.6.20: {} + whatwg-mimetype@4.0.0: {} whatwg-mimetype@5.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e69fdeec3a5b..5fbfaebf0496 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -106,6 +106,9 @@ trustPolicyExclude: - 'tinyexec@1.2.2' # 0.7.4 is published with provenance attestation rather than trusted publisher. - 'prettier-plugin-tailwindcss@0.7.4' + # Required for the Mesa workspace filesystem provider. 0.38.0 is published + # without the trusted-publisher evidence that older @mesadev/sdk versions had. + - '@mesadev/sdk@0.38.0' overrides: typescript: ^6.0.3 '@types/node': 22.19.15 diff --git a/renovate.json b/renovate.json index f1dbc0588d34..50414d83fdf2 100644 --- a/renovate.json +++ b/renovate.json @@ -38,12 +38,23 @@ "explorations/**", ".github/scripts", "templates/**", - "**/examples/**", "**/_examples/**", ".claude/skills/**/assets/**/package.json", "e2e-tests/**/template/package.json" ], "packageRules": [ + { + "description": "Disable all dependency updates in examples (Renovate previously ignored these via ignorePaths)", + "matchFileNames": ["examples/**/package.json"], + "enabled": false + }, + { + "description": "Allow zod updates in examples (kept in sync with the catalog to prevent version collision with workspace-linked @mastra packages that caused TS2589/OOM)", + "matchFileNames": ["examples/**/package.json"], + "matchPackageNames": ["zod"], + "enabled": true, + "groupName": "Schema" + }, { "matchDepTypes": ["engines"], "enabled": false @@ -170,9 +181,10 @@ }, { "groupName": "AI SDK Examples", - "description": "Leave example apps eligible for all AI SDK update types", + "description": "Allow all AI SDK update types in examples", "matchFileNames": ["examples/**/package.json"], - "matchSourceUrls": ["https://github.com/vercel/ai"] + "matchSourceUrls": ["https://github.com/vercel/ai"], + "enabled": true }, { "groupName": "Assistant UI", diff --git a/scripts/gh-dane-command/index.ts b/scripts/gh-dane-command/index.ts index cf438e4040a7..fb05f139061a 100644 --- a/scripts/gh-dane-command/index.ts +++ b/scripts/gh-dane-command/index.ts @@ -122,7 +122,7 @@ async function main(): Promise<never> { // Cleanup releaseAllThreadLocks(); - await Promise.allSettled([mcpManager?.disconnect(), controller?.stopHeartbeats()]); + await Promise.allSettled([mcpManager?.disconnect(), controller?.stopIntervals()]); process.exit(exitCode); } diff --git a/server-adapters/_test-utils/src/route-test-utils.ts b/server-adapters/_test-utils/src/route-test-utils.ts index a7369bcdb91b..60ea518424c2 100644 --- a/server-adapters/_test-utils/src/route-test-utils.ts +++ b/server-adapters/_test-utils/src/route-test-utils.ts @@ -46,6 +46,8 @@ export function generateContextualValue(fieldName?: string): string { if (field === 'entitytype') return 'AGENT'; if (field === 'entityid') return 'test-agent'; if (field === 'role') return 'user'; + // Cron fields must be a valid cron expression (heartbeat/schedule create routes). + if (field === 'cron') return '* * * * *'; if (field === 'fields') return 'result'; // For workflow execution result field filtering (status is always included) // JSON-encoded query params (wrapped with wrapSchemaForQueryParams) if (field === 'tags') return '["test-tag"]'; // For observability traces filtering @@ -368,6 +370,7 @@ export function getDefaultValidPathParams(route: ServerRoute): Record<string, an } if (route.path.includes(':workflowId')) params.workflowId = 'test-workflow'; if (route.path.includes(':scheduleId')) params.scheduleId = 'test-schedule'; + if (route.path.includes(':heartbeatId')) params.heartbeatId = 'hb_test-heartbeat'; if (route.path.includes(':backgroundTaskId')) params.backgroundTaskId = 'test-background-task-id'; if (route.path.includes(':toolId')) params.toolId = 'test-tool'; if (route.path.includes(':threadId')) params.threadId = 'test-thread'; diff --git a/server-adapters/_test-utils/src/test-helpers.ts b/server-adapters/_test-utils/src/test-helpers.ts index f94d4ba3bf3e..241678f31924 100644 --- a/server-adapters/_test-utils/src/test-helpers.ts +++ b/server-adapters/_test-utils/src/test-helpers.ts @@ -797,6 +797,15 @@ export async function createDefaultTestContext(): Promise<AdapterTestContext> { createdAt: now, updatedAt: now, }); + await schedules.createSchedule({ + id: 'hb_test-heartbeat', + target: { type: 'heartbeat', agentId: 'test-agent', prompt: 'ping' }, + cron: '* * * * *', + status: 'active', + nextFireAt: now + 60_000, + createdAt: now, + updatedAt: now, + }); } const saveStoredResponseFixtures = async (memoryStore: Awaited<ReturnType<InMemoryStore['getStore']>>) => { diff --git a/server-adapters/express/CHANGELOG.md b/server-adapters/express/CHANGELOG.md index 6ffde0f80d01..76522ac958ed 100644 --- a/server-adapters/express/CHANGELOG.md +++ b/server-adapters/express/CHANGELOG.md @@ -1,5 +1,61 @@ # @mastra/express +## 1.4.3-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + - @mastra/server@1.48.0-alpha.9 + +## 1.4.3-alpha.8 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + - @mastra/server@1.48.0-alpha.8 + +## 1.4.3-alpha.7 + +### Patch Changes + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + - @mastra/server@1.48.0-alpha.7 + +## 1.4.3-alpha.6 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/server@1.48.0-alpha.6 + +## 1.4.3-alpha.5 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/server@1.48.0-alpha.5 + +## 1.4.3-alpha.4 + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + - @mastra/server@1.48.0-alpha.4 + +## 1.4.3-alpha.3 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`e05dade`](https://github.com/mastra-ai/mastra/commit/e05dade21c3e2551893ad939e6028cdafd84a1b2), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/server@1.48.0-alpha.3 + ## 1.4.3-alpha.2 ### Patch Changes diff --git a/server-adapters/express/package.json b/server-adapters/express/package.json index e616b3b33c72..0fa4b038e413 100644 --- a/server-adapters/express/package.json +++ b/server-adapters/express/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/express", - "version": "1.4.3-alpha.2", + "version": "1.4.3-alpha.9", "description": "Mastra Express adapter for the server", "type": "module", "main": "dist/index.js", diff --git a/server-adapters/fastify/CHANGELOG.md b/server-adapters/fastify/CHANGELOG.md index 74abadc7f33b..d5e700c4c247 100644 --- a/server-adapters/fastify/CHANGELOG.md +++ b/server-adapters/fastify/CHANGELOG.md @@ -1,5 +1,61 @@ # @mastra/fastify +## 1.4.3-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + - @mastra/server@1.48.0-alpha.9 + +## 1.4.3-alpha.8 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + - @mastra/server@1.48.0-alpha.8 + +## 1.4.3-alpha.7 + +### Patch Changes + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + - @mastra/server@1.48.0-alpha.7 + +## 1.4.3-alpha.6 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/server@1.48.0-alpha.6 + +## 1.4.3-alpha.5 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/server@1.48.0-alpha.5 + +## 1.4.3-alpha.4 + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + - @mastra/server@1.48.0-alpha.4 + +## 1.4.3-alpha.3 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`e05dade`](https://github.com/mastra-ai/mastra/commit/e05dade21c3e2551893ad939e6028cdafd84a1b2), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/server@1.48.0-alpha.3 + ## 1.4.3-alpha.2 ### Patch Changes diff --git a/server-adapters/fastify/package.json b/server-adapters/fastify/package.json index 6b12d00ddba7..1f30a3827833 100644 --- a/server-adapters/fastify/package.json +++ b/server-adapters/fastify/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/fastify", - "version": "1.4.3-alpha.2", + "version": "1.4.3-alpha.9", "description": "Mastra Fastify adapter for the server", "type": "module", "main": "dist/index.js", diff --git a/server-adapters/hono/CHANGELOG.md b/server-adapters/hono/CHANGELOG.md index ef7d4a25e1a3..69bfedeb7366 100644 --- a/server-adapters/hono/CHANGELOG.md +++ b/server-adapters/hono/CHANGELOG.md @@ -1,5 +1,61 @@ # @mastra/hono +## 1.5.3-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + - @mastra/server@1.48.0-alpha.9 + +## 1.5.3-alpha.8 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + - @mastra/server@1.48.0-alpha.8 + +## 1.5.3-alpha.7 + +### Patch Changes + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + - @mastra/server@1.48.0-alpha.7 + +## 1.5.3-alpha.6 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/server@1.48.0-alpha.6 + +## 1.5.3-alpha.5 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/server@1.48.0-alpha.5 + +## 1.5.3-alpha.4 + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + - @mastra/server@1.48.0-alpha.4 + +## 1.5.3-alpha.3 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`e05dade`](https://github.com/mastra-ai/mastra/commit/e05dade21c3e2551893ad939e6028cdafd84a1b2), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/server@1.48.0-alpha.3 + ## 1.5.3-alpha.2 ### Patch Changes diff --git a/server-adapters/hono/package.json b/server-adapters/hono/package.json index 66ac90c1f2b9..0d8e1b33c210 100644 --- a/server-adapters/hono/package.json +++ b/server-adapters/hono/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/hono", - "version": "1.5.3-alpha.2", + "version": "1.5.3-alpha.9", "description": "Mastra Hono adapter for the server", "type": "module", "main": "dist/index.js", diff --git a/server-adapters/koa/CHANGELOG.md b/server-adapters/koa/CHANGELOG.md index 9121e531abf1..bf982c3faf44 100644 --- a/server-adapters/koa/CHANGELOG.md +++ b/server-adapters/koa/CHANGELOG.md @@ -1,5 +1,61 @@ # @mastra/koa +## 1.6.3-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + - @mastra/server@1.48.0-alpha.9 + +## 1.6.3-alpha.8 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + - @mastra/server@1.48.0-alpha.8 + +## 1.6.3-alpha.7 + +### Patch Changes + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + - @mastra/server@1.48.0-alpha.7 + +## 1.6.3-alpha.6 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/server@1.48.0-alpha.6 + +## 1.6.3-alpha.5 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/server@1.48.0-alpha.5 + +## 1.6.3-alpha.4 + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + - @mastra/server@1.48.0-alpha.4 + +## 1.6.3-alpha.3 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`e05dade`](https://github.com/mastra-ai/mastra/commit/e05dade21c3e2551893ad939e6028cdafd84a1b2), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/server@1.48.0-alpha.3 + ## 1.6.3-alpha.2 ### Patch Changes diff --git a/server-adapters/koa/package.json b/server-adapters/koa/package.json index 39a3027e4bba..c4987da14dfd 100644 --- a/server-adapters/koa/package.json +++ b/server-adapters/koa/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/koa", - "version": "1.6.3-alpha.2", + "version": "1.6.3-alpha.9", "description": "Mastra Koa adapter for the server", "type": "module", "main": "dist/index.js", diff --git a/server-adapters/nestjs/CHANGELOG.md b/server-adapters/nestjs/CHANGELOG.md index 70f504dd93a1..5027d634095d 100644 --- a/server-adapters/nestjs/CHANGELOG.md +++ b/server-adapters/nestjs/CHANGELOG.md @@ -1,5 +1,61 @@ # @mastra/nestjs +## 0.2.3-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + - @mastra/server@1.48.0-alpha.9 + +## 0.2.3-alpha.8 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + - @mastra/server@1.48.0-alpha.8 + +## 0.2.3-alpha.7 + +### Patch Changes + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + - @mastra/server@1.48.0-alpha.7 + +## 0.2.3-alpha.6 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/server@1.48.0-alpha.6 + +## 0.2.3-alpha.5 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/server@1.48.0-alpha.5 + +## 0.2.3-alpha.4 + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + - @mastra/server@1.48.0-alpha.4 + +## 0.2.3-alpha.3 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`e05dade`](https://github.com/mastra-ai/mastra/commit/e05dade21c3e2551893ad939e6028cdafd84a1b2), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/server@1.48.0-alpha.3 + ## 0.2.3-alpha.2 ### Patch Changes diff --git a/server-adapters/nestjs/package.json b/server-adapters/nestjs/package.json index 5fd7a41b423d..91d0b2185d19 100644 --- a/server-adapters/nestjs/package.json +++ b/server-adapters/nestjs/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/nestjs", - "version": "0.2.3-alpha.2", + "version": "0.2.3-alpha.9", "description": "Mastra NestJS adapter for the server", "type": "module", "main": "dist/index.js", diff --git a/server-adapters/next/CHANGELOG.md b/server-adapters/next/CHANGELOG.md index dc986b674747..12388e9635c4 100644 --- a/server-adapters/next/CHANGELOG.md +++ b/server-adapters/next/CHANGELOG.md @@ -1,5 +1,68 @@ # @mastra/next +## 0.2.2-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + - @mastra/server@1.48.0-alpha.9 + - @mastra/hono@1.5.3-alpha.9 + +## 0.2.2-alpha.8 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + - @mastra/server@1.48.0-alpha.8 + - @mastra/hono@1.5.3-alpha.8 + +## 0.2.2-alpha.7 + +### Patch Changes + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + - @mastra/server@1.48.0-alpha.7 + - @mastra/hono@1.5.3-alpha.7 + +## 0.2.2-alpha.6 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/server@1.48.0-alpha.6 + - @mastra/hono@1.5.3-alpha.6 + +## 0.2.2-alpha.5 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/server@1.48.0-alpha.5 + - @mastra/hono@1.5.3-alpha.5 + +## 0.2.2-alpha.4 + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + - @mastra/server@1.48.0-alpha.4 + - @mastra/hono@1.5.3-alpha.4 + +## 0.2.2-alpha.3 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`e05dade`](https://github.com/mastra-ai/mastra/commit/e05dade21c3e2551893ad939e6028cdafd84a1b2), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/server@1.48.0-alpha.3 + - @mastra/hono@1.5.3-alpha.3 + ## 0.2.2-alpha.2 ### Patch Changes diff --git a/server-adapters/next/package.json b/server-adapters/next/package.json index b3e8ec0cb9e8..0698e74f9066 100644 --- a/server-adapters/next/package.json +++ b/server-adapters/next/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/next", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.9", "description": "Mastra Next.js server adapter — drop your Mastra instance into a Next.js app", "type": "module", "main": "dist/index.js", diff --git a/server-adapters/tanstack-start/CHANGELOG.md b/server-adapters/tanstack-start/CHANGELOG.md index 46b1f9e56c6d..faa69dfaba1a 100644 --- a/server-adapters/tanstack-start/CHANGELOG.md +++ b/server-adapters/tanstack-start/CHANGELOG.md @@ -1,5 +1,68 @@ # @mastra/tanstack-start +## 0.2.2-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + - @mastra/server@1.48.0-alpha.9 + - @mastra/hono@1.5.3-alpha.9 + +## 0.2.2-alpha.8 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + - @mastra/server@1.48.0-alpha.8 + - @mastra/hono@1.5.3-alpha.8 + +## 0.2.2-alpha.7 + +### Patch Changes + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + - @mastra/server@1.48.0-alpha.7 + - @mastra/hono@1.5.3-alpha.7 + +## 0.2.2-alpha.6 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/server@1.48.0-alpha.6 + - @mastra/hono@1.5.3-alpha.6 + +## 0.2.2-alpha.5 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/server@1.48.0-alpha.5 + - @mastra/hono@1.5.3-alpha.5 + +## 0.2.2-alpha.4 + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + - @mastra/server@1.48.0-alpha.4 + - @mastra/hono@1.5.3-alpha.4 + +## 0.2.2-alpha.3 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`e05dade`](https://github.com/mastra-ai/mastra/commit/e05dade21c3e2551893ad939e6028cdafd84a1b2), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/server@1.48.0-alpha.3 + - @mastra/hono@1.5.3-alpha.3 + ## 0.2.2-alpha.2 ### Patch Changes diff --git a/server-adapters/tanstack-start/package.json b/server-adapters/tanstack-start/package.json index 3216fce745fb..85d5abb3013a 100644 --- a/server-adapters/tanstack-start/package.json +++ b/server-adapters/tanstack-start/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/tanstack-start", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.9", "description": "Mastra TanStack Start server adapter — drop your Mastra instance into a TanStack Start app", "type": "module", "main": "dist/index.js", diff --git a/stores/_test-utils/src/domains/schedules/index.ts b/stores/_test-utils/src/domains/schedules/index.ts index 7c7b06483c49..f96635f0ef39 100644 --- a/stores/_test-utils/src/domains/schedules/index.ts +++ b/stores/_test-utils/src/domains/schedules/index.ts @@ -250,7 +250,7 @@ export function createSchedulesTests({ storage }: SchedulesTestOptions) { scheduleId: 's1', runId: 'r1', id: 'fire_1', - outcome: 'deferred', + outcome: 'persisted', }), ); await scheduleStore.recordTrigger( @@ -258,7 +258,7 @@ export function createSchedulesTests({ storage }: SchedulesTestOptions) { scheduleId: 's1', id: 'drain_1', runId: null, - outcome: 'appended-from-queue', + outcome: 'delivered', triggerKind: 'queue-drain', parentTriggerId: 'fire_1', actualFireAt: Date.now() + 1_000, diff --git a/stores/mongodb/CHANGELOG.md b/stores/mongodb/CHANGELOG.md index f5ad6bcf17a3..fce6043a3720 100644 --- a/stores/mongodb/CHANGELOG.md +++ b/stores/mongodb/CHANGELOG.md @@ -1,5 +1,18 @@ # @mastra/mongodb +## 1.11.1-alpha.0 + +### Patch Changes + +- Made MongoDB store writes safe against partial failures, preventing orphaned records when an operation fails partway through. ([#18393](https://github.com/mastra-ai/mastra/pull/18393)) + + **Atomic multi-collection writes.** Creates, deletes, and updates across the agents, mcp-clients, mcp-servers, prompt-blocks, scorer-definitions, skills, workspaces, schedules, and datasets domains now run in a transaction on replica sets, so a failed write leaves no half-written state. On standalone servers (which can't run transactions) these degrade to sequential best-effort, matching the previous behavior. + + **Scalable cascade deletes.** Deleting a thread (with its messages) or a dataset (with its items) is deliberately _not_ wrapped in a transaction, because those children are unbounded and a transactional delete is capped by MongoDB's 60-second transaction limit — a large thread or dataset would abort and become permanently undeletable. Instead the children are removed first and the parent record last, so a failure mid-delete leaves the parent in place and re-running the delete safely finishes the job. + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + ## 1.11.0 ### Minor Changes diff --git a/stores/mongodb/package.json b/stores/mongodb/package.json index b60004b80f2b..a820d7bce275 100644 --- a/stores/mongodb/package.json +++ b/stores/mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/mongodb", - "version": "1.11.0", + "version": "1.11.1-alpha.0", "description": "MongoDB provider for Mastra - includes vector store capabilities", "type": "module", "main": "dist/index.js", diff --git a/stores/mongodb/src/storage/connectors/MongoDBConnector.ts b/stores/mongodb/src/storage/connectors/MongoDBConnector.ts index 4e3a3fbc2e5a..fe749945b10f 100644 --- a/stores/mongodb/src/storage/connectors/MongoDBConnector.ts +++ b/stores/mongodb/src/storage/connectors/MongoDBConnector.ts @@ -1,5 +1,5 @@ import { MongoClient } from 'mongodb'; -import type { Db } from 'mongodb'; +import type { ClientSession, Db } from 'mongodb'; import packageJson from '../../../package.json'; import type { DatabaseConfig } from '../types'; import type { ConnectorHandler } from './base'; @@ -22,6 +22,7 @@ export class MongoDBConnector { readonly #handler?: ConnectorHandler; #isConnected: boolean; #db?: Db; + #supportsTransactions?: boolean; constructor(options: MongoDBConnectorOptions) { this.#client = options.client; @@ -87,6 +88,55 @@ export class MongoDBConnector { return db.collection(collectionName); } + /** + * Returns true when the deployment supports multi-document transactions + * (replica set or sharded cluster). Standalone servers and custom connector + * handlers return false. Probed once and cached. + */ + async supportsTransactions(): Promise<boolean> { + if (this.#supportsTransactions !== undefined) { + return this.#supportsTransactions; + } + if (!this.#client) { + // Custom connector handler: no client to probe; assume no transactions. + this.#supportsTransactions = false; + return false; + } + try { + const db = await this.getConnection(); + const hello = await db.admin().command({ hello: 1 }); + this.#supportsTransactions = Boolean(hello.setName) || hello.msg === 'isdbgrid'; + return this.#supportsTransactions; + } catch { + // Do not cache a transient probe failure — re-probe on the next call so a + // momentary outage does not permanently disable transactions on a replica set. + return false; + } + } + + /** + * Runs `fn` inside a transaction when the deployment supports it, passing the + * session so callers can scope each operation with `{ session }`. On a + * standalone server (or custom handler) it degrades to running `fn` directly + * with an undefined session — best-effort sequential, no atomicity. + */ + async withTransaction<T>(fn: (session?: ClientSession) => Promise<T>): Promise<T> { + const supported = await this.supportsTransactions(); + if (!supported || !this.#client) { + return fn(undefined); + } + const session = this.#client.startSession(); + try { + let result!: T; + await session.withTransaction(async () => { + result = await fn(session); + }); + return result; + } finally { + await session.endSession(); + } + } + async close() { if (this.#client) { await this.#client.close(); diff --git a/stores/mongodb/src/storage/domains/agents/index.ts b/stores/mongodb/src/storage/domains/agents/index.ts index ffcd7257234e..52768bfa1146 100644 --- a/stores/mongodb/src/storage/domains/agents/index.ts +++ b/stores/mongodb/src/storage/domains/agents/index.ts @@ -240,20 +240,26 @@ export class MongoDBAgentsStorage extends AgentsStorage { updatedAt: now, }; - await collection.insertOne(this.serializeAgent(newAgent)); - // Extract config fields from the flat input const { id: _id, authorId: _authorId, visibility: _visibility, metadata: _metadata, ...snapshotConfig } = agent; - // Create version 1 from the config const versionId = randomUUID(); - await this.createVersion({ + const versionDoc: Record<string, any> = { id: versionId, agentId: agent.id, versionNumber: 1, - ...snapshotConfig, changedFields: Object.keys(snapshotConfig), changeMessage: 'Initial version', + createdAt: new Date(), + }; + for (const field of SNAPSHOT_FIELDS) { + if ((snapshotConfig as any)[field] !== undefined) versionDoc[field] = (snapshotConfig as any)[field]; + } + + await this.#connector.withTransaction(async session => { + await collection.insertOne(this.serializeAgent(newAgent), { session }); + const versionsCol = await this.getCollection(TABLE_AGENT_VERSIONS); + await versionsCol.insertOne(versionDoc, { session }); }); // Return the thin agent record (activeVersionId remains undefined, status remains 'draft') @@ -352,13 +358,12 @@ export class MongoDBAgentsStorage extends AgentsStorage { async delete(id: string): Promise<void> { try { - // Delete all versions for this agent first - await this.deleteVersionsByParentId(id); - - // Then delete the agent - const collection = await this.getCollection(TABLE_AGENTS); - // Idempotent delete - no-op if agent doesn't exist - await collection.deleteOne({ id }); + await this.#connector.withTransaction(async session => { + const versionsCol = await this.getCollection(TABLE_AGENT_VERSIONS); + await versionsCol.deleteMany({ agentId: id }, { session }); + const col = await this.getCollection(TABLE_AGENTS); + await col.deleteOne({ id }, { session }); + }); } catch (error) { throw new MastraError( { diff --git a/stores/mongodb/src/storage/domains/datasets/index.ts b/stores/mongodb/src/storage/domains/datasets/index.ts index 63356fc46bc0..fb517fd1a9c6 100644 --- a/stores/mongodb/src/storage/domains/datasets/index.ts +++ b/stores/mongodb/src/storage/domains/datasets/index.ts @@ -338,7 +338,14 @@ export class MongoDBDatasetsStorage extends DatasetsStorage { if (!isNamespaceNotFound) throw e; } - // Cascade delete + // Cascade delete — best-effort, deliberately NOT transactional: dataset + // items are unbounded, and a transactional deleteMany is capped by + // transactionLifetimeLimitSeconds (60s default) plus cache pressure, so a + // large dataset would abort and become permanently undeletable. A plain + // deleteMany commits incrementally and always completes. The dataset row is + // deleted last as the linearization point: a crash mid-cascade leaves the + // dataset re-deletable (deleteMany is idempotent), with only orphaned + // version/item rows keyed by a datasetId nothing queries as transient residue. const versionsCollection = await this.getCollection(TABLE_DATASET_VERSIONS); const itemsCollection = await this.getCollection(TABLE_DATASET_ITEMS); const datasetsCollection = await this.getCollection(TABLE_DATASETS); @@ -426,50 +433,59 @@ export class MongoDBDatasetsStorage extends DatasetsStorage { const itemsCollection = await this.getCollection(TABLE_DATASET_ITEMS); const versionsCollection = await this.getCollection(TABLE_DATASET_VERSIONS); - // Bump dataset version - const result = await datasetsCollection.findOneAndUpdate( - { id: args.datasetId }, - { $inc: { version: 1 } }, - { returnDocument: 'after' }, - ); - if (!result) { - throw new MastraError({ - id: createStorageErrorId('MONGODB', 'ADD_ITEM', 'DATASET_NOT_FOUND'), - domain: ErrorDomain.STORAGE, - category: ErrorCategory.USER, - details: { datasetId: args.datasetId }, - }); - } - const newVersion = result.version as number; - const organizationId = (result.organizationId as string | null | undefined) ?? null; - const projectId = (result.projectId as string | null | undefined) ?? null; + // Bump version and insert item + dataset_version row atomically + let newVersion = 0; + let organizationId: string | null = null; + let projectId: string | null = null; + await this.#connector.withTransaction(async session => { + const result = await datasetsCollection.findOneAndUpdate( + { id: args.datasetId }, + { $inc: { version: 1 } }, + { session, returnDocument: 'after' }, + ); + if (!result) { + throw new MastraError({ + id: createStorageErrorId('MONGODB', 'ADD_ITEM', 'DATASET_NOT_FOUND'), + domain: ErrorDomain.STORAGE, + category: ErrorCategory.USER, + details: { datasetId: args.datasetId }, + }); + } + newVersion = result.version as number; + organizationId = (result.organizationId as string | null | undefined) ?? null; + projectId = (result.projectId as string | null | undefined) ?? null; - // Insert item - await itemsCollection.insertOne({ - id, - datasetId: args.datasetId, - datasetVersion: newVersion, - organizationId, - projectId, - validTo: null, - isDeleted: false, - input: args.input, - groundTruth: args.groundTruth ?? null, - expectedTrajectory: args.expectedTrajectory ?? null, - toolMocks: args.toolMocks ?? null, - requestContext: args.requestContext ?? null, - metadata: args.metadata ?? null, - source: args.source ?? null, - createdAt: now, - updatedAt: now, - }); + await itemsCollection.insertOne( + { + id, + datasetId: args.datasetId, + datasetVersion: newVersion, + organizationId, + projectId, + validTo: null, + isDeleted: false, + input: args.input, + groundTruth: args.groundTruth ?? null, + expectedTrajectory: args.expectedTrajectory ?? null, + toolMocks: args.toolMocks ?? null, + requestContext: args.requestContext ?? null, + metadata: args.metadata ?? null, + source: args.source ?? null, + createdAt: now, + updatedAt: now, + }, + { session }, + ); - // Insert dataset_version row - await versionsCollection.insertOne({ - id: versionId, - datasetId: args.datasetId, - version: newVersion, - createdAt: now, + await versionsCollection.insertOne( + { + id: versionId, + datasetId: args.datasetId, + version: newVersion, + createdAt: now, + }, + { session }, + ); }); return { @@ -551,57 +567,67 @@ export class MongoDBDatasetsStorage extends DatasetsStorage { const itemsCollection = await this.getCollection(TABLE_DATASET_ITEMS); const versionsCollection = await this.getCollection(TABLE_DATASET_VERSIONS); - // Bump dataset version - const result = await datasetsCollection.findOneAndUpdate( - { id: args.datasetId }, - { $inc: { version: 1 } }, - { returnDocument: 'after' }, - ); - if (!result) { - throw new MastraError({ - id: createStorageErrorId('MONGODB', 'UPDATE_ITEM', 'DATASET_NOT_FOUND'), - domain: ErrorDomain.STORAGE, - category: ErrorCategory.USER, - details: { datasetId: args.datasetId }, - }); - } - const newVersion = result.version as number; + // Bump version, close old row, insert new row, and insert version row atomically + let newVersion = 0; // Tenancy re-inherited from parent dataset (Option B) - const parentOrganizationId = (result.organizationId as string | null | undefined) ?? null; - const parentProjectId = (result.projectId as string | null | undefined) ?? null; - - // Close old row (set validTo = newVersion) - await itemsCollection.updateOne( - { id: args.id, validTo: null, isDeleted: false }, - { $set: { validTo: newVersion } }, - ); + let parentOrganizationId: string | null = null; + let parentProjectId: string | null = null; + await this.#connector.withTransaction(async session => { + const result = await datasetsCollection.findOneAndUpdate( + { id: args.datasetId }, + { $inc: { version: 1 } }, + { session, returnDocument: 'after' }, + ); + if (!result) { + throw new MastraError({ + id: createStorageErrorId('MONGODB', 'UPDATE_ITEM', 'DATASET_NOT_FOUND'), + domain: ErrorDomain.STORAGE, + category: ErrorCategory.USER, + details: { datasetId: args.datasetId }, + }); + } + newVersion = result.version as number; + parentOrganizationId = (result.organizationId as string | null | undefined) ?? null; + parentProjectId = (result.projectId as string | null | undefined) ?? null; + + await itemsCollection.updateOne( + { id: args.id, validTo: null, isDeleted: false }, + { $set: { validTo: newVersion } }, + { session }, + ); - // Insert new row with merged fields, preserving original createdAt - await itemsCollection.insertOne({ - id: args.id, - datasetId: args.datasetId, - datasetVersion: newVersion, - organizationId: parentOrganizationId, - projectId: parentProjectId, - validTo: null, - isDeleted: false, - input: mergedInput, - groundTruth: mergedGroundTruth, - expectedTrajectory: mergedExpectedTrajectory ?? null, - toolMocks: mergedToolMocks ?? null, - requestContext: mergedRequestContext, - metadata: mergedMetadata, - source: mergedSource, - createdAt: existing.createdAt, - updatedAt: now, - }); + // Insert new row with merged fields, preserving original createdAt + await itemsCollection.insertOne( + { + id: args.id, + datasetId: args.datasetId, + datasetVersion: newVersion, + organizationId: parentOrganizationId, + projectId: parentProjectId, + validTo: null, + isDeleted: false, + input: mergedInput, + groundTruth: mergedGroundTruth, + expectedTrajectory: mergedExpectedTrajectory ?? null, + toolMocks: mergedToolMocks ?? null, + requestContext: mergedRequestContext, + metadata: mergedMetadata, + source: mergedSource, + createdAt: existing.createdAt, + updatedAt: now, + }, + { session }, + ); - // Insert dataset_version row - await versionsCollection.insertOne({ - id: versionId, - datasetId: args.datasetId, - version: newVersion, - createdAt: now, + await versionsCollection.insertOne( + { + id: versionId, + datasetId: args.datasetId, + version: newVersion, + createdAt: now, + }, + { session }, + ); }); return { @@ -651,54 +677,66 @@ export class MongoDBDatasetsStorage extends DatasetsStorage { const itemsCollection = await this.getCollection(TABLE_DATASET_ITEMS); const versionsCollection = await this.getCollection(TABLE_DATASET_VERSIONS); - // Bump dataset version - const result = await datasetsCollection.findOneAndUpdate( - { id: datasetId }, - { $inc: { version: 1 } }, - { returnDocument: 'after' }, - ); - if (!result) { - throw new MastraError({ - id: createStorageErrorId('MONGODB', 'DELETE_ITEM', 'DATASET_NOT_FOUND'), - domain: ErrorDomain.STORAGE, - category: ErrorCategory.USER, - details: { datasetId }, - }); - } - const newVersion = result.version as number; - // Tenancy re-inherited from parent dataset (Option B) - const parentOrganizationId = (result.organizationId as string | null | undefined) ?? null; - const parentProjectId = (result.projectId as string | null | undefined) ?? null; - - // Close old row - await itemsCollection.updateOne({ id, validTo: null, isDeleted: false }, { $set: { validTo: newVersion } }); + // Bump version, close old row, insert tombstone, and insert version row atomically + await this.#connector.withTransaction(async session => { + const result = await datasetsCollection.findOneAndUpdate( + { id: datasetId }, + { $inc: { version: 1 } }, + { session, returnDocument: 'after' }, + ); + if (!result) { + throw new MastraError({ + id: createStorageErrorId('MONGODB', 'DELETE_ITEM', 'DATASET_NOT_FOUND'), + domain: ErrorDomain.STORAGE, + category: ErrorCategory.USER, + details: { datasetId }, + }); + } + const newVersion = result.version as number; + // Tenancy re-inherited from parent dataset (Option B) + const parentOrganizationId = (result.organizationId as string | null | undefined) ?? null; + const parentProjectId = (result.projectId as string | null | undefined) ?? null; + + // Close old row + await itemsCollection.updateOne( + { id, validTo: null, isDeleted: false }, + { $set: { validTo: newVersion } }, + { session }, + ); - // Insert tombstone - await itemsCollection.insertOne({ - id, - datasetId, - datasetVersion: newVersion, - organizationId: parentOrganizationId, - projectId: parentProjectId, - validTo: null, - isDeleted: true, - input: existing.input, - groundTruth: existing.groundTruth, - expectedTrajectory: existing.expectedTrajectory ?? null, - toolMocks: existing.toolMocks ?? null, - requestContext: existing.requestContext, - metadata: existing.metadata, - source: existing.source, - createdAt: existing.createdAt, - updatedAt: now, - }); + // Insert tombstone + await itemsCollection.insertOne( + { + id, + datasetId, + datasetVersion: newVersion, + organizationId: parentOrganizationId, + projectId: parentProjectId, + validTo: null, + isDeleted: true, + input: existing.input, + groundTruth: existing.groundTruth, + expectedTrajectory: existing.expectedTrajectory ?? null, + toolMocks: existing.toolMocks ?? null, + requestContext: existing.requestContext, + metadata: existing.metadata, + source: existing.source, + createdAt: existing.createdAt, + updatedAt: now, + }, + { session }, + ); - // Insert dataset_version row - await versionsCollection.insertOne({ - id: versionId, - datasetId, - version: newVersion, - createdAt: now, + // Insert dataset_version row + await versionsCollection.insertOne( + { + id: versionId, + datasetId, + version: newVersion, + createdAt: now, + }, + { session }, + ); }); } catch (error) { if (error instanceof MastraError) throw error; @@ -745,26 +783,28 @@ export class MongoDBDatasetsStorage extends DatasetsStorage { const itemsCollection = await this.getCollection(TABLE_DATASET_ITEMS); const versionsCollection = await this.getCollection(TABLE_DATASET_VERSIONS); - // Single version bump - const result = await datasetsCollection.findOneAndUpdate( - { id: input.datasetId }, - { $inc: { version: 1 } }, - { returnDocument: 'after' }, - ); - if (!result) { - throw new MastraError({ - id: createStorageErrorId('MONGODB', 'BULK_ADD_ITEMS', 'DATASET_NOT_FOUND'), - domain: ErrorDomain.STORAGE, - category: ErrorCategory.USER, - details: { datasetId: input.datasetId }, - }); - } - const newVersion = result.version as number; - const organizationId = (result.organizationId as string | null | undefined) ?? null; - const projectId = (result.projectId as string | null | undefined) ?? null; + // Single version bump, batch insert items, and record version row atomically + let newVersion = 0; + let organizationId: string | null = null; + let projectId: string | null = null; + await this.#connector.withTransaction(async session => { + const result = await datasetsCollection.findOneAndUpdate( + { id: input.datasetId }, + { $inc: { version: 1 } }, + { session, returnDocument: 'after' }, + ); + if (!result) { + throw new MastraError({ + id: createStorageErrorId('MONGODB', 'BULK_ADD_ITEMS', 'DATASET_NOT_FOUND'), + domain: ErrorDomain.STORAGE, + category: ErrorCategory.USER, + details: { datasetId: input.datasetId }, + }); + } + newVersion = result.version as number; + organizationId = (result.organizationId as string | null | undefined) ?? null; + projectId = (result.projectId as string | null | undefined) ?? null; - // Batch insert items - if (itemsWithIds.length > 0) { const docs = itemsWithIds.map(({ generatedId, itemInput }) => ({ id: generatedId, datasetId: input.datasetId, @@ -783,15 +823,19 @@ export class MongoDBDatasetsStorage extends DatasetsStorage { createdAt: now, updatedAt: now, })); - await itemsCollection.insertMany(docs); - } - // Single dataset_version row - await versionsCollection.insertOne({ - id: versionId, - datasetId: input.datasetId, - version: newVersion, - createdAt: now, + await itemsCollection.insertMany(docs, { session }); + + // Single dataset_version row + await versionsCollection.insertOne( + { + id: versionId, + datasetId: input.datasetId, + version: newVersion, + createdAt: now, + }, + { session }, + ); }); return itemsWithIds.map(({ generatedId, itemInput }) => ({ @@ -855,59 +899,66 @@ export class MongoDBDatasetsStorage extends DatasetsStorage { const datasetsCollection = await this.getCollection(TABLE_DATASETS); const versionsCollection = await this.getCollection(TABLE_DATASET_VERSIONS); - // Single version bump - const result = await datasetsCollection.findOneAndUpdate( - { id: input.datasetId }, - { $inc: { version: 1 } }, - { returnDocument: 'after' }, - ); - if (!result) { - throw new MastraError({ - id: createStorageErrorId('MONGODB', 'BULK_DELETE_ITEMS', 'DATASET_NOT_FOUND'), - domain: ErrorDomain.STORAGE, - category: ErrorCategory.USER, - details: { datasetId: input.datasetId }, - }); - } - const newVersion = result.version as number; - // Tenancy re-inherited from parent dataset (Option B) - const parentOrganizationId = (result.organizationId as string | null | undefined) ?? null; - const parentProjectId = (result.projectId as string | null | undefined) ?? null; - - // Close old rows in batch + // Close old rows, insert tombstones, and record version — all atomic including the version bump const currentIds = currentItems.map(i => i.id); - await itemsCollection.updateMany( - { id: { $in: currentIds }, validTo: null, isDeleted: false }, - { $set: { validTo: newVersion } }, - ); + await this.#connector.withTransaction(async session => { + const result = await datasetsCollection.findOneAndUpdate( + { id: input.datasetId }, + { $inc: { version: 1 } }, + { session, returnDocument: 'after' }, + ); + if (!result) { + throw new MastraError({ + id: createStorageErrorId('MONGODB', 'BULK_DELETE_ITEMS', 'DATASET_NOT_FOUND'), + domain: ErrorDomain.STORAGE, + category: ErrorCategory.USER, + details: { datasetId: input.datasetId }, + }); + } + const newVersion = result.version as number; + // Tenancy re-inherited from parent dataset (Option B) + const parentOrganizationId = (result.organizationId as string | null | undefined) ?? null; + const parentProjectId = (result.projectId as string | null | undefined) ?? null; - // Insert tombstones in batch - const tombstones = currentItems.map(item => ({ - id: item.id, - datasetId: input.datasetId, - datasetVersion: newVersion, - organizationId: parentOrganizationId, - projectId: parentProjectId, - validTo: null, - isDeleted: true, - input: item.input, - groundTruth: item.groundTruth, - expectedTrajectory: item.expectedTrajectory ?? null, - toolMocks: item.toolMocks ?? null, - requestContext: item.requestContext, - metadata: item.metadata, - source: item.source, - createdAt: item.createdAt, - updatedAt: now, - })); - await itemsCollection.insertMany(tombstones); + const tombstones = currentItems.map(item => ({ + id: item.id, + datasetId: input.datasetId, + datasetVersion: newVersion, + organizationId: parentOrganizationId, + projectId: parentProjectId, + validTo: null, + isDeleted: true, + input: item.input, + groundTruth: item.groundTruth, + expectedTrajectory: item.expectedTrajectory ?? null, + toolMocks: item.toolMocks ?? null, + requestContext: item.requestContext, + metadata: item.metadata, + source: item.source, + createdAt: item.createdAt, + updatedAt: now, + })); - // Single dataset_version row - await versionsCollection.insertOne({ - id: versionId, - datasetId: input.datasetId, - version: newVersion, - createdAt: now, + // Close old rows in batch + await itemsCollection.updateMany( + { id: { $in: currentIds }, validTo: null, isDeleted: false }, + { $set: { validTo: newVersion } }, + { session }, + ); + + // Insert tombstones in batch + await itemsCollection.insertMany(tombstones, { session }); + + // Single dataset_version row + await versionsCollection.insertOne( + { + id: versionId, + datasetId: input.datasetId, + version: newVersion, + createdAt: now, + }, + { session }, + ); }); } catch (error) { if (error instanceof MastraError) throw error; diff --git a/stores/mongodb/src/storage/domains/mcp-clients/index.ts b/stores/mongodb/src/storage/domains/mcp-clients/index.ts index ec2092bee53c..486708500c4b 100644 --- a/stores/mongodb/src/storage/domains/mcp-clients/index.ts +++ b/stores/mongodb/src/storage/domains/mcp-clients/index.ts @@ -166,33 +166,30 @@ export class MongoDBMCPClientsStorage extends MCPClientsStorage { updatedAt: now, }; - await collection.insertOne(this.serializeMCPClient(newMCPClient)); - // Extract snapshot config from flat input const snapshotConfig: Record<string, any> = {}; for (const field of SNAPSHOT_FIELDS) { - if ((mcpClient as any)[field] !== undefined) { - snapshotConfig[field] = (mcpClient as any)[field]; - } + if ((mcpClient as any)[field] !== undefined) snapshotConfig[field] = (mcpClient as any)[field]; } - - // Create version 1 const versionId = randomUUID(); - try { - await this.createVersion({ - id: versionId, - mcpClientId: mcpClient.id, - versionNumber: 1, - ...snapshotConfig, - changedFields: Object.keys(snapshotConfig), - changeMessage: 'Initial version', - } as CreateMCPClientVersionInput); - } catch (versionError) { - // Clean up the orphaned client record - await collection.deleteOne({ id: mcpClient.id }); - throw versionError; + const versionDoc: Record<string, any> = { + id: versionId, + mcpClientId: mcpClient.id, + versionNumber: 1, + changedFields: Object.keys(snapshotConfig), + changeMessage: 'Initial version', + createdAt: new Date(), + }; + for (const field of SNAPSHOT_FIELDS) { + if (snapshotConfig[field] !== undefined) versionDoc[field] = snapshotConfig[field]; } + await this.#connector.withTransaction(async session => { + await collection.insertOne(this.serializeMCPClient(newMCPClient), { session }); + const versionsCol = await this.getCollection(TABLE_MCP_CLIENT_VERSIONS); + await versionsCol.insertOne(versionDoc, { session }); + }); + return newMCPClient; } catch (error) { if (error instanceof MastraError) { @@ -284,12 +281,12 @@ export class MongoDBMCPClientsStorage extends MCPClientsStorage { async delete(id: string): Promise<void> { try { - // Delete all versions first - await this.deleteVersionsByParentId(id); - - // Then delete the MCP client - const collection = await this.getCollection(TABLE_MCP_CLIENTS); - await collection.deleteOne({ id }); + await this.#connector.withTransaction(async session => { + const versionsCol = await this.getCollection(TABLE_MCP_CLIENT_VERSIONS); + await versionsCol.deleteMany({ mcpClientId: id }, { session }); + const col = await this.getCollection(TABLE_MCP_CLIENTS); + await col.deleteOne({ id }, { session }); + }); } catch (error) { throw new MastraError( { diff --git a/stores/mongodb/src/storage/domains/mcp-servers/index.ts b/stores/mongodb/src/storage/domains/mcp-servers/index.ts index 7630e9f88eb8..129bd81f802f 100644 --- a/stores/mongodb/src/storage/domains/mcp-servers/index.ts +++ b/stores/mongodb/src/storage/domains/mcp-servers/index.ts @@ -179,8 +179,6 @@ export class MongoDBMCPServersStorage extends MCPServersStorage { updatedAt: now, }; - await collection.insertOne(this.serializeMCPServer(newMCPServer)); - // Extract snapshot config from flat input const snapshotConfig: Record<string, any> = {}; for (const field of SNAPSHOT_FIELDS) { @@ -191,21 +189,24 @@ export class MongoDBMCPServersStorage extends MCPServersStorage { // Create version 1 const versionId = randomUUID(); - try { - await this.createVersion({ - id: versionId, - mcpServerId: mcpServer.id, - versionNumber: 1, - ...snapshotConfig, - changedFields: Object.keys(snapshotConfig), - changeMessage: 'Initial version', - } as CreateMCPServerVersionInput); - } catch (versionError) { - // Clean up the orphaned server record - await collection.deleteOne({ id: mcpServer.id }); - throw versionError; + const versionDoc: Record<string, any> = { + id: versionId, + mcpServerId: mcpServer.id, + versionNumber: 1, + changedFields: Object.keys(snapshotConfig), + changeMessage: 'Initial version', + createdAt: new Date(), + }; + for (const field of SNAPSHOT_FIELDS) { + if (snapshotConfig[field] !== undefined) versionDoc[field] = snapshotConfig[field]; } + await this.#connector.withTransaction(async session => { + await collection.insertOne(this.serializeMCPServer(newMCPServer), { session }); + const versionsCol = await this.getCollection(TABLE_MCP_SERVER_VERSIONS); + await versionsCol.insertOne(versionDoc, { session }); + }); + return newMCPServer; } catch (error) { if (error instanceof MastraError) { @@ -297,12 +298,12 @@ export class MongoDBMCPServersStorage extends MCPServersStorage { async delete(id: string): Promise<void> { try { - // Delete all versions first - await this.deleteVersionsByParentId(id); - - // Then delete the MCP server - const collection = await this.getCollection(TABLE_MCP_SERVERS); - await collection.deleteOne({ id }); + await this.#connector.withTransaction(async session => { + const versionsCol = await this.getCollection(TABLE_MCP_SERVER_VERSIONS); + await versionsCol.deleteMany({ mcpServerId: id }, { session }); + const col = await this.getCollection(TABLE_MCP_SERVERS); + await col.deleteOne({ id }, { session }); + }); } catch (error) { if (error instanceof MastraError) throw error; throw new MastraError( diff --git a/stores/mongodb/src/storage/domains/memory/index.ts b/stores/mongodb/src/storage/domains/memory/index.ts index 4deada10d2cc..f53dc5e0fd67 100644 --- a/stores/mongodb/src/storage/domains/memory/index.ts +++ b/stores/mongodb/src/storage/domains/memory/index.ts @@ -83,24 +83,24 @@ export class MemoryStorageMongoDB extends MemoryStorage { */ getDefaultIndexDefinitions(): MongoDBIndexConfig[] { return [ - // Threads collection indexes + // Threads: point lookups (id) + resource-scoped listing sorted by createdAt or updatedAt. + // A single descending compound serves both ASC and DESC sorts, and its resourceId prefix + // covers resourceId-only filters. Unfiltered (no resourceId) listing is a rare admin path + // left to an in-memory sort rather than carrying standalone single-field sort indexes. { collection: TABLE_THREADS, keys: { id: 1 }, options: { unique: true } }, - { collection: TABLE_THREADS, keys: { resourceId: 1 } }, - { collection: TABLE_THREADS, keys: { createdAt: -1 } }, - { collection: TABLE_THREADS, keys: { updatedAt: -1 } }, - // Messages collection indexes + { collection: TABLE_THREADS, keys: { resourceId: 1, createdAt: -1 } }, + { collection: TABLE_THREADS, keys: { resourceId: 1, updatedAt: -1 } }, + // Messages: point lookups (id) + per-thread retrieval (listMessages) and per-resource + // retrieval (listMessagesByResourceId), both sorted by createdAt. The compound prefixes + // cover thread_id-only and resourceId-only filters. { collection: TABLE_MESSAGES, keys: { id: 1 }, options: { unique: true } }, - { collection: TABLE_MESSAGES, keys: { thread_id: 1 } }, - { collection: TABLE_MESSAGES, keys: { resourceId: 1 } }, - { collection: TABLE_MESSAGES, keys: { createdAt: -1 } }, { collection: TABLE_MESSAGES, keys: { thread_id: 1, createdAt: 1 } }, - // Resources collection indexes + { collection: TABLE_MESSAGES, keys: { resourceId: 1, createdAt: 1 } }, + // Resources: only ever fetched by id. { collection: TABLE_RESOURCES, keys: { id: 1 }, options: { unique: true } }, - { collection: TABLE_RESOURCES, keys: { createdAt: -1 } }, - { collection: TABLE_RESOURCES, keys: { updatedAt: -1 } }, - // Observational Memory collection indexes + // Observational Memory: point lookups (id) + latest-generation-per-lookupKey. The compound + // prefix covers lookupKey-only filters. { collection: OM_TABLE, keys: { id: 1 }, options: { unique: true } }, - { collection: OM_TABLE, keys: { lookupKey: 1 } }, { collection: OM_TABLE, keys: { lookupKey: 1, generationCount: -1 } }, ]; } @@ -118,8 +118,18 @@ export class MemoryStorageMongoDB extends MemoryStorage { const collection = await this.getCollection(indexDef.collection); await collection.createIndex(indexDef.keys, indexDef.options); } catch (error) { - // Log but continue - indexes are performance optimizations - this.logger?.warn?.(`Failed to create index on ${indexDef.collection}:`, error); + // Fail loud: a silently missing index degrades query performance at scale. + // Users who manage their own indexes can set skipDefaultIndexes. + throw new MastraError( + { + id: createStorageErrorId('MONGODB', 'CREATE_DEFAULT_INDEXES', 'FAILED'), + domain: ErrorDomain.STORAGE, + category: ErrorCategory.THIRD_PARTY, + text: `Failed to create default index on collection "${indexDef.collection}". Set skipDefaultIndexes to manage indexes yourself.`, + details: { collection: indexDef.collection }, + }, + error, + ); } } } @@ -144,14 +154,18 @@ export class MemoryStorageMongoDB extends MemoryStorage { } async dangerouslyClearAll(): Promise<void> { - const threadsCollection = await this.getCollection(TABLE_THREADS); - const messagesCollection = await this.getCollection(TABLE_MESSAGES); - const resourcesCollection = await this.getCollection(TABLE_RESOURCES); + const [threadsCollection, messagesCollection, resourcesCollection, omCollection] = await Promise.all([ + this.getCollection(TABLE_THREADS), + this.getCollection(TABLE_MESSAGES), + this.getCollection(TABLE_RESOURCES), + this.getCollection(OM_TABLE), + ]); await Promise.all([ threadsCollection.deleteMany({}), messagesCollection.deleteMany({}), resourcesCollection.deleteMany({}), + omCollection.deleteMany({}), ]); } @@ -651,11 +665,20 @@ export class MemoryStorageMongoDB extends MemoryStorage { }; }); - // Execute message inserts and thread update in parallel - await Promise.all([ - collection.bulkWrite(messagesToInsert), - threadsCollection.updateOne({ id: threadId }, { $set: { updatedAt: new Date() } }), - ]); + // Collect every distinct thread touched by this batch (mirrors pg behaviour) + const allThreadIds = new Set(messages.map(m => m.threadId!)); + const now = new Date(); + + // Write messages and refresh each touched thread's updatedAt atomically when + // supported. Operations are sequential because a transaction session is not + // concurrency-safe; on a standalone server this degrades to the same sequential + // best-effort behavior. + await this.#connector.withTransaction(async session => { + await collection.bulkWrite(messagesToInsert, { session }); + for (const tid of allThreadIds) { + await threadsCollection.updateOne({ id: tid }, { $set: { updatedAt: now } }, { session }); + } + }); const list = new MessageList().add(messages as (MastraMessageV1 | MastraDBMessage)[], 'memory'); return { messages: list.get.all.db() }; @@ -808,10 +831,7 @@ export class MemoryStorageMongoDB extends MemoryStorage { await collection.updateOne( { id: resource.id }, { - $set: { - ...resource, - metadata: JSON.stringify(resource.metadata), - }, + $set: { ...resource }, }, { upsert: true }, ); @@ -869,7 +889,7 @@ export class MemoryStorageMongoDB extends MemoryStorage { } if (metadata) { - updateDoc.metadata = JSON.stringify(updatedResource.metadata); + updateDoc.metadata = updatedResource.metadata; } await collection.updateOne({ id: resourceId }, { $set: updateDoc }); @@ -1117,10 +1137,17 @@ export class MemoryStorageMongoDB extends MemoryStorage { async deleteThread({ threadId }: { threadId: string }): Promise<void> { try { - // First, delete all messages associated with the thread + // Best-effort cascade, deliberately NOT wrapped in a transaction: a thread + // can accumulate an unbounded number of messages, and a transactional + // deleteMany is capped by transactionLifetimeLimitSeconds (60s default) and + // must hold every delete in cache until commit — so a large thread would + // abort and become permanently undeletable. A plain deleteMany commits + // incrementally and always completes. Messages are removed before the thread + // so the thread row is the linearization point: a crash mid-drain leaves the + // thread re-deletable (deleteMany is idempotent), with only orphaned messages + // keyed by a thread_id nothing queries as transient, sweepable residue. const collectionMessages = await this.getCollection(TABLE_MESSAGES); await collectionMessages.deleteMany({ thread_id: threadId }); - // Then delete the thread itself const collectionThreads = await this.getCollection(TABLE_THREADS); await collectionThreads.deleteOne({ id: threadId }); } catch (error) { diff --git a/stores/mongodb/src/storage/domains/memory/memory-data-modeling.test.ts b/stores/mongodb/src/storage/domains/memory/memory-data-modeling.test.ts new file mode 100644 index 000000000000..1039cd315385 --- /dev/null +++ b/stores/mongodb/src/storage/domains/memory/memory-data-modeling.test.ts @@ -0,0 +1,165 @@ +import { MastraError } from '@mastra/core/error'; +import { MongoClient } from 'mongodb'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { MongoDBStore } from '../../index'; +import { MemoryStorageMongoDB } from './index'; + +const URI = process.env.MONGODB_URL || 'mongodb://localhost:27017'; +const DB = 'mastra-memory-data-modeling-test'; // dedicated, fresh db so "absence of dropped index" assertions are valid + +const dropTestDb = async () => { + const client = new MongoClient(URI); + try { + await client.connect(); + await client.db(DB).dropDatabase(); + } finally { + await client.close(); + } +}; + +describe('MemoryStorageMongoDB — index definitions and metadata storage', () => { + beforeAll(dropTestDb); + afterAll(dropTestDb); + + test('memory collections have the expected index set after init', async () => { + const store = new MongoDBStore({ id: 'memory-data-modeling-a1', uri: URI, dbName: DB }); + await store.init(); + + const client = new MongoClient(URI); + try { + await client.connect(); + const db = client.db(DB); + const keysFor = async (coll: string) => + (await db.collection(coll).indexes()) + .map(i => JSON.stringify(i.key)) + .filter(k => k !== JSON.stringify({ _id: 1 })); + + // Threads: id + two resource-scoped compounds; standalone resourceId/createdAt/updatedAt dropped. + const threads = await keysFor('mastra_threads'); + expect(threads).toContain(JSON.stringify({ resourceId: 1, createdAt: -1 })); + expect(threads).toContain(JSON.stringify({ resourceId: 1, updatedAt: -1 })); + expect(threads).not.toContain(JSON.stringify({ resourceId: 1 })); + expect(threads).not.toContain(JSON.stringify({ createdAt: -1 })); + expect(threads).not.toContain(JSON.stringify({ updatedAt: -1 })); + + // Messages: id + per-thread and per-resource compounds; standalone thread_id/createdAt dropped. + const messages = await keysFor('mastra_messages'); + expect(messages).toContain(JSON.stringify({ thread_id: 1, createdAt: 1 })); + expect(messages).toContain(JSON.stringify({ resourceId: 1, createdAt: 1 })); + expect(messages).not.toContain(JSON.stringify({ thread_id: 1 })); + expect(messages).not.toContain(JSON.stringify({ createdAt: -1 })); + + // Resources: only id. + const resources = await keysFor('mastra_resources'); + expect(resources).toEqual([JSON.stringify({ id: 1 })]); + + // OM: id + lookupKey/generationCount compound; standalone lookupKey dropped. + const om = await keysFor('mastra_observational_memory'); + expect(om).toContain(JSON.stringify({ lookupKey: 1, generationCount: -1 })); + expect(om).not.toContain(JSON.stringify({ lookupKey: 1 })); + + // The id indexes must remain unique. + for (const coll of ['mastra_threads', 'mastra_messages', 'mastra_resources', 'mastra_observational_memory']) { + const idx = (await db.collection(coll).indexes()).find( + i => JSON.stringify(i.key) === JSON.stringify({ id: 1 }), + ); + expect(idx?.unique).toBe(true); + } + } finally { + await client.close(); + await store.close(); + } + }); + + test('resource metadata is stored as a native sub-document; legacy string rows still parse on read', async () => { + const store = new MongoDBStore({ id: 'memory-data-modeling-a3', uri: URI, dbName: DB }); + await store.init(); + const memory = await store.getStore('memory'); + + const resourceId = `res-a3-${Date.now()}`; + await memory?.saveResource({ + resource: { + id: resourceId, + workingMemory: 'wm', + metadata: { tier: 'gold', nested: { count: 3 } }, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const client = new MongoClient(URI); + try { + await client.connect(); + const resources = client.db(DB).collection('mastra_resources'); + + // saveResource stores metadata as a native sub-document, not a JSON string. + const raw = await resources.findOne<any>({ id: resourceId }); + expect(typeof raw.metadata).toBe('object'); + expect(raw.metadata.tier).toBe('gold'); + + // getResourceById returns an object. + const fetched = await memory?.getResourceById({ resourceId }); + expect(fetched?.metadata).toEqual({ tier: 'gold', nested: { count: 3 } }); + + // updateResource also writes metadata natively (merging with existing). + await memory?.updateResource({ resourceId, metadata: { tier: 'platinum' } }); + const rawUpdated = await resources.findOne<any>({ id: resourceId }); + expect(typeof rawUpdated.metadata).toBe('object'); + expect(rawUpdated.metadata).toEqual({ tier: 'platinum', nested: { count: 3 } }); + + // Back-compat: a legacy row whose metadata is a JSON string still parses on read. + const legacyId = `res-a3-legacy-${Date.now()}`; + await resources.insertOne({ + id: legacyId, + workingMemory: '', + metadata: JSON.stringify({ tier: 'silver' }), + createdAt: new Date(), + updatedAt: new Date(), + }); + const legacy = await memory?.getResourceById({ resourceId: legacyId }); + expect(legacy?.metadata).toEqual({ tier: 'silver' }); + } finally { + await client.close(); + await store.close(); + } + }); + + test('createDefaultIndexes throws a MastraError when index creation fails', async () => { + const throwingMemory = new MemoryStorageMongoDB({ + connectorHandler: { + getCollection: async () => + ({ + createIndex: async () => { + throw new Error('index build failed (simulated)'); + }, + }) as any, + close: async () => {}, + }, + }); + + const err = await throwingMemory.createDefaultIndexes().catch(e => e); + expect(err).toBeInstanceOf(MastraError); + expect(String(err.id)).toContain('CREATE_DEFAULT_INDEXES'); + }); + + test('createDefaultIndexes resolves when index creation succeeds', async () => { + const okMemory = new MemoryStorageMongoDB({ + connectorHandler: { + getCollection: async () => ({ createIndex: async () => 'ok' }) as any, + close: async () => {}, + }, + }); + + await expect(okMemory.createDefaultIndexes()).resolves.toBeUndefined(); + }); + + test.todo( + 'F3: concurrent swapBufferedToActive calls must not duplicate observation chunks ' + + '(non-deterministic: requires two goroutine-equivalent async tasks to read the same snapshot)', + ); + + test.todo( + 'F4: concurrent updateBufferedReflection calls must not lose one write ' + + '(non-deterministic: lost update requires two writes to race on the same document)', + ); +}); diff --git a/stores/mongodb/src/storage/domains/observability/index.ts b/stores/mongodb/src/storage/domains/observability/index.ts index 789df0ea6134..cbb00e819584 100644 --- a/stores/mongodb/src/storage/domains/observability/index.ts +++ b/stores/mongodb/src/storage/domains/observability/index.ts @@ -26,6 +26,7 @@ import type { GetTraceResponse, GetTraceLightResponse, } from '@mastra/core/storage'; +import { MongoBulkWriteError } from 'mongodb'; import type { MongoDBConnector } from '../../connectors/MongoDBConnector'; import { resolveMongoDBConfig } from '../../db'; import type { MongoDBDomainConfig, MongoDBIndexConfig } from '../../types'; @@ -927,9 +928,15 @@ export class ObservabilityMongoDB extends ObservabilityStorage { if (records.length > 0) { const collection = await this.getCollection(TABLE_SPANS); - await collection.insertMany(records); + await collection.insertMany(records, { ordered: false }); } } catch (error) { + // With ordered: false, MongoDB throws MongoBulkWriteError when any writes fail. + // Duplicate key errors (11000) are expected in at-least-once delivery — skip them. + if (error instanceof MongoBulkWriteError) { + const writeErrors = Array.isArray(error.writeErrors) ? error.writeErrors : [error.writeErrors]; + if (writeErrors.length > 0 && writeErrors.every(e => e.code === 11000)) return; + } throw new MastraError( { id: createStorageErrorId('MONGODB', 'BATCH_CREATE_SPANS', 'FAILED'), diff --git a/stores/mongodb/src/storage/domains/prompt-blocks/index.ts b/stores/mongodb/src/storage/domains/prompt-blocks/index.ts index 59e2f7fad9c4..67673b6470d2 100644 --- a/stores/mongodb/src/storage/domains/prompt-blocks/index.ts +++ b/stores/mongodb/src/storage/domains/prompt-blocks/index.ts @@ -166,8 +166,6 @@ export class MongoDBPromptBlocksStorage extends PromptBlocksStorage { updatedAt: now, }; - await collection.insertOne(this.serializeBlock(newBlock)); - // Extract snapshot config from flat input const snapshotConfig: Record<string, any> = {}; for (const field of SNAPSHOT_FIELDS) { @@ -178,14 +176,23 @@ export class MongoDBPromptBlocksStorage extends PromptBlocksStorage { // Create version 1 const versionId = randomUUID(); - await this.createVersion({ + const versionDoc: Record<string, any> = { id: versionId, blockId: promptBlock.id, versionNumber: 1, - ...snapshotConfig, changedFields: Object.keys(snapshotConfig), changeMessage: 'Initial version', - } as CreatePromptBlockVersionInput); + createdAt: new Date(), + }; + for (const field of SNAPSHOT_FIELDS) { + if (snapshotConfig[field] !== undefined) versionDoc[field] = snapshotConfig[field]; + } + + await this.#connector.withTransaction(async session => { + await collection.insertOne(this.serializeBlock(newBlock), { session }); + const versionsCol = await this.getCollection(TABLE_PROMPT_BLOCK_VERSIONS); + await versionsCol.insertOne(versionDoc, { session }); + }); return newBlock; } catch (error) { @@ -278,12 +285,12 @@ export class MongoDBPromptBlocksStorage extends PromptBlocksStorage { async delete(id: string): Promise<void> { try { - // Delete all versions first - await this.deleteVersionsByParentId(id); - - // Then delete the block - const collection = await this.getCollection(TABLE_PROMPT_BLOCKS); - await collection.deleteOne({ id }); + await this.#connector.withTransaction(async session => { + const versionsCol = await this.getCollection(TABLE_PROMPT_BLOCK_VERSIONS); + await versionsCol.deleteMany({ blockId: id }, { session }); + const col = await this.getCollection(TABLE_PROMPT_BLOCKS); + await col.deleteOne({ id }, { session }); + }); } catch (error) { throw new MastraError( { diff --git a/stores/mongodb/src/storage/domains/schedules/index.ts b/stores/mongodb/src/storage/domains/schedules/index.ts index e9d5481883ec..5a416c3726d5 100644 --- a/stores/mongodb/src/storage/domains/schedules/index.ts +++ b/stores/mongodb/src/storage/domains/schedules/index.ts @@ -243,10 +243,12 @@ export class SchedulesMongoDB extends SchedulesStorage { } async deleteSchedule(id: string): Promise<void> { - const triggers = await this.getTriggersCollection(); - await triggers.deleteMany({ schedule_id: id }); - const schedules = await this.getSchedulesCollection(); - await schedules.deleteOne({ id }); + await this.#connector.withTransaction(async session => { + const triggers = await this.getTriggersCollection(); + await triggers.deleteMany({ schedule_id: id }, { session }); + const schedules = await this.getSchedulesCollection(); + await schedules.deleteOne({ id }, { session }); + }); } async recordTrigger(trigger: ScheduleTrigger): Promise<void> { diff --git a/stores/mongodb/src/storage/domains/scorer-definitions/index.ts b/stores/mongodb/src/storage/domains/scorer-definitions/index.ts index 5037cee16065..27ca050ca59a 100644 --- a/stores/mongodb/src/storage/domains/scorer-definitions/index.ts +++ b/stores/mongodb/src/storage/domains/scorer-definitions/index.ts @@ -175,8 +175,6 @@ export class MongoDBScorerDefinitionsStorage extends ScorerDefinitionsStorage { updatedAt: now, }; - await collection.insertOne(this.serializeScorerDefinition(newScorerDefinition)); - // Extract snapshot config from flat input const snapshotConfig: Record<string, any> = {}; for (const field of SNAPSHOT_FIELDS) { @@ -187,14 +185,23 @@ export class MongoDBScorerDefinitionsStorage extends ScorerDefinitionsStorage { // Create version 1 const versionId = randomUUID(); - await this.createVersion({ + const versionDoc: Record<string, any> = { id: versionId, scorerDefinitionId: scorerDefinition.id, versionNumber: 1, - ...snapshotConfig, changedFields: Object.keys(snapshotConfig), changeMessage: 'Initial version', - } as CreateScorerDefinitionVersionInput); + createdAt: new Date(), + }; + for (const field of SNAPSHOT_FIELDS) { + if (snapshotConfig[field] !== undefined) versionDoc[field] = snapshotConfig[field]; + } + + await this.#connector.withTransaction(async session => { + await collection.insertOne(this.serializeScorerDefinition(newScorerDefinition), { session }); + const versionsCol = await this.getCollection(TABLE_SCORER_DEFINITION_VERSIONS); + await versionsCol.insertOne(versionDoc, { session }); + }); return newScorerDefinition; } catch (error) { @@ -287,12 +294,12 @@ export class MongoDBScorerDefinitionsStorage extends ScorerDefinitionsStorage { async delete(id: string): Promise<void> { try { - // Delete all versions first - await this.deleteVersionsByParentId(id); - - // Then delete the scorer definition - const collection = await this.getCollection(TABLE_SCORER_DEFINITIONS); - await collection.deleteOne({ id }); + await this.#connector.withTransaction(async session => { + const versionsCol = await this.getCollection(TABLE_SCORER_DEFINITION_VERSIONS); + await versionsCol.deleteMany({ scorerDefinitionId: id }, { session }); + const col = await this.getCollection(TABLE_SCORER_DEFINITIONS); + await col.deleteOne({ id }, { session }); + }); } catch (error) { throw new MastraError( { diff --git a/stores/mongodb/src/storage/domains/skills/index.ts b/stores/mongodb/src/storage/domains/skills/index.ts index 917e2b10e212..92ec3a919193 100644 --- a/stores/mongodb/src/storage/domains/skills/index.ts +++ b/stores/mongodb/src/storage/domains/skills/index.ts @@ -184,33 +184,30 @@ export class MongoDBSkillsStorage extends SkillsStorage { updatedAt: now, }; - await collection.insertOne(this.serializeSkill(newSkill)); - // Extract snapshot config from flat input const snapshotConfig: Record<string, any> = {}; for (const field of SNAPSHOT_FIELDS) { - if ((skill as any)[field] !== undefined) { - snapshotConfig[field] = (skill as any)[field]; - } + if ((skill as any)[field] !== undefined) snapshotConfig[field] = (skill as any)[field]; } - - // Create version 1 const versionId = randomUUID(); - try { - await this.createVersion({ - id: versionId, - skillId: id, - versionNumber: 1, - ...snapshotConfig, - changedFields: Object.keys(snapshotConfig), - changeMessage: 'Initial version', - } as CreateSkillVersionInput); - } catch (versionError) { - // Clean up the orphaned skill record - await collection.deleteOne({ id }); - throw versionError; + const versionDoc: Record<string, any> = { + id: versionId, + skillId: id, + versionNumber: 1, + changedFields: Object.keys(snapshotConfig), + changeMessage: 'Initial version', + createdAt: new Date(), + }; + for (const field of SNAPSHOT_FIELDS) { + if (snapshotConfig[field] !== undefined) versionDoc[field] = snapshotConfig[field]; } + await this.#connector.withTransaction(async session => { + await collection.insertOne(this.serializeSkill(newSkill), { session }); + const versionsCol = await this.getCollection(TABLE_SKILL_VERSIONS); + await versionsCol.insertOne(versionDoc, { session }); + }); + return newSkill; } catch (error) { if (error instanceof MastraError) { @@ -264,6 +261,22 @@ export class MongoDBSkillsStorage extends SkillsStorage { } } + // Handle metadata-level updates + if (metadataFields.authorId !== undefined) updateDoc.authorId = metadataFields.authorId; + if (metadataFields.visibility !== undefined) updateDoc.visibility = metadataFields.visibility; + if (metadataFields.activeVersionId !== undefined) { + updateDoc.activeVersionId = metadataFields.activeVersionId; + // Auto-set status to 'published' when activeVersionId is set, consistent with InMemory and LibSQL + if (metadataFields.status === undefined) { + updateDoc.status = 'published'; + } + } + if (metadataFields.status !== undefined) { + updateDoc.status = metadataFields.status; + } + + // Build new version doc if config fields changed + let newVersionDoc: Record<string, any> | null = null; if (Object.keys(configFields).length > 0) { const latestVersion = await this.getLatestVersion(id); @@ -287,33 +300,29 @@ export class MongoDBSkillsStorage extends SkillsStorage { ); if (changedFields.length > 0) { - await this.createVersion({ + const mergedSnapshot = { ...existingSnapshot, ...configFields }; + newVersionDoc = { id: randomUUID(), skillId: id, versionNumber: latestVersion.versionNumber + 1, - ...existingSnapshot, - ...configFields, changedFields, changeMessage: `Updated: ${changedFields.join(', ')}`, - } as CreateSkillVersionInput); + createdAt: new Date(), + }; + for (const field of SNAPSHOT_FIELDS) { + if ((mergedSnapshot as any)[field] !== undefined) newVersionDoc[field] = (mergedSnapshot as any)[field]; + } } } - // Handle metadata-level updates - if (metadataFields.authorId !== undefined) updateDoc.authorId = metadataFields.authorId; - if (metadataFields.visibility !== undefined) updateDoc.visibility = metadataFields.visibility; - if (metadataFields.activeVersionId !== undefined) { - updateDoc.activeVersionId = metadataFields.activeVersionId; - // Auto-set status to 'published' when activeVersionId is set, consistent with InMemory and LibSQL - if (metadataFields.status === undefined) { - updateDoc.status = 'published'; + await this.#connector.withTransaction(async session => { + if (newVersionDoc) { + const versionsCol = await this.getCollection(TABLE_SKILL_VERSIONS); + await versionsCol.insertOne(newVersionDoc, { session }); } - } - if (metadataFields.status !== undefined) { - updateDoc.status = metadataFields.status; - } - - await collection.updateOne({ id }, { $set: updateDoc }); + const col = await this.getCollection(TABLE_SKILLS); + await col.updateOne({ id }, { $set: updateDoc }, { session }); + }); const updatedSkill = await collection.findOne<any>({ id }); if (!updatedSkill) { @@ -344,12 +353,12 @@ export class MongoDBSkillsStorage extends SkillsStorage { async delete(id: string): Promise<void> { try { - // Delete all versions first - await this.deleteVersionsByParentId(id); - - // Then delete the skill - const collection = await this.getCollection(TABLE_SKILLS); - await collection.deleteOne({ id }); + await this.#connector.withTransaction(async session => { + const versionsCol = await this.getCollection(TABLE_SKILL_VERSIONS); + await versionsCol.deleteMany({ skillId: id }, { session }); + const col = await this.getCollection(TABLE_SKILLS); + await col.deleteOne({ id }, { session }); + }); } catch (error) { throw new MastraError( { diff --git a/stores/mongodb/src/storage/domains/workflows/index.ts b/stores/mongodb/src/storage/domains/workflows/index.ts index ee9039fb9e14..24a21fed9b21 100644 --- a/stores/mongodb/src/storage/domains/workflows/index.ts +++ b/stores/mongodb/src/storage/domains/workflows/index.ts @@ -209,16 +209,10 @@ export class WorkflowsStorageMongoDB extends WorkflowsStorage { // Use findOneAndUpdate with aggregation pipeline for atomic read-modify-write // This ensures concurrent updates don't overwrite each other const updatedDoc = await collection.findOneAndUpdate( - { - workflow_name: workflowName, - run_id: runId, - // Only update if snapshot exists and has context - 'snapshot.context': { $exists: true }, - }, + { workflow_name: workflowName, run_id: runId }, [ { $set: { - // Merge the new options into the existing snapshot snapshot: { $mergeObjects: ['$snapshot', opts], }, @@ -234,8 +228,20 @@ export class WorkflowsStorageMongoDB extends WorkflowsStorage { } const snapshot = typeof updatedDoc.snapshot === 'string' ? JSON.parse(updatedDoc.snapshot) : updatedDoc.snapshot; + + if (!snapshot?.context) { + throw new MastraError({ + id: createStorageErrorId('MONGODB', 'UPDATE_WORKFLOW_STATE', 'FAILED'), + domain: ErrorDomain.STORAGE, + category: ErrorCategory.USER, + text: `Snapshot not found for runId ${runId}`, + details: { workflowName, runId }, + }); + } + return snapshot; } catch (error) { + if (error instanceof MastraError) throw error; throw new MastraError( { id: createStorageErrorId('MONGODB', 'UPDATE_WORKFLOW_STATE', 'FAILED'), diff --git a/stores/mongodb/src/storage/domains/workspaces/index.ts b/stores/mongodb/src/storage/domains/workspaces/index.ts index f41d402932bb..38f05cf54e36 100644 --- a/stores/mongodb/src/storage/domains/workspaces/index.ts +++ b/stores/mongodb/src/storage/domains/workspaces/index.ts @@ -193,33 +193,29 @@ export class MongoDBWorkspacesStorage extends WorkspacesStorage { updatedAt: now, }; - await collection.insertOne(this.serializeWorkspace(newWorkspace)); - // Extract snapshot config from flat input const snapshotConfig: Record<string, any> = {}; for (const field of SNAPSHOT_FIELDS) { - if ((workspace as any)[field] !== undefined) { - snapshotConfig[field] = (workspace as any)[field]; - } + if ((workspace as any)[field] !== undefined) snapshotConfig[field] = (workspace as any)[field]; } - - // Create version 1 const versionId = randomUUID(); - try { - await this.createVersion({ - id: versionId, - workspaceId: id, - versionNumber: 1, - ...snapshotConfig, - changedFields: Object.keys(snapshotConfig), - changeMessage: 'Initial version', - } as CreateWorkspaceVersionInput); - } catch (versionError) { - // Clean up the orphaned workspace record - await collection.deleteOne({ id }); - throw versionError; + const versionDoc: Record<string, any> = { + id: versionId, + workspaceId: id, + versionNumber: 1, + changedFields: Object.keys(snapshotConfig), + changeMessage: 'Initial version', + createdAt: new Date(), + }; + for (const field of SNAPSHOT_FIELDS) { + if (snapshotConfig[field] !== undefined) versionDoc[field] = snapshotConfig[field]; } + await this.#connector.withTransaction(async session => { + await collection.insertOne(this.serializeWorkspace(newWorkspace), { session }); + const versionsCol = await this.getCollection(TABLE_WORKSPACE_VERSIONS); + await versionsCol.insertOne(versionDoc, { session }); + }); return newWorkspace; } catch (error) { if (error instanceof MastraError) { @@ -274,6 +270,7 @@ export class MongoDBWorkspacesStorage extends WorkspacesStorage { } // If we have config updates, create a new version + let newVersionDoc: Record<string, any> | null = null; if (Object.keys(configFields).length > 0) { const latestVersion = await this.getLatestVersion(id); @@ -289,16 +286,18 @@ export class MongoDBWorkspacesStorage extends WorkspacesStorage { // Extract existing snapshot and merge with updates const existingSnapshot = this.extractSnapshotFields(latestVersion); - - await this.createVersion({ + const mergedSnapshot = { ...existingSnapshot, ...configFields }; + newVersionDoc = { id: randomUUID(), workspaceId: id, versionNumber: latestVersion.versionNumber + 1, - ...existingSnapshot, - ...configFields, changedFields: Object.keys(configFields), changeMessage: `Updated: ${Object.keys(configFields).join(', ')}`, - } as CreateWorkspaceVersionInput); + createdAt: new Date(), + }; + for (const field of SNAPSHOT_FIELDS) { + if ((mergedSnapshot as any)[field] !== undefined) newVersionDoc[field] = (mergedSnapshot as any)[field]; + } } // Handle metadata-level updates @@ -320,7 +319,23 @@ export class MongoDBWorkspacesStorage extends WorkspacesStorage { updateDoc.metadata = { ...existingMetadata, ...metadataFields.metadata }; } - await collection.updateOne({ id }, { $set: updateDoc }); + await this.#connector.withTransaction(async session => { + if (newVersionDoc) { + const versionsCol = await this.getCollection(TABLE_WORKSPACE_VERSIONS); + await versionsCol.insertOne(newVersionDoc, { session }); + } + const col = await this.getCollection(TABLE_WORKSPACES); + const updateResult = await col.updateOne({ id }, { $set: updateDoc }, { session }); + if (updateResult.matchedCount === 0) { + throw new MastraError({ + id: createStorageErrorId('MONGODB', 'UPDATE_WORKSPACE', 'NOT_FOUND_AFTER_UPDATE'), + domain: ErrorDomain.STORAGE, + category: ErrorCategory.SYSTEM, + text: `Workspace with id ${id} was deleted during update`, + details: { id }, + }); + } + }); const updatedWorkspace = await collection.findOne<any>({ id }); if (!updatedWorkspace) { @@ -351,12 +366,12 @@ export class MongoDBWorkspacesStorage extends WorkspacesStorage { async delete(id: string): Promise<void> { try { - // Delete all versions first - await this.deleteVersionsByParentId(id); - - // Then delete the workspace - const collection = await this.getCollection(TABLE_WORKSPACES); - await collection.deleteOne({ id }); + await this.#connector.withTransaction(async session => { + const versionsCol = await this.getCollection(TABLE_WORKSPACE_VERSIONS); + await versionsCol.deleteMany({ workspaceId: id }, { session }); + const col = await this.getCollection(TABLE_WORKSPACES); + await col.deleteOne({ id }, { session }); + }); } catch (error) { throw new MastraError( { diff --git a/stores/mongodb/src/storage/index.test.ts b/stores/mongodb/src/storage/index.test.ts index 35d8ce18cbad..8d0a947ddd82 100644 --- a/stores/mongodb/src/storage/index.test.ts +++ b/stores/mongodb/src/storage/index.test.ts @@ -9,7 +9,7 @@ import { import { SpanType } from '@mastra/core/observability'; import { TABLE_THREADS } from '@mastra/core/storage'; import { MongoClient } from 'mongodb'; -import { describe, expect, it, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { describe, expect, it, test, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import type { ConnectorHandler } from './connectors/base'; import { MemoryStorageMongoDB } from './domains/memory'; @@ -896,3 +896,174 @@ createDomainIndexTests({ columns: ['id'], }, }); + +// ─── Hardening: NODE-7556 ──────────────────────────────────────────────────── +describe('Hardening: NODE-7556', () => { + let store: MongoDBStore; + + beforeAll(async () => { + store = new MongoDBStore(TEST_CONFIG); + await store.init(); + }); + + afterAll(async () => { + try { + await store.close(); + } catch {} + }); + + test('F5: batchCreateSpans with a duplicate span must not drop subsequent spans', async () => { + const observabilityStore = await store.getStore('observability'); + expect(observabilityStore).toBeDefined(); + await observabilityStore!.dangerouslyClearAll(); + + const traceId = `f5-trace-${Date.now()}`; + const baseSpan = { + spanId: 'f5-span-existing', + traceId, + name: 'existing span', + spanType: SpanType.AGENT_RUN, + startedAt: new Date(), + endedAt: new Date(), + }; + + // Write the span so it already exists in the DB + await observabilityStore!.createSpan({ span: baseSpan as any }); + + // Batch: duplicate first, then two new spans that must survive + await expect( + observabilityStore!.batchCreateSpans({ + records: [ + baseSpan as any, + { ...baseSpan, spanId: 'f5-span-new-1', name: 'new span 1' } as any, + { ...baseSpan, spanId: 'f5-span-new-2', name: 'new span 2' } as any, + ], + }), + ).resolves.not.toThrow(); + + // Both new spans must be persisted despite the leading duplicate + const span1 = await observabilityStore!.getSpan({ spanId: 'f5-span-new-1', traceId }); + const span2 = await observabilityStore!.getSpan({ spanId: 'f5-span-new-2', traceId }); + expect(span1).not.toBeNull(); + expect(span2).not.toBeNull(); + }); + + test('F10: saveMessages with messages from two threads must update both threads updatedAt', async () => { + const memoryStore = await store.getStore('memory'); + expect(memoryStore).toBeDefined(); + + const past = new Date(Date.now() - 60_000); + + // Create two threads with a known-old updatedAt + const threadA = { + id: `f10-thread-a-${Date.now()}`, + title: 'Thread A', + resourceId: 'f10-resource', + metadata: {}, + createdAt: past, + updatedAt: past, + }; + const threadB = { + id: `f10-thread-b-${Date.now()}`, + title: 'Thread B', + resourceId: 'f10-resource', + metadata: {}, + createdAt: past, + updatedAt: past, + }; + await memoryStore!.saveThread({ thread: threadA as any }); + await memoryStore!.saveThread({ thread: threadB as any }); + + const before = new Date(); + + // Batch contains messages from both threads + await memoryStore!.saveMessages({ + messages: [ + { + id: `f10-msg-a-${Date.now()}`, + threadId: threadA.id, + resourceId: 'f10-resource', + role: 'user', + type: 'v2', + content: [{ type: 'text', text: 'hello from A' }], + createdAt: new Date(), + } as any, + { + id: `f10-msg-b-${Date.now()}`, + threadId: threadB.id, + resourceId: 'f10-resource', + role: 'user', + type: 'v2', + content: [{ type: 'text', text: 'hello from B' }], + createdAt: new Date(), + } as any, + ], + }); + + const updatedA = await memoryStore!.getThreadById({ threadId: threadA.id }); + const updatedB = await memoryStore!.getThreadById({ threadId: threadB.id }); + + // Both threads must have their updatedAt refreshed by the saveMessages call + expect(updatedA!.updatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(updatedB!.updatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime()); + }); + + test('F13: dangerouslyClearAll must also clear observational memory records', async () => { + const memoryStore = await store.getStore('memory'); + expect(memoryStore).toBeDefined(); + + const resourceId = `f13-resource-${Date.now()}`; + + await memoryStore!.initializeObservationalMemory({ + threadId: null, + resourceId, + scope: 'resource', + config: {}, + }); + + const before = await memoryStore!.getObservationalMemory(null, resourceId); + expect(before).not.toBeNull(); + + await memoryStore!.dangerouslyClearAll(); + + // Currently: OM record survives the clear + // After fix: getObservationalMemory returns null + const after = await memoryStore!.getObservationalMemory(null, resourceId); + expect(after).toBeNull(); + }); + + test('F14: updateWorkflowState must throw when the persisted snapshot has no context field', async () => { + const workflowsStore = await store.getStore('workflows'); + expect(workflowsStore).toBeDefined(); + + const workflowName = `f14-workflow-${Date.now()}`; + const runId = `f14-run-${Date.now()}`; + + // Persist a snapshot intentionally missing the 'context' field — valid in some + // workflow states but causes the $exists filter to silently drop updates. + await workflowsStore!.persistWorkflowSnapshot({ + workflowName, + runId, + snapshot: { + status: 'suspended', + value: {}, + activePaths: [], + suspendedPaths: {}, + runId, + timestamp: Date.now(), + // context intentionally omitted + } as any, + }); + + // Currently: $exists filter misses the doc, returns undefined with no error + // After fix: throws because the snapshot exists but has no context (mirrors pg/libsql) + await expect( + workflowsStore!.updateWorkflowState({ + workflowName, + runId, + opts: { status: 'failed' } as any, + }), + ).rejects.toThrow(); + }); +}); +// ───────────────────────────────────────────────────────────────────────────── diff --git a/stores/mongodb/src/storage/transactions.test.ts b/stores/mongodb/src/storage/transactions.test.ts new file mode 100644 index 000000000000..7bb309b2df5e --- /dev/null +++ b/stores/mongodb/src/storage/transactions.test.ts @@ -0,0 +1,260 @@ +import { Collection, MongoClient } from 'mongodb'; +import { describe, expect, test, vi } from 'vitest'; +import { MongoDBConnector } from './connectors/MongoDBConnector'; +import { MongoDBStore } from './index'; + +const STANDALONE_URI = process.env.MONGODB_URL || 'mongodb://localhost:27017'; +const REPLICA_SET_URI = + process.env.MONGODB_RS_URL || + 'mongodb://mongodb:mongodb@localhost:27018/?authSource=admin&directConnection=true&serverSelectionTimeoutMS=2000'; +const DB = 'mastra-transactions-test'; + +describe('MongoDB storage — topology-aware transactions', () => { + test('supportsTransactions() returns false on a standalone server', async () => { + const client = new MongoClient(STANDALONE_URI); + const connector = MongoDBConnector.fromDatabaseConfig({ id: 'tx-standalone', url: STANDALONE_URI, dbName: DB }); + try { + await client.connect(); + const hello = await client.db(DB).admin().command({ hello: 1 }); + expect(Boolean(hello.setName) || hello.msg === 'isdbgrid').toBe(false); + expect(await connector.supportsTransactions()).toBe(false); + } finally { + await client.close(); + await connector.close(); + } + }); + + test('supportsTransactions() returns true on a replica set', async () => { + const connector = MongoDBConnector.fromDatabaseConfig({ id: 'tx-rs', url: REPLICA_SET_URI, dbName: DB }); + try { + expect(await connector.supportsTransactions()).toBe(true); + } finally { + await connector.close(); + } + }); + + test('withTransaction rolls back all writes when the callback throws (replica set)', async () => { + const connector = MongoDBConnector.fromDatabaseConfig({ id: 'tx-rollback', url: REPLICA_SET_URI, dbName: DB }); + try { + const col = await connector.getCollection('tx_probe'); + await col.deleteMany({}); + await expect( + connector.withTransaction(async session => { + await col.insertOne({ marker: 'rollback' }, { session }); + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + expect(await col.countDocuments({})).toBe(0); + } finally { + await connector.close(); + } + }); + + test('withTransaction degrades gracefully on standalone: writes persist and errors still propagate', async () => { + const connector = MongoDBConnector.fromDatabaseConfig({ id: 'tx-degrade', url: STANDALONE_URI, dbName: DB }); + try { + const col = await connector.getCollection('tx_probe'); + await col.deleteMany({}); + await expect( + connector.withTransaction(async session => { + await col.insertOne({ marker: 'degrade' }, { session }); + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + expect(await col.countDocuments({})).toBe(1); + } finally { + await connector.close(); + } + }); + + test('deleteThread cascades best-effort (no rollback) and recovers via idempotent retry (replica set)', async () => { + // deleteThread is deliberately NOT transactional: a thread's messages are + // unbounded and a transactional deleteMany would be capped by the 60s + // transaction lifetime limit. Instead it drains messages first, then deletes + // the thread row last as the linearization point. This verifies that when the + // final thread delete fails, the message deletion is NOT rolled back, the + // thread row survives (re-deletable), and a retry completes the cascade. + const store = new MongoDBStore({ id: 'tx-delete-thread', uri: REPLICA_SET_URI, dbName: DB }); + await store.init(); + const memory = await store.getStore('memory'); + + const threadId = `thr-b2-${Date.now()}`; + const resourceId = `res-b2-${Date.now()}`; + await memory?.saveThread({ + thread: { id: threadId, resourceId, title: 't', metadata: {}, createdAt: new Date(), updatedAt: new Date() }, + }); + await memory?.saveMessages({ + messages: [ + { + id: `m-b2-${Date.now()}`, + threadId, + resourceId, + role: 'user', + type: 'v2', + content: { format: 2, parts: [{ type: 'text', text: 'hi' }] }, + } as any, + ], + }); + + // Make the thread deleteOne fail AFTER the messages deleteMany has run. + const spy = vi.spyOn(Collection.prototype, 'deleteOne').mockRejectedValueOnce(new Error('thread delete boom')); + try { + await expect(memory?.deleteThread({ threadId })).rejects.toThrow(); + } finally { + spy.mockRestore(); + } + + const client = new MongoClient(REPLICA_SET_URI); + try { + await client.connect(); + const db = client.db(DB); + + // No rollback: messages were drained before the thread delete failed. + const remainingMessages = await db.collection('mastra_messages').countDocuments({ thread_id: threadId }); + expect(remainingMessages).toBe(0); + // The thread row survives — the failed delete left a recoverable state. + const remainingThreads = await db.collection('mastra_threads').countDocuments({ id: threadId }); + expect(remainingThreads).toBe(1); + + // Idempotent retry completes the cascade with no spy in place. + await memory?.deleteThread({ threadId }); + const threadsAfterRetry = await db.collection('mastra_threads').countDocuments({ id: threadId }); + expect(threadsAfterRetry).toBe(0); + } finally { + await client.close(); + await store.close(); + } + }); + + test('saveMessages rolls back the message write when the thread timestamp update fails (replica set)', async () => { + const store = new MongoDBStore({ id: 'tx-save-messages', uri: REPLICA_SET_URI, dbName: DB }); + await store.init(); + const memory = await store.getStore('memory'); + + const threadId = `thr-b3-${Date.now()}`; + const resourceId = `res-b3-${Date.now()}`; + await memory?.saveThread({ + thread: { id: threadId, resourceId, title: 't', metadata: {}, createdAt: new Date(), updatedAt: new Date() }, + }); + + const messageId = `m-b3-${Date.now()}`; + // Throw on the next updateOne (the thread updatedAt write inside saveMessages). + // bulkWrite is a different method, so the message insert itself is not stubbed. + const spy = vi.spyOn(Collection.prototype, 'updateOne').mockRejectedValueOnce(new Error('updatedAt boom')); + try { + await expect( + memory?.saveMessages({ + messages: [ + { + id: messageId, + threadId, + resourceId, + role: 'user', + type: 'v2', + content: { format: 2, parts: [{ type: 'text', text: 'hi' }] }, + } as any, + ], + }), + ).rejects.toThrow(); + // The failure must come from the thread updatedAt updateOne (called once), not anything else. + expect(spy).toHaveBeenCalledTimes(1); + } finally { + spy.mockRestore(); + } + + const client = new MongoClient(REPLICA_SET_URI); + try { + await client.connect(); + const saved = await client.db(DB).collection('mastra_messages').countDocuments({ id: messageId }); + expect(saved).toBe(0); // message write rolled back + } finally { + await client.close(); + await store.close(); + } + }); + + test('agents.create() rolls back the agent insert when version insert fails (replica set)', async () => { + const store = new MongoDBStore({ id: 'tx-agents-create', uri: REPLICA_SET_URI, dbName: DB }); + await store.init(); + const agents = await store.getStore('agents'); + + const agentId = `agent-create-tx-${Date.now()}`; + + // The transaction calls insertOne twice: first for the agent row, second for the version row. + // Make the second call throw so the transaction aborts after the agent row is staged. + let callCount = 0; + const original = Collection.prototype.insertOne; + const spy = vi.spyOn(Collection.prototype, 'insertOne').mockImplementation(async function ( + this: Collection<any>, + ...args: Parameters<typeof original> + ) { + callCount++; + if (callCount >= 2) throw new Error('version insert boom'); + return original.apply(this, args); + }); + + try { + await expect( + agents?.create({ + agent: { + id: agentId, + name: 'Test Agent', + instructions: 'test', + model: { provider: 'ANTHROPIC', toolChoice: 'auto', name: 'claude-sonnet-4-6' }, + } as any, + }), + ).rejects.toThrow('version insert boom'); + } finally { + spy.mockRestore(); + } + + // Transaction rolled back — agent row must not exist. + const client = new MongoClient(REPLICA_SET_URI); + try { + await client.connect(); + const count = await client.db(DB).collection('mastra_agents').countDocuments({ id: agentId }); + expect(count).toBe(0); + } finally { + await client.close(); + await store.close(); + } + }); + + test('agents.delete() rolls back version deletion when agent deleteOne fails (replica set)', async () => { + const store = new MongoDBStore({ id: 'tx-agents-delete', uri: REPLICA_SET_URI, dbName: DB }); + await store.init(); + const agents = await store.getStore('agents'); + + const agentId = `agent-del-tx-${Date.now()}`; + + // Create the agent first (no spy — let this succeed). + await agents?.create({ + agent: { + id: agentId, + name: 'To Delete', + instructions: 'test', + model: { provider: 'ANTHROPIC', toolChoice: 'auto', name: 'claude-sonnet-4-6' }, + } as any, + }); + + // delete() runs deleteMany(versions) then deleteOne(agent) inside a transaction. + // Make deleteOne fail so the transaction aborts after versions have been staged for deletion. + const spy = vi.spyOn(Collection.prototype, 'deleteOne').mockRejectedValueOnce(new Error('agent delete boom')); + try { + await expect(agents?.delete(agentId)).rejects.toThrow(); + } finally { + spy.mockRestore(); + } + + // Transaction rolled back — versions must still exist. + const client = new MongoClient(REPLICA_SET_URI); + try { + await client.connect(); + const vCount = await client.db(DB).collection('mastra_agent_versions').countDocuments({ agentId }); + expect(vCount).toBeGreaterThan(0); + } finally { + await client.close(); + await store.close(); + } + }); +}); diff --git a/stores/mongodb/src/vector/index.test.ts b/stores/mongodb/src/vector/index.test.ts index 9d633a2b1155..e27e97d1b5db 100644 --- a/stores/mongodb/src/vector/index.test.ts +++ b/stores/mongodb/src/vector/index.test.ts @@ -1,4 +1,5 @@ import { createVectorTestSuite } from '@internal/storage-test-utils'; +import { MongoClient } from 'mongodb'; import { vi, describe, it, expect, beforeAll, afterAll, test } from 'vitest'; import { MongoDBVector } from './'; @@ -338,6 +339,72 @@ describe('MongoDBVector Integration Tests', () => { expect(threadIds).toContain('thread-456'); }); }); + + // ─── Hardening: NODE-7556 ─────────────────────────────────────────────────── + describe('Hardening: NODE-7556', () => { + test('F2: updateVector with a vector must not throw when collectionForValidation was never set', async () => { + const indexName = `f2-npe-${Date.now()}`; + + // Create the index and upsert a document via the shared vectorDB instance. + // createIndex writes the sentinel (__index_metadata__); upsert writes the doc. + await createIndexAndWait(vectorDB, indexName, 4, 'cosine'); + await vectorDB.upsert({ indexName, vectors: [[1, 0, 0, 0]], ids: ['f2-doc'] }); + + // Reproduce the edge case where the Atlas Search index has been modified outside + // Mastra (e.g. via the Atlas UI or mongosh) but the __index_metadata__ document + // was not updated alongside it — or was dropped entirely during that operation. + const rawClient = new MongoClient(uri); + await rawClient.connect(); + try { + await rawClient + .db(dbName) + .collection(indexName) + .deleteOne({ _id: '__index_metadata__' as any }); + } finally { + await rawClient.close(); + } + + // Fresh instance — collectionForValidation is null (upsert was never called on it). + // describeIndex now returns dimension=0 (no sentinel) → validateVectorDimensions + // calls setIndexDimension → this.collectionForValidation! is null → TypeError. + const vectorDB2 = new MongoDBVector({ uri, dbName, id: 'f2-fresh' }); + await vectorDB2.connect(); + try { + // Currently throws: TypeError: Cannot read properties of null (reading 'updateOne') + // After fix: resolves without error + await expect( + vectorDB2.updateVector({ indexName, id: 'f2-doc', update: { vector: [0.5, 0.5, 0.5, 0.5] } }), + ).resolves.not.toThrow(); + } finally { + await vectorDB2.disconnect(); + await deleteIndexAndWait(vectorDB, indexName); + } + }); + + test.todo( + 'F1: concurrent upserts to the same index must not write dimension metadata to the wrong collection ' + + '(non-deterministic timing: requires a controlled async yield between upsert calls)', + ); + + test.todo( + 'F6: query with a pre-filter matching >370 000 documents must not hit the 16 MB BSON limit ' + + '(requires seeding ~400 000 documents — impractical in CI; fix is to pass combinedFilter directly to $vectorSearch)', + ); + + test.todo( + 'F12: upsert immediately after createIndex must not fail with index-not-ready ' + + '(non-deterministic in atlas-local where indexes become READY within milliseconds; ' + + 'fix: callers must call waitForIndexReady after createIndex before querying — ' + + 'see createIndex JSDoc and the createIndexAndWait helper in this test file)', + ); + + test.todo( + 'F15: getCollection with throwIfNotExists=true must throw after the collection is dropped externally ' + + '(failure mode is a wrong error message not silence, making a clear red/green assertion impractical; ' + + 'fix: phantom handles are no longer cached when collectionExists=false)', + ); + }); + // ───────────────────────────────────────────────────────────────────────────── }); // Shared vector store test suite diff --git a/stores/mongodb/src/vector/index.ts b/stores/mongodb/src/vector/index.ts index c4125fd085be..8a32248fb84c 100644 --- a/stores/mongodb/src/vector/index.ts +++ b/stores/mongodb/src/vector/index.ts @@ -28,6 +28,13 @@ export interface MongoDBUpsertVectorParams extends UpsertVectorParams { export interface MongoDBQueryVectorParams extends QueryVectorParams<MongoDBVectorFilter> { documentFilter?: MongoDBVectorFilter; + /** + * Number of candidates the HNSW graph considers before selecting the + * top-K results. Higher values improve recall at the cost of latency. + * Must be >= topK. Defaults to 20 * topK, capped at 10000. + * See: https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-stage/ + */ + numCandidates?: number; } export interface MongoDBVectorConfig { @@ -69,7 +76,6 @@ export class MongoDBVector extends MastraVector<MongoDBVectorFilter> { private readonly embeddingFieldName: string; private readonly metadataFieldName = 'metadata'; private readonly documentFieldName = 'document'; - private collectionForValidation: Collection<MongoDBDocument> | null = null; private mongoMetricMap: { [key: string]: string } = { cosine: 'cosine', euclidean: 'euclidean', @@ -127,6 +133,17 @@ export class MongoDBVector extends MastraVector<MongoDBVectorFilter> { } } + /** + * Creates a MongoDB collection and the Atlas Search indexes that back a + * Mastra index with the given name. + * + * **Async index lifecycle:** Atlas Search indexes transition through + * PENDING → BUILDING → READY after this method returns. If you need to + * `upsert` or `query` immediately after calling `createIndex`, call + * `waitForIndexReady({ indexName })` first to block until the index is + * queryable. Skipping that step on a real Atlas cluster may cause + * "index not found" or "index not ready" errors on subsequent operations. + */ async createIndex({ indexName, dimension, metric = 'cosine' }: CreateIndexParams): Promise<void> { let mongoMetric; try { @@ -168,7 +185,16 @@ export class MongoDBVector extends MastraVector<MongoDBVectorFilter> { const embeddingField = this.embeddingFieldName; const numDimensions = dimension; - // Create search indexes + // Create search indexes. + // Note that fast filtering can be done during vector search, but only if + // we know the fields at when the index is created. + // 'document' is declared as a filter field so documentFilter queries can be + // passed directly to $vectorSearch without materialising candidate IDs. + // Metadata fields are NOT declared here because they are arbitrary and unknown + // at index-creation time. MongoDB can filter metadata fields more efficiently + // during the vector search itself if they are declared as filter fields in the + // index — this requires a filterFields parameter on createIndex (see + // https://github.com/mastra-ai/mastra/issues/18587). await collection.createSearchIndex({ definition: { fields: [ @@ -182,6 +208,10 @@ export class MongoDBVector extends MastraVector<MongoDBVectorFilter> { type: 'filter', path: '_id', }, + { + type: 'filter', + path: 'document', + }, ], }, name: indexNameInternal, @@ -208,23 +238,6 @@ export class MongoDBVector extends MastraVector<MongoDBVectorFilter> { ); } } - - try { - // Store the dimension and metric in a special metadata document - await collection?.updateOne({ _id: '__index_metadata__' }, { $set: { dimension, metric } }, { upsert: true }); - } catch (error) { - throw new MastraError( - { - id: createVectorErrorId('MONGODB', 'CREATE_INDEX', 'STORE_METADATA_FAILED'), - domain: ErrorDomain.STORAGE, - category: ErrorCategory.THIRD_PARTY, - details: { - indexName, - }, - }, - error, - ); - } } /** @@ -256,6 +269,20 @@ export class MongoDBVector extends MastraVector<MongoDBVectorFilter> { throw new Error(`Index "${indexNameInternal}" did not become ready within timeout`); } + /** + * Inserts or updates vectors in the specified index. + * + * @param indexName - Name of the index (MongoDB collection) to write into. + * @param vectors - Array of embedding vectors. Each must have the same + * dimension as declared when the index was created. + * @param metadata - Optional array of metadata objects, one per vector, + * stored in a nested `metadata` field alongside the embedding. + * @param ids - Optional string IDs for each vector, used as the MongoDB + * document `_id`. Auto-generated UUIDs are used when omitted. + * @param documents - Optional text strings associated with each vector, + * stored in a `document` field. + * @returns The IDs of the upserted vectors in input order. + */ async upsert({ indexName, vectors, metadata, ids, documents }: MongoDBUpsertVectorParams): Promise<string[]> { // Validate input parameters validateUpsertInput('MONGODB', vectors, metadata, ids); @@ -264,8 +291,6 @@ export class MongoDBVector extends MastraVector<MongoDBVectorFilter> { try { const collection = await this.getCollection(indexName); - this.collectionForValidation = collection; - // Get index stats to check dimension const stats = await this.describeIndex({ indexName }); @@ -323,6 +348,22 @@ export class MongoDBVector extends MastraVector<MongoDBVectorFilter> { ); } } + /** + * Runs an approximate nearest-neighbor search against the specified index. + * + * @param indexName - Name of the index (MongoDB collection) to search. + * @param queryVector - The query embedding. Must match the index dimension. + * @param topK - Maximum number of results to return (default: 10). + * @param filter - Optional metadata filter. Fields are matched against the + * nested `metadata` subdocument; no `metadata.` prefix is needed. + * @param includeVector - When true, each result includes the stored embedding + * in a `vector` field (default: false). + * @param documentFilter - Optional filter applied to the `document` text + * field, independent of `filter`. + * @param numCandidates - HNSW candidate pool size. Higher values improve + * recall at the cost of latency. Defaults to 20 * topK, capped at 10000. + * @returns Array of results ordered by descending similarity score. + */ async query({ indexName, queryVector, @@ -330,6 +371,7 @@ export class MongoDBVector extends MastraVector<MongoDBVectorFilter> { filter, includeVector = false, documentFilter, + numCandidates, }: MongoDBQueryVectorParams): Promise<QueryResult[]> { if (!queryVector) { throw new MastraError({ @@ -345,52 +387,41 @@ export class MongoDBVector extends MastraVector<MongoDBVectorFilter> { const collection = await this.getCollection(indexName, true); const indexNameInternal = `${indexName}_vector_index`; - // Transform the filters using MongoDBFilterTranslator - const mongoFilter = this.transformFilter(filter); - const documentMongoFilter = documentFilter ? { [this.documentFieldName]: documentFilter } : {}; - - // Transform metadata field filters to use dot notation - const transformedMongoFilter = this.transformMetadataFilter(mongoFilter); - - // Combine the filters - let combinedFilter: any = {}; - if (Object.keys(transformedMongoFilter).length > 0 && Object.keys(documentMongoFilter).length > 0) { - combinedFilter = { $and: [transformedMongoFilter, documentMongoFilter] }; - } else if (Object.keys(transformedMongoFilter).length > 0) { - combinedFilter = transformedMongoFilter; - } else if (Object.keys(documentMongoFilter).length > 0) { - combinedFilter = documentMongoFilter; - } + // Metadata filter: translate then add 'metadata.' prefix to user-facing field names. + const metadataFilter = this.transformMetadataFilter(this.transformFilter(filter)); + const hasMetadataFilter = Object.keys(metadataFilter).length > 0; const vectorSearch: Document = { index: indexNameInternal, queryVector: queryVector, path: this.embeddingFieldName, - numCandidates: Math.min(10000, Math.max(100, topK)), + numCandidates: Math.min(10000, Math.max(topK, numCandidates ?? topK * 20)), limit: Math.min(10000, topK), }; - if (Object.keys(combinedFilter).length > 0) { - // Exclude the special metadata document - const filterWithExclusion = { - $and: [{ _id: { $ne: '__index_metadata__' } }, combinedFilter], - }; - - // pre-filter for candidate document IDs + if (hasMetadataFilter) { + // Metadata fields are not declared as filter fields in the vectorSearch index, + // so they cannot be passed directly to $vectorSearch. Materialise matching _ids + // via $match first, then filter by _id inside $vectorSearch. + // Declaring metadata fields as filter fields at index-creation time would allow + // passing the filter directly and avoid this materialisation step — see the + // planned filterFields parameter on createIndex. + // https://github.com/mastra-ai/mastra/issues/18587 const candidateIds = await collection - .aggregate([{ $match: filterWithExclusion }, { $project: { _id: 1 } }]) + .aggregate([{ $match: metadataFilter }, { $project: { _id: 1 } }]) .map(doc => doc._id) .toArray(); - if (candidateIds.length > 0) { - vectorSearch.filter = { _id: { $in: candidateIds } }; - } else { - // No documents match the filter, return empty results - return []; - } - } else { - // Even with no filter, exclude the metadata document - vectorSearch.filter = { _id: { $ne: '__index_metadata__' } }; + if (candidateIds.length === 0) return []; + + // 'document' is a declared filter field — combine directly when present. + vectorSearch.filter = documentFilter + ? { $and: [{ _id: { $in: candidateIds } }, { [this.documentFieldName]: documentFilter }] } + : { _id: { $in: candidateIds } }; + } else if (documentFilter) { + // 'document' is a declared filter field in the index — pass directly, + // no candidate materialisation needed. + vectorSearch.filter = { [this.documentFieldName]: documentFilter }; } // Build the aggregation pipeline @@ -462,28 +493,49 @@ export class MongoDBVector extends MastraVector<MongoDBVectorFilter> { try { const collection = await this.getCollection(indexName, true); - // Get the count of documents, excluding the metadata document - const count = await collection.countDocuments({ _id: { $ne: '__index_metadata__' } }); + const count = await collection.countDocuments({ _id: { $ne: '__index_metadata__' as any } }); + + const indexNameInternal = `${indexName}_vector_index`; + const indexInfo: any[] = await (collection as any).listSearchIndexes().toArray(); + const indexData = indexInfo.find((idx: any) => idx.name === indexNameInternal); - // Retrieve the dimension and metric from the metadata document - const metadataDoc = await collection.findOne({ _id: '__index_metadata__' }); - const dimension = metadataDoc?.dimension || 0; - const metric = metadataDoc?.metric || 'cosine'; + if (!indexData) { + throw new MastraError({ + id: createVectorErrorId('MONGODB', 'DESCRIBE_INDEX', 'NOT_FOUND'), + domain: ErrorDomain.STORAGE, + category: ErrorCategory.USER, + details: { indexName }, + text: `Atlas Search index "${indexNameInternal}" does not exist on collection "${indexName}". The collection may predate Mastra or the index may have been dropped externally.`, + }); + } - return { - dimension, - count, - metric: metric as 'cosine' | 'euclidean' | 'dotproduct', + const vectorField = indexData.latestDefinition?.fields?.find((f: any) => f.type === 'vector'); + if (!vectorField) { + throw new MastraError({ + id: createVectorErrorId('MONGODB', 'DESCRIBE_INDEX', 'INVALID'), + domain: ErrorDomain.STORAGE, + category: ErrorCategory.USER, + details: { indexName }, + text: `Atlas Search index "${indexNameInternal}" exists but has no vector field. The index may have been created outside of Mastra or without vector configuration.`, + }); + } + const dimension = vectorField.numDimensions; + const reverseMetricMap: Record<string, 'cosine' | 'euclidean' | 'dotproduct'> = { + cosine: 'cosine', + euclidean: 'euclidean', + dotProduct: 'dotproduct', }; + const metric = reverseMetricMap[vectorField.similarity] ?? 'cosine'; + + return { dimension, count, metric }; } catch (error) { + if (error instanceof MastraError) throw error; throw new MastraError( { id: createVectorErrorId('MONGODB', 'DESCRIBE_INDEX', 'FAILED'), domain: ErrorDomain.STORAGE, category: ErrorCategory.THIRD_PARTY, - details: { - indexName, - }, + details: { indexName }, }, error, ); @@ -731,12 +783,7 @@ export class MongoDBVector extends MastraVector<MongoDBVectorFilter> { }); } - // Exclude the special metadata document and combine with user filter - const finalFilter = { - $and: [{ _id: { $ne: '__index_metadata__' } }, transformedFilter], - }; - - await collection.deleteMany(finalFilter); + await collection.deleteMany(transformedFilter); } } catch (error) { // If it's already a MastraError, rethrow it @@ -760,7 +807,26 @@ export class MongoDBVector extends MastraVector<MongoDBVectorFilter> { } } - // Private methods + /** + * Returns the MongoDB Collection that backs the given Mastra index. + * + * **Index vs. collection:** In this driver, each Mastra index is stored as a + * MongoDB collection whose name equals the index name. The Atlas Vector Search + * index (named `${indexName}_vector_index`) lives on that collection. The two + * terms are distinct: "index" is the Mastra concept; "collection" is the + * MongoDB storage primitive that implements it. + * + * **Caching:** Collection handles are cached on first successful lookup to + * avoid redundant `listCollections` round-trips. Only handles for collections + * that actually exist are cached; a handle for a missing collection is returned + * without being cached so the next call re-checks existence rather than + * returning a stale phantom. + * + * @param indexName - Mastra index name, which is also the MongoDB collection name. + * @param throwIfNotExists - When `true` (default), throws if no MongoDB + * collection exists for this index name. Pass `false` when absence is not an + * error (e.g., inside `deleteIndex`). + */ private async getCollection( indexName: string, throwIfNotExists: boolean = true, @@ -771,13 +837,17 @@ export class MongoDBVector extends MastraVector<MongoDBVectorFilter> { const collection = this.db.collection<MongoDBDocument>(indexName); - // Check if collection exists const collectionExists = await this.db.listCollections({ name: indexName }).hasNext(); if (!collectionExists && throwIfNotExists) { - throw new Error(`Index (Collection) "${indexName}" does not exist`); + throw new Error( + `Mastra index "${indexName}" has no backing MongoDB collection. ` + + `Call createIndex first, or verify the collection was not dropped externally.`, + ); } - this.collections.set(indexName, collection); + if (collectionExists) { + this.collections.set(indexName, collection); + } return collection; } @@ -787,9 +857,7 @@ export class MongoDBVector extends MastraVector<MongoDBVectorFilter> { } if (dimension === 0) { - // If dimension is not set, retrieve and set it from the vectors dimension = vectors[0] ? vectors[0].length : 0; - await this.setIndexDimension(dimension); } for (let i = 0; i < vectors.length; i++) { @@ -800,12 +868,6 @@ export class MongoDBVector extends MastraVector<MongoDBVectorFilter> { } } - private async setIndexDimension(dimension: number): Promise<void> { - // Store the dimension in a special metadata document - const collection = this.collectionForValidation!; // 'collectionForValidation' is set in 'upsert' method - await collection.updateOne({ _id: '__index_metadata__' }, { $set: { dimension } }, { upsert: true }); - } - private transformFilter(filter?: MongoDBVectorFilter) { const translator = new MongoDBFilterTranslator(); if (!filter) return {}; @@ -844,13 +906,8 @@ export class MongoDBVector extends MastraVector<MongoDBVectorFilter> { } // Check if the key already has 'metadata.' prefix else if (key.startsWith('metadata.')) { - // Already prefixed, keep as is but recursively transform the value if it's an object with operators - if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - const hasOperator = Object.keys(value).some(k => k.startsWith('$')); - transformed[key] = hasOperator ? value : value; - } else { - transformed[key] = value; - } + // Already prefixed — keep as-is. + transformed[key] = value; } // Check if this is a known metadata field that needs prefixing else if (this.isMetadataField(key)) { diff --git a/voice/google-gemini-live-api/CHANGELOG.md b/voice/google-gemini-live-api/CHANGELOG.md index 1be450ba9d43..4a5e6949c7e6 100644 --- a/voice/google-gemini-live-api/CHANGELOG.md +++ b/voice/google-gemini-live-api/CHANGELOG.md @@ -1,5 +1,19 @@ # @mastra/voice-google-gemini-live +## 0.14.2-alpha.1 + +### Patch Changes + +- Updated dependencies [[`d0702ee`](https://github.com/mastra-ai/mastra/commit/d0702eedc1594cb2d0d83476440cfc2ec8820adb)]: + - @mastra/schema-compat@1.3.2-alpha.1 + +## 0.14.2-alpha.0 + +### Patch Changes + +- Updated dependencies [[`9feeaa0`](https://github.com/mastra-ai/mastra/commit/9feeaa0f9a1af07039e5b4f22b932b0cb18617e8), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/schema-compat@1.3.2-alpha.0 + ## 0.14.1 ### Patch Changes diff --git a/voice/google-gemini-live-api/package.json b/voice/google-gemini-live-api/package.json index d4021e81e33a..78b156f802f6 100644 --- a/voice/google-gemini-live-api/package.json +++ b/voice/google-gemini-live-api/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/voice-google-gemini-live", - "version": "0.14.1", + "version": "0.14.2-alpha.1", "description": "Mastra Google Gemini Live API integration", "type": "module", "files": [ diff --git a/voice/openai-realtime-api/CHANGELOG.md b/voice/openai-realtime-api/CHANGELOG.md index 47c0811300d6..e9d720950352 100644 --- a/voice/openai-realtime-api/CHANGELOG.md +++ b/voice/openai-realtime-api/CHANGELOG.md @@ -1,5 +1,19 @@ # @mastra/voice-openai-realtime +## 0.13.2-alpha.1 + +### Patch Changes + +- Updated dependencies [[`d0702ee`](https://github.com/mastra-ai/mastra/commit/d0702eedc1594cb2d0d83476440cfc2ec8820adb)]: + - @mastra/schema-compat@1.3.2-alpha.1 + +## 0.13.2-alpha.0 + +### Patch Changes + +- Updated dependencies [[`9feeaa0`](https://github.com/mastra-ai/mastra/commit/9feeaa0f9a1af07039e5b4f22b932b0cb18617e8), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/schema-compat@1.3.2-alpha.0 + ## 0.13.1 ### Patch Changes diff --git a/voice/openai-realtime-api/package.json b/voice/openai-realtime-api/package.json index 4df7542296b5..920371c88d49 100644 --- a/voice/openai-realtime-api/package.json +++ b/voice/openai-realtime-api/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/voice-openai-realtime", - "version": "0.13.1", + "version": "0.13.2-alpha.1", "description": "Mastra OpenAI Realtime API integration", "type": "module", "main": "dist/index.js", diff --git a/voice/xai-realtime-api/CHANGELOG.md b/voice/xai-realtime-api/CHANGELOG.md index b1982274f38e..f106d69b4337 100644 --- a/voice/xai-realtime-api/CHANGELOG.md +++ b/voice/xai-realtime-api/CHANGELOG.md @@ -1,5 +1,19 @@ # @mastra/voice-xai-realtime +## 0.2.2-alpha.1 + +### Patch Changes + +- Updated dependencies [[`d0702ee`](https://github.com/mastra-ai/mastra/commit/d0702eedc1594cb2d0d83476440cfc2ec8820adb)]: + - @mastra/schema-compat@1.3.2-alpha.1 + +## 0.2.2-alpha.0 + +### Patch Changes + +- Updated dependencies [[`9feeaa0`](https://github.com/mastra-ai/mastra/commit/9feeaa0f9a1af07039e5b4f22b932b0cb18617e8), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/schema-compat@1.3.2-alpha.0 + ## 0.2.1 ### Patch Changes diff --git a/voice/xai-realtime-api/package.json b/voice/xai-realtime-api/package.json index 3288bf08a5d9..913e25b05ff8 100644 --- a/voice/xai-realtime-api/package.json +++ b/voice/xai-realtime-api/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/voice-xai-realtime", - "version": "0.2.1", + "version": "0.2.2-alpha.1", "description": "Mastra xAI Grok Voice Agent API integration", "type": "module", "main": "dist/index.js", diff --git a/workflows/inngest/CHANGELOG.md b/workflows/inngest/CHANGELOG.md index e484db00c709..8f5fb120541e 100644 --- a/workflows/inngest/CHANGELOG.md +++ b/workflows/inngest/CHANGELOG.md @@ -1,5 +1,63 @@ # @mastra/inngest +## 1.8.0-alpha.2 + +### Minor Changes + +- Added support for the fine-grained authorization (FGA) `actor` signal on the Inngest execution engine. ([#18674](https://github.com/mastra-ai/mastra/pull/18674)) + + Workflows running on the Inngest engine can now pass a trusted `actor` through `run.start()`, `startAsync()`, `resume()`, `stream()`, and `timeTravel()`. The signal is re-threaded across durable step and nested-workflow boundaries, so every nested agent, tool, and memory FGA check sees the same actor. Previously `actor` was only threaded through the default engine, so trusted background workflows on Inngest lost the membership bypass at each step re-entry. + + **Usage** + + ```ts + const run = await workflow.createRun(); + await run.start({ + inputData, + requestContext, // includes organizationId / tenant scope + actor: { actorKind: 'system', sourceWorkflow: 'nightly-sync' }, + }); + ``` + +### Patch Changes + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + +## 1.8.0-alpha.1 + +### Minor Changes + +- Bring `InngestAgent` (Inngest-backed durable agent) to parity with `DurableAgent` for per-call execution options, abort handling, idle-aware resume, and `generate()`. ([#18615](https://github.com/mastra-ai/mastra/pull/18615)) + + `InngestAgent.stream()` and `resume()` now accept the same execution-option surface as `DurableAgent`, including `stopWhen`, `activeTools`, `structuredOutput`, `versions`, `system`, `disableBackgroundTasks`, `tracingOptions`, `actor`, `transform`, `prepareStep`, `isTaskComplete`, `delegation`, function-form `requireToolApproval`, and the lifecycle callbacks `onAbort` / `onIterationComplete`. Closure-shaped options (`prepareStep`, `transform`, function-form `isTaskComplete` / `requireToolApproval`, `stopWhen` callbacks) continue to work in-process; they degrade after a worker hop the same way they do for in-memory `DurableAgent`. + + ```ts + const result = await inngestAgent.stream(messages, { + runId: 'run-1', + abortSignal: controller.signal, + stopWhen: stepCountIs(5), + onIterationComplete: ({ iteration }) => console.log('done', iteration), + }); + + // Cancel a live run from the caller + result.abort(); + + // Resume and drive the run to completion in a single call + await inngestAgent.resume({ runId: 'run-1', resumeData, untilIdle: true }); + + // Durable equivalents of Agent.generate / resumeGenerate + const out = await inngestAgent.generate(messages, { runId: 'run-2' }); + const resumed = await inngestAgent.resumeGenerate({ runId: 'run-2', resumeData }); + ``` + + `@mastra/core` re-exports `globalRunRegistry` and `runResumeDurableStreamUntilIdle` from `@mastra/core/agent/durable` so durable-agent integrations can share the same registry and idle-wrapper plumbing. + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + ## 1.7.1-alpha.0 ### Patch Changes diff --git a/workflows/inngest/package.json b/workflows/inngest/package.json index 0ba0e5a6494e..8f5043ae09c8 100644 --- a/workflows/inngest/package.json +++ b/workflows/inngest/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/inngest", - "version": "1.7.1-alpha.0", + "version": "1.8.0-alpha.2", "description": "Mastra Inngest integration", "type": "module", "main": "dist/index.js", diff --git a/workflows/inngest/src/__tests__/create-inngest-agent.test.ts b/workflows/inngest/src/__tests__/create-inngest-agent.test.ts index 20134939c517..ddf63ac16407 100644 --- a/workflows/inngest/src/__tests__/create-inngest-agent.test.ts +++ b/workflows/inngest/src/__tests__/create-inngest-agent.test.ts @@ -7,7 +7,7 @@ */ import { Agent } from '@mastra/core/agent'; -import { AGENT_STREAM_TOPIC, AgentStreamEventTypes } from '@mastra/core/agent/durable'; +import { AGENT_STREAM_TOPIC, AgentStreamEventTypes, globalRunRegistry } from '@mastra/core/agent/durable'; import { InMemoryServerCache } from '@mastra/core/cache'; import { CachingPubSub, EventEmitterPubSub } from '@mastra/core/events'; import { Mastra } from '@mastra/core/mastra'; @@ -455,3 +455,168 @@ describe('createInngestAgent with Mastra auto-registration', () => { expect(workflow).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Parity surface tests +// +// These tests exercise the InngestAgent execution surface that was added to +// match DurableAgent: the widened InngestAgentStreamOptions, the abort path, +// untilIdle on resume(), and the generate()/resumeGenerate() wrappers. +// +// We deliberately avoid spinning up a real Inngest dev server. `inngest.send` +// is stubbed to a no-op so stream()/resume() can complete their non-durable +// preparation phase (preparation, run-registry registration, stream +// subscription) and we can assert the observable side effects on +// globalRunRegistry and on the returned result. The durable workflow itself +// is covered by the integration suite. +// --------------------------------------------------------------------------- +describe('InngestAgent parity surface', () => { + const inngest = new Inngest({ + id: 'parity-tests', + baseUrl: `http://localhost:${INNGEST_PORT}`, + }); + + // Replace inngest.send with a no-op so stream()/resume() don't attempt + // a real network roundtrip; the only thing under test here is the + // non-durable preparation/registry path on the agent itself. + function stubInngestSend(target: Inngest = inngest) { + return vi.spyOn(target as any, 'send').mockResolvedValue(undefined as any); + } + + function makeAgent(id: string) { + return new Agent({ + id, + name: id, + instructions: 'Test', + model: createMockModel() as any, + }); + } + + // The agent's CachingPubSub wraps an InngestPubSub. Without a real Inngest + // dev server, terminal stream events (finish/error/abort) try to publish + // over inngest realtime and produce unhandled fetch rejections. Swap the + // inner with an in-process broker so the surface tests stay self-contained. + function makeIsolatedAgent(id: string) { + const durableAgent = createInngestAgent({ agent: makeAgent(id), inngest }); + (durableAgent.pubsub as any).inner = new EventEmitterPubSub(); + return durableAgent; + } + + it('threads widened execution options through prepare() into workflow input', async () => { + // Slice 1: prove the widened option surface actually flows to + // prepareForDurableExecution. We use prepare() instead of stream() because + // it returns workflowInput synchronously without needing to mock the + // workflow trigger, and prepare() shares the preparation path with + // stream() / generate(). + const durableAgent = createInngestAgent({ agent: makeAgent('parity-prepare'), inngest }); + + const result = await durableAgent.prepare([{ role: 'user', content: 'hi' }], { + maxSteps: 7, + disableBackgroundTasks: true, + actor: { id: 'actor-1', type: 'user' } as any, + system: 'extra system message', + tracingOptions: { metadata: { feature: 'parity' } } as any, + }); + + const opts = result.workflowInput.options; + expect(opts.maxSteps).toBe(7); + expect(opts.disableBackgroundTasks).toBe(true); + expect(opts.actor).toEqual({ id: 'actor-1', type: 'user' }); + expect(opts.systemMessage).toBe('extra system message'); + expect(opts.tracingOptions).toEqual({ metadata: { feature: 'parity' } }); + }); + + it('exposes result.abort and flips the registry abortSignal', async () => { + // Slice 2: stream() must own an AbortController, expose it via + // result.abort, and surface its signal on the run-registry entry so the + // durable LLM step (when co-located) can short-circuit. + const durableAgent = makeIsolatedAgent('parity-abort'); + const sendSpy = stubInngestSend(); + + const result = await durableAgent.stream([{ role: 'user', content: 'hi' }]); + try { + expect(typeof result.abort).toBe('function'); + const entry = globalRunRegistry.get(result.runId); + expect(entry?.abortSignal).toBeInstanceOf(AbortSignal); + expect(entry?.abortSignal?.aborted).toBe(false); + + result.abort('user-cancelled'); + + expect(entry?.abortSignal?.aborted).toBe(true); + } finally { + result.cleanup(); + sendSpy.mockRestore(); + } + }); + + it('forwards an external abortSignal onto the internal controller', async () => { + // External signal must be wired through so either source (caller's + // signal or result.abort) flips the registry-tracked AbortSignal that + // workflow steps observe. + const durableAgent = makeIsolatedAgent('parity-abort-external'); + const sendSpy = stubInngestSend(); + + const external = new AbortController(); + const result = await durableAgent.stream([{ role: 'user', content: 'hi' }], { + abortSignal: external.signal, + }); + try { + const entry = globalRunRegistry.get(result.runId); + expect(entry?.abortSignal?.aborted).toBe(false); + + external.abort(new Error('external-cancel')); + + // The forwarded controller is flipped synchronously by the abort + // event listener installed in stream(). + expect(entry?.abortSignal?.aborted).toBe(true); + } finally { + result.cleanup(); + sendSpy.mockRestore(); + } + }); + + it('tracks the workflow trigger promise on globalRunRegistry.workflowExecution', async () => { + // generate()/resumeGenerate() rely on awaiting workflowExecution after a + // suspend to make sure the snapshot has landed before they return. This + // covers the registration side of that contract. + const durableAgent = makeIsolatedAgent('parity-workflow-exec'); + const sendSpy = stubInngestSend(); + + const result = await durableAgent.stream([{ role: 'user', content: 'hi' }]); + try { + // The `ready.then(() => triggerWorkflow(...))` chain attaches the + // workflowExecution promise on the next microtask after `ready` settles. + // Poll the registry until the promise lands instead of sleeping a fixed + // amount of time, so this stays deterministic across machine speeds. + const deadline = Date.now() + 1_000; + let entry = globalRunRegistry.get(result.runId); + while (!entry?.workflowExecution && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 0)); + entry = globalRunRegistry.get(result.runId); + } + expect(entry?.workflowExecution).toBeInstanceOf(Promise); + // The promise should settle once inngest.send resolves (stubbed to + // undefined). Awaiting it shouldn't throw. + await expect(entry?.workflowExecution).resolves.toBeUndefined(); + expect(sendSpy).toHaveBeenCalled(); + } finally { + result.cleanup(); + sendSpy.mockRestore(); + } + }); + + it('exposes generate() and resumeGenerate() with durable signatures', () => { + // Slice 5 surface check. The Proxy used to forward both methods to the + // underlying Agent; after parity work generate() must be the durable + // implementation defined on the InngestAgent factory, and + // resumeGenerate() must exist as well (regardless of test environment + // limitations). + const durableAgent = createInngestAgent({ agent: makeAgent('parity-generate-surface'), inngest }); + expect(typeof durableAgent.generate).toBe('function'); + expect(typeof durableAgent.resumeGenerate).toBe('function'); + // The Proxy forwarded the underlying Agent's generate signature; the + // durable replacement is the function defined on the inngestAgent object + // itself, so it should NOT be the agent's bound generate. + expect(durableAgent.generate).not.toBe((durableAgent.agent as any).generate); + }); +}); diff --git a/workflows/inngest/src/actor-signal.test.ts b/workflows/inngest/src/actor-signal.test.ts new file mode 100644 index 000000000000..4f50ccf58966 --- /dev/null +++ b/workflows/inngest/src/actor-signal.test.ts @@ -0,0 +1,350 @@ +import type { ActorSignal } from '@mastra/core/auth/ee'; +import { Mastra } from '@mastra/core/mastra'; +import { MockStore } from '@mastra/core/storage'; +import { Inngest } from 'inngest'; +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { InngestExecutionEngine } from './execution-engine'; +import { init } from './index'; + +/** + * Hermetic tests for FGA `actor` signal threading through @mastra/inngest. + * + * These do NOT require an Inngest dev server. They exercise the two seams the + * engine owns directly: + * 1. The run/start path serializes `actor` into the Inngest event payload. + * 2. `executeWorkflowStep` forwards `actor` across the nested-workflow + * `step.invoke()` serialization boundary so the nested run re-threads it. + */ +describe('@mastra/inngest actor signal threading (hermetic)', () => { + const actor: ActorSignal = { actorKind: 'system', sourceWorkflow: 'nightly-workflow' }; + + // Surface parity — every public Inngest path that maps to core actor-aware + // execution flows into one of four run-level event sinks (or a nested invoke). + // We assert `actor` at each distinct sink; shared sinks cover their callers: + // + // public path -> event sink -> covered by + // start -> _start -> "serializes actor on the start (_start) path" + // stream -> _start -> (shared _start sink) + // streamLegacy -> _start -> (shared _start sink) + // startAsync -> startAsync (own send) -> "serializes actor ... on the start path" (startAsync) + // resume -> _resumeAndSendEvent -> (shared resume sink) + // resumeAsync -> _resumeAndSendEvent -> "forwards a re-supplied actor through the resume event payload" + // timeTravel -> _timeTravel -> "serializes actor on the timeTravel path" + // timeTravelStream -> _timeTravel -> (shared _timeTravel sink) + // nested (start) -> executeWorkflowStep -> "forwards actor into the nested-workflow invoke payload" + // nested (resume) -> executeWorkflowStep -> "forwards actor into the nested-workflow RESUME invoke payload" + + it('forwards actor into the nested-workflow invoke payload (durable step boundary)', async () => { + const inngest = new Inngest({ id: 'mastra-test' }); + const { createWorkflow, createStep } = init(inngest); + + const nestedStep = createStep({ + id: 'nested-step', + inputSchema: z.object({ value: z.string() }), + outputSchema: z.object({ value: z.string() }), + execute: async ({ inputData }) => inputData, + }); + + const nestedWorkflow = createWorkflow({ + id: 'nested-workflow', + inputSchema: z.object({ value: z.string() }), + outputSchema: z.object({ value: z.string() }), + steps: [nestedStep], + }) + .then(nestedStep) + .commit(); + + // Capture the data handed to inngestStep.invoke (the serialization boundary). + const invokeData: any[] = []; + const fakeStep: any = { + run: async (_id: string, fn: () => Promise<any>) => fn(), + invoke: async (_id: string, opts: { function: any; data: any }) => { + invokeData.push(opts.data); + return { result: { status: 'success', result: { value: 'ok' }, state: {} }, runId: 'nested-run' }; + }, + sleep: async () => {}, + sleepUntil: async () => {}, + }; + + const engine = new InngestExecutionEngine({} as Mastra, fakeStep, 0, {}); + const pubsub: any = { publish: vi.fn().mockResolvedValue(undefined) }; + + const result = await engine.executeWorkflowStep({ + step: nestedWorkflow as any, + stepResults: {}, + executionContext: { + workflowId: 'parent-workflow', + runId: 'parent-run', + executionPath: [0], + suspendedPaths: {}, + state: {}, + } as any, + prevOutput: {}, + inputData: { value: 'ok' }, + pubsub, + startedAt: Date.now(), + actor, + } as any); + + expect(result?.status).toBe('success'); + expect(invokeData).toHaveLength(1); + expect(invokeData[0].actor).toEqual(actor); + }); + + it('serializes actor into the Inngest event payload on the start path', async () => { + const inngest = new Inngest({ id: 'mastra-test' }); + const { createWorkflow, createStep } = init(inngest); + + const step = createStep({ + id: 'step', + inputSchema: z.object({ value: z.string() }), + outputSchema: z.object({ value: z.string() }), + execute: async ({ inputData }) => inputData, + }); + + const workflow = createWorkflow({ + id: 'actor-start-workflow', + inputSchema: z.object({ value: z.string() }), + outputSchema: z.object({ value: z.string() }), + steps: [step], + }) + .then(step) + .commit(); + + const mastra = new Mastra({ + logger: false, + storage: new MockStore(), + workflows: { 'actor-start-workflow': workflow as any }, + }); + workflow.__registerMastra(mastra); + + const sendSpy = vi.spyOn(inngest, 'send').mockResolvedValue({ ids: ['evt-1'] } as any); + + const run = await workflow.createRun(); + await run.startAsync({ inputData: { value: 'ok' }, actor }); + + expect(sendSpy).toHaveBeenCalledTimes(1); + const sentData = (sendSpy.mock.calls[0]![0] as any).data; + expect(sentData.actor).toEqual(actor); + }); + + it('forwards a re-supplied actor through the resume event payload (per-call contract)', async () => { + // `actor` is intentionally NOT rehydrated from the snapshot (matching the default + // engine — see packages/core/src/workflows/workflow.ts `_resume`). A trusted resumer + // re-supplies it on each resume; this locks in that per-call contract. + const inngest = new Inngest({ id: 'mastra-test' }); + const { createWorkflow, createStep } = init(inngest); + + const step = createStep({ + id: 'suspendable-step', + inputSchema: z.object({ value: z.string() }), + outputSchema: z.object({ value: z.string() }), + execute: async ({ inputData }) => inputData, + }); + + const workflow = createWorkflow({ + id: 'actor-resume-workflow', + inputSchema: z.object({ value: z.string() }), + outputSchema: z.object({ value: z.string() }), + steps: [step], + }) + .then(step) + .commit(); + + const mastra = new Mastra({ + logger: false, + storage: new MockStore(), + workflows: { 'actor-resume-workflow': workflow as any }, + }); + workflow.__registerMastra(mastra); + + const run = await workflow.createRun(); + + // Persist a suspended snapshot so the resume path has something to load. + const workflowsStore = await mastra.getStorage()!.getStore('workflows'); + await workflowsStore!.persistWorkflowSnapshot({ + workflowName: 'actor-resume-workflow', + runId: run.runId, + snapshot: { + runId: run.runId, + serializedStepGraph: [], + status: 'suspended', + value: {}, + context: { input: { value: 'ok' } }, + activePaths: [], + suspendedPaths: { 'suspendable-step': [0] }, + activeStepsPath: {}, + resumeLabels: {}, + waitingPaths: {}, + timestamp: Date.now(), + } as any, + }); + + const sendSpy = vi.spyOn(inngest, 'send').mockResolvedValue({ ids: ['evt-resume'] } as any); + + await run.resumeAsync({ resumeData: { value: 'ok' }, step: 'suspendable-step', actor }); + + expect(sendSpy).toHaveBeenCalledTimes(1); + const sentData = (sendSpy.mock.calls[0]![0] as any).data; + expect(sentData.actor).toEqual(actor); + }); + + it('serializes actor on the start (_start) path', async () => { + // Covers start + stream + streamLegacy, which all funnel through `_start`. + const inngest = new Inngest({ id: 'mastra-test' }); + const { createWorkflow, createStep } = init(inngest); + + const step = createStep({ + id: 's', + inputSchema: z.object({ value: z.string() }), + outputSchema: z.object({ value: z.string() }), + execute: async ({ inputData }) => inputData, + }); + const workflow = createWorkflow({ + id: 'actor-start-sink-workflow', + inputSchema: z.object({ value: z.string() }), + outputSchema: z.object({ value: z.string() }), + steps: [step], + }) + .then(step) + .commit(); + + const mastra = new Mastra({ + logger: false, + storage: new MockStore(), + workflows: { 'actor-start-sink-workflow': workflow as any }, + }); + workflow.__registerMastra(mastra); + + const run = await workflow.createRun(); + // start() awaits getRunOutput, which polls a live Inngest server; stub it so the + // hermetic test exercises only the `_start` event-send sink. + vi.spyOn(run as any, 'getRunOutput').mockResolvedValue({ output: { result: { status: 'success' } } }); + const sendSpy = vi.spyOn(inngest, 'send').mockResolvedValue({ ids: ['evt-start'] } as any); + + await run.start({ inputData: { value: 'ok' }, actor }); + + expect(sendSpy).toHaveBeenCalledTimes(1); + expect((sendSpy.mock.calls[0]![0] as any).data.actor).toEqual(actor); + }); + + it('serializes actor on the timeTravel path', async () => { + // Covers timeTravel + timeTravelStream, which both funnel through `_timeTravel`. + const inngest = new Inngest({ id: 'mastra-test' }); + const { createWorkflow, createStep } = init(inngest); + + const step = createStep({ + id: 's', + inputSchema: z.object({ value: z.string() }), + outputSchema: z.object({ value: z.string() }), + execute: async ({ inputData }) => inputData, + }); + const workflow = createWorkflow({ + id: 'actor-tt-workflow', + inputSchema: z.object({ value: z.string() }), + outputSchema: z.object({ value: z.string() }), + steps: [step], + }) + .then(step) + .commit(); + + const mastra = new Mastra({ + logger: false, + storage: new MockStore(), + workflows: { 'actor-tt-workflow': workflow as any }, + }); + workflow.__registerMastra(mastra); + + const run = await workflow.createRun(); + // A completed snapshot to load + rebuild time-travel execution params from. + const store = await mastra.getStorage()!.getStore('workflows'); + await store!.persistWorkflowSnapshot({ + workflowName: 'actor-tt-workflow', + runId: run.runId, + snapshot: { + runId: run.runId, + serializedStepGraph: [], + status: 'success', + value: {}, + context: { input: { value: 'ok' } }, + activePaths: [], + suspendedPaths: {}, + activeStepsPath: {}, + resumeLabels: {}, + waitingPaths: {}, + timestamp: Date.now(), + } as any, + }); + + vi.spyOn(run as any, 'getRunOutput').mockResolvedValue({ output: { result: { status: 'success' } } }); + const sendSpy = vi.spyOn(inngest, 'send').mockResolvedValue({ ids: ['evt-tt'] } as any); + + await run.timeTravel({ step: 's', inputData: { value: 'ok' }, actor }); + + expect(sendSpy).toHaveBeenCalledTimes(1); + expect((sendSpy.mock.calls[0]![0] as any).data.actor).toEqual(actor); + }); + + it('forwards actor into the nested-workflow RESUME invoke payload', async () => { + // The resume branch of executeWorkflowStep is a distinct invoke site from the + // start-time branch — this is the durable-boundary-on-resume case. + const inngest = new Inngest({ id: 'mastra-test' }); + const { createWorkflow, createStep } = init(inngest); + + const nestedStep = createStep({ + id: 'nested-step', + inputSchema: z.object({ value: z.string() }), + outputSchema: z.object({ value: z.string() }), + execute: async ({ inputData }) => inputData, + }); + const nestedWorkflow = createWorkflow({ + id: 'nested-resume-workflow', + inputSchema: z.object({ value: z.string() }), + outputSchema: z.object({ value: z.string() }), + steps: [nestedStep], + }) + .then(nestedStep) + .commit(); + + const invokeData: any[] = []; + const fakeStep: any = { + run: async (_id: string, fn: () => Promise<any>) => fn(), + invoke: async (_id: string, opts: { function: any; data: any }) => { + invokeData.push(opts.data); + return { result: { status: 'success', result: { value: 'ok' }, state: {} }, runId: 'nested-run' }; + }, + sleep: async () => {}, + sleepUntil: async () => {}, + }; + + // The resume branch loads a snapshot via mastra storage, so give the engine a real store. + const mastra = new Mastra({ logger: false, storage: new MockStore() }); + const engine = new InngestExecutionEngine(mastra, fakeStep, 0, {}); + const pubsub: any = { publish: vi.fn().mockResolvedValue(undefined) }; + + const result = await engine.executeWorkflowStep({ + step: nestedWorkflow as any, + stepResults: {}, + executionContext: { + workflowId: 'parent', + runId: 'parent-run', + executionPath: [0], + suspendedPaths: {}, + state: {}, + } as any, + resume: { steps: ['nested-step'], resumePayload: { value: 'ok' } }, + prevOutput: {}, + inputData: { value: 'ok' }, + pubsub, + startedAt: Date.now(), + actor, + } as any); + + expect(result?.status).toBe('success'); + expect(invokeData).toHaveLength(1); + expect(invokeData[0].resume).toBeDefined(); // confirms we hit the resume branch + expect(invokeData[0].actor).toEqual(actor); + }); +}); diff --git a/workflows/inngest/src/durable-agent/create-inngest-agent.ts b/workflows/inngest/src/durable-agent/create-inngest-agent.ts index cd67447e0082..cfb20cbe4e5d 100644 --- a/workflows/inngest/src/durable-agent/create-inngest-agent.ts +++ b/workflows/inngest/src/durable-agent/create-inngest-agent.ts @@ -42,6 +42,8 @@ import { createDurableAgentStream, emitErrorEvent, runDurableStreamUntilIdle, + runResumeDurableStreamUntilIdle, + globalRunRegistry, } from '@mastra/core/agent/durable'; import type { AgentFinishEventData, @@ -55,7 +57,7 @@ import { CachingPubSub } from '@mastra/core/events'; import type { PubSub } from '@mastra/core/events'; import type { Mastra } from '@mastra/core/mastra'; import { SpanType, EntityType } from '@mastra/core/observability'; -import type { MastraModelOutput, ChunkType } from '@mastra/core/stream'; +import type { MastraModelOutput, ChunkType, FullOutput } from '@mastra/core/stream'; import type { Workflow } from '@mastra/core/workflows'; import type { Inngest } from 'inngest'; @@ -63,6 +65,26 @@ import { InngestPubSub } from '../pubsub'; import type { InngestWorkflow } from '../workflow'; import { createInngestDurableAgenticWorkflow, InngestDurableStepIds } from './create-inngest-agentic-workflow'; +/** + * Internal sentinel used by {@link InngestAgent.generate} and + * {@link InngestAgent.resumeGenerate} to ask the underlying `stream()` / + * `resume()` implementation to close the consumer stream on a SUSPENDED + * event, so `getFullOutput()` resolves promptly with `finishReason: + * 'suspended'` instead of waiting for FINISH/ERROR. + * + * Modelled on `CLOSE_ON_SUSPEND` in core `DurableAgent`. + */ +const CLOSE_ON_SUSPEND = Symbol('mastra.durable.inngest.closeOnSuspend'); + +/** + * Internal symbol used by `generate()` / `resumeGenerate()` to tear down the + * pubsub subscription on suspend without removing the run-registry entry. + * The public `cleanup()` does both; this lets the generate wrappers keep the + * registry alive across `suspend` → `resumeGenerate()` while still releasing + * the local stream subscription. + */ +const STREAM_CLEANUP = Symbol('mastra.durable.inngest.streamCleanup'); + // ============================================================================= // Types // ============================================================================= @@ -94,7 +116,13 @@ export interface CreateInngestAgentOptions { } /** - * Options for InngestAgent.stream() + * Options for InngestAgent.stream(). + * + * Mirrors `DurableAgentStreamOptions` from `@mastra/core/agent/durable` so that + * Inngest-backed durable agents accept the same execution surface as the + * in-memory `DurableAgent`. Most options flow straight through + * `prepareForDurableExecution` and onto the shared workflow steps; see + * `.context/durable-agent-parity.md` for the per-option durability matrix. */ export interface InngestAgentStreamOptions<OUTPUT = undefined> { /** Custom instructions that override the agent's default instructions */ @@ -109,16 +137,25 @@ export interface InngestAgentStreamOptions<OUTPUT = undefined> { requestContext?: AgentExecutionOptions<OUTPUT>['requestContext']; /** Maximum number of steps */ maxSteps?: number; + /** + * Stop condition(s) for the agentic loop. Data-shaped conditions are + * serialized into the workflow snapshot; function-form conditions are stored + * on the in-process run registry and degrade to "no extra stop" on a + * cross-worker resume (same as core DurableAgent). + */ + stopWhen?: AgentExecutionOptions<OUTPUT>['stopWhen']; /** Additional tool sets */ toolsets?: AgentExecutionOptions<OUTPUT>['toolsets']; /** Client-side tools */ clientTools?: AgentExecutionOptions<OUTPUT>['clientTools']; /** Tool selection strategy */ toolChoice?: AgentExecutionOptions<OUTPUT>['toolChoice']; + /** Tool names enabled for this execution */ + activeTools?: AgentExecutionOptions<OUTPUT>['activeTools']; /** Model settings */ modelSettings?: AgentExecutionOptions<OUTPUT>['modelSettings']; - /** Require approval for all tool calls */ - requireToolApproval?: boolean; + /** Require approval for tool calls. Boolean (gate all / none) or a per-call function policy. */ + requireToolApproval?: AgentExecutionOptions<OUTPUT>['requireToolApproval']; /** Automatically resume suspended tools */ autoResumeSuspendedTools?: boolean; /** Maximum concurrent tool calls */ @@ -127,6 +164,43 @@ export interface InngestAgentStreamOptions<OUTPUT = undefined> { includeRawChunks?: boolean; /** Maximum processor retries */ maxProcessorRetries?: number; + /** Structured output configuration */ + structuredOutput?: AgentExecutionOptions<OUTPUT>['structuredOutput']; + /** Version overrides for sub-agent delegation */ + versions?: AgentExecutionOptions<OUTPUT>['versions']; + /** Additional system message appended after context but before user messages. */ + system?: AgentExecutionOptions<OUTPUT>['system']; + /** When true, background tasks are disabled for this run. */ + disableBackgroundTasks?: AgentExecutionOptions<OUTPUT>['disableBackgroundTasks']; + /** Tracing options forwarded to the agent/model spans. */ + tracingOptions?: AgentExecutionOptions<OUTPUT>['tracingOptions']; + /** Per-call actor signal forwarded to FGA checks and tool execution. */ + actor?: AgentExecutionOptions<OUTPUT>['actor']; + /** + * Tool payload transform policy. `targets` is JSON-safe and persisted in the + * workflow snapshot; `transformToolPayload` is a closure on the run registry + * and degrades on cross-worker resume. + */ + transform?: AgentExecutionOptions<OUTPUT>['transform']; + /** + * Per-step preparation hook. Stored on the run registry and applied via a + * processor in the durable LLM step. Closure — degrades on cross-worker + * resume. + */ + prepareStep?: AgentExecutionOptions<OUTPUT>['prepareStep']; + /** + * Optional completion config (scorers + onComplete + suppressFeedback). + * JSON-safe parts are serialized; the `onComplete` callback lives on the run + * registry and degrades on cross-worker resume. + */ + isTaskComplete?: AgentExecutionOptions<OUTPUT>['isTaskComplete']; + /** + * Sub-agent delegation hooks. Forwarded into `convertTools` at prepare time + * and baked into the sub-agent tool wrappers stored on the run registry. + * Cross-worker resume on a fresh worker loses the callbacks and degrades to + * the agent's default delegation. + */ + delegation?: AgentExecutionOptions<OUTPUT>['delegation']; /** Callback when chunk is received */ onChunk?: (chunk: ChunkType<OUTPUT>) => void | Promise<void>; /** Callback when step finishes */ @@ -137,6 +211,17 @@ export interface InngestAgentStreamOptions<OUTPUT = undefined> { onError?: (error: Error) => void | Promise<void>; /** Callback when workflow suspends */ onSuspended?: (data: AgentSuspendedEventData) => void | Promise<void>; + /** Callback when execution is aborted via abortSignal or `result.abort()` */ + onAbort?: AgentExecutionOptions<OUTPUT>['onAbort']; + /** Callback fired after each agentic-loop iteration (observation only) */ + onIterationComplete?: AgentExecutionOptions<OUTPUT>['onIterationComplete']; + /** + * Optional external abort signal. Forwarded onto an internal AbortController + * stored on the run registry. Either the external signal or + * `result.abort()` will cancel the stream and emit an ABORT event over + * pubsub. + */ + abortSignal?: AbortSignal; /** * When set, `stream()` delegates to the idle-loop wrapper that keeps the * outer stream open across background-task continuations. @@ -165,6 +250,43 @@ export interface InngestAgentStreamResult<OUTPUT = undefined> { resourceId?: string; /** Cleanup function */ cleanup: () => void; + /** + * Abort this run. Flips the internal AbortController for this stream so the + * durable LLM step short-circuits (when the step worker shares the same + * process) and emits an ABORT event over pubsub so the consumer stream + * closes. Safe to call after the run has already finished. + */ + abort: (reason?: unknown) => void; +} + +/** + * Options for InngestAgent.resume(). Mirrors core DurableAgent.resume(). + */ +export interface InngestAgentResumeOptions<OUTPUT = undefined> { + threadId?: string; + resourceId?: string; + onChunk?: (chunk: ChunkType<OUTPUT>) => void | Promise<void>; + onStepFinish?: (result: AgentStepFinishEventData) => void | Promise<void>; + onFinish?: (result: AgentFinishEventData) => void | Promise<void>; + onError?: (error: Error) => void | Promise<void>; + onSuspended?: (data: AgentSuspendedEventData) => void | Promise<void>; + /** Callback when execution is aborted via abortSignal or `result.abort()` */ + onAbort?: AgentExecutionOptions<OUTPUT>['onAbort']; + /** + * Optional abort signal scoped to the resumed segment. Forwarded onto a + * fresh internal controller installed on the run's registry entry, so + * `result.abort()` and the external signal can both cancel the resumed + * iterations. + */ + abortSignal?: AbortSignal; + /** + * When set, keep the resumed segment open after the workflow's initial + * resume turn finishes and continue streaming follow-up turns until the + * agent goes idle (no in-flight background tasks for the same memory + * scope). Same semantics as `stream({ untilIdle })`. Pass an object to + * tune `maxIdleMs`. + */ + untilIdle?: boolean | { maxIdleMs?: number }; } /** @@ -211,15 +333,7 @@ export interface InngestAgent<TOutput = undefined> { resume( runId: string, resumeData: unknown, - options?: { - threadId?: string; - resourceId?: string; - onChunk?: (chunk: ChunkType<TOutput>) => void | Promise<void>; - onStepFinish?: (result: AgentStepFinishEventData) => void | Promise<void>; - onFinish?: (result: AgentFinishEventData) => void | Promise<void>; - onError?: (error: Error) => void | Promise<void>; - onSuspended?: (data: AgentSuspendedEventData) => void | Promise<void>; - }, + options?: InngestAgentResumeOptions<TOutput>, ): Promise<InngestAgentStreamResult<TOutput>>; /** @@ -269,13 +383,37 @@ export interface InngestAgent<TOutput = undefined> { */ __setMastra(mastra: Mastra): void; + /** + * Drain a durable run to a single {@link FullOutput}. Mirrors + * {@link DurableAgent.generate}: kicks off the same Inngest durable + * workflow as {@link InngestAgent.stream}, but threads + * `methodType: 'generate'` into preparation (so tool/preparation paths + * that branch on method behave consistently with non-durable + * `Agent.generate`) and awaits `output.getFullOutput()`. + * + * If the run suspends (e.g. tool approval), the returned output's + * `finishReason` is `'suspended'` — use {@link InngestAgent.resumeGenerate} + * to continue. The run registry entry is intentionally not cleaned up on + * suspend so resume can pick it up. + */ + generate(messages: MessageListInput, options?: InngestAgentStreamOptions<TOutput>): Promise<FullOutput<TOutput>>; + + /** + * Resume a suspended durable run and drain it to a single + * {@link FullOutput}. Mirrors {@link DurableAgent.resumeGenerate} on top + * of {@link InngestAgent.resume}. + */ + resumeGenerate( + runId: string, + resumeData: unknown, + options?: InngestAgentResumeOptions<TOutput>, + ): Promise<FullOutput<TOutput>>; + // --------------------------------------------------------------------------- // Agent methods forwarded via Proxy to the underlying Agent at runtime. // Declared here so TypeScript can see them without the Proxy indirection. // --------------------------------------------------------------------------- - /** Generate a non-streaming response. Forwarded to the underlying Agent. */ - generate(messages: MessageListInput, options?: AgentExecutionOptions<any>): Promise<any>; /** Get the agent's description. Forwarded to the underlying Agent. */ getDescription(): string; /** Get the agent's instructions. Forwarded to the underlying Agent. */ @@ -310,8 +448,6 @@ export interface InngestAgent<TOutput = undefined> { toRawConfig(...args: any[]): any; /** Resume a streaming execution. Forwarded to the underlying Agent. */ resumeStream(...args: any[]): any; - /** Resume a generate execution. Forwarded to the underlying Agent. */ - resumeGenerate(...args: any[]): any; /** Approve a pending tool call. Forwarded to the underlying Agent. */ approveToolCall(...args: any[]): any; /** @internal Update the agent's model. Forwarded to the underlying Agent. */ @@ -490,6 +626,8 @@ export function createInngestAgent<TOutput = undefined>(options: CreateInngestAg | 'resume' | 'prepare' | 'observe' + | 'generate' + | 'resumeGenerate' | 'getDurableWorkflows' | '__setMastra' > = { @@ -535,14 +673,42 @@ export function createInngestAgent<TOutput = undefined>(options: CreateInngestAg options: streamOptions as AgentExecutionOptions<TOutput>, runId: streamOptions?.runId, requestContext: streamOptions?.requestContext, + methodType: (streamOptions as any)?.__methodType ?? 'stream', }); - const { runId, messageId, workflowInput, threadId, resourceId } = preparation; + const { runId, messageId, workflowInput, registryEntry, threadId, resourceId } = preparation; // Override agentId and agentName in workflowInput with the durable agent's values workflowInput.agentId = agentId; workflowInput.agentName = agentName; + // 1a. Install abort controller for this run. The controller is owned by + // this InngestAgent instance; `result.abort()` flips it, the durable + // LLM-execution step reads `abortSignal` off the global run registry + // (when running in the same process) and the consumer stream closes via + // an ABORT pubsub event when the inner catch detects the signal. If the + // caller supplied an external signal, forward it onto the internal + // controller so either source can cancel the run. + const abortController = new AbortController(); + if (streamOptions?.abortSignal) { + const external = streamOptions.abortSignal; + if (external.aborted) { + abortController.abort((external as AbortSignal & { reason?: unknown }).reason); + } else { + external.addEventListener( + 'abort', + () => abortController.abort((external as AbortSignal & { reason?: unknown }).reason), + { once: true }, + ); + } + } + registryEntry.abortController = abortController; + registryEntry.abortSignal = abortController.signal; + + // 1b. Register non-serializable state on the global run registry so + // workflow steps running in the same process can recover it. + globalRunRegistry.set(runId, registryEntry); + // 2. Create AGENT_RUN span BEFORE the workflow starts // This ensures the agent_run is the root of the trace, not the workflow const observability = mastra?.observability?.getSelectedInstance({ @@ -586,6 +752,14 @@ export function createInngestAgent<TOutput = undefined>(options: CreateInngestAg workflowInput.modelSpanData = modelSpanData; workflowInput.stepIndex = 0; + // Track cleanup state and global registry entry lifecycle. + let cleanedUp = false; + const finalizeGlobalRegistry = () => { + if (cleanedUp) return; + cleanedUp = true; + globalRunRegistry.delete(runId); + }; + // 2. Create the durable agent stream (subscribes to pubsub) const { output, @@ -604,9 +778,34 @@ export function createInngestAgent<TOutput = undefined>(options: CreateInngestAg resourceId, onChunk: streamOptions?.onChunk, onStepFinish: streamOptions?.onStepFinish, - onFinish: streamOptions?.onFinish, - onError: streamOptions?.onError, + onFinish: async result => { + try { + await streamOptions?.onFinish?.(result); + } finally { + finalizeGlobalRegistry(); + } + }, + onError: async error => { + try { + await streamOptions?.onError?.(error); + } finally { + finalizeGlobalRegistry(); + } + }, onSuspended: streamOptions?.onSuspended, + onAbort: async data => { + try { + await (streamOptions?.onAbort as ((event: any) => void | Promise<void>) | undefined)?.(data); + } finally { + finalizeGlobalRegistry(); + } + }, + onIterationComplete: streamOptions?.onIterationComplete + ? async data => { + await (streamOptions.onIterationComplete as (ctx: any) => void | Promise<void>)?.(data); + } + : undefined, + closeOnSuspend: (streamOptions as any)?.[CLOSE_ON_SUSPEND] === true, }); // 3. Wait for subscription to be established, then trigger workflow @@ -616,31 +815,114 @@ export function createInngestAgent<TOutput = undefined>(options: CreateInngestAg : undefined; // Wait for subscription to be ready before triggering workflow - // This prevents race conditions where events are published before subscription - ready + // This prevents race conditions where events are published before subscription. + // Track the trigger promise on the registry so generate() can await suspend + // snapshot persistence before returning. + const workflowExecution = ready .then(() => triggerWorkflow(runId, workflowInput, tracingOptions)) .catch(error => { void emitError(runId, error); }); + const trackedEntry = globalRunRegistry.get(runId); + if (trackedEntry) { + trackedEntry.workflowExecution = workflowExecution; + } // 4. Return stream result - attach extra properties to output for compatibility // This allows both destructuring { output, runId, cleanup } AND direct access to fullStream + const cleanup = () => { + streamCleanup(); + finalizeGlobalRegistry(); + }; + const abort = (reason?: unknown) => { + if (!abortController.signal.aborted) { + abortController.abort(reason); + } + }; const result = { output, runId, threadId, resourceId, - cleanup: streamCleanup, + cleanup, + abort, // Also expose fullStream directly for server compatibility get fullStream() { return output.fullStream; }, + // Internal: stream-only cleanup for generate()/resumeGenerate() to + // release the subscription on suspend without dropping the registry. + [STREAM_CLEANUP]: streamCleanup, }; return result as InngestAgentStreamResult<TOutput>; }, async resume(runId, resumeData, resumeOptions): Promise<InngestAgentStreamResult<TOutput>> { + // Delegate to the resume idle-loop wrapper when `untilIdle` is set. + // After the resumed segment completes, the wrapper runs + // `agent.stream([], ...)` continuations against the same thread until + // pending background tasks settle. + if (resumeOptions?.untilIdle) { + const { untilIdle, ...rest } = resumeOptions; + const maxIdleMs = typeof untilIdle === 'object' ? untilIdle.maxIdleMs : undefined; + return runResumeDurableStreamUntilIdle<TOutput>( + proxyRef as any, + runId, + resumeData, + { ...rest, maxIdleMs } as any, + { + activeStreams: activeStreamUntilIdle, + bgManager: mastra?.backgroundTaskManager, + }, + ) as Promise<InngestAgentStreamResult<TOutput>>; + } + + // Install a fresh abort controller scoped to the resumed segment and + // attach it to the run-registry entry so the durable LLM step (when + // co-located) can react. The previous run's controller is no longer + // relevant. + const abortController = new AbortController(); + if (resumeOptions?.abortSignal) { + const external = resumeOptions.abortSignal; + if (external.aborted) { + abortController.abort((external as AbortSignal & { reason?: unknown }).reason); + } else { + external.addEventListener( + 'abort', + () => abortController.abort((external as AbortSignal & { reason?: unknown }).reason), + { once: true }, + ); + } + } + // Ensure a registry entry exists for this resumed segment. On Inngest, + // a resume frequently runs in a fresh process where no prior stream() + // entry is in memory — without this, the abort controller would be + // silently dropped and the durable LLM step (when co-located) would + // have nothing to react to. + let existingEntry = globalRunRegistry.get(runId); + if (!existingEntry) { + existingEntry = { + // Minimal placeholder fields. The durable LLM step recreates tools + // and model from the workflow input; this slot exists primarily to + // carry the abort controller across the resumed segment. + tools: {}, + model: undefined as any, + }; + globalRunRegistry.set(runId, existingEntry); + } + existingEntry.abortController = abortController; + existingEntry.abortSignal = abortController.signal; + + // Track cleanup state for the resumed segment so terminal events + // (finish/error/abort/cleanup) always tear down the registry entry. + let resumeCleanedUp = false; + const finalizeResumeRegistry = () => { + if (resumeCleanedUp) return; + resumeCleanedUp = true; + globalRunRegistry.delete(runId); + }; + // Re-subscribe to the stream const { output, @@ -659,9 +941,29 @@ export function createInngestAgent<TOutput = undefined>(options: CreateInngestAg resourceId: resumeOptions?.resourceId, onChunk: resumeOptions?.onChunk, onStepFinish: resumeOptions?.onStepFinish, - onFinish: resumeOptions?.onFinish, - onError: resumeOptions?.onError, + onFinish: async result => { + try { + await resumeOptions?.onFinish?.(result); + } finally { + finalizeResumeRegistry(); + } + }, + onError: async error => { + try { + await resumeOptions?.onError?.(error); + } finally { + finalizeResumeRegistry(); + } + }, onSuspended: resumeOptions?.onSuspended, + onAbort: async data => { + try { + await (resumeOptions?.onAbort as ((event: any) => void | Promise<void>) | undefined)?.(data); + } finally { + finalizeResumeRegistry(); + } + }, + closeOnSuspend: (resumeOptions as any)?.[CLOSE_ON_SUSPEND] === true, }); // Load the workflow snapshot to build proper resume data @@ -669,7 +971,7 @@ export function createInngestAgent<TOutput = undefined>(options: CreateInngestAg // and sends an event to the same trigger name (not a .resume suffix) const eventName = `workflow.${InngestDurableStepIds.AGENTIC_LOOP}`; - ready + const workflowExecution = ready .then(async () => { const workflowsStore = await mastra?.getStorage()?.getStore('workflows'); const snapshot: any = await workflowsStore?.loadWorkflowSnapshot({ @@ -703,6 +1005,19 @@ export function createInngestAgent<TOutput = undefined>(options: CreateInngestAg void emitError(runId, error); }); + existingEntry.workflowExecution = workflowExecution; + + const abort = (reason?: unknown) => { + if (!abortController.signal.aborted) { + abortController.abort(reason); + } + }; + + const cleanup = () => { + streamCleanup(); + finalizeResumeRegistry(); + }; + return { output, get fullStream() { @@ -711,8 +1026,12 @@ export function createInngestAgent<TOutput = undefined>(options: CreateInngestAg runId, threadId: resumeOptions?.threadId, resourceId: resumeOptions?.resourceId, - cleanup: streamCleanup, - }; + cleanup, + abort, + // Internal: stream-only cleanup for resumeGenerate() to release the + // subscription on suspend without dropping the resumed registry entry. + [STREAM_CLEANUP]: streamCleanup, + } as InngestAgentStreamResult<TOutput>; }, async prepare(messages, prepareOptions) { @@ -761,6 +1080,15 @@ export function createInngestAgent<TOutput = undefined>(options: CreateInngestAg await ready; + // `observe()` is a read-only re-subscription — it does not own the run + // so it cannot abort the underlying workflow. We still expose `abort()` + // on the result for type parity with stream()/resume(); calling it + // closes the local subscription via cleanup but is a no-op against the + // running workflow. + const abort = (_reason?: unknown) => { + streamCleanup(); + }; + return { output, get fullStream() { @@ -768,9 +1096,89 @@ export function createInngestAgent<TOutput = undefined>(options: CreateInngestAg }, runId, cleanup: streamCleanup, + abort, }; }, + async generate(messages, generateOptions): Promise<FullOutput<TOutput>> { + // Delegate to stream() with `methodType: 'generate'` and `closeOnSuspend` + // so that getFullOutput() resolves promptly on suspend (mirroring + // DurableAgent.generate). We do NOT pass `untilIdle` through — generate + // is a one-shot drain, not an idle loop. + const { untilIdle, ...rest } = generateOptions ?? {}; + void untilIdle; + const streamOpts = { + ...rest, + [CLOSE_ON_SUSPEND]: true, + __methodType: 'generate', + } as InngestAgentStreamOptions<TOutput>; + const result = await proxyRef!.stream(messages, streamOpts); + + let suspended = false; + try { + const fullOutput = (await result.output.getFullOutput()) as FullOutput<TOutput>; + if (fullOutput.error) { + throw fullOutput.error; + } + suspended = fullOutput.finishReason === 'suspended'; + // On suspend, wait for the workflow trigger promise so the suspend + // snapshot has landed before returning — otherwise a follow-up + // resumeGenerate() may race the storage write. + if (suspended) { + await globalRunRegistry.get(result.runId)?.workflowExecution; + } + if (!fullOutput.runId) { + (fullOutput as { runId?: string }).runId = result.runId; + } + return fullOutput; + } finally { + // Always release the local stream subscription. On suspend, keep the + // registry entry alive so resumeGenerate() can pick it up; other + // outcomes run the full public cleanup (which also finalizes the + // registry). + if (suspended) { + const streamOnlyCleanup = (result as unknown as { [STREAM_CLEANUP]?: () => void })[STREAM_CLEANUP]; + streamOnlyCleanup?.(); + } else { + result.cleanup(); + } + } + }, + + async resumeGenerate(runId, resumeData, resumeOptions): Promise<FullOutput<TOutput>> { + // `resumeGenerate` is a one-shot drain; strip `untilIdle` so the + // underlying resume() never delegates to the idle-loop wrapper. + const { untilIdle, ...rest } = resumeOptions ?? {}; + void untilIdle; + const result = await proxyRef!.resume(runId, resumeData, { + ...rest, + [CLOSE_ON_SUSPEND]: true, + } as InngestAgentResumeOptions<TOutput>); + + let suspended = false; + try { + const fullOutput = (await result.output.getFullOutput()) as FullOutput<TOutput>; + if (fullOutput.error) { + throw fullOutput.error; + } + suspended = fullOutput.finishReason === 'suspended'; + if (suspended) { + await globalRunRegistry.get(result.runId)?.workflowExecution; + } + if (!fullOutput.runId) { + (fullOutput as { runId?: string }).runId = result.runId; + } + return fullOutput; + } finally { + if (suspended) { + const streamOnlyCleanup = (result as unknown as { [STREAM_CLEANUP]?: () => void })[STREAM_CLEANUP]; + streamOnlyCleanup?.(); + } else { + result.cleanup(); + } + } + }, + getDurableWorkflows() { return [workflow]; }, diff --git a/workflows/inngest/src/execution-engine.ts b/workflows/inngest/src/execution-engine.ts index bf89f18c95f6..4e7eed91a777 100644 --- a/workflows/inngest/src/execution-engine.ts +++ b/workflows/inngest/src/execution-engine.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import type { ActorSignal } from '@mastra/core/auth/ee'; import type { RequestContext } from '@mastra/core/di'; import { getErrorFromUnknown } from '@mastra/core/error'; import type { SerializedError } from '@mastra/core/error'; @@ -424,6 +425,7 @@ export class InngestExecutionEngine extends DefaultExecutionEngine { startedAt: number; perStep?: boolean; stepSpan?: any; + actor?: ActorSignal; }): Promise<StepResult<any, any, any, any> | null> { // Only handle InngestWorkflow instances if (!(params.step instanceof InngestWorkflow)) { @@ -442,6 +444,7 @@ export class InngestExecutionEngine extends DefaultExecutionEngine { startedAt, perStep, stepSpan, + actor, } = params; // Build trace context to propagate to nested workflow @@ -483,6 +486,7 @@ export class InngestExecutionEngine extends DefaultExecutionEngine { outputOptions: { includeState: true }, perStep, tracingOptions: nestedTracingContext, + actor, }, })) as any; result = invokeResp.result; @@ -512,6 +516,7 @@ export class InngestExecutionEngine extends DefaultExecutionEngine { outputOptions: { includeState: true }, perStep, tracingOptions: nestedTracingContext, + actor, }, })) as any; result = invokeResp.result; @@ -526,6 +531,7 @@ export class InngestExecutionEngine extends DefaultExecutionEngine { outputOptions: { includeState: true }, perStep, tracingOptions: nestedTracingContext, + actor, }, })) as any; result = invokeResp.result; diff --git a/workflows/inngest/src/index.test.ts b/workflows/inngest/src/index.test.ts index 08d2510d61d4..285c31f66ed2 100644 --- a/workflows/inngest/src/index.test.ts +++ b/workflows/inngest/src/index.test.ts @@ -237,6 +237,119 @@ describe('MastraInngestWorkflow', () => { } }); + describe.sequential('FGA actor signal', () => { + it('bypasses membership resolution for a trusted system actor across a nested-workflow step boundary', async ctx => { + const inngest = new Inngest({ + id: 'mastra', + baseUrl: `http://localhost:${(ctx as any).inngestPort}`, + }); + + const { createWorkflow, createStep } = init(inngest); + + const fgaProvider = { + require: vi.fn().mockResolvedValue(undefined), + check: vi.fn(), + filterAccessible: vi.fn(), + }; + + const agent = new Agent({ + id: 'membership-agent', + name: 'Membership Agent', + instructions: 'Say ok', + model: new MockLanguageModelV2({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + content: [{ type: 'text', text: 'ok' }], + warnings: [], + }), + }), + }); + + // Inside a durable step, forward the per-call actor + tenant-scoped requestContext + // to a nested agent FGA check, exactly as a trusted background workflow would. + const callAgentStep = createStep({ + id: 'call-agent', + inputSchema: z.object({}), + outputSchema: z.object({ text: z.string() }), + execute: async ({ actor, requestContext, mastra }) => { + const res = await mastra!.getAgent('membership-agent').generate('hello', { actor, requestContext }); + return { text: res.text }; + }, + }); + + const nestedWorkflow = createWorkflow({ + id: 'nested-actor-workflow', + inputSchema: z.object({}), + outputSchema: z.object({ text: z.string() }), + steps: [callAgentStep], + }) + .then(callAgentStep) + .commit(); + + const workflow = createWorkflow({ + id: 'actor-parent-workflow', + inputSchema: z.object({}), + outputSchema: z.object({ text: z.string() }), + steps: [nestedWorkflow], + }) + .then(nestedWorkflow) + .commit(); + + const mastra = new Mastra({ + logger: false, + storage: new DefaultStorage({ + id: 'test-storage', + url: ':memory:', + }), + agents: { 'membership-agent': agent }, + workflows: { + 'actor-parent-workflow': workflow, + }, + server: { + fga: fgaProvider, + apiRoutes: [ + { + path: '/inngest/api', + method: 'ALL', + createHandler: async ({ mastra }) => inngestServe({ mastra, inngest, ...getDockerRegisterOptions() }), + }, + ], + }, + }); + + const app = await createHonoServer(mastra); + + const srv = (globServer = serve({ + fetch: app.fetch, + port: (ctx as any).handlerPort, + })); + await resetInngest(); + + const requestContext = new RequestContext(); + requestContext.set('organizationId', 'org-1'); + + const run = await workflow.createRun(); + const result = await run.start({ + inputData: {}, + requestContext, + actor: { actorKind: 'system', sourceWorkflow: 'nightly-workflow' }, + }); + + srv.close(); + + // The trusted actor bypasses membership resolution at the nested agent FGA check, + // which only happens if `actor` survived the parent -> nested-workflow step boundary. + expect(fgaProvider.require).not.toHaveBeenCalled(); + expect(result.status).toBe('success'); + expect(result.steps['nested-actor-workflow']).toMatchObject({ + status: 'success', + output: { text: 'ok' }, + }); + }); + }); + describe.sequential('Basic Workflow Execution', () => { it('should be able to bail workflow execution', async ctx => { const t0 = Date.now(); diff --git a/workflows/inngest/src/run.ts b/workflows/inngest/src/run.ts index 4015544c655b..5a897433b81a 100644 --- a/workflows/inngest/src/run.ts +++ b/workflows/inngest/src/run.ts @@ -1,4 +1,5 @@ import { ReadableStream } from 'node:stream/web'; +import type { ActorSignal } from '@mastra/core/auth/ee'; import { getErrorFromUnknown } from '@mastra/core/error'; import type { Mastra } from '@mastra/core/mastra'; import type { TracingContext, TracingOptions } from '@mastra/core/observability'; @@ -285,6 +286,7 @@ export class InngestRun< initialState: TState; }) & { requestContext?: RequestContext; + actor?: ActorSignal; outputWriter?: OutputWriter; tracingContext?: TracingContext; tracingOptions?: TracingOptions; @@ -320,6 +322,7 @@ export class InngestRun< initialState: TState; }) & { requestContext?: RequestContext; + actor?: ActorSignal; tracingOptions?: TracingOptions; outputOptions?: { includeState?: boolean; @@ -364,6 +367,7 @@ export class InngestRun< outputOptions: args.outputOptions, tracingOptions: args.tracingOptions, requestContext: args.requestContext ? Object.fromEntries(args.requestContext.entries()) : {}, + actor: args.actor, perStep: args.perStep, }, }); @@ -384,10 +388,12 @@ export class InngestRun< tracingOptions, format, requestContext, + actor, perStep, }: { inputData?: TInput; requestContext?: RequestContext; + actor?: ActorSignal; initialState?: TState; tracingOptions?: TracingOptions; outputOptions?: { @@ -433,6 +439,7 @@ export class InngestRun< tracingOptions, format, requestContext: requestContext ? Object.fromEntries(requestContext.entries()) : {}, + actor, perStep, }, }); @@ -467,6 +474,7 @@ export class InngestRun< | string[]; label?: string; requestContext?: RequestContext; + actor?: ActorSignal; perStep?: boolean; }): Promise<WorkflowResult<TState, TInput, TOutput, TSteps>> { const p = this._resume(params).then(result => { @@ -498,6 +506,7 @@ export class InngestRun< | string[]; label?: string; requestContext?: RequestContext; + actor?: ActorSignal; perStep?: boolean; }): Promise<{ eventId: string }> { const storage = this.#mastra?.getStorage(); @@ -570,6 +579,13 @@ export class InngestRun< resumePath: steps?.[0] ? (snapshot?.suspendedPaths?.[steps?.[0]] as any) : undefined, }, requestContext: mergedRequestContext, + // `actor` is a per-call trust signal, not rehydrated from the snapshot like + // `requestContext` is above. This intentionally matches the default engine, + // which passes `actor: params.actor` on resume and never reads it from the + // snapshot (see packages/core/src/workflows/workflow.ts `_resume`). The caller + // (a trusted background system) re-supplies `actor` on each resume; we never + // persist a membership-bypass signal into durable storage. + actor: params.actor, perStep: params.perStep, }, }); @@ -607,6 +623,7 @@ export class InngestRun< | string[]; label?: string; requestContext?: RequestContext; + actor?: ActorSignal; perStep?: boolean; }): Promise<WorkflowResult<TState, TInput, TOutput, TSteps>> { const { eventId } = await this._resumeAndSendEvent(params); @@ -640,6 +657,7 @@ export class InngestRun< | string[]; label?: string; requestContext?: RequestContext; + actor?: ActorSignal; perStep?: boolean; }): Promise<{ runId: string }> { await this._resumeAndSendEvent(params); @@ -659,6 +677,7 @@ export class InngestRun< context?: TimeTravelContext<any, any, any, any>; nestedStepsContext?: Record<string, TimeTravelContext<any, any, any, any>>; requestContext?: RequestContext; + actor?: ActorSignal; tracingOptions?: TracingOptions; outputOptions?: { includeState?: boolean; @@ -690,6 +709,7 @@ export class InngestRun< context?: TimeTravelContext<any, any, any, any>; nestedStepsContext?: Record<string, TimeTravelContext<any, any, any, any>>; requestContext?: RequestContext; + actor?: ActorSignal; tracingOptions?: TracingOptions; outputOptions?: { includeState?: boolean; @@ -808,6 +828,7 @@ export class InngestRun< tracingOptions: params.tracingOptions, outputOptions: params.outputOptions, requestContext: params.requestContext ? Object.fromEntries(params.requestContext.entries()) : {}, + actor: params.actor, perStep: params.perStep, }, }); @@ -873,7 +894,11 @@ export class InngestRun< }; } - streamLegacy({ inputData, requestContext }: { inputData?: TInput; requestContext?: RequestContext } = {}): { + streamLegacy({ + inputData, + requestContext, + actor, + }: { inputData?: TInput; requestContext?: RequestContext; actor?: ActorSignal } = {}): { stream: ReadableStream<StreamEvent>; getWorkflowState: () => Promise<WorkflowResult<TState, TInput, TOutput, TSteps>>; } { @@ -919,7 +944,7 @@ export class InngestRun< } }; - this.executionResults = this._start({ inputData, requestContext, format: 'legacy' }).then(result => { + this.executionResults = this._start({ inputData, requestContext, actor, format: 'legacy' }).then(result => { if (result.status !== 'suspended') { this.closeStreamAction?.().catch(() => {}); } @@ -936,6 +961,7 @@ export class InngestRun< stream({ inputData, requestContext, + actor, tracingOptions, closeOnSuspend = true, initialState, @@ -944,6 +970,7 @@ export class InngestRun< }: { inputData?: TInput; requestContext?: RequestContext; + actor?: ActorSignal; tracingContext?: TracingContext; tracingOptions?: TracingOptions; closeOnSuspend?: boolean; @@ -989,6 +1016,7 @@ export class InngestRun< const executionResultsPromise = self._start({ inputData, requestContext, + actor, // tracingContext, // We are not able to pass a reference to a span here, what to do? initialState, tracingOptions, @@ -1036,6 +1064,7 @@ export class InngestRun< context, nestedStepsContext, requestContext, + actor, // tracingContext, tracingOptions, outputOptions, @@ -1052,6 +1081,7 @@ export class InngestRun< context?: TimeTravelContext<any, any, any, any>; nestedStepsContext?: Record<string, TimeTravelContext<any, any, any, any>>; requestContext?: RequestContext; + actor?: ActorSignal; tracingContext?: TracingContext; tracingOptions?: TracingOptions; outputOptions?: { @@ -1095,6 +1125,7 @@ export class InngestRun< resumeData, initialState, requestContext, + actor, tracingOptions, outputOptions, perStep, diff --git a/workflows/inngest/src/workflow.ts b/workflows/inngest/src/workflow.ts index 38d096cdf6db..98e66855ff38 100644 --- a/workflows/inngest/src/workflow.ts +++ b/workflows/inngest/src/workflow.ts @@ -306,6 +306,7 @@ export class InngestWorkflow< timeTravel, perStep, tracingOptions, + actor, } = event.data; if (!runId) { @@ -373,6 +374,7 @@ export class InngestWorkflow< pubsub, retryConfig: this.retryConfig, requestContext, + actor, resume, timeTravel, perStep, diff --git a/workflows/temporal/CHANGELOG.md b/workflows/temporal/CHANGELOG.md index 4243cfd28553..ac55d46c18af 100644 --- a/workflows/temporal/CHANGELOG.md +++ b/workflows/temporal/CHANGELOG.md @@ -1,5 +1,61 @@ # @mastra/temporal +## 0.2.3-alpha.9 + +### Patch Changes + +- Updated dependencies [[`e84e791`](https://github.com/mastra-ai/mastra/commit/e84e79174031d7bc8793ca6c805eb38b06e7cfb1)]: + - @mastra/core@1.48.0-alpha.9 + - @mastra/deployer@1.48.0-alpha.9 + +## 0.2.3-alpha.8 + +### Patch Changes + +- Updated dependencies [[`0ac14ce`](https://github.com/mastra-ai/mastra/commit/0ac14cea48e1b0a7857782153c78f7242fdf7e1a), [`c2f0b7f`](https://github.com/mastra-ai/mastra/commit/c2f0b7f1370f4428d165f51f0d1d9a48331cc257)]: + - @mastra/core@1.48.0-alpha.8 + - @mastra/deployer@1.48.0-alpha.8 + +## 0.2.3-alpha.7 + +### Patch Changes + +- Updated dependencies [[`8be63b0`](https://github.com/mastra-ai/mastra/commit/8be63b015fb8d72cea1220f05e7dc3bb997cc249), [`7331245`](https://github.com/mastra-ai/mastra/commit/733124501b4504578648cf15ab6d64330e8778c7), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c), [`345eecc`](https://github.com/mastra-ai/mastra/commit/345eecce6ba519b5d987f0e10b5de4c8e5734580), [`ee14cae`](https://github.com/mastra-ai/mastra/commit/ee14cae244805783bde518a6142de28b744b169c)]: + - @mastra/core@1.48.0-alpha.7 + - @mastra/deployer@1.48.0-alpha.7 + +## 0.2.3-alpha.6 + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`9e76ed9`](https://github.com/mastra-ai/mastra/commit/9e76ed9f9d92619ccf5b77978d8cdea76bcae61e), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + - @mastra/deployer@1.48.0-alpha.6 + +## 0.2.3-alpha.5 + +### Patch Changes + +- Updated dependencies [[`1917c53`](https://github.com/mastra-ai/mastra/commit/1917c53b19dac43926f29c496893b0686462dca4), [`58e287b`](https://github.com/mastra-ai/mastra/commit/58e287b1edaf978b13745a1795989cad3826e82b)]: + - @mastra/core@1.48.0-alpha.5 + - @mastra/deployer@1.48.0-alpha.5 + +## 0.2.3-alpha.4 + +### Patch Changes + +- Updated dependencies [[`705ba98`](https://github.com/mastra-ai/mastra/commit/705ba98726d388a596e896225f237907ca6807a9), [`e62c108`](https://github.com/mastra-ai/mastra/commit/e62c108409dfd6a6cac0a48ec39c5cc81d24fd52), [`bfbbb01`](https://github.com/mastra-ai/mastra/commit/bfbbb01bd845ba54cdc0c678c277d08a7cb847e4)]: + - @mastra/core@1.48.0-alpha.4 + - @mastra/deployer@1.48.0-alpha.4 + +## 0.2.3-alpha.3 + +### Patch Changes + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + - @mastra/deployer@1.48.0-alpha.3 + ## 0.2.3-alpha.2 ### Patch Changes diff --git a/workflows/temporal/package.json b/workflows/temporal/package.json index a7bb713502d5..607f76fb9c13 100644 --- a/workflows/temporal/package.json +++ b/workflows/temporal/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/temporal", - "version": "0.2.3-alpha.2", + "version": "0.2.3-alpha.9", "description": "Mastra Temporal workflows integration - run Mastra workflows on the Temporal durable execution platform", "type": "module", "main": "dist/index.js", diff --git a/workspaces/mesa/CHANGELOG.md b/workspaces/mesa/CHANGELOG.md new file mode 100644 index 000000000000..582456391715 --- /dev/null +++ b/workspaces/mesa/CHANGELOG.md @@ -0,0 +1,5 @@ +# @mastra/mesa + +## 0.1.0 + +Initial release. diff --git a/workspaces/mesa/README.md b/workspaces/mesa/README.md new file mode 100644 index 000000000000..969c7b137965 --- /dev/null +++ b/workspaces/mesa/README.md @@ -0,0 +1,41 @@ +# @mastra/mesa + +Mesa filesystem provider for Mastra workspaces. + +## Installation + +```bash +npm install @mastra/core @mastra/mesa +``` + +## Usage + +```typescript +import { Agent } from '@mastra/core/agent'; +import { Workspace } from '@mastra/core/workspace'; +import { MesaFilesystem } from '@mastra/mesa'; + +const workspace = new Workspace({ + filesystem: new MesaFilesystem({ + apiKey: process.env.MESA_API_KEY, + org: 'acme', + repos: [{ name: 'docs', bookmark: 'main' }], + }), +}); + +const agent = new Agent({ + name: 'my-agent', + model: '__GATEWAY_ANTHROPIC_MODEL_OPUS__', + workspace, +}); +``` + +Filesystem methods expect absolute paths rooted at the Mesa mount. Include the org slug and repo name: + +```typescript +await workspace.filesystem.readFile('/acme/docs/README.md'); +``` + +## License + +Apache-2.0 diff --git a/workspaces/mesa/eslint.config.js b/workspaces/mesa/eslint.config.js new file mode 100644 index 000000000000..10b24d4a5ae3 --- /dev/null +++ b/workspaces/mesa/eslint.config.js @@ -0,0 +1,6 @@ +import { createConfig } from '@internal/lint/eslint'; + +const config = await createConfig(); + +/** @type {import("eslint").Linter.Config[]} */ +export default config; diff --git a/workspaces/mesa/lint-staged.config.js b/workspaces/mesa/lint-staged.config.js new file mode 100644 index 000000000000..0b44008827ba --- /dev/null +++ b/workspaces/mesa/lint-staged.config.js @@ -0,0 +1,5 @@ +export default { + '*.{ts,tsx}': ['eslint --fix --max-warnings=0', 'prettier --write'], + '*.{js,jsx}': ['prettier --write'], + '*.{json,md,yml,yaml}': ['prettier --write'], +}; diff --git a/workspaces/mesa/package.json b/workspaces/mesa/package.json new file mode 100644 index 000000000000..d7b1524da286 --- /dev/null +++ b/workspaces/mesa/package.json @@ -0,0 +1,65 @@ +{ + "name": "@mastra/mesa", + "version": "0.1.0", + "description": "Mesa filesystem provider for Mastra workspaces", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsup --silent --config tsup.config.ts", + "build:lib": "pnpm build", + "build:watch": "pnpm build --watch", + "test:unit": "vitest run --exclude '**/*.integration.test.ts'", + "test:watch": "vitest watch", + "test": "pnpm test:unit", + "test:cloud": "vitest run ./src/**/*.integration.test.ts", + "lint": "eslint ." + }, + "license": "Apache-2.0", + "dependencies": { + "@mesadev/sdk": "0.38.0" + }, + "devDependencies": { + "@internal/lint": "workspace:*", + "@internal/types-builder": "workspace:*", + "@internal/workspace-test-utils": "workspace:*", + "@mastra/core": "workspace:*", + "@types/node": "22.19.21", + "eslint": "^10.4.1", + "tsup": "^8.5.1", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "@mastra/core": ">=1.4.0-0 <2.0.0-0" + }, + "files": [ + "dist", + "CHANGELOG.md" + ], + "homepage": "https://mastra.ai", + "repository": { + "type": "git", + "url": "git+https://github.com/mastra-ai/mastra.git", + "directory": "workspaces/mesa" + }, + "bugs": { + "url": "https://github.com/mastra-ai/mastra/issues" + }, + "engines": { + "node": ">=22.13.0" + } +} diff --git a/workspaces/mesa/src/filesystem/index.integration.test.ts b/workspaces/mesa/src/filesystem/index.integration.test.ts new file mode 100644 index 000000000000..5116ee5405f2 --- /dev/null +++ b/workspaces/mesa/src/filesystem/index.integration.test.ts @@ -0,0 +1,335 @@ +import path from 'node:path/posix'; + +import { createFilesystemTestSuite } from '@internal/workspace-test-utils'; +import type { + CopyOptions, + FileContent, + FileEntry, + FilesystemIcon, + FilesystemInfo, + FileStat, + ListOptions, + ProviderStatus, + ReadOptions, + RemoveOptions, + WorkspaceFilesystem, + WriteOptions, +} from '@mastra/core/workspace'; +import { Mesa } from '@mesadev/sdk'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { MesaFilesystem } from './index'; + +interface MesaTestEnv { + apiKey: string; + org: string; + repo: string; + mesa: Mesa; +} + +let mesaTestEnv: MesaTestEnv | undefined; +const hasMesaApiKey = Boolean(process.env.MESA_API_KEY); +const describeWithMesaApiKey = hasMesaApiKey ? describe : describe.skip; + +function createTestRepoName(): string { + return `mastra-test-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function isNotFoundError(error: unknown): boolean { + const err = + error && typeof error === 'object' ? (error as { code?: unknown; name?: unknown; message?: unknown }) : undefined; + const code = typeof err?.code === 'string' ? err.code : undefined; + const name = typeof err?.name === 'string' ? err.name : undefined; + const message = + typeof err?.message === 'string' ? err.message : error instanceof Error ? error.message : String(error); + + return ( + code === 'ENOENT' || + code === 'NotFound' || + name === 'ENOENT' || + name === 'NotFound' || + /\b(no such|not found|enoent)\b/i.test(message) + ); +} + +if (hasMesaApiKey) { + beforeAll(async () => { + const apiKey = process.env.MESA_API_KEY!; + const mesa = new Mesa({ apiKey }); + const org = await mesa.resolveOrg(); + const repo = createTestRepoName(); + + await mesa.repos.create({ name: repo }); + + mesaTestEnv = { + apiKey, + org, + repo, + mesa, + }; + }); +} + +function getMesaTestEnv(): MesaTestEnv { + if (!mesaTestEnv) { + throw new Error('MesaFilesystem integration test environment was not initialized.'); + } + + return mesaTestEnv; +} + +async function deleteMesaTestRepo(): Promise<void> { + if (!mesaTestEnv) return; + + try { + await mesaTestEnv.mesa.repos.delete({ repo: mesaTestEnv.repo }); + } catch (error) { + if (!isNotFoundError(error)) throw error; + } +} + +afterAll(async () => { + await deleteMesaTestRepo(); +}); + +function mesaRepoPath(...parts: string[]): string { + const env = getMesaTestEnv(); + return path.join('/', env.org, env.repo, ...parts); +} + +function createMesaFilesystem(): MesaFilesystem { + const env = getMesaTestEnv(); + + return new MesaFilesystem({ + apiKey: env.apiKey, + repos: [ + { + name: env.repo, + bookmark: 'main', + }, + ], + }); +} + +class RootedMesaFilesystem implements WorkspaceFilesystem { + constructor( + private readonly filesystem: MesaFilesystem, + private readonly root: string, + ) {} + + get id(): string { + return this.filesystem.id; + } + + get name(): string { + return this.filesystem.name; + } + + get provider(): string { + return this.filesystem.provider; + } + + get status(): ProviderStatus { + return this.filesystem.status; + } + + get error(): string | undefined { + return this.filesystem.error; + } + + get readOnly(): boolean | undefined { + return this.filesystem.readOnly; + } + + get icon(): FilesystemIcon | undefined { + return this.filesystem.icon; + } + + get displayName(): string | undefined { + return this.filesystem.displayName; + } + + get description(): string | undefined { + return this.filesystem.description; + } + + async init(): Promise<void> { + await this.filesystem._init(); + await this.filesystem.mkdir(this.root, { recursive: true }); + } + + async destroy(): Promise<void> { + await this.filesystem._destroy(); + } + + getInfo(): FilesystemInfo { + return { + ...this.filesystem.getInfo(), + id: this.id, + name: this.name, + provider: this.provider, + status: this.status, + error: this.error, + readOnly: this.readOnly, + icon: this.icon, + }; + } + + getInstructions(): string { + return this.filesystem.getInstructions(); + } + + async realpath(inputPath: string): Promise<string> { + const resolved = await this.filesystem.realpath(this.toMesaPath(inputPath)); + return this.fromMesaPath(resolved); + } + + readFile(inputPath: string, options?: ReadOptions): Promise<string | Buffer> { + return this.filesystem.readFile(this.toMesaPath(inputPath), options); + } + + writeFile(inputPath: string, content: FileContent, options?: WriteOptions): Promise<void> { + return this.filesystem.writeFile(this.toMesaPath(inputPath), content, options); + } + + appendFile(inputPath: string, content: FileContent): Promise<void> { + return this.filesystem.appendFile(this.toMesaPath(inputPath), content); + } + + deleteFile(inputPath: string, options?: RemoveOptions): Promise<void> { + return this.filesystem.deleteFile(this.toMesaPath(inputPath), options); + } + + copyFile(src: string, dest: string, options?: CopyOptions): Promise<void> { + return this.filesystem.copyFile(this.toMesaPath(src), this.toMesaPath(dest), options); + } + + moveFile(src: string, dest: string, options?: CopyOptions): Promise<void> { + return this.filesystem.moveFile(this.toMesaPath(src), this.toMesaPath(dest), options); + } + + mkdir(inputPath: string, options?: { recursive?: boolean }): Promise<void> { + return this.filesystem.mkdir(this.toMesaPath(inputPath), options); + } + + rmdir(inputPath: string, options?: RemoveOptions): Promise<void> { + return this.filesystem.rmdir(this.toMesaPath(inputPath), options); + } + + readdir(inputPath: string, options?: ListOptions): Promise<FileEntry[]> { + return this.filesystem.readdir(this.toMesaPath(inputPath), options); + } + + exists(inputPath: string): Promise<boolean> { + return this.filesystem.exists(this.toMesaPath(inputPath)); + } + + async stat(inputPath: string): Promise<FileStat> { + const stat = await this.filesystem.stat(this.toMesaPath(inputPath)); + return { ...stat, path: this.fromMesaPath(stat.path) }; + } + + private toMesaPath(inputPath: string): string { + const normalized = path.normalize(inputPath || '/'); + const relativePath = normalized === '/' ? '' : normalized.replace(/^\/+/, ''); + const resolved = path.resolve(this.root, relativePath); + + if (resolved !== this.root && !resolved.startsWith(`${this.root}/`)) { + throw new Error(`Path escapes Mesa conformance test root: ${inputPath}`); + } + + return resolved; + } + + private fromMesaPath(inputPath: string): string { + const normalized = path.normalize(inputPath); + if (normalized === this.root) return '/'; + if (normalized.startsWith(`${this.root}/`)) return normalized.slice(this.root.length); + throw new Error(`Mesa path escapes conformance test root: ${inputPath}`); + } +} + +describeWithMesaApiKey('MesaFilesystem integration', () => { + it('creates an isolated Mesa repo for the test run', () => { + const env = getMesaTestEnv(); + + expect(env.org).toBeTruthy(); + expect(env.repo).toMatch(/^mastra-test-/); + }); + + it('performs basic file operations against a Mesa repo', async () => { + const testDir = mesaRepoPath(`smoke-${Date.now()}`); + const fs = createMesaFilesystem(); + + try { + await fs.writeFile(`${testDir}/hello.txt`, 'hello'); + await expect(fs.readFile(`${testDir}/hello.txt`, { encoding: 'utf-8' })).resolves.toBe('hello'); + + await fs.copyFile(`${testDir}/hello.txt`, `${testDir}/copy.txt`); + await expect(fs.exists(`${testDir}/copy.txt`)).resolves.toBe(true); + + await fs.moveFile(`${testDir}/copy.txt`, `${testDir}/moved.txt`); + await expect(fs.exists(`${testDir}/moved.txt`)).resolves.toBe(true); + + const entries = await fs.readdir(testDir); + expect(entries.map(entry => entry.name).sort()).toEqual(['hello.txt', 'moved.txt']); + } finally { + await fs.rmdir(testDir, { recursive: true, force: true }); + } + }); + + it('reports Mesa path stats without assuming local runner timestamps', async () => { + const testDir = mesaRepoPath(`path-${Date.now()}`); + const fs = createMesaFilesystem(); + + try { + await fs.writeFile(`${testDir}/stat-time.txt`, 'content'); + + await expect(fs.exists(`${testDir}/stat-time.txt`)).resolves.toBe(true); + + const stat = await fs.stat(`${testDir}/stat-time.txt`); + expect(stat).toEqual( + expect.objectContaining({ + name: 'stat-time.txt', + path: `${testDir}/stat-time.txt`, + type: 'file', + size: expect.any(Number), + createdAt: expect.any(Date), + modifiedAt: expect.any(Date), + }), + ); + } finally { + await fs.rmdir(testDir, { recursive: true, force: true }); + } + }); +}); + +if (hasMesaApiKey) { + createFilesystemTestSuite({ + suiteName: 'MesaFilesystem Conformance', + createFilesystem: async () => { + const testRoot = mesaRepoPath(`conformance-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + return new RootedMesaFilesystem(createMesaFilesystem(), testRoot); + }, + capabilities: { + supportsAppend: true, + supportsBinaryFiles: true, + supportsMounting: false, + supportsForceDelete: true, + supportsOverwrite: true, + supportsConcurrency: true, + supportsEmptyDirectories: true, + deleteThrowsOnMissing: true, + }, + testDomains: { + // Mesa returns server-side mtimes, but the shared pathOperations suite + // assumes stat().modifiedAt is comparable to the local test runner clock. + pathOperations: false, + }, + testTimeout: 30000, + }); +} else { + describe.skip('MesaFilesystem Conformance', () => { + it('requires MESA_API_KEY', () => {}); + }); +} diff --git a/workspaces/mesa/src/filesystem/index.test.ts b/workspaces/mesa/src/filesystem/index.test.ts new file mode 100644 index 000000000000..48c84370f594 --- /dev/null +++ b/workspaces/mesa/src/filesystem/index.test.ts @@ -0,0 +1,482 @@ +import { + DirectoryNotEmptyError, + DirectoryNotFoundError, + FileExistsError, + FileNotFoundError, + StaleFileError, + WorkspaceReadOnlyError, +} from '@mastra/core/workspace'; +import type { MesaFileSystem } from '@mesadev/sdk'; +import type { Mocked } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { MesaFilesystem } from './index'; + +const mesaSdkMock = vi.hoisted(() => ({ + Mesa: vi.fn(), + mount: vi.fn(), + filesystem: undefined as Mocked<MesaFileSystem> | undefined, +})); + +vi.mock('@mesadev/sdk', () => ({ + Mesa: mesaSdkMock.Mesa, +})); + +function notFound(path: string): Error & { code: string } { + return Object.assign(new Error(`not found: ${path}`), { code: 'ENOENT' }); +} + +function createStat(overrides: Partial<Awaited<ReturnType<MesaFileSystem['stat']>>> = {}) { + return { + isFile: true, + isDirectory: false, + isSymbolicLink: false, + mode: 0o644, + size: 5, + mtime: new Date('2025-06-01T00:00:00.000Z'), + ...overrides, + }; +} + +function createMockMesaFileSystem(): Mocked<MesaFileSystem> { + return { + readFile: vi.fn(), + readFileBuffer: vi.fn().mockResolvedValue(new Uint8Array()), + writeFile: vi.fn().mockResolvedValue(undefined), + appendFile: vi.fn().mockResolvedValue(undefined), + exists: vi.fn().mockResolvedValue(false), + stat: vi.fn().mockResolvedValue(createStat()), + lstat: vi.fn(), + mkdir: vi.fn().mockResolvedValue(undefined), + readdir: vi.fn(), + readdirWithFileTypes: vi.fn().mockResolvedValue([]), + rm: vi.fn().mockResolvedValue(undefined), + cp: vi.fn().mockResolvedValue(undefined), + mv: vi.fn().mockResolvedValue(undefined), + resolvePath: vi.fn(), + getAllPaths: vi.fn(), + chmod: vi.fn(), + symlink: vi.fn(), + link: vi.fn(), + readlink: vi.fn(), + realpath: vi.fn().mockImplementation(async (path: string) => path), + utimes: vi.fn(), + setMetadata: vi.fn(), + getMetadata: vi.fn(), + clearMetadata: vi.fn(), + subscribe: vi.fn(), + change: { + new: vi.fn(), + edit: vi.fn(), + list: vi.fn(), + current: vi.fn().mockResolvedValue({ changeId: 'zzzz', commitOid: 'abc123' }), + }, + bookmark: { + create: vi.fn(), + move: vi.fn(), + list: vi.fn(), + }, + bash: vi.fn().mockReturnValue({ kind: 'bash' }), + } as unknown as Mocked<MesaFileSystem>; +} + +function createFs(options: Partial<ConstructorParameters<typeof MesaFilesystem>[0]> = {}) { + const mesaFs = createMockMesaFileSystem(); + mesaSdkMock.filesystem = mesaFs; + mesaSdkMock.Mesa.mockImplementation(function (this: { fs: { mount: typeof mesaSdkMock.mount } }) { + this.fs = { mount: mesaSdkMock.mount }; + }); + + const fs = new MesaFilesystem({ + repos: [{ name: 'docs', bookmark: 'main' }], + ...options, + } as ConstructorParameters<typeof MesaFilesystem>[0]); + + return { fs, mesaFs }; +} + +describe('MesaFilesystem', () => { + beforeEach(() => { + vi.clearAllMocks(); + mesaSdkMock.filesystem = undefined; + mesaSdkMock.mount.mockImplementation(async () => mesaSdkMock.filesystem); + mesaSdkMock.Mesa.mockImplementation(function (this: { fs: { mount: typeof mesaSdkMock.mount } }) { + this.fs = { mount: mesaSdkMock.mount }; + }); + }); + + describe('constructor and metadata', () => { + it('generates unique ids when not provided', () => { + const fs1 = new MesaFilesystem({ repos: [{ name: 'docs', bookmark: 'main' }] }); + const fs2 = new MesaFilesystem({ repos: [{ name: 'docs', bookmark: 'main' }] }); + + expect(fs1.id).toMatch(/^mesa-fs-/); + expect(fs2.id).toMatch(/^mesa-fs-/); + expect(fs1.id).not.toBe(fs2.id); + }); + + it('uses fixed display metadata', () => { + const { fs } = createFs({ + readOnly: true, + org: 'acme', + repos: [{ name: 'docs', bookmark: 'main' }], + }); + + expect(fs.id).toMatch(/^mesa-fs-/); + expect(fs.name).toBe('MesaFilesystem'); + expect(fs.provider).toBe('mesa'); + expect(fs.displayName).toBe('Mesa'); + expect(fs.icon).toBe('mesa'); + expect(fs.description).toBe('Versioned Mesa filesystem for workspace files'); + expect(fs.readOnly).toBe(true); + }); + + it('returns filesystem info without exposing credentials', () => { + const { fs } = createFs({ + org: 'acme', + repos: [{ name: 'docs', bookmark: 'main' }], + }); + + expect(fs.getInfo()).toEqual( + expect.objectContaining({ + id: fs.id, + name: 'MesaFilesystem', + provider: 'mesa', + icon: 'mesa', + metadata: { + org: 'acme', + repos: ['docs'], + mode: 'client', + }, + }), + ); + }); + + it('builds instructions with org, repo, and read-only context', () => { + const { fs } = createFs({ + readOnly: true, + org: 'acme', + repos: [{ name: 'docs', bookmark: 'main' }], + }); + + expect(fs.getInstructions()).toContain('Org: "acme"'); + expect(fs.getInstructions()).toContain('Mounted repos: "docs"'); + expect(fs.getInstructions()).toContain('Mounted read-only'); + }); + }); + + describe('lifecycle', () => { + it('creates and mounts a Mesa client during init', async () => { + const { fs, mesaFs } = createFs(); + + await fs.readFile('/acme/docs/README.md'); + + expect(fs.filesystem).toBe(mesaFs); + expect(fs.status).toBe('ready'); + expect(mesaSdkMock.Mesa).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: undefined, + org: undefined, + }), + ); + expect(mesaSdkMock.mount).toHaveBeenCalledWith( + expect.objectContaining({ + repos: [{ name: 'docs', bookmark: 'main' }], + }), + ); + }); + + it('marks mounted repos read-only when provider readOnly is true', async () => { + const { fs } = createFs({ readOnly: true }); + + await fs.readFile('/acme/docs/README.md'); + + expect(mesaSdkMock.mount).toHaveBeenCalledWith( + expect.objectContaining({ + repos: [{ name: 'docs', bookmark: 'main', readOnly: true }], + }), + ); + }); + + it('throws when mounting without repos', async () => { + const fs = new MesaFilesystem({ repos: [] }); + + await expect(fs.readFile('/acme/docs/README.md')).rejects.toThrow(/requires at least one repo/); + expect(fs.status).toBe('error'); + }); + }); + + describe('file operations', () => { + it('reads Buffer content by default', async () => { + const { fs, mesaFs } = createFs(); + mesaFs.readFileBuffer.mockResolvedValueOnce(new Uint8Array([104, 105])); + + const result = await fs.readFile('acme/docs/hi.txt'); + + expect(Buffer.isBuffer(result)).toBe(true); + expect(result.toString()).toBe('hi'); + expect(mesaFs.readFileBuffer).toHaveBeenCalledWith('/acme/docs/hi.txt'); + }); + + it('returns encoded string content when requested', async () => { + const { fs, mesaFs } = createFs(); + mesaFs.readFileBuffer.mockResolvedValueOnce(new TextEncoder().encode('hello')); + + const result = await fs.readFile('/acme/docs/hello.txt', { encoding: 'utf-8' }); + + expect(result).toBe('hello'); + }); + + it('maps missing reads to FileNotFoundError', async () => { + const { fs, mesaFs } = createFs(); + mesaFs.readFileBuffer.mockRejectedValueOnce(notFound('/missing.txt')); + + await expect(fs.readFile('/missing.txt')).rejects.toBeInstanceOf(FileNotFoundError); + }); + + it('writes strings and creates parent directories by default', async () => { + const { fs, mesaFs } = createFs(); + + await fs.writeFile('/acme/docs/new/file.txt', 'hello'); + + expect(mesaFs.mkdir).toHaveBeenCalledWith('/acme/docs/new', { recursive: true }); + expect(mesaFs.writeFile).toHaveBeenCalledWith('/acme/docs/new/file.txt', 'hello'); + }); + + it('anchors relative paths before normalizing parent traversal', async () => { + const { fs, mesaFs } = createFs(); + + await fs.writeFile('../acme/docs/file.txt', 'hello'); + + expect(mesaFs.mkdir).toHaveBeenCalledWith('/acme/docs', { recursive: true }); + expect(mesaFs.writeFile).toHaveBeenCalledWith('/acme/docs/file.txt', 'hello'); + }); + + it('requires existing parent directory when recursive=false', async () => { + const { fs, mesaFs } = createFs(); + mesaFs.stat.mockRejectedValueOnce(notFound('/acme/docs/new')); + + await expect(fs.writeFile('/acme/docs/new/file.txt', 'hello', { recursive: false })).rejects.toBeInstanceOf( + DirectoryNotFoundError, + ); + expect(mesaFs.writeFile).not.toHaveBeenCalled(); + }); + + it('writes Buffer content as Uint8Array', async () => { + const { fs, mesaFs } = createFs(); + + await fs.writeFile('/acme/docs/file.bin', Buffer.from([1, 2, 3])); + + expect(mesaFs.writeFile).toHaveBeenCalledWith('/acme/docs/file.bin', expect.any(Uint8Array)); + }); + + it('honors overwrite=false with a preflight exists check', async () => { + const { fs, mesaFs } = createFs(); + mesaFs.exists.mockResolvedValueOnce(true); + + await expect(fs.writeFile('/acme/docs/existing.txt', 'data', { overwrite: false })).rejects.toBeInstanceOf( + FileExistsError, + ); + expect(mesaFs.writeFile).not.toHaveBeenCalled(); + }); + + it('does not treat arbitrary exists failures as missing for overwrite=false', async () => { + const { fs, mesaFs } = createFs(); + mesaFs.exists.mockRejectedValueOnce(new Error('network failed')); + + await expect(fs.writeFile('/acme/docs/existing.txt', 'data', { overwrite: false })).rejects.toThrow( + /network failed/, + ); + expect(mesaFs.writeFile).not.toHaveBeenCalled(); + }); + + it('honors expectedMtime with a preflight stat check', async () => { + const { fs, mesaFs } = createFs(); + mesaFs.stat.mockResolvedValueOnce(createStat({ mtime: new Date('2025-06-02T00:00:00.000Z') })); + + await expect( + fs.writeFile('/acme/docs/existing.txt', 'data', { expectedMtime: new Date('2025-06-01T00:00:00.000Z') }), + ).rejects.toBeInstanceOf(StaleFileError); + expect(mesaFs.writeFile).not.toHaveBeenCalled(); + }); + + it('appends content through Mesa', async () => { + const { fs, mesaFs } = createFs(); + + await fs.appendFile('/acme/docs/log.txt', 'line'); + + expect(mesaFs.appendFile).toHaveBeenCalledWith('/acme/docs/log.txt', 'line'); + }); + + it('deletes files through Mesa rm', async () => { + const { fs, mesaFs } = createFs(); + mesaFs.stat.mockResolvedValueOnce(createStat({ isFile: true, isDirectory: false })); + + await fs.deleteFile('/acme/docs/file.txt'); + + expect(mesaFs.rm).toHaveBeenCalledWith('/acme/docs/file.txt', { force: undefined }); + }); + + it('ignores missing deleteFile when force=true', async () => { + const { fs, mesaFs } = createFs(); + mesaFs.stat.mockRejectedValueOnce(notFound('/acme/docs/missing.txt')); + + await fs.deleteFile('/acme/docs/missing.txt', { force: true }); + + expect(mesaFs.rm).not.toHaveBeenCalled(); + }); + + it('copies files through Mesa cp', async () => { + const { fs, mesaFs } = createFs(); + + await fs.copyFile('/acme/docs/a.txt', '/acme/docs/b.txt', { recursive: true }); + + expect(mesaFs.cp).toHaveBeenCalledWith('/acme/docs/a.txt', '/acme/docs/b.txt', { recursive: true }); + }); + + it('moves files through Mesa mv', async () => { + const { fs, mesaFs } = createFs(); + + await fs.moveFile('/acme/docs/a.txt', '/acme/docs/b.txt'); + + expect(mesaFs.mv).toHaveBeenCalledWith('/acme/docs/a.txt', '/acme/docs/b.txt'); + }); + }); + + describe('directory and path operations', () => { + it('creates directories through Mesa mkdir', async () => { + const { fs, mesaFs } = createFs(); + + await fs.mkdir('/acme/docs/new'); + + expect(mesaFs.mkdir).toHaveBeenCalledWith('/acme/docs/new', { recursive: true }); + }); + + it('removes directories through Mesa rm', async () => { + const { fs, mesaFs } = createFs(); + mesaFs.stat.mockResolvedValueOnce(createStat({ isFile: false, isDirectory: true, size: 0 })); + + await fs.rmdir('/acme/docs/old', { recursive: true, force: true }); + + expect(mesaFs.rm).toHaveBeenCalledWith('/acme/docs/old', { recursive: true, force: true }); + }); + + it('removes empty directories without requiring recursive=true from callers', async () => { + const { fs, mesaFs } = createFs(); + mesaFs.stat.mockResolvedValueOnce(createStat({ isFile: false, isDirectory: true, size: 0 })); + mesaFs.readdirWithFileTypes.mockResolvedValueOnce([]); + + await fs.rmdir('/acme/docs/empty'); + + expect(mesaFs.readdirWithFileTypes).toHaveBeenCalledWith('/acme/docs/empty'); + expect(mesaFs.rm).toHaveBeenCalledWith('/acme/docs/empty', { recursive: true, force: undefined }); + }); + + it('rejects non-empty directory removal without recursive=true', async () => { + const { fs, mesaFs } = createFs(); + mesaFs.stat.mockResolvedValueOnce(createStat({ isFile: false, isDirectory: true, size: 0 })); + mesaFs.readdirWithFileTypes.mockResolvedValueOnce([ + { name: 'file.txt', isFile: true, isDirectory: false, isSymbolicLink: false }, + ]); + + await expect(fs.rmdir('/acme/docs/not-empty')).rejects.toBeInstanceOf(DirectoryNotEmptyError); + expect(mesaFs.rm).not.toHaveBeenCalled(); + }); + + it('lists direct children and filters extensions', async () => { + const { fs, mesaFs } = createFs(); + mesaFs.readdirWithFileTypes.mockResolvedValueOnce([ + { name: 'README.md', isFile: true, isDirectory: false, isSymbolicLink: false }, + { name: 'index.ts', isFile: true, isDirectory: false, isSymbolicLink: false }, + { name: 'src', isFile: false, isDirectory: true, isSymbolicLink: false }, + ]); + + const entries = await fs.readdir('/acme/docs', { extension: '.ts' }); + + expect(entries).toEqual([ + { name: 'index.ts', type: 'file', size: 5 }, + { name: 'src', type: 'directory' }, + ]); + }); + + it('lists recursively with relative child names', async () => { + const { fs, mesaFs } = createFs(); + mesaFs.readdirWithFileTypes + .mockResolvedValueOnce([{ name: 'src', isFile: false, isDirectory: true, isSymbolicLink: false }]) + .mockResolvedValueOnce([{ name: 'index.ts', isFile: true, isDirectory: false, isSymbolicLink: false }]); + + const entries = await fs.readdir('/acme/docs', { recursive: true }); + + expect(entries).toEqual([ + { name: 'src', type: 'directory' }, + { name: 'src/index.ts', type: 'file', size: 5 }, + ]); + }); + + it('delegates exists and realpath', async () => { + const { fs, mesaFs } = createFs(); + mesaFs.exists.mockResolvedValueOnce(true); + mesaFs.realpath.mockResolvedValueOnce('/acme/docs/file.txt'); + + await expect(fs.exists('acme/docs/file.txt')).resolves.toBe(true); + await expect(fs.realpath('acme/docs/file.txt')).resolves.toBe('/acme/docs/file.txt'); + }); + + it('returns false when exists receives a Mesa not-found error', async () => { + const { fs, mesaFs } = createFs(); + mesaFs.exists.mockRejectedValueOnce(notFound('/acme/docs/missing.txt')); + + await expect(fs.exists('/acme/docs/missing.txt')).resolves.toBe(false); + }); + + it('maps stat results to Mastra FileStat', async () => { + const { fs, mesaFs } = createFs(); + mesaFs.stat.mockResolvedValueOnce(createStat({ size: 12 })); + + await expect(fs.stat('/acme/docs/file.txt')).resolves.toEqual({ + name: 'file.txt', + path: '/acme/docs/file.txt', + type: 'file', + size: 12, + createdAt: new Date('2025-06-01T00:00:00.000Z'), + modifiedAt: new Date('2025-06-01T00:00:00.000Z'), + }); + }); + }); + + describe('Mesa-specific operations', () => { + it('exposes Mesa bash', async () => { + const { fs, mesaFs } = createFs(); + + const bash = await fs.bash({ cwd: '/acme/docs' }); + + expect(bash).toEqual({ kind: 'bash' }); + expect(mesaFs.bash).toHaveBeenCalledWith({ cwd: '/acme/docs' }); + }); + + it('exposes Mesa change and bookmark operations', async () => { + const { fs, mesaFs } = createFs(); + + await fs.readFile('/acme/docs/README.md'); + + expect(fs.change).toBe(mesaFs.change); + expect(fs.bookmark).toBe(mesaFs.bookmark); + }); + }); + + describe('read-only mode', () => { + it.each([ + ['writeFile', (fs: MesaFilesystem) => fs.writeFile('/acme/docs/file.txt', 'data')], + ['appendFile', (fs: MesaFilesystem) => fs.appendFile('/acme/docs/file.txt', 'data')], + ['deleteFile', (fs: MesaFilesystem) => fs.deleteFile('/acme/docs/file.txt')], + ['copyFile', (fs: MesaFilesystem) => fs.copyFile('/acme/docs/file.txt', '/acme/docs/copy.txt')], + ['moveFile', (fs: MesaFilesystem) => fs.moveFile('/acme/docs/file.txt', '/acme/docs/moved.txt')], + ['mkdir', (fs: MesaFilesystem) => fs.mkdir('/acme/docs/new')], + ['rmdir', (fs: MesaFilesystem) => fs.rmdir('/acme/docs/old')], + ])('blocks %s', async (_name, operation) => { + const { fs } = createFs({ readOnly: true }); + + await expect(operation(fs)).rejects.toBeInstanceOf(WorkspaceReadOnlyError); + }); + }); +}); diff --git a/workspaces/mesa/src/filesystem/index.ts b/workspaces/mesa/src/filesystem/index.ts new file mode 100644 index 000000000000..68758482d295 --- /dev/null +++ b/workspaces/mesa/src/filesystem/index.ts @@ -0,0 +1,616 @@ +import path from 'node:path/posix'; + +import type { + CopyOptions, + FileContent, + FileEntry, + FilesystemInfo, + FileStat, + ListOptions, + MastraFilesystemOptions, + ProviderStatus, + ReadOptions, + RemoveOptions, + WriteOptions, +} from '@mastra/core/workspace'; +import { + DirectoryNotEmptyError, + DirectoryNotFoundError, + FileExistsError, + FileNotFoundError, + IsDirectoryError, + MastraFilesystem, + NotDirectoryError, + StaleFileError, + WorkspaceReadOnlyError, +} from '@mastra/core/workspace'; +import { Mesa } from '@mesadev/sdk'; +import type { Bash, MesaBashOptions, MesaFileSystem, MesaOptions, RepoConfig, TelemetryConfig } from '@mesadev/sdk'; + +type MesaFsStat = Awaited<ReturnType<MesaFileSystem['stat']>>; +type MesaDirent = Awaited<ReturnType<NonNullable<MesaFileSystem['readdirWithFileTypes']>>>[number]; + +export interface MesaFilesystemOptions extends MastraFilesystemOptions { + /** Mesa API key. Falls back to MESA_API_KEY when omitted. */ + apiKey?: string; + /** Block all write operations through the Mastra filesystem interface. */ + readOnly?: boolean; + /** Mesa org slug. Falls back to Mesa SDK org inference when omitted. */ + org?: string; + /** Mesa repos to mount. */ + repos: RepoConfig[]; + /** Mesa filesystem cache configuration. */ + cache?: { + diskCache?: { + path: string; + maxSizeBytes?: number; + }; + }; + /** Mesa mount token lifetime in seconds. */ + ttl?: number; + /** Mesa filesystem telemetry configuration. */ + telemetry?: TelemetryConfig; + /** Custom fetch implementation for Mesa API calls. */ + fetch?: MesaOptions['fetch']; + /** User agent for Mesa API calls. */ + userAgent?: string; +} + +function generateId(): string { + return `mesa-fs-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function normalizePath(inputPath: string): string { + return path.normalize(inputPath.startsWith('/') ? inputPath : `/${inputPath}`); +} + +function getExtension(name: string): string { + const dot = name.lastIndexOf('.'); + return dot === -1 ? '' : name.slice(dot); +} + +function matchesExtension(name: string, extensions?: string[]): boolean { + if (!extensions) return true; + const ext = getExtension(name); + return extensions.some(candidate => candidate === ext || candidate === ext.slice(1)); +} + +function toMesaContent(content: FileContent): string | Uint8Array { + if (typeof content === 'string') return content; + if (Buffer.isBuffer(content)) return new Uint8Array(content); + return content; +} + +type MesaErrorKind = 'notFound' | 'alreadyExists' | 'notDirectory' | 'isDirectory' | 'directoryNotEmpty'; + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function getMesaErrorKind(error: unknown): MesaErrorKind | undefined { + const err = + error && typeof error === 'object' ? (error as { code?: unknown; name?: unknown; message?: unknown }) : undefined; + const code = typeof err?.code === 'string' ? err.code : undefined; + const name = typeof err?.name === 'string' ? err.name : undefined; + + for (const value of [code, name]) { + switch (value) { + case 'ENOENT': + case 'NotFound': + case 'NoSuchFile': + case 'NoSuchKey': + return 'notFound'; + case 'EEXIST': + case 'AlreadyExists': + return 'alreadyExists'; + case 'ENOTDIR': + case 'NotDirectory': + return 'notDirectory'; + case 'EISDIR': + case 'IsDirectory': + return 'isDirectory'; + case 'ENOTEMPTY': + case 'DirectoryNotEmpty': + return 'directoryNotEmpty'; + } + } + + const message = + typeof err?.message === 'string' ? err.message : error instanceof Error ? error.message : String(error); + if (/\b(no such file|not found|enoent)\b/i.test(message)) return 'notFound'; + if (/\b(already exists|eexist)\b/i.test(message)) return 'alreadyExists'; + if (/\b(not a directory|enotdir)\b/i.test(message)) return 'notDirectory'; + if (/\b(is a directory|eisdir)\b/i.test(message)) return 'isDirectory'; + if (/\b(directory not empty|enotempty)\b/i.test(message)) return 'directoryNotEmpty'; + + return undefined; +} + +function mapMesaError(error: unknown, inputPath: string, context: 'file' | 'directory' = 'file'): Error { + switch (getMesaErrorKind(error)) { + case 'notFound': + return context === 'directory' ? new DirectoryNotFoundError(inputPath) : new FileNotFoundError(inputPath); + case 'alreadyExists': + return new FileExistsError(inputPath); + case 'notDirectory': + return new NotDirectoryError(inputPath); + case 'isDirectory': + return new IsDirectoryError(inputPath); + case 'directoryNotEmpty': + return new DirectoryNotEmptyError(inputPath); + default: + return toError(error); + } +} + +/** + * Workspace filesystem adapter backed by Mesa. + * + * This provider runs in the Mastra process and implements the workspace file + * API against Mesa repos. It does not mount Mesa into a sandbox. + */ +export class MesaFilesystem extends MastraFilesystem { + readonly id: string; + readonly name = 'MesaFilesystem'; + readonly provider = 'mesa'; + readonly readOnly?: boolean; + readonly icon = 'mesa'; + readonly displayName = 'Mesa'; + readonly description = 'Versioned Mesa filesystem for workspace files'; + + status: ProviderStatus = 'pending'; + + private readonly _apiKey?: string; + private readonly _org?: string; + private readonly _repos?: RepoConfig[]; + private readonly _cache?: { + diskCache?: { + path: string; + maxSizeBytes?: number; + }; + }; + private readonly _ttl?: number; + private readonly _telemetry?: TelemetryConfig; + private readonly _fetch?: MesaOptions['fetch']; + private readonly _userAgent?: string; + + private _mesa?: Mesa; + private _filesystem?: MesaFileSystem; + + constructor(options: MesaFilesystemOptions) { + super({ name: 'MesaFilesystem', ...options }); + + this.id = generateId(); + this.readOnly = options.readOnly; + this._org = options.org; + this._repos = options.repos; + this._apiKey = options.apiKey; + this._cache = options.cache; + this._ttl = options.ttl; + this._telemetry = options.telemetry; + this._fetch = options.fetch; + this._userAgent = options.userAgent; + } + + /** + * The active Mesa client, available after initialization when this instance + * created the client itself. + */ + get client(): Mesa | undefined { + return this._mesa; + } + + /** + * The active Mesa filesystem. Accessing this before initialization throws. + */ + get filesystem(): MesaFileSystem { + if (!this._filesystem) { + throw new Error('MesaFilesystem is not initialized. Call init() first or perform a filesystem operation.'); + } + return this._filesystem; + } + + override async init(): Promise<void> { + if (!this._repos || this._repos.length === 0) { + throw new Error('MesaFilesystem requires at least one repo.'); + } + + this._mesa = new Mesa({ + apiKey: this._apiKey, + org: this._org, + fetch: this._fetch, + userAgent: this._userAgent, + }); + + const repos = this.readOnly ? this._repos.map(repo => ({ ...repo, readOnly: true })) : this._repos; + this._filesystem = await this._mesa.fs.mount({ + repos, + cache: this._cache, + ttl: this._ttl, + telemetry: this._telemetry, + }); + } + + getInfo(): FilesystemInfo<{ + org?: string; + repos?: string[]; + mode: 'mounted' | 'client'; + }> { + return { + id: this.id, + name: this.name, + provider: this.provider, + status: this.status, + error: this.error, + readOnly: this.readOnly, + icon: this.icon, + metadata: { + ...(this._org && { org: this._org }), + ...(this._repos && { repos: this._repos.map(repo => repo.name) }), + mode: 'client', + }, + }; + } + + getInstructions(): string { + const parts = ['Mesa filesystem. Paths are rooted at the Mesa mount. Include the org and repo name in paths.']; + + if (this._org) { + parts.push(`Org: "${this._org}".`); + } else { + parts.push('Use the Mesa org resolved by the SDK as the first path segment.'); + } + + if (this._repos && this._repos.length > 0) { + const repoNames = this._repos.map(repo => `"${repo.name}"`).join(', '); + const firstRepo = this._repos[0]?.name ?? 'repo'; + const orgSegment = this._org ?? 'org'; + parts.push(`Mounted repos: ${repoNames}. For example "/${orgSegment}/${firstRepo}/file.txt".`); + } + + parts.push('Files are versioned by Mesa.'); + + if (this.readOnly) { + parts.push('Mounted read-only.'); + } + + return parts.join(' '); + } + + async readFile(inputPath: string, options?: ReadOptions): Promise<string | Buffer> { + await this.ensureReady(); + const target = normalizePath(inputPath); + + try { + const buffer = Buffer.from(await this.filesystem.readFileBuffer(target)); + if (options?.encoding) return buffer.toString(options.encoding); + return buffer; + } catch (error) { + throw mapMesaError(error, inputPath); + } + } + + async writeFile(inputPath: string, content: FileContent, options?: WriteOptions): Promise<void> { + await this.ensureReady(); + this.assertWritable('writeFile'); + const target = normalizePath(inputPath); + + if (options?.overwrite === false && (await this.exists(target))) { + throw new FileExistsError(inputPath); + } + + if (options?.expectedMtime) { + await this.assertExpectedMtime(inputPath, options.expectedMtime); + } + + if (options?.recursive === false) { + await this.assertParentDirectoryExists(target); + } else { + await this.ensureParentDirectory(target); + } + + try { + await this.filesystem.writeFile(target, toMesaContent(content)); + } catch (error) { + const mapped = mapMesaError(error, inputPath); + if (mapped instanceof NotDirectoryError) throw new NotDirectoryError(path.dirname(target)); + if (mapped instanceof FileNotFoundError) throw new DirectoryNotFoundError(path.dirname(target)); + throw mapped; + } + } + + async appendFile(inputPath: string, content: FileContent): Promise<void> { + await this.ensureReady(); + this.assertWritable('appendFile'); + const target = normalizePath(inputPath); + + await this.ensureParentDirectory(target); + + try { + await this.filesystem.appendFile(target, toMesaContent(content)); + } catch (error) { + const mapped = mapMesaError(error, inputPath); + if (mapped instanceof FileNotFoundError) throw new DirectoryNotFoundError(path.dirname(target)); + throw mapped; + } + } + + async deleteFile(inputPath: string, options?: RemoveOptions): Promise<void> { + await this.ensureReady(); + this.assertWritable('deleteFile'); + const target = normalizePath(inputPath); + + try { + const stats = await this.filesystem.stat(target); + if (stats.isDirectory) throw new IsDirectoryError(inputPath); + await this.filesystem.rm(target, { force: options?.force }); + } catch (error) { + if (error instanceof IsDirectoryError) throw error; + const mapped = mapMesaError(error, inputPath); + if (mapped instanceof FileNotFoundError) { + if (options?.force) return; + throw mapped; + } + throw mapped; + } + } + + async copyFile(src: string, dest: string, options?: CopyOptions): Promise<void> { + await this.ensureReady(); + this.assertWritable('copyFile'); + const source = normalizePath(src); + const target = normalizePath(dest); + + if (options?.overwrite === false && (await this.exists(target))) { + throw new FileExistsError(dest); + } + + await this.ensureParentDirectory(target); + + try { + await this.filesystem.cp(source, target, { recursive: options?.recursive }); + } catch (error) { + const mapped = mapMesaError(error, src); + if (mapped instanceof FileExistsError) throw new FileExistsError(dest); + throw mapped; + } + } + + async moveFile(src: string, dest: string, options?: CopyOptions): Promise<void> { + await this.ensureReady(); + this.assertWritable('moveFile'); + const source = normalizePath(src); + const target = normalizePath(dest); + + if (options?.overwrite === false && (await this.exists(target))) { + throw new FileExistsError(dest); + } + + await this.ensureParentDirectory(target); + + try { + await this.filesystem.mv(source, target); + } catch (error) { + const mapped = mapMesaError(error, src); + if (mapped instanceof FileExistsError) throw new FileExistsError(dest); + throw mapped; + } + } + + async mkdir(inputPath: string, options?: { recursive?: boolean }): Promise<void> { + await this.ensureReady(); + this.assertWritable('mkdir'); + const target = normalizePath(inputPath); + + try { + await this.filesystem.mkdir(target, { recursive: options?.recursive ?? true }); + } catch (error) { + const mapped = mapMesaError(error, inputPath); + if (mapped instanceof FileNotFoundError) throw new DirectoryNotFoundError(path.dirname(target)); + throw mapped; + } + } + + async rmdir(inputPath: string, options?: RemoveOptions): Promise<void> { + await this.ensureReady(); + this.assertWritable('rmdir'); + const target = normalizePath(inputPath); + + try { + const stats = await this.filesystem.stat(target); + if (!stats.isDirectory) throw new NotDirectoryError(inputPath); + + if (!options?.recursive) { + const entries = await this.filesystem.readdirWithFileTypes(target); + if (entries.length > 0) throw new DirectoryNotEmptyError(inputPath); + } + + await this.filesystem.rm(target, { recursive: options?.recursive ?? true, force: options?.force }); + } catch (error) { + if (error instanceof NotDirectoryError) throw error; + if (error instanceof DirectoryNotEmptyError) throw error; + const mapped = mapMesaError(error, inputPath, 'directory'); + if (mapped instanceof DirectoryNotFoundError) { + if (options?.force) return; + throw mapped; + } + throw mapped; + } + } + + async readdir(inputPath: string, options?: ListOptions): Promise<FileEntry[]> { + await this.ensureReady(); + const target = normalizePath(inputPath); + + try { + return await this.readDirectory(target, options); + } catch (error) { + throw mapMesaError(error, inputPath, 'directory'); + } + } + + async exists(inputPath: string): Promise<boolean> { + await this.ensureReady(); + const target = normalizePath(inputPath); + + try { + return await this.filesystem.exists(target); + } catch (error) { + if (getMesaErrorKind(error) === 'notFound') return false; + throw mapMesaError(error, inputPath); + } + } + + async stat(inputPath: string): Promise<FileStat> { + await this.ensureReady(); + const target = normalizePath(inputPath); + + try { + const stats = await this.filesystem.stat(target); + return this.toFileStat(target, stats); + } catch (error) { + throw mapMesaError(error, inputPath); + } + } + + async realpath(inputPath: string): Promise<string> { + await this.ensureReady(); + try { + return await this.filesystem.realpath(normalizePath(inputPath)); + } catch (error) { + throw mapMesaError(error, inputPath); + } + } + + /** + * Create a Mesa-backed Bash runtime for this filesystem. + */ + async bash(options?: MesaBashOptions): Promise<Bash> { + await this.ensureReady(); + return this.filesystem.bash(options); + } + + /** + * Mesa change management operations for the mounted filesystem. + */ + get change(): MesaFileSystem['change'] { + return this.filesystem.change; + } + + /** + * Mesa bookmark management operations for the mounted filesystem. + */ + get bookmark(): MesaFileSystem['bookmark'] { + return this.filesystem.bookmark; + } + + private assertWritable(operation: string): void { + if (this.readOnly) { + throw new WorkspaceReadOnlyError(operation); + } + } + + private async assertExpectedMtime(inputPath: string, expectedMtime: Date): Promise<void> { + try { + const currentStat = await this.stat(inputPath); + if (currentStat.modifiedAt.getTime() !== expectedMtime.getTime()) { + throw new StaleFileError(inputPath, expectedMtime, currentStat.modifiedAt); + } + } catch (error) { + if (error instanceof StaleFileError) throw error; + if (error instanceof FileNotFoundError) return; + throw error; + } + } + + private async ensureParentDirectory(inputPath: string): Promise<void> { + const parent = path.dirname(inputPath); + if (parent === '/' || parent === inputPath) return; + + try { + await this.filesystem.mkdir(parent, { recursive: true }); + } catch (error) { + const mapped = mapMesaError(error, parent, 'directory'); + if (!(mapped instanceof FileExistsError)) throw mapped; + } + } + + private async assertParentDirectoryExists(inputPath: string): Promise<void> { + const parent = path.dirname(inputPath); + if (parent === '/' || parent === inputPath) return; + + try { + const stats = await this.filesystem.stat(parent); + if (!stats.isDirectory) { + throw new NotDirectoryError(parent); + } + } catch (error) { + if (error instanceof NotDirectoryError) throw error; + throw mapMesaError(error, parent, 'directory'); + } + } + + private async readDirectory(inputPath: string, options?: ListOptions, depth = 0): Promise<FileEntry[]> { + const entries = await this.filesystem.readdirWithFileTypes(inputPath); + const extensions = options?.extension + ? Array.isArray(options.extension) + ? options.extension + : [options.extension] + : undefined; + + const result: FileEntry[] = []; + for (const entry of entries) { + const childPath = path.join(inputPath, entry.name); + if (entry.isFile) { + if (matchesExtension(entry.name, extensions)) { + const stat = await this.safeStat(childPath); + result.push({ name: entry.name, type: 'file', size: stat?.size }); + } + continue; + } + + if (entry.isDirectory) { + result.push({ name: entry.name, type: 'directory' }); + + if (options?.recursive && (options.maxDepth === undefined || depth < options.maxDepth)) { + const childEntries = await this.readDirectory(childPath, options, depth + 1); + result.push(...childEntries.map(child => ({ ...child, name: `${entry.name}/${child.name}` }))); + } + continue; + } + + result.push(this.toFileEntry(entry)); + } + + return result; + } + + private async safeStat(inputPath: string): Promise<MesaFsStat | undefined> { + try { + return await this.filesystem.stat(inputPath); + } catch { + return undefined; + } + } + + private toFileEntry(entry: MesaDirent): FileEntry { + return { + name: entry.name, + type: entry.isDirectory ? 'directory' : 'file', + isSymlink: entry.isSymbolicLink || undefined, + }; + } + + private toFileStat(inputPath: string, stats: MesaFsStat): FileStat { + const target = normalizePath(inputPath); + + return { + name: target === '/' ? '/' : path.basename(target), + path: target, + type: stats.isDirectory ? 'directory' : 'file', + size: stats.isDirectory ? 0 : stats.size, + createdAt: stats.mtime, + modifiedAt: stats.mtime, + }; + } +} diff --git a/workspaces/mesa/src/index.ts b/workspaces/mesa/src/index.ts new file mode 100644 index 000000000000..40d8a2aaf019 --- /dev/null +++ b/workspaces/mesa/src/index.ts @@ -0,0 +1,8 @@ +/** + * @mastra/mesa - Mesa Filesystem Provider + * + * A filesystem implementation backed by Mesa repos. + */ + +export { MesaFilesystem, type MesaFilesystemOptions } from './filesystem'; +export { mesaFilesystemProvider } from './provider'; diff --git a/workspaces/mesa/src/provider.test.ts b/workspaces/mesa/src/provider.test.ts new file mode 100644 index 000000000000..223ad76d50c9 --- /dev/null +++ b/workspaces/mesa/src/provider.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { MesaFilesystem } from './filesystem'; +import { mesaFilesystemProvider } from './provider'; + +describe('mesaFilesystemProvider', () => { + it('describes the Mesa filesystem provider', () => { + expect(mesaFilesystemProvider.id).toBe('mesa'); + expect(mesaFilesystemProvider.name).toBe('Mesa'); + expect(mesaFilesystemProvider.configSchema).toEqual( + expect.objectContaining({ + type: 'object', + required: ['repos'], + }), + ); + }); + + it('requires at least one repo in provider config', () => { + expect(mesaFilesystemProvider.configSchema.properties?.repos).toEqual( + expect.objectContaining({ + type: 'array', + minItems: 1, + }), + ); + }); + + it('creates MesaFilesystem instances', () => { + const filesystem = mesaFilesystemProvider.createFilesystem({ + repos: [{ name: 'docs', bookmark: 'main' }], + }); + + expect(filesystem).toBeInstanceOf(MesaFilesystem); + expect(filesystem.provider).toBe('mesa'); + }); +}); diff --git a/workspaces/mesa/src/provider.ts b/workspaces/mesa/src/provider.ts new file mode 100644 index 000000000000..e033a9c4d31c --- /dev/null +++ b/workspaces/mesa/src/provider.ts @@ -0,0 +1,40 @@ +/** + * Mesa filesystem provider descriptor for MastraEditor. + */ +import type { FilesystemProvider } from '@mastra/core/editor'; + +import { MesaFilesystem } from './filesystem'; +import type { MesaFilesystemOptions } from './filesystem'; + +export const mesaFilesystemProvider: FilesystemProvider<MesaFilesystemOptions> = { + id: 'mesa', + name: 'Mesa', + description: 'Versioned Mesa filesystem for workspace files', + configSchema: { + type: 'object', + required: ['repos'], + properties: { + apiKey: { type: 'string', description: 'Mesa API key. Falls back to MESA_API_KEY when omitted.' }, + org: { type: 'string', description: 'Mesa org slug' }, + repos: { + type: 'array', + description: 'Mesa repos to mount', + minItems: 1, + items: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', description: 'Mesa repo name' }, + bookmark: { type: 'string', description: 'Bookmark to mount' }, + changeId: { type: 'string', description: 'Change ID to mount' }, + readOnly: { type: 'boolean', description: 'Mount this repo as read-only' }, + }, + }, + }, + cache: { type: 'object', description: 'Mesa filesystem cache configuration' }, + ttl: { type: 'number', description: 'Mesa mount token lifetime in seconds' }, + readOnly: { type: 'boolean', description: 'Mount all repos as read-only', default: false }, + }, + }, + createFilesystem: config => new MesaFilesystem(config), +}; diff --git a/workspaces/mesa/tsconfig.build.json b/workspaces/mesa/tsconfig.build.json new file mode 100644 index 000000000000..e6d055d606f9 --- /dev/null +++ b/workspaces/mesa/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "**/*.test.ts", "**/*.integration.test.ts"] +} diff --git a/workspaces/mesa/tsconfig.json b/workspaces/mesa/tsconfig.json new file mode 100644 index 000000000000..e1195ebd755c --- /dev/null +++ b/workspaces/mesa/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.node.json", + "include": ["src/**/*", "tsup.config.ts"], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/workspaces/mesa/tsup.config.ts b/workspaces/mesa/tsup.config.ts new file mode 100644 index 000000000000..b255231ff8c7 --- /dev/null +++ b/workspaces/mesa/tsup.config.ts @@ -0,0 +1,18 @@ +import { generateTypes } from '@internal/types-builder'; +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + clean: true, + dts: false, + splitting: true, + treeshake: { + preset: 'smallest', + }, + sourcemap: true, + external: ['@mastra/core', '@mesadev/sdk'], + onSuccess: async () => { + await generateTypes(process.cwd()); + }, +}); diff --git a/workspaces/mesa/vitest.config.ts b/workspaces/mesa/vitest.config.ts new file mode 100644 index 000000000000..7e4f88bfb1bb --- /dev/null +++ b/workspaces/mesa/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + testTimeout: 30000, + coverage: { + reporter: ['text', 'json', 'html'], + }, + }, +}); diff --git a/workspaces/railway/CHANGELOG.md b/workspaces/railway/CHANGELOG.md index fbdfe97d3ee2..d770f5f1b16d 100644 --- a/workspaces/railway/CHANGELOG.md +++ b/workspaces/railway/CHANGELOG.md @@ -1,5 +1,14 @@ # @mastra/railway +## 0.2.1-alpha.0 + +### Patch Changes + +- Fixed Railway sandbox templates so they are built once during sandbox creation. ([#18605](https://github.com/mastra-ai/mastra/pull/18605)) + +- Updated dependencies [[`cdd5f93`](https://github.com/mastra-ai/mastra/commit/cdd5f939cefa67390629704dce92563ccbf492b2), [`1b8728a`](https://github.com/mastra-ai/mastra/commit/1b8728a57fd844205a452b0b4216d20ff60c784a), [`213feb8`](https://github.com/mastra-ai/mastra/commit/213feb87bfdd1d8ec00ea660e218f9bcfcb34e7b)]: + - @mastra/core@1.48.0-alpha.3 + ## 0.2.0 ### Minor Changes diff --git a/workspaces/railway/README.md b/workspaces/railway/README.md index 761b5590e55f..f6adbdde339e 100644 --- a/workspaces/railway/README.md +++ b/workspaces/railway/README.md @@ -69,8 +69,8 @@ const sandbox = new RailwaySandbox({ sandboxId: 'existing-railway-sandbox-id' }) ### Custom base image (templates) Pre-install packages and run setup steps so every sandbox starts ready. Pass a -builder callback over the Railway template builder — it's built once on the -first `start()`: +builder callback over the Railway template builder — Railway builds the image +when the sandbox is created during `start()`: ```typescript const sandbox = new RailwaySandbox({ @@ -78,8 +78,9 @@ const sandbox = new RailwaySandbox({ }); ``` -You can also pass a pre-built `SandboxTemplate` to reuse it across sandboxes -without rebuilding. Templates are ignored when `sandboxId` is set (reattach). +You can also pass a `SandboxTemplate` to reuse it across sandboxes. Railway +builds the image during sandbox creation. Templates are ignored when +`sandboxId` is set (reattach). ### Fork a running sandbox diff --git a/workspaces/railway/package.json b/workspaces/railway/package.json index 1db29070d95d..c904fd3eeb29 100644 --- a/workspaces/railway/package.json +++ b/workspaces/railway/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/railway", - "version": "0.2.0", + "version": "0.2.1-alpha.0", "description": "Railway cloud sandbox provider for Mastra workspaces", "type": "module", "main": "dist/index.js", diff --git a/workspaces/railway/src/sandbox/index.test.ts b/workspaces/railway/src/sandbox/index.test.ts index 34c2cbec26eb..4d558cb38088 100644 --- a/workspaces/railway/src/sandbox/index.test.ts +++ b/workspaces/railway/src/sandbox/index.test.ts @@ -192,7 +192,7 @@ describe('RailwaySandbox', () => { }); describe('template', () => { - it('builds a template from a builder callback and creates from it', async () => { + it('resolves a template from a builder callback and creates from it', async () => { const sandbox = new RailwaySandbox({ token: 'tok', template: t => t.withPackages('git', 'curl').run('npm i -g pnpm'), @@ -202,8 +202,8 @@ describe('RailwaySandbox', () => { expect(mockTemplateFactory).toHaveBeenCalledTimes(1); expect(mockTemplate.withPackages).toHaveBeenCalledWith('git', 'curl'); expect(mockTemplate.run).toHaveBeenCalledWith('npm i -g pnpm'); - expect(mockTemplate.build).toHaveBeenCalledTimes(1); - // create(template, options) + expect(mockTemplate.build).not.toHaveBeenCalled(); + // create(template, options) — Railway builds the template during create expect(mockCreate).toHaveBeenCalledWith(mockTemplate, expect.objectContaining({ token: 'tok' })); }); @@ -212,7 +212,7 @@ describe('RailwaySandbox', () => { await sandbox._start(); expect(mockTemplateFactory).not.toHaveBeenCalled(); - expect(mockTemplate.build).toHaveBeenCalledTimes(1); + expect(mockTemplate.build).not.toHaveBeenCalled(); expect(mockCreate).toHaveBeenCalledWith(mockTemplate, expect.objectContaining({ token: 'tok' })); }); @@ -225,6 +225,7 @@ describe('RailwaySandbox', () => { await sandbox._start(); expect(mockConnect).toHaveBeenCalledWith('rw-existing', expect.anything()); + expect(mockTemplateFactory).not.toHaveBeenCalled(); expect(mockTemplate.build).not.toHaveBeenCalled(); expect(mockCreate).not.toHaveBeenCalled(); }); diff --git a/workspaces/railway/src/sandbox/index.ts b/workspaces/railway/src/sandbox/index.ts index 25ccfc3dc4f8..6b45ae7b02df 100644 --- a/workspaces/railway/src/sandbox/index.ts +++ b/workspaces/railway/src/sandbox/index.ts @@ -61,13 +61,13 @@ export interface RailwaySandboxOptions extends Omit<MastraSandboxOptions, 'proce * every sandbox created from it starts ready. * * - Builder callback — receives the base `Sandbox.template()` and returns the - * configured template. The template is built (`.build()`) on first - * `start()` if not already built. + * configured template. Railway builds the template when `Sandbox.create()` + * runs during `start()`. * ```ts * template: t => t.withPackages('git', 'curl').run('npm i -g pnpm') * ``` - * - Pre-built `SandboxTemplate` — pass a template you built yourself to reuse - * it across sandboxes without rebuilding. + * - Pre-built `SandboxTemplate` — pass a template to reuse it across + * sandboxes. Railway still builds the image during sandbox creation. * * Ignored when `sandboxId` is set (reattach) or when forking. */ @@ -211,7 +211,7 @@ export class RailwaySandbox extends MastraSandbox { this.logger.debug(`${LOG_PREFIX} Reconnecting to Railway sandbox ${this._sandboxId}...`); this._sandbox = await Sandbox.connect(this._sandboxId, clientConfig); } else if (this._templateOption) { - const template = await this._resolveTemplate(clientConfig); + const template = this._resolveTemplate(); this.logger.debug(`${LOG_PREFIX} Creating Railway sandbox from template for: ${this.id}`); this._sandbox = await Sandbox.create(template, createOptions); } else { @@ -254,15 +254,13 @@ export class RailwaySandbox extends MastraSandbox { } /** - * Build the configured template into a ready-to-use base. Accepts either a - * pre-built `SandboxTemplate` or a builder callback over `Sandbox.template()`. - * Calls `.build()` so the recipe is materialised before `Sandbox.create()`. + * Resolve the configured template into a `SandboxTemplate` that Railway + * builds during `Sandbox.create()`. Accepts either a pre-built + * `SandboxTemplate` or a builder callback over `Sandbox.template()`. */ - private async _resolveTemplate(buildOptions: { token?: string; environmentId?: string }): Promise<SandboxTemplate> { + private _resolveTemplate(): SandboxTemplate { const option = this._templateOption!; - const template = typeof option === 'function' ? option(Sandbox.template()) : option; - this.logger.debug(`${LOG_PREFIX} Building Railway sandbox template for: ${this.id}`); - return template.build(buildOptions); + return typeof option === 'function' ? option(Sandbox.template()) : option; } /** diff --git a/workspaces/vercel/CHANGELOG.md b/workspaces/vercel/CHANGELOG.md index 078ff2add98c..8fb65bcf13ce 100644 --- a/workspaces/vercel/CHANGELOG.md +++ b/workspaces/vercel/CHANGELOG.md @@ -1,5 +1,59 @@ # @mastra/vercel +## 1.2.0-alpha.0 + +### Minor Changes + +- **Breaking change:** Renamed the Vercel sandbox exports to make the MicroVM and serverless implementations explicit. `VercelSandbox` now refers to the MicroVM-backed Vercel Sandbox product. The serverless implementation is now exported as `VercelServerlessSandbox`. ([#18667](https://github.com/mastra-ai/mastra/pull/18667)) + - If you have been using `VercelSandbox` in your code, you should update your imports to use `VercelServerlessSandbox` instead. + + ```diff + -import { VercelSandbox } from '@mastra/vercel'; + -import type { VercelSandboxOptions } from '@mastra/vercel'; + +import { VercelServerlessSandbox } from '@mastra/vercel'; + +import type { VercelServerlessSandboxOptions } from '@mastra/vercel'; + + -const sandbox = new VercelSandbox({ + +const sandbox = new VercelServerlessSandbox({ + token: process.env.VERCEL_TOKEN, + }); + + -const options: VercelSandboxOptions = { + +const options: VercelServerlessSandboxOptions = { + token: process.env.VERCEL_TOKEN, + }; + ``` + + - If you have been using `VercelMicroVMSandbox` in your code, you should update your imports to use `VercelSandbox` instead. + + ```diff + -import { VercelMicroVMSandbox } from '@mastra/vercel'; + +import { VercelSandbox } from '@mastra/vercel'; + -import type { VercelMicroVMSandboxOptions } from '@mastra/vercel'; + +import type { VercelSandboxOptions } from '@mastra/vercel'; + + -const sandbox = new VercelMicroVMSandbox(); + +const sandbox = new VercelSandbox(); + + -const options: VercelMicroVMSandboxOptions = { + +const options: VercelSandboxOptions = { + runtime: 'node24', + }; + ``` + + - Provider descriptors are also split by runtime: + + ```ts + import { vercelSandboxProvider, vercelServerlessSandboxProvider } from '@mastra/vercel'; + ``` + + Use `vercelSandboxProvider` for MicroVM-backed Vercel Sandbox instances and `vercelServerlessSandboxProvider` for Vercel Functions-backed serverless instances. + +### Patch Changes + +- Updated dependencies [[`b33c77d`](https://github.com/mastra-ai/mastra/commit/b33c77d5293f14a794f3ec38dc947a6676de2764), [`1009f77`](https://github.com/mastra-ai/mastra/commit/1009f772aa40016b49267c8566d0c29f6a16aa3c), [`23c31de`](https://github.com/mastra-ai/mastra/commit/23c31de96ed8153402dcf092ac84b27a0c3638c1), [`0368766`](https://github.com/mastra-ai/mastra/commit/0368766744c7ea3df4d6059e2cc15f7bdf55f5a6), [`2866f04`](https://github.com/mastra-ai/mastra/commit/2866f04953edb78c1637fa45cc53abe24122edcb)]: + - @mastra/core@1.48.0-alpha.6 + ## 1.1.1 ### Patch Changes diff --git a/workspaces/vercel/README.md b/workspaces/vercel/README.md new file mode 100644 index 000000000000..3ee65a18d677 --- /dev/null +++ b/workspaces/vercel/README.md @@ -0,0 +1,202 @@ +# @mastra/vercel + +Vercel workspace sandbox providers for Mastra. + +This package exposes two Vercel-backed sandbox implementations: + +- `VercelSandbox` runs commands in a Vercel Sandbox (MicroVM). +- `VercelServerlessSandbox` runs commands as stateless Vercel Functions. + +Use `VercelSandbox` when you need a Linux environment with a filesystem, exposed ports, or background processes. Use `VercelServerlessSandbox` when you need short-lived, stateless command execution backed by Vercel Functions. + +## Installation + +```bash +npm install @mastra/vercel +``` + +## VercelSandbox + +`VercelSandbox` executes commands inside [Vercel Sandbox](https://vercel.com/docs/sandbox), an ephemeral Firecracker MicroVM running Amazon Linux 2023. + +Use it when your agent or workflow needs: + +- a Linux filesystem for the sandbox session +- `sudo` access +- exposed ports for preview servers +- background processes +- command execution with streamed output + +```typescript +import { Workspace } from '@mastra/core/workspace'; +import { VercelSandbox } from '@mastra/vercel'; + +const workspace = new Workspace({ + sandbox: new VercelSandbox({ + runtime: 'node24', + timeout: 600_000, + }), +}); + +const result = await workspace.sandbox.executeCommand('node', ['--version']); +console.log(result.stdout); +``` + +### Authentication + +When Vercel OIDC is available, `VercelSandbox` can authenticate automatically. Outside an OIDC environment, provide Vercel credentials directly or through environment variables: + +```bash +export VERCEL_TOKEN="..." +export VERCEL_TEAM_ID="team_..." +export VERCEL_PROJECT_ID="prj_..." +``` + +```typescript +const sandbox = new VercelSandbox({ + token: process.env.VERCEL_TOKEN, + teamId: process.env.VERCEL_TEAM_ID, + projectId: process.env.VERCEL_PROJECT_ID, +}); +``` + +### Resources and exposed ports + +Use `resources.vcpus` to request CPU capacity and `ports` to expose services from the sandbox. + +```typescript +const sandbox = new VercelSandbox({ + resources: { vcpus: 4 }, + ports: [3000], +}); + +await sandbox.start(); +await sandbox.executeCommand('npm', ['run', 'dev'], { background: true }); + +const info = await sandbox.getInfo(); +console.log(info.metadata.domains); +``` + +Vercel supports up to 8 vCPUs and up to 4 exposed ports per sandbox. Memory is allocated at 2048 MB per vCPU. + +### Streaming command output + +```typescript +const result = await sandbox.executeCommand('npm', ['test'], { + onStdout: chunk => process.stdout.write(chunk), + onStderr: chunk => process.stderr.write(chunk), +}); + +console.log(result.exitCode); +``` + +### Background processes + +`VercelSandbox` includes `VercelSandboxProcessManager`, so you can start and manage background processes. + +```typescript +const process = await sandbox.processes.spawn('npm run dev'); + +console.log(process.pid); + +await process.kill(); +``` + +### Options + +| Option | Type | Default | Description | +| -------------- | -------------------------------------------------- | -------------------- | ------------------------------------------------------------- | +| `id` | `string` | generated | Unique identifier for this sandbox instance. | +| `sandboxName` | `string` | generated by Vercel | Optional sandbox name passed to the Vercel API. | +| `token` | `string` | `VERCEL_TOKEN` | Vercel API token. Omit when OIDC authentication is available. | +| `teamId` | `string` | `VERCEL_TEAM_ID` | Vercel team ID. | +| `projectId` | `string` | `VERCEL_PROJECT_ID` | Vercel project ID. | +| `runtime` | `'node24' \| 'node22' \| 'node26' \| 'python3.13'` | `'node24'` | Sandbox runtime. | +| `timeout` | `number` | `300_000` | Timeout in milliseconds before the sandbox auto-terminates. | +| `resources` | `{ vcpus?: number }` | `undefined` | Resources to allocate. | +| `ports` | `number[]` | `undefined` | Ports to expose from the sandbox. | +| `env` | `Record<string, string>` | `{}` | Default environment variables inherited by all commands. | +| `metadata` | `Record<string, unknown>` | `{}` | Custom metadata surfaced by `getInfo()`. | +| `instructions` | `string \| (opts) => string` | default instructions | Custom instructions returned by `getInstructions()`. | + +## VercelServerlessSandbox + +`VercelServerlessSandbox` executes commands as Vercel serverless Functions. It deploys an executor function and invokes it over HTTP. + +Use it when you want short-lived command execution without managing infrastructure. + +```typescript +import { Workspace } from '@mastra/core/workspace'; +import { VercelServerlessSandbox } from '@mastra/vercel'; + +const workspace = new Workspace({ + sandbox: new VercelServerlessSandbox({ + token: process.env.VERCEL_TOKEN, + teamId: process.env.VERCEL_TEAM_ID, + regions: ['iad1'], + maxDuration: 60, + memory: 1024, + }), +}); + +const result = await workspace.sandbox.executeCommand('node', ['--version']); +console.log(result.stdout); +``` + +### Authentication + +`VercelServerlessSandbox` requires a Vercel API token. Pass `token` or set `VERCEL_TOKEN`. + +```bash +export VERCEL_TOKEN="..." +``` + +```typescript +const sandbox = new VercelServerlessSandbox({ + token: process.env.VERCEL_TOKEN, + teamId: process.env.VERCEL_TEAM_ID, +}); +``` + +### Options + +| Option | Type | Default | Description | +| ---------------- | ---------------------------- | -------------------- | -------------------------------------------------------- | +| `token` | `string` | `VERCEL_TOKEN` | Vercel API token. | +| `teamId` | `string` | `undefined` | Vercel team ID for team-scoped deployments. | +| `projectName` | `string` | generated | Existing Vercel project name. Auto-generated if omitted. | +| `regions` | `string[]` | `['iad1']` | Deployment regions. | +| `maxDuration` | `number` | `60` | Function max duration in seconds. | +| `memory` | `number` | `1024` | Function memory in MB. | +| `env` | `Record<string, string>` | `{}` | Environment variables baked into the deployed function. | +| `commandTimeout` | `number` | `55_000` | Per-invocation command timeout in milliseconds. | +| `instructions` | `string \| (opts) => string` | default instructions | Custom instructions returned by `getInstructions()`. | + +### Limitations + +`VercelServerlessSandbox` is stateless. It doesn't provide: + +- a persistent filesystem +- an interactive shell +- long-running background processes +- mounted directories + +Only `/tmp` is writable during a function invocation. + +## Editor provider descriptors + +Use the provider descriptors when registering Vercel sandboxes with the Mastra editor. + +```typescript +import { MastraEditor } from '@mastra/core/editor'; +import { vercelSandboxProvider, vercelServerlessSandboxProvider } from '@mastra/vercel'; + +const editor = new MastraEditor({ + sandboxes: [vercelSandboxProvider, vercelServerlessSandboxProvider], +}); +``` + +| Provider | ID | Creates | +| --------------------------------- | ------------------- | ------------------------- | +| `vercelSandboxProvider` | `vercel-sandbox` | `VercelSandbox` | +| `vercelServerlessSandboxProvider` | `vercel-serverless` | `VercelServerlessSandbox` | diff --git a/workspaces/vercel/package.json b/workspaces/vercel/package.json index 728291ea5b69..2e793dc5b740 100644 --- a/workspaces/vercel/package.json +++ b/workspaces/vercel/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/vercel", - "version": "1.1.1", + "version": "1.2.0-alpha.0", "description": "Vercel serverless sandbox provider for Mastra workspaces", "type": "module", "main": "dist/index.js", diff --git a/workspaces/vercel/src/index.ts b/workspaces/vercel/src/index.ts index a7b1430c68bc..319c63561b30 100644 --- a/workspaces/vercel/src/index.ts +++ b/workspaces/vercel/src/index.ts @@ -1,8 +1,6 @@ -/** @deprecated Will be renamed to `VercelServerlessSandbox` in a future release. */ -export { VercelSandbox, type VercelSandboxOptions } from './sandbox'; -export { vercelSandboxProvider } from './provider'; +export { VercelServerlessSandbox, type VercelServerlessSandboxOptions } from './serverless'; +export { vercelServerlessSandboxProvider } from './serverless-provider'; -/** @deprecated Will be renamed to `VercelSandbox` in a future release. */ -export { VercelMicroVMSandbox, type VercelMicroVMSandboxOptions, type VercelMicroVMRuntime } from './microvm'; -export { VercelMicroVMProcessManager } from './microvm/process-manager'; -export { vercelMicroVMSandboxProvider } from './microvm-provider'; +export { VercelSandbox, type VercelSandboxOptions, type VercelSandboxRuntime } from './microvm'; +export { VercelSandboxProcessManager } from './microvm/process-manager'; +export { vercelSandboxProvider } from './microvm-provider'; diff --git a/workspaces/vercel/src/microvm-provider.ts b/workspaces/vercel/src/microvm-provider.ts index 7a25a4a06fc9..32ade689914e 100644 --- a/workspaces/vercel/src/microvm-provider.ts +++ b/workspaces/vercel/src/microvm-provider.ts @@ -3,20 +3,20 @@ * * @example * ```typescript - * import { vercelMicroVMSandboxProvider } from '@mastra/vercel'; + * import { vercelSandboxProvider } from '@mastra/vercel'; * * const editor = new MastraEditor({ - * sandboxes: [vercelMicroVMSandboxProvider], + * sandboxes: [vercelSandboxProvider], * }); * ``` */ import type { SandboxProvider } from '@mastra/core/editor'; -import { VercelMicroVMSandbox } from './microvm'; +import { VercelSandbox } from './microvm'; /** - * Serializable subset of VercelMicroVMSandboxOptions for editor storage. + * Serializable subset of VercelSandboxOptions for editor storage. */ -interface VercelMicroVMProviderConfig { +interface VercelSandboxProviderConfig { token?: string; teamId?: string; projectId?: string; @@ -27,8 +27,8 @@ interface VercelMicroVMProviderConfig { env?: Record<string, string>; } -export const vercelMicroVMSandboxProvider: SandboxProvider<VercelMicroVMProviderConfig> = { - id: 'vercel-microvm', +export const vercelSandboxProvider: SandboxProvider<VercelSandboxProviderConfig> = { + id: 'vercel-sandbox', name: 'Vercel Sandbox (MicroVM)', description: 'Ephemeral Firecracker MicroVM sandbox powered by Vercel Sandbox', configSchema: { @@ -58,7 +58,7 @@ export const vercelMicroVMSandboxProvider: SandboxProvider<VercelMicroVMProvider }, }, createSandbox: config => - new VercelMicroVMSandbox({ + new VercelSandbox({ token: config.token, teamId: config.teamId, projectId: config.projectId, diff --git a/workspaces/vercel/src/microvm/index.integration.test.ts b/workspaces/vercel/src/microvm/index.integration.test.ts index 2d8913e6d115..c5d9e3a7102a 100644 --- a/workspaces/vercel/src/microvm/index.integration.test.ts +++ b/workspaces/vercel/src/microvm/index.integration.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { VercelMicroVMSandbox } from './index'; +import { VercelSandbox } from './index'; // The @vercel/sandbox SDK authenticates via VERCEL_OIDC_TOKEN or the full // VERCEL_TOKEN/VERCEL_TEAM_ID/VERCEL_PROJECT_ID triple. Only run when one of @@ -11,11 +11,11 @@ const HAS_TOKEN_TRIPLE = Boolean( ); const HAS_CREDS = HAS_OIDC || HAS_TOKEN_TRIPLE; -describe.skipIf(!HAS_CREDS)('VercelMicroVMSandbox Integration', () => { - let sandbox: VercelMicroVMSandbox | undefined; +describe.skipIf(!HAS_CREDS)('VercelSandbox Integration', () => { + let sandbox: VercelSandbox | undefined; beforeAll(async () => { - sandbox = new VercelMicroVMSandbox({ + sandbox = new VercelSandbox({ ...(HAS_TOKEN_TRIPLE ? { token: process.env.VERCEL_TOKEN, diff --git a/workspaces/vercel/src/microvm/index.test.ts b/workspaces/vercel/src/microvm/index.test.ts index 52e61ad21b0a..fc218a073417 100644 --- a/workspaces/vercel/src/microvm/index.test.ts +++ b/workspaces/vercel/src/microvm/index.test.ts @@ -1,6 +1,6 @@ import { Workspace, createWorkspaceTools } from '@mastra/core/workspace'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { VercelMicroVMSandbox } from './index'; +import { VercelSandbox } from './index'; const createMock = vi.fn(); @@ -30,7 +30,7 @@ function makeFinished(exitCode: number, stdout: string, stderr = '') { }; } -describe('VercelMicroVMSandbox', () => { +describe('VercelSandbox', () => { beforeEach(() => { createMock.mockReset(); delete process.env.VERCEL_OIDC_TOKEN; @@ -45,11 +45,11 @@ describe('VercelMicroVMSandbox', () => { describe('constructor', () => { it('creates an instance with defaults', () => { - const sandbox = new VercelMicroVMSandbox(); - expect(sandbox.name).toBe('VercelMicroVMSandbox'); - expect(sandbox.provider).toBe('vercel-microvm'); + const sandbox = new VercelSandbox(); + expect(sandbox.name).toBe('VercelSandbox'); + expect(sandbox.provider).toBe('vercel-sandbox'); expect(sandbox.status).toBe('pending'); - expect(sandbox.id).toMatch(/^vercel-microvm-/); + expect(sandbox.id).toMatch(/^vercel-sandbox-/); expect(sandbox.processes).toBeDefined(); }); }); @@ -59,7 +59,7 @@ describe('VercelMicroVMSandbox', () => { const fake = makeFakeSandbox(); createMock.mockResolvedValue(fake); - const sandbox = new VercelMicroVMSandbox({ + const sandbox = new VercelSandbox({ runtime: 'node22', timeout: 600_000, resources: { vcpus: 4 }, @@ -85,7 +85,7 @@ describe('VercelMicroVMSandbox', () => { it('passes explicit credentials when all three are provided', async () => { createMock.mockResolvedValue(makeFakeSandbox()); - const sandbox = new VercelMicroVMSandbox({ + const sandbox = new VercelSandbox({ token: 't', teamId: 'team', projectId: 'proj', @@ -104,7 +104,7 @@ describe('VercelMicroVMSandbox', () => { process.env.VERCEL_PROJECT_ID = 'envproj'; createMock.mockResolvedValue(makeFakeSandbox()); - const sandbox = new VercelMicroVMSandbox(); + const sandbox = new VercelSandbox(); await sandbox._start(); const params = createMock.mock.calls[0]![0] as Record<string, unknown>; @@ -114,7 +114,7 @@ describe('VercelMicroVMSandbox', () => { }); it('throws when credentials are incomplete', async () => { - const sandbox = new VercelMicroVMSandbox({ token: 'only-token' }); + const sandbox = new VercelSandbox({ token: 'only-token' }); const error = await sandbox._start().catch(e => e); expect(error).toBeInstanceOf(Error); expect(error.message).toContain('Incomplete credentials'); @@ -123,7 +123,7 @@ describe('VercelMicroVMSandbox', () => { it('does not recreate the sandbox if already running', async () => { createMock.mockResolvedValue(makeFakeSandbox()); - const sandbox = new VercelMicroVMSandbox(); + const sandbox = new VercelSandbox(); await sandbox._start(); await sandbox._start(); expect(createMock).toHaveBeenCalledTimes(1); @@ -137,7 +137,7 @@ describe('VercelMicroVMSandbox', () => { }); createMock.mockResolvedValue(fake); - const sandbox = new VercelMicroVMSandbox(); + const sandbox = new VercelSandbox(); const result = await sandbox.executeCommand('echo', ['hello']); expect(result.success).toBe(true); @@ -156,7 +156,7 @@ describe('VercelMicroVMSandbox', () => { }); createMock.mockResolvedValue(fake); - const sandbox = new VercelMicroVMSandbox(); + const sandbox = new VercelSandbox(); const result = await sandbox.executeCommand('false'); expect(result.success).toBe(false); @@ -172,7 +172,7 @@ describe('VercelMicroVMSandbox', () => { const onStdout = vi.fn(); const onStderr = vi.fn(); - const sandbox = new VercelMicroVMSandbox(); + const sandbox = new VercelSandbox(); await sandbox.executeCommand('cmd', [], { onStdout, onStderr }); expect(onStdout).toHaveBeenCalledWith('out'); @@ -186,7 +186,7 @@ describe('VercelMicroVMSandbox', () => { }); createMock.mockResolvedValue(fake); - const sandbox = new VercelMicroVMSandbox(); + const sandbox = new VercelSandbox(); const result = await sandbox.executeCommand('sleep', ['100'], { timeout: 20 }); expect(result.timedOut).toBe(true); @@ -200,7 +200,7 @@ describe('VercelMicroVMSandbox', () => { }); createMock.mockResolvedValue(fake); - const sandbox = new VercelMicroVMSandbox({ env: { BASE: '1' } }); + const sandbox = new VercelSandbox({ env: { BASE: '1' } }); await sandbox.executeCommand('node', ['app.js'], { cwd: '/app', env: { EXTRA: '2' } }); const runArgs = (fake.runCommand as ReturnType<typeof vi.fn>).mock.calls[0]![0]; @@ -214,11 +214,11 @@ describe('VercelMicroVMSandbox', () => { const fake = makeFakeSandbox(); createMock.mockResolvedValue(fake); - const sandbox = new VercelMicroVMSandbox({ runtime: 'node24', timeout: 120_000, ports: [8080] }); + const sandbox = new VercelSandbox({ runtime: 'node24', timeout: 120_000, ports: [8080] }); await sandbox._start(); const info = sandbox.getInfo(); - expect(info.provider).toBe('vercel-microvm'); + expect(info.provider).toBe('vercel-sandbox'); expect(info.metadata?.runtime).toBe('node24'); expect(info.metadata?.timeout).toBe(120_000); expect(info.metadata?.domains).toEqual({ 8080: 'https://port-8080.vercel.run' }); @@ -227,7 +227,7 @@ describe('VercelMicroVMSandbox', () => { describe('getInstructions()', () => { it('returns default instructions describing the MicroVM', () => { - const sandbox = new VercelMicroVMSandbox(); + const sandbox = new VercelSandbox(); const text = sandbox.getInstructions!(); expect(text).toContain('Vercel Sandbox'); expect(text).toContain('Firecracker MicroVM'); @@ -235,12 +235,12 @@ describe('VercelMicroVMSandbox', () => { }); it('honors a string override', () => { - const sandbox = new VercelMicroVMSandbox({ instructions: 'custom only' }); + const sandbox = new VercelSandbox({ instructions: 'custom only' }); expect(sandbox.getInstructions!()).toBe('custom only'); }); it('honors a function override receiving defaults', () => { - const sandbox = new VercelMicroVMSandbox({ + const sandbox = new VercelSandbox({ instructions: ({ defaultInstructions }) => `${defaultInstructions}\nEXTRA`, }); const text = sandbox.getInstructions!(); @@ -251,7 +251,7 @@ describe('VercelMicroVMSandbox', () => { describe('WorkspaceSandbox conformance', () => { it('exposes sandbox tools when wired into a Workspace', async () => { - const sandbox = new VercelMicroVMSandbox(); + const sandbox = new VercelSandbox(); const workspace = new Workspace({ sandbox }); const tools = await createWorkspaceTools(workspace); @@ -266,7 +266,7 @@ describe('VercelMicroVMSandbox', () => { const fake = makeFakeSandbox(); createMock.mockResolvedValue(fake); - const sandbox = new VercelMicroVMSandbox(); + const sandbox = new VercelSandbox(); await sandbox._start(); await sandbox._stop(); @@ -278,7 +278,7 @@ describe('VercelMicroVMSandbox', () => { const fake = makeFakeSandbox(); createMock.mockResolvedValue(fake); - const sandbox = new VercelMicroVMSandbox(); + const sandbox = new VercelSandbox(); await sandbox._start(); await sandbox._destroy(); diff --git a/workspaces/vercel/src/microvm/index.ts b/workspaces/vercel/src/microvm/index.ts index b924bff2144a..7dedc7bf85d2 100644 --- a/workspaces/vercel/src/microvm/index.ts +++ b/workspaces/vercel/src/microvm/index.ts @@ -5,7 +5,7 @@ * Firecracker MicroVMs (Amazon Linux 2023) with a persistent in-session * filesystem, command execution, background processes, and exposed ports. * - * This is distinct from the `VercelSandbox` provider in this package, which + * This is distinct from the `VercelServerlessSandbox` provider in this package, which * runs commands as Vercel serverless Functions and is stateless. * * @see https://vercel.com/docs/vercel-sandbox @@ -22,14 +22,12 @@ import type { } from '@mastra/core/workspace'; import { MastraSandbox, SandboxNotReadyError } from '@mastra/core/workspace'; import { Sandbox } from '@vercel/sandbox'; -import { VercelMicroVMProcessManager } from './process-manager'; +import { VercelSandboxProcessManager } from './process-manager'; -const LOG_PREFIX = '[VercelMicroVMSandbox]'; - -let deprecatedNameWarned = false; +const LOG_PREFIX = '[VercelSandbox]'; /** Vercel Sandbox runtimes (default `node24`). */ -export type VercelMicroVMRuntime = 'node24' | 'node22' | 'node26' | 'python3.13'; +export type VercelSandboxRuntime = 'node24' | 'node22' | 'node26' | 'python3.13'; // ============================================================================= // Options @@ -43,7 +41,7 @@ export type VercelMicroVMRuntime = 'node24' | 'node22' | 'node26' | 'python3.13' * OIDC, supply `token`, `teamId`, and `projectId` together (falling back to * the `VERCEL_TOKEN`, `VERCEL_TEAM_ID`, and `VERCEL_PROJECT_ID` env vars). */ -export interface VercelMicroVMSandboxOptions extends Omit<MastraSandboxOptions, 'processes'> { +export interface VercelSandboxOptions extends Omit<MastraSandboxOptions, 'processes'> { /** Unique identifier for this sandbox instance. */ id?: string; /** Optional sandbox name passed to the Vercel API. Auto-generated if omitted. */ @@ -55,7 +53,7 @@ export interface VercelMicroVMSandboxOptions extends Omit<MastraSandboxOptions, /** Vercel project ID. Falls back to the `VERCEL_PROJECT_ID` env var. */ projectId?: string; /** Sandbox runtime. @default 'node24' */ - runtime?: VercelMicroVMRuntime; + runtime?: VercelSandboxRuntime; /** * Timeout in milliseconds before the sandbox auto-terminates. * @default 300_000 // 5 minutes @@ -91,24 +89,22 @@ export interface VercelMicroVMSandboxOptions extends Omit<MastraSandboxOptions, * @example Basic usage * ```typescript * import { Workspace } from '@mastra/core/workspace'; - * import { VercelMicroVMSandbox } from '@mastra/vercel'; + * import { VercelSandbox } from '@mastra/vercel'; * * const workspace = new Workspace({ - * sandbox: new VercelMicroVMSandbox({ runtime: 'node24', timeout: 600_000 }), + * sandbox: new VercelSandbox({ runtime: 'node24', timeout: 600_000 }), * }); * * const result = await workspace.sandbox.executeCommand('node', ['--version']); * ``` - * - * @deprecated Will be renamed to `VercelSandbox` in a future release. */ -export class VercelMicroVMSandbox extends MastraSandbox { +export class VercelSandbox extends MastraSandbox { readonly id: string; - readonly name = 'VercelMicroVMSandbox'; - readonly provider = 'vercel-microvm'; + readonly name = 'VercelSandbox'; + readonly provider = 'vercel-sandbox'; status: ProviderStatus = 'pending'; - declare readonly processes: VercelMicroVMProcessManager; + declare readonly processes: VercelSandboxProcessManager; private _sandbox: Sandbox | null = null; private _createdAt: Date | null = null; @@ -117,7 +113,7 @@ export class VercelMicroVMSandbox extends MastraSandbox { private readonly _token?: string; private readonly _teamId?: string; private readonly _projectId?: string; - private readonly _runtime: VercelMicroVMRuntime; + private readonly _runtime: VercelSandboxRuntime; private readonly _timeout: number; private readonly _vcpus?: number; private readonly _ports?: number[]; @@ -125,19 +121,14 @@ export class VercelMicroVMSandbox extends MastraSandbox { private readonly _metadata: Record<string, unknown>; private readonly _instructionsOverride?: InstructionsOption; - constructor(options: VercelMicroVMSandboxOptions = {}) { + constructor(options: VercelSandboxOptions = {}) { super({ ...options, - name: 'VercelMicroVMSandbox', - processes: new VercelMicroVMProcessManager({ env: options.env ?? {} }), + name: 'VercelSandbox', + processes: new VercelSandboxProcessManager({ env: options.env ?? {} }), }); - if (!deprecatedNameWarned) { - deprecatedNameWarned = true; - console.warn('VercelMicroVMSandbox will be renamed to VercelSandbox in a future release.'); - } - - this.id = options.id ?? `vercel-microvm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + this.id = options.id ?? `vercel-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; this._sandboxName = options.sandboxName; this._token = options.token ?? process.env.VERCEL_TOKEN; this._teamId = options.teamId ?? process.env.VERCEL_TEAM_ID; diff --git a/workspaces/vercel/src/microvm/process-manager.test.ts b/workspaces/vercel/src/microvm/process-manager.test.ts index 25c8f8d169cb..6ae75a2056d1 100644 --- a/workspaces/vercel/src/microvm/process-manager.test.ts +++ b/workspaces/vercel/src/microvm/process-manager.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { VercelMicroVMProcessManager } from './process-manager'; -import type { VercelMicroVMSandbox } from './index'; +import { VercelSandboxProcessManager } from './process-manager'; +import type { VercelSandbox } from './index'; /** * Build a fake detached @vercel/sandbox Command that emits the given logs and @@ -36,15 +36,15 @@ function makeFakeCommand(opts: { return { command, kill }; } -/** Build a fake VercelMicroVMSandbox exposing a sandbox with runCommand. */ +/** Build a fake VercelSandbox exposing a sandbox with runCommand. */ function makeSandboxStub(runCommand: ReturnType<typeof vi.fn>) { return { ensureRunning: vi.fn().mockResolvedValue(undefined), sandbox: { runCommand }, - } as unknown as VercelMicroVMSandbox; + } as unknown as VercelSandbox; } -describe('VercelMicroVMProcessManager', () => { +describe('VercelSandboxProcessManager', () => { it('spawns a detached command via sh -c and streams output', async () => { const { command } = makeFakeCommand({ cmdId: 'cmd-1', @@ -56,7 +56,7 @@ describe('VercelMicroVMProcessManager', () => { }); const runCommand = vi.fn().mockResolvedValue(command); - const pm = new VercelMicroVMProcessManager({ env: {} }); + const pm = new VercelSandboxProcessManager({ env: {} }); pm.sandbox = makeSandboxStub(runCommand); const handle = await pm.spawn('echo hello'); @@ -79,7 +79,7 @@ describe('VercelMicroVMProcessManager', () => { const { command } = makeFakeCommand({ cmdId: 'cmd-2', exitCode: 3 }); const runCommand = vi.fn().mockResolvedValue(command); - const pm = new VercelMicroVMProcessManager(); + const pm = new VercelSandboxProcessManager(); pm.sandbox = makeSandboxStub(runCommand); const handle = await pm.spawn('false'); @@ -92,7 +92,7 @@ describe('VercelMicroVMProcessManager', () => { const { command, kill } = makeFakeCommand({ cmdId: 'cmd-3' }); const runCommand = vi.fn().mockResolvedValue(command); - const pm = new VercelMicroVMProcessManager(); + const pm = new VercelSandboxProcessManager(); pm.sandbox = makeSandboxStub(runCommand); const handle = await pm.spawn('sleep 100'); @@ -105,7 +105,7 @@ describe('VercelMicroVMProcessManager', () => { const { command } = makeFakeCommand({ cmdId: 'cmd-4', exitCode: 0 }); const runCommand = vi.fn().mockResolvedValue(command); - const pm = new VercelMicroVMProcessManager({ env: { BASE: '1' } }); + const pm = new VercelSandboxProcessManager({ env: { BASE: '1' } }); pm.sandbox = makeSandboxStub(runCommand); await pm.spawn('node app.js', { cwd: '/app', env: { EXTRA: '2' } }); @@ -118,7 +118,7 @@ describe('VercelMicroVMProcessManager', () => { const { command } = makeFakeCommand({ cmdId: 'cmd-5', exitCode: 0 }); const runCommand = vi.fn().mockResolvedValue(command); - const pm = new VercelMicroVMProcessManager(); + const pm = new VercelSandboxProcessManager(); pm.sandbox = makeSandboxStub(runCommand); const handle = await pm.spawn('echo hi'); @@ -130,7 +130,7 @@ describe('VercelMicroVMProcessManager', () => { const { command } = makeFakeCommand({ cmdId: 'cmd-6', exitCode: 0 }); const runCommand = vi.fn().mockResolvedValue(command); - const pm = new VercelMicroVMProcessManager(); + const pm = new VercelSandboxProcessManager(); pm.sandbox = makeSandboxStub(runCommand); const handle = await pm.spawn('cat'); diff --git a/workspaces/vercel/src/microvm/process-manager.ts b/workspaces/vercel/src/microvm/process-manager.ts index 634e3b96a952..123815f891f3 100644 --- a/workspaces/vercel/src/microvm/process-manager.ts +++ b/workspaces/vercel/src/microvm/process-manager.ts @@ -12,7 +12,7 @@ import { ProcessHandle, SandboxProcessManager } from '@mastra/core/workspace'; import type { CommandResult, ProcessInfo, SpawnProcessOptions } from '@mastra/core/workspace'; import type { Command } from '@vercel/sandbox'; -import type { VercelMicroVMSandbox } from './index'; +import type { VercelSandbox } from './index'; // ============================================================================= // Process Handle @@ -22,7 +22,7 @@ import type { VercelMicroVMSandbox } from './index'; * Wraps a detached Vercel Sandbox {@link Command} to conform to Mastra's * ProcessHandle. Not exported — internal to this module. */ -class VercelMicroVMProcessHandle extends ProcessHandle { +class VercelSandboxProcessHandle extends ProcessHandle { readonly pid: string; private readonly _command: Command; @@ -132,7 +132,7 @@ class VercelMicroVMProcessHandle extends ProcessHandle { } async sendStdin(_data: string): Promise<void> { - throw new Error('VercelMicroVMSandbox does not support sending stdin to running processes.'); + throw new Error('VercelSandbox does not support sending stdin to running processes.'); } } @@ -140,7 +140,7 @@ class VercelMicroVMProcessHandle extends ProcessHandle { // Process Manager // ============================================================================= -export interface VercelMicroVMProcessManagerOptions { +export interface VercelSandboxProcessManagerOptions { env?: Record<string, string | undefined>; } @@ -148,7 +148,7 @@ export interface VercelMicroVMProcessManagerOptions { * Vercel Sandbox implementation of SandboxProcessManager. Uses one detached * `runCommand` per spawned process. */ -export class VercelMicroVMProcessManager extends SandboxProcessManager<VercelMicroVMSandbox> { +export class VercelSandboxProcessManager extends SandboxProcessManager<VercelSandbox> { async spawn(command: string, options: SpawnProcessOptions = {}): Promise<ProcessHandle> { const mergedEnv = { ...this.env, ...options.env }; const env = Object.fromEntries( @@ -165,7 +165,7 @@ export class VercelMicroVMProcessManager extends SandboxProcessManager<VercelMic detached: true, }); - const handle = new VercelMicroVMProcessHandle(cmd, Date.now(), options); + const handle = new VercelSandboxProcessHandle(cmd, Date.now(), options); const streamingPromise = (async () => { for await (const log of cmd.logs()) { diff --git a/workspaces/vercel/src/provider.ts b/workspaces/vercel/src/serverless-provider.ts similarity index 71% rename from workspaces/vercel/src/provider.ts rename to workspaces/vercel/src/serverless-provider.ts index 09727805842f..5034cf07f19f 100644 --- a/workspaces/vercel/src/provider.ts +++ b/workspaces/vercel/src/serverless-provider.ts @@ -1,20 +1,20 @@ /** - * Vercel sandbox provider descriptor for MastraEditor. + * Vercel serverless sandbox provider descriptor for MastraEditor. * * @example * ```typescript - * import { vercelSandboxProvider } from '@mastra/vercel'; + * import { vercelServerlessSandboxProvider } from '@mastra/vercel'; * * const editor = new MastraEditor({ - * sandboxes: [vercelSandboxProvider], + * sandboxes: [vercelServerlessSandboxProvider], * }); * ``` */ import type { SandboxProvider } from '@mastra/core/editor'; -import { VercelSandbox } from './sandbox'; +import { VercelServerlessSandbox } from './serverless'; /** - * Serializable subset of VercelSandboxOptions for editor storage. + * Serializable subset of VercelServerlessSandboxOptions for editor storage. */ interface VercelProviderConfig { token?: string; @@ -27,9 +27,9 @@ interface VercelProviderConfig { commandTimeout?: number; } -export const vercelSandboxProvider: SandboxProvider<VercelProviderConfig> = { - id: 'vercel', - name: 'Vercel Sandbox', +export const vercelServerlessSandboxProvider: SandboxProvider<VercelProviderConfig> = { + id: 'vercel-serverless', + name: 'Vercel Sandbox (Serverless)', description: 'Serverless sandbox powered by Vercel Functions', configSchema: { type: 'object', @@ -53,5 +53,5 @@ export const vercelSandboxProvider: SandboxProvider<VercelProviderConfig> = { commandTimeout: { type: 'number', description: 'Per-invocation timeout in ms', default: 55000 }, }, }, - createSandbox: config => new VercelSandbox(config), + createSandbox: config => new VercelServerlessSandbox(config), }; diff --git a/workspaces/vercel/src/sandbox/index.integration.test.ts b/workspaces/vercel/src/serverless/index.integration.test.ts similarity index 86% rename from workspaces/vercel/src/sandbox/index.integration.test.ts rename to workspaces/vercel/src/serverless/index.integration.test.ts index 58f8647e842b..e2ad881d5949 100644 --- a/workspaces/vercel/src/sandbox/index.integration.test.ts +++ b/workspaces/vercel/src/serverless/index.integration.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { VercelSandbox } from './index'; +import { VercelServerlessSandbox } from './index'; const VERCEL_TOKEN = process.env.VERCEL_TOKEN; -describe.skipIf(!VERCEL_TOKEN)('VercelSandbox Integration', () => { - let sandbox: VercelSandbox; +describe.skipIf(!VERCEL_TOKEN)('VercelServerlessSandbox Integration', () => { + let sandbox: VercelServerlessSandbox; beforeAll(async () => { - sandbox = new VercelSandbox({ + sandbox = new VercelServerlessSandbox({ token: VERCEL_TOKEN!, teamId: process.env.VERCEL_TEAM_ID, }); diff --git a/workspaces/vercel/src/sandbox/index.test.ts b/workspaces/vercel/src/serverless/index.test.ts similarity index 95% rename from workspaces/vercel/src/sandbox/index.test.ts rename to workspaces/vercel/src/serverless/index.test.ts index 3975e2481bb5..c52906cf2e4f 100644 --- a/workspaces/vercel/src/sandbox/index.test.ts +++ b/workspaces/vercel/src/serverless/index.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { getExecutorSource } from '../executor'; -import { VercelSandbox } from './index'; +import { VercelServerlessSandbox } from './index'; // Mock global fetch const mockFetch = vi.fn(); @@ -30,14 +30,14 @@ function createExecuteResponse(result: Record<string, unknown>) { * Start a sandbox with fake timers. The polling loop and retry backoff use * real setTimeout, so we need to flush timers while the promise is in-flight. */ -async function startWithTimers(sb: VercelSandbox) { +async function startWithTimers(sb: VercelServerlessSandbox) { const promise = sb._start(); await vi.runAllTimersAsync(); return promise; } -describe('VercelSandbox', () => { - let sandbox: VercelSandbox; +describe('VercelServerlessSandbox', () => { + let sandbox: VercelServerlessSandbox; beforeEach(() => { vi.useFakeTimers(); @@ -45,7 +45,7 @@ describe('VercelSandbox', () => { // vi.clearAllMocks only clears call history, leaking mockResolvedValueOnce // values into subsequent tests. mockFetch.mockReset(); - sandbox = new VercelSandbox({ token: 'test-token' }); + sandbox = new VercelServerlessSandbox({ token: 'test-token' }); }); afterEach(() => { @@ -54,10 +54,10 @@ describe('VercelSandbox', () => { describe('constructor', () => { it('should create instance with defaults', () => { - expect(sandbox.name).toBe('VercelSandbox'); - expect(sandbox.provider).toBe('vercel'); + expect(sandbox.name).toBe('VercelServerlessSandbox'); + expect(sandbox.provider).toBe('vercel-serverless'); expect(sandbox.status).toBe('pending'); - expect(sandbox.id).toMatch(/^vercel-sandbox-/); + expect(sandbox.id).toMatch(/^vercel-serverless-sandbox-/); }); }); @@ -94,7 +94,7 @@ describe('VercelSandbox', () => { }); it('should include vercel.json with correct functions and regions', async () => { - const customSandbox = new VercelSandbox({ + const customSandbox = new VercelServerlessSandbox({ token: 'test-token', memory: 512, maxDuration: 30, @@ -118,7 +118,7 @@ describe('VercelSandbox', () => { }); it('should throw if no token', async () => { - const noTokenSandbox = new VercelSandbox({ token: '' }); + const noTokenSandbox = new VercelServerlessSandbox({ token: '' }); const promise = noTokenSandbox._start().catch(e => e); await vi.runAllTimersAsync(); const error = await promise; @@ -153,7 +153,7 @@ describe('VercelSandbox', () => { }); it('should include teamId in request', async () => { - const teamSandbox = new VercelSandbox({ token: 'test-token', teamId: 'team-abc' }); + const teamSandbox = new VercelServerlessSandbox({ token: 'test-token', teamId: 'team-abc' }); mockFetch .mockResolvedValueOnce(createDeploymentResponse('dep-123', 'my-deploy.vercel.app', 'BUILDING')) @@ -300,7 +300,7 @@ describe('VercelSandbox', () => { describe('executeCommand()', () => { it('should auto-start on the first command', async () => { // Use a fresh sandbox — don't rely on the shared beforeEach start - const freshSandbox = new VercelSandbox({ token: 'test-token' }); + const freshSandbox = new VercelServerlessSandbox({ token: 'test-token' }); mockFetch .mockResolvedValueOnce(createDeploymentResponse('dep-123', 'my-deploy.vercel.app', 'BUILDING')) @@ -422,7 +422,7 @@ describe('VercelSandbox', () => { }); it('should throw if destroyed', async () => { - const freshSandbox = new VercelSandbox({ token: 'test-token' }); + const freshSandbox = new VercelServerlessSandbox({ token: 'test-token' }); // Force status to 'destroyed' to bypass ensureRunning auto-start (freshSandbox as any).status = 'destroyed'; await expect(freshSandbox.executeCommand('echo', ['hi'])).rejects.toThrow(/not ready/i); @@ -574,7 +574,7 @@ describe('VercelSandbox', () => { }); it('should be a no-op when never started', async () => { - const freshSandbox = new VercelSandbox({ token: 'test-token' }); + const freshSandbox = new VercelServerlessSandbox({ token: 'test-token' }); await freshSandbox._destroy(); @@ -593,7 +593,7 @@ describe('VercelSandbox', () => { }); it('should use string override', () => { - const customSandbox = new VercelSandbox({ + const customSandbox = new VercelServerlessSandbox({ token: 'test-token', instructions: 'Custom instructions', }); @@ -601,7 +601,7 @@ describe('VercelSandbox', () => { }); it('should use function override', () => { - const customSandbox = new VercelSandbox({ + const customSandbox = new VercelServerlessSandbox({ token: 'test-token', instructions: ({ defaultInstructions }) => `${defaultInstructions}\nExtra info.`, }); @@ -615,8 +615,8 @@ describe('VercelSandbox', () => { it('should return sandbox info', async () => { const info = await sandbox.getInfo!(); expect(info.id).toBe(sandbox.id); - expect(info.name).toBe('VercelSandbox'); - expect(info.provider).toBe('vercel'); + expect(info.name).toBe('VercelServerlessSandbox'); + expect(info.provider).toBe('vercel-serverless'); expect(info.metadata?.regions).toEqual(['iad1']); }); }); diff --git a/workspaces/vercel/src/sandbox/index.ts b/workspaces/vercel/src/serverless/index.ts similarity index 95% rename from workspaces/vercel/src/sandbox/index.ts rename to workspaces/vercel/src/serverless/index.ts index cd5b26bc3e11..61560318fa34 100644 --- a/workspaces/vercel/src/sandbox/index.ts +++ b/workspaces/vercel/src/serverless/index.ts @@ -1,5 +1,5 @@ /** - * Vercel Sandbox Provider + * Vercel Serverless Sandbox Provider * * Deploys code as Vercel serverless functions and executes commands * via HTTP invocation. Stateless — no persistent filesystem, no @@ -20,9 +20,7 @@ import type { import { MastraSandbox, SandboxNotReadyError } from '@mastra/core/workspace'; import { getExecutorSource } from '../executor'; -const LOG_PREFIX = '[VercelSandbox]'; - -let deprecatedNameWarned = false; +const LOG_PREFIX = '[VercelServerlessSandbox]'; const VERCEL_API_BASE = 'https://api.vercel.com'; @@ -40,7 +38,7 @@ function shellQuote(arg: string): string { // Options // ============================================================================= -export interface VercelSandboxOptions extends Omit<MastraSandboxOptions, 'processes'> { +export interface VercelServerlessSandboxOptions extends Omit<MastraSandboxOptions, 'processes'> { /** Vercel API token. Falls back to VERCEL_TOKEN env var. */ token?: string; /** Vercel team ID for team-scoped deployments. */ @@ -68,13 +66,10 @@ export interface VercelSandboxOptions extends Omit<MastraSandboxOptions, 'proces // Implementation // ============================================================================= -/** - * @deprecated Will be renamed to `VercelServerlessSandbox` in a future release. - */ -export class VercelSandbox extends MastraSandbox { +export class VercelServerlessSandbox extends MastraSandbox { readonly id: string; - readonly name = 'VercelSandbox'; - readonly provider = 'vercel'; + readonly name = 'VercelServerlessSandbox'; + readonly provider = 'vercel-serverless'; status: ProviderStatus = 'pending'; private readonly _token: string; @@ -93,15 +88,10 @@ export class VercelSandbox extends MastraSandbox { private _protectionBypass: string | null = null; private _createdAt: Date | null = null; - constructor(options: VercelSandboxOptions = {}) { - super({ name: 'VercelSandbox' }); - - if (!deprecatedNameWarned) { - deprecatedNameWarned = true; - console.warn('VercelSandbox will be renamed to VercelServerlessSandbox in a future release.'); - } + constructor(options: VercelServerlessSandboxOptions = {}) { + super({ name: 'VercelServerlessSandbox' }); - this.id = `vercel-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + this.id = `vercel-serverless-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; this._token = options.token || process.env.VERCEL_TOKEN || ''; this._teamId = options.teamId; this._projectName = options.projectName;