diff --git a/.changeset/post-merge-p2-fixes.md b/.changeset/post-merge-p2-fixes.md new file mode 100644 index 000000000..3f9c1335b --- /dev/null +++ b/.changeset/post-merge-p2-fixes.md @@ -0,0 +1,9 @@ +--- +"agent-bundle": patch +"create-agent-bundle": patch +--- + +Reject the runtime's reserved notice-ledger state id during extraction, mount +each missing route-unit binding independently when the caller overrides only +one of state or noticeLedger, and document `zod` in the cli-tool migration +steps. diff --git a/packages/agent-bundle/src/config/state-extract.ts b/packages/agent-bundle/src/config/state-extract.ts index 16351e928..0263a9b55 100644 --- a/packages/agent-bundle/src/config/state-extract.ts +++ b/packages/agent-bundle/src/config/state-extract.ts @@ -6,13 +6,16 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import { deepFreeze } from '../core/freeze.ts'; import type { NormalizedStateDefinition } from '../core/types.ts'; +/** Reserved by the generated runtime for the internal notice ledger store. */ +const AGENT_NOTICE_LEDGER_STATE_ID = '@agent-bundle/runtime/agent-notice-ledger/v1'; + export interface ExtractedStateDefinition { readonly definition?: Pick; readonly diagnostics: readonly Diagnostic[]; } const diagnostic = ( - code: 'AB4818' | 'AB4819' | 'AB4820', + code: 'AB4818' | 'AB4819' | 'AB4820' | 'AB4821', message: string, recovery: string, sourcePath: string, @@ -108,6 +111,16 @@ export const extractStateDefinition = ( )], }); } + if (id === AGENT_NOTICE_LEDGER_STATE_ID) { + return deepFreeze({ + diagnostics: [diagnostic( + 'AB4821', + `State module ${relativePath} uses the reserved notice-ledger id ${AGENT_NOTICE_LEDGER_STATE_ID}.`, + 'Choose a project-scoped state id; the generated runtime owns the notice ledger store under that id.', + sourcePath, + )], + }); + } return deepFreeze({ definition: { id, lifetime }, diagnostics: [], diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index d6a219e34..f8de90c0b 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -444,8 +444,7 @@ const mountManifestState = async ( if ( manifest === undefined || descriptor === undefined - || context.state !== undefined - || context.noticeLedger !== undefined + || (context.state !== undefined && context.noticeLedger !== undefined) ) return noMountedState; const loader = registeredStateLoader(manifest); if (loader === undefined) { @@ -478,8 +477,8 @@ const mountManifestState = async ( let closed = false; return Object.freeze({ context: { - noticeLedger: bindings.noticeLedger, - state: bindings.state, + ...(context.noticeLedger === undefined ? { noticeLedger: bindings.noticeLedger } : {}), + ...(context.state === undefined ? { state: bindings.state } : {}), }, async close() { if (closed) return; diff --git a/packages/agent-bundle/tests/route-unit/render-route.test.ts b/packages/agent-bundle/tests/route-unit/render-route.test.ts index 7133634c2..70c62b8d6 100644 --- a/packages/agent-bundle/tests/route-unit/render-route.test.ts +++ b/packages/agent-bundle/tests/route-unit/render-route.test.ts @@ -101,6 +101,47 @@ describe('renderRoute through the real renderer', () => { }); }); + it('auto-mounts noticeLedger when the caller supplied state alone', async () => { + const state = { + lifetime: 'workspace-durable', + changes: async function*() {}, + dispatch: async () => ({ replayed: false, revision: 0, state: { entries: [] } }), + read: async () => ({ revision: 0, state: { entries: [] } }), + } as never; + const rendered = await renderRoute('tool:harness/publish-notice', { + context: { state }, + input: { message: 'partial mount', recipientSession: 'sess-a' }, + }); + + expectDocument(rendered).toHaveStatus('success'); + expect(rendered.result).toMatchObject({ state: 'pending' }); + expect(typeof (rendered.result as { noticeId: unknown }).noticeId).toBe('string'); + }); + + it('auto-mounts state when the caller supplied noticeLedger alone', async () => { + const noticeLedger = { + expire: async () => ({ notices: [] }), + openRequest: async () => Object.freeze({ + close: () => undefined, + handle: Object.freeze({ + publish: async () => { throw new Error('unused in journal route'); }, + read: async () => ({ notices: [] }), + }), + }), + read: async () => ({ notices: [] }), + withdraw: async () => ({ notices: [] }), + } as never; + const rendered = await renderRoute('tool:harness/journal', { + context: { noticeLedger }, + input: { note: 'partial mount' }, + }); + + expectDocument(rendered).toHaveValue({ + entries: [{ note: 'partial mount' }], + revision: 1, + }); + }); + it('records progress even when the caller supplies its own reporter', async () => { const delegated: unknown[] = []; const rendered = await renderRoute('tool:harness/echo', { diff --git a/packages/agent-bundle/tests/state-definition-extract.test.ts b/packages/agent-bundle/tests/state-definition-extract.test.ts index a1788566d..071a4035b 100644 --- a/packages/agent-bundle/tests/state-definition-extract.test.ts +++ b/packages/agent-bundle/tests/state-definition-extract.test.ts @@ -47,4 +47,16 @@ describe('state definition extraction', () => { expect(result.diagnostics[0]).toMatchObject({ code: 'AB4820', severity: 'error' }); expect(result.diagnostics[0]!.message).toContain('request, process, and workspace-durable'); }); + + it('rejects the reserved notice-ledger state id', () => { + const result = extract([ + 'export default defineState({', + " id: '@agent-bundle/runtime/agent-notice-ledger/v1',", + " lifetime: 'workspace-durable',", + '});', + ].join('\n')); + expect(result.definition).toBeUndefined(); + expect(result.diagnostics[0]).toMatchObject({ code: 'AB4821', severity: 'error' }); + expect(result.diagnostics[0]!.message).toContain('notice-ledger'); + }); }); diff --git a/packages/create-agent-bundle/templates/cli-tool/README.md b/packages/create-agent-bundle/templates/cli-tool/README.md index 002fadd21..a77f88977 100644 --- a/packages/create-agent-bundle/templates/cli-tool/README.md +++ b/packages/create-agent-bundle/templates/cli-tool/README.md @@ -71,9 +71,12 @@ export default defineConfig(await agentBundleRstest()); "test:routes": "rstest --config rstest.route-unit.config.ts" ``` -Route rendering needs `react` and `@agent-bundle/runtime` (the same packages -the generated entries import) plus `@rstest/core`; install them alongside the -first route module. The `mcp-server` template ships this wiring already. +Route rendering needs `react`, `zod`, and `@agent-bundle/runtime` (the same +packages the generated entries import) plus `@rstest/core`; install them +alongside the first route module. Routed commands export zod-based +`inputSchema` and `resultSchema`, so the scaffold cannot typecheck without +`zod` once `src/cli/**` modules exist. The `mcp-server` template ships this +wiring already. ## The agent-bundle dependency