opencode-goal-plugin 0.5.0 → 0.6.1
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.
- package/CHANGELOG.md +29 -4
- package/CONTRIBUTING.md +8 -7
- package/README.md +67 -39
- package/SECURITY.md +29 -1
- package/demo/README.md +86 -0
- package/demo/opencode.json +11 -0
- package/demo/package.json +10 -0
- package/demo/src/math.js +3 -0
- package/demo/test/math.test.js +11 -0
- package/docs/providers.md +84 -0
- package/index.d.ts +53 -5
- package/package.json +19 -6
- package/scripts/behavior-benchmark.mjs +272 -0
- package/scripts/packed-host-contract.mjs +160 -0
- package/scripts/verify.mjs +4 -2
- package/src/completion-claim.js +127 -0
- package/src/goal-plugin.js +1166 -304
- package/src/goal-tool-result.js +18 -0
- package/src/native-agent-config.js +73 -0
- package/src/opencode-session-api.js +110 -0
- package/src/persistence-lease.js +94 -0
package/index.d.ts
CHANGED
|
@@ -31,10 +31,19 @@ export interface CompletionAuditContext {
|
|
|
31
31
|
export interface CompletionAuditorOptions {
|
|
32
32
|
/**
|
|
33
33
|
* How long, in milliseconds, the built-in auditor waits for a verdict from
|
|
34
|
-
* its child OpenCode session
|
|
34
|
+
* its child OpenCode session. A timeout rejects the audit and pauses the
|
|
35
|
+
* goal. Operational failures follow {@link failurePolicy}.
|
|
35
36
|
* @default 120000
|
|
36
37
|
*/
|
|
37
38
|
timeoutMs?: number
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Result used when the child-session API is unavailable, malformed, throws,
|
|
42
|
+
* or times out. Semantic rejection or an invalid verdict always rejects.
|
|
43
|
+
* `"approve"` is an explicit compatibility escape hatch.
|
|
44
|
+
* @default "reject"
|
|
45
|
+
*/
|
|
46
|
+
failurePolicy?: "reject" | "approve"
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
/**
|
|
@@ -45,6 +54,14 @@ export interface CompletionAuditorOptions {
|
|
|
45
54
|
* flags (e.g. `--max-turns`, `--success`, `--mode`).
|
|
46
55
|
*/
|
|
47
56
|
export interface GoalPluginOptions {
|
|
57
|
+
/**
|
|
58
|
+
* OpenCode session SDK argument shape. PluginInput currently supplies the
|
|
59
|
+
* legacy generated client; set `"flat"` when embedding with the v2 SDK.
|
|
60
|
+
* The compatibility adapter remembers the successful shape per operation.
|
|
61
|
+
* @default "legacy"
|
|
62
|
+
*/
|
|
63
|
+
sdkShape?: "legacy" | "flat"
|
|
64
|
+
|
|
48
65
|
/**
|
|
49
66
|
* Maximum number of auto-continue turns sent toward a goal before it is
|
|
50
67
|
* stopped for exceeding limits. Overridable per-goal with `--max-turns`.
|
|
@@ -104,7 +121,7 @@ export interface GoalPluginOptions {
|
|
|
104
121
|
* Grace window for tool-free continuation turns (a "talk only" turn that
|
|
105
122
|
* calls no tool). Complements the no-progress check by catching
|
|
106
123
|
* self-chat loops that still produce output. Overridable per-goal with
|
|
107
|
-
* `--no-tool-turns`.
|
|
124
|
+
* `--no-tool-turns`. Set the plugin option to `0` to disable this heuristic.
|
|
108
125
|
* @default 2
|
|
109
126
|
*/
|
|
110
127
|
noToolCallTurnsBeforePause?: number
|
|
@@ -172,6 +189,12 @@ export interface GoalPluginOptions {
|
|
|
172
189
|
*/
|
|
173
190
|
ledgerFilePath?: string
|
|
174
191
|
|
|
192
|
+
/** Maximum bytes in one lifecycle-ledger generation. @default 2097152 */
|
|
193
|
+
ledgerMaxBytes?: number
|
|
194
|
+
|
|
195
|
+
/** Number of rotated lifecycle-ledger generations to retain (0-10). @default 3 */
|
|
196
|
+
ledgerRetentionFiles?: number
|
|
197
|
+
|
|
175
198
|
/**
|
|
176
199
|
* How long, in milliseconds, a completed goal's summary remains
|
|
177
200
|
* available through `/goal status` after the goal leaves active memory.
|
|
@@ -206,14 +229,25 @@ export interface GoalPluginOptions {
|
|
|
206
229
|
|
|
207
230
|
/**
|
|
208
231
|
* Whether the plugin registers the agent-facing goal tools
|
|
209
|
-
* (`
|
|
210
|
-
* `
|
|
232
|
+
* (canonical `goal_status`, `goal_set`, `goal_pause`, `goal_resume`,
|
|
233
|
+
* `goal_block`, `goal_complete`, plus legacy `get_goal`,
|
|
234
|
+
* `get_goal_history`, `set_goal`, `update_goal`, `clear_goal`).
|
|
235
|
+
* Canonical tools return versioned JSON envelopes. Requires the optional `@opencode-ai/plugin` peer
|
|
211
236
|
* dependency; when it is absent, tool registration is silently skipped
|
|
212
237
|
* and the command/event hooks still work.
|
|
213
238
|
* @default true
|
|
214
239
|
*/
|
|
215
240
|
registerTools?: boolean
|
|
216
241
|
|
|
242
|
+
/** Register collision-safe native `goal` and `goal-verify` agents through OpenCode's config hook. */
|
|
243
|
+
registerAgents?: boolean
|
|
244
|
+
|
|
245
|
+
/** Name of the native primary goal agent. @default "goal" */
|
|
246
|
+
goalAgentName?: string
|
|
247
|
+
|
|
248
|
+
/** Name of the native read-only verifier subagent. @default "goal-verify" */
|
|
249
|
+
verifierAgentName?: string
|
|
250
|
+
|
|
217
251
|
/**
|
|
218
252
|
* Enables the built-in child-session completion auditor: before a
|
|
219
253
|
* `[goal:complete]` is archived, the plugin spawns an independent
|
|
@@ -262,17 +296,22 @@ export interface GoalPluginOptions {
|
|
|
262
296
|
* not by this package.
|
|
263
297
|
*/
|
|
264
298
|
export interface GoalPluginHooks {
|
|
299
|
+
/** Registers collision-safe native goal and verifier agents. */
|
|
300
|
+
config: (config: unknown) => Promise<void>
|
|
265
301
|
/** Omitted entirely when {@link GoalPluginOptions.registerCommand} is `false`. */
|
|
266
302
|
"command.execute.before"?: (input: unknown, output: unknown) => Promise<void>
|
|
267
303
|
event: (input: unknown) => Promise<void>
|
|
268
304
|
"experimental.chat.system.transform": (input: unknown, output: unknown) => Promise<void>
|
|
269
305
|
"experimental.compaction.autocontinue": (input: unknown, output: unknown) => Promise<void>
|
|
306
|
+
"experimental.session.compacting": (input: unknown, output: unknown) => Promise<void>
|
|
270
307
|
/**
|
|
271
308
|
* Agent-facing tool definitions, present only when
|
|
272
309
|
* {@link GoalPluginOptions.registerTools} is enabled (default) and the
|
|
273
310
|
* optional `@opencode-ai/plugin` peer dependency is installed.
|
|
274
311
|
*/
|
|
275
312
|
tool?: Record<string, unknown>
|
|
313
|
+
/** Cancels pending continuation work and releases this plugin instance. */
|
|
314
|
+
dispose: () => Promise<void>
|
|
276
315
|
[hook: string]: unknown
|
|
277
316
|
}
|
|
278
317
|
|
|
@@ -282,10 +321,19 @@ export interface GoalPluginHooks {
|
|
|
282
321
|
* `opencode.json`.
|
|
283
322
|
*/
|
|
284
323
|
export function GoalPlugin(
|
|
285
|
-
context: {
|
|
324
|
+
context: {
|
|
325
|
+
client: unknown
|
|
326
|
+
/** OpenCode's resolved project directory for this plugin instance. */
|
|
327
|
+
directory?: string
|
|
328
|
+
/** OpenCode's resolved worktree directory for this plugin instance. */
|
|
329
|
+
worktree?: string
|
|
330
|
+
},
|
|
286
331
|
options?: GoalPluginOptions,
|
|
287
332
|
): Promise<GoalPluginHooks>
|
|
288
333
|
|
|
334
|
+
/** Internal diagnostic/test helpers. Not covered by semantic-version compatibility guarantees. */
|
|
335
|
+
export const testInternals: Readonly<Record<string, unknown>>
|
|
336
|
+
|
|
289
337
|
/**
|
|
290
338
|
* Default export consumed by OpenCode's plugin loader:
|
|
291
339
|
* `{ "opencode-goal-plugin": { ... } }` in `opencode.json` resolves `id`
|
package/package.json
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-goal-plugin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"description": "Durable, guarded goal workflows for OpenCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/goal-plugin.js",
|
|
7
7
|
"types": "./index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
10
|
-
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./index.d.ts",
|
|
11
|
+
"import": "./src/goal-plugin.js",
|
|
12
|
+
"default": "./src/goal-plugin.js"
|
|
13
|
+
},
|
|
14
|
+
"./server": {
|
|
15
|
+
"types": "./index.d.ts",
|
|
16
|
+
"import": "./src/goal-plugin.js",
|
|
17
|
+
"default": "./src/goal-plugin.js"
|
|
18
|
+
}
|
|
11
19
|
},
|
|
12
20
|
"bin": {
|
|
13
21
|
"opencode-goal-plugin": "./scripts/verify.mjs"
|
|
@@ -16,6 +24,8 @@
|
|
|
16
24
|
"src",
|
|
17
25
|
"scripts",
|
|
18
26
|
"examples",
|
|
27
|
+
"demo",
|
|
28
|
+
"docs",
|
|
19
29
|
"index.d.ts",
|
|
20
30
|
"README.md",
|
|
21
31
|
"CHANGELOG.md",
|
|
@@ -28,6 +38,8 @@
|
|
|
28
38
|
"test": "node --test test/*.test.js",
|
|
29
39
|
"test:coverage": "node --test --experimental-test-coverage test/*.test.js",
|
|
30
40
|
"smoke": "node scripts/smoke-command-hook.mjs",
|
|
41
|
+
"smoke:packed-host": "node scripts/packed-host-contract.mjs",
|
|
42
|
+
"benchmark:behavior": "node scripts/behavior-benchmark.mjs",
|
|
31
43
|
"verify": "node scripts/verify.mjs",
|
|
32
44
|
"check": "node -c src/goal-plugin.js && npm test",
|
|
33
45
|
"pack:check": "npm pack --dry-run"
|
|
@@ -39,7 +51,7 @@
|
|
|
39
51
|
"goal"
|
|
40
52
|
],
|
|
41
53
|
"peerDependencies": {
|
|
42
|
-
"@opencode-ai/plugin": ">=1"
|
|
54
|
+
"@opencode-ai/plugin": ">=1.17.15 <2"
|
|
43
55
|
},
|
|
44
56
|
"peerDependenciesMeta": {
|
|
45
57
|
"@opencode-ai/plugin": {
|
|
@@ -48,7 +60,8 @@
|
|
|
48
60
|
},
|
|
49
61
|
"license": "MIT",
|
|
50
62
|
"engines": {
|
|
51
|
-
"node": ">=18"
|
|
63
|
+
"node": ">=18",
|
|
64
|
+
"opencode": ">=1.17.15 <2"
|
|
52
65
|
},
|
|
53
66
|
"repository": {
|
|
54
67
|
"type": "git",
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict"
|
|
4
|
+
import { promises as fs } from "node:fs"
|
|
5
|
+
import { tmpdir } from "node:os"
|
|
6
|
+
import { join } from "node:path"
|
|
7
|
+
import { GoalPlugin } from "../src/goal-plugin.js"
|
|
8
|
+
|
|
9
|
+
const startedAt = performance.now()
|
|
10
|
+
const temporaryDirectories = []
|
|
11
|
+
|
|
12
|
+
function assistantMessage(sessionID, text, id = `assistant-${sessionID}`) {
|
|
13
|
+
return {
|
|
14
|
+
info: {
|
|
15
|
+
id,
|
|
16
|
+
role: "assistant",
|
|
17
|
+
sessionID,
|
|
18
|
+
tokens: { input: 20, output: 120, reasoning: 0 },
|
|
19
|
+
},
|
|
20
|
+
parts: [{ type: "text", text }],
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createHost(messageForSession = () => "Working with tools.") {
|
|
25
|
+
const prompts = []
|
|
26
|
+
const notices = []
|
|
27
|
+
return {
|
|
28
|
+
prompts,
|
|
29
|
+
notices,
|
|
30
|
+
client: {
|
|
31
|
+
app: { log: async () => {} },
|
|
32
|
+
session: {
|
|
33
|
+
messages: async ({ path }) => ({
|
|
34
|
+
data: [assistantMessage(path.id, messageForSession(path.id))],
|
|
35
|
+
}),
|
|
36
|
+
promptAsync: async (input) => {
|
|
37
|
+
prompts.push(input)
|
|
38
|
+
return {}
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function promptCharacters(prompts) {
|
|
46
|
+
return prompts.reduce(
|
|
47
|
+
(total, prompt) => total + (prompt?.body?.parts || []).reduce(
|
|
48
|
+
(partTotal, part) => partTotal + (typeof part?.text === "string" ? part.text.length : 0),
|
|
49
|
+
0,
|
|
50
|
+
),
|
|
51
|
+
0,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function makeDirectory(label) {
|
|
56
|
+
const directory = await fs.mkdtemp(join(tmpdir(), `goal-benchmark-${label}-`))
|
|
57
|
+
temporaryDirectories.push(directory)
|
|
58
|
+
return directory
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function createHooks(host, options = {}) {
|
|
62
|
+
return GoalPlugin(
|
|
63
|
+
{ client: host.client, directory: await makeDirectory("workspace") },
|
|
64
|
+
{
|
|
65
|
+
persistState: false,
|
|
66
|
+
registerTools: false,
|
|
67
|
+
registerAgents: false,
|
|
68
|
+
minDelayMs: 1,
|
|
69
|
+
noProgressTokenThreshold: 1,
|
|
70
|
+
noProgressTurnsBeforePause: 10,
|
|
71
|
+
noToolCallTurnsBeforePause: 2,
|
|
72
|
+
...options,
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function goalCommand(hooks, sessionID, argumentsText) {
|
|
78
|
+
const output = { parts: [] }
|
|
79
|
+
await hooks["command.execute.before"](
|
|
80
|
+
{ command: "goal", sessionID, arguments: argumentsText },
|
|
81
|
+
output,
|
|
82
|
+
)
|
|
83
|
+
return output.parts.map((part) => part.text || "").join("\n")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function idle(hooks, sessionID, id) {
|
|
87
|
+
await hooks.event({
|
|
88
|
+
event: {
|
|
89
|
+
id,
|
|
90
|
+
type: "session.status",
|
|
91
|
+
properties: { sessionID, status: { type: "idle" } },
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function scenario(name, points, run) {
|
|
97
|
+
const scenarioStartedAt = performance.now()
|
|
98
|
+
try {
|
|
99
|
+
const telemetry = await run()
|
|
100
|
+
return {
|
|
101
|
+
name,
|
|
102
|
+
passed: true,
|
|
103
|
+
points,
|
|
104
|
+
durationMs: Number((performance.now() - scenarioStartedAt).toFixed(2)),
|
|
105
|
+
...telemetry,
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return {
|
|
109
|
+
name,
|
|
110
|
+
passed: false,
|
|
111
|
+
points: 0,
|
|
112
|
+
possiblePoints: points,
|
|
113
|
+
durationMs: Number((performance.now() - scenarioStartedAt).toFixed(2)),
|
|
114
|
+
error: error instanceof Error ? error.message : String(error),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const results = []
|
|
120
|
+
|
|
121
|
+
results.push(await scenario("verified-success", 20, async () => {
|
|
122
|
+
const sessionID = "benchmark-success"
|
|
123
|
+
const host = createHost(() => "Tests pass.\n[goal:evidence] npm test: 210/210\n[goal:complete]")
|
|
124
|
+
const hooks = await createHooks(host, {
|
|
125
|
+
auditor: async () => ({ approved: true, reason: "evidence independently accepted" }),
|
|
126
|
+
})
|
|
127
|
+
await goalCommand(hooks, sessionID, "ship a verified release")
|
|
128
|
+
await idle(hooks, sessionID, "success-idle")
|
|
129
|
+
const status = await goalCommand(hooks, sessionID, "status")
|
|
130
|
+
assert.match(status, /State: achieved/)
|
|
131
|
+
await hooks.dispose()
|
|
132
|
+
return {
|
|
133
|
+
continuationPrompts: host.prompts.length,
|
|
134
|
+
continuationCharacters: promptCharacters(host.prompts),
|
|
135
|
+
status: "archived",
|
|
136
|
+
}
|
|
137
|
+
}))
|
|
138
|
+
|
|
139
|
+
results.push(await scenario("false-completion", 20, async () => {
|
|
140
|
+
const sessionID = "benchmark-false-completion"
|
|
141
|
+
const host = createHost(() => "Looks done.\n[goal:evidence] guessed from source\n[goal:complete]")
|
|
142
|
+
const hooks = await createHooks(host, {
|
|
143
|
+
auditor: async () => ({ approved: false, reason: "no executed verification" }),
|
|
144
|
+
})
|
|
145
|
+
await goalCommand(hooks, sessionID, "do not accept an unverified claim")
|
|
146
|
+
await idle(hooks, sessionID, "false-idle")
|
|
147
|
+
const status = await goalCommand(hooks, sessionID, "status")
|
|
148
|
+
assert.match(status, /audit rejected/i)
|
|
149
|
+
assert.doesNotMatch(status, /No active goal/)
|
|
150
|
+
await hooks.dispose()
|
|
151
|
+
return {
|
|
152
|
+
continuationPrompts: host.prompts.length,
|
|
153
|
+
continuationCharacters: promptCharacters(host.prompts),
|
|
154
|
+
status: "rejected",
|
|
155
|
+
}
|
|
156
|
+
}))
|
|
157
|
+
|
|
158
|
+
results.push(await scenario("loop-circuit-breaker", 15, async () => {
|
|
159
|
+
const sessionID = "benchmark-loop"
|
|
160
|
+
let turn = 0
|
|
161
|
+
const host = createHost(() => `Still discussing the work, turn ${turn++}.`)
|
|
162
|
+
const hooks = await createHooks(host)
|
|
163
|
+
await goalCommand(hooks, sessionID, "stop self-chat loops")
|
|
164
|
+
await idle(hooks, sessionID, "loop-1")
|
|
165
|
+
await idle(hooks, sessionID, "loop-2")
|
|
166
|
+
await idle(hooks, sessionID, "loop-3")
|
|
167
|
+
const status = await goalCommand(hooks, sessionID, "status")
|
|
168
|
+
assert.match(status, /no tool calls|self-chat loop/i)
|
|
169
|
+
assert.equal(host.prompts.length, 2)
|
|
170
|
+
await hooks.dispose()
|
|
171
|
+
return {
|
|
172
|
+
continuationPrompts: host.prompts.length,
|
|
173
|
+
continuationCharacters: promptCharacters(host.prompts),
|
|
174
|
+
status: "paused",
|
|
175
|
+
}
|
|
176
|
+
}))
|
|
177
|
+
|
|
178
|
+
results.push(await scenario("human-interruption", 15, async () => {
|
|
179
|
+
const sessionID = "benchmark-interruption"
|
|
180
|
+
const host = createHost()
|
|
181
|
+
const hooks = await createHooks(host)
|
|
182
|
+
await goalCommand(hooks, sessionID, "respect explicit interruption")
|
|
183
|
+
await hooks.event({
|
|
184
|
+
event: {
|
|
185
|
+
type: "session.error",
|
|
186
|
+
properties: {
|
|
187
|
+
sessionID,
|
|
188
|
+
error: { name: "MessageAbortedError", message: "aborted by user" },
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
await idle(hooks, sessionID, "interruption-idle")
|
|
193
|
+
assert.equal(host.prompts.length, 0)
|
|
194
|
+
assert.match(await goalCommand(hooks, sessionID, "status"), /abort|paused|stopped/i)
|
|
195
|
+
await hooks.dispose()
|
|
196
|
+
return { continuationPrompts: 0, status: "paused" }
|
|
197
|
+
}))
|
|
198
|
+
|
|
199
|
+
results.push(await scenario("compaction-continuity", 15, async () => {
|
|
200
|
+
const sessionID = "benchmark-compaction"
|
|
201
|
+
const host = createHost()
|
|
202
|
+
const hooks = await createHooks(host)
|
|
203
|
+
await goalCommand(hooks, sessionID, "preserve the objective across compaction")
|
|
204
|
+
const output = { context: [] }
|
|
205
|
+
await hooks["experimental.session.compacting"]({ sessionID }, output)
|
|
206
|
+
assert.equal(output.context.length, 1)
|
|
207
|
+
assert.match(output.context[0], /preserve the objective across compaction/)
|
|
208
|
+
assert.ok(output.context[0].length < 2_000, "compaction context exceeded token-efficient size cap")
|
|
209
|
+
await hooks.dispose()
|
|
210
|
+
return {
|
|
211
|
+
contextCharacters: output.context[0].length,
|
|
212
|
+
estimatedContextTokens: Math.ceil(output.context[0].length / 4),
|
|
213
|
+
status: "preserved",
|
|
214
|
+
}
|
|
215
|
+
}))
|
|
216
|
+
|
|
217
|
+
results.push(await scenario("restart-recovery", 15, async () => {
|
|
218
|
+
const sessionID = "benchmark-restart"
|
|
219
|
+
const directory = await makeDirectory("restart")
|
|
220
|
+
const stateFilePath = join(directory, "state.json")
|
|
221
|
+
const host = createHost()
|
|
222
|
+
const first = await GoalPlugin(
|
|
223
|
+
{ client: host.client, directory },
|
|
224
|
+
{ persistState: true, stateFilePath, registerTools: false, registerAgents: false, minDelayMs: 1 },
|
|
225
|
+
)
|
|
226
|
+
await goalCommand(first, sessionID, "recover safely after restart")
|
|
227
|
+
await first.dispose()
|
|
228
|
+
const second = await GoalPlugin(
|
|
229
|
+
{ client: host.client, directory },
|
|
230
|
+
{ persistState: true, stateFilePath, registerTools: false, registerAgents: false, minDelayMs: 1 },
|
|
231
|
+
)
|
|
232
|
+
const status = await goalCommand(second, sessionID, "status")
|
|
233
|
+
assert.match(status, /Recovered persisted goal state|recovered after restart/i)
|
|
234
|
+
await idle(second, sessionID, "restart-idle")
|
|
235
|
+
assert.equal(host.prompts.length, 0, "recovered goals must not resume without user consent")
|
|
236
|
+
const stateBytes = (await fs.stat(stateFilePath)).size
|
|
237
|
+
await second.dispose()
|
|
238
|
+
return { continuationPrompts: 0, persistedStateBytes: stateBytes, status: "recovered-paused" }
|
|
239
|
+
}))
|
|
240
|
+
|
|
241
|
+
const score = results.reduce((total, result) => total + result.points, 0)
|
|
242
|
+
const possibleScore = 100
|
|
243
|
+
const continuationCharacters = results.reduce(
|
|
244
|
+
(total, result) => total + (result.continuationCharacters || 0),
|
|
245
|
+
0,
|
|
246
|
+
)
|
|
247
|
+
const report = {
|
|
248
|
+
schemaVersion: 1,
|
|
249
|
+
benchmark: "opencode-goal-plugin-behavior",
|
|
250
|
+
score,
|
|
251
|
+
possibleScore,
|
|
252
|
+
passed: score === possibleScore,
|
|
253
|
+
durationMs: Number((performance.now() - startedAt).toFixed(2)),
|
|
254
|
+
efficiency: {
|
|
255
|
+
totalContinuationPrompts: results.reduce(
|
|
256
|
+
(total, result) => total + (result.continuationPrompts || 0),
|
|
257
|
+
0,
|
|
258
|
+
),
|
|
259
|
+
continuationCharacters,
|
|
260
|
+
estimatedContinuationTokens: Math.ceil(continuationCharacters / 4),
|
|
261
|
+
modelCalls: 0,
|
|
262
|
+
externalRequests: 0,
|
|
263
|
+
},
|
|
264
|
+
scenarios: results,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const directory of temporaryDirectories) {
|
|
268
|
+
await fs.rm(directory, { recursive: true, force: true })
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
272
|
+
if (!report.passed) process.exitCode = 1
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { execFileSync } from "node:child_process"
|
|
3
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
import { pathToFileURL } from "node:url"
|
|
7
|
+
|
|
8
|
+
const repository = new URL("..", import.meta.url)
|
|
9
|
+
const root = await mkdtemp(join(tmpdir(), "opencode-goal-plugin-packed-host-"))
|
|
10
|
+
const packDirectory = join(root, "pack")
|
|
11
|
+
const projectDirectory = join(root, "host-project")
|
|
12
|
+
const cacheDirectory = join(root, "npm-cache")
|
|
13
|
+
const npmEnvironment = { ...process.env, npm_config_cache: cacheDirectory }
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
await Promise.all([
|
|
17
|
+
mkdir(packDirectory, { recursive: true }),
|
|
18
|
+
mkdir(projectDirectory, { recursive: true }),
|
|
19
|
+
mkdir(cacheDirectory, { recursive: true }),
|
|
20
|
+
])
|
|
21
|
+
await writeFile(
|
|
22
|
+
join(projectDirectory, "package.json"),
|
|
23
|
+
JSON.stringify({ private: true, type: "module" }),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const packResult = JSON.parse(
|
|
27
|
+
execFileSync(
|
|
28
|
+
"npm",
|
|
29
|
+
["pack", "--json", "--pack-destination", packDirectory],
|
|
30
|
+
{ cwd: repository, encoding: "utf8", env: npmEnvironment },
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
assert.equal(packResult.length, 1)
|
|
34
|
+
const tarball = join(packDirectory, packResult[0].filename)
|
|
35
|
+
|
|
36
|
+
// Install only the artifact npm produced. The optional peer is omitted so this
|
|
37
|
+
// contract test is offline-safe and cannot mutate the user's OpenCode install.
|
|
38
|
+
execFileSync(
|
|
39
|
+
"npm",
|
|
40
|
+
[
|
|
41
|
+
"install",
|
|
42
|
+
"--ignore-scripts",
|
|
43
|
+
"--no-audit",
|
|
44
|
+
"--no-fund",
|
|
45
|
+
"--no-package-lock",
|
|
46
|
+
"--omit=peer",
|
|
47
|
+
"--offline",
|
|
48
|
+
"--cache",
|
|
49
|
+
cacheDirectory,
|
|
50
|
+
tarball,
|
|
51
|
+
],
|
|
52
|
+
{ cwd: projectDirectory, encoding: "utf8", env: npmEnvironment },
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const installedManifestPath = join(
|
|
56
|
+
projectDirectory,
|
|
57
|
+
"node_modules",
|
|
58
|
+
"opencode-goal-plugin",
|
|
59
|
+
"package.json",
|
|
60
|
+
)
|
|
61
|
+
const installedManifest = JSON.parse(await readFile(installedManifestPath, "utf8"))
|
|
62
|
+
const installedEntry = join(
|
|
63
|
+
projectDirectory,
|
|
64
|
+
"node_modules",
|
|
65
|
+
"opencode-goal-plugin",
|
|
66
|
+
installedManifest.main,
|
|
67
|
+
)
|
|
68
|
+
const installed = await import(pathToFileURL(installedEntry).href)
|
|
69
|
+
|
|
70
|
+
assert.equal(installed.default.id, "opencode-goal-plugin")
|
|
71
|
+
assert.equal(installed.default.server, installed.GoalPlugin)
|
|
72
|
+
|
|
73
|
+
const sessionID = "packed-host-contract"
|
|
74
|
+
const promptCalls = []
|
|
75
|
+
const client = {
|
|
76
|
+
app: { log: async () => {} },
|
|
77
|
+
session: {
|
|
78
|
+
messages: async ({ path }) => ({
|
|
79
|
+
data: [
|
|
80
|
+
{
|
|
81
|
+
info: {
|
|
82
|
+
id: "assistant-packed-contract",
|
|
83
|
+
role: "assistant",
|
|
84
|
+
sessionID: path.id,
|
|
85
|
+
tokens: { input: 1, output: 1, reasoning: 0 },
|
|
86
|
+
},
|
|
87
|
+
parts: [{ type: "text", text: "Work remains." }],
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
}),
|
|
91
|
+
promptAsync: async (input) => {
|
|
92
|
+
promptCalls.push(input)
|
|
93
|
+
return {}
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
const hooks = await installed.GoalPlugin(
|
|
98
|
+
{ client, directory: projectDirectory },
|
|
99
|
+
{
|
|
100
|
+
persistState: false,
|
|
101
|
+
registerTools: false,
|
|
102
|
+
minDelayMs: 1,
|
|
103
|
+
noToolCallTurnsBeforePause: 10,
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
for (const hook of [
|
|
108
|
+
"config",
|
|
109
|
+
"command.execute.before",
|
|
110
|
+
"event",
|
|
111
|
+
"experimental.chat.system.transform",
|
|
112
|
+
"experimental.session.compacting",
|
|
113
|
+
"experimental.compaction.autocontinue",
|
|
114
|
+
"dispose",
|
|
115
|
+
]) {
|
|
116
|
+
assert.equal(typeof hooks[hook], "function", `${hook} must be callable`)
|
|
117
|
+
}
|
|
118
|
+
const config = {}
|
|
119
|
+
await hooks.config(config)
|
|
120
|
+
assert.equal(config.agent.goal.mode, "primary")
|
|
121
|
+
assert.equal(config.agent["goal-verify"].tools.edit, false)
|
|
122
|
+
|
|
123
|
+
const output = { parts: [] }
|
|
124
|
+
await hooks["command.execute.before"](
|
|
125
|
+
{ command: "goal", sessionID, arguments: "verify the installed artifact --max-turns 1" },
|
|
126
|
+
output,
|
|
127
|
+
)
|
|
128
|
+
assert.match(output.parts[0]?.text, /New active goal/)
|
|
129
|
+
|
|
130
|
+
// Let the configured throttle window elapse before idle. This avoids leaving
|
|
131
|
+
// the contract dependent on the host's event-loop/timer shutdown behavior.
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
133
|
+
|
|
134
|
+
await hooks.event({
|
|
135
|
+
event: {
|
|
136
|
+
type: "session.status",
|
|
137
|
+
properties: { sessionID, status: { type: "idle" } },
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
assert.equal(promptCalls.length, 1)
|
|
142
|
+
// PluginInput currently supplies OpenCode's generated legacy client shape:
|
|
143
|
+
// session.promptAsync({ path, body }). The standalone adapter suite covers
|
|
144
|
+
// flattened v2 clients separately.
|
|
145
|
+
assert.deepEqual(promptCalls[0].path, { id: sessionID })
|
|
146
|
+
assert.equal(promptCalls[0].body.parts.length, 1)
|
|
147
|
+
assert.deepEqual(promptCalls[0].body.parts[0].metadata, {
|
|
148
|
+
"opencode-goal-plugin": { kind: "continuation" },
|
|
149
|
+
})
|
|
150
|
+
assert.equal(promptCalls[0].body.parts[0].synthetic, true)
|
|
151
|
+
|
|
152
|
+
await hooks.dispose()
|
|
153
|
+
await hooks.dispose()
|
|
154
|
+
|
|
155
|
+
console.log(
|
|
156
|
+
`packed host contract passed (${installedManifest.name}@${installedManifest.version}; ${packResult[0].size} byte tarball)`,
|
|
157
|
+
)
|
|
158
|
+
} finally {
|
|
159
|
+
await rm(root, { recursive: true, force: true })
|
|
160
|
+
}
|
package/scripts/verify.mjs
CHANGED
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
import assert from "node:assert/strict"
|
|
8
8
|
|
|
9
9
|
const REQUIRED_HOOKS = [
|
|
10
|
+
"config",
|
|
10
11
|
"command.execute.before",
|
|
11
12
|
"event",
|
|
12
13
|
"experimental.chat.system.transform",
|
|
13
14
|
"experimental.compaction.autocontinue",
|
|
15
|
+
"experimental.session.compacting",
|
|
14
16
|
]
|
|
15
17
|
|
|
16
18
|
const results = []
|
|
@@ -68,10 +70,10 @@ const client = {
|
|
|
68
70
|
|
|
69
71
|
let hooks
|
|
70
72
|
|
|
71
|
-
await check(
|
|
73
|
+
await check(`plugin initializes and registers all ${REQUIRED_HOOKS.length} required hooks`, async () => {
|
|
72
74
|
// registerTools defaults to true but silently no-ops without the optional
|
|
73
75
|
// @opencode-ai/plugin peer dependency, so it is not asserted here — the
|
|
74
|
-
//
|
|
76
|
+
// These hooks are always present regardless of that peer dependency.
|
|
75
77
|
hooks = await GoalPlugin({ client }, { minDelayMs: 1, persistState: false })
|
|
76
78
|
for (const hookName of REQUIRED_HOOKS) {
|
|
77
79
|
assert.equal(
|