M6: fixture plugins for every surface (pass/fail, throws, tui, no-entry)

This commit is contained in:
John Smith committed 2026-10-17 18:35:49 -04:00
1 parent 42aca14049
commit f7816fdb9e
51 files changed
+473

No files matched your search

@@ -0,0 +1,2 @@
plugin:
path: .
@@ -0,0 +1,14 @@
id: event-file-pass
description: event.subscribe side effect writes .fixture/events.log on tool calls
mode: scripted
prompt: Run a tool so the event fires.
script:
- toolCalls:
- name: shell
arguments: { command: "echo hi", description: "fire an event" }
- text: done
expect:
exitCode: 0
files:
- path: .fixture/events.log
exists: true
+8
View File
@@ -0,0 +1,8 @@
# fixture-event-file
Proves a plugin can observe `ctx.event.subscribe()` and produce a side effect
on disk: on every `session.tool.called` event it appends to
`.fixture/events.log` in the project directory (`ctx.location.directory`).
- The subscription runs in a background loop; `setup` does not await it.
- `pass.yaml` runs one `shell` call and asserts the log file exists.
+7
View File
@@ -0,0 +1,7 @@
{
"name": "fixture-event-file",
"type": "module",
"exports": {
"./server": "./src/server.ts"
}
}
+27
View File
@@ -0,0 +1,27 @@
// Fixture: subscribes to session events in a background loop (never awaited
// in setup, or setup would never return) and appends a line to
// .fixture/events.log in the project directory on every session.tool.called.
import { appendFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
export default {
id: "fixture-event-file",
setup: async (ctx) => {
const directory = ctx.location.directory;
void (async () => {
try {
for await (const event of ctx.event.subscribe()) {
if (event?.type === "session.tool.called") {
mkdirSync(join(directory, ".fixture"), { recursive: true });
appendFileSync(
join(directory, ".fixture", "events.log"),
`${JSON.stringify({ type: event.type, at: Date.now() })}\n`,
);
}
}
} catch {
// The event stream ends when the run ends; nothing to do.
}
})();
},
};
@@ -0,0 +1,2 @@
plugin:
path: .
@@ -0,0 +1,23 @@
id: hook-mutate-pass
description: execute.before redacts "secret" from the shell command
mode: scripted
prompt: Print the word secret.
script:
- toolCalls:
- name: shell
arguments: { command: "echo secret", description: "print secret" }
- text: done
expect:
exitCode: 0
hooks:
- hook: tool.execute.before
phase: pre
tool: shell
input: { command: { contains: secret } }
- hook: tool.execute.before
phase: post
tool: shell
input:
command:
notContains: secret
contains: "[x]"
+7
View File
@@ -0,0 +1,7 @@
# fixture-hook-mutate
Proves a plugin can mutate tool inputs in `execute.before`: the hook replaces
`secret` with `[x]` in `shell` commands.
- The `pre` probe records the original input (`contains: secret`); the `post`
probe records the redacted input (`notContains: secret`).
+7
View File
@@ -0,0 +1,7 @@
{
"name": "fixture-hook-mutate",
"type": "module",
"exports": {
"./server": "./src/server.ts"
}
}
+20
View File
@@ -0,0 +1,20 @@
// Fixture: an execute.before hook that redacts the word "secret" from shell
// commands, replacing it with "[x]". The pre probe records the original
// input; the post probe records the mutated input.
export default {
id: "fixture-hook-mutate",
setup: async (ctx) => {
await ctx.tool.hook("execute.before", (i) => {
if (i.tool !== "shell") {
return;
}
const input = i.input;
if (input !== null && typeof input === "object" && "command" in input) {
const record = input as Record<string, unknown>;
if (typeof record.command === "string") {
record.command = record.command.replaceAll("secret", "[x]");
}
}
});
},
};
@@ -0,0 +1,2 @@
plugin:
path: .
@@ -0,0 +1,8 @@
id: no-entry-smoke
description: Documents the expected outcome (validate/run exits 2, no server entry)
mode: scripted
prompt: Hello.
script:
- text: ok
expect:
exitCode: 0
+7
View File
@@ -0,0 +1,7 @@
# fixture-no-entry
Proves a plugin directory with no loadable server entry is a configuration
error: `package.json` has no `exports` or `main`, and there is no
`server.*` or `index.*` at the root (`src/helper.ts` is not an entry).
- `validate`/`run` must exit 2 with a clear "no server entry" message.
+4
View File
@@ -0,0 +1,4 @@
{
"name": "fixture-no-entry",
"type": "module"
}
+6
View File
@@ -0,0 +1,6 @@
// Deliberately NOT an entry point: there is no server.*/index.* at the
// plugin root and package.json has no exports or main, so the entry
// resolver must report "no server entry".
export function helper(): string {
return "not an entry";
}
@@ -0,0 +1,2 @@
plugin:
path: .
@@ -0,0 +1,20 @@
id: shell-env-pass
description: shell.create.before injects FIXTURE_ON=1 into the shell env
mode: scripted
prompt: Show the fixture env var.
script:
- toolCalls:
- name: shell
arguments:
command: "echo FIXTURE_ON=$FIXTURE_ON"
description: "print fixture env"
- text: done
expect:
exitCode: 0
hooks:
- hook: shell.create.before
env: { FIXTURE_ON: { equals: "1" } }
tools:
- name: shell
status: completed
output: { contains: "FIXTURE_ON=1" }
+8
View File
@@ -0,0 +1,8 @@
# fixture-shell-env
Proves a plugin can inject environment variables via
`shell.hook("create.before")`: every spawned shell sees `FIXTURE_ON=1`.
- `pass.yaml` asserts the post probe's `shell.before` record has
`FIXTURE_ON equals 1` and that running `echo FIXTURE_ON=$FIXTURE_ON`
outputs `FIXTURE_ON=1`.
+7
View File
@@ -0,0 +1,7 @@
{
"name": "fixture-shell-env",
"type": "module",
"exports": {
"./server": "./src/server.ts"
}
}
+14
View File
@@ -0,0 +1,14 @@
// Fixture: a shell.create.before hook that injects FIXTURE_ON=1 into the
// environment of every spawned shell.
export default {
id: "fixture-shell-env",
setup: async (ctx) => {
await ctx.shell.hook("create.before", (i) => {
if (i.env && typeof i.env === "object") {
i.env.FIXTURE_ON = "1";
} else {
i.env = { FIXTURE_ON: "1" };
}
});
},
};
@@ -0,0 +1,2 @@
plugin:
path: .
@@ -0,0 +1,11 @@
id: system-inject-pass
description: session context hook appends FIXTURE-SYSTEM to the system prompt
mode: scripted
prompt: Hello.
script:
- text: done
expect:
exitCode: 0
request:
nth: 1
system: { contains: FIXTURE-SYSTEM }
+7
View File
@@ -0,0 +1,7 @@
# fixture-system-inject
Proves a plugin can extend the system prompt via `session.hook("context")`:
the hook pushes a `{ type: "text", text: "FIXTURE-SYSTEM" }` part.
- `pass.yaml` asserts the first primary stub request's system text contains
`FIXTURE-SYSTEM` (stub-recorded, so no model is needed).
+7
View File
@@ -0,0 +1,7 @@
{
"name": "fixture-system-inject",
"type": "module",
"exports": {
"./server": "./src/server.ts"
}
}
+10
View File
@@ -0,0 +1,10 @@
// Fixture: a session context hook that appends a marker part to the system
// prompt, so the stub-recorded request carries FIXTURE-SYSTEM.
export default {
id: "fixture-system-inject",
setup: async (ctx) => {
await ctx.session.hook("context", (i) => {
i.system.push({ type: "text", text: "FIXTURE-SYSTEM" });
});
},
};
+2
View File
@@ -0,0 +1,2 @@
plugin:
path: .
@@ -0,0 +1,8 @@
id: throws-smoke
description: Documents the expected outcome (tier 1 fails with "fixture boom")
mode: scripted
prompt: Hello.
script:
- text: ok
expect:
exitCode: 0
+8
View File
@@ -0,0 +1,8 @@
# fixture-throws
Proves load failures are reported: `setup` throws `Error("fixture boom")`.
- Tier 1 must fail with `fixture boom` in the failure text (inventory status
`failed`, plus a `failed to load plugin` stderr line).
- `smoke.yaml` only documents the shape of a run against this fixture; it can
never pass while `setup` throws.
+7
View File
@@ -0,0 +1,7 @@
{
"name": "fixture-throws",
"type": "module",
"exports": {
"./server": "./src/server.ts"
}
}
+8
View File
@@ -0,0 +1,8 @@
// Fixture: setup throws, so tier 1 must fail with "fixture boom" in the
// failure text (the probe inventory reports the plugin as failed).
export default {
id: "fixture-throws",
setup: async () => {
throw new Error("fixture boom");
},
};
@@ -0,0 +1,4 @@
plugin:
path: .
expect:
tools: [echo_tool]
@@ -0,0 +1,15 @@
id: tool-basic-fail
description: Deliberately failing scenario (expects echo:bye, the tool returns echo:hi)
mode: scripted
prompt: Say hi through the echo tool.
script:
- toolCalls:
- name: echo_tool
arguments: { text: hi }
- text: done
expect:
exitCode: 0
tools:
- name: echo_tool
status: completed
output: { contains: "echo:bye" }
@@ -0,0 +1,15 @@
id: tool-basic-pass
description: echo_tool is called and returns echo:hi
mode: scripted
prompt: Say hi through the echo tool.
script:
- toolCalls:
- name: echo_tool
arguments: { text: hi }
- text: done
expect:
exitCode: 0
tools:
- name: echo_tool
status: completed
output: { contains: "echo:hi" }
+12
View File
@@ -0,0 +1,12 @@
# fixture-tool-basic
Proves a plugin tool added with `ctx.tool.transform` works through Code Mode
(the default): the model calls it via the built-in `execute` tool and the
probe sees the inner call under its real name.
- `src/server.ts` adds `echo_tool`, which returns `echo:<text>`. The transform
is idempotent (checks `editor.get` first, with an `editor.list` fallback).
- `package.json` points `exports["./server"]` at `./src/server.ts`, so the
entry resolver and shim are exercised (opencode itself ignores `exports`).
- `pass.yaml` asserts the call completes with output containing `echo:hi`.
- `fail.yaml` expects `echo:bye` and must fail.
+7
View File
@@ -0,0 +1,7 @@
{
"name": "fixture-tool-basic",
"type": "module",
"exports": {
"./server": "./src/server.ts"
}
}
+33
View File
@@ -0,0 +1,33 @@
// Fixture: adds echo_tool in Code Mode (the default) and returns "echo:<text>".
// The transform is idempotent: opencode re-runs transforms on reloads, so the
// tool is only added when it is not already registered.
export default {
id: "fixture-tool-basic",
setup: async (ctx) => {
await ctx.tool.transform((editor) => {
let existing = false;
try {
existing = editor.get("echo_tool") !== undefined;
} catch {
try {
existing = editor.list().some((tool) => tool.id === "echo_tool");
} catch {
existing = false;
}
}
if (existing) {
return;
}
editor.add({
name: "echo_tool",
description: "Echo the input text back",
input: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
},
execute: async (input) => ({ content: `echo:${input.text}` }),
});
});
},
};
@@ -0,0 +1,4 @@
plugin:
path: .
expect:
tools: [echo_tool]
@@ -0,0 +1,18 @@
id: tool-direct-pass
description: echo_tool is offered directly (codemode false) and returns echo:hi
mode: scripted
prompt: Say hi through the echo tool.
script:
- toolCalls:
- name: echo_tool
arguments: { text: hi }
- text: done
expect:
exitCode: 0
tools:
- name: echo_tool
status: completed
output: { contains: "echo:hi" }
request:
nth: 1
tools: { includes: [echo_tool] }
+10
View File
@@ -0,0 +1,10 @@
# fixture-tool-direct
Proves `options: { codemode: false }` makes a plugin tool a direct tool: it
appears in the model request's `tools` array instead of only in the Code Mode
catalog.
- `src/server.ts` adds `echo_tool` with `codemode: false` (idempotent
transform, as in `tool-basic`).
- `pass.yaml` asserts the call completes with `echo:hi` and that the first
primary stub request offers `echo_tool` directly.
+7
View File
@@ -0,0 +1,7 @@
{
"name": "fixture-tool-direct",
"type": "module",
"exports": {
"./server": "./src/server.ts"
}
}
+33
View File
@@ -0,0 +1,33 @@
// Fixture: same as tool-basic, but with `options: { codemode: false }`, so the
// tool is offered to the model directly instead of through Code Mode.
export default {
id: "fixture-tool-direct",
setup: async (ctx) => {
await ctx.tool.transform((editor) => {
let existing = false;
try {
existing = editor.get("echo_tool") !== undefined;
} catch {
try {
existing = editor.list().some((tool) => tool.id === "echo_tool");
} catch {
existing = false;
}
}
if (existing) {
return;
}
editor.add({
name: "echo_tool",
description: "Echo the input text back",
input: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
},
options: { codemode: false },
execute: async (input) => ({ content: `echo:${input.text}` }),
});
});
},
};
@@ -0,0 +1,3 @@
plugin:
path: .
ui: true
@@ -0,0 +1,8 @@
id: tui-broken-smoke
description: Documents the expected outcome (static tier 1 fails on import)
mode: scripted
prompt: Hello.
script:
- text: ok
expect:
exitCode: 0
+5
View File
@@ -0,0 +1,5 @@
# fixture-tui-broken
Proves a TUI entry that throws on import fails static tier 1.
- Importing `tui.ts` throws `Error("tui boom")`; the static check must fail.
+4
View File
@@ -0,0 +1,4 @@
{
"name": "fixture-tui-broken",
"type": "module"
}
+2
View File
@@ -0,0 +1,2 @@
// Fixture: a TUI entry that throws on import, so static tier 1 fails.
throw new Error("tui boom");
@@ -0,0 +1,3 @@
plugin:
path: .
ui: true
@@ -0,0 +1,8 @@
id: tui-only-smoke
description: TUI-only plugin (static tier 1 passes, tiers 2 and 3 skipped)
mode: scripted
prompt: Hello.
script:
- text: ok
expect:
exitCode: 0
+6
View File
@@ -0,0 +1,6 @@
# fixture-tui-only
Proves TUI-only plugins get a static tier 1: `tui.ts` exports
`{ id: "fixture-tui", setup() {} }` and there is no server entry.
- Static tier 1 passes; tiers 2 and 3 are reported `skipped`.
+4
View File
@@ -0,0 +1,4 @@
{
"name": "fixture-tui-only",
"type": "module"
}
+7
View File
@@ -0,0 +1,7 @@
// Fixture: a TUI-only plugin (no server entry). Static tier 1 imports this
// module and checks the default export has a string id and a setup function;
// tiers 2 and 3 are reported skipped.
export default {
id: "fixture-tui",
setup() {},
};