From e84ddcc0fe08f2787a678fe1f83f8748742fec11 Mon Sep 17 00:00:00 2001 From: Willy Date: Thu, 4 Jun 2026 02:44:34 +0000 Subject: [PATCH 1/2] Harden goal plugin verification and compatibility --- .github/workflows/ci.yml | 10 +- .nvmrc | 1 + CHANGELOG.md | 6 + CONTRIBUTING.md | 32 ++- README.md | 38 +++- SECURITY.md | 14 +- package.json | 5 +- scripts/smoke-command-hook.mjs | 49 +++++ src/goal-plugin.js | 286 ++++++++++++++++++++++++--- test/goal-plugin.test.js | 351 +++++++++++++++++++++++++++++++++ 10 files changed, 750 insertions(+), 42 deletions(-) create mode 100644 .nvmrc create mode 100644 scripts/smoke-command-hook.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8391cdb..9b389b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: pull_request: push: branches: [main] + workflow_dispatch: permissions: contents: read @@ -11,6 +12,10 @@ permissions: jobs: check: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [18, 20, 22] steps: - name: Check out repository uses: actions/checkout@v4 @@ -18,10 +23,13 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: "22" + node-version: ${{ matrix.node-version }} - name: Run checks run: npm run check + - name: Run package smoke test + run: npm run smoke + - name: Verify package contents run: npm run pack:check diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7424695..fb4ce84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Add `npm run smoke`, a package-export smoke test that exercises the `/goal` command hook without invoking a model. +- Run CI across Node 18, 20, and 22, and wire the package-entry smoke test into the workflow. +- Harden persisted-state loading with schema validation and explicit skipping of malformed goal/result entries. +- Make hook handling more defensive around message payload shapes and `system` block normalization. +- Expand docs around compatibility, release checks, smoke testing, and security reporting fallback. + ## 0.1.10 — 2026-05-30 - Fix `experimental.chat.system.transform` to merge the goal continuation block into the primary system entry instead of pushing a separate one. Prevents `"System message must be at the beginning."` errors on strict-template backends (Qwen on vLLM, several Llama.cpp/Mistral templates). See issue #1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 020efff..db95641 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,9 +4,18 @@ Thanks for helping improve `opencode-goal-plugin`. ## Development +Use the pinned Node version when possible: + +```sh +nvm use +``` + Run the local checks before submitting changes: ```sh +npm test +npm run test:coverage +npm run smoke npm run check npm run pack:check ``` @@ -15,11 +24,27 @@ For behavior changes, add or update tests in `test/goal-plugin.test.js`. ## OpenCode Compatibility -This plugin depends on OpenCode plugin hooks, including experimental hooks. When changing hook usage or command behavior: +This plugin depends on OpenCode plugin hooks, including experimental hooks. When changing hook usage, command behavior, or system-prompt transforms: 1. Check the current OpenCode plugin and command documentation. -2. Test against a real OpenCode install when possible. -3. Update the README compatibility note if the tested version changes. +2. Run `npm run smoke` to verify the packaged entrypoint and command hook surface. +3. Test against a real OpenCode install when possible. +4. Update the README compatibility snapshot if the tested surface changes. + +`npm run smoke` verifies the package export path and `/goal` command hook without invoking a model. It does not replace a real OpenCode smoke test after hook or command behavior changes. + +## Release checklist + +Before publishing or tagging a release: + +- update `CHANGELOG.md` +- run `npm test` +- run `npm run test:coverage` +- run `npm run smoke` +- run `npm run check` +- run `npm run pack:check` +- perform at least one manual OpenCode smoke test if hook behavior changed +- refresh compatibility notes if the tested OpenCode surface changed ## Pull Requests @@ -29,4 +54,3 @@ Keep pull requests focused. Include: - why it changed - the checks you ran - any manual OpenCode smoke testing performed - diff --git a/README.md b/README.md index 3ff0bb5..4ecd084 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,16 @@ An experimental session-scoped `/goal` command for [OpenCode](https://opencode.a Set a goal and the plugin keeps it in context, auto-continues the session whenever the assistant goes idle, and stops when the goal is marked complete, a blocker is reported, or a safety limit is reached. -Compatibility: tested against OpenCode 1.15.11. The plugin relies on experimental OpenCode hooks; pin or re-test against your OpenCode version before using it for unattended long-running work. +Compatibility: this plugin relies on experimental OpenCode hooks. Re-test against the exact OpenCode build and provider/backend stack you plan to use for unattended work. + +## Compatibility snapshot + +| Surface | Status | +|---|---| +| Node.js | Declared support: `>=18`; CI covers Node 18, 20, and 22 | +| Package entrypoint | `npm run smoke` verifies the package export path plus `/goal` command-hook behavior from a local install without invoking a model | +| OpenCode host | Manually smoke-tested against OpenCode 1.15.10 using the `opencode-go` provider (`qwen3.7-plus`) on this repo's local hardening branch; re-test your own version/provider stack before relying on unattended runs | +| Provider/backend quirks | Strict-template backends require the goal block to merge into the primary `system` message; covered by regression tests | ## Install @@ -209,19 +218,28 @@ Keep test files outside OpenCode's auto-loaded plugin directory — OpenCode wil ### Smoke-test checklist -1. Install or file-load the plugin in a temporary OpenCode config. -2. Add a `goal` command with `"template": "$ARGUMENTS"`. -3. Run `/goal status` — should report no active goal. -4. Run `/goal inspect this repo and stop immediately with [goal:blocked] if you need user input`. -5. Verify `/goal status`, `/goal pause`, `/goal resume`, and `/goal clear` behave as expected. +1. Run `npm run smoke` to verify the package export path and `/goal` command hook without a model call. +2. Install or file-load the plugin in a temporary OpenCode config. +3. Add a `goal` command with `"template": "$ARGUMENTS"`. +4. Run `/goal status` — should report no active goal. +5. Run `/goal inspect this repo and stop immediately with [goal:blocked] if you need user input`. +6. Verify `/goal status`, `/goal pause`, `/goal resume`, and `/goal clear` behave as expected. +7. If you changed hook payload handling or command behavior, repeat the smoke test against the exact OpenCode version and provider/backend combination you care about. + +### Release checklist + +- Confirm `npm test`, `npm run test:coverage`, `npm run check`, `npm run smoke`, and `npm run pack:check` all pass. +- Re-test against a real OpenCode install when touching command hooks, idle-event handling, or system-prompt transforms. +- Update compatibility notes and changelog entries when behavior or tested surfaces change. ## Development ```sh -npm test # run the test suite -npm run test:coverage # run tests with coverage -npm run check # syntax check + tests -npm run pack:check # verify package contents before publishing +npm test # run the test suite +npm run test:coverage # run tests with coverage +npm run smoke # verify package export + command hook without a model call +npm run check # syntax check + tests +npm run pack:check # verify package contents before publishing ``` ## License diff --git a/SECURITY.md b/SECURITY.md index aa4eb2c..855f967 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,13 +6,18 @@ This project is experimental. Security fixes are provided for the latest publish ## Reporting a Vulnerability -Please report security issues through GitHub's private vulnerability reporting for this repository. +GitHub private vulnerability reporting may not always be enabled for this repository. -Do not open a public issue for vulnerabilities that could expose user data, credentials, or local system access. +Until a dedicated private reporting channel is documented here, do **not** open a public issue with exploit details, credentials, local paths, or reproduction steps that could expose user data or local system access. + +Instead: + +1. open a minimal public issue asking for a private contact path, or +2. contact the maintainer through their GitHub profile and request a private handoff. ## Scope -This plugin does not intentionally read credentials, write files, or execute shell commands. It observes OpenCode session events, injects goal context into prompts, and sends continuation prompts through OpenCode's SDK client. +This plugin does not intentionally read credentials, write arbitrary user files, or execute shell commands. It observes OpenCode session events, injects goal context into prompts, and sends continuation prompts through OpenCode's SDK client. Relevant security-sensitive areas include: @@ -20,5 +25,6 @@ Relevant security-sensitive areas include: - unexpected auto-continuation behavior - incorrect command or hook handling across OpenCode versions - leakage of goal text through logs or status output +- malformed persisted state causing stale or unexpected goal recovery -The goal text is wrapped in `` tags and the closing tag is escaped before insertion. Other structural tags used in continuation prompts (``, ``, etc.) are not escaped. Crafted goal text containing those literal strings would close the tag early in the plaintext prompt; the model treats it as text rather than structure, so the practical risk for a local single-user tool is negligible. Do not paste untrusted third-party text into a goal; treat goal text as if you typed it directly into the assistant. +The goal text is wrapped in `` tags and the closing tag is escaped before insertion. Other structural tags used in continuation prompts (``, ``, etc.) are not escaped. Crafted goal text containing those literal strings would close the tag early in the plaintext prompt; the model still receives plaintext rather than true privileged structure, but you should still treat goal text as trusted local input rather than pasting in arbitrary third-party content. diff --git a/package.json b/package.json index 163ea06..03b381c 100644 --- a/package.json +++ b/package.json @@ -10,16 +10,19 @@ }, "files": [ "src", + "scripts", "examples", "README.md", "CHANGELOG.md", "CONTRIBUTING.md", "SECURITY.md", - "LICENSE" + "LICENSE", + ".nvmrc" ], "scripts": { "test": "node --test", "test:coverage": "node --test --experimental-test-coverage", + "smoke": "node scripts/smoke-command-hook.mjs", "check": "node -c src/goal-plugin.js && npm test", "pack:check": "npm pack --dry-run" }, diff --git a/scripts/smoke-command-hook.mjs b/scripts/smoke-command-hook.mjs new file mode 100644 index 0000000..ec2bb9d --- /dev/null +++ b/scripts/smoke-command-hook.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict" +import pluginModule, { GoalPlugin } from "opencode-goal-plugin" + +const sessionID = `smoke-${Date.now()}` +const promptCalls = [] +const logCalls = [] + +const client = { + app: { + log: async (input) => { + logCalls.push(input) + }, + }, + session: { + messages: async () => ({ data: [] }), + promptAsync: async (input) => { + promptCalls.push(input) + return {} + }, + }, +} + +assert.equal(pluginModule.id, "opencode-goal-plugin") +assert.equal(pluginModule.server, GoalPlugin) + +const hooks = await GoalPlugin({ client }, { minDelayMs: 1 }) +assert.equal(typeof hooks["command.execute.before"], "function") +assert.equal(typeof hooks.event, "function") +assert.equal(typeof hooks["experimental.chat.system.transform"], "function") + +const commandHook = hooks["command.execute.before"] + +async function runGoalCommand(args) { + const output = { parts: [] } + await commandHook({ command: "goal", sessionID, arguments: args }, output) + assert.equal(output.parts.length, 1) + assert.equal(output.parts[0].type, "text") + return output.parts[0].text +} + +assert.match(await runGoalCommand("status"), /No active goal/) +assert.match(await runGoalCommand("ship a smoke test --max-turns 1"), /New active goal/) +assert.match(await runGoalCommand("status"), /Active goal: ship a smoke test/) +assert.match(await runGoalCommand("clear"), /Goal cleared/) +assert.match(await runGoalCommand("status"), /No active goal/) +assert.equal(promptCalls.length, 0) +assert.equal(logCalls.length, 0) + +console.log("opencode-goal-plugin command hook smoke passed") diff --git a/src/goal-plugin.js b/src/goal-plugin.js index 59cc5f8..b68a924 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -295,6 +295,11 @@ function parsePositiveIntegerStrict(value) { return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null } +function toNonNegativeInteger(value, fallback = 0) { + const parsed = Number(value) + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback +} + function stripWrappingQuotes(value) { return value.replace(/^["']|["']$/g, "") } @@ -358,6 +363,109 @@ function normalizePersistenceOptions(options = {}) { } } +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +function normalizeTimestamp(value, fallback = Date.now()) { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback +} + +function normalizeHistoryEntries(entries) { + if (!Array.isArray(entries)) return [] + return entries + .filter(isPlainObject) + .map((entry) => + makeHistoryEntry( + typeof entry.type === "string" && entry.type.trim() ? entry.type.trim() : "event", + typeof entry.detail === "string" ? entry.detail : "", + normalizeTimestamp(entry.timestamp), + ), + ) +} + +function normalizeCheckpointEntry(entry) { + if (!isPlainObject(entry)) return null + const summary = summarizeText(entry.summary) + if (!summary) return null + return { + summary, + timestamp: normalizeTimestamp(entry.timestamp), + } +} + +function normalizeCheckpointEntries(entries) { + if (!Array.isArray(entries)) return [] + return entries.map(normalizeCheckpointEntry).filter(Boolean) +} + +function normalizePersistedGoal(rawGoal) { + if (!isPlainObject(rawGoal)) return null + if (typeof rawGoal.sessionID !== "string" || !rawGoal.sessionID.trim()) return null + if (typeof rawGoal.condition !== "string" || !rawGoal.condition.trim()) return null + + const checkpoints = normalizeCheckpointEntries(rawGoal.checkpoints) + const lastCheckpoint = normalizeCheckpointEntry(rawGoal.lastCheckpoint) || checkpoints.at(-1) || null + + return { + goalId: + typeof rawGoal.goalId === "string" && rawGoal.goalId.trim() + ? rawGoal.goalId + : randomUUID(), + condition: rawGoal.condition.trim(), + sessionID: rawGoal.sessionID.trim(), + turnCount: toNonNegativeInteger(rawGoal.turnCount), + startedAt: normalizeTimestamp(rawGoal.startedAt), + totalTokens: toNonNegativeInteger(rawGoal.totalTokens), + options: normalizeOptions(isPlainObject(rawGoal.options) ? rawGoal.options : {}), + lastStatus: typeof rawGoal.lastStatus === "string" ? rawGoal.lastStatus : "Goal recovered.", + lastAssistantText: + typeof rawGoal.lastAssistantText === "string" ? rawGoal.lastAssistantText : "", + lastAssistantMessageID: + typeof rawGoal.lastAssistantMessageID === "string" ? rawGoal.lastAssistantMessageID : "", + lastContinueAt: toNonNegativeInteger(rawGoal.lastContinueAt), + lastProgressAt: toNonNegativeInteger(rawGoal.lastProgressAt), + noProgressTurns: toNonNegativeInteger(rawGoal.noProgressTurns), + blockedReason: typeof rawGoal.blockedReason === "string" ? rawGoal.blockedReason : "", + budgetWrapupSent: rawGoal.budgetWrapupSent === true, + stopped: rawGoal.stopped === true, + stopReason: typeof rawGoal.stopReason === "string" ? rawGoal.stopReason : "", + promptFailures: toNonNegativeInteger(rawGoal.promptFailures), + messageIDs: Array.isArray(rawGoal.messageIDs) + ? rawGoal.messageIDs.filter((messageID) => typeof messageID === "string" && messageID) + : [], + history: normalizeHistoryEntries(rawGoal.history).slice(-MAX_HISTORY_ENTRIES), + checkpoints: checkpoints.slice(-MAX_CHECKPOINTS), + lastCheckpoint, + } +} + +function normalizePersistedResult(rawResult) { + if (!isPlainObject(rawResult)) return null + if (typeof rawResult.sessionID !== "string" || !rawResult.sessionID.trim()) return null + if (typeof rawResult.condition !== "string" || !rawResult.condition.trim()) return null + + const checkpoints = normalizeCheckpointEntries(rawResult.checkpoints) + const lastCheckpoint = normalizeCheckpointEntry(rawResult.lastCheckpoint) || checkpoints.at(-1) || null + + return { + sessionID: rawResult.sessionID.trim(), + condition: rawResult.condition.trim(), + state: typeof rawResult.state === "string" && rawResult.state.trim() ? rawResult.state : "unknown", + reason: typeof rawResult.reason === "string" ? rawResult.reason : "", + blockedReason: typeof rawResult.blockedReason === "string" ? rawResult.blockedReason : "", + turnCount: toNonNegativeInteger(rawResult.turnCount), + totalTokens: toNonNegativeInteger(rawResult.totalTokens), + startedAt: normalizeTimestamp(rawResult.startedAt), + finishedAt: normalizeTimestamp(rawResult.finishedAt), + lastStatus: typeof rawResult.lastStatus === "string" ? rawResult.lastStatus : "", + lastCheckpoint, + checkpoints: checkpoints.slice(-MAX_CHECKPOINTS), + history: normalizeHistoryEntries(rawResult.history).slice(-MAX_HISTORY_ENTRIES), + } +} + function serializeGoal(goal) { return { ...goal, @@ -405,13 +513,47 @@ async function loadPersistedState(persistenceOptions, client) { return "invalid" } + if (!Array.isArray(parsed.goals) || !Array.isArray(parsed.results)) { + await logPluginError(client, "Skipped persisted goal state: malformed goals/results arrays.") + return "invalid" + } + + const loadedGoals = [] + let skippedGoals = 0 + for (const rawGoal of parsed.goals) { + const normalizedGoal = normalizePersistedGoal(rawGoal) + if (normalizedGoal) { + loadedGoals.push(normalizedGoal) + } else { + skippedGoals += 1 + } + } + + const loadedResults = [] + let skippedResults = 0 + for (const rawResult of parsed.results) { + const normalizedResult = normalizePersistedResult(rawResult) + if (normalizedResult) { + loadedResults.push(normalizedResult) + } else { + skippedResults += 1 + } + } + + if (skippedGoals > 0 || skippedResults > 0) { + await logPluginError( + client, + `Skipped invalid persisted entries: ${skippedGoals} goal(s), ${skippedResults} result(s).`, + ) + } + clearRuntimeState() - for (const goal of parsed.goals || []) { + for (const goal of loadedGoals) { goalStates.set(goal.sessionID, deserializeGoal(goal)) } - for (const result of parsed.results || []) { + for (const result of loadedResults) { lastGoalResults.set(result.sessionID, result) } @@ -634,12 +776,104 @@ function formatArgumentErrors(errors) { ].join("\n") } +function messageRole(message) { + return message?.info?.role || message?.role || "" +} + +function messageID(message) { + return message?.info?.id || message?.id || "" +} + +function messageSessionID(message) { + return message?.info?.sessionID || message?.sessionID || "" +} + +function messageTokens(message) { + return isPlainObject(message?.info?.tokens) + ? message.info.tokens + : isPlainObject(message?.tokens) + ? message.tokens + : {} +} + +function totalTokensForMessage(message) { + const tokens = messageTokens(message) + return ( + toNonNegativeInteger(tokens.input) + + toNonNegativeInteger(tokens.output) + + toNonNegativeInteger(tokens.reasoning) + ) +} + +function messageInfoFromEvent(event) { + const candidates = [ + event?.properties?.info, + event?.properties?.message?.info, + event?.properties?.message, + ] + return candidates.find(isPlainObject) || null +} + +function appendGoalToSystemBlock(block, goalBlock) { + if (typeof block === "string") { + return `${block}\n\n${goalBlock}` + } + + if (!isPlainObject(block)) return null + + if (typeof block.text === "string") { + return { + ...block, + text: `${block.text}\n\n${goalBlock}`, + } + } + + if (typeof block.content === "string") { + return { + ...block, + content: `${block.content}\n\n${goalBlock}`, + } + } + + if (Array.isArray(block.content)) { + const content = [...block.content] + const firstTextIndex = content.findIndex( + (part) => isPlainObject(part) && typeof part.text === "string", + ) + if (firstTextIndex >= 0) { + content[firstTextIndex] = { + ...content[firstTextIndex], + text: `${content[firstTextIndex].text}\n\n${goalBlock}`, + } + return { + ...block, + content, + } + } + } + + return null +} + +function systemBlockContainsGoal(block) { + if (typeof block === "string") return block.includes("") + if (!isPlainObject(block)) return false + if (typeof block.text === "string") return block.text.includes("") + if (typeof block.content === "string") return block.content.includes("") + if (Array.isArray(block.content)) { + return block.content.some( + (part) => isPlainObject(part) && typeof part.text === "string" && part.text.includes(""), + ) + } + return false +} + function findLatestAssistantMessage(messages) { - return [...(messages || [])].reverse().find((message) => message.info?.role === "assistant") || null + return [...(messages || [])].reverse().find((message) => messageRole(message) === "assistant") || null } function outputTokensForMessage(message) { - return message?.info?.tokens?.output || 0 + return toNonNegativeInteger(messageTokens(message).output) } function budgetWrapupNeeded(goal) { @@ -821,34 +1055,34 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => { event: async ({ event }) => { if (event.type === "message.updated") { - const message = event.properties?.info + const message = messageInfoFromEvent(event) if (!message) return - const goal = goalStates.get(message.sessionID) + const goal = goalStates.get(messageSessionID(message)) if (!goal) return + const currentMessageID = messageID(message) + if (!currentMessageID) return + let changed = false - const currentOutputTokens = message.tokens?.output || 0 - const previousOutputTokens = seenOutputTokens.get(message.id) || 0 - const currentTokens = - (message.tokens?.input || 0) + - currentOutputTokens + - (message.tokens?.reasoning || 0) - const previousTokens = seenTokens.get(message.id) || 0 + const currentOutputTokens = outputTokensForMessage(message) + const previousOutputTokens = seenOutputTokens.get(currentMessageID) || 0 + const currentTokens = totalTokensForMessage(message) + const previousTokens = seenTokens.get(currentMessageID) || 0 if (currentTokens > previousTokens) { goal.totalTokens += currentTokens - previousTokens - seenTokens.set(message.id, currentTokens) - goal.messageIDs.add(message.id) + seenTokens.set(currentMessageID, currentTokens) + goal.messageIDs.add(currentMessageID) changed = true } if (currentOutputTokens > previousOutputTokens) { - seenOutputTokens.set(message.id, currentOutputTokens) - goal.messageIDs.add(message.id) + seenOutputTokens.set(currentMessageID, currentOutputTokens) + goal.messageIDs.add(currentMessageID) changed = true } - if (message.role === "assistant" && currentOutputTokens > previousOutputTokens) { + if (messageRole(message) === "assistant" && currentOutputTokens > previousOutputTokens) { goal.lastProgressAt = Date.now() changed = true } @@ -1057,7 +1291,8 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => { const goal = goalStates.get(input.sessionID) if (!goal) return if (goal.stopped) return - if (output.system.some((block) => block.includes(""))) return + const systemBlocks = Array.isArray(output.system) ? [...output.system] : [] + if (systemBlocks.some(systemBlockContainsGoal)) return const goalBlock = [ buildGoalBlock(goal), @@ -1067,11 +1302,18 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => { buildLimitWarning(goal), ].filter(Boolean).join("\n") - if (output.system.length === 0) { - output.system.push(goalBlock) + if (systemBlocks.length === 0) { + output.system = [goalBlock] + return + } + + const mergedFirstBlock = appendGoalToSystemBlock(systemBlocks[0], goalBlock) + if (mergedFirstBlock) { + systemBlocks[0] = mergedFirstBlock } else { - output.system[0] = `${output.system[0]}\n\n${goalBlock}` + systemBlocks.unshift(goalBlock) } + output.system = systemBlocks }, } } diff --git a/test/goal-plugin.test.js b/test/goal-plugin.test.js index 97dd765..42bc3eb 100644 --- a/test/goal-plugin.test.js +++ b/test/goal-plugin.test.js @@ -8,6 +8,7 @@ import pluginModule, { GoalPlugin, testInternals } from "../src/goal-plugin.js" const { buildContinueMessage, buildGoalBlock, + buildLimitWarning, currentGoal, extractBlockedReason, formatStatus, @@ -1214,3 +1215,353 @@ test("two sessions run independent goals without interference", async () => { assert.equal(currentGoal("session-A").condition, "task A") assert.equal(currentGoal("session-B").condition, "task B") }) + +test("buildLimitWarning reports remaining seconds when duration is nearly exhausted", () => { + const warning = buildLimitWarning({ + turnCount: 0, + totalTokens: 0, + startedAt: Date.now() - 59_500, + options: normalizeOptions({ + maxTurns: 10, + maxTokens: 200_000, + maxDurationMs: 60_000, + warnDurationMsRemaining: 60_000, + }), + }) + + assert.match(warning, /s remaining/) +}) + +test("duration limit requests a final handoff and stops the goal", async () => { + const { calls, hooks } = await createHooks({ + options: { minDelayMs: 1, maxDurationMs: 10_000, noProgressTokenThreshold: 1 }, + }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-duration", arguments: "ship it" }, + { parts: [] }, + ) + + const goal = currentGoal("session-duration") + goal.startedAt = Date.now() - 11_000 + + await hooks.event({ + event: { + type: "session.status", + properties: { sessionID: "session-duration", status: { type: "idle" } }, + }, + }) + + assert.equal(calls.length, 1) + assert.match(calls[0].body.parts[0].text, //) + assert.equal(goal.stopped, true) + assert.match(goal.stopReason, /max duration reached/) +}) + +test("system transform tolerates missing and structured system blocks", async () => { + const { hooks } = await createHooks() + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-system-shape", arguments: "ship it" }, + { parts: [] }, + ) + + const output = {} + await hooks["experimental.chat.system.transform"]({ sessionID: "session-system-shape" }, output) + assert.equal(Array.isArray(output.system), true) + assert.equal(output.system.length, 1) + assert.match(output.system[0], /\nship it\n<\/goal_objective>/) + + const outputWithObject = { system: [{ role: "system", text: "base system" }] } + await hooks["experimental.chat.system.transform"]({ sessionID: "session-system-shape" }, outputWithObject) + assert.equal(outputWithObject.system.length, 1) + assert.equal(outputWithObject.system[0].role, "system") + assert.match(outputWithObject.system[0].text, /base system/) + assert.match(outputWithObject.system[0].text, //) + + const outputWithOpaqueObject = { system: [{ role: "system", metadata: true }] } + await hooks["experimental.chat.system.transform"]( + { sessionID: "session-system-shape" }, + outputWithOpaqueObject, + ) + assert.equal(outputWithOpaqueObject.system.length, 2) + assert.match(outputWithOpaqueObject.system[0], //) + assert.deepEqual(outputWithOpaqueObject.system[1], { role: "system", metadata: true }) +}) + +test("message.updated accepts nested message payload shapes", async () => { + const dir = await mkdtemp(join(tmpdir(), "goal-plugin-test-")) + const stateFilePath = join(dir, "state.json") + + try { + const hooks = await GoalPlugin( + { + client: { + app: { log: async () => {} }, + session: { + messages: async () => ({ data: [] }), + promptAsync: async () => ({}), + }, + }, + }, + { persistState: true, stateFilePath, minDelayMs: 1 }, + ) + + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-nested-message", arguments: "ship it" }, + { parts: [] }, + ) + + await hooks.event({ + event: { + type: "message.updated", + properties: { + message: { + info: { + id: "msg-nested", + role: "assistant", + sessionID: "session-nested-message", + tokens: { input: 4, output: 7, reasoning: 3 }, + }, + }, + }, + }, + }) + + const goal = currentGoal("session-nested-message") + assert.equal(goal.totalTokens, 14) + assert.ok(goal.messageIDs.has("msg-nested")) + assert.ok(goal.lastProgressAt > 0) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test("unsupported persisted state version is ignored without clearing runtime state", async () => { + const dir = await mkdtemp(join(tmpdir(), "goal-plugin-test-")) + const stateFilePath = join(dir, "state.json") + + try { + await writeFile( + stateFilePath, + JSON.stringify({ version: 999, goals: [{ sessionID: "bad", condition: "bad" }], results: [] }), + "utf8", + ) + + await GoalPlugin( + { + client: { + app: { log: async () => {} }, + session: { messages: async () => ({ data: [] }), promptAsync: async () => ({}) }, + }, + }, + { persistState: true, stateFilePath, minDelayMs: 1 }, + ) + + assert.equal(currentGoal("bad"), null) + assert.equal(JSON.parse(await readFile(stateFilePath, "utf8")).version, 999) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test("malformed persisted arrays are ignored and not overwritten on startup", async () => { + const dir = await mkdtemp(join(tmpdir(), "goal-plugin-test-")) + const stateFilePath = join(dir, "state.json") + + try { + await writeFile(stateFilePath, JSON.stringify({ version: 1, goals: {}, results: [] }), "utf8") + + await GoalPlugin( + { + client: { + app: { log: async () => {} }, + session: { messages: async () => ({ data: [] }), promptAsync: async () => ({}) }, + }, + }, + { persistState: true, stateFilePath, minDelayMs: 1 }, + ) + + assert.equal(JSON.parse(await readFile(stateFilePath, "utf8")).goals.constructor, Object) + assert.equal(currentGoal("anything"), null) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test("persisted state skips malformed entries while keeping valid ones", async () => { + const dir = await mkdtemp(join(tmpdir(), "goal-plugin-test-")) + const stateFilePath = join(dir, "state.json") + + try { + await writeFile( + stateFilePath, + JSON.stringify({ + version: 1, + goals: [ + { + sessionID: "session-valid-goal", + condition: "valid goal", + startedAt: Date.now(), + options: { maxTurns: 7 }, + history: [{ type: "set", detail: "Goal created.", timestamp: Date.now() }], + checkpoints: [{ summary: "Checked the repo.", timestamp: Date.now() }], + }, + { sessionID: "", condition: "invalid goal" }, + ], + results: [ + { + sessionID: "session-valid-result", + condition: "valid result", + state: "achieved", + startedAt: Date.now() - 1000, + finishedAt: Date.now(), + history: [{ type: "completed", detail: "Wrapped up.", timestamp: Date.now() }], + }, + { sessionID: "session-bad-result" }, + ], + }), + "utf8", + ) + + const hooks = await GoalPlugin( + { + client: { + app: { log: async () => {} }, + session: { messages: async () => ({ data: [] }), promptAsync: async () => ({}) }, + }, + }, + { persistState: true, stateFilePath, minDelayMs: 1 }, + ) + + const loadedGoal = currentGoal("session-valid-goal") + assert.equal(loadedGoal.condition, "valid goal") + assert.equal(loadedGoal.options.maxTurns, 7) + assert.equal(currentGoal("") , null) + + const statusOutput = { parts: [] } + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-valid-result", arguments: "status" }, + statusOutput, + ) + assert.match(statusOutput.parts[0].text, /Last goal: valid result/) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test("/goal history returns the most recent completed goal history", async () => { + const { hooks } = await createHooks({ + messages: async () => ({ data: [message("Done after inspecting src/goal-plugin.js\n\n[goal:complete]")] }), + options: { minDelayMs: 1 }, + }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-completed-history", arguments: "ship it" }, + { parts: [] }, + ) + await hooks.event({ + event: { + type: "session.status", + properties: { sessionID: "session-completed-history", status: { type: "idle" } }, + }, + }) + + const output = { parts: [] } + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-completed-history", arguments: "history" }, + output, + ) + + assert.match(output.parts[0].text, /Last goal history for: ship it/) + assert.match(output.parts[0].text, /completed:/) +}) + +test("repeated thrown event-handler errors eventually pause the goal", async () => { + const { hooks } = await createHooks({ + messages: async () => { + throw new Error("network") + }, + options: { minDelayMs: 1, maxPromptFailures: 2 }, + }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-thrown-failures", arguments: "ship it" }, + { parts: [] }, + ) + + await hooks.event({ + event: { + type: "session.status", + properties: { sessionID: "session-thrown-failures", status: { type: "idle" } }, + }, + }) + await hooks.event({ + event: { + type: "session.status", + properties: { sessionID: "session-thrown-failures", status: { type: "idle" } }, + }, + }) + + const goal = currentGoal("session-thrown-failures") + assert.equal(goal.stopped, true) + assert.equal(goal.stopReason, "auto-continue failures") +}) + +test("missing client.app.log falls back to console.error", async () => { + const originalConsoleError = console.error + const captured = [] + console.error = (...args) => { + captured.push(args) + } + + try { + const hooks = await GoalPlugin( + { + client: { + session: { + messages: async () => { + throw new Error("network") + }, + promptAsync: async () => ({}), + }, + }, + }, + { persistState: false, minDelayMs: 1 }, + ) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-console-fallback", arguments: "ship it" }, + { parts: [] }, + ) + await hooks.event({ + event: { + type: "session.status", + properties: { sessionID: "session-console-fallback", status: { type: "idle" } }, + }, + }) + + assert.ok(captured.length >= 1) + assert.match(String(captured.at(-1)[1]), /Auto-continue failed/) + } finally { + console.error = originalConsoleError + } +}) + +test("persist failures are logged without throwing", async () => { + const logs = [] + const hooks = await GoalPlugin( + { + client: { + app: { log: async (input) => logs.push(input) }, + session: { + messages: async () => ({ data: [] }), + promptAsync: async () => ({}), + }, + }, + }, + { persistState: true, stateFilePath: "/dev/null/state.json", minDelayMs: 1 }, + ) + + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-persist-failure", arguments: "ship it" }, + { parts: [] }, + ) + + assert.ok(logs.some((entry) => entry.body.message === "Failed to persist goal state")) +}) From ac048e0afdd8492dc80d13a213b094c517543265 Mon Sep 17 00:00:00 2001 From: Willy Date: Thu, 4 Jun 2026 18:06:32 +0000 Subject: [PATCH 2/2] chore(release): prepare 0.1.11 --- CHANGELOG.md | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb4ce84..1eb0289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.1.11 — 2026-06-04 + - Add `npm run smoke`, a package-export smoke test that exercises the `/goal` command hook without invoking a model. - Run CI across Node 18, 20, and 22, and wire the package-entry smoke test into the workflow. - Harden persisted-state loading with schema validation and explicit skipping of malformed goal/result entries. diff --git a/package.json b/package.json index 03b381c..a12d49a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-goal-plugin", - "version": "0.1.10", + "version": "0.1.11", "description": "Session-scoped /goal workflow for OpenCode.", "type": "module", "main": "./src/goal-plugin.js",