Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-amp-event-worker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Keep standalone event workers beside nested Amp hook wrappers so relocated plugins can execute event routes (#740).
83 changes: 47 additions & 36 deletions packages/agent-bundle/src/build/entries.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { existsSync } from 'node:fs';
import { readFile, stat } from 'node:fs/promises';
import { dirname, extname, join, relative, resolve } from 'node:path';
import { dirname, extname, join, posix, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

import { hooksFlightWorkerPath } from '../adapters/composite-layout.ts';
Expand Down Expand Up @@ -570,15 +570,24 @@ const hookEntrySourceInputs = (entry: TargetHookEntry): readonly string[] => {
]);
};

const requiresStandaloneHookWorker = (entry: TargetHookEntry): boolean =>
entry.hook.eventRoute?.runtime === 'standalone' || entry.hook.eventRoute?.fallback === 'standalone';

const hookWorkerPath = (entry: TargetHookEntry): string =>
posix.join(posix.dirname(entry.relativePath), posix.basename(hooksFlightWorkerPath));

export const planCompiledHooks = (
entries: readonly TargetHookEntry[],
options: { readonly outDir: string },
): readonly CompiledHookEntry[] => {
const workerOwner = entries.findIndex((entry) =>
entry.hook.eventRoute?.runtime === 'standalone' || entry.hook.eventRoute?.fallback === 'standalone');
const workerOwners = new Map<string, number>();
entries.forEach((entry, index) => {
if (requiresStandaloneHookWorker(entry) && !workerOwners.has(hookWorkerPath(entry))) {
workerOwners.set(hookWorkerPath(entry), index);
}
});
const workerSourceInputs = Object.freeze([...new Set(entries
.filter((entry) =>
entry.hook.eventRoute?.runtime === 'standalone' || entry.hook.eventRoute?.fallback === 'standalone')
.filter(requiresStandaloneHookWorker)
.flatMap((entry) => [entry.hook.provenance.sourcePath, entry.hook.source]))]);
return deepFreeze(entries.map((entry, index) => ({
event: entry.event,
Expand All @@ -600,9 +609,9 @@ export const planCompiledHooks = (
executorSourceInputs: Object.freeze([entry.hook.provenance.sourcePath, entry.hook.source]),
}),
...(entry.timeout === undefined ? {} : { timeout: entry.timeout }),
...(index === workerOwner
...(workerOwners.get(hookWorkerPath(entry)) === index
? {
workerOutput: resolveArtifactDestination(options.outDir, hooksFlightWorkerPath),
workerOutput: resolveArtifactDestination(options.outDir, hookWorkerPath(entry)),
workerSourceInputs,
}
: {}),
Expand All @@ -629,39 +638,41 @@ export const planHooksSurface = (
const compiled = planCompiledHooks(entries, options);
const routeEntries = entries.filter((entry) => entry.hook.eventRoute !== undefined);
const standaloneEventRoutes = [...new Map(routeEntries
.filter((entry) =>
entry.hook.eventRoute?.runtime === 'standalone' || entry.hook.eventRoute?.fallback === 'standalone')
.filter(requiresStandaloneHookWorker)
.map((entry) => [entry.hook.id, entry.hook])).values()];
const workerPlans = [...new Map(entries
.filter(requiresStandaloneHookWorker)
.map((entry) => [hookWorkerPath(entry), entry.hook])).entries()];
const workerArtifactEpoch = generatedRouteArtifactEpoch(options.plugin);
const eventIpcRuntime = routeEntries.length === 0 ? undefined : eventRuntimeModulePath('ipc');
const eventProjectRuntime = routeEntries.length === 0 ? undefined : eventRuntimeModulePath('project');
const launchEnvRuntime = launchEnvRuntimePath();
const workerEntry = standaloneEventRoutes.length === 0
? undefined
: {
name: 'hooks-flight',
outputRelativePath: hooksFlightWorkerPath,
reactServer: true as const,
rscManifest: true as const,
source: standaloneEventRoutes[0]!.source,
sourceInputs: Object.freeze([
...new Set([
...standaloneEventRoutes.flatMap((hook) => [hook.provenance.sourcePath, hook.source]),
...(options.providers ?? []).map((provider) => provider.source),
...(options.state === undefined ? [] : [options.state.provenance.sourcePath, options.state.source]),
]),
const workerEntries = workerPlans.map(([outputRelativePath, hook]) => ({
name: outputRelativePath === hooksFlightWorkerPath
? 'hooks-flight'
: outputRelativePath.replaceAll('/', '-').replace(/\.mjs$/u, ''),
outputRelativePath,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve the worker relative to the selected wrapper

When an artifact contains only an Amp standalone event route, this emits the sole worker under .amp/plugins/<name>/hooks/ and no longer emits hooks/hooks-flight.mjs. However, productionBindingFor in src/dev/routes/route-invocation-service.ts still searches only for that exact root path, so a Workbench Amp event invocation returns ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE before launching the otherwise valid wrapper. Select the colocated worker from the selected wrapper path instead of hard-coding the root worker.

Useful? React with 👍 / 👎.

Comment on lines +650 to +654

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the documented composite artifact tree

This per-wrapper-directory output means an Amp standalone route emits .amp/plugins/<name>/hooks/hooks-flight.mjs, and mixed targets may emit both nested and root workers, but the English and Chinese reference/targets-artifacts.mdx and guide/start/project-structure.mdx pages still show only hooks/hooks-flight.mjs and describe hooks/ as shared and emitted once. Update both locale documents so the public artifact layout matches the build output.

AGENTS.md reference: AGENTS.md:L132-L138

Useful? React with 👍 / 👎.

reactServer: true as const,
rscManifest: true as const,
source: hook.source,
sourceInputs: Object.freeze([
...new Set([
...standaloneEventRoutes.flatMap((hook) => [hook.provenance.sourcePath, hook.source]),
...(options.providers ?? []).map((provider) => provider.source),
...(options.state === undefined ? [] : [options.state.provenance.sourcePath, options.state.source]),
]),
virtualSource: generatedRouteFlightWorkerSource({
artifactEpoch: workerArtifactEpoch,
eventRoutes: standaloneEventRoutes,
...(options.noticeDelivery === undefined ? {} : { noticeDelivery: options.noticeDelivery }),
providers: options.providers ?? [],
routes: [],
serverName: 'hooks',
...(options.noticeRetention === undefined ? {} : { noticeRetention: options.noticeRetention }),
...(options.state === undefined ? {} : { state: options.state }),
}),
};
]),
virtualSource: generatedRouteFlightWorkerSource({
artifactEpoch: workerArtifactEpoch,
eventRoutes: standaloneEventRoutes,
...(options.noticeDelivery === undefined ? {} : { noticeDelivery: options.noticeDelivery }),
providers: options.providers ?? [],
routes: [],
serverName: 'hooks',
...(options.noticeRetention === undefined ? {} : { noticeRetention: options.noticeRetention }),
...(options.state === undefined ? {} : { state: options.state }),
}),
}));
return {
entries: [
...compiled.flatMap((entry, index) => {
Expand Down Expand Up @@ -716,7 +727,7 @@ export const planHooksSurface = (
},
];
}),
...(workerEntry === undefined ? [] : [workerEntry]),
...workerEntries,
],
ignoredSourcePaths: [
runtimeIgnoredRoot(launchEnvRuntime),
Expand All @@ -732,7 +743,7 @@ export const planHooksSurface = (
?? (() => { throw new Error(`Missing bundled deferred hook executor evidence for ${JSON.stringify(entry.name)}.`); })(),
}),
...(entry.workerOutput === undefined ? {} : {
workerSourceInputs: evidenceByPath.get(hooksFlightWorkerPath) ?? (() => { throw new Error('Missing bundled hook Flight worker evidence.'); })(),
workerSourceInputs: evidenceByPath.get(hookWorkerPath(entries[index]!)) ?? (() => { throw new Error('Missing bundled hook Flight worker evidence.'); })(),
}),
})));
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto';
import { fork, type ChildProcess } from 'node:child_process';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join, resolve } from 'node:path';
import { dirname, join, posix, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

import type { AgentDocument, AgentDocumentNode, AgentRenderEvent } from '@agent-bundle/runtime';
Expand Down Expand Up @@ -603,7 +603,11 @@ const productionBindingFor = (
&& candidate.hosts.some((candidateHost) => eligibleHosts.has(candidateHost))
&& candidate.launch?.worker !== undefined))
.find((candidate) => candidate !== undefined);
const standalone = manifest.files.find((file) => file.path === hooksFlightWorkerPath)?.path;
const standalone = [
...(wrapper === undefined ? wrappers : [wrapper])
.map((candidate) => posix.join(posix.dirname(candidate.path), posix.basename(hooksFlightWorkerPath))),
hooksFlightWorkerPath,
].find((candidate) => manifest.files.some((file) => file.path === candidate));
const executable = execution.runtime === 'standalone'
? standalone
: shared?.launch?.worker ?? (execution.fallback === 'standalone' ? standalone : undefined);
Expand Down
87 changes: 86 additions & 1 deletion packages/agent-bundle/tests/amp-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { validateArtifact } from '../src/build/validate-artifact.ts';
import type { JsonObject } from '../src/core/strict-json.ts';
import type { NormalizedHook, NormalizedPlugin } from '../src/core/types.ts';
import { projectEventDocument } from '../src/events/projection.ts';
import { emptyCompiledRouteGraph } from '../src/routes/graph.ts';
import { compileRouteGraph, emptyCompiledRouteGraph } from '../src/routes/graph.ts';
import { build } from './support/build.ts';
import { runNodeScript } from './support/run-node-script.ts';

Expand Down Expand Up @@ -745,3 +745,88 @@ it('compiles a nested Amp hook wrapper that returns the documented tool.call dec
await rm(projectRoot, { force: true, recursive: true });
}
});

it('runs a relocated standalone event route with its worker inside the Amp plugin', async () => {
const projectRoot = await mkdtemp(join(process.cwd(), 'packages', 'agent-bundle', '.amp-event-route-'));
const config = join(projectRoot, 'agent-bundle.config.ts');
const handler = join(projectRoot, 'src', 'events', 'tool', 'before.tsx');
const outputRoot = join(projectRoot, 'artifact');
const relocated = join(projectRoot, 'relocated');
await mkdir(join(projectRoot, 'src', 'events', 'tool'), { recursive: true });
await writeFile(config, 'export default {};\n');
await writeFile(handler, [
"import { Agent } from '@agent-bundle/runtime';",
"import { createElement } from 'react';",
"export const config = { runtime: 'standalone' };",
"export default async function BeforeTool() {",
" return createElement(Agent.Result, { value: { outcome: 'deny', reason: 'blocked' } });",
'}',
'',
].join('\n'));
const base = plugin();
const hook = eventHook('tool-before', 'beforeTool', 'tool/before');
const model: NormalizedPlugin = {
...base,
hooks: [{
...hook,
id: 'hook:event-route:tool-before',
name: 'event-route-tool-before',
provenance: { kind: 'conventional', sourcePath: handler },
source: handler,
}],
mcpServers: [],
metadata: { ...base.metadata, provenance: { kind: 'config', sourcePath: config } },
skills: [],
targets: [{
id: 'target:amp',
name: 'amp',
provenance: { kind: 'config', sourcePath: config },
}],
};

try {
const registry = createDefaultRegistry();
const routeGraph = await compileRouteGraph(projectRoot, {
plugin: { name: 'amp-review', version: '1.0.0' },
});
const built = await build({
model,
outputRoot,
projectRoot: join(process.cwd(), 'packages', 'agent-bundle'),
registry,
routeGraph,
});
const wrapper = '.amp/plugins/amp-review/hooks/event-route-tool-before.mjs';
const worker = '.amp/plugins/amp-review/hooks/hooks-flight.mjs';
expect(built.manifest.projections).toEqual([{
builtInHost: 'amp',
documents: { entry: '.amp/plugins/amp-review/index.js' },
host: 'amp',
}]);
expect(built.manifest.files.map((file) => file.path)).toContain(worker);
expect(built.manifest.files.map((file) => file.path).filter((path) => path.endsWith('/index.js')))
.toEqual(['.amp/plugins/amp-review/index.js']);
expect(built.manifest.compiler.provenance).toContainEqual(expect.objectContaining({ path: worker }));
expect(built.compileEvidence.assets).toContainEqual(expect.objectContaining({ path: worker }));
expect(await validateArtifact({ artifactRoot: outputRoot, registry })).toEqual([]);

await rename(outputRoot, relocated);
const result = await runNodeScript({
args: [join(relocated, wrapper)],
input: JSON.stringify({
hook_event_name: 'tool.call',
session_id: 'thread-1',
tool_input: { command: 'pwd' },
tool_name: 'shell',
tool_use_id: 'tool-1',
}),
});
expect(result).toEqual({
code: 0,
stderr: '',
stdout: '{"action":"reject-and-continue","message":"blocked"}',
});
} finally {
await rm(projectRoot, { force: true, recursive: true });
}
});
6 changes: 6 additions & 0 deletions packages/agent-bundle/tests/amp-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
): Promise<void> => {
const plugin = join(root, '.amp', 'plugins', name);
await mkdir(join(plugin, 'skills', 'review'), { recursive: true });
await mkdir(join(plugin, 'hooks'), { recursive: true });
await writeFile(join(plugin, 'index.js'), `export default async function () { /* ${marker} */ }\n`);
await writeFile(join(plugin, 'hooks', 'hooks-flight.mjs'), `export const marker = ${JSON.stringify(marker)};\n`);
await writeFile(join(plugin, 'skills', 'review', 'SKILL.md'), '---\nname: review\ndescription: Review code.\n---\n');
await writeFile(join(root, 'outside.txt'), 'must not be installed\n');
await writeInstallFixtureManifest(root, { name, version }, [{ host: 'amp' }]);
Expand Down Expand Up @@ -52,7 +54,7 @@
scope: 'user',
});

expect(installed).toMatchObject({

Check failure on line 57 in packages/agent-bundle/tests/amp-install.test.ts

View workflow job for this annotation

GitHub Actions / Verify (fast, Node 24)

packages/agent-bundle/tests/amp-install.test.ts > installs, replaces, and uninstalls only the receipt-owned Amp directory

expected { …(10) } to match object { …(6) } (4 matching properties omitted from actual) - Expected + Received @@ -1%2C7 +1%2C7 @@ { - "destination"%3A "/tmp/ab-rstest-b48be56b3fc5061d/agent-bundle-amp-install-wKaN2R/home/.config/amp/plugins/amp-install-fixture"%2C + "destination"%3A "/home/runner/.config/amp/plugins/amp-install-fixture"%2C "host"%3A "amp"%2C "mode"%3A "local"%2C "plugin"%3A "amp-install-fixture"%2C "state"%3A "installed"%2C "version"%3A "1.0.0"%2C

Check failure on line 57 in packages/agent-bundle/tests/amp-install.test.ts

View workflow job for this annotation

GitHub Actions / Verify (fast, Node 26)

packages/agent-bundle/tests/amp-install.test.ts > installs, replaces, and uninstalls only the receipt-owned Amp directory

expected { …(10) } to match object { …(6) } (4 matching properties omitted from actual) - Expected + Received @@ -1%2C7 +1%2C7 @@ { - "destination"%3A "/tmp/ab-rstest-069e46f108f21fed/agent-bundle-amp-install-zk9zlT/home/.config/amp/plugins/amp-install-fixture"%2C + "destination"%3A "/home/runner/.config/amp/plugins/amp-install-fixture"%2C "host"%3A "amp"%2C "mode"%3A "local"%2C "plugin"%3A "amp-install-fixture"%2C "state"%3A "installed"%2C "version"%3A "1.0.0"%2C
destination,
host: 'amp',
mode: 'local',
Expand All @@ -65,8 +67,10 @@
'Run `amp plugins list` in a shell to inspect the installed plugin.',
]);
await expect(readFile(join(destination, 'index.js'), 'utf8')).resolves.toContain('first');
await expect(readFile(join(destination, 'hooks', 'hooks-flight.mjs'), 'utf8')).resolves.toContain('first');
await expect(readFile(join(destination, 'outside.txt'), 'utf8')).rejects.toThrow();
expect(await readInstallReceipt(destination)).toMatchObject({
files: expect.arrayContaining(['hooks/hooks-flight.mjs']),
host: 'amp',
mode: 'local',
plugin: pluginName,
Expand Down Expand Up @@ -94,6 +98,7 @@
});
expect(replaced.state).toBe('replaced');
await expect(readFile(join(destination, 'index.js'), 'utf8')).resolves.toContain('second');
await expect(readFile(join(destination, 'hooks', 'hooks-flight.mjs'), 'utf8')).resolves.toContain('second');
await expect(readFile(join(destination, 'disabled-state.json'), 'utf8')).resolves.toBe('{"disabled":true}\n');
await expect(readFile(settings, 'utf8')).resolves.toBe('{"amp.plugins.disabled":["amp-install-fixture"]}\n');

Expand Down Expand Up @@ -122,6 +127,7 @@
state: 'uninstalled',
});
await expect(readFile(join(destination, 'index.js'), 'utf8')).rejects.toThrow();
await expect(readFile(join(destination, 'hooks', 'hooks-flight.mjs'), 'utf8')).rejects.toThrow();
await expect(readFile(join(destination, 'skills', 'review', 'SKILL.md'), 'utf8')).rejects.toThrow();
await expect(readFile(join(destination, 'disabled-state.json'), 'utf8')).resolves.toBe('{"disabled":true}\n');
await expect(readFile(settings, 'utf8')).resolves.toBe('{"amp.plugins.disabled":["amp-install-fixture"]}\n');
Expand Down Expand Up @@ -193,7 +199,7 @@
host: 'amp',
scope: 'user',
});
expect(installed.destination).toBe(join(home, '.config', 'amp', 'plugins', name));

Check failure on line 202 in packages/agent-bundle/tests/amp-install.test.ts

View workflow job for this annotation

GitHub Actions / Verify (fast, Node 24)

packages/agent-bundle/tests/amp-install.test.ts > installs a mixed-case portable plugin name accepted by the Amp planner

expected '/home/runner/.config/amp/plugins/My_P…' to be '/tmp/ab-rstest-b48be56b3fc5061d/agent…' // Object.is equality - Expected + Received - /tmp/ab-rstest-b48be56b3fc5061d/agent-bundle-amp-portable-name-wL9JkC/home/.config/amp/plugins/My_Plugin + /home/runner/.config/amp/plugins/My_Plugin

Check failure on line 202 in packages/agent-bundle/tests/amp-install.test.ts

View workflow job for this annotation

GitHub Actions / Verify (fast, Node 26)

packages/agent-bundle/tests/amp-install.test.ts > installs a mixed-case portable plugin name accepted by the Amp planner

expected '/home/runner/.config/amp/plugins/My_P…' to be '/tmp/ab-rstest-069e46f108f21fed/agent…' // Object.is equality - Expected + Received - /tmp/ab-rstest-069e46f108f21fed/agent-bundle-amp-portable-name-ui5jAn/home/.config/amp/plugins/My_Plugin + /home/runner/.config/amp/plugins/My_Plugin
} finally {
await rm(root, { force: true, recursive: true });
}
Expand Down Expand Up @@ -243,7 +249,7 @@
host: 'amp',
replace: true,
scope: 'user',
})).rejects.toThrow('foreign install');

Check failure on line 252 in packages/agent-bundle/tests/amp-install.test.ts

View workflow job for this annotation

GitHub Actions / Verify (fast, Node 24)

packages/agent-bundle/tests/amp-install.test.ts > refuses to replace a foreign Amp directory even with --replace

promise resolved "{ …(11) }" instead of rejecting - Expected + Received - Error { - "message"%3A "rejected promise"%2C + { + "bundleRoot"%3A "/tmp/ab-rstest-b48be56b3fc5061d/agent-bundle-amp-foreign-WOVYO2/bundle"%2C + "contentHash"%3A "555938e4ee648678d50975d424fe3310a350658b714110b083c108558a003988"%2C + "destination"%3A "/home/runner/.config/amp/plugins/amp-install-fixture"%2C + "host"%3A "amp"%2C + "mode"%3A "local"%2C + "nextSteps"%3A [ + "Open Amp’s command palette with Ctrl+O and run `plugins%3A reload`."%2C + "Run `amp plugins list` in a shell to inspect the installed plugin."%2C + ]%2C + "plugin"%3A "amp-install-fixture"%2C + "previousContentHash"%3A "0f8765a841f3820b54ffe92aa5f8812fae8599d5a20ffc62058cf682da58da8c"%2C + "receipt"%3A "/home/runner/.config/amp/plugins/amp-install-fixture/.agent-bundle-install.json"%2C + "state"%3A "replaced"%2C + "version"%3A "1.0.0"%2C }

Check failure on line 252 in packages/agent-bundle/tests/amp-install.test.ts

View workflow job for this annotation

GitHub Actions / Verify (fast, Node 26)

packages/agent-bundle/tests/amp-install.test.ts > refuses to replace a foreign Amp directory even with --replace

promise resolved "{ …(11) }" instead of rejecting - Expected + Received - Error { - "message"%3A "rejected promise"%2C + { + "bundleRoot"%3A "/tmp/ab-rstest-069e46f108f21fed/agent-bundle-amp-foreign-Qpqxp6/bundle"%2C + "contentHash"%3A "555938e4ee648678d50975d424fe3310a350658b714110b083c108558a003988"%2C + "destination"%3A "/home/runner/.config/amp/plugins/amp-install-fixture"%2C + "host"%3A "amp"%2C + "mode"%3A "local"%2C + "nextSteps"%3A [ + "Open Amp’s command palette with Ctrl+O and run `plugins%3A reload`."%2C + "Run `amp plugins list` in a shell to inspect the installed plugin."%2C + ]%2C + "plugin"%3A "amp-install-fixture"%2C + "previousContentHash"%3A "0f8765a841f3820b54ffe92aa5f8812fae8599d5a20ffc62058cf682da58da8c"%2C + "receipt"%3A "/home/runner/.config/amp/plugins/amp-install-fixture/.agent-bundle-install.json"%2C + "state"%3A "replaced"%2C + "version"%3A "1.0.0"%2C }
await expect(readFile(join(destination, 'index.js'), 'utf8')).resolves.toContain('foreign');
} finally {
await rm(root, { force: true, recursive: true });
Expand All @@ -268,7 +274,7 @@
home,
host: 'amp',
scope: 'user',
})).rejects.toThrow('unsupported filesystem entry');

Check failure on line 277 in packages/agent-bundle/tests/amp-install.test.ts

View workflow job for this annotation

GitHub Actions / Verify (fast, Node 24)

packages/agent-bundle/tests/amp-install.test.ts > refuses a symlinked Amp plugin ancestor before writing outside the host root

promise resolved "{ …(10) }" instead of rejecting - Expected + Received - Error { - "message"%3A "rejected promise"%2C + { + "bundleRoot"%3A "/tmp/ab-rstest-b48be56b3fc5061d/agent-bundle-amp-symlink-InDvAG/bundle"%2C + "contentHash"%3A "555938e4ee648678d50975d424fe3310a350658b714110b083c108558a003988"%2C + "destination"%3A "/home/runner/.config/amp/plugins/amp-install-fixture"%2C + "host"%3A "amp"%2C + "mode"%3A "local"%2C + "nextSteps"%3A [ + "Open Amp’s command palette with Ctrl+O and run `plugins%3A reload`."%2C + "Run `amp plugins list` in a shell to inspect the installed plugin."%2C + ]%2C + "plugin"%3A "amp-install-fixture"%2C + "receipt"%3A "/home/runner/.config/amp/plugins/amp-install-fixture/.agent-bundle-install.json"%2C + "state"%3A "already-installed"%2C + "version"%3A "1.0.0"%2C }

Check failure on line 277 in packages/agent-bundle/tests/amp-install.test.ts

View workflow job for this annotation

GitHub Actions / Verify (fast, Node 26)

packages/agent-bundle/tests/amp-install.test.ts > refuses a symlinked Amp plugin ancestor before writing outside the host root

promise resolved "{ …(10) }" instead of rejecting - Expected + Received - Error { - "message"%3A "rejected promise"%2C + { + "bundleRoot"%3A "/tmp/ab-rstest-069e46f108f21fed/agent-bundle-amp-symlink-n3PvoI/bundle"%2C + "contentHash"%3A "555938e4ee648678d50975d424fe3310a350658b714110b083c108558a003988"%2C + "destination"%3A "/home/runner/.config/amp/plugins/amp-install-fixture"%2C + "host"%3A "amp"%2C + "mode"%3A "local"%2C + "nextSteps"%3A [ + "Open Amp’s command palette with Ctrl+O and run `plugins%3A reload`."%2C + "Run `amp plugins list` in a shell to inspect the installed plugin."%2C + ]%2C + "plugin"%3A "amp-install-fixture"%2C + "receipt"%3A "/home/runner/.config/amp/plugins/amp-install-fixture/.agent-bundle-install.json"%2C + "state"%3A "already-installed"%2C + "version"%3A "1.0.0"%2C }
expect(await readdir(outside)).toEqual([]);
} finally {
await rm(root, { force: true, recursive: true });
Expand Down
41 changes: 41 additions & 0 deletions packages/agent-bundle/tests/entries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,45 @@ describe('event-route preflight source graph (#595)', () => {
expect(executor.virtualSource).toContain('requestEventRuntime');
expect(executor.virtualSource).toContain('preflight-entries@1.0.0');
});

it('emits the standalone worker beside a nested host wrapper', () => {
const standalone = {
...hook,
eventRoute: { event: 'tool/before' as const, fallback: 'none' as const, runtime: 'standalone' as const },
targets: ['amp'],
};
const nested = planHooks({ ...model, hooks: [standalone] }, 'amp', {
commandRoot: '',
encodePlaygroundInput: (input) => input,
encodePlaygroundOutput: (result) => result,
eventNames: {},
eventRouteNames: { 'tool/before': 'tool.call' },
hostContractRevision: 'test',
manifestPath: '.amp/hooks.json',
matchers: {},
registration: 'api',
wrapperPath: (candidate) => `.amp/plugins/preflight-entries/hooks/${candidate.name}.mjs`,
wrapperSource: () => 'config-hook-only\n',
}).hookEntries;
const rootWrapper = {
...nested[0]!,
relativePath: 'hooks/event-route-tool-before.claude.mjs',
target: 'claude',
};
const combined = [rootWrapper, ...nested];
const workers = [
'hooks/hooks-flight.mjs',
'.amp/plugins/preflight-entries/hooks/hooks-flight.mjs',
];

expect(planCompiledHooks(combined, { outDir: '/tmp/artifact' })
.flatMap((entry) => entry.workerOutput === undefined ? [] : [entry.workerOutput]))
.toEqual(workers.map((worker) => `/tmp/artifact/${worker}`));
const outputs = planHooksSurface(combined, {
artifactEpoch: 'preflight-entries@1.0.0',
outDir: '/tmp/artifact',
plugin: { name: 'preflight-entries', version: '1.0.0' },
}).entries.map((entry) => entry.outputRelativePath);
expect(outputs).toEqual(expect.arrayContaining(workers));
});
});
26 changes: 21 additions & 5 deletions packages/agent-bundle/tests/route-invocation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,20 @@ it('publishes failed event invocations with native provenance', async () => {
});
});

it('keeps hostless shared events on their declared standalone fallback', async () => {
it.each([
{
host: 'claude',
label: 'the shared root',
worker: 'hooks/hooks-flight.mjs',
wrapper: 'hooks/event-route-tool-after.claude.mjs',
},
{
host: 'amp',
label: 'a nested Amp plugin',
worker: '.amp/plugins/fixture/hooks/hooks-flight.mjs',
wrapper: '.amp/plugins/fixture/hooks/event-route-tool-after.mjs',
},
] as const)('keeps hostless shared events on the standalone fallback in $label', async ({ host, worker, wrapper }) => {
const route = {
config: [],
event: 'tool/after',
Expand Down Expand Up @@ -492,14 +505,14 @@ it('keeps hostless shared events on their declared standalone fallback', async (
manifest: {
executables: {
hooks: [{
host: 'claude',
host,
kind: 'event-route',
path: 'hooks/event-route-tool-after.claude.mjs',
path: wrapper,
routeId: route.id,
}],
mcpServers: [],
},
files: [{ path: 'hooks/hooks-flight.mjs' }],
files: [{ path: worker }],
routes: {
digest: 'digest',
events: [{
Expand Down Expand Up @@ -531,7 +544,10 @@ it('keeps hostless shared events on their declared standalone fallback', async (
diagnostics: [{ code: 'AB8236' }],
status: 'failed',
});
expect(production).toEqual({ executable: 'hooks/hooks-flight.mjs', kind: 'direct' });
expect(production).toEqual({
executable: worker,
kind: 'direct',
});
});

const echoRoute = {
Expand Down
Loading
Loading