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..0c2b185f744 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,106 @@ 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. 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 +// 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)?" + + "|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("session_switch", () => { + wakeOpened = false; + 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 (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() : ""; + 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: replacement } : block + ), + }; + }); + omp.on("message_end", (event) => { + const message = event.message; + 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; + }); +} + 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 +343,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..e48618ff051 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 `…` (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. 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..1884a775b0f 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -739,6 +739,34 @@ 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 + +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`: + +```sh +FM_OMP_PACKAGE_DIR=/node_modules/@oh-my-pi/pi-coding-agent bash tests/fm-omp-primary.test.sh +``` + +```text +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 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 The compatibility floor is protocol 14. diff --git a/tests/fm-omp-primary.test.sh b/tests/fm-omp-primary.test.sh index 868c89420de..baacb2d1af7 100755 --- a/tests/fm-omp-primary.test.sh +++ b/tests/fm-omp-primary.test.sh @@ -2924,6 +2924,155 @@ 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); + +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), + 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 }; + 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), 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"; +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."], + ["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-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."], +]; +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("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 + ) + 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 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 +# 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 +3101,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..1c14fc2654f --- /dev/null +++ b/tests/fm-omp-silent-wake-sdk.mjs @@ -0,0 +1,133 @@ +// Real OMP turn recovery and chat rendering for silent wake turns, with a +// 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; +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 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) { + 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`; + // reasoning_content parses to a thinking block whose signature is the field + // name: the signed thinking-only shape OMP's unexpected-stop path retries. + 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, frameRecorder, (pi) => installSilentWakeTurns(pi)], + modelPattern: "silent-probe/probe", enableMCP: false, enableLsp: false, skills: [], contextFiles: [], + promptTemplates: [], toolNames: [], autoApprove: true, systemPrompt: "Main test session", +}); + +// 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(); + 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`); + 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: 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( + { 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"); + + 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.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)}`); + + 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"); + 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 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 in any streamed frame, captain turns and real outcomes stay visible"); +} finally { + await session.dispose(); + server.stop(); +}