diff --git a/scripts/v2-plugin-sentinel-test.mjs b/scripts/v2-plugin-sentinel-test.mjs index a11eb8f..d2df55b 100644 --- a/scripts/v2-plugin-sentinel-test.mjs +++ b/scripts/v2-plugin-sentinel-test.mjs @@ -1,78 +1,41 @@ import assert from "node:assert/strict" import plugin, { OPENCODE_LOOP_V2_PLUGIN_ID } from "../src/source/opencode2/experimental.js" import { + OPENCODE_LOOP_V2_REQUIRED_COMMANDS, OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS, inspectOpenCode2CommandDraft, - inspectOpenCode2Context, + inspectOpenCode2CommandFiles, openCode2LoopRuntimeStatus, } from "../src/source/opencode2/capabilities.js" assert.equal(plugin.id, OPENCODE_LOOP_V2_PLUGIN_ID) -assert.equal(plugin.id, "bybrawe.opencode-loop.v2.experimental") assert.equal(typeof plugin.setup, "function") -assert.deepEqual(OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS, [ - "command.add", - "session.events", - "session.prompt", -]) +assert.deepEqual(OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS, ["command.files", "session.events", "session.prompt"]) -let transforms = 0 let registered -const currentContext = { +const context = { command: { transform: async (callback) => { - transforms += 1 registered = callback return { dispose: async () => {} } }, }, } -await plugin.setup(currentContext) -assert.equal(transforms, 1) +await plugin.setup(context) assert.equal(typeof registered, "function") -const currentDraft = { - list: () => [], - get: () => undefined, +const commands = OPENCODE_LOOP_V2_REQUIRED_COMMANDS.map((name) => ({ name })) +const draft = { + list: () => commands, + get: (name) => commands.find((item) => item.name === name), update: () => {}, remove: () => {}, } -await registered(currentDraft) - -assert.deepEqual(inspectOpenCode2Context(currentContext), { - commandTransform: true, - sessionEvents: false, - sessionPrompt: false, -}) -assert.deepEqual(inspectOpenCode2CommandDraft(currentDraft), { - list: true, - get: true, - update: true, - remove: true, - add: false, -}) -assert.deepEqual(openCode2LoopRuntimeStatus(currentContext, currentDraft).blockers, [ - "command.add", - "session.events", - "session.prompt", -]) -assert.equal(openCode2LoopRuntimeStatus(currentContext, currentDraft).ready, false) - -const futureContext = { - command: { transform: async () => ({ dispose: async () => {} }) }, - event: { subscribe: async () => ({ dispose: async () => {} }) }, - session: { prompt: async () => ({}) }, -} -const futureDraft = { ...currentDraft, add: () => {} } -assert.deepEqual(openCode2LoopRuntimeStatus(futureContext, futureDraft).blockers, []) -assert.equal(openCode2LoopRuntimeStatus(futureContext, futureDraft).ready, true) - -let missingCapabilityFailed = false -try { - await plugin.setup({ command: {} }) -} catch (error) { - missingCapabilityFailed = /command\.transform capability is unavailable/.test(String(error)) -} -assert.equal(missingCapabilityFailed, true) +await registered(draft) +assert.deepEqual(inspectOpenCode2CommandDraft(draft), { list: true, get: true, update: true, remove: true }) +assert.equal(inspectOpenCode2CommandFiles(draft).ready, true) +assert.deepEqual(inspectOpenCode2CommandFiles(draft).missing, []) +assert.deepEqual(openCode2LoopRuntimeStatus(context, draft).blockers, ["session.events", "session.prompt"]) +await plugin.setup({}) console.log("OpenCode 2 plugin sentinel contract passed") diff --git a/src/source/opencode2/capabilities.js b/src/source/opencode2/capabilities.js index 9ec486d..417db7c 100644 --- a/src/source/opencode2/capabilities.js +++ b/src/source/opencode2/capabilities.js @@ -6,8 +6,30 @@ function frozenRecord(value) { return Object.freeze(value) } +function commandName(value) { + if (!value || typeof value !== "object") return undefined + for (const key of ["name", "id", "command"]) { + const candidate = value[key] + if (typeof candidate === "string" && candidate.trim()) return candidate.trim() + } + return undefined +} + +export const OPENCODE_LOOP_V2_COMMAND_SOURCE = "command-files" + +export const OPENCODE_LOOP_V2_REQUIRED_COMMANDS = Object.freeze([ + "loop", + "loop-now", + "loop-pause", + "loop-resume", + "loop-stop", + "loop-status", + "loop-clear", + "loop-help", +]) + export const OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS = Object.freeze([ - "command.add", + "command.files", "session.events", "session.prompt", ]) @@ -31,17 +53,49 @@ export function inspectOpenCode2CommandDraft(draft) { get: hasFunction(draft, "get"), update: hasFunction(draft, "update"), remove: hasFunction(draft, "remove"), - add: hasFunction(draft, "add") || hasFunction(draft, "create"), + }) +} + +export function inspectOpenCode2CommandFiles(draft, requiredCommands = OPENCODE_LOOP_V2_REQUIRED_COMMANDS) { + const command = inspectOpenCode2CommandDraft(draft) + const required = [...requiredCommands].map((value) => String(value || "").trim()).filter(Boolean) + const available = new Set() + + if (command.list) { + try { + for (const item of draft.list() || []) { + const name = commandName(item) + if (name) available.add(name) + } + } catch {} + } + + if (command.get) { + for (const name of required) { + if (available.has(name)) continue + try { + if (draft.get(name)) available.add(name) + } catch {} + } + } + + const missing = required.filter((name) => !available.has(name)) + return frozenRecord({ + source: OPENCODE_LOOP_V2_COMMAND_SOURCE, + ready: missing.length === 0, + required: Object.freeze(required), + available: Object.freeze([...available].filter((name) => required.includes(name))), + missing: Object.freeze(missing), }) } export function openCode2LoopRuntimeStatus(ctx, commandDraft) { const context = inspectOpenCode2Context(ctx) const command = inspectOpenCode2CommandDraft(commandDraft) + const commandFiles = inspectOpenCode2CommandFiles(commandDraft) const blockers = [] - if (!context.commandTransform) blockers.push("command.transform") - if (!command.add) blockers.push("command.add") + if (!commandFiles.ready) blockers.push("command.files") if (!context.sessionEvents) blockers.push("session.events") if (!context.sessionPrompt) blockers.push("session.prompt") @@ -50,5 +104,6 @@ export function openCode2LoopRuntimeStatus(ctx, commandDraft) { blockers: Object.freeze(blockers), context, command, + commandFiles, }) } diff --git a/src/source/opencode2/experimental.js b/src/source/opencode2/experimental.js index 57c95dd..968e461 100644 --- a/src/source/opencode2/experimental.js +++ b/src/source/opencode2/experimental.js @@ -1,17 +1,19 @@ import { define } from "@opencode-ai/plugin/v2/promise" -import { inspectOpenCode2Context } from "./capabilities.js" +import { inspectOpenCode2CommandFiles, inspectOpenCode2Context } from "./capabilities.js" export const OPENCODE_LOOP_V2_PLUGIN_ID = "bybrawe.opencode-loop.v2.experimental" +export async function registerOpenCode2CommandProbe(ctx) { + if (!inspectOpenCode2Context(ctx).commandTransform) return undefined + return await ctx.command.transform((draft) => { + inspectOpenCode2CommandFiles(draft) + }) +} + export const OpenCodeLoopV2ExperimentalPlugin = define({ id: OPENCODE_LOOP_V2_PLUGIN_ID, async setup(ctx) { - const capabilities = inspectOpenCode2Context(ctx) - if (!capabilities.commandTransform) { - throw new Error("OpenCode 2 command.transform capability is unavailable") - } - - await ctx.command.transform(() => {}) + await registerOpenCode2CommandProbe(ctx) }, })