Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/opencode2-real-adapter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,10 @@ jobs:
if [ ! -f "$OPENCODE_LOOP_V2_MARKER" ]; then
opencode2 api get /api/plugin -H "x-opencode-directory: $OPENCODE_LOOP_V2_PROJECT" > "$RUNNER_TEMP/opencode2-real-plugin.json" 2>&1 || true
fi
for attempt in $(seq 1 50); do
for attempt in $(seq 1 80); do
if [ -f "$OPENCODE_LOOP_V2_MARKER" ]; then
cat "$OPENCODE_LOOP_V2_MARKER"
node -e 'const fs=require("node:fs"); const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if(value.id!=="bybrawe.opencode-loop.v2.experimental" || value.activated!==true || value.commandTransform!==true || value.eventSubscribe!==true || value.sessionPrompt!==true || value.sessionCommand!==true) process.exit(1)' "$OPENCODE_LOOP_V2_MARKER"
node -e 'const fs=require("node:fs"); const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if(value.id!=="bybrawe.opencode-loop.v2.experimental" || value.activated!==true || value.commandTransform!==true || value.eventSubscribe!==true || value.sessionPrompt!==true || value.sessionCommand!==true || value.commandFieldProbe?.matched!==true) process.exit(1)' "$OPENCODE_LOOP_V2_MARKER"
exit 0
fi
sleep 0.2
Expand Down
32 changes: 32 additions & 0 deletions scripts/fixtures/opencode2-real-adapter-probe.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { writeFile } from "node:fs/promises"

const COMMAND_SENTINEL = "__opencode_loop_missing_command_probe__"

export default {
id: "bybrawe.opencode-loop.v2.real-adapter-probe",
async setup(ctx) {
Expand All @@ -19,6 +21,35 @@ export default {
.map((key) => [key, typeof ctx.session[key]]),
)

let commandFieldProbe = { matched: false, error: "session command probe did not run" }
if (typeof ctx?.session?.create === "function" && typeof ctx?.session?.command === "function") {
try {
const created = await ctx.session.create()
const sessionID = String(created?.id || created?.data?.id || created?.sessionID || "")
if (!sessionID) throw new Error("session.create returned no session id")
try {
await ctx.session.command({ sessionID, command: COMMAND_SENTINEL })
commandFieldProbe = {
matched: false,
sessionID,
error: "missing command unexpectedly succeeded",
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
commandFieldProbe = {
matched: message.includes(`Command not found: ${COMMAND_SENTINEL}`),
sessionID,
error: message,
}
}
} catch (error) {
commandFieldProbe = {
matched: false,
error: error instanceof Error ? error.message : String(error),
}
}
}

await writeFile(marker, JSON.stringify({
id: plugin.id,
activated: true,
Expand All @@ -28,6 +59,7 @@ export default {
sessionCommand: typeof ctx?.session?.command === "function",
sessionShell: typeof ctx?.session?.shell === "function",
sessionMethods,
commandFieldProbe,
toolTransform: typeof ctx?.tool?.transform === "function",
}, null, 2), "utf8")

Expand Down
12 changes: 5 additions & 7 deletions scripts/v2-command-adapter-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,10 @@ async function waitFor(predicate, description, timeoutMs = 2_000) {
}
const adapter = createOpenCode2RuntimeAdapter(ctx)
await adapter.start()
const result = await adapter.command({ sessionID: "ses_adapter", id: "/review", arguments: "--quick" })
const result = await adapter.command({ sessionID: "ses_adapter", command: "/review", arguments: "--quick" })
assert.deepEqual(result, { accepted: true })
assert.equal(commands.length, 1)
assert.equal(commands[0].sessionID, "ses_adapter")
assert.equal(commands[0].id, "review")
assert.equal(commands[0].arguments, "--quick")
assert.deepEqual(commands, [{ sessionID: "ses_adapter", command: "review", arguments: "--quick" }])
assert.equal(Object.prototype.hasOwnProperty.call(commands[0], "id"), false)
await adapter.dispose()
}

Expand All @@ -70,7 +68,7 @@ async function waitFor(predicate, description, timeoutMs = 2_000) {
session: { prompt: async () => ({ accepted: true }) },
})
await adapter.start()
await assert.rejects(adapter.command({ sessionID: "ses_adapter", id: "review" }), /session\.command capability is unavailable/)
await assert.rejects(adapter.command({ sessionID: "ses_adapter", command: "review" }), /session\.command capability is unavailable/)
await adapter.dispose()
}

Expand Down Expand Up @@ -105,7 +103,7 @@ async function waitFor(predicate, description, timeoutMs = 2_000) {
})
events.push({ directory, payload: { type: "session.idle", properties: { sessionID } } })
await waitFor(() => commands.length === 1, "V2 command dispatch")
assert.deepEqual(commands[0], { sessionID, id: "review", arguments: "--quick" })
assert.deepEqual(commands[0], { sessionID, command: "review", arguments: "--quick" })
} finally {
await cleanup?.()
await rm(directory, { recursive: true, force: true })
Expand Down
18 changes: 15 additions & 3 deletions scripts/v2-command-runtime-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,17 @@ try {
const first = await runtime.onEvent({ kind: "session", action: "idle", sessionID, directory })
assert.equal(first.dispatched, true)
assert.equal(first.kind, "command")
assert.deepEqual(first.request, { sessionID, id: "review", arguments: "--quick" })
assert.deepEqual(commands, [{ sessionID, id: "review", arguments: "--quick" }])
assert.deepEqual(first.request, { sessionID, command: "review", arguments: "--quick" })
assert.deepEqual(commands, [{ sessionID, command: "review", arguments: "--quick" }])
assert.equal(Object.prototype.hasOwnProperty.call(commands[0], "id"), false, "OpenCode 2 id is a msg_ message ID, never the command name")
assert.equal(prompts.length, 0)
let state = await readState()
assert.equal(state.jobs[0].runCount, 1)
assert.equal(state.jobs[0].enabled, true)

const second = await runtime.onEvent({ kind: "session", action: "idle", sessionID, directory })
assert.equal(second.dispatched, true)
assert.deepEqual(commands[1], { sessionID, id: "review", arguments: "--quick" })
assert.deepEqual(commands[1], { sessionID, command: "review", arguments: "--quick" })
state = await readState()
assert.equal(state.jobs[0].runCount, 2)
assert.equal(state.jobs[0].enabled, false)
Expand All @@ -52,6 +53,17 @@ try {
assert.equal(third.dispatched, false)
assert.equal(commands.length, 2)

const compact = await runtime.onEvent({
kind: "command",
action: "executed",
name: "loop",
sessionID,
directory,
arguments: '0s --command "/compact"',
})
assert.equal(compact.accepted, false)
assert.ok(compact.blockers.includes("kind"), "shared actionKind keeps /compact on the dedicated compact path")

const shell = await runtime.onEvent({
kind: "command",
action: "executed",
Expand Down
16 changes: 9 additions & 7 deletions scripts/v2-host-contract-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const host = createOpenCode2HostContract({
})

await assert.rejects(host.prompt({ sessionID: "ses_a", text: "early" }), /not started/)
await assert.rejects(host.command({ sessionID: "ses_a", id: "review" }), /not started/)
await assert.rejects(host.command({ sessionID: "ses_a", command: "review" }), /not started/)
assert.equal(await host.start(), true)
assert.equal(await host.start(), false)
assert.equal(typeof listener, "function")
Expand All @@ -43,25 +43,27 @@ assert.equal(prompts[0].sessionID, "ses_a")
assert.equal(prompts[0].text, "continue")
assert.equal(prompts[0].runtime, runtime)

const commandResult = await host.command({ sessionID: "ses_a", id: "/review", arguments: "--quick" })
const commandResult = await host.command({ sessionID: "ses_a", command: "/review", arguments: "--quick" })
assert.deepEqual(commandResult, { accepted: true, kind: "command" })
assert.equal(commands.length, 1)
assert.equal(commands[0].sessionID, "ses_a")
assert.equal(commands[0].id, "review")
assert.equal(commands[0].command, "review")
assert.equal(Object.prototype.hasOwnProperty.call(commands[0], "id"), false)
assert.equal(commands[0].arguments, "--quick")
assert.equal(commands[0].runtime, runtime)

await assert.rejects(host.prompt({ sessionID: "", text: "missing" }), /session ID/)
await assert.rejects(host.prompt({ sessionID: "ses_a", text: " " }), /requires text/)
await assert.rejects(host.command({ sessionID: "", id: "review" }), /session ID/)
await assert.rejects(host.command({ sessionID: "ses_a", id: "" }), /requires an ID/)
await assert.rejects(host.command({ sessionID: "", command: "review" }), /session ID/)
await assert.rejects(host.command({ sessionID: "ses_a", command: "" }), /requires a command name/)
await assert.rejects(host.command({ sessionID: "ses_a", id: "review" }), /requires a command name/)

const promptOnly = createOpenCode2HostContract({
subscribe: async (_callback) => ({ unsubscribe: async () => {} }),
sendPrompt: async () => ({ accepted: true }),
})
await promptOnly.start()
await assert.rejects(promptOnly.command({ sessionID: "ses_a", id: "review" }), /session\.command capability is unavailable/)
await assert.rejects(promptOnly.command({ sessionID: "ses_a", command: "review" }), /session\.command capability is unavailable/)
await promptOnly.dispose()

await listener({
Expand All @@ -72,7 +74,7 @@ assert.equal(host.isHostDisposed(), true)
assert.deepEqual(host.runtimeManager.entries(), [])
assert.ok(events.some(({ event }) => event.kind === "server" && event.action === "disposed"))
await assert.rejects(host.prompt({ sessionID: "ses_a", text: "late" }), /unavailable/)
await assert.rejects(host.command({ sessionID: "ses_a", id: "review" }), /unavailable/)
await assert.rejects(host.command({ sessionID: "ses_a", command: "review" }), /unavailable/)

assert.equal(await host.dispose(), true)
assert.equal(await host.dispose(), false)
Expand Down
6 changes: 3 additions & 3 deletions src/source/opencode2/host-contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ function normalizePrompt(input) {

function normalizeCommand(input) {
const sessionID = String(input?.sessionID || "").trim()
const id = String(input?.id || "").trim().replace(/^\/+/, "")
const command = String(input?.command || "").trim().replace(/^\/+/, "")
if (!sessionID) throw new TypeError("command requires a session ID")
if (!id) throw new TypeError("command requires an ID")
if (!command) throw new TypeError("command requires a command name")
return Object.freeze({
sessionID,
id,
command,
arguments: input?.arguments === undefined ? undefined : String(input.arguments),
agent: input?.agent,
model: input?.model,
Expand Down
4 changes: 2 additions & 2 deletions src/source/opencode2/prompt-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,10 @@ export function createOpenCode2PromptRuntime(options = {}) {
async function dispatchJob(scope, job) {
const kind = actionKind(job?.action, job)
if (kind === "command") {
const [id, argumentsText] = commandParts(job.action)
const [command, argumentsText] = commandParts(job.action)
const request = {
sessionID: scope.sessionID,
id,
command,
arguments: argumentsText || undefined,
}
await options.command(request)
Expand Down
2 changes: 1 addition & 1 deletion src/source/opencode2/runtime-adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function promptRequest(request) {
function commandRequest(request) {
const value = {
sessionID: request.sessionID,
id: request.id,
command: request.command,
}
if (request.arguments !== undefined) value.arguments = request.arguments
if (request.agent !== undefined) value.agent = request.agent
Expand Down