opencode-goal-plugin 0.6.0 → 0.6.2
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 +34 -15
- package/CONTRIBUTING.md +10 -13
- package/README.md +30 -19
- package/SECURITY.md +7 -9
- package/docs/compatibility.md +41 -0
- package/docs/providers.md +22 -3
- package/docs/releasing.md +41 -0
- package/index.d.ts +58 -6
- package/package.json +24 -6
- package/scripts/verify.mjs +1 -0
- package/src/goal-plugin.js +660 -229
- package/src/native-agent-config.js +5 -1
- package/src/opencode-session-api.js +11 -1
- package/src/persistence-lease.js +14 -2
- package/scripts/behavior-benchmark.mjs +0 -272
- package/scripts/packed-host-contract.mjs +0 -160
- package/scripts/smoke-command-hook.mjs +0 -51
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const GOAL_AGENT_PROMPT = `Execute explicit goals persistently. Use goal tools to track state and checkpoints. Make concrete progress; claim completion only with verification evidence. Report only genuine blockers.`
|
|
2
2
|
|
|
3
|
-
const VERIFIER_AGENT_PROMPT = `Independently verify the claim against the goal, constraints, evidence, and workspace. Use
|
|
3
|
+
const VERIFIER_AGENT_PROMPT = `Independently verify the claim against the goal, constraints, evidence, and workspace. Use only the read, glob, and grep tools; never edit, execute commands, call other tools, or mutate goal state. Approve only when proven; otherwise give one actionable reason.`
|
|
4
4
|
|
|
5
5
|
export function applyNativeGoalConfig(config, options = {}) {
|
|
6
6
|
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
@@ -40,6 +40,10 @@ export function applyNativeGoalConfig(config, options = {}) {
|
|
|
40
40
|
hidden: true,
|
|
41
41
|
prompt: VERIFIER_AGENT_PROMPT,
|
|
42
42
|
permission: {
|
|
43
|
+
"*": "deny",
|
|
44
|
+
read: "allow",
|
|
45
|
+
glob: "allow",
|
|
46
|
+
grep: "allow",
|
|
43
47
|
edit: "deny",
|
|
44
48
|
bash: "deny",
|
|
45
49
|
},
|
|
@@ -5,6 +5,11 @@ const SHAPE_ERROR_PATTERNS = [
|
|
|
5
5
|
/(?:validation|schema|invalid input|invalid argument)/i,
|
|
6
6
|
]
|
|
7
7
|
|
|
8
|
+
// Only read-only operations may be retried with another argument shape. A
|
|
9
|
+
// TypeError can be raised after a mutating SDK call has already reached the
|
|
10
|
+
// host, so replaying create/prompt/update/delete/abort could duplicate side effects.
|
|
11
|
+
const REPLAY_SAFE_OPERATIONS = new Set(["messages", "get"])
|
|
12
|
+
|
|
8
13
|
function isArgumentShapeError(error) {
|
|
9
14
|
if (!(error instanceof TypeError)) return false
|
|
10
15
|
const message = String(error.message || "")
|
|
@@ -46,7 +51,9 @@ export function createOpenCodeSessionApi(client, options = {}) {
|
|
|
46
51
|
shapes.set(operation, firstShape)
|
|
47
52
|
return unwrapData(response)
|
|
48
53
|
} catch (error) {
|
|
49
|
-
if (knownShape || !isArgumentShapeError(error))
|
|
54
|
+
if (knownShape || !REPLAY_SAFE_OPERATIONS.has(operation) || !isArgumentShapeError(error)) {
|
|
55
|
+
throw error
|
|
56
|
+
}
|
|
50
57
|
const fallbackShape = firstShape === "flat" ? "legacy" : "flat"
|
|
51
58
|
const fallbackInput = fallbackShape === "flat" ? flatInput : legacyInput
|
|
52
59
|
const response = await method.call(client.session, fallbackInput)
|
|
@@ -91,6 +98,9 @@ export function createOpenCodeSessionApi(client, options = {}) {
|
|
|
91
98
|
get(sessionID) {
|
|
92
99
|
return invoke("get", { sessionID }, { path: { id: sessionID } })
|
|
93
100
|
},
|
|
101
|
+
delete(sessionID) {
|
|
102
|
+
return invoke("delete", { sessionID }, { path: { id: sessionID } })
|
|
103
|
+
},
|
|
94
104
|
abort(sessionID) {
|
|
95
105
|
return invoke("abort", { sessionID }, { path: { id: sessionID } })
|
|
96
106
|
},
|
package/src/persistence-lease.js
CHANGED
|
@@ -27,7 +27,10 @@ async function readOwner(lockPath) {
|
|
|
27
27
|
* deliberately rejects a second writer instead of allowing stale full-state
|
|
28
28
|
* snapshots to overwrite each other.
|
|
29
29
|
*/
|
|
30
|
-
export async function acquirePersistenceLease(
|
|
30
|
+
export async function acquirePersistenceLease(
|
|
31
|
+
stateFilePath,
|
|
32
|
+
{ malformedGraceMs = 30_000, now = () => Date.now() } = {},
|
|
33
|
+
) {
|
|
31
34
|
const lockPath = `${stateFilePath}.lock`
|
|
32
35
|
await fs.mkdir(dirname(stateFilePath), { recursive: true, mode: 0o700 })
|
|
33
36
|
const owner = {
|
|
@@ -58,7 +61,16 @@ export async function acquirePersistenceLease(stateFilePath) {
|
|
|
58
61
|
}
|
|
59
62
|
const existing = await readOwner(lockPath)
|
|
60
63
|
const sameHost = existing?.hostname === owner.hostname
|
|
61
|
-
|
|
64
|
+
let reclaimableMalformed = false
|
|
65
|
+
if (!existing) {
|
|
66
|
+
try {
|
|
67
|
+
const info = await fs.lstat(lockPath)
|
|
68
|
+
reclaimableMalformed = now() - info.mtimeMs >= malformedGraceMs
|
|
69
|
+
} catch (statError) {
|
|
70
|
+
if (statError?.code === "ENOENT") continue
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if ((sameHost && processIsAlive(existing?.pid) === false) || reclaimableMalformed) {
|
|
62
74
|
const stalePath = `${lockPath}.stale.${randomUUID()}`
|
|
63
75
|
try {
|
|
64
76
|
await fs.rename(lockPath, stalePath)
|
|
@@ -1,272 +0,0 @@
|
|
|
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
|
|
@@ -1,160 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict"
|
|
2
|
-
import pluginModule, { GoalPlugin } from "opencode-goal-plugin"
|
|
3
|
-
|
|
4
|
-
const sessionID = `smoke-${Date.now()}`
|
|
5
|
-
const promptCalls = []
|
|
6
|
-
const logCalls = []
|
|
7
|
-
|
|
8
|
-
const client = {
|
|
9
|
-
app: {
|
|
10
|
-
log: async (input) => {
|
|
11
|
-
logCalls.push(input)
|
|
12
|
-
},
|
|
13
|
-
},
|
|
14
|
-
session: {
|
|
15
|
-
messages: async () => ({ data: [] }),
|
|
16
|
-
promptAsync: async (input) => {
|
|
17
|
-
promptCalls.push(input)
|
|
18
|
-
return {}
|
|
19
|
-
},
|
|
20
|
-
},
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
assert.equal(pluginModule.id, "opencode-goal-plugin")
|
|
24
|
-
assert.equal(pluginModule.server, GoalPlugin)
|
|
25
|
-
|
|
26
|
-
// persistState:false keeps the smoke test from reading or overwriting the
|
|
27
|
-
// user's real ~/.opencode-goal-plugin/state.json.
|
|
28
|
-
const hooks = await GoalPlugin({ client }, { minDelayMs: 1, persistState: false })
|
|
29
|
-
assert.equal(typeof hooks["command.execute.before"], "function")
|
|
30
|
-
assert.equal(typeof hooks.event, "function")
|
|
31
|
-
assert.equal(typeof hooks["experimental.chat.system.transform"], "function")
|
|
32
|
-
|
|
33
|
-
const commandHook = hooks["command.execute.before"]
|
|
34
|
-
|
|
35
|
-
async function runGoalCommand(args) {
|
|
36
|
-
const output = { parts: [] }
|
|
37
|
-
await commandHook({ command: "goal", sessionID, arguments: args }, output)
|
|
38
|
-
assert.equal(output.parts.length, 1)
|
|
39
|
-
assert.equal(output.parts[0].type, "text")
|
|
40
|
-
return output.parts[0].text
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
assert.match(await runGoalCommand("status"), /No active goal/)
|
|
44
|
-
assert.match(await runGoalCommand("ship a smoke test --max-turns 1"), /New active goal/)
|
|
45
|
-
assert.match(await runGoalCommand("status"), /Active goal: ship a smoke test/)
|
|
46
|
-
assert.match(await runGoalCommand("clear"), /Goal cleared/)
|
|
47
|
-
assert.match(await runGoalCommand("status"), /No active goal/)
|
|
48
|
-
assert.equal(promptCalls.length, 0)
|
|
49
|
-
assert.equal(logCalls.length, 0)
|
|
50
|
-
|
|
51
|
-
console.log("opencode-goal-plugin command hook smoke passed")
|