@vellumai/assistant 0.8.7-dev.202606052118.34cd356 → 0.8.7-dev.202606052220.6efc86d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/examples/plugins/echo/README.md +55 -61
  2. package/examples/plugins/echo/hooks/post-tool-use.ts +18 -0
  3. package/examples/plugins/echo/hooks/stop.ts +16 -0
  4. package/examples/plugins/echo/hooks/user-prompt-submit.ts +18 -0
  5. package/examples/plugins/echo/package.json +1 -2
  6. package/examples/plugins/echo/src/emit.ts +19 -0
  7. package/package.json +1 -1
  8. package/src/__tests__/conversation-runtime-assembly.test.ts +21 -17
  9. package/src/__tests__/gateway-only-guard.test.ts +12 -2
  10. package/src/__tests__/plugin-api-shim.test.ts +3 -6
  11. package/src/__tests__/plugin-bootstrap.test.ts +12 -23
  12. package/src/__tests__/plugin-registry.test.ts +3 -49
  13. package/src/__tests__/plugin-types.test.ts +0 -35
  14. package/src/__tests__/user-plugin-loader.test.ts +54 -286
  15. package/src/acp/__tests__/prepare-agent-env.test.ts +137 -0
  16. package/src/acp/__tests__/session-manager-resume.test.ts +44 -3
  17. package/src/acp/auto-install.test.ts +75 -4
  18. package/src/acp/auto-install.ts +8 -5
  19. package/src/acp/prepare-agent-env.ts +83 -29
  20. package/src/acp/resolve-agent.test.ts +173 -1
  21. package/src/acp/resolve-agent.ts +101 -11
  22. package/src/acp/resume-hint.ts +6 -3
  23. package/src/acp/session-manager.ts +10 -4
  24. package/src/acp/types.ts +8 -0
  25. package/src/config/bundled-skills/acp/SKILL.md +30 -12
  26. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  27. package/src/config/feature-flag-registry.json +2 -1
  28. package/src/context/window-manager.ts +2 -1
  29. package/src/daemon/conversation-agent-loop.ts +0 -5
  30. package/src/daemon/conversation-runtime-assembly.ts +40 -21
  31. package/src/daemon/external-plugins-bootstrap.ts +11 -11
  32. package/src/plugin-api/index.ts +0 -5
  33. package/src/plugins/external-api.ts +2 -2
  34. package/src/plugins/pipeline.ts +6 -290
  35. package/src/plugins/registry.ts +9 -37
  36. package/src/plugins/types.ts +30 -145
  37. package/src/plugins/user-loader.ts +30 -127
  38. package/src/runtime/routes/__tests__/acp-routes.test.ts +30 -0
  39. package/src/tools/acp/spawn.test.ts +48 -0
  40. package/src/tools/acp/spawn.ts +18 -5
  41. package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +4 -5
  42. package/docs/plugins.md +0 -828
  43. package/examples/plugins/echo/register.ts +0 -133
  44. package/src/__tests__/pipeline-runner.test.ts +0 -553
@@ -1,46 +1,59 @@
1
1
  # Echo plugin
2
2
 
3
- Minimal example plugin. Observes every assistant pipeline and logs one JSON
4
- line per invocation to `stderr`:
3
+ Minimal example plugin. Observes the assistant's turn-lifecycle hooks and logs
4
+ one JSON line per hook invocation to `stderr`:
5
5
 
6
6
  ```json
7
7
  {
8
8
  "plugin": "echo",
9
- "pipeline": "compaction",
10
- "durationMs": 1590,
11
- "outcome": "success"
9
+ "hook": "post-tool-use",
10
+ "conversationId": "conv_abc123"
12
11
  }
13
12
  ```
14
13
 
15
14
  Use this as a starting point for writing your own plugin, or as a quick way
16
- to eyeball which pipelines fire during a conversation and how long they
17
- take.
15
+ to eyeball which hooks fire during a conversation.
18
16
 
19
- For the full plugin authoring guide, see
20
- [`assistant/docs/plugins.md`](../../../docs/plugins.md).
17
+ For the full plugin authoring guide — manifest shape, every contribution
18
+ surface, hook patterns, and conventions — see
19
+ [`experimental/plugins/README.md`](../../../../experimental/plugins/README.md).
20
+ [`simple-memory`](../../../../experimental/plugins/simple-memory/) is the
21
+ canonical reference implementation that exercises every wired surface.
21
22
 
22
23
  ## What it does
23
24
 
24
- - Registers one observer middleware per slot in
25
- `PipelineMiddlewareMap` — `compaction`.
26
- - Each middleware calls `next(args)` to pass the request through unchanged,
27
- measures wall-clock duration, and emits one line to `stderr` whether the
28
- downstream succeeded or threw.
29
- - Never modifies arguments, never rewrites results, never swallows errors.
30
- It is purely observational — safe to stack alongside any other plugin.
25
+ - Contributes one observer hook per turn-lifecycle event:
26
+ `user-prompt-submit`, `post-tool-use`, and `stop`.
27
+ - Each hook emits one line to `stderr` and returns `void`, so the threaded
28
+ context flows through unchanged.
29
+ - Never modifies the turn's messages, tool results, or stop decision. It is
30
+ purely observational — safe to stack alongside any other plugin.
31
+
32
+ ## Directory layout
33
+
34
+ The assistant discovers a plugin by its `package.json` manifest and builds the
35
+ `Plugin` from the interface directories — `hooks/<name>.ts` files whose default
36
+ export is the hook function. Files under `src/` are internal helpers and are
37
+ not walked by the loader.
38
+
39
+ ```
40
+ echo/
41
+ ├── package.json # Manifest (name + @vellumai/plugin-api range)
42
+ ├── README.md
43
+ ├── hooks/
44
+ │ ├── user-prompt-submit.ts # default export = hook function
45
+ │ ├── post-tool-use.ts
46
+ │ └── stop.ts
47
+ └── src/
48
+ └── emit.ts # shared stderr emitter (not a surface)
49
+ ```
31
50
 
32
51
  ## Install locally
33
52
 
34
53
  The assistant scans `<workspaceDir>/plugins/*` (e.g.
35
- `~/.vellum/workspace/plugins/`) for subdirectories containing a
36
- `register.{ts,js}` file and dynamic-imports each one during assistant
37
- startup. Dropping (or symlinking) this directory in place is enough to
38
- enable it.
39
-
40
- The plugin reads `registerPlugin` from `globalThis.__vellumPluginRuntime`,
41
- which the daemon attaches before scanning plugins. This works against both
42
- the `bun --compile`-bundled daemon binary AND a daemon running from
43
- source — no special install procedure required either way.
54
+ `~/.vellum/workspace/plugins/`) for subdirectories containing a `package.json`
55
+ and loads each one during assistant startup. Dropping (or symlinking) this
56
+ directory in place is enough to enable it.
44
57
 
45
58
  ### Option 1 — symlink from the repo (simplest in-repo dev)
46
59
 
@@ -57,23 +70,10 @@ pick up changes.
57
70
  ### Option 2 — standalone copy
58
71
 
59
72
  A plain `cp -R` of this directory into `~/.vellum/workspace/plugins/echo/`
60
- works for the runtime imports (which go through the global bridge), but
61
- the `import type` lines at the top of `register.ts` still resolve into
62
- the in-repo assistant source tree. If your standalone copy lives outside
63
- a vellum-assistant checkout, rewrite those `import type` paths to point
64
- at an absolute path inside any checkout — they're erased at compile time
65
- and have no module-identity effect at runtime:
66
-
67
- ```ts
68
- // before (repo-local):
69
- import type { VellumPluginRuntime } from "../../../src/plugins/external-api.js";
70
- import type { Plugin } from "../../../src/plugins/types.js";
71
- // after (standalone, edit to your checkout path):
72
- import type { VellumPluginRuntime } from "/path/to/vellum-assistant/assistant/src/plugins/external-api.js";
73
- import type { Plugin } from "/path/to/vellum-assistant/assistant/src/plugins/types.js";
74
- ```
75
-
76
- No runtime-import rewriting is needed — the bridge already handles that.
73
+ works as-is. The hooks import their types from the public `@vellumai/plugin-api`
74
+ specifier, which the daemon materializes as a workspace-level shim before it
75
+ loads any plugin — so the copied directory resolves it without any path
76
+ rewriting, in or out of a vellum-assistant checkout.
77
77
 
78
78
  ### Restart the assistant
79
79
 
@@ -87,28 +87,23 @@ vellum restart
87
87
  ## Verify it works
88
88
 
89
89
  With the plugin installed and the assistant restarted, send any message
90
- that exercises a pipeline — a conversation turn, a tool call, a title
91
- generation — and tail the assistant's stderr log:
90
+ that exercises a turn — a conversation reply, a tool call — and tail the
91
+ assistant's stderr log:
92
92
 
93
93
  ```bash
94
94
  tail -f ~/.vellum/daemon.log
95
95
  ```
96
96
 
97
- You should see one line per pipeline invocation, similar to:
97
+ You should see one line per hook invocation, similar to:
98
98
 
99
99
  ```json
100
100
  {
101
101
  "plugin": "echo",
102
- "pipeline": "compaction",
103
- "durationMs": 1590,
104
- "outcome": "success"
102
+ "hook": "post-tool-use",
103
+ "conversationId": "conv_abc123"
105
104
  }
106
105
  ```
107
106
 
108
- If a pipeline throws (for example, a tool that errors out), you'll see a
109
- line with `"outcome":"error"` — the plugin rethrows after logging so the
110
- original error still propagates.
111
-
112
107
  ## Uninstall
113
108
 
114
109
  Remove the symlink (or the copied directory) and restart the assistant:
@@ -120,14 +115,13 @@ vellum restart
120
115
 
121
116
  ## Next steps
122
117
 
123
- - Read [`assistant/docs/plugins.md`](../../../docs/plugins.md) for the full
124
- plugin authoring guide: manifest shape, middleware patterns
125
- (observe / transform / short-circuit / veto), strict-fail semantics, the
126
- per-pipeline timeout table, credential and config access, and
127
- troubleshooting.
118
+ - Read [`experimental/plugins/README.md`](../../../../experimental/plugins/README.md)
119
+ for the full plugin authoring guide: manifest shape, every contribution
120
+ surface, hook patterns (observe / transform), tool contributions, and
121
+ conventions.
128
122
  - Look at the first-party default plugins under
129
- `assistant/src/plugins/defaults/` for examples of non-observational
130
- middleware.
123
+ `assistant/src/plugins/defaults/` for examples of hooks that transform a
124
+ turn rather than just observing it.
131
125
  - Build your own plugin by copying this directory, renaming the manifest
132
- `name`, and replacing the observer with a middleware that does whatever
133
- you need.
126
+ `name`, and replacing the observer hooks with ones that do whatever you
127
+ need.
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `post-tool-use` hook for the echo plugin.
3
+ *
4
+ * Purely observational — logs one structured line to stderr and returns
5
+ * `void`, leaving the tool result untouched.
6
+ *
7
+ * Convention: the default export is the function the harness invokes.
8
+ */
9
+
10
+ import type { PostToolUseContext } from "@vellumai/plugin-api";
11
+
12
+ import { emit } from "../src/emit.js";
13
+
14
+ export default async function postToolUse(
15
+ ctx: PostToolUseContext,
16
+ ): Promise<void> {
17
+ emit("post-tool-use", ctx.conversationId);
18
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `stop` hook for the echo plugin.
3
+ *
4
+ * Purely observational — logs one structured line to stderr and returns
5
+ * `void`, leaving the stop decision untouched.
6
+ *
7
+ * Convention: the default export is the function the harness invokes.
8
+ */
9
+
10
+ import type { StopContext } from "@vellumai/plugin-api";
11
+
12
+ import { emit } from "../src/emit.js";
13
+
14
+ export default async function stop(ctx: StopContext): Promise<void> {
15
+ emit("stop", ctx.conversationId);
16
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `user-prompt-submit` hook for the echo plugin.
3
+ *
4
+ * Purely observational — logs one structured line to stderr and returns
5
+ * `void`, leaving the threaded context (message list, injections) untouched.
6
+ *
7
+ * Convention: the default export is the function the harness invokes.
8
+ */
9
+
10
+ import type { UserPromptSubmitContext } from "@vellumai/plugin-api";
11
+
12
+ import { emit } from "../src/emit.js";
13
+
14
+ export default async function userPromptSubmit(
15
+ ctx: UserPromptSubmitContext,
16
+ ): Promise<void> {
17
+ emit("user-prompt-submit", ctx.conversationId);
18
+ }
@@ -1,11 +1,10 @@
1
1
  {
2
2
  "name": "@vellumai/plugin-echo-example",
3
3
  "version": "0.1.0",
4
- "description": "Example plugin that observes every assistant pipeline and logs one structured line per invocation to stderr. Meant as an authoring reference — not shipped with the assistant runtime.",
4
+ "description": "Example plugin that observes every assistant turn-lifecycle hook and logs one structured line per invocation to stderr. Meant as an authoring reference — not shipped with the assistant runtime.",
5
5
  "private": true,
6
6
  "license": "MIT",
7
7
  "type": "module",
8
- "main": "./register.ts",
9
8
  "engines": {
10
9
  "node": ">=20.12.0"
11
10
  },
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Shared stderr emitter for the echo plugin's hooks.
3
+ *
4
+ * Files under `src/` are internal helpers — the external-plugin loader does
5
+ * not walk this directory, so it contributes no surface of its own. The
6
+ * `hooks/` files import from here.
7
+ */
8
+
9
+ export const PLUGIN_NAME = "echo";
10
+
11
+ /**
12
+ * One line written to stderr per hook invocation. Kept intentionally compact —
13
+ * pino-style JSON so operators can pipe the assistant's stderr through `jq`
14
+ * without reshaping.
15
+ */
16
+ export function emit(hook: string, conversationId: string): void {
17
+ const record = { plugin: PLUGIN_NAME, hook, conversationId };
18
+ process.stderr.write(`${JSON.stringify(record)}\n`);
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.8.7-dev.202606052118.34cd356",
3
+ "version": "0.8.7-dev.202606052220.6efc86d",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -122,6 +122,7 @@ function seedActiveSurfaceConversation(
122
122
  surfaceId: string,
123
123
  data: SurfaceData,
124
124
  channelCapabilities?: ChannelCapabilities,
125
+ commandIntent?: { type: string; payload?: string; languageCode?: string },
125
126
  ): void {
126
127
  setConversation(conversationId, {
127
128
  conversationId,
@@ -134,6 +135,7 @@ function seedActiveSurfaceConversation(
134
135
  { surfaceType: SurfaceType; data: SurfaceData }
135
136
  >([[surfaceId, { surfaceType: "dynamic_page", data }]]),
136
137
  channelCapabilities: channelCapabilities ?? undefined,
138
+ commandIntent,
137
139
  } as never);
138
140
  }
139
141
 
@@ -149,6 +151,7 @@ function clearWorkspaceContext(): void {
149
151
  // between tests keeps suites asserting absence unaffected.
150
152
  function seedChannelCapabilitiesConversation(
151
153
  caps: ChannelCapabilities | null,
154
+ transportHints?: string[],
152
155
  ): void {
153
156
  setConversation("runtime-assembly-fallback", {
154
157
  conversationId: "runtime-assembly-fallback",
@@ -157,6 +160,7 @@ function seedChannelCapabilitiesConversation(
157
160
  workspaceTopLevelDirty: false,
158
161
  surfaceState: new Map(),
159
162
  channelCapabilities: caps ?? undefined,
163
+ transportHints,
160
164
  } as never);
161
165
  }
162
166
 
@@ -819,7 +823,6 @@ describe("applyRuntimeInjections — injection mode", () => {
819
823
  };
820
824
 
821
825
  const fullOptions = {
822
- channelCommandContext: { type: "start" } as const,
823
826
  unifiedTurnContext:
824
827
  "<turn_context>\ncurrent_time: 2026-03-04 (Tuesday) 12:00:00 +00:00 (UTC)\ninterface: telegram\n</turn_context>",
825
828
  isNonInteractive: true,
@@ -849,6 +852,7 @@ describe("applyRuntimeInjections — injection mode", () => {
849
852
  "sf_1",
850
853
  { html: "<div>test</div>" },
851
854
  channelCapabilities,
855
+ { type: "start" },
852
856
  );
853
857
  });
854
858
  afterEach(() => {
@@ -3044,12 +3048,13 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3044
3048
  { loader: () => rows, trustClass: "guardian" },
3045
3049
  );
3046
3050
 
3047
- seedChannelCapabilitiesConversation(slackChannelCaps);
3051
+ seedChannelCapabilitiesConversation(slackChannelCaps, [
3052
+ "thread context: ...",
3053
+ ]);
3048
3054
  const { messages: result } = await applyRuntimeInjections(
3049
3055
  [{ role: "user", content: [{ type: "text", text: "current turn" }] }],
3050
3056
  {
3051
3057
  slackChronologicalMessages,
3052
- transportHints: ["thread context: ..."],
3053
3058
  },
3054
3059
  );
3055
3060
 
@@ -3074,12 +3079,10 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3074
3079
  chatType: "im",
3075
3080
  };
3076
3081
 
3077
- seedChannelCapabilitiesConversation(slackDmCaps);
3082
+ seedChannelCapabilitiesConversation(slackDmCaps, ["dm context: ..."]);
3078
3083
  const { messages: result } = await applyRuntimeInjections(
3079
3084
  [{ role: "user", content: [{ type: "text", text: "hi DM" }] }],
3080
- {
3081
- transportHints: ["dm context: ..."],
3082
- },
3085
+ {},
3083
3086
  );
3084
3087
 
3085
3088
  const allText = result
@@ -3093,18 +3096,19 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3093
3096
 
3094
3097
  // ── transport_hints kept for non-slack channels ───────────────────────
3095
3098
  test("non-slack conversations still receive <transport_hints>", async () => {
3096
- seedChannelCapabilitiesConversation({
3097
- channel: "telegram",
3098
- dashboardCapable: false,
3099
- supportsDynamicUi: false,
3100
- supportsVoiceInput: false,
3101
- chatType: "private",
3102
- });
3103
- const { messages: result } = await applyRuntimeInjections(
3104
- [{ role: "user", content: [{ type: "text", text: "hi" }] }],
3099
+ seedChannelCapabilitiesConversation(
3105
3100
  {
3106
- transportHints: ["please answer concisely"],
3101
+ channel: "telegram",
3102
+ dashboardCapable: false,
3103
+ supportsDynamicUi: false,
3104
+ supportsVoiceInput: false,
3105
+ chatType: "private",
3107
3106
  },
3107
+ ["please answer concisely"],
3108
+ );
3109
+ const { messages: result } = await applyRuntimeInjections(
3110
+ [{ role: "user", content: [{ type: "text", text: "hi" }] }],
3111
+ {},
3108
3112
  );
3109
3113
  const allText = result
3110
3114
  .flatMap((m) => m.content)
@@ -27,6 +27,7 @@ const ALLOWLIST = new Set([
27
27
  "clients/shared/App/Auth/PlatformOAuthService.swift", // comment explaining runtimeUrl vs platformUrl
28
28
  "clients/macos/vellum-assistant/App/AppDelegate.swift",
29
29
  "clients/macos/vellum-assistant/Features/Settings/SettingsConnectTab.swift",
30
+ "apps/macos/src/main/bundle-flow.ts", // Electron main calls the local gateway (gatewayPort) with a Guardian token to scan bundles
30
31
  ".claude/skills/update/SKILL.md", // daemon health check script
31
32
 
32
33
  // --- Test fixtures that poll the daemon directly (gateway may require auth) ---
@@ -84,12 +85,21 @@ function isGatewayInternal(filePath: string): boolean {
84
85
  return filePath.startsWith("gateway/");
85
86
  }
86
87
 
88
+ /** Additional files allowed for the interpolated-port check only (use gateway port, not runtime). */
89
+ const INTERPOLATED_PORT_ALLOWLIST = new Set([
90
+ "apps/macos/src/main/bundle-flow.ts",
91
+ ]);
92
+
87
93
  /** Shared violation filter: exempt test files, gateway internals, and allowlisted paths. */
88
- function filterViolations(files: string[]): string[] {
94
+ function filterViolations(
95
+ files: string[],
96
+ extraAllowlist?: Set<string>,
97
+ ): string[] {
89
98
  return files.filter((f) => {
90
99
  if (isTestFile(f)) return false;
91
100
  if (isGatewayInternal(f)) return false;
92
101
  if (ALLOWLIST.has(f)) return false;
102
+ if (extraAllowlist?.has(f)) return false;
93
103
  return true;
94
104
  });
95
105
  }
@@ -181,7 +191,7 @@ describe("gateway-only API consumption guard", () => {
181
191
  }
182
192
 
183
193
  const files = grepOutput.split("\n").filter((f) => f.length > 0);
184
- const violations = filterViolations(files);
194
+ const violations = filterViolations(files, INTERPOLATED_PORT_ALLOWLIST);
185
195
 
186
196
  if (violations.length > 0) {
187
197
  const message = [
@@ -44,10 +44,7 @@ describe("buildShimSource", () => {
44
44
  });
45
45
 
46
46
  test("handles an empty export list (today's types-only surface)", () => {
47
- const source = buildShimSource(
48
- [],
49
- Symbol.for("vellum.plugin-api.v1"),
50
- );
47
+ const source = buildShimSource([], Symbol.for("vellum.plugin-api.v1"));
51
48
  expect(source).toBe(
52
49
  `const api = globalThis[Symbol.for("vellum.plugin-api.v1")];\n`,
53
50
  );
@@ -109,7 +106,7 @@ describe("ensurePluginApiShim", () => {
109
106
  const pluginDir = join(workspaceDir, "plugins", "fake-plugin");
110
107
  await mkdir(pluginDir, { recursive: true });
111
108
  await writeFile(
112
- join(pluginDir, "register.js"),
109
+ join(pluginDir, "probe.js"),
113
110
  `import * as api from "@vellumai/plugin-api";\nexport { api };\n`,
114
111
  );
115
112
 
@@ -118,7 +115,7 @@ describe("ensurePluginApiShim", () => {
118
115
  // → plugin-api namespace. If any link in that chain is broken, this
119
116
  // import throws.
120
117
  const mod: { api: Record<string, unknown> } = await import(
121
- join(pluginDir, "register.js")
118
+ join(pluginDir, "probe.js")
122
119
  );
123
120
  expect(mod.api).toBeDefined();
124
121
  });
@@ -38,13 +38,11 @@ import { RiskLevel } from "../permissions/types.js";
38
38
  import { registerDefaultPlugins } from "../plugins/defaults/index.js";
39
39
  import {
40
40
  closeRegistration,
41
- getMiddlewaresFor,
42
41
  getRegisteredPlugins,
43
42
  registerPlugin,
44
43
  resetPluginRegistryForTests,
45
44
  } from "../plugins/registry.js";
46
45
  import {
47
- type PipelineMiddlewareMap,
48
46
  type Plugin,
49
47
  PluginExecutionError,
50
48
  type PluginInitContext,
@@ -485,33 +483,24 @@ describe("plugin bootstrap", () => {
485
483
  expect(initFired).toBe(false);
486
484
  });
487
485
 
488
- test("requiresFlag disabled: plugin middleware is dropped from the registry", async () => {
489
- // Regression: prior to the unregisterPlugin() call on the flag-gated skip
490
- // path, `getMiddlewaresFor()` iterated over every entry in
491
- // `registeredPlugins` — so a gated-off plugin's middleware still ran on
492
- // every pipeline invocation even though `init()` had never fired to set up
493
- // the state it depended on.
494
- setOverridesForTesting({ "plugin-middleware-disabled": false });
495
-
496
- const gatedMiddleware: PipelineMiddlewareMap["compaction"] = async (
497
- args,
498
- next,
499
- ) => next(args);
486
+ test("requiresFlag disabled: the skipped plugin is removed from the registry", async () => {
487
+ // Regression: a flag-gated skip must call `unregisterPlugin()` so the
488
+ // gated-off plugin does not linger in `registeredPlugins` with its
489
+ // `init()` never having fired to set up the state it depends on.
490
+ setOverridesForTesting({ "plugin-registry-disabled": false });
491
+
500
492
  const plugin = buildPlugin(
501
- "gated-middleware",
502
- {
503
- middleware: { compaction: gatedMiddleware },
504
- },
505
- { requiresFlag: ["plugin-middleware-disabled"] },
493
+ "gated-registry",
494
+ {},
495
+ { requiresFlag: ["plugin-registry-disabled"] },
506
496
  );
507
497
  registerPlugin(plugin);
508
498
 
509
499
  await bootstrapPlugins();
510
500
 
511
- // The middleware slot must not expose the flag-gated plugin's contribution.
512
- // The default plugins also contribute compaction middleware, so we key on
513
- // identity rather than asserting an empty list.
514
- expect(getMiddlewaresFor("compaction")).not.toContain(gatedMiddleware);
501
+ // The gated-off plugin must not survive in the registry snapshot.
502
+ const names = getRegisteredPlugins().map((p) => p.manifest.name);
503
+ expect(names).not.toContain("gated-registry");
515
504
  });
516
505
 
517
506
  test("requiresFlag disabled: no shutdown hook entry installed for the skipped plugin", async () => {
@@ -2,26 +2,19 @@
2
2
  * Tests for the plugin registry (PR 13).
3
3
  *
4
4
  * Covers successful registration, required-field and duplicate-name
5
- * validation, capability-version negotiation error messaging, injector
6
- * ordering, and middleware collection order.
5
+ * validation, capability-version negotiation error messaging, and
6
+ * registration order.
7
7
  */
8
8
 
9
9
  import { beforeEach, describe, expect, test } from "bun:test";
10
10
 
11
11
  import {
12
12
  closeRegistration,
13
- getMiddlewaresFor,
14
13
  getRegisteredPlugins,
15
14
  registerPlugin,
16
15
  resetPluginRegistryForTests,
17
16
  } from "../plugins/registry.js";
18
- import {
19
- type CompactionArgs,
20
- type CompactionResult,
21
- type Middleware,
22
- type Plugin,
23
- PluginExecutionError,
24
- } from "../plugins/types.js";
17
+ import { type Plugin, PluginExecutionError } from "../plugins/types.js";
25
18
 
26
19
  /** Build a minimal, valid plugin with the given name and optional extras. */
27
20
  function buildPlugin(
@@ -147,45 +140,6 @@ describe("plugin registry", () => {
147
140
  expect(() => registerPlugin(bad)).toThrow(/manifest\.version is required/);
148
141
  });
149
142
 
150
- test("getMiddlewaresFor returns middleware in registration order", () => {
151
- const firstMw: Middleware<CompactionArgs, CompactionResult> = async (
152
- args,
153
- next,
154
- ) => next(args);
155
- const secondMw: Middleware<CompactionArgs, CompactionResult> = async (
156
- args,
157
- next,
158
- ) => next(args);
159
-
160
- registerPlugin(
161
- buildPlugin("plugin-first", { middleware: { compaction: firstMw } }),
162
- );
163
- registerPlugin(
164
- buildPlugin("plugin-second", { middleware: { compaction: secondMw } }),
165
- );
166
-
167
- const middlewares = getMiddlewaresFor("compaction");
168
- expect(middlewares).toHaveLength(2);
169
- // Identity comparison proves the middleware instances come back in
170
- // registration order — outer→inner composition semantics belong to the
171
- // pipeline runner (PR 12), not the registry.
172
- expect(middlewares[0]).toBe(firstMw);
173
- expect(middlewares[1]).toBe(secondMw);
174
- });
175
-
176
- test("getMiddlewaresFor skips plugins without a middleware for the pipeline", () => {
177
- const mw: Middleware<CompactionArgs, CompactionResult> = async (
178
- args,
179
- next,
180
- ) => next(args);
181
- registerPlugin(buildPlugin("bare"));
182
- registerPlugin(buildPlugin("has-mw", { middleware: { compaction: mw } }));
183
-
184
- const middlewares = getMiddlewaresFor("compaction");
185
- expect(middlewares).toHaveLength(1);
186
- expect(middlewares[0]).toBe(mw);
187
- });
188
-
189
143
  test("getRegisteredPlugins reflects registration order", () => {
190
144
  registerPlugin(buildPlugin("one"));
191
145
  registerPlugin(buildPlugin("two"));
@@ -13,14 +13,10 @@ import { describe, expect, test } from "bun:test";
13
13
  import type { TrustContext } from "../daemon/trust-context.js";
14
14
  import { RiskLevel } from "../permissions/types.js";
15
15
  import {
16
- type CompactionArgs,
17
- type CompactionResult,
18
- type Middleware,
19
16
  type Plugin,
20
17
  PluginExecutionError,
21
18
  type PluginInitContext,
22
19
  type PluginManifest,
23
- PluginTimeoutError,
24
20
  type TurnContext,
25
21
  } from "../plugins/types.js";
26
22
  import type { Tool } from "../tools/types.js";
@@ -34,7 +30,6 @@ const sampleTurnContext: TurnContext = {
34
30
  requestId: "req-abc",
35
31
  conversationId: "conv-xyz",
36
32
  turnIndex: 0,
37
- pluginName: "sample-plugin",
38
33
  trust: sampleTrust,
39
34
  };
40
35
 
@@ -48,14 +43,6 @@ describe("plugin core types", () => {
48
43
  config: { parse: (input: unknown) => input },
49
44
  };
50
45
 
51
- // The `compaction` slot has a concrete arg/result shape, so it needs a
52
- // dedicated passthrough whose middleware signature matches — keeping the
53
- // shape-only `satisfies Plugin` assertion meaningful for the slot.
54
- const compactionPassthrough: Middleware<
55
- CompactionArgs,
56
- CompactionResult
57
- > = async (args, next, _ctx) => next(args);
58
-
59
46
  const sampleTool: Tool = {
60
47
  name: "sample-tool",
61
48
  description: "Sample plugin tool",
@@ -99,32 +86,10 @@ describe("plugin core types", () => {
99
86
  body: "## Sample\n\nPlugin-provided skill body.",
100
87
  },
101
88
  ],
102
- middleware: {
103
- compaction: compactionPassthrough,
104
- },
105
89
  } satisfies Plugin;
106
90
 
107
91
  // Minimal runtime check so the test body is non-empty.
108
92
  expect(plugin.manifest.name).toBe("sample-plugin");
109
- expect(plugin.middleware.compaction).toBe(compactionPassthrough);
110
- });
111
-
112
- test("PluginTimeoutError carries pipeline, plugin, and elapsed fields", () => {
113
- const err = new PluginTimeoutError("compaction", "sample-plugin", 30000);
114
- expect(err).toBeInstanceOf(Error);
115
- expect(err.name).toBe("PluginTimeoutError");
116
- expect(err.pipeline).toBe("compaction");
117
- expect(err.pluginName).toBe("sample-plugin");
118
- expect(err.elapsedMs).toBe(30000);
119
- expect(err.message).toContain("compaction");
120
- expect(err.message).toContain("30000");
121
- expect(err.message).toContain("sample-plugin");
122
- });
123
-
124
- test("PluginTimeoutError omits plugin suffix when unknown", () => {
125
- const err = new PluginTimeoutError("compaction", undefined, 1234);
126
- expect(err.pluginName).toBeUndefined();
127
- expect(err.message).not.toContain("offending plugin");
128
93
  });
129
94
 
130
95
  test("PluginExecutionError carries the plugin name and message", () => {