From 20dae1760c8128e16b5c1dc03d734b33cc19c907 Mon Sep 17 00:00:00 2001 From: dnth Date: Tue, 20 Oct 2026 10:00:02 +0800 Subject: [PATCH 1/4] fix(omp): keep wake-only no-change turns out of the captain's chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OMP 18.10.1 re-prompts a final message whose only content is signed thinking (features.unexpectedStopDetection defaults to "mechanical"; turn-recovery.ts #handleUnexpectedAssistantStop), so a wake turn with nothing for the captain could not end silently and firstmate wrote a filler line such as "Captain, standing by." to stop the retry loop. OMP has no per-turn silent-stop API, so the primary extension now: - lets a wake-only turn end with the marker [fm:silent], which is text and therefore never retried in mechanical mode; - rewrites that marker, or a whole-message no-change line, to "…" via the assistant_message hook; OMP's chat view renders dot-only text as nothing; - only does so in an episode a watcher wake opened with no captain input (user message or user-attributed custom message) and no captain-facing branch merge; every other turn keeps its text. The routine branch-merge prefix moves into the shared dispatch lib so the primary can tell routine notes from captain-facing merges. Replay on the 19-20 Oct primary transcript hides 146 of 434 final text messages, all pure no-change lines. --- .omp/extensions/fm-branch-supervision-omp.ts | 2 +- .omp/extensions/fm-primary-omp.ts | 99 ++++++++++++++--- .omp/extensions/lib/fm-branch-dispatch.ts | 5 + docs/supervision-protocols/omp.md | 2 + docs/verification/runtime-backends.md | 19 ++++ tests/fm-omp-primary.test.sh | 110 +++++++++++++++++++ tests/fm-omp-silent-wake-sdk.mjs | 107 ++++++++++++++++++ 7 files changed, 325 insertions(+), 19 deletions(-) create mode 100644 tests/fm-omp-silent-wake-sdk.mjs diff --git a/.omp/extensions/fm-branch-supervision-omp.ts b/.omp/extensions/fm-branch-supervision-omp.ts index 3a8cf0bb159..e04703de576 100644 --- a/.omp/extensions/fm-branch-supervision-omp.ts +++ b/.omp/extensions/fm-branch-supervision-omp.ts @@ -93,6 +93,7 @@ import { captureTaskStatusSnapshot, BRANCH_ELIGIBLE_STATUS_FILE, FM_BRANCH_DISPATCH_EVENT, + MERGE_NOTE_BOAT, releaseEligibleRowsSnapshot, rollbackEligibleRowsOwnerActivation, scopeForUnreadWake, @@ -147,7 +148,6 @@ const BRANCH_TOOL_NAMES = ["read", "bash", "fm_branch_report"] as const; const branchCacheKey = `fm-branch-${createHash("sha256").update(fmHome).digest("hex").slice(0, 24)}`; const MIRROR_MESSAGE_CAP = 4000; -const MERGE_NOTE_BOAT = "⛵"; // One provider failure falls back immediately but leaves room for a transient // outage to recover on the next wake. A second consecutive provider failure // latches the branch off. While latched, main keeps every wake except one diff --git a/.omp/extensions/fm-primary-omp.ts b/.omp/extensions/fm-primary-omp.ts index 5c7ffcaf208..f578a8a2dac 100644 --- a/.omp/extensions/fm-primary-omp.ts +++ b/.omp/extensions/fm-primary-omp.ts @@ -16,6 +16,7 @@ import { createPrimaryWatchCore, ompNativeProcessIdentity, type WakeDeliveryEvid import { createBranchDispatchOffer, FM_BRANCH_DISPATCH_EVENT, + MERGE_NOTE_BOAT, scopeForUnreadWake, } from "./lib/fm-branch-dispatch.ts"; import { installTaskInboxDoorbell } from "./lib/fm-task-inbox-doorbell.ts"; @@ -214,10 +215,90 @@ function runGuard(event: SessionStopEvent): Promise { }); } +// The text OMP carries in a user message_start: sendMessage wraps a string +// as one text part, so the joined text parts equal the sent content. +function userMessageText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const part of content) { + if ( + typeof part === "object" && part !== null && + (part as { type?: unknown }).type === "text" && + typeof (part as { text?: unknown }).text === "string" + ) { + parts.push((part as { text: string }).text); + } + } + return parts.join("\n"); +} + +// Silent wake turns (docs/supervision-protocols/omp.md). OMP re-prompts a final +// message whose only content is signed thinking ("You said you would continue +// ..."; features.unexpectedStopDetection, default "mechanical"), so a wake turn +// cannot end without text. An episode is every message since the last final +// assistant message that carried text. In an episode a watcher wake opened and +// no captain input or captain-facing branch merge joined, a final message that is +// exactly SILENT_WAKE_MARKER or a whole-message no-change line is rewritten to +// "…": still text, so OMP does not retry it, while OMP's chat view renders +// dot-only text as nothing. Every other episode keeps its text untouched. +export const SILENT_WAKE_MARKER = "[fm:silent]"; +const SILENT_WAKE_TEXT = "\u2026"; +const NO_CHANGE_SENTENCE = "(?:captain,\\s+)?(?:aye|shipshape|standing by" + + "|no (?:change|changes|news)(?: since my last (?:message|update))?" + + "|no new (?:results|updates)(?: to report)?" + + "|no (?:action|reply) (?:needed|required)" + + "|nothing (?:new|changed|has changed)(?: (?:for you|needs you|to report|since my last (?:message|update)))?" + + "|nothing needs you" + + "|nothing was queued, so there(?:'|\u2019)s nothing new for you)" + + "(?: right now)?(?:,\\s+captain)?[.!]?"; +const NO_CHANGE_LINE = new RegExp(`^${NO_CHANGE_SENTENCE}(?:\\s+${NO_CHANGE_SENTENCE})?$`, "i"); + +export function installSilentWakeTurns(omp: Pick): void { + let wakeOpened = false; + let captainJoined = false; + omp.on("message_start", (event) => { + const message = event.message; + const attribution = "attribution" in message ? message.attribution : undefined; + const customType = "customType" in message ? message.customType : undefined; + if (message.role === "user" || attribution === "user") { + captainJoined = true; + } else if (customType === "firstmate-watcher-wake") { + wakeOpened = true; + } else if (customType === "fm-branch-merge" && "content" in message && + !userMessageText(message.content).startsWith(MERGE_NOTE_BOAT)) { + captainJoined = true; + } + }); + omp.on("assistant_message", (event) => { + const { content, stopReason } = event.message; + if (!wakeOpened || captainJoined || stopReason !== "stop") return undefined; + if (content.some((block) => block.type === "toolCall")) return undefined; + const spoken = content.flatMap((block, index) => block.type === "text" && block.text.trim() ? [index] : []); + if (spoken.length !== 1) return undefined; + const target = content[spoken[0]]; + const text = target.type === "text" ? target.text.trim() : ""; + if (text !== SILENT_WAKE_MARKER && !NO_CHANGE_LINE.test(text)) return undefined; + return { + content: content.map((block, index) => + index === spoken[0] && block.type === "text" ? { ...block, text: SILENT_WAKE_TEXT } : block + ), + }; + }); + omp.on("message_end", (event) => { + const message = event.message; + if (message.role !== "assistant" || message.stopReason !== "stop") return; + if (!message.content.some((block) => block.type === "text" && block.text.trim())) return; + wakeOpened = false; + captainJoined = false; + }); +} + export default function (omp: ExtensionAPI) { if (!primaryIntegrationApplies()) return; publishNativeProcessIdentity(); const taskInboxDoorbell = installTaskInboxDoorbell(omp, { observeTurns: false, deferUntilTurnStart: true }); + installSilentWakeTurns(omp); let pendingStartupNudge = ""; // Hidden next-turn delivery with triggerTurn. OMP schedules an internal @@ -246,24 +327,6 @@ export default function (omp: ExtensionAPI) { ); }; - // The text OMP carries in a user message_start: sendMessage wraps a string - // as one text part, so the joined text parts equal the sent content. - function userMessageText(content: unknown): string { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return ""; - const parts: string[] = []; - for (const part of content) { - if ( - typeof part === "object" && part !== null && - (part as { type?: unknown }).type === "text" && - typeof (part as { text?: unknown }).text === "string" - ) { - parts.push((part as { text: string }).text); - } - } - return parts.join("\n"); - } - // Durable rows are the whole persistence: only fm-wake-drain acknowledgement // removes them, so an interruption leaves the next session event able to // re-notify. At most one notification is sent per session event, and the diff --git a/.omp/extensions/lib/fm-branch-dispatch.ts b/.omp/extensions/lib/fm-branch-dispatch.ts index 915e9b7cfda..3fff79110ad 100644 --- a/.omp/extensions/lib/fm-branch-dispatch.ts +++ b/.omp/extensions/lib/fm-branch-dispatch.ts @@ -20,6 +20,11 @@ import { runCommandAsync } from "./fm-async-exec.ts"; export const FM_BRANCH_DISPATCH_EVENT = "fm-branch-supervision:dispatch"; +// Prefix of every routine fm-branch-merge note. A merge without it is a +// captain-facing delivery whose follow-up main turn is itself the +// captain-visible outcome (docs/omp-supervision-branch.md). +export const MERGE_NOTE_BOAT = "⛵"; + export type UnreadWakeScopeStatus = "safe" | "empty" | "unsafe"; export interface UnreadWakeScope { diff --git a/docs/supervision-protocols/omp.md b/docs/supervision-protocols/omp.md index 70b010d6e81..c29e8be5fe0 100644 --- a/docs/supervision-protocols/omp.md +++ b/docs/supervision-protocols/omp.md @@ -22,6 +22,8 @@ When this session owns supervision and away mode is not active: 12. Missing, failed, or unhealthy cycle only: drain queued notifications, inspect the failure, call `fm_watch_arm_omp`, and restart with the explicit `-e` fallback if the integration is missing or stale. 13. Never use shell `&` for watcher supervision. 14. While the supervision branch is active, MAIN must claim the reserved backlog lease before every `tasks-axi` or direct `data/backlog.md` mutation, then release it after the mutation: `bin/fm-lease.sh claim backlog`, mutate, `bin/fm-lease.sh release backlog`. +15. A turn opened only by a watcher wake that has nothing captain-facing ends with exactly `[fm:silent]` as its whole final message, never a no-change line; the primary extension hides it, and OMP does not re-prompt a final message that has text. + Never use it in place of a real outcome, decision, PR-ready report, or failure; on a turn where the captain wrote, the marker stays visible. For a persistent secondmate, the watcher in that secondmate home touches `state/.last-watcher-beat` at the start of every cycle. The parent watcher treats a fresh, non-future secondmate-home beacon as positive liveness evidence when the secondmate is neither paused nor captain-held and its pane is idle between child polls, reading remote beacon age through a short bounded call with a forced-kill grace on the configured host route. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 96bfdef5da0..65fb3c86cb9 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -739,6 +739,25 @@ The phase fires a watcher close while a captain turn runs `sleep 20`; in the dia The draft-preservation failure reproduces identically at base `2ec2c819` on OMP 18.10.1: the composer capture reads the typed draft with one extra trailing character, so that assertion needs its own fix. With only the two composer-draft assertions removed, the same run also passed `ok - OMP omp/18.10.1 primary E2E proved an idle session runs the watcher wake turn itself with an intact editable draft`. +#### OMP silent wake turns + +OMP 18.10.1 re-prompts a final assistant message whose only content is signed thinking with no text: `features.unexpectedStopDetection` defaults to `mechanical`, and `src/session/turn-recovery.ts` retries such a stop up to 3 times with "You said you would continue ... Give the final answer now.". +It never retries a final message that has visible text in that mode, and its chat view renders text made only of dots or ellipses as nothing (`pi-tui` `canonicalizeMessage`). +The primary extension's silent wake-turn rewrite (owner: `installSilentWakeTurns` in `.omp/extensions/fm-primary-omp.ts`; agent rule: [OMP supervision protocol](../supervision-protocols/omp.md)) relies on both behaviors. +On 2026-10-20 the opt-in real-SDK regression ran under Bun 1.4.3 against `@oh-my-pi/pi-coding-agent` 18.10.1, the same version as the installed `omp` binary, installed into a scratch directory with `bun add @oh-my-pi/pi-coding-agent@18.10.1`: + +```sh +FM_OMP_PACKAGE_DIR=/node_modules/@oh-my-pi/pi-coding-agent bash tests/fm-omp-primary.test.sh +``` + +```text +ok - OMP primary hides only marker or no-change finals of wake-only episodes +ok - real OMP: silent wake turns draw no retry and render nothing, captain turns and real outcomes stay visible +``` + +The real-SDK phase first asserts that a thinking-only wake turn is re-prompted, so it fails rather than passing vacuously once OMP stops retrying such stops. +Without `FM_OMP_PACKAGE_DIR` it prints a skip line and only the portable episode-rule phase runs. + ## Herdr The compatibility floor is protocol 14. diff --git a/tests/fm-omp-primary.test.sh b/tests/fm-omp-primary.test.sh index 868c89420de..86b22e23f24 100755 --- a/tests/fm-omp-primary.test.sh +++ b/tests/fm-omp-primary.test.sh @@ -2924,6 +2924,114 @@ test_fm_guard_warns_on_stale_omp_inflight_wake() { pass "fm-guard warns only when an OMP in-flight wake outlives a turn boundary" } +# Silent wake turns: which episodes may lose their final text, driven through +# the extension's own handlers in OMP event order. +test_silent_wake_turn_episode_rules() { + local out status + out=$(EXTENSION="$ROOT/.omp/extensions/fm-primary-omp.ts" node --input-type=module 2>&1 <<'JS' +import assert from "node:assert/strict"; +import { pathToFileURL } from "node:url"; +const { installSilentWakeTurns, SILENT_WAKE_MARKER } = await import(pathToFileURL(process.env.EXTENSION).href); +const HIDDEN = "\u2026"; + +function session() { + const handlers = new Map(); + installSilentWakeTurns({ on(name, handler) { handlers.set(name, [...(handlers.get(name) ?? []), handler]); } }); + const emit = (name, message) => (handlers.get(name) ?? []).map((handler) => handler({ type: name, message })); + return { + start: (message) => emit("message_start", message), + // An assistant message as OMP finalizes it: rewrite hook, then message_end. + answer(content, stopReason = "stop") { + let message = { role: "assistant", stopReason, content }; + for (const result of emit("assistant_message", structuredClone(message))) { + if (result?.content) message = { ...message, content: result.content }; + } + emit("message_end", message); + return message.content.filter((block) => block.type === "text").map((block) => block.text).join(""); + }, + }; +} +const thinking = { type: "thinking", thinking: "Nothing new.", thinkingSignature: "sig" }; +const say = (text) => [thinking, { type: "text", text }]; +const wake = { role: "custom", customType: "firstmate-watcher-wake", content: "WAKE: heartbeat", display: false, attribution: "agent" }; +const captain = (text) => ({ role: "user", content: [{ type: "text", text }], attribution: "user" }); +const merge = (content) => ({ role: "custom", customType: "fm-branch-merge", content, display: false, attribution: "agent" }); + +function finalText(label, inputs, text) { + const s = session(); + for (const input of inputs) { + if (input === "thinking-only") s.answer([thinking]); + else if (input === "tool-step") s.answer([thinking, { type: "toolCall", id: "t1", name: "bash", arguments: {} }], "toolUse"); + else s.start(input); + } + return s.answer(say(text)); +} + +const hidden = [ + ["marker after a wake", [wake, "tool-step"], SILENT_WAKE_MARKER], + ["no-change line after a wake", [wake], "Captain, standing by."], + ["two no-change sentences", [wake], "Captain, aye. Nothing new since my last update."], + ["routine branch note beside a wake", [wake, merge("\u26f5 aceh: still loading")], "Nothing new needs you."], + ["retry after a thinking-only stop", [wake, "thinking-only"], "Captain, nothing has changed."], +]; +for (const [label, inputs, text] of hidden) { + assert.equal(finalText(label, inputs, text), HIDDEN, `${label} was not hidden`); +} + +const outcome = "Captain, the fix is ready for review: https://github.com/example/repo/pull/7"; +const kept = [ + ["real outcome after a wake", [wake], outcome], + ["no-change opener with a fact", [wake], "Captain, no change. The Mac is waiting on your #532 call."], + ["captain turn", [captain("status?")], "Captain, standing by."], + ["marker on a captain turn", [captain("status?")], SILENT_WAKE_MARKER], + ["captain steer during a wake", [wake, "tool-step", captain("also check CI")], "Nothing new needs you."], + ["captain skill during a wake", [wake, { role: "custom", customType: "skill-prompt", content: "skill", attribution: "user" }], SILENT_WAKE_MARKER], + ["captain-facing branch merge", [wake, merge("kcc-audit: The audit is complete.")], "Captain, standing by."], + ["agent input without a wake", [{ role: "custom", customType: "async-result", content: "job done", attribution: "agent" }], "Captain, standing by."], + ["captain turn retried after a thinking-only stop", [captain("status?"), "thinking-only"], SILENT_WAKE_MARKER], +]; +for (const [label, inputs, text] of kept) { + assert.equal(finalText(label, inputs, text), text, `${label} lost its text`); +} + +// A text answer closes the episode, so captain input does not leak into the +// next wake-only episode and a wake does not leak into the next captain turn. +const s = session(); +s.start(captain("hello")); +assert.equal(s.answer(say("Captain, here it is.")), "Captain, here it is."); +s.start(wake); +assert.equal(s.answer(say(SILENT_WAKE_MARKER)), HIDDEN, "captain input leaked past its answered episode"); +s.start(captain("anything?")); +assert.equal(s.answer(say("Captain, standing by.")), "Captain, standing by.", "a wake leaked into the next captain turn"); +console.log("silent-wake-rules-ok"); +JS + ) + status=$? + assert_contains "$out" silent-wake-rules-ok "silent wake-turn episode rules failed: $out" + expect_code 0 "$status" "silent wake-turn episode rules" + pass "OMP primary hides only marker or no-change finals of wake-only episodes" +} + +# Opt-in real-SDK check of OMP's own recovery and chat rendering; the provider +# is local and uses no credentials. +test_silent_wake_turn_with_real_sdk() { + local package_dir fixture + package_dir=${FM_OMP_PACKAGE_DIR:-} + if [ -z "$package_dir" ]; then + echo "skip: set FM_OMP_PACKAGE_DIR for the real-SDK silent wake-turn regression" + return 0 + fi + command -v bun >/dev/null 2>&1 || fail "bun is required for the real-SDK silent wake-turn regression" + [ -f "$package_dir/package.json" ] || fail "OMP SDK package is missing: $package_dir" + fixture="$TMP_ROOT/silent-wake-sdk" + mkdir -p "$fixture" + cp "$ROOT/tests/fm-omp-silent-wake-sdk.mjs" "$fixture/driver.mjs" + ln -s "$(cd "$package_dir/../.." && pwd)" "$fixture/node_modules" + env -u FM_TASK_ID FM_HOME="$fixture" PI_CODING_AGENT_DIR="$fixture/agent" FM_OMP_PACKAGE_DIR="$package_dir" \ + EXTENSION_PATH="$ROOT/.omp/extensions/fm-primary-omp.ts" \ + bun "$fixture/driver.mjs" || fail "real-SDK silent wake-turn regression failed" +} + test_resolve_path_uses_node_when_readlink_f_is_unavailable test_exact_bun_omp_primary_identity test_standalone_omp_primary_identity @@ -2952,3 +3060,5 @@ test_native_omp_unconsumed_inflight_wake_forces_turn_after_bound test_native_omp_delivered_handoff_does_not_suppress_queue_notification test_native_omp_mid_turn_wake_reaches_idle_main test_fm_guard_warns_on_stale_omp_inflight_wake +test_silent_wake_turn_episode_rules +test_silent_wake_turn_with_real_sdk diff --git a/tests/fm-omp-silent-wake-sdk.mjs b/tests/fm-omp-silent-wake-sdk.mjs new file mode 100644 index 00000000000..4e58858af8b --- /dev/null +++ b/tests/fm-omp-silent-wake-sdk.mjs @@ -0,0 +1,107 @@ +// Real OMP turn recovery and chat rendering for silent wake turns, with a +// disposable local model endpoint. Launched by test_silent_wake_turn_with_real_sdk. +import assert from "node:assert/strict"; +import { createAgentSession, SessionManager } from "@oh-my-pi/pi-coding-agent"; +const packageDir = process.env.FM_OMP_PACKAGE_DIR; +const { initializeExtensions } = await import(`${packageDir}/src/modes/runtime-init.ts`); +const { AssistantMessageComponent } = await import(`${packageDir}/../pi-tui/src/chat/assistant-message.ts`); +const { installSilentWakeTurns, SILENT_WAKE_MARKER } = await import(process.env.EXTENSION_PATH); + +const RETRY_PROMPTS = ["You said you would continue", "Stopped without actionable output"]; +const requests = []; +const replies = []; +const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + async fetch(req) { + const body = await req.json(); + // A recovery retry appends its prompt as the newest message of the request. + requests.push(JSON.stringify(body.messages.at(-1))); + const reply = replies.shift() ?? { text: "unscripted provider call" }; + const chunk = (delta, finish_reason = null) => `data: ${JSON.stringify({ id: "silent", object: "chat.completion.chunk", created: 1, model: "probe", choices: [{ index: 0, delta, finish_reason }] })}\n\n`; + let stream = ""; + // reasoning_content parses to a thinking block whose signature is the field + // name: the signed thinking-only shape OMP's unexpected-stop path retries. + if (reply.reasoning) stream += chunk({ role: "assistant", reasoning_content: reply.reasoning }); + if (reply.text) stream += chunk({ role: "assistant", content: reply.text }); + return new Response(`${stream}${chunk({}, "stop")}data: [DONE]\n\n`, { headers: { "Content-Type": "text/event-stream" } }); + }, +}); +const provider = (pi) => pi.registerProvider("silent-probe", { + baseUrl: `http://127.0.0.1:${server.port}/v1`, apiKey: "disposable", api: "openai-completions", + models: [{ id: "probe", name: "probe", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 1000, compat: { supportsDeveloperRole: false } }], +}); +const home = process.env.FM_HOME; +const { session } = await createAgentSession({ + cwd: home, agentDir: `${home}/agent`, sessionManager: SessionManager.inMemory(home), + disableExtensionDiscovery: true, extensions: [provider, (pi) => installSilentWakeTurns(pi)], + modelPattern: "silent-probe/probe", enableMCP: false, enableLsp: false, skills: [], contextFiles: [], + promptTemplates: [], toolNames: [], autoApprove: true, systemPrompt: "Main test session", +}); + +const visibleLines = (message) => + new AssistantMessageComponent(message, true).render(80) + .map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trim()) + .filter(Boolean); + +// Runs one turn and returns what the captain would see from it. +async function turn(label, start, scripted) { + const before = requests.length; + replies.push(...scripted); + await start(); + await session.waitForIdle(); + assert.equal(replies.length, 0, `${label}: not every scripted reply was requested`); + const sent = requests.slice(before); + const final = session.messages.at(-1); + assert.equal(final?.role, "assistant", `${label}: turn did not end on an assistant message`); + const text = final.content.filter((block) => block.type === "text").map((block) => block.text).join(""); + return { + calls: sent.length, + nudged: sent.some((request) => RETRY_PROMPTS.some((prompt) => request.includes(prompt))), + text, + lines: visibleLines(final), + }; +} +const wake = () => session.sendCustomMessage( + { customType: "firstmate-watcher-wake", content: "FIRSTMATE WATCHER WAKE: heartbeat", display: false, attribution: "agent" }, + { deliverAs: "nextTurn", triggerTurn: true }, +); +const captain = (text) => () => session.prompt(text); + +try { + await session.setModel(session.modelRegistry.find("silent-probe", "probe")); + await initializeExtensions(session, { reportSendError: (_source, error) => { throw error; }, reportRuntimeError: (error) => { throw error; } }); + + // Divergence proof: without text, this OMP retries a thinking-only wake turn. + const unmarked = await turn("thinking-only wake", wake, [ + { reasoning: "Nothing new for the captain." }, + { reasoning: "The retry asks again.", text: SILENT_WAKE_MARKER }, + ]); + assert.equal(unmarked.calls, 2, "a thinking-only wake turn was not re-prompted, so this OMP no longer needs the marker"); + assert(unmarked.nudged, "the second request did not carry OMP's unexpected-stop retry"); + assert.deepEqual(unmarked.lines, [], `the marker answer after a retry stayed visible: ${JSON.stringify(unmarked)}`); + + const silent = await turn("marked wake", wake, [{ reasoning: "Nothing new for the captain.", text: SILENT_WAKE_MARKER }]); + assert.deepEqual({ calls: silent.calls, nudged: silent.nudged }, { calls: 1, nudged: false }, "the silent marker drew a recovery retry"); + assert.equal(silent.text, "\u2026", "the silent marker was not rewritten"); + assert.deepEqual(silent.lines, [], `the silent wake turn left visible chat text: ${JSON.stringify(silent.lines)}`); + + const noChange = await turn("no-change wake", wake, [{ reasoning: "Routine.", text: "Captain, standing by." }]); + assert.deepEqual({ calls: noChange.calls, nudged: noChange.nudged, lines: noChange.lines }, { calls: 1, nudged: false, lines: [] }, + `a whole-message no-change line on a wake turn stayed visible: ${JSON.stringify(noChange)}`); + + const outcome = "Captain, the fix is ready for review: https://github.com/example/repo/pull/7"; + const real = await turn("wake with outcome", wake, [{ reasoning: "A PR is ready.", text: outcome }]); + assert.equal(real.text, outcome, "a real wake outcome was rewritten"); + assert.deepEqual(real.lines, [outcome], "a real wake outcome did not render"); + + const asked = await turn("captain no-change", captain("anything new?"), [{ text: "Captain, standing by." }]); + assert.deepEqual(asked.lines, ["Captain, standing by."], "a captain-initiated reply was hidden"); + + const misuse = await turn("captain marker", captain("status?"), [{ text: SILENT_WAKE_MARKER }]); + assert.deepEqual(misuse.lines, [SILENT_WAKE_MARKER], "the marker hid a captain-initiated turn"); + + console.log("ok - real OMP: silent wake turns draw no retry and render nothing, captain turns and real outcomes stay visible"); +} finally { + await session.dispose(); + server.stop(); +} From 81783ffd37a36aec1023a56b7bba19890fef3737 Mon Sep 17 00:00:00 2001 From: dnth Date: Tue, 20 Oct 2026 10:06:09 +0800 Subject: [PATCH 2/4] fix(omp): end silent wake turns with a marker that never renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The [fm:silent] marker streamed visibly before the assistant_message rewrite replaced it. A wake-only turn now ends with "…" directly: it is text, so OMP's mechanical unexpected-stop path never retries it, and OMP's chat view renders dot-only text as nothing in every streamed frame. The no-change backstop keeps rewriting whole-message no-change lines on wake-only episodes to the marker. In an episode the captain joined, a dot-only final text is emptied so OMP re-prompts for a visible answer, as it does for any silent stop. The real-SDK test now streams replies in delayed chunks and asserts the silent wake turn draws zero visible lines in every streamed frame. --- .omp/extensions/fm-primary-omp.ts | 33 +++++++++----- docs/supervision-protocols/omp.md | 4 +- docs/verification/runtime-backends.md | 11 +++-- tests/fm-omp-primary.test.sh | 23 +++++++--- tests/fm-omp-silent-wake-sdk.mjs | 66 +++++++++++++++++++-------- 5 files changed, 91 insertions(+), 46 deletions(-) diff --git a/.omp/extensions/fm-primary-omp.ts b/.omp/extensions/fm-primary-omp.ts index f578a8a2dac..acd33f2064c 100644 --- a/.omp/extensions/fm-primary-omp.ts +++ b/.omp/extensions/fm-primary-omp.ts @@ -236,14 +236,17 @@ function userMessageText(content: unknown): string { // Silent wake turns (docs/supervision-protocols/omp.md). OMP re-prompts a final // message whose only content is signed thinking ("You said you would continue // ..."; features.unexpectedStopDetection, default "mechanical"), so a wake turn -// cannot end without text. An episode is every message since the last final -// assistant message that carried text. In an episode a watcher wake opened and -// no captain input or captain-facing branch merge joined, a final message that is -// exactly SILENT_WAKE_MARKER or a whole-message no-change line is rewritten to -// "…": still text, so OMP does not retry it, while OMP's chat view renders -// dot-only text as nothing. Every other episode keeps its text untouched. -export const SILENT_WAKE_MARKER = "[fm:silent]"; -const SILENT_WAKE_TEXT = "\u2026"; +// cannot end without text. It never re-prompts a final message with text, and +// its chat view renders text made only of dots, ellipses, and blank space as +// nothing in every streamed frame, so SILENT_WAKE_MARKER ends a wake turn +// silently. An episode is every message since the last final assistant message +// that carried text. In an episode a watcher wake opened and no captain input or +// captain-facing branch merge joined, a whole-message no-change line is +// rewritten to the marker as a backstop; it streamed before the rewrite, so the +// marker stays the primary path. In an episode the captain joined, a dot-only +// final text is emptied so OMP asks for a visible answer as for any silent stop. +export const SILENT_WAKE_MARKER = "\u2026"; +const DOT_ONLY = /^[.\u2026 \t\r\n]+$/; const NO_CHANGE_SENTENCE = "(?:captain,\\s+)?(?:aye|shipshape|standing by" + "|no (?:change|changes|news)(?: since my last (?:message|update))?" + "|no new (?:results|updates)(?: to report)?" + @@ -272,16 +275,22 @@ export function installSilentWakeTurns(omp: Pick): void { }); omp.on("assistant_message", (event) => { const { content, stopReason } = event.message; - if (!wakeOpened || captainJoined || stopReason !== "stop") return undefined; - if (content.some((block) => block.type === "toolCall")) return undefined; + if (stopReason !== "stop" || content.some((block) => block.type === "toolCall")) return undefined; const spoken = content.flatMap((block, index) => block.type === "text" && block.text.trim() ? [index] : []); if (spoken.length !== 1) return undefined; const target = content[spoken[0]]; const text = target.type === "text" ? target.text.trim() : ""; - if (text !== SILENT_WAKE_MARKER && !NO_CHANGE_LINE.test(text)) return undefined; + let replacement: string; + if (captainJoined && DOT_ONLY.test(text)) { + replacement = ""; + } else if (wakeOpened && !captainJoined && NO_CHANGE_LINE.test(text)) { + replacement = SILENT_WAKE_MARKER; + } else { + return undefined; + } return { content: content.map((block, index) => - index === spoken[0] && block.type === "text" ? { ...block, text: SILENT_WAKE_TEXT } : block + index === spoken[0] && block.type === "text" ? { ...block, text: replacement } : block ), }; }); diff --git a/docs/supervision-protocols/omp.md b/docs/supervision-protocols/omp.md index c29e8be5fe0..47c3d0a02d3 100644 --- a/docs/supervision-protocols/omp.md +++ b/docs/supervision-protocols/omp.md @@ -22,8 +22,8 @@ When this session owns supervision and away mode is not active: 12. Missing, failed, or unhealthy cycle only: drain queued notifications, inspect the failure, call `fm_watch_arm_omp`, and restart with the explicit `-e` fallback if the integration is missing or stale. 13. Never use shell `&` for watcher supervision. 14. While the supervision branch is active, MAIN must claim the reserved backlog lease before every `tasks-axi` or direct `data/backlog.md` mutation, then release it after the mutation: `bin/fm-lease.sh claim backlog`, mutate, `bin/fm-lease.sh release backlog`. -15. A turn opened only by a watcher wake that has nothing captain-facing ends with exactly `[fm:silent]` as its whole final message, never a no-change line; the primary extension hides it, and OMP does not re-prompt a final message that has text. - Never use it in place of a real outcome, decision, PR-ready report, or failure; on a turn where the captain wrote, the marker stays visible. +15. A turn opened only by a watcher wake that has nothing captain-facing ends with exactly `…` (one ellipsis character) as its whole final message, never a no-change line; OMP shows nothing for it and does not re-prompt a final message that has text. + Never use it in place of a real outcome, decision, PR-ready report, or failure; on a turn where the captain wrote, the primary extension discards it so OMP asks for a visible answer. For a persistent secondmate, the watcher in that secondmate home touches `state/.last-watcher-beat` at the start of every cycle. The parent watcher treats a fresh, non-future secondmate-home beacon as positive liveness evidence when the secondmate is neither paused nor captain-held and its pane is idle between child polls, reading remote beacon age through a short bounded call with a forced-kill grace on the configured host route. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 65fb3c86cb9..be959b740c2 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -742,8 +742,8 @@ With only the two composer-draft assertions removed, the same run also passed `o #### OMP silent wake turns OMP 18.10.1 re-prompts a final assistant message whose only content is signed thinking with no text: `features.unexpectedStopDetection` defaults to `mechanical`, and `src/session/turn-recovery.ts` retries such a stop up to 3 times with "You said you would continue ... Give the final answer now.". -It never retries a final message that has visible text in that mode, and its chat view renders text made only of dots or ellipses as nothing (`pi-tui` `canonicalizeMessage`). -The primary extension's silent wake-turn rewrite (owner: `installSilentWakeTurns` in `.omp/extensions/fm-primary-omp.ts`; agent rule: [OMP supervision protocol](../supervision-protocols/omp.md)) relies on both behaviors. +It never retries a final message that has visible text in that mode, and its chat view renders text made only of dots, ellipses, or blank space as nothing in every streamed frame (`pi-tui` `canonicalizeMessage`). +The silent wake-turn marker `…` and the primary extension's backstop and captain guard (owner: `installSilentWakeTurns` in `.omp/extensions/fm-primary-omp.ts`; agent rule: [OMP supervision protocol](../supervision-protocols/omp.md)) rely on both behaviors. On 2026-10-20 the opt-in real-SDK regression ran under Bun 1.4.3 against `@oh-my-pi/pi-coding-agent` 18.10.1, the same version as the installed `omp` binary, installed into a scratch directory with `bun add @oh-my-pi/pi-coding-agent@18.10.1`: ```sh @@ -751,11 +751,12 @@ FM_OMP_PACKAGE_DIR=/node_modules/@oh-my-pi/pi-coding-agent bash tests/f ``` ```text -ok - OMP primary hides only marker or no-change finals of wake-only episodes -ok - real OMP: silent wake turns draw no retry and render nothing, captain turns and real outcomes stay visible +ok - OMP primary silences only marker or no-change finals of wake-only episodes and re-asks silent captain turns +ok - real OMP: silent wake turns draw no retry and render nothing in any streamed frame, captain turns and real outcomes stay visible ``` -The real-SDK phase first asserts that a thinking-only wake turn is re-prompted, so it fails rather than passing vacuously once OMP stops retrying such stops. +The real-SDK phase streams each reply in delayed chunks and renders every streamed frame through OMP's assistant message component. +It first asserts that a thinking-only wake turn is re-prompted, so it fails rather than passing vacuously once OMP stops retrying such stops. Without `FM_OMP_PACKAGE_DIR` it prints a skip line and only the portable episode-rule phase runs. ## Herdr diff --git a/tests/fm-omp-primary.test.sh b/tests/fm-omp-primary.test.sh index 86b22e23f24..20c101bedc1 100755 --- a/tests/fm-omp-primary.test.sh +++ b/tests/fm-omp-primary.test.sh @@ -2932,7 +2932,6 @@ test_silent_wake_turn_episode_rules() { import assert from "node:assert/strict"; import { pathToFileURL } from "node:url"; const { installSilentWakeTurns, SILENT_WAKE_MARKER } = await import(pathToFileURL(process.env.EXTENSION).href); -const HIDDEN = "\u2026"; function session() { const handlers = new Map(); @@ -2975,7 +2974,19 @@ const hidden = [ ["retry after a thinking-only stop", [wake, "thinking-only"], "Captain, nothing has changed."], ]; for (const [label, inputs, text] of hidden) { - assert.equal(finalText(label, inputs, text), HIDDEN, `${label} was not hidden`); + assert.equal(finalText(label, inputs, text), SILENT_WAKE_MARKER, `${label} was not hidden`); +} + +// A dot-only answer in an episode the captain joined is emptied, so OMP treats +// it as a silent stop and asks for a visible answer. +const reasked = [ + ["marker on a captain turn", [captain("status?")], SILENT_WAKE_MARKER], + ["ascii dots on a captain turn", [captain("status?")], "..."], + ["captain skill during a wake", [wake, { role: "custom", customType: "skill-prompt", content: "skill", attribution: "user" }], SILENT_WAKE_MARKER], + ["captain turn retried after a thinking-only stop", [captain("status?"), "thinking-only"], SILENT_WAKE_MARKER], +]; +for (const [label, inputs, text] of reasked) { + assert.equal(finalText(label, inputs, text), "", `${label} kept a silent answer`); } const outcome = "Captain, the fix is ready for review: https://github.com/example/repo/pull/7"; @@ -2983,12 +2994,10 @@ const kept = [ ["real outcome after a wake", [wake], outcome], ["no-change opener with a fact", [wake], "Captain, no change. The Mac is waiting on your #532 call."], ["captain turn", [captain("status?")], "Captain, standing by."], - ["marker on a captain turn", [captain("status?")], SILENT_WAKE_MARKER], + ["dot-only answer without captain or wake", [{ role: "custom", customType: "async-result", content: "job done", attribution: "agent" }], SILENT_WAKE_MARKER], ["captain steer during a wake", [wake, "tool-step", captain("also check CI")], "Nothing new needs you."], - ["captain skill during a wake", [wake, { role: "custom", customType: "skill-prompt", content: "skill", attribution: "user" }], SILENT_WAKE_MARKER], ["captain-facing branch merge", [wake, merge("kcc-audit: The audit is complete.")], "Captain, standing by."], ["agent input without a wake", [{ role: "custom", customType: "async-result", content: "job done", attribution: "agent" }], "Captain, standing by."], - ["captain turn retried after a thinking-only stop", [captain("status?"), "thinking-only"], SILENT_WAKE_MARKER], ]; for (const [label, inputs, text] of kept) { assert.equal(finalText(label, inputs, text), text, `${label} lost its text`); @@ -3000,7 +3009,7 @@ const s = session(); s.start(captain("hello")); assert.equal(s.answer(say("Captain, here it is.")), "Captain, here it is."); s.start(wake); -assert.equal(s.answer(say(SILENT_WAKE_MARKER)), HIDDEN, "captain input leaked past its answered episode"); +assert.equal(s.answer(say("Captain, standing by.")), SILENT_WAKE_MARKER, "captain input leaked past its answered episode"); s.start(captain("anything?")); assert.equal(s.answer(say("Captain, standing by.")), "Captain, standing by.", "a wake leaked into the next captain turn"); console.log("silent-wake-rules-ok"); @@ -3009,7 +3018,7 @@ JS status=$? assert_contains "$out" silent-wake-rules-ok "silent wake-turn episode rules failed: $out" expect_code 0 "$status" "silent wake-turn episode rules" - pass "OMP primary hides only marker or no-change finals of wake-only episodes" + pass "OMP primary silences only marker or no-change finals of wake-only episodes and re-asks silent captain turns" } # Opt-in real-SDK check of OMP's own recovery and chat rendering; the provider diff --git a/tests/fm-omp-silent-wake-sdk.mjs b/tests/fm-omp-silent-wake-sdk.mjs index 4e58858af8b..1c14fc2654f 100644 --- a/tests/fm-omp-silent-wake-sdk.mjs +++ b/tests/fm-omp-silent-wake-sdk.mjs @@ -1,5 +1,6 @@ // Real OMP turn recovery and chat rendering for silent wake turns, with a -// disposable local model endpoint. Launched by test_silent_wake_turn_with_real_sdk. +// disposable local model endpoint that streams each reply in delayed chunks. +// Launched by test_silent_wake_turn_with_real_sdk. import assert from "node:assert/strict"; import { createAgentSession, SessionManager } from "@oh-my-pi/pi-coding-agent"; const packageDir = process.env.FM_OMP_PACKAGE_DIR; @@ -10,6 +11,9 @@ const { installSilentWakeTurns, SILENT_WAKE_MARKER } = await import(process.env. const RETRY_PROMPTS = ["You said you would continue", "Stopped without actionable output"]; const requests = []; const replies = []; +const frames = []; +const encoder = new TextEncoder(); +const pieces = (text, size) => text ? Array.from({ length: Math.ceil(text.length / size) }, (_, i) => text.slice(i * size, (i + 1) * size)) : []; const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(req) { @@ -18,34 +22,49 @@ const server = Bun.serve({ requests.push(JSON.stringify(body.messages.at(-1))); const reply = replies.shift() ?? { text: "unscripted provider call" }; const chunk = (delta, finish_reason = null) => `data: ${JSON.stringify({ id: "silent", object: "chat.completion.chunk", created: 1, model: "probe", choices: [{ index: 0, delta, finish_reason }] })}\n\n`; - let stream = ""; // reasoning_content parses to a thinking block whose signature is the field // name: the signed thinking-only shape OMP's unexpected-stop path retries. - if (reply.reasoning) stream += chunk({ role: "assistant", reasoning_content: reply.reasoning }); - if (reply.text) stream += chunk({ role: "assistant", content: reply.text }); - return new Response(`${stream}${chunk({}, "stop")}data: [DONE]\n\n`, { headers: { "Content-Type": "text/event-stream" } }); + const events = [ + ...pieces(reply.reasoning, 8).map((part) => chunk({ role: "assistant", reasoning_content: part })), + ...pieces(reply.text, 3).map((part) => chunk({ role: "assistant", content: part })), + chunk({}, "stop"), + "data: [DONE]\n\n", + ]; + return new Response(new ReadableStream({ + async start(controller) { + for (const event of events) { + controller.enqueue(encoder.encode(event)); + await Bun.sleep(5); + } + controller.close(); + }, + }), { headers: { "Content-Type": "text/event-stream" } }); }, }); const provider = (pi) => pi.registerProvider("silent-probe", { baseUrl: `http://127.0.0.1:${server.port}/v1`, apiKey: "disposable", api: "openai-completions", models: [{ id: "probe", name: "probe", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 1000, compat: { supportsDeveloperRole: false } }], }); +const visibleLines = (message) => + new AssistantMessageComponent(message, true).render(80) + .map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trim()) + .filter(Boolean); +// Every streamed frame OMP's chat view would draw for an assistant message. +const frameRecorder = (pi) => pi.on("message_update", (event) => { + if (event.message.role === "assistant") frames.push(visibleLines(event.message)); +}); const home = process.env.FM_HOME; const { session } = await createAgentSession({ cwd: home, agentDir: `${home}/agent`, sessionManager: SessionManager.inMemory(home), - disableExtensionDiscovery: true, extensions: [provider, (pi) => installSilentWakeTurns(pi)], + disableExtensionDiscovery: true, extensions: [provider, frameRecorder, (pi) => installSilentWakeTurns(pi)], modelPattern: "silent-probe/probe", enableMCP: false, enableLsp: false, skills: [], contextFiles: [], promptTemplates: [], toolNames: [], autoApprove: true, systemPrompt: "Main test session", }); -const visibleLines = (message) => - new AssistantMessageComponent(message, true).render(80) - .map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trim()) - .filter(Boolean); - // Runs one turn and returns what the captain would see from it. async function turn(label, start, scripted) { const before = requests.length; + frames.length = 0; replies.push(...scripted); await start(); await session.waitForIdle(); @@ -53,12 +72,13 @@ async function turn(label, start, scripted) { const sent = requests.slice(before); const final = session.messages.at(-1); assert.equal(final?.role, "assistant", `${label}: turn did not end on an assistant message`); - const text = final.content.filter((block) => block.type === "text").map((block) => block.text).join(""); + assert(frames.length > 1, `${label}: no streamed frames were observed`); return { calls: sent.length, nudged: sent.some((request) => RETRY_PROMPTS.some((prompt) => request.includes(prompt))), - text, + text: final.content.filter((block) => block.type === "text").map((block) => block.text).join(""), lines: visibleLines(final), + streamed: frames.filter((lines) => lines.length > 0), }; } const wake = () => session.sendCustomMessage( @@ -78,13 +98,13 @@ try { ]); assert.equal(unmarked.calls, 2, "a thinking-only wake turn was not re-prompted, so this OMP no longer needs the marker"); assert(unmarked.nudged, "the second request did not carry OMP's unexpected-stop retry"); - assert.deepEqual(unmarked.lines, [], `the marker answer after a retry stayed visible: ${JSON.stringify(unmarked)}`); - const silent = await turn("marked wake", wake, [{ reasoning: "Nothing new for the captain.", text: SILENT_WAKE_MARKER }]); + const silent = await turn("marked wake", wake, [{ reasoning: "Nothing new for the captain this time.", text: SILENT_WAKE_MARKER }]); assert.deepEqual({ calls: silent.calls, nudged: silent.nudged }, { calls: 1, nudged: false }, "the silent marker drew a recovery retry"); - assert.equal(silent.text, "\u2026", "the silent marker was not rewritten"); - assert.deepEqual(silent.lines, [], `the silent wake turn left visible chat text: ${JSON.stringify(silent.lines)}`); + assert.deepEqual({ lines: silent.lines, streamed: silent.streamed }, { lines: [], streamed: [] }, + `the silent wake turn drew visible chat text: ${JSON.stringify(silent)}`); + // Backstop: the line streams before the rewrite, but the settled turn shows nothing. const noChange = await turn("no-change wake", wake, [{ reasoning: "Routine.", text: "Captain, standing by." }]); assert.deepEqual({ calls: noChange.calls, nudged: noChange.nudged, lines: noChange.lines }, { calls: 1, nudged: false, lines: [] }, `a whole-message no-change line on a wake turn stayed visible: ${JSON.stringify(noChange)}`); @@ -93,14 +113,20 @@ try { const real = await turn("wake with outcome", wake, [{ reasoning: "A PR is ready.", text: outcome }]); assert.equal(real.text, outcome, "a real wake outcome was rewritten"); assert.deepEqual(real.lines, [outcome], "a real wake outcome did not render"); + assert(real.streamed.some((lines) => lines.join(" ") !== outcome), "frame capture never saw a partial streamed reply"); const asked = await turn("captain no-change", captain("anything new?"), [{ text: "Captain, standing by." }]); assert.deepEqual(asked.lines, ["Captain, standing by."], "a captain-initiated reply was hidden"); - const misuse = await turn("captain marker", captain("status?"), [{ text: SILENT_WAKE_MARKER }]); - assert.deepEqual(misuse.lines, [SILENT_WAKE_MARKER], "the marker hid a captain-initiated turn"); + const answer = "Captain, CI on main is green."; + const misuse = await turn("captain marker", captain("status?"), [ + { reasoning: "Nothing to say.", text: SILENT_WAKE_MARKER }, + { reasoning: "Answer the captain.", text: answer }, + ]); + assert.deepEqual({ calls: misuse.calls, nudged: misuse.nudged, lines: misuse.lines }, { calls: 2, nudged: true, lines: [answer] }, + `the marker silenced a captain-initiated turn: ${JSON.stringify(misuse)}`); - console.log("ok - real OMP: silent wake turns draw no retry and render nothing, captain turns and real outcomes stay visible"); + console.log("ok - real OMP: silent wake turns draw no retry and render nothing in any streamed frame, captain turns and real outcomes stay visible"); } finally { await session.dispose(); server.stop(); From 5b28d81e50320180ddbd46d0f7d90742108c79d9 Mon Sep 17 00:00:00 2001 From: dnth Date: Tue, 20 Oct 2026 10:09:45 +0800 Subject: [PATCH 3/4] no-mistakes(review): Reset silent-wake episodes on session switches and aborts --- .omp/extensions/fm-primary-omp.ts | 15 +++++++++++---- tests/fm-omp-primary.test.sh | 32 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/.omp/extensions/fm-primary-omp.ts b/.omp/extensions/fm-primary-omp.ts index acd33f2064c..7ed68086a7c 100644 --- a/.omp/extensions/fm-primary-omp.ts +++ b/.omp/extensions/fm-primary-omp.ts @@ -239,8 +239,9 @@ function userMessageText(content: unknown): string { // cannot end without text. It never re-prompts a final message with text, and // its chat view renders text made only of dots, ellipses, and blank space as // nothing in every streamed frame, so SILENT_WAKE_MARKER ends a wake turn -// silently. An episode is every message since the last final assistant message -// that carried text. In an episode a watcher wake opened and no captain input or +// silently. An episode ends on session replacement, an assistant abort, or a +// final assistant stop that carried text; errors keep the episode for retries. +// In an episode a watcher wake opened and no captain input or // captain-facing branch merge joined, a whole-message no-change line is // rewritten to the marker as a backstop; it streamed before the rewrite, so the // marker stays the primary path. In an episode the captain joined, a dot-only @@ -260,6 +261,10 @@ const NO_CHANGE_LINE = new RegExp(`^${NO_CHANGE_SENTENCE}(?:\\s+${NO_CHANGE_SENT export function installSilentWakeTurns(omp: Pick): void { let wakeOpened = false; let captainJoined = false; + omp.on("session_switch", () => { + wakeOpened = false; + captainJoined = false; + }); omp.on("message_start", (event) => { const message = event.message; const attribution = "attribution" in message ? message.attribution : undefined; @@ -296,8 +301,10 @@ export function installSilentWakeTurns(omp: Pick): void { }); omp.on("message_end", (event) => { const message = event.message; - if (message.role !== "assistant" || message.stopReason !== "stop") return; - if (!message.content.some((block) => block.type === "text" && block.text.trim())) return; + if (message.role !== "assistant") return; + if (message.stopReason !== "aborted" && + !(message.stopReason === "stop" && + message.content.some((block) => block.type === "text" && block.text.trim()))) return; wakeOpened = false; captainJoined = false; }); diff --git a/tests/fm-omp-primary.test.sh b/tests/fm-omp-primary.test.sh index 20c101bedc1..baacb2d1af7 100755 --- a/tests/fm-omp-primary.test.sh +++ b/tests/fm-omp-primary.test.sh @@ -2939,6 +2939,9 @@ function session() { const emit = (name, message) => (handlers.get(name) ?? []).map((handler) => handler({ type: name, message })); return { start: (message) => emit("message_start", message), + switch: (reason) => { + for (const handler of handlers.get("session_switch") ?? []) handler({ type: "session_switch", reason }); + }, // An assistant message as OMP finalizes it: rewrite hook, then message_end. answer(content, stopReason = "stop") { let message = { role: "assistant", stopReason, content }; @@ -3012,6 +3015,35 @@ s.start(wake); assert.equal(s.answer(say("Captain, standing by.")), SILENT_WAKE_MARKER, "captain input leaked past its answered episode"); s.start(captain("anything?")); assert.equal(s.answer(say("Captain, standing by.")), "Captain, standing by.", "a wake leaked into the next captain turn"); +for (const stopReason of ["aborted", "error"]) { + const captainEpisode = session(); + captainEpisode.start(captain("status?")); + captainEpisode.answer([thinking], stopReason); + captainEpisode.start(wake); + assert.equal(captainEpisode.answer(say(SILENT_WAKE_MARKER)), stopReason === "aborted" ? SILENT_WAKE_MARKER : "", + `${stopReason} mishandled the captain episode boundary`); + + const wakeEpisode = session(); + wakeEpisode.start(wake); + wakeEpisode.answer([thinking], stopReason); + assert.equal(wakeEpisode.answer(say("Captain, standing by.")), stopReason === "aborted" ? "Captain, standing by." : SILENT_WAKE_MARKER, + `${stopReason} mishandled the wake episode boundary`); +} + +for (const reason of ["new", "resume", "fork"]) { + const captainEpisode = session(); + captainEpisode.start(captain("status?")); + captainEpisode.switch(reason); + captainEpisode.start(wake); + assert.equal(captainEpisode.answer(say(SILENT_WAKE_MARKER)), SILENT_WAKE_MARKER, + `${reason} retained the previous session's captain input`); + + const wakeEpisode = session(); + wakeEpisode.start(wake); + wakeEpisode.switch(reason); + assert.equal(wakeEpisode.answer(say("Captain, standing by.")), "Captain, standing by.", + `${reason} retained the previous session's wake`); +} console.log("silent-wake-rules-ok"); JS ) From 8e8c809a50edeba8eafddcbd8b8afe4652c7dd21 Mon Sep 17 00:00:00 2001 From: dnth Date: Tue, 20 Oct 2026 10:15:53 +0800 Subject: [PATCH 4/4] no-mistakes(document): Clarify OMP silent wake guarantees and compatibility limits --- .omp/extensions/fm-primary-omp.ts | 6 +++--- docs/supervision-protocols/omp.md | 2 +- docs/verification/runtime-backends.md | 12 ++++++++++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.omp/extensions/fm-primary-omp.ts b/.omp/extensions/fm-primary-omp.ts index 7ed68086a7c..0c2b185f744 100644 --- a/.omp/extensions/fm-primary-omp.ts +++ b/.omp/extensions/fm-primary-omp.ts @@ -236,9 +236,9 @@ function userMessageText(content: unknown): string { // Silent wake turns (docs/supervision-protocols/omp.md). OMP re-prompts a final // message whose only content is signed thinking ("You said you would continue // ..."; features.unexpectedStopDetection, default "mechanical"), so a wake turn -// cannot end without text. It never re-prompts a final message with text, and -// its chat view renders text made only of dots, ellipses, and blank space as -// nothing in every streamed frame, so SILENT_WAKE_MARKER ends a wake turn +// cannot end without text. In mechanical mode it never re-prompts a final +// message with text. Its chat view renders text made only of dots, ellipses, +// and blank space as nothing in every frame, so SILENT_WAKE_MARKER ends a wake turn // silently. An episode ends on session replacement, an assistant abort, or a // final assistant stop that carried text; errors keep the episode for retries. // In an episode a watcher wake opened and no captain input or diff --git a/docs/supervision-protocols/omp.md b/docs/supervision-protocols/omp.md index 47c3d0a02d3..e48618ff051 100644 --- a/docs/supervision-protocols/omp.md +++ b/docs/supervision-protocols/omp.md @@ -22,7 +22,7 @@ When this session owns supervision and away mode is not active: 12. Missing, failed, or unhealthy cycle only: drain queued notifications, inspect the failure, call `fm_watch_arm_omp`, and restart with the explicit `-e` fallback if the integration is missing or stale. 13. Never use shell `&` for watcher supervision. 14. While the supervision branch is active, MAIN must claim the reserved backlog lease before every `tasks-axi` or direct `data/backlog.md` mutation, then release it after the mutation: `bin/fm-lease.sh claim backlog`, mutate, `bin/fm-lease.sh release backlog`. -15. A turn opened only by a watcher wake that has nothing captain-facing ends with exactly `…` (one ellipsis character) as its whole final message, never a no-change line; OMP shows nothing for it and does not re-prompt a final message that has text. +15. A turn opened only by a watcher wake that has nothing captain-facing ends with exactly `…` (one ellipsis character) as its whole final message, never a no-change line; OMP shows nothing for it and, in the default `mechanical` recovery mode, does not re-prompt a final message that has text. Never use it in place of a real outcome, decision, PR-ready report, or failure; on a turn where the captain wrote, the primary extension discards it so OMP asks for a visible answer. For a persistent secondmate, the watcher in that secondmate home touches `state/.last-watcher-beat` at the start of every cycle. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index be959b740c2..1884a775b0f 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -741,8 +741,13 @@ With only the two composer-draft assertions removed, the same run also passed `o #### OMP silent wake turns -OMP 18.10.1 re-prompts a final assistant message whose only content is signed thinking with no text: `features.unexpectedStopDetection` defaults to `mechanical`, and `src/session/turn-recovery.ts` retries such a stop up to 3 times with "You said you would continue ... Give the final answer now.". -It never retries a final message that has visible text in that mode, and its chat view renders text made only of dots, ellipses, or blank space as nothing in every streamed frame (`pi-tui` `canonicalizeMessage`). +In `@oh-my-pi/pi-coding-agent` 18.10.1, `features.unexpectedStopDetection` is `none|mechanical|smart`, defaulting to `mechanical` (`src/session/settings.ts:1052-1056`). +The recovery handler (`src/session/turn-recovery.ts:1108-1195`, invoked at `src/session/agent-session.ts:4381-4385`) retries a final `stop` with no tool call, no visible text, and a thinking block with non-empty thinking and signature (`src/session/unexpected-stop-classifier.ts:42-61`) in both mechanical and smart modes (`src/session/turn-recovery.ts:1127-1135`), up to 3 times (`:93`). +Signed thinking counts as actionable content (`src/session/messages.ts:544,568`), bypassing the empty-stop path. +It never retries a final message that has visible text in mechanical mode (`src/session/turn-recovery.ts:1136-1138`), and its chat view renders text made only of dots, ellipses, or blank space as nothing in every streamed frame (`pi-tui` `canonicalizeMessage`). +Smart mode's judge on `…` is unproven. +There is no supported per-turn silent stop in this version: the recovery setting is session-wide and not extension-settable, `acceptTerminalEmptyStop` is internal and handles empty stops rather than signed thinking-only stops, and `yield` is the subagent terminal protocol. +Rewriting text to empty through `assistant_message` restores the retried thinking-only shape. The silent wake-turn marker `…` and the primary extension's backstop and captain guard (owner: `installSilentWakeTurns` in `.omp/extensions/fm-primary-omp.ts`; agent rule: [OMP supervision protocol](../supervision-protocols/omp.md)) rely on both behaviors. On 2026-10-20 the opt-in real-SDK regression ran under Bun 1.4.3 against `@oh-my-pi/pi-coding-agent` 18.10.1, the same version as the installed `omp` binary, installed into a scratch directory with `bun add @oh-my-pi/pi-coding-agent@18.10.1`: @@ -758,6 +763,9 @@ ok - real OMP: silent wake turns draw no retry and render nothing in any streame The real-SDK phase streams each reply in delayed chunks and renders every streamed frame through OMP's assistant message component. It first asserts that a thinking-only wake turn is re-prompted, so it fails rather than passing vacuously once OMP stops retrying such stops. Without `FM_OMP_PACKAGE_DIR` it prints a skip line and only the portable episode-rule phase runs. +The portable phase also covers episode reset on assistant abort and session replacement, and episode retention on errors for retries. +OMP versions before 18.4.4 lack the `assistant_message` hook, so the marker remains usable but the backstop and captain guard are unavailable. +The backstop can briefly stream a no-change line before rewriting the settled message; only the marker path is proven invisible in every streamed frame. ## Herdr