From 5c929ce73a115a5bf1db25a6745af09bfe43c9de Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Sat, 17 Oct 2026 08:47:42 -0700 Subject: [PATCH] feat(T187): keep an OpenCode resume note when the turn is busy A running turn or a host with no session note API keeps the bounded resume note and reports one fallback. A fresh session and a quiet tool result still show the note. Mail is not read or acked. --- CHANGELOG.md | 2 + dist/setup.js | 22 +++- src/setup.ts | 22 +++- test/opencode-resume.test.ts | 191 +++++++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 6 deletions(-) create mode 100644 test/opencode-resume.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a06257f..8f83176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- **OpenCode resume notes wait when the turn is busy or the session API is missing (T187).** A fresh `session.created`, and the first tool result of a quiet turn, still show the bounded `[mbx]` resume note: who the session is, what it has not caught up on, and `mbx_inbox`. A turn that is already running keeps that note and adds one fallback line (`mbx_whoami`, then `mbx_inbox`); the note is admitted when the turn ends. A host with no `synthetic` or `prompt` does the same, and a later tool result can still show the note. This path does not read or ack mail. The plugin template changed: run `agentmbx setup --only opencode` and reopen OpenCode tabs to load it. + ## 0.5.47 (2026-10-16) - **Service-hosted OpenCode sessions stay wakeable after MCP eviction (T688).** OpenCode stops an idle session's MCP about hourly, which releases its lease. T659 kept standalone sessions wakeable through their plugin's poll; a session hosted by the shared `opencode serve --service` is now a wake target too while that service process (same pid and birth) lives, and the wake posts to the service with `resume: true` and a verified receipt. A lease held by another session of the agent still blocks it. docs/OPENCODE.md recommends plain `opencode` (one service per machine, every project) for long-lived agents. diff --git a/dist/setup.js b/dist/setup.js index fbc506f..664417a 100644 --- a/dist/setup.js +++ b/dist/setup.js @@ -1583,18 +1583,34 @@ const postToolSteady = (sid: string): boolean => { * admitted through the hosting serve's session API. A failure never surfaces. The same call starts * the standalone wake poll once the session id is known. */ const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record, pending: Map, sending: Set, - place?: (text: string) => boolean): Promise => { + resumeTold: Set, place?: (text: string) => boolean): Promise => { try { const sid = typeof payload.session_id === "string" ? payload.session_id : null; + // Captured before this hook marks or ends the turn, so the first tool of a turn can still admit + // and a turn that is already running cannot. + const since = sid ? turnSince.get(sid) : undefined; + const turnOpen = since !== undefined && Date.now() - since < TURN_STALE_MS; if (sid) watchWake(ctx, sid); if (sid && event === "post-tool") markTurn(sid); if (sid && event === "stop") endTurn(sid); if (sid && (event === "post-tool" || event === "stop")) await releaseStartNote(ctx, sid, event, place); // T187: retry an unadmitted notice on an existing hook, without bypassing the zero-spawn fast path. + // A turn already running, or a host with no session note API, keeps the note and says so once. + const sayResumeFallback = (session: string, text: string) => { + const key = session + "\\0" + text; + if (resumeTold.has(key)) return; + resumeTold.add(key); + const line = "[mbx] Resume note was not admitted. Call mbx_whoami, then mbx_inbox. Mail is unchanged."; + if (!(place && place(line))) logWake("resume", session, "not admitted; mail unchanged"); + }; const deliver = async () => { if (!sid || sending.has(sid)) return; const text = pending.get(sid); if (!text) return; + if (turnOpen && event !== "stop") { sayResumeFallback(sid, text); return; } + if (event === "post-tool" && place && place(text)) { if (pending.get(sid) === text) pending.delete(sid); return; } + const api = typeof ctx?.session?.synthetic === "function" || typeof ctx?.session?.prompt === "function"; + if (!api) { sayResumeFallback(sid, text); return; } sending.add(sid); try { if (await inject(ctx, sid, "resume-entry", text) && pending.get(sid) === text) pending.delete(sid); } finally { sending.delete(sid); } @@ -1622,8 +1638,8 @@ const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record< }; const runFor = (ctx: NoteCtx, bin: string[], cwd: string | undefined) => { - const pending = new Map(), sending = new Set(); - return (event: string, payload: Record, place?: (text: string) => boolean) => call(ctx, bin, event, { cwd, ...payload }, pending, sending, place); + const pending = new Map(), sending = new Set(), resumeTold = new Set(); + return (event: string, payload: Record, place?: (text: string) => boolean) => call(ctx, bin, event, { cwd, ...payload }, pending, sending, resumeTold, place); }; const sidOf = (v: unknown): string | undefined => { diff --git a/src/setup.ts b/src/setup.ts index 0d7c8c5..c62efcd 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1416,18 +1416,34 @@ const postToolSteady = (sid: string): boolean => { * admitted through the hosting serve's session API. A failure never surfaces. The same call starts * the standalone wake poll once the session id is known. */ const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record, pending: Map, sending: Set, - place?: (text: string) => boolean): Promise => { + resumeTold: Set, place?: (text: string) => boolean): Promise => { try { const sid = typeof payload.session_id === "string" ? payload.session_id : null; + // Captured before this hook marks or ends the turn, so the first tool of a turn can still admit + // and a turn that is already running cannot. + const since = sid ? turnSince.get(sid) : undefined; + const turnOpen = since !== undefined && Date.now() - since < TURN_STALE_MS; if (sid) watchWake(ctx, sid); if (sid && event === "post-tool") markTurn(sid); if (sid && event === "stop") endTurn(sid); if (sid && (event === "post-tool" || event === "stop")) await releaseStartNote(ctx, sid, event, place); // T187: retry an unadmitted notice on an existing hook, without bypassing the zero-spawn fast path. + // A turn already running, or a host with no session note API, keeps the note and says so once. + const sayResumeFallback = (session: string, text: string) => { + const key = session + "\\0" + text; + if (resumeTold.has(key)) return; + resumeTold.add(key); + const line = "[mbx] Resume note was not admitted. Call mbx_whoami, then mbx_inbox. Mail is unchanged."; + if (!(place && place(line))) logWake("resume", session, "not admitted; mail unchanged"); + }; const deliver = async () => { if (!sid || sending.has(sid)) return; const text = pending.get(sid); if (!text) return; + if (turnOpen && event !== "stop") { sayResumeFallback(sid, text); return; } + if (event === "post-tool" && place && place(text)) { if (pending.get(sid) === text) pending.delete(sid); return; } + const api = typeof ctx?.session?.synthetic === "function" || typeof ctx?.session?.prompt === "function"; + if (!api) { sayResumeFallback(sid, text); return; } sending.add(sid); try { if (await inject(ctx, sid, "resume-entry", text) && pending.get(sid) === text) pending.delete(sid); } finally { sending.delete(sid); } @@ -1455,8 +1471,8 @@ const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record< }; const runFor = (ctx: NoteCtx, bin: string[], cwd: string | undefined) => { - const pending = new Map(), sending = new Set(); - return (event: string, payload: Record, place?: (text: string) => boolean) => call(ctx, bin, event, { cwd, ...payload }, pending, sending, place); + const pending = new Map(), sending = new Set(), resumeTold = new Set(); + return (event: string, payload: Record, place?: (text: string) => boolean) => call(ctx, bin, event, { cwd, ...payload }, pending, sending, resumeTold, place); }; const sidOf = (v: unknown): string | undefined => { diff --git a/test/opencode-resume.test.ts b/test/opencode-resume.test.ts new file mode 100644 index 0000000..95cbb2b --- /dev/null +++ b/test/opencode-resume.test.ts @@ -0,0 +1,191 @@ +// T187: the OpenCode plugin's resume-entry path. Fresh and quiet-resume hooks show the bounded +// notice. A busy turn, a missing binding, a stale connector, or a host with no session note API +// keeps the note and says so, without a wake and without a fake receipt. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { opencodePluginSource } from "../src/setup.ts"; +import { version } from "../src/version.ts"; + +const CMD = ["/opt/bin/agentmbx"]; +const NOTICE = [ + "[mbx] You are worker@alpha (role: builder).", + "[mbx] 1 unread mbx message(s): call mbx_inbox.", + "[mbx] Not caught up: call mbx_catchup.", + "[mbx] 1 open thread(s) with unacked mail.", +].join("\n"); +const FALLBACK = "[mbx] Resume note was not admitted. Call mbx_whoami, then mbx_inbox. Mail is unchanged."; + +interface Note { sessionID: string; id: string; text: string; delivery: string; resume: boolean } +interface Spawned { args: string[]; input: Record } +type Hooks = { + event: (event: unknown) => Promise; + "tool.execute.after": (input: unknown, output?: { output?: string }) => Promise; +}; + +async function loadPlugin() { + (globalThis as { __mbxWakePort?: string | null }).__mbxWakePort = null; + const dir = mkdtempSync(join(tmpdir(), "mbx-oc-resume-")); + const file = join(dir, "agentmbx.ts"); + writeFileSync(file, opencodePluginSource(CMD, version())); + try { + const mod = await import(file) as { AgentMBXHooks: (ctx: unknown) => Promise }; + return { mod, dir }; + } catch (e) { rmSync(dir, { recursive: true, force: true }); throw e; } +} + +function stubSpawn(calls: Spawned[], script: (c: Spawned) => string) { + (globalThis as { __mbxSpawn?: unknown }).__mbxSpawn = (bin: string[], args: string[], input: string, cb: (out: string) => void) => { + const c = { args, input: JSON.parse(input) as Record }; + calls.push(c); + cb(script(c)); + }; + return () => { delete (globalThis as { __mbxSpawn?: unknown }).__mbxSpawn; }; +} + +function stubSession(notes: Note[]) { + return { synthetic: async (body: Note) => { notes.push(body); return body; } }; +} + +const envelope = (sid: string, text: string) => JSON.stringify({ mbx_resume: { session_id: sid, text } }); + +test("T187: a fresh session admits the bounded notice once, to that session only", async () => { + const { mod, dir } = await loadPlugin(), calls: Spawned[] = [], notes: Note[] = []; + const restore = stubSpawn(calls, c => { + if (c.args[1] === "session-start" && c.input.session_id === "ses_fresh") return envelope("ses_fresh", NOTICE); + if (c.input.session_id === "ses_other") return envelope("ses_fresh", NOTICE); + return ""; + }); + try { + const hooks = await mod.AgentMBXHooks({ directory: "/work", session: stubSession(notes) }); + await hooks.event({ event: { type: "session.created", properties: { info: { id: "ses_fresh" } } } }); + assert.equal(notes.length, 1); + assert.equal(notes[0].sessionID, "ses_fresh"); + assert.equal(notes[0].text, NOTICE); + assert.equal(notes[0].resume, false); + assert.equal(notes[0].delivery, "queue"); + assert.ok(NOTICE.length <= 1200 && NOTICE.split("\n").length <= 8); + assert.match(NOTICE, /mbx_inbox/); + assert.match(NOTICE, /mbx_catchup/); + assert.doesNotMatch(NOTICE, /PRIVATE/); + await hooks["tool.execute.after"]({ sessionID: "ses_fresh" }); + await hooks.event({ event: { type: "session.created", properties: { info: { id: "ses_other" } } } }); + assert.equal(notes.length, 1, "an empty follow-up and a mismatched envelope do not admit again"); + assert.ok(notes.every(n => n.resume === false)); + } finally { restore(); rmSync(dir, { recursive: true, force: true }); } +}); + +test("T187: a quiet resume places the same notice in that tool result and does not queue a copy", async () => { + const { mod, dir } = await loadPlugin(), notes: Note[] = []; + let once = false; + const restore = stubSpawn([], c => { + if (!once && c.args[1] === "post-tool" && c.input.session_id === "ses_resume") { once = true; return envelope("ses_resume", NOTICE); } + return ""; + }); + const output = { output: "tool ok" }; + try { + const hooks = await mod.AgentMBXHooks({ directory: "/work", session: stubSession(notes) }); + await hooks["tool.execute.after"]({ sessionID: "ses_resume" }, output); + assert.equal(notes.length, 0, "the tool result is the admission; nothing is queued behind the turn"); + assert.match(output.output, /You are worker@alpha/); + assert.match(output.output, /mbx_inbox/); + assert.match(output.output, /mbx_catchup/); + assert.doesNotMatch(output.output, /Resume note was not admitted/); + const again = { output: "next tool" }; + await hooks["tool.execute.after"]({ sessionID: "ses_resume" }, again); + assert.equal(again.output, "next tool", "the same turn does not repeat the notice"); + assert.equal(notes.length, 0); + } finally { restore(); rmSync(dir, { recursive: true, force: true }); } +}); + +test("T187: a busy turn keeps the resume note, reports the fallback once, and admits it when the turn ends", async () => { + const { mod, dir } = await loadPlugin(), notes: Note[] = []; + let tools = 0; + const restore = stubSpawn([], c => { + if (c.args[1] !== "post-tool") return ""; + return ++tools === 1 ? "" : envelope("ses_busy", NOTICE); + }); + try { + const hooks = await mod.AgentMBXHooks({ directory: "/work", session: stubSession(notes) }); + await hooks["tool.execute.after"]({ sessionID: "ses_busy" }, { output: "first" }); + const mid = { output: "mid" }; + await hooks["tool.execute.after"]({ sessionID: "ses_busy" }, mid); + assert.equal(notes.length, 0, "a running turn does not admit the note"); + assert.ok(mid.output.includes(FALLBACK)); + assert.doesNotMatch(mid.output, /mbx_catchup/, "the full note stays pending"); + const third = { output: "third" }; + await hooks["tool.execute.after"]({ sessionID: "ses_busy" }, third); + assert.equal(third.output, "third", "the fallback is once per pending note"); + await hooks.event({ event: { type: "session.idle", properties: { info: { id: "ses_busy" } } } }); + assert.equal(notes.length, 1); + assert.equal(notes[0].sessionID, "ses_busy"); + assert.equal(notes[0].text, NOTICE); + assert.equal(notes[0].resume, false, "ending the turn admits the note; it does not start a wake"); + } finally { restore(); rmSync(dir, { recursive: true, force: true }); } +}); + +test("T187: a missing binding holds the register fallback and never admits a resume note", async () => { + const { mod, dir } = await loadPlugin(), notes: Note[] = []; + const unbound = "[mbx] This session has no mailbox identity yet.\n[mbx] Register or claim, then call mbx_inbox."; + let started = false; + const restore = stubSpawn([], c => { + if (c.args[1] === "session-start" && !started) { started = true; return unbound; } + return ""; + }); + const output = { output: "tool ok" }; + try { + const hooks = await mod.AgentMBXHooks({ directory: "/work", session: stubSession(notes) }); + await hooks.event({ event: { type: "session.created", properties: { info: { id: "ses_unbound" } } } }); + assert.equal(notes.length, 0, "an unbound start note is held, not admitted"); + await hooks["tool.execute.after"]({ sessionID: "ses_unbound" }, output); + assert.match(output.output, /no mailbox identity yet/); + assert.match(output.output, /mbx_inbox/); + assert.doesNotMatch(output.output, /Resume note was not admitted/); + assert.equal(notes.length, 0); + await hooks.event({ event: { type: "session.idle", properties: { info: { id: "ses_unbound" } } } }); + assert.equal(notes.length, 0, "idle does not turn the fallback into a resume admission"); + } finally { restore(); rmSync(dir, { recursive: true, force: true }); } +}); + +test("T187: a stale connector shows the disconnect line and does not admit a resume note", async () => { + const { mod, dir } = await loadPlugin(), notes: Note[] = []; + const line = "[mbx] mbx disconnected: reconnect it with /mcps in OpenCode (select mbx); this session no longer holds stale-worker@alpha."; + const restore = stubSpawn([], c => c.args[1] === "post-tool" ? line : ""); + const output = { output: "tool ok" }; + try { + const hooks = await mod.AgentMBXHooks({ directory: "/work", session: stubSession(notes) }); + await hooks["tool.execute.after"]({ sessionID: "ses_stale" }, output); + assert.match(output.output, /mbx disconnected/); + assert.match(output.output, /stale-worker@alpha/); + assert.equal(notes.length, 0); + await hooks.event({ event: { type: "session.idle", properties: { info: { id: "ses_stale" } } } }); + assert.equal(notes.length, 0, "the disconnect line is not kept as a resume note"); + assert.ok(notes.every(n => n.resume === false)); + } finally { restore(); rmSync(dir, { recursive: true, force: true }); } +}); + +test("T187: a host with no session note API keeps the note and a later tool result can show it", async () => { + const { mod, dir } = await loadPlugin(); + const home = process.env.MBX_HOME; + process.env.MBX_HOME = dir; + const restore = stubSpawn([], c => c.args[1] === "session-start" ? envelope("ses_noapi", NOTICE) : ""); + const output = { output: "tool ok" }; + try { + const hooks = await mod.AgentMBXHooks({ directory: "/work", session: {} }); + await hooks.event({ event: { type: "session.created", properties: { info: { id: "ses_noapi" } } } }); + const log = readFileSync(join(dir, "logs", "opencode-plugin.log"), "utf8"); + assert.match(log, /resume session=ses_noapi/); + assert.match(log, /not admitted; mail unchanged/); + await hooks["tool.execute.after"]({ sessionID: "ses_noapi" }, output); + assert.match(output.output, /You are worker@alpha/); + assert.match(output.output, /mbx_inbox/); + assert.doesNotMatch(output.output, /Resume note was not admitted/, "the tool result shows the note itself"); + await hooks.event({ event: { type: "session.idle", properties: { info: { id: "ses_noapi" } } } }); + assert.equal(output.output.includes(NOTICE), true); + } finally { + if (home === undefined) delete process.env.MBX_HOME; else process.env.MBX_HOME = home; + restore(); rmSync(dir, { recursive: true, force: true }); + } +});