From f64a972c2b3e3803dac5198fe4a765f2df39c6c3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 20:37:47 +0000 Subject: [PATCH] fix(routes): make route helpers contract-aware --- .changeset/contract-aware-route-helpers.md | 5 ++ packages/agent-bundle/src/routes/typegen.ts | 14 +++- .../agent-bundle/tests/route-graph.test.ts | 82 ++++++++++++++++++- 3 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 .changeset/contract-aware-route-helpers.md diff --git a/.changeset/contract-aware-route-helpers.md b/.changeset/contract-aware-route-helpers.md new file mode 100644 index 000000000..f8504479f --- /dev/null +++ b/.changeset/contract-aware-route-helpers.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': patch +--- + +Fix generated route input and result helpers to infer event component contracts. diff --git a/packages/agent-bundle/src/routes/typegen.ts b/packages/agent-bundle/src/routes/typegen.ts index d8eba3d24..2acb125f3 100644 --- a/packages/agent-bundle/src/routes/typegen.ts +++ b/packages/agent-bundle/src/routes/typegen.ts @@ -35,6 +35,16 @@ export const generateRouteTypes = (graph: CompiledRouteGraph): string => { ' component: Component;', ' event: Event;', '}>;', + 'type ComponentInput = Component extends (props: infer Props) => unknown ? Props : never;', + 'type ComponentResult = Component extends (...args: never[]) => infer Result ? Awaited : never;', + 'type ContractInput =', + ' Contract extends { readonly input: infer Input } ? Input', + ' : Contract extends { readonly component: infer Component } ? ComponentInput', + ' : never;', + 'type ContractResult =', + ' Contract extends { readonly result: infer Result } ? Result', + ' : Contract extends { readonly component: infer Component } ? ComponentResult', + ' : never;', '', 'export interface AgentBundleRoutes {', ...routes.map((route, index) => @@ -44,8 +54,8 @@ export const generateRouteTypes = (graph: CompiledRouteGraph): string => { '}', '', 'export type RouteId = keyof AgentBundleRoutes;', - 'export type RouteInput = AgentBundleRoutes[Id][\'input\'];', - 'export type RouteResult = AgentBundleRoutes[Id][\'result\'];', + 'export type RouteInput = ContractInput;', + 'export type RouteResult = ContractResult;', '', ].join('\n'); }; diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 7e7179107..d8845aaec 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, expect, it } from '@rstest/core'; +import ts from 'typescript-5'; import { inspect, type ReadyInspectResult } from '../src/api.ts'; import { runCli } from '../src/cli.ts'; @@ -481,7 +482,14 @@ it('generates deterministic route-specific types from the compiled graph', () => const graph: CompiledRouteGraph = { diagnostics: [], digest: 'typegen-digest', - events: [], + events: [{ + config: emptyRouteConfig, + event: 'workspace/open', + id: 'event:workspace/open', + kind: 'event-route', + provenance: { kind: 'conventional', relativePath: 'src/events/workspace/open.tsx' }, + source: '/workspace/project/src/events/workspace/open.tsx', + }], providers: [], scripts: [{ config: emptyRouteConfig, @@ -508,13 +516,81 @@ it('generates deterministic route-specific types from the compiled graph', () => const second = generate(structuredClone(graph)); expect(second).toBe(first); - expect(first).toContain('import type * as route0 from "../src/mcp/curator/tools/inspect.js";'); - expect(first).toContain('"tool:curator/inspect": RouteContract;'); + expect(first).toContain('import type * as route0 from "../src/events/workspace/open.js";'); + expect(first).toContain('import type * as route1 from "../src/mcp/curator/tools/inspect.js";'); + expect(first).toContain('"event:workspace/open": EventRouteContract;'); + expect(first).toContain('"tool:curator/inspect": RouteContract;'); expect(first).not.toContain('src/scripts/rebuild-index'); expect(first).not.toContain('"script:rebuild-index"'); expect(first).toContain('export type RouteId = keyof AgentBundleRoutes;'); + expect(first).toContain('type ContractInput ='); + expect(first).toContain('type ContractResult ='); + expect(first).toContain('export type RouteInput = ContractInput;'); + expect(first).toContain('export type RouteResult = ContractResult;'); }); +it('resolves generated helper types for schema and event route contracts', async () => { + const root = await createRoot(); + await writeTree(root, { + 'package.json': '{"type":"module"}\n', + 'src/events/workspace/open.ts': [ + 'export interface WorkspaceOpenInput {', + " readonly canonical: { readonly event: 'workspace/open' };", + ' readonly native: Readonly>;', + ' readonly signal: AbortSignal;', + '}', + 'export interface WorkspaceOpenResult { readonly rendered: true; }', + 'export default async function WorkspaceOpen(_props: WorkspaceOpenInput): Promise {', + ' return { rendered: true };', + '}', + '', + ].join('\n'), + 'src/mcp/curator/tools/inspect.ts': [ + 'export interface InspectInput { readonly source: string; }', + 'export interface InspectResult { readonly accepted: boolean; }', + 'export const inputSchema = {} as { readonly _output: InspectInput };', + 'export const resultSchema = {} as { readonly _output: InspectResult };', + 'export default async function Inspect() { return undefined; }', + '', + ].join('\n'), + }); + + const graph = await compileRouteGraph(root, fixtureConfig()); + expect(graph.diagnostics).toEqual([]); + await writeTree(root, { + '.agent-bundle/routes.d.ts': routesModule.generateRouteTypes(graph), + 'assertions.ts': [ + "import type { RouteId, RouteInput, RouteResult } from './.agent-bundle/routes.js';", + "import type { WorkspaceOpenInput, WorkspaceOpenResult } from './src/events/workspace/open.js';", + "import type { InspectInput, InspectResult } from './src/mcp/curator/tools/inspect.js';", + '', + 'type Equal =', + ' (() => Value extends Left ? 1 : 2) extends', + ' (() => Value extends Right ? 1 : 2) ? true : false;', + 'type Assert = Value;', + '', + "export type SchemaInput = Assert, InspectInput>>;", + "export type SchemaResult = Assert, InspectResult>>;", + "export type EventInput = Assert, WorkspaceOpenInput>>;", + "export type EventResult = Assert, WorkspaceOpenResult>>;", + 'export type AllInputs = Assert, InspectInput | WorkspaceOpenInput>>;', + 'export type AllResults = Assert, InspectResult | WorkspaceOpenResult>>;', + '', + ].join('\n'), + }); + + const program = ts.createProgram([join(root, 'assertions.ts')], { + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + noEmit: true, + skipLibCheck: false, + strict: true, + target: ts.ScriptTarget.ES2022, + }); + const diagnostics = ts.getPreEmitDiagnostics(program) + .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')); + expect(diagnostics).toEqual([]); +}); it('validates the single async route-module authoring contract statically', async () => { const root = await createRoot();