Skip to content

Commit 16ca47c

Browse files
Merge pull request #19 from jkrandom-sudo/fix/issue-18-run-mode-fallback
fix: resolve issue #18 — deterministic /loop parsing in opencode run mode
2 parents dd516e2 + a228c7d commit 16ca47c

2 files changed

Lines changed: 248 additions & 20 deletions

File tree

src/index.ts

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
*/
2525

2626
import type { Plugin, Hooks, PluginModule } from "@opencode-ai/plugin"
27+
import type { Part } from "@opencode-ai/sdk"
2728
import { LoopStore } from "./store.js"
2829
import { InstanceLock } from "./instance-lock.js"
2930
import { Scheduler } from "./scheduler.js"
@@ -140,6 +141,34 @@ export const LoopPlugin: Plugin = async (ctx) => {
140141
}
141142
}, config.tickerIntervalMs)
142143

144+
// Deterministic /loop handling shared by the command.execute.before hook
145+
// (TUI path) and the chat.message fallback below (opencode run path), so
146+
// every mode applies the exact same parsing and input guards.
147+
const runLoopCommand = async (
148+
args: string,
149+
sessionID: string | null | undefined,
150+
parts: Part[]
151+
): Promise<void> => {
152+
setActive(sessionID)
153+
let result
154+
try {
155+
result = await scheduler.handleUserCommand(args, ctx.directory, sessionID)
156+
} catch (error) {
157+
result = { message: `❌ /loop failed: ${errorMessage(error)}` }
158+
}
159+
if (result.message.startsWith("❌") && !result.modelPrompt) {
160+
result.modelPrompt = buildLoopFailedPrompt(result.message)
161+
} else if (!result.modelPrompt) {
162+
result.modelPrompt = buildLoopResultPrompt(result.message)
163+
}
164+
consumeLoopCommand(parts, result.modelPrompt)
165+
await logger(result.message.startsWith("❌") ? "error" : "info", result.message, {
166+
sessionID,
167+
action: commandAction(args),
168+
argumentLength: args.length,
169+
})
170+
}
171+
143172
const hooks: Hooks = {
144173
event: async ({ event }) => {
145174
const e = event as { type?: string; properties?: any; sessionID?: string }
@@ -172,31 +201,28 @@ export const LoopPlugin: Plugin = async (ctx) => {
172201
}
173202
},
174203

175-
"chat.message": async (input) => {
204+
"chat.message": async (input, output) => {
176205
setActive(input.sessionID)
206+
// Run-mode fallback (issue #18): `opencode run` — headless and `-i` —
207+
// sends "/loop ..." as a plain user message via session.prompt and
208+
// never emits command.execute.before, so the raw $ARGUMENTS would go
209+
// straight to the model and every deterministic guard would be
210+
// bypassed. Intercept the literal command text here and run the same
211+
// deterministic parser. Parts already consumed by
212+
// command.execute.before are synthetic/ignored and skipped, so the
213+
// TUI path is never handled twice.
214+
for (const part of output?.parts ?? []) {
215+
if (part.type !== "text" || part.synthetic || part.ignored) continue
216+
const match = /^\/loop(?:\s+([\s\S]*))?$/.exec(part.text.trim())
217+
if (!match) return
218+
await runLoopCommand(match[1] ?? "", input.sessionID, output.parts)
219+
return
220+
}
177221
},
178222

179223
"command.execute.before": async (input, output) => {
180224
if (input.command !== "loop") return
181-
setActive(input.sessionID)
182-
const args = input.arguments || ""
183-
let result
184-
try {
185-
result = await scheduler.handleUserCommand(args, ctx.directory, input.sessionID)
186-
} catch (error) {
187-
result = { message: `❌ /loop failed: ${errorMessage(error)}` }
188-
}
189-
if (result.message.startsWith("❌") && !result.modelPrompt) {
190-
result.modelPrompt = buildLoopFailedPrompt(result.message)
191-
} else if (!result.modelPrompt) {
192-
result.modelPrompt = buildLoopResultPrompt(result.message)
193-
}
194-
consumeLoopCommand(output.parts, result.modelPrompt)
195-
await logger(result.message.startsWith("❌") ? "error" : "info", result.message, {
196-
sessionID: input.sessionID,
197-
action: commandAction(args),
198-
argumentLength: args.length,
199-
})
225+
await runLoopCommand(input.arguments || "", input.sessionID, output.parts)
200226
},
201227
}
202228

tests/run-mode.test.mjs

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/**
2+
* Run-mode fallback tests (issue #18).
3+
*
4+
* `opencode run "/loop ..."` (headless and -i) sends the literal command text
5+
* as a plain user message via session.prompt, so command.execute.before is
6+
* never emitted. The plugin intercepts the literal `/loop ...` text in the
7+
* chat.message hook and runs the same deterministic parser, so the documented
8+
* guards (missing prompt, cron rejection, unknown flags, canonical help)
9+
* apply in every mode.
10+
*/
11+
12+
import { test } from "node:test"
13+
import assert from "node:assert/strict"
14+
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs"
15+
import { join } from "node:path"
16+
import { tmpdir } from "node:os"
17+
18+
const pluginModule = await import("../dist/index.js")
19+
20+
async function makeHooks(dir) {
21+
return pluginModule.LoopPlugin({
22+
client: {},
23+
project: { id: "test" },
24+
directory: dir,
25+
worktree: dir,
26+
$: {},
27+
serverUrl: new URL("http://localhost:3000"),
28+
experimental_workspace: { register: () => {} },
29+
})
30+
}
31+
32+
function textMessage(text, sessionID = "sRun") {
33+
return {
34+
message: { id: "m1", sessionID, role: "user", time: { created: Date.now() } },
35+
parts: [{ id: "p1", sessionID, messageID: "m1", type: "text", text }],
36+
}
37+
}
38+
39+
function tasksFile(dir) {
40+
return join(dir, ".opencode/cache/loop/tasks.json")
41+
}
42+
43+
function taskCount(dir) {
44+
return existsSync(tasksFile(dir))
45+
? JSON.parse(readFileSync(tasksFile(dir), "utf-8")).tasks.length
46+
: 0
47+
}
48+
49+
test("run mode: '/loop 5m' returns missing-prompt error and creates nothing", async () => {
50+
const dir = mkdtempSync(join(tmpdir(), "loop-run-"))
51+
try {
52+
const hooks = await makeHooks(dir)
53+
const out = textMessage("/loop 5m")
54+
await hooks["chat.message"]({ sessionID: "sRun" }, out)
55+
assert.equal(out.parts[0].synthetic, true, "command text consumed")
56+
assert.ok(
57+
out.parts[0].text.includes('Missing prompt after interval "5m"'),
58+
`expected missing-prompt failure, got: ${out.parts[0].text.slice(0, 120)}`
59+
)
60+
assert.equal(taskCount(dir), 0, "no task created")
61+
await hooks.dispose()
62+
} finally {
63+
rmSync(dir, { recursive: true, force: true })
64+
}
65+
})
66+
67+
test("run mode: cron expression is rejected deterministically", async () => {
68+
const dir = mkdtempSync(join(tmpdir(), "loop-run-"))
69+
try {
70+
const hooks = await makeHooks(dir)
71+
const out = textMessage("/loop */5 * * * * check something")
72+
await hooks["chat.message"]({ sessionID: "sRun" }, out)
73+
assert.ok(
74+
out.parts[0].text.includes("Cron expressions are not supported"),
75+
`expected cron rejection, got: ${out.parts[0].text.slice(0, 120)}`
76+
)
77+
assert.equal(taskCount(dir), 0, "no task created")
78+
await hooks.dispose()
79+
} finally {
80+
rmSync(dir, { recursive: true, force: true })
81+
}
82+
})
83+
84+
test("run mode: unknown flag is rejected deterministically", async () => {
85+
const dir = mkdtempSync(join(tmpdir(), "loop-run-"))
86+
try {
87+
const hooks = await makeHooks(dir)
88+
const out = textMessage("/loop --bogus do something")
89+
await hooks["chat.message"]({ sessionID: "sRun" }, out)
90+
assert.ok(
91+
out.parts[0].text.includes('Unknown flag "--bogus"'),
92+
`expected unknown-flag rejection, got: ${out.parts[0].text.slice(0, 120)}`
93+
)
94+
assert.equal(taskCount(dir), 0, "no task created")
95+
await hooks.dispose()
96+
} finally {
97+
rmSync(dir, { recursive: true, force: true })
98+
}
99+
})
100+
101+
test("run mode: '/loop help' yields the canonical LOOP_HELP text", async () => {
102+
const dir = mkdtempSync(join(tmpdir(), "loop-run-"))
103+
try {
104+
const hooks = await makeHooks(dir)
105+
const out = textMessage("/loop help")
106+
await hooks["chat.message"]({ sessionID: "sRun" }, out)
107+
assert.equal(out.parts[0].synthetic, true)
108+
assert.ok(out.parts[0].text.includes("run prompts on a schedule"))
109+
assert.ok(out.parts[0].text.includes("/loop cancel"))
110+
await hooks.dispose()
111+
} finally {
112+
rmSync(dir, { recursive: true, force: true })
113+
}
114+
})
115+
116+
test("run mode: valid fixed interval creates a fixed task bound to the session", async () => {
117+
const dir = mkdtempSync(join(tmpdir(), "loop-run-"))
118+
try {
119+
const hooks = await makeHooks(dir)
120+
const out = textMessage("/loop 1m ping the server")
121+
await hooks["chat.message"]({ sessionID: "sRun" }, out)
122+
assert.equal(taskCount(dir), 1, "one task created")
123+
const task = JSON.parse(readFileSync(tasksFile(dir), "utf-8")).tasks[0]
124+
assert.equal(task.mode, "fixed")
125+
assert.equal(task.intervalMs, 60_000)
126+
assert.equal(task.prompt, "ping the server")
127+
assert.equal(task.sessionID, "sRun")
128+
assert.equal(out.parts[0].synthetic, true, "confirmation replaces command text")
129+
await hooks.dispose()
130+
} finally {
131+
rmSync(dir, { recursive: true, force: true })
132+
}
133+
})
134+
135+
test("run mode: bare '/loop' starts maintenance mode", async () => {
136+
const dir = mkdtempSync(join(tmpdir(), "loop-run-"))
137+
try {
138+
const hooks = await makeHooks(dir)
139+
const out = textMessage("/loop")
140+
await hooks["chat.message"]({ sessionID: "sRun" }, out)
141+
assert.equal(taskCount(dir), 1)
142+
const task = JSON.parse(readFileSync(tasksFile(dir), "utf-8")).tasks[0]
143+
assert.equal(task.mode, "maintenance")
144+
assert.equal(out.parts[0].synthetic, true)
145+
await hooks.dispose()
146+
} finally {
147+
rmSync(dir, { recursive: true, force: true })
148+
}
149+
})
150+
151+
test("run mode: regular messages and mere mentions of /loop are untouched", async () => {
152+
const dir = mkdtempSync(join(tmpdir(), "loop-run-"))
153+
try {
154+
const hooks = await makeHooks(dir)
155+
for (const text of [
156+
"hello there",
157+
"please explain what /loop 5m does",
158+
"/loops are great",
159+
"/loopx not a command",
160+
]) {
161+
const out = textMessage(text)
162+
await hooks["chat.message"]({ sessionID: "sRun" }, out)
163+
assert.equal(out.parts[0].text, text, `message untouched: ${text}`)
164+
assert.notEqual(out.parts[0].synthetic, true)
165+
}
166+
assert.equal(taskCount(dir), 0)
167+
await hooks.dispose()
168+
} finally {
169+
rmSync(dir, { recursive: true, force: true })
170+
}
171+
})
172+
173+
test("no double handling: command.execute.before consumption is skipped by chat.message", async () => {
174+
const dir = mkdtempSync(join(tmpdir(), "loop-run-"))
175+
try {
176+
const hooks = await makeHooks(dir)
177+
// TUI path: command.execute.before consumes the parts first...
178+
const output = { parts: [{ id: "p1", sessionID: "sT", messageID: "m1", type: "text", text: "1m ping" }] }
179+
await hooks["command.execute.before"](
180+
{ command: "loop", arguments: "1m ping", sessionID: "sT" },
181+
output
182+
)
183+
assert.equal(taskCount(dir), 1)
184+
// ...then chat.message fires for the same message and must not re-handle
185+
await hooks["chat.message"]({ sessionID: "sT" }, output)
186+
assert.equal(taskCount(dir), 1, "still exactly one task")
187+
await hooks.dispose()
188+
} finally {
189+
rmSync(dir, { recursive: true, force: true })
190+
}
191+
})
192+
193+
test("chat.message without output argument still tracks the active session", async () => {
194+
const dir = mkdtempSync(join(tmpdir(), "loop-run-"))
195+
try {
196+
const hooks = await makeHooks(dir)
197+
await hooks["chat.message"]({ sessionID: "sB" })
198+
await hooks.dispose()
199+
} finally {
200+
rmSync(dir, { recursive: true, force: true })
201+
}
202+
})

0 commit comments

Comments
 (0)