-
Notifications
You must be signed in to change notification settings - Fork 0
feat(test): projection-contract proof levels for the consumer harness (#103 stage 2) #190
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| --- | ||
| 'agent-bundle': patch | ||
| --- | ||
|
|
||
| Fix generated executables crashing on any route authored with JSX. | ||
|
|
||
| Route entries were bundled without the React plugin, so Rslib lowered JSX to | ||
| the classic `React.createElement` factory — which no generated entry or Flight | ||
| worker has in scope. Every documented `.tsx` route (the contract's own example | ||
| shape) therefore failed at run time with `React is not defined`, while builds | ||
| and route-unit tests stayed green because the test transform selects the | ||
| automatic runtime. Route entries now build with the automatic JSX runtime, so | ||
| emitted modules import `react/jsx-runtime` themselves — under the | ||
| `react-server` condition for worker entries. | ||
|
|
||
| The defect survived because every build-level test authored its routes with an | ||
| explicit `createElement` import; the generated-route server test now authors | ||
| its tool route as JSX instead, which is what surfaced this from the new | ||
| `packed-stdio` proof level. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| --- | ||
| 'agent-bundle': minor | ||
| --- | ||
|
|
||
| Add the projection-contract proof levels to `agent-bundle/test` (#103 stage 2). | ||
|
|
||
| Three levels join `route-unit`, each labeled in its result provenance and in | ||
| every failure message, because a pass at one level is never a receipt for | ||
| another: | ||
|
|
||
| - `mcp-in-memory` — `openInMemoryMcpServer`, `invokeMcpTool`, `readMcpResource`, | ||
| `getMcpPrompt`, and `listMcpSurface` drive the real generated MCP server with | ||
| a real MCP client over the SDK's in-memory transport pair. Protocol-contract | ||
| proof only: no process, no stdio framing, no packed artifact. | ||
| - `cli-dispatch` — `invokeCli` runs an argv vector through the routed CLI's own | ||
| shell (#102 stage 2) over the compiled command graph the manifest now | ||
| carries, in-process. Command resolution, argv projection, help, `--version`, | ||
| and the exit-code policy are the product's; the harness supplies only the | ||
| `execute` bridge, and it mirrors the one the generated executable inlines. | ||
| `cliJson` reads the canonical stdout line. | ||
| - `packed-stdio` — `openPackedMcpServer` spawns a built artifact's generated | ||
| stdio entry and connects a real MCP client to it. This is the only level here | ||
| that is process evidence. | ||
|
|
||
| `renderRouteEvents` returns the ordered render-event stream alongside the final | ||
| document, and `expectEvents` asserts over it. The default matcher | ||
| (`toContainSequence`) is sequence-tolerant so a legitimate extra `progress` or | ||
| `replace` frame cannot turn a passing render red, while a missing frame, a | ||
| reordering, or a regressed ordinal still fails. | ||
|
|
||
| The test manifest gains `cliCommands`, the compiled routed-CLI command graph | ||
| from the same compiler pass, so the dispatch level never recompiles it. | ||
| `expectDocument` gains `toContainContext` for the context nodes an event route | ||
| returns to its host. | ||
|
|
||
| Event routes now render with the props the public contract defines — | ||
| `{ canonical, native, signal }`, the same unwrapping the generated Flight | ||
| worker performs — instead of the raw invocation payload. A route written | ||
| against `AgentEventRouteProps` previously received `undefined` for both. | ||
|
|
||
| Internally, the generated MCP server's warm Flight host, route registration, | ||
| and MCP projection move out of the entry template into the shared | ||
| `agent-bundle/mcp-server-runtime` module the generated entry aliases, so the | ||
| in-memory level exercises the artifact's own code rather than a second copy of | ||
| it. Generated-entry behaviour is unchanged. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { defineConfig } from '@rstest/core'; | ||
| import { agentBundleRstest } from 'agent-bundle/rstest'; | ||
|
|
||
| /** | ||
| * The framework-generated route-unit configuration. One Agent Bundle compiler | ||
| * pass runs here — no artifact build — and it supplies the route manifest, the | ||
| * TypeScript transform, and the React Server Components conditions the demo's | ||
| * event route needs. The example maintains none of that by hand. | ||
| */ | ||
| export default defineConfig(await agentBundleRstest()); |
68 changes: 68 additions & 0 deletions
68
examples/rsc-agent-runtime/tests/route-unit/event-route.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| import { mkdtemp, readFile, rm } from 'node:fs/promises'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join, resolve } from 'node:path'; | ||
|
|
||
| import { afterEach, beforeEach, expect, it } from '@rstest/core'; | ||
| import { expectDocument, renderRoute, testManifest } from 'agent-bundle/test'; | ||
|
|
||
| /** | ||
| * The route-unit proof level for the demo's PostToolUse migration: the hook is | ||
| * a compiled `src/events/tool/after.tsx` route, and it renders through the same | ||
| * renderer and request scope every other route uses. Native wrapper delivery | ||
| * and the host response projection are proven by the artifact suites; this is | ||
| * not host or process evidence. | ||
| */ | ||
| const manifest = testManifest(); | ||
| const fixture = resolve(import.meta.dirname, '../fixtures/events/claude-post-tool-use.json'); | ||
|
|
||
| let workspace: string; | ||
| let previousStateFile: string | undefined; | ||
|
|
||
| beforeEach(async () => { | ||
| workspace = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-event-route-')); | ||
| previousStateFile = process.env.AGENT_RUNTIME_STATE_FILE; | ||
| process.env.AGENT_RUNTIME_STATE_FILE = join(workspace, 'state.json'); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| if (previousStateFile === undefined) delete process.env.AGENT_RUNTIME_STATE_FILE; | ||
| else process.env.AGENT_RUNTIME_STATE_FILE = previousStateFile; | ||
| await rm(workspace, { force: true, recursive: true }); | ||
| }); | ||
|
|
||
| it('compiles the PostToolUse hook as a real event route rather than configuration', () => { | ||
| expect(manifest.proofLevel).toBe('route-unit'); | ||
| expect(manifest.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); | ||
| expect(manifest.routes['event:tool/after']).toMatchObject({ | ||
| kind: 'event-route', | ||
| relativePath: 'src/events/tool/after.tsx', | ||
| }); | ||
| }); | ||
|
|
||
| it('renders a native Claude PostToolUse envelope into the document the host projects from', async () => { | ||
| const native = JSON.parse(await readFile(fixture, 'utf8')) as Record<string, unknown>; | ||
| const rendered = await renderRoute('event:tool/after', { | ||
| input: { | ||
| canonical: { | ||
| event: 'tool/after', | ||
| idempotencyKey: 'route-unit-claude-write', | ||
| observedAt: '2026-09-01T00:00:00.000Z', | ||
| provenance: { | ||
| host: 'claude', | ||
| hostContractRevision: 'route-unit', | ||
| nativeEvent: 'PostToolUse', | ||
| source: 'native', | ||
| }, | ||
| sequence: 1, | ||
| }, | ||
| native: { ...native, cwd: workspace }, | ||
| }, | ||
| }); | ||
|
|
||
| expect(rendered.invocation.kind).toBe('event'); | ||
| expectDocument(rendered) | ||
| .toHaveStatus('success') | ||
| .toHaveNodeKinds(['result', 'context']) | ||
| .toContainContext('Recorded claude-note.txt from claude. Shared state now contains 1 edit.'); | ||
| expect(rendered.provenance).toMatchObject({ kind: 'event-route', proofLevel: 'route-unit' }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
29 changes: 29 additions & 0 deletions
29
packages/agent-bundle/fixtures/route-harness/src/cli/db/migrate.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; | ||
| import { z } from 'zod'; | ||
|
|
||
| /** | ||
| * Nested one level below the CLI root, so the dispatch level exercises path | ||
| * nesting (`db migrate`) rather than a single flat command, and carries the | ||
| * `result` exit-code policy so the harness proves that mapping too. | ||
| */ | ||
| export const config = { | ||
| description: 'Applies pending harness migrations.', | ||
| exitCode: 'result', | ||
| } satisfies CliRouteConfig; | ||
|
|
||
| export const inputSchema = z.object({ | ||
| dryRun: z.boolean().default(false), | ||
| }).strict(); | ||
|
|
||
| export const resultSchema = z.object({ | ||
| applied: z.number().int(), | ||
| dryRun: z.boolean(), | ||
| exitCode: z.number().int(), | ||
| }).strict(); | ||
|
|
||
| export default async function migrate({ input }: CliRouteProps<typeof inputSchema>) { | ||
| // A dry run reports pending work and exits non-zero without applying it. | ||
| return input.dryRun | ||
| ? { applied: 0, dryRun: true, exitCode: 3 } | ||
| : { applied: 2, dryRun: false, exitCode: 0 }; | ||
| } |
34 changes: 34 additions & 0 deletions
34
packages/agent-bundle/fixtures/route-harness/src/cli/inventory.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import { agent } from '@agent-bundle/runtime'; | ||
| import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; | ||
| import { z } from 'zod'; | ||
|
|
||
| export const config = { | ||
| aliases: ['inv'], | ||
| description: 'Lists the harness library inventory.', | ||
| positionals: ['shelf'], | ||
| } satisfies CliRouteConfig; | ||
|
|
||
| export const inputSchema = z.object({ | ||
| format: z.enum(['json', 'text']).default('text'), | ||
| limit: z.number().int().min(1).max(8).optional(), | ||
| shelf: z.string().min(1), | ||
| }).strict(); | ||
|
|
||
| export const resultSchema = z.object({ | ||
| format: z.string(), | ||
| shelf: z.string(), | ||
| titles: z.array(z.string()), | ||
| }).strict(); | ||
|
|
||
| const shelves: Readonly<Record<string, readonly string[]>> = { | ||
| fiction: ['Piranesi', 'Solaris'], | ||
| history: ['SPQR'], | ||
| }; | ||
|
|
||
| export default async function inventory({ input }: CliRouteProps<typeof inputSchema>) { | ||
| const context = await agent(); | ||
| await context.progress.report({ completed: 1, message: 'reading inventory', total: 2 }); | ||
| const titles = (shelves[input.shelf] ?? []).slice(0, input.limit ?? 8); | ||
| await context.progress.report({ completed: 2, message: 'inventory ready', total: 2 }); | ||
| return { format: input.format, shelf: input.shelf, titles: [...titles] }; | ||
| } |
12 changes: 9 additions & 3 deletions
12
packages/agent-bundle/fixtures/route-harness/src/events/tool/after.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,16 @@ | ||
| import { Agent, agent } from '@agent-bundle/runtime'; | ||
| import type { AgentEventRouteProps } from 'agent-bundle'; | ||
|
|
||
| export default async function AfterTool({ event, payload }: { readonly event: string; readonly payload: unknown }) { | ||
| export default async function AfterTool({ canonical, native }: AgentEventRouteProps) { | ||
| const context = await agent(); | ||
| return ( | ||
| <Agent.Result value={{ event, invocationKind: context.invocation.kind, payload: payload as never }}> | ||
| <Agent.Markdown>{`Observed ${event}.`}</Agent.Markdown> | ||
| <Agent.Result value={{ | ||
| event: canonical.event, | ||
| invocationKind: context.invocation.kind, | ||
| tool: typeof native['tool_name'] === 'string' ? native['tool_name'] : 'unknown', | ||
| }} | ||
| > | ||
| <Agent.Markdown>{`Observed ${canonical.event} from ${canonical.provenance.host}.`}</Agent.Markdown> | ||
| </Agent.Result> | ||
| ); | ||
| } |
23 changes: 23 additions & 0 deletions
23
packages/agent-bundle/fixtures/route-harness/src/mcp/harness/prompts/summarize.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import { Agent } from '@agent-bundle/runtime'; | ||
| import { z } from 'zod'; | ||
|
|
||
| export const config = { description: 'Summarizes one harness note.', title: 'Summarize' }; | ||
|
|
||
| export const inputSchema = z.object({ note: z.string() }); | ||
|
|
||
| /** The generated server returns a prompt route's result as the protocol's `GetPromptResult`. */ | ||
| export const resultSchema = z.object({ | ||
| messages: z.array(z.object({ | ||
| content: z.object({ text: z.string(), type: z.literal('text') }), | ||
| role: z.literal('user'), | ||
| })), | ||
| }); | ||
|
|
||
| export default async function Summarize({ input }: { readonly input: z.infer<typeof inputSchema> }) { | ||
| const messages = [{ content: { text: `Summarize ${input.note}`, type: 'text' as const }, role: 'user' as const }]; | ||
| return ( | ||
| <Agent.Result value={{ messages }}> | ||
| <Agent.Text>{`prompt ready for ${input.note}`}</Agent.Text> | ||
| </Agent.Result> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
34 changes: 34 additions & 0 deletions
34
packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/catalog.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import { Agent } from '@agent-bundle/runtime'; | ||
| import { Suspense } from 'react'; | ||
| import { z } from 'zod'; | ||
|
|
||
| export const config = { | ||
| description: 'Streams the harness catalog behind one Suspense boundary.', | ||
| title: 'Catalog', | ||
| }; | ||
|
|
||
| export const inputSchema = z.object({ genre: z.string().optional() }); | ||
|
|
||
| export const resultSchema = z.object({ genre: z.string(), titles: z.array(z.string()) }); | ||
|
|
||
| const titles = ['Piranesi', 'Solaris']; | ||
|
|
||
| /** Resolves after the shell, so the render has a boundary to replace. */ | ||
| const Titles = async ({ genre }: { readonly genre: string }) => { | ||
| await new Promise<void>((resolve) => { | ||
| setTimeout(resolve, 1); | ||
| }); | ||
| return <Agent.Markdown>{`## ${genre}\n\n${titles.map((title) => `- ${title}`).join('\n')}`}</Agent.Markdown>; | ||
| }; | ||
|
|
||
| export default async function Catalog({ input }: { readonly input: z.infer<typeof inputSchema> }) { | ||
| const genre = input.genre ?? 'all'; | ||
| return ( | ||
| <Agent.Result value={{ genre, titles }}> | ||
| <Agent.Text>{`catalog: ${genre}`}</Agent.Text> | ||
| <Suspense fallback={<Agent.Progress completed={0} message={`loading ${genre}`} total={titles.length} />}> | ||
| <Titles genre={genre} /> | ||
| </Suspense> | ||
| </Agent.Result> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The newly documented example cannot type-check because
invokeMcpToolreturnsMcpToolInvocation, whosestructuredContentfield is directly oncall; there is noresultproperty. Users copying the primary example for this new public helper receiveProperty 'result' does not exist, so the assertion should usecall.structuredContent.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in #206 (merged as 7dbfacf): the README example now reads call.structuredContent, matching McpToolInvocation.