From fc96a281b6f007a1faeb18146b438602c81b6eb1 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 22 Oct 2026 20:08:22 -0700 Subject: [PATCH 1/2] fix(next): discover exported agents without loading scripts --- .changeset/safe-agent-discovery.md | 5 + packages/durable-harness/README.md | 9 ++ packages/durable-harness/package.json | 3 + packages/durable-harness/src/discovery.ts | 137 ++++++++++++++++++ packages/durable-harness/src/next.ts | 12 +- packages/durable-harness/src/scan.ts | 25 ++-- .../durable-harness/test/discovery.test.ts | 71 +++++++++ packages/durable-harness/test/next.test.ts | 63 +++++++- pnpm-lock.yaml | 91 ++++++++++++ 9 files changed, 397 insertions(+), 19 deletions(-) create mode 100644 .changeset/safe-agent-discovery.md create mode 100644 packages/durable-harness/src/discovery.ts create mode 100644 packages/durable-harness/test/discovery.test.ts diff --git a/.changeset/safe-agent-discovery.md b/.changeset/safe-agent-discovery.md new file mode 100644 index 0000000..18b396c --- /dev/null +++ b/.changeset/safe-agent-discovery.md @@ -0,0 +1,5 @@ +--- +"durable-harness": patch +--- + +Discover exported agents from source syntax without importing scripts or code samples into the generated flow route. diff --git a/packages/durable-harness/README.md b/packages/durable-harness/README.md index ae297b2..3f3afec 100644 --- a/packages/durable-harness/README.md +++ b/packages/durable-harness/README.md @@ -68,6 +68,15 @@ const nextConfig: NextConfig = { export default withDurableHarness(nextConfig); ``` +`withDurableHarness` discovers modules that export agents created at module +scope with `durableAgent`, `claudeCode`, `codex`, `opencode` or `pi`, and modules +that register a pi-durable opener with `resumeWith`. A script that creates an +agent for itself, an exported factory that creates one later, and code samples +in strings or comments are excluded. Import aliases, default exports, export +lists, and containers or accessors for an existing agent are supported. +For custom factories or effect-only agent registration, list the modules +explicitly: `withDurableHarness(nextConfig, { agents: ['lib/agent.ts'] })`. + Choose your harness and framework in the [agent harnesses docs](https://vercel.com/docs/agent-harnesses). diff --git a/packages/durable-harness/package.json b/packages/durable-harness/package.json index b19a99a..acc498f 100644 --- a/packages/durable-harness/package.json +++ b/packages/durable-harness/package.json @@ -121,6 +121,8 @@ "check-dist": "node scripts/check-dist.mjs" }, "dependencies": { + "@babel/parser": "^7.29.9", + "@babel/traverse": "^7.29.10", "@opentelemetry/api": "1.9.0", "@vercel/oidc": "3.2.0", "@workflow/utils": "^5.0.2", @@ -229,6 +231,7 @@ "@opentelemetry/otlp-transformer": "0.208.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0", + "@types/babel__traverse": "^7.28.0", "@vercel/agent-runs": "0.0.1", "effect": "4.0.0-rc.112", "harness-world": "workspace:*", diff --git a/packages/durable-harness/src/discovery.ts b/packages/durable-harness/src/discovery.ts new file mode 100644 index 0000000..9467222 --- /dev/null +++ b/packages/durable-harness/src/discovery.ts @@ -0,0 +1,137 @@ +import { parse } from '@babel/parser'; +import traverseModule, { type Binding, type NodePath } from '@babel/traverse'; + +// Babel's CommonJS entry has a default property when loaded by Node's ESM +// loader; bundlers may unwrap it already. +const traverse = 'default' in traverseModule ? traverseModule.default as typeof traverseModule : traverseModule; + +type Reference = { source: string; name?: string }; +export interface HarnessModule { + agent: boolean; + /** A real factory call, even without an export: classifies explicitly listed modules. */ + agentFactory: boolean; + piOpener: boolean; + piSessions: boolean; +} + +const FACTORIES: Readonly> = { + 'durable-harness': 'durableAgent', + 'durable-harness/claude-code': 'claudeCode', + 'durable-harness/codex': 'codex', + 'durable-harness/opencode': 'opencode', + 'durable-harness/pi': 'pi', +}; +const PI = 'durable-harness/pi-durable'; + +/** + * Read actual imports, calls and exports without loading the app. Automatic + * discovery follows exported values back to agents made at module scope; + * scripts and exported factories are not registration modules. Explicit + * `agents` handles factories or registrations this static check cannot see. + * Pi's resumeWith is deliberately different: it registers an opener for its + * side effect, even when its return value is not exported. + */ +export function harnessModule(text: string): HarnessModule { + const found: HarnessModule = { agent: false, agentFactory: false, piOpener: false, piSessions: false }; + if (!text.includes('durable-harness')) return found; + let ast: ReturnType; + const options = { sourceType: 'unambiguous', allowUndeclaredExports: true, createImportExpressions: true } as const; + try { + ast = parse(text, { ...options, plugins: ['typescript', 'jsx', 'decorators-legacy'] }); + } catch { + // A .ts angle-bracket assertion or generic arrow may be ambiguous with + // JSX. No source extension is needed to handle both kinds of module. + try { + ast = parse(text, { ...options, plugins: ['typescript', 'decorators-legacy'] }); + } catch { + return found; + } + } + traverse(ast, { + 'CallExpression|OptionalCallExpression'(path) { + const callee = path.get('callee') as NodePath; + const imported = reference(callee); + if (!imported) return; + if (imported.source === PI) { + if (['openWorldStorage', 'acquireHarness', 'chatRoute'].includes(imported.name ?? '')) found.piSessions = true; + if (imported.name === 'resumeWith' && atModuleScope(path)) found.piOpener = found.agent = true; + } else if (FACTORIES[imported.source] === imported.name && imported.name) { + found.agentFactory = true; + if (atModuleScope(path) && reachesExport(path)) found.agent = true; + } + }, + }); + return found; +} + +/** Constructors and instance fields, like functions, do not run at import. */ +function atModuleScope(path: NodePath): boolean { + return !path.findParent((parent) => parent.isFunction() || parent.isClass()); +} + +/** Follow a value import, namespace member, or local alias to its module. */ +function reference(path: NodePath, seen = new Set()): Reference | undefined { + if (path.isAwaitExpression()) return reference(path.get('argument'), seen); + if (path.isTSAsExpression() || path.isTSSatisfiesExpression() || path.isTSNonNullExpression() || path.isTSTypeAssertion() || path.isParenthesizedExpression()) return reference(path.get('expression') as NodePath, seen); + if (path.isImportExpression() && path.node.source.type === 'StringLiteral') return { source: path.node.source.value }; + if (path.isCallExpression() && path.node.callee.type === 'Identifier' && path.node.callee.name === 'require' && !path.scope.getBinding('require') && path.node.arguments[0]?.type === 'StringLiteral') return { source: path.node.arguments[0].value }; + if (path.isMemberExpression() || path.isOptionalMemberExpression()) { + const object = reference(path.get('object') as NodePath, seen); + const property = path.node.property; + const name = !path.node.computed && property.type === 'Identifier' ? property.name : property.type === 'StringLiteral' ? property.value : undefined; + return object && !object.name && name ? { source: object.source, name } : undefined; + } + if (!path.isIdentifier()) return undefined; + const binding = path.scope.getBinding(path.node.name); + if (!binding || seen.has(binding) || !binding.constant) return undefined; + seen.add(binding); + const declared = binding.path; + if (declared.isImportSpecifier() || declared.isImportNamespaceSpecifier()) { + const statement = declared.parentPath; + if (!statement.isImportDeclaration() || statement.node.importKind === 'type') return undefined; + if (declared.isImportNamespaceSpecifier()) return { source: statement.node.source.value }; + if (declared.node.importKind === 'type') return undefined; + const imported = declared.node.imported; + return { source: statement.node.source.value, name: imported.type === 'Identifier' ? imported.name : imported.value }; + } + if (!declared.isVariableDeclarator() || !declared.node.init) return undefined; + const imported = reference(declared.get('init') as NodePath, seen); + if (declared.node.id.type === 'Identifier') return imported; + if (declared.node.id.type !== 'ObjectPattern' || !imported || imported.name) return undefined; + for (const property of declared.node.id.properties) { + if (property.type !== 'ObjectProperty' || property.value.type !== 'Identifier' || property.value.name !== path.node.name) continue; + const key = property.key; + const name = !property.computed && key.type === 'Identifier' ? key.name : key.type === 'StringLiteral' ? key.value : undefined; + if (name) return { source: imported.source, name }; + } + return undefined; +} + +/** Does this value reach a value export, possibly through local bindings? */ +function reachesExport(path: NodePath, seen = new Set()): boolean { + for (let parent: NodePath | null = path; parent && !parent.isProgram(); parent = parent.parentPath) { + if (parent.isExportNamedDeclaration()) return parent.node.exportKind !== 'type'; + if (parent.isExportDefaultDeclaration()) return true; + if (parent.isExportSpecifier() && parent.node.exportKind === 'type') return false; + if (parent.node.type.startsWith('TS') && !parent.isTSAsExpression() && !parent.isTSSatisfiesExpression() && !parent.isTSNonNullExpression() && !parent.isTSTypeAssertion()) return false; + if (parent.isAssignmentExpression()) { + const left = parent.get('left'); + if (left.isMemberExpression()) { + const { object, property, computed } = left.node; + const key = !computed && property.type === 'Identifier' ? property.name : property.type === 'StringLiteral' ? property.value : undefined; + if (object.type === 'Identifier' && object.name === 'module' && key === 'exports' && !left.scope.getBinding('module')) return true; + if (object.type === 'Identifier' && object.name === 'exports' && !left.scope.getBinding('exports')) return true; + if (object.type === 'MemberExpression' && object.object.type === 'Identifier' && object.object.name === 'module' && !left.scope.getBinding('module') && !object.computed && object.property.type === 'Identifier' && object.property.name === 'exports') return true; + } + } + if (!parent.isVariableDeclarator() && !parent.isFunctionDeclaration() && !parent.isClassDeclaration() && !parent.isAssignmentExpression()) continue; + const identifiers = parent.isAssignmentExpression() ? parent.get('left').getBindingIdentifiers() : parent.getBindingIdentifiers(); + for (const name of Object.keys(identifiers)) { + const binding = parent.scope.getBinding(name); + if (!binding || !binding.scope.path.isProgram() || seen.has(binding)) continue; + seen.add(binding); + if (binding.referencePaths.some((ref) => reachesExport(ref, seen))) return true; + } + } + return false; +} diff --git a/packages/durable-harness/src/next.ts b/packages/durable-harness/src/next.ts index caea6c9..feec21a 100644 --- a/packages/durable-harness/src/next.ts +++ b/packages/durable-harness/src/next.ts @@ -24,10 +24,14 @@ export interface DurableHarnessOptions { /** * The modules that make your agents, relative to the project: durable-harness * agents (`claudeCode()`, `pi()`, ...) and fx agents (libfx's - * `createFxAgent()`). Default: every source file that imports a harness - * subpath of durable-harness (for `durable-harness/pi-durable`, one that - * calls `resumeWith(`: it registers the opener a wake reopens sessions - * with) or calls `durableAgent(`, and every one that + * `createFxAgent()`). Default: modules that export an agent made at module + * scope with `durableAgent`, `claudeCode`, `codex`, `opencode` or `pi` from + * durable-harness (including import aliases and exported containers or + * accessors for that value). Calls inside factories, strings and comments + * do not count. A module that calls pi-durable's `resumeWith` at module + * scope is also included: it registers an opener, with or without an + * export. Use `agents` for custom factories or effect-only registrations. + * Automatic discovery also includes every module that * imports `createFxAgent` (from `libfx`, or a module of yours that * re-exports it) and calls it. A listed module that calls `createFxAgent(` * is an fx agent's (the first one listed is the one the fx route mounts); diff --git a/packages/durable-harness/src/scan.ts b/packages/durable-harness/src/scan.ts index c87fbf9..162481b 100644 --- a/packages/durable-harness/src/scan.ts +++ b/packages/durable-harness/src/scan.ts @@ -1,5 +1,6 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { dirname, join, relative, sep } from 'node:path'; +import { harnessModule } from './discovery.ts'; import { packageDir, resolves } from './native.ts'; import { FLOW_MAX_DURATION, FLOW_ROUTE, flowTrigger, FX_ROUTE, fxTrigger, type GeneratedRoute, LIBFX_QUEUE_PREFIX, WORKFLOW_FLOW_ROUTE } from './routing.ts'; @@ -22,7 +23,7 @@ export function appDir(projectDir: string): string | undefined { export interface Source { file: string; - /** It imports a harness subpath (it makes an agent), or `durable-harness/pi-durable` and calls `resumeWith(` (it registers an opener). */ + /** It exports an agent made at module scope, or registers a pi-durable opener with `resumeWith(` there. */ agent: boolean; /** * It opens pi-durable sessions on the World (`openWorldStorage(`, @@ -308,9 +309,6 @@ function matchesKey(pattern: string, file: string): boolean { const SKIP = new Set(['node_modules', 'public', 'out', 'dist', 'build', 'coverage']); const SOURCE = /\.(?:[cm]?[jt]sx?)$/; const ROUTE_FILE = /^route\.(?:[cm]?[jt]sx?)$/; -const CORE_IMPORT = /(?:from\s*|import\s*\(\s*|require\s*\(\s*)['"]durable-harness['"]/; -const HARNESS_IMPORT = /^(?!\s*import\s+type\b).*(?:from\s*|import\s*\(\s*|require\s*\(\s*)['"]durable-harness\/(?:claude-code|codex|opencode|pi)['"]/m; -const PI_DURABLE_IMPORT = /^(?!\s*import\s+type\b).*(?:from\s*|import\s*\(\s*|require\s*\(\s*)['"]durable-harness\/pi-durable['"]/m; const LIBFX = String.raw`['"]libfx(?:\/node)?['"]`; const FX_CALL = /(?\s*)?\(/; @@ -354,24 +352,26 @@ export const isFxModule = (text: string) => (importsLibfx(text) || importsCreate * imports it from), both, or neither (an agent made through a function of * the app's own). */ -const listedKind = (text: string) => ({ harness: isHarnessModule(text), fx: fxCalls(text) > 0 }); +const listedKind = (text: string) => { + const module = harnessModule(text); + return { harness: module.agentFactory || module.piOpener, fx: fxCalls(text) > 0 }; +}; /** * Whether a module registers a pi-durable opener: a value import of * `durable-harness/pi-durable` and a `resumeWith(` call. A module that only * opens storages there (a route) is not one: the flow route never imports it. */ -export const isPiOpenerModule = (text: string) => PI_DURABLE_IMPORT.test(text) && /\bresumeWith\s*\(/.test(code(text)); +export const isPiOpenerModule = (text: string) => harnessModule(text).piOpener; /** Whether a module opens pi-durable sessions on the World: a value import of `durable-harness/pi-durable` and a call that opens one. */ -export const opensPiSessions = (text: string) => PI_DURABLE_IMPORT.test(text) && /\b(?:openWorldStorage|acquireHarness|chatRoute)\s*\(/.test(code(text)); +export const opensPiSessions = (text: string) => harnessModule(text).piSessions; /** - * Whether a module makes a durable-harness agent: a value import of a harness - * subpath, `durableAgent(` from durable-harness, or a pi-durable opener - * (`resumeWith(`). + * Whether a module exports a durable-harness agent made at module scope, or + * registers a pi-durable opener (`resumeWith(`) there. */ -export const isHarnessModule = (text: string) => HARNESS_IMPORT.test(text) || isPiOpenerModule(text) || (CORE_IMPORT.test(text) && /\bdurableAgent\s*\(/.test(text)); +export const isHarnessModule = (text: string) => harnessModule(text).agent; /** A function, not a value made now: an initializer an export would hold. */ const FUNCTION_VALUE = /^(?:async\s+)?(?:function\b|class\b|\([^)]*\)\s*(?::[^=]*)?=>|[\w$]+\s*=>)/; @@ -493,11 +493,10 @@ function scan(projectDir: string, app: string): Source[] { } const inApp = full.startsWith(app + sep); const route = inApp && ROUTE_FILE.test(entry.name) ? routeOf(app, full) : undefined; - const agent = isHarnessModule(text); + const { agent, piSessions } = harnessModule(text); const fx = isFxModule(text); const chat = /\bchatRoute\s*\(/.test(text); const wake = /\bwakeHandler\s*\(/.test(code(text)); - const piSessions = opensPiSessions(text); if (agent || fx || piSessions || (route && (chat || wake))) out.push({ file: full, agent, fx, chat, wake, ...(piSessions ? { piSessions } : {}), ...(route ? { route } : {}) }); } } diff --git a/packages/durable-harness/test/discovery.test.ts b/packages/durable-harness/test/discovery.test.ts new file mode 100644 index 0000000..0245ea6 --- /dev/null +++ b/packages/durable-harness/test/discovery.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { harnessModule } from '../src/discovery.ts'; + +const CORE = "import { durableAgent } from 'durable-harness'; "; + +describe('automatic harness discovery', () => { + it.each([ + 'export const sample = "durableAgent({})"', + 'export const sample = `durableAgent({})`', + 'export const sample = /durableAgent\\(/', + '/* export const agent = durableAgent({}) */ export const sample = null', + '// export const agent = durableAgent({})\nexport const sample = null', + 'const agent = durableAgent({}); await agent.session().prompt("hi").result', + 'export function makeAgent() { return durableAgent({}) }', + 'export const makeAgent = () => durableAgent({})', + 'export const makeAgent = async () => durableAgent({})', + 'export default function makeAgent() { return durableAgent({}) }', + 'export class Factory { agent = durableAgent({}) }', + 'export const factory = { make() { return durableAgent({}) } }', + 'const agent = durableAgent({}); export type Agent = typeof agent', + 'const agent = durableAgent({}); export const sample: typeof agent = null', + 'const agent = durableAgent({}); export { type agent }', + 'function helper(durableAgent: Function) { return durableAgent({}) }; export const value = helper(() => null)', + ])('ignores samples, scripts, factories and type-only exports: %s', (body) => { + expect(harnessModule(CORE + body).agent).toBe(false); + }); + + it.each([ + "export const sample = `import { durableAgent } from 'durable-harness'; durableAgent({})`", + "import type { durableAgent } from 'durable-harness'; export const agent = durableAgent({})", + "import { type durableAgent } from 'durable-harness'; export const agent = durableAgent({})", + "import { flowHandler } from 'durable-harness'; const durableAgent = () => null; export const agent = durableAgent({})", + "import { createCodingTools } from 'durable-harness/pi'; export const tools = createCodingTools({})", + "import { pi } from 'durable-harness/pi'; export { pi }", + "import { pi } from 'durable-harness/pi'; const agent = pi({}); export function helper(agent: unknown) { return agent }", + ])('requires a real call of an imported agent factory: %s', (text) => { + expect(harnessModule(text).agent).toBe(false); + }); + + it.each([ + 'export const agent = durableAgent({})', + 'export const agent = durableAgent({})', + 'const make = durableAgent; export const agent = make({})', + 'const agent = durableAgent({}); const getAgent = () => agent; export { getAgent }', + 'const agent = durableAgent({}); function getAgent() { return agent }; export default getAgent', + 'export const agent = wrap(durableAgent({}))', + 'export const agent = ready ? durableAgent({}) : durableAgent({})', + 'const agent = durableAgent({}); export const config = { agent }', + 'let agent; agent = durableAgent({}); export { agent }', + 'module.exports = durableAgent({})', + 'module.exports.agent = durableAgent({})', + 'const agent = durableAgent({}); exports.agent = agent', + ])('follows actual calls and value exports: %s', (body) => { + expect(harnessModule(CORE + body).agent).toBe(true); + }); + + it('preserves effect-only pi opener registration and resolves aliases', () => { + const result = harnessModule("import { resumeWith as register, openWorldStorage as storage } from 'durable-harness/pi-durable'; register(async () => null); export async function POST() { return storage() }"); + expect(result).toMatchObject({ agent: true, piOpener: true, piSessions: true }); + expect(harnessModule("import { resumeWith } from 'durable-harness/pi-durable'; export function register() { resumeWith(async () => null) }").piOpener).toBe(false); + expect(harnessModule("import { resumeWith } from 'durable-harness/pi-durable'; export const sample = 'resumeWith()'").piOpener).toBe(false); + }); + + it('does not evaluate template substitutions or confuse them with literal samples', () => { + expect(harnessModule(CORE + 'export const example = `example: ${durableAgent({})}`').agent).toBe(true); + }); + + it('leaves syntax it cannot read to the explicit agents option', () => { + expect(harnessModule(CORE + 'export const agent = durableAgent(').agent).toBe(false); + }); +}); diff --git a/packages/durable-harness/test/next.test.ts b/packages/durable-harness/test/next.test.ts index 5981954..626cdb6 100644 --- a/packages/durable-harness/test/next.test.ts +++ b/packages/durable-harness/test/next.test.ts @@ -378,6 +378,65 @@ describe('withDurableHarness, a harness of your own', () => { }); }); +describe('withDurableHarness, automatic agent discovery', () => { + it('imports exported agents without importing scripts or docs samples for their side effects', async () => { + const dir = project({ + ...STUBS, + ...MINE, + 'scripts/drill.js': `const { durableAgent } = await import('durable-harness') +async function drill() { return durableAgent({ name: 'drill' }) } +await drill() +throw new Error('the build must not run this script') +`, + 'app/how-it-works/page.tsx': `const sample = "import { durableAgent } from 'durable-harness'; durableAgent({})" +export default function Page() { return
{sample}
} +`, + 'lib/docs.ts': `import { durableAgent } from 'durable-harness' +export const sample = \`durableAgent({})\` +`, + 'scripts/helper.ts': `import { pi } from 'durable-harness/pi' +export async function run() { return pi({ model: 'test' }) } +`, + }); + applyDurableHarness({}, 'phase-production-build', dir); + const text = readFileSync(join(dir, FLOW), 'utf8'); + expect(text).toContain('lib/mine'); + for (const excluded of ['scripts/drill', 'how-it-works', 'lib/docs', 'scripts/helper']) expect(text).not.toContain(excluded); + expect((await routeOf(dir)).POST).toBeTypeOf('function'); + }); + + it.each([ + ["import { durableAgent as make } from 'durable-harness'", 'export const agent = make({})'], + ["import * as harness from 'durable-harness'", 'export default harness.durableAgent({})'], + ["const { durableAgent: make } = await import('durable-harness')", 'const agent = make({}); export { agent as custom }'], + ["const harness = require('durable-harness')", 'const agent = harness.durableAgent({}); export default agent'], + ["import { pi as make } from 'durable-harness/pi'", 'export const agent = (make({}) as Agent) satisfies Agent'], + ["import { codex } from 'durable-harness/codex'", 'const agent = codex({}); export const agents = [agent]'], + ["import { claudeCode } from 'durable-harness/claude-code'", 'export const agent = globalThis.agent ??= claudeCode({})'], + ["import { opencode } from 'durable-harness/opencode'", 'const agent = opencode({}); export function getAgent() { return agent }'], + ["import { durableAgent } from 'durable-harness'", 'const agent = durableAgent({}); const alias = agent; export { alias }'], + ["import { pi } from 'durable-harness/pi'", 'export const { GET, POST } = pi({}).chatRoute()'], + ])('finds real exported values with %s and %s', (imported, made) => { + const dir = project({ 'app/page.tsx': 'export default () => null', 'lib/agent.ts': `${imported}; ${made}` }); + applyDurableHarness({}, 'phase-production-build', dir); + expect(readFileSync(join(dir, FLOW), 'utf8')).toContain('lib/agent'); + }); + + it('keeps explicit agents as the escape hatch for side-effect registration and custom factories', () => { + const dir = project({ + 'app/page.tsx': 'export default () => null', + 'lib/registration.ts': "import { durableAgent } from 'durable-harness'; durableAgent({})", + 'lib/custom.ts': "import { makeAgent } from './factory'; makeAgent()", + }); + applyDurableHarness({}, 'phase-production-build', dir); + expect(existsSync(join(dir, FLOW))).toBe(false); + applyDurableHarness({}, 'phase-production-build', dir, { agents: ['lib/registration.ts', 'lib/custom.ts'] }); + const text = readFileSync(join(dir, FLOW), 'utf8'); + expect(text).toContain('lib/registration'); + expect(text).toContain('lib/custom'); + }); +}); + describe('withDurableHarness, Pi with pi-durable: your own code on a World storage', () => { const PI_LIB = [ "import { BACKGROUND_CONTEXT as ctx } from '@earendil-works/chord/context'", @@ -646,10 +705,10 @@ describe('withDurableHarness, fx agents, without the Workflow SDK', () => { expect(await (await (await routeOf(dir)).POST(delivery(LIBFX_TOPIC))).text()).toBe(`fx main: ${LIBFX_TOPIC}`); }); - it('takes a listed module that makes both kinds of agent as both', () => { + it.each(['export const mine = durableAgent({})', 'durableAgent({})'])('takes a listed module that makes both kinds of agent as both: %s', (harness) => { const dir = project({ ...STUBS, - 'lib/agents.ts': "import { createFxAgent } from 'libfx'\nimport { durableAgent } from 'durable-harness'\nexport const fx = createFxAgent({})\nexport const mine = durableAgent({})\n", + 'lib/agents.ts': `import { createFxAgent } from 'libfx'\nimport { durableAgent } from 'durable-harness'\nexport const fx = createFxAgent({})\n${harness}\n`, }); applyDurableHarness({}, 'phase-production-build', dir, { agents: ['lib/agents.ts'] }); const route = readFileSync(join(dir, FLOW), 'utf8'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b765881..d71c240 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -526,6 +526,12 @@ importers: packages/durable-harness: dependencies: + '@babel/parser': + specifier: ^7.29.9 + version: 7.29.9 + '@babel/traverse': + specifier: ^7.29.10 + version: 7.29.10(supports-color@10.2.2) '@opentelemetry/api': specifier: 1.9.0 version: 1.9.0 @@ -599,6 +605,9 @@ importers: '@opentelemetry/sdk-trace-base': specifier: 2.2.0 version: 2.2.0(@opentelemetry/api@1.9.0) + '@types/babel__traverse': + specifier: ^7.28.0 + version: 7.28.0 '@vercel/agent-runs': specifier: 0.0.1 version: 0.0.1(@opentelemetry/api@1.9.0) @@ -1352,14 +1361,43 @@ packages: resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.10': + resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} @@ -3644,6 +3682,9 @@ packages: resolution: {integrity: sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==} engines: {node: ^20.17.0 || >=22.9.0} + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -5285,6 +5326,11 @@ packages: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} @@ -7385,10 +7431,49 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.10(supports-color@10.2.2)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@borewit/text-codec@0.2.2': {} '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': @@ -10716,6 +10801,10 @@ snapshots: '@tufjs/canonical-json': 2.0.0 minimatch: 10.2.6 + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -12742,6 +12831,8 @@ snapshots: dependencies: argparse: 2.0.1 + jsesc@3.1.0: {} + json-bigint@1.0.0: dependencies: bignumber.js: 9.3.1 From 58d330d9f40203646172a95d53772084c95301b6 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 22 Oct 2026 20:16:52 -0700 Subject: [PATCH 2/2] fix(next): include static class agent registration --- packages/durable-harness/src/discovery.ts | 6 ++++-- packages/durable-harness/test/discovery.test.ts | 7 +++++++ packages/durable-harness/test/next.test.ts | 1 + 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/durable-harness/src/discovery.ts b/packages/durable-harness/src/discovery.ts index 9467222..afd9174 100644 --- a/packages/durable-harness/src/discovery.ts +++ b/packages/durable-harness/src/discovery.ts @@ -64,9 +64,11 @@ export function harnessModule(text: string): HarnessModule { return found; } -/** Constructors and instance fields, like functions, do not run at import. */ +/** Static class initializers run at import; method bodies and instance fields do not. */ function atModuleScope(path: NodePath): boolean { - return !path.findParent((parent) => parent.isFunction() || parent.isClass()); + return !path.findParent((parent) => parent.isFunction() || ( + (parent.isClassProperty() || parent.isClassPrivateProperty() || parent.isClassAccessorProperty()) && !parent.node.static + )); } /** Follow a value import, namespace member, or local alias to its module. */ diff --git a/packages/durable-harness/test/discovery.test.ts b/packages/durable-harness/test/discovery.test.ts index 0245ea6..713efda 100644 --- a/packages/durable-harness/test/discovery.test.ts +++ b/packages/durable-harness/test/discovery.test.ts @@ -16,6 +16,9 @@ describe('automatic harness discovery', () => { 'export const makeAgent = async () => durableAgent({})', 'export default function makeAgent() { return durableAgent({}) }', 'export class Factory { agent = durableAgent({}) }', + 'export class Factory { static make() { return durableAgent({}) } }', + 'export class Factory { static get agent() { return durableAgent({}) } }', + 'export const factory = () => class { static agent = durableAgent({}) }', 'export const factory = { make() { return durableAgent({}) } }', 'const agent = durableAgent({}); export type Agent = typeof agent', 'const agent = durableAgent({}); export const sample: typeof agent = null', @@ -49,6 +52,10 @@ describe('automatic harness discovery', () => { 'let agent; agent = durableAgent({}); export { agent }', 'module.exports = durableAgent({})', 'module.exports.agent = durableAgent({})', + 'export class Agents { static agent = durableAgent({}) }', + 'class Agents { static agent = durableAgent({}) }; export { Agents }', + 'export class Agents { static #agent = durableAgent({}) }', + 'export class Agents { static { durableAgent({}) } }', 'const agent = durableAgent({}); exports.agent = agent', ])('follows actual calls and value exports: %s', (body) => { expect(harnessModule(CORE + body).agent).toBe(true); diff --git a/packages/durable-harness/test/next.test.ts b/packages/durable-harness/test/next.test.ts index 626cdb6..ed68e37 100644 --- a/packages/durable-harness/test/next.test.ts +++ b/packages/durable-harness/test/next.test.ts @@ -416,6 +416,7 @@ export async function run() { return pi({ model: 'test' }) } ["import { opencode } from 'durable-harness/opencode'", 'const agent = opencode({}); export function getAgent() { return agent }'], ["import { durableAgent } from 'durable-harness'", 'const agent = durableAgent({}); const alias = agent; export { alias }'], ["import { pi } from 'durable-harness/pi'", 'export const { GET, POST } = pi({}).chatRoute()'], + ["import { durableAgent } from 'durable-harness'", 'export class Agents { static agent = durableAgent({}) }'], ])('finds real exported values with %s and %s', (imported, made) => { const dir = project({ 'app/page.tsx': 'export default () => null', 'lib/agent.ts': `${imported}; ${made}` }); applyDurableHarness({}, 'phase-production-build', dir);