fix: show unrecovered OpenClaw local turns as attention (#2092)

An OpenClaw seat whose local turn was interrupted, and which now shows OpenClaw's "Session … changed while starting work. Retry." frame, reads as needing attention on nodes, `rig ps` and the TUI, with "OpenClaw turn not recovered; inspect the pane before retrying". A send to it still goes through, as today, with an advisory that OpenClaw refused its last local turn and that `rig capture <seat>` shows it.

The recognizer reads only OpenClaw's own footer above the editor, so pasting the error into the input box doesn't produce the diagnosis. It observes; it doesn't repair native records, replay the request or start a fresh conversation. The presets guide recommends Gateway mode for unattended seats. 0.7.3 slice 06, OpenClaw (a).
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-10-26 03:56:01 -07:00
1 parent ef668abd27
commit 4b515ecdbf
6 files changed
+218 -15

No files matched your search

+2 -1
View File
@@ -171,7 +171,8 @@ OpenClaw's CLI.
- **The conversation:** both examples use only agent `main`, mapping OpenRig's name to the full native key `agent:main:<OpenRig name>`. Each seat has its own client state folder.
- **Its resume check:** local mode checks `sessions --agent main --json --limit all`; Gateway mode calls the selected Gateway's `sessions.describe`. Both require the exact canonical key. An unavailable lookup stops the launch; a confirmed missing key is never recreated. A client's local cache cannot authorize Gateway resume.
- **Stopping:** a Gateway client's pane closing detaches it. The Gateway and an accepted turn can keep running; stopping that service is separate. Local mode runs its embedded agent in the pane.
- **Local interruption:** Finish the current local turn before stopping; an interrupted local turn may retain native recovery work and refuse the next request. This launcher does not repair or retry it.
- **Unattended seats:** prefer Gateway mode within the capabilities qualified for your installed version. Local interrupted-turn recovery is not supported by these launchers.
- **Local interruption:** Finish the current local turn before stopping; an interrupted local turn may retain native recovery work and refuse the next request. This launcher does not repair or retry it. OpenRig recognizes the recorded local “changed while starting work” error frame as needing attention: “OpenClaw turn not recovered; inspect the pane before retrying”. Use `rig capture <session>` to inspect the native conversation. An ordinary send still submits a new turn, with an advisory that the last local turn was not recovered; it is not an automatic replay of that turn. It does not clear native recovery records, repeat the request or start a fresh conversation; a successful process launch is not proof that the turn recovered.
- **Local startup after resume:** OpenClaw may run its database integrity check during startup. A reported resumed process does not confirm readiness for the next request; check that the TUI has finished starting before sending work.
- **Resets must stay off:** automatic session resets can create a new conversation under the same key. Typing `/new` also moves to a fresh key while the seat keeps the name OpenRig gave it.
- **The presets:** both declare `names_conversation`, `resume` and `first_input`. The launcher passes the generated first startup text through OpenClaw's `--message` option. OpenRig labels the saved name as passed, not observed by its adapter.
@@ -118,7 +118,7 @@ export class SeatStructuralActivityService {
this.latestBySession.delete(sessionName);
return null;
}
const c = communityHarnessPaneActivity(classifyPaneActivity(content), communityHarness);
const c = communityHarnessPaneActivity(classifyPaneActivity(content), communityHarness, content);
const at = observedAt ?? this.now();
const obs: StructuralObservation = {
state: c.state,
@@ -29,7 +29,8 @@ import { OutboxHandler } from "./outbox-handler.js";
import { DraftAwareDelivery, type GuardedDeliverySummary, type QueueWakeFacts } from "./draft-aware-delivery.js";
import type Database from "better-sqlite3";
import type { RigRepository } from "./rig-repository.js";
import { claudeResumeRotation, type SessionRegistry } from "./session-registry.js";
import { communityHarnessOf } from "./built-in-runtimes.js";
import { claudeResumeRotation, sessionCommunityHarnessSelect, type SessionRegistry } from "./session-registry.js";
import type { TmuxAdapter, TmuxResult } from "../adapters/tmux.js";
import { newPinHolder, readPinSizing, recordPin, releasePin, WINDOW_SIZE_PRIOR_OPTION, withWindowPin, type PinRelease } from "../terminal/window-pin.js";
import { latestHookPermissionPending, latestHookWaitsOnPerson, type AgentActivityStore } from "./agent-activity-store.js";
@@ -554,10 +555,33 @@ function draftComposerLine(lines: string[], verdict: PaneActivityClassification)
return lines[last]!.trim();
}
const OPENCLAW_TURN_NOT_RECOVERED = "OpenClaw turn not recovered; inspect the pane before retrying";
/** The pinned OpenClaw local error frame, not a quoted error in history or a recovery-state diagnosis.
* Multiple native branches emit the same refusal; observe it without touching native session state. */
function currentOpenClawTurnRefusal(paneContent: string): string | null {
const lines = trimPaneLines(styledTextLines(paneContent).map(line => line.text).join("\n"));
const editorRules = lines.flatMap((line, at) => /^─{3,}$/.test(line) ? [at] : []);
// The pinned TUI puts its status/footer above the editor. Extra rules make that boundary ambiguous;
// leave those layouts unclassified rather than selecting a quoted frame inside the composer.
if (editorRules.length !== 2 || editorRules[1] !== lines.length - 1) return null;
for (let at = editorRules[0]! - 1; at >= 0; at--) {
if (!/^(?:local|gateway)\s+.*\|/.test(lines[at]!)) continue;
if (lines[at]!.replace(/\s+/g, " ") !== "local ready | error"
|| !lines[at + 1]?.startsWith("agent main | session ")) return null;
const error = lines.slice(Math.max(0, at - 12), at).join(" ");
const match = /(?:^| )run error:\s*Session\s+"[^"]+"\s+changed while\s+starting work\.\s+Retry\.$/.exec(error);
return match ? truncateEvidence(match[0]) : null;
}
return null;
}
/** A community harness's screen never proves it's idle: a quiet prompt can be an approval or a pause, so its idle
* reading becomes unknown. Positive work and attention are kept. `communityHarness` is the seat's recorded selection
* (`communityHarnessOf`); without it the classification is returned as it is. */
export function communityHarnessPaneActivity(classification: PaneActivityClassification, communityHarness: string | null | undefined): PaneActivityClassification {
export function communityHarnessPaneActivity(classification: PaneActivityClassification, communityHarness: string | null | undefined, paneContent = ""): PaneActivityClassification {
const refused = communityHarness === "openclaw" ? currentOpenClawTurnRefusal(paneContent) : null;
if (refused) return { state: "attention", reason: OPENCLAW_TURN_NOT_RECOVERED, evidence: refused };
return communityHarness && classification.state === "agent_idle"
? { state: "unknown", reason: "community_harness_no_idle_signal", evidence: classification.evidence }
: classification;
@@ -767,6 +791,8 @@ export async function probeSessionActivity(input: {
composerSnapshot?: () => Promise<ComposerSnapshot | null>;
/** The seat's community harness (`communityHarnessOf`): its idle-looking screen reads unknown. */
communityHarness?: string | null;
/** Ordinary sends keep existing community idle readings; an OpenClaw turn error is advisory, not a picker. */
communityHarnessSendProbe?: boolean;
/** See ClassifyPaneOptions.postCompactTurnBoundary. */
postCompactTurnBoundary?: boolean;
}): Promise<AgentActivity> {
@@ -891,8 +917,13 @@ export async function probeSessionActivity(input: {
currentClaudeDialogueOnly: input.currentClaudeDialogueOnly === true,
postCompactTurnBoundary: input.postCompactTurnBoundary === true,
};
let classification = communityHarnessPaneActivity(classifyPaneActivity(paneContent ?? "", { ...classifyOptions,
composerSnapshot: snapshot ?? undefined, runtime }), input.communityHarness);
const paneClassification = classifyPaneActivity(paneContent ?? "", { ...classifyOptions,
composerSnapshot: snapshot ?? undefined, runtime });
let classification = communityHarnessPaneActivity(paneClassification, input.communityHarness, paneContent ?? "");
if (input.communityHarnessSendProbe) {
if (classification.reason === "community_harness_no_idle_signal") classification = paneClassification;
else if (classification.reason === OPENCLAW_TURN_NOT_RECOVERED) classification = { ...classification, state: "unknown" };
}
// Claude renders its prompt suggestion faint, which the plain capture loses. When a Claude pane reads
// as a draft, one capture with styling is classified on its own. Newer attention or work there is
// kept. A faint suggestion counts only when it's the same composer text this capture read as a draft;
@@ -1927,7 +1958,7 @@ interface SessionTransportDeps {
interface SessionRow { node_id: string; session_name: string; }
interface NodeRow { rig_id: string; logical_id: string; }
interface SessionMetaRow { runtime: string | null; attachment_type: string | null; node_id: string | null; binding_session: string | null; pane: string | null; occupant: string | null; resume_token: string | null;
interface SessionMetaRow { community_harness: string | null; runtime: string | null; attachment_type: string | null; node_id: string | null; binding_session: string | null; pane: string | null; occupant: string | null; resume_token: string | null;
resume_provenance: string | null; resume_rotated_from: string | null; resume_rotated_process: string | null; resume_launch_process: string | null; }
interface ResolvedTarget { sessionName: string; rigName: string; nodeLogicalId: string; }
interface AbsenceProbeTarget { session_id: string; node_id: string; session_name: string; tmux_pane: string | null; }
@@ -2043,6 +2074,7 @@ export class SessionTransport {
private getSessionMeta(sessionName: string): {
runtime: string | null; attachmentType: string | null; nodeId: string | null; pane: string | null; occupant: string | null; resumeToken: string | null;
rotation: ClaudeResumeRotation | null;
communityHarness: string | null;
} {
sessionName = physicalSessionName(this.db, sessionName);
// One existing statement; P2 reads the binding columns it already joins plus the
@@ -2050,6 +2082,7 @@ export class SessionTransport {
const row = this.db.prepare(`
SELECT
n.runtime AS runtime,
${sessionCommunityHarnessSelect(this.db)} AS community_harness,
b.attachment_type AS attachment_type,
n.id AS node_id,
b.tmux_session AS binding_session,
@@ -2070,6 +2103,7 @@ export class SessionTransport {
return {
runtime: row?.runtime ?? null,
communityHarness: communityHarnessOf(row?.community_harness, row?.runtime),
attachmentType: row?.attachment_type ?? null,
nodeId: row?.node_id ?? null,
// The row may be a historical session of a node now bound elsewhere: only a
@@ -3079,8 +3113,12 @@ export class SessionTransport {
// or not --dangerously-interact was passed (B1 code-review fix) — so the honest telemetry is
// surfaced without ever blocking communication. Hooks are advisory telemetry, not authority
// over whether agents can talk.
const linkDiagnosis = await this.diagnoseProducerLink(sessionName);
sendAdvisory = [sendAdvisory, `producer-link: ${linkDiagnosis} — activity could not be determined (${readiness.reason}); sent anyway (telemetry is advisory).`].filter(Boolean).join(" ");
if (readiness.reason === OPENCLAW_TURN_NOT_RECOVERED) {
sendAdvisory = [sendAdvisory, `OpenClaw refused its last local turn and OpenRig hasn't recovered it; this message submits a new turn. Inspect it with rig capture ${sessionName}.`].filter(Boolean).join(" ");
} else {
const linkDiagnosis = await this.diagnoseProducerLink(sessionName);
sendAdvisory = [sendAdvisory, `producer-link: ${linkDiagnosis} — activity could not be determined (${readiness.reason}); sent anyway (telemetry is advisory).`].filter(Boolean).join(" ");
}
// fall through to the send below.
} else if (readiness.state === "running") {
// OPR.0.4.3.28 fast-follow (advisor audit-catch, pm-ruled a founder-principle residual):
@@ -3530,8 +3568,10 @@ export class SessionTransport {
}, privateInput = false): Promise<AgentActivity> {
const now = this.now();
let binding = input.binding;
let communityHarness: string | null = null;
try {
const stored = this.getSessionMeta(input.sessionName);
communityHarness = stored.communityHarness;
binding = {
sessionName: input.sessionName,
nodeId: binding?.nodeId ?? stored.nodeId,
@@ -3576,6 +3616,7 @@ export class SessionTransport {
if (this.hookFreshForSend(hookActivity, now)) {
if (hookActivity.state === "running" && input.runtime === "claude-code") {
const paneVeto = await probeSessionActivity({
communityHarness, communityHarnessSendProbe: true,
composerSnapshot,
currentClaudeDialogueOnly: true,
sessionName: input.sessionName,
@@ -3605,6 +3646,7 @@ export class SessionTransport {
// vetoes (→ refuse); an unknown pane (the flaky-Codex case this trust
// exists for) or a clean idle pane does NOT veto → trust the stale hook.
const paneVeto = await probeSessionActivity({
communityHarness, communityHarnessSendProbe: true,
composerSnapshot,
sessionName: input.sessionName,
runtime: input.runtime,
@@ -3622,6 +3664,7 @@ export class SessionTransport {
}
const probe = await probeSessionActivity({
communityHarness, communityHarnessSendProbe: true,
composerSnapshot,
sessionName: input.sessionName,
runtime: input.runtime,
@@ -0,0 +1,9 @@
run error: Session
"agent:main:openrig-fixture" changed while
starting work. Retry.
local ready | error
agent main | session openrig-fixture |
openai/gpt-4o-mini | deliver:off | tokens 2.4k/128k (2%)
────────────────────────────────────────────────────────────────────────────────
────────────────────────────────────────────────────────────────────────────────
@@ -1,5 +1,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { SeatStructuralActivityService } from "../src/domain/seat-structural-activity-service.js";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { PsProjectionService } from "../src/domain/ps-projection.js";
@@ -30,12 +33,17 @@ describe("served seat attention across the rig, CLI and TUI", () => {
{ name: "after a daemon restart, with no arbitrated state yet", signal: null, hook: null, cause: "none", pane: true, restart: true, expected: true },
{ name: "an observed clear overrides the pane reading", signal: false, hook: null, cause: "none", pane: true, expected: false },
{ name: "a current running hook outranks the pane reading", signal: null, hook: "running", cause: "none", pane: true, expected: false },
])("$name", async ({ signal, hook, cause, expected, pane = false, restart = false }) => {
{ name: "an OpenClaw local turn was not recovered", signal: null, hook: null, cause: "none", pane: true, openclaw: true, expected: true },
])("$name", async ({ signal, hook, cause, expected, pane = false, restart = false, openclaw = false }) => {
const db = createFullTestDb(); closes.push(() => db.close());
db.exec(`INSERT INTO rigs(id,name) VALUES('rig','team');
INSERT INTO nodes(id,rig_id,logical_id,runtime) VALUES('node','rig','worker','codex');
INSERT INTO bindings(id,node_id,tmux_session,tmux_pane) VALUES('binding','node','worker@team','%1');
INSERT INTO sessions(id,node_id,session_name,status,startup_status) VALUES('session','node','worker@team','running','ready');`);
if (openclaw) {
db.prepare("UPDATE nodes SET runtime='openclaw'").run();
new SessionRegistry(db).recordCommunityHarnessSelection("session", "openclaw");
}
if (cause === "attention_required" || cause === "failed") db.prepare("UPDATE sessions SET startup_status=?").run(cause);
if (cause === "held") {
db.exec("UPDATE sessions SET status='exited'");
@@ -50,8 +58,14 @@ describe("served seat attention across the rig, CLI and TUI", () => {
activity: "working", needsInput: { count: signal ? 1 : 0, reason: signal ? "visible dialog" : null },
needsInputEvidence: evidence, decidedBy: "window-sampling", seq: 1, lastSwap: null,
}) } as unknown as SeatActivityService;
const structuralActivity = { getStructuralActivity: () => pane
? { state: "attention", reason: "selection_prompt", evidence: "› 1. Yes, proceed", observedAt: new Date().toISOString() } : null };
const structuralActivity = openclaw
? new SeatStructuralActivityService({ capturePaneContent: async () => readFileSync(new URL("./fixtures/openclaw-local-turn-refused.txt", import.meta.url), "utf8") })
: { getStructuralActivity: () => pane
? { state: "attention", reason: "selection_prompt", evidence: "› 1. Yes, proceed", observedAt: new Date().toISOString() } : null };
if (structuralActivity instanceof SeatStructuralActivityService) {
await structuralActivity.pollSeat("worker@team", undefined, "openclaw", "openclaw");
}
const attentionReason = openclaw ? "OpenClaw turn not recovered; inspect the pane before retrying" : "selection_prompt";
const activityStore = { getLatestForNode: () => hook == null ? null : ({
state: hook, reason: "hook reason", evidenceSource: "runtime_hook", sampledAt: new Date().toISOString(), evidence: null,
}) } as unknown as AgentActivityStore;
@@ -74,8 +88,8 @@ describe("served seat attention across the rig, CLI and TUI", () => {
expect(cli).toMatchObject({ needsAttention: expected });
if (pane && expected) {
// The served activity and the attention flag tell the same truth, and the reason survives into the compact read.
expect(nodes[0].agentActivity).toMatchObject({ state: "needs_input", reason: "selection_prompt", evidenceSource: "pane_heuristic" });
expect(cli).toMatchObject({ agentActivity: { state: "needs_input", reason: "selection_prompt" } });
expect(nodes[0].agentActivity).toMatchObject({ state: "needs_input", reason: attentionReason, evidenceSource: "pane_heuristic" });
expect(cli).toMatchObject({ agentActivity: { state: "needs_input", reason: attentionReason } });
}
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
vi.stubEnv("ATTENTION_FIXTURE_TOKEN", "fixture-only");
@@ -93,7 +107,8 @@ describe("served seat attention across the rig, CLI and TUI", () => {
stdout.mockClear();
await command.parseAsync(["node", "rig", "ps", "--host", "fixture", "--nodes", "--rig", "team"]);
const row = stdout.mock.calls.map(call => String(call[0])).find(line => line.includes("worker@team"));
expect(row).toMatch(/\byes\b.*selection_prompt\s*$/);
expect(row).toMatch(/\byes\b/);
expect(row).toContain(attentionReason);
}
stdout.mockRestore();
// The TUI's actual HTTP reader consumes the exact node response, not an independently authored node fixture.
@@ -0,0 +1,135 @@
import type { TmuxAdapter } from "../src/adapters/tmux.js";
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import { classifyPaneActivity, communityHarnessPaneActivity, probeSessionActivity, SessionTransport } from "../src/domain/session-transport.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { createFullTestDb } from "./helpers/test-app.js";
import { SeatStructuralActivityService } from "../src/domain/seat-structural-activity-service.js";
// OpenClaw2026.10.6 retained local refusal, with a synthetic conversation name.
// The text has several native emitters: it proves a refused turn, not a specific recovery-claim diagnosis.
const refusal = readFileSync(new URL("./fixtures/openclaw-local-turn-refused.txt", import.meta.url), "utf8");
const read = (screen: string, harness: string | null = "openclaw") =>
communityHarnessPaneActivity(classifyPaneActivity(screen), harness, screen);
const needsAttention = { state: "attention", reason: "OpenClaw turn not recovered; inspect the pane before retrying" };
describe("an OpenClaw local turn that was not recovered", () => {
it("reports the retained wrapped refusal, with inspection guidance instead of an automatic retry", () => {
expect(read(refusal)).toMatchObject(needsAttention);
expect(read(refusal).evidence).toContain('Session "agent:main:openrig-fixture" changed while starting work. Retry.');
});
it("reads a styled, unwrapped refusal through the same path", () => {
expect(read(refusal.replace('Session\n "agent:main:openrig-fixture" changed while\n starting', 'Session "agent:main:openrig-fixture" changed while starting')
.replace("local ready | error", "\x1b[31mlocal ready | error\x1b[0m"))).toMatchObject(needsAttention);
});
it.each([null, "claude-code", "pi", "synth", "openclaw-gateway"])("does not infer an OpenClaw refusal for %s", harness => {
expect(read(refusal, harness).reason).not.toBe(needsAttention.reason);
});
it.each([
refusal.replace("local ready | error", "local ready | idle"),
refusal.replace("local ready | error", "gateway connected | error"),
refusal.replace("starting work. Retry.", "starting work. Try again."),
refusal.replace('run error: Session', 'quoted example: Session'),
refusal.replace("agent main | session", "quoted output | session"),
'Session "agent:main:openrig-fixture" changed while starting work. Retry.',
refusal + "\nlocal ready | idle\nagent main | session openrig-fixture |",
refusal + "\n⠋ Working (esc to interrupt)",
])("does not mistake history, another error or an incomplete frame for the current native refusal", screen => {
expect(read(screen).reason).not.toBe(needsAttention.reason);
});
it("feeds the existing capture cache and invalidates on an unreadable pane", async () => {
let content: string | null = refusal;
const svc = new SeatStructuralActivityService({ capturePaneContent: async () => content });
expect(await svc.pollSeat("worker@team", undefined, "openclaw", "openclaw")).toMatchObject(needsAttention);
expect(svc.getStructuralActivity("worker@team")).toMatchObject(needsAttention);
content = null;
expect(await svc.pollSeat("worker@team", undefined, "openclaw", "openclaw")).toBeNull();
expect(svc.getStructuralActivity("worker@team")).toBeNull();
});
it("uses the same attention and guidance in a direct inventory-style probe", async () => {
const activity = await probeSessionActivity({
sessionName: "worker@team", runtime: "openclaw", communityHarness: "openclaw", attachmentType: "tmux",
tmuxAdapter: { hasSession: async () => true, capturePaneContent: async () => refusal, getPaneCommand: async () => "openclaw" } as unknown as TmuxAdapter,
});
expect(activity).toMatchObject({ ...needsAttention, state: "needs_input" });
});
});
const rule = "─".repeat(80);
const pastedError = [
"Please explain this pasted error:",
'run error: Session "agent:main:example" changed while starting work. Retry.',
"local ready | error",
"agent main | session example |",
"openai/gpt-4o-mini | deliver:off | tokens 2.4k/128k (2%)",
].join("\n");
describe("OpenClaw footer boundaries and ordinary sends", () => {
it.each(["local ready | idle", "gateway connected | idle"])("ignores a pasted refusal inside the editor below %s", footer => {
const screen = [footer, "agent main | session actual | model | tokens 1/100", rule, pastedError, rule].join("\n");
expect(read(screen).reason).not.toBe(needsAttention.reason);
});
it("does not select a nested full frame pasted inside a healthy editor", () => {
const screen = ["local ready | idle", "agent main | session actual |", rule, refusal, rule].join("\n");
expect(read(screen).reason).not.toBe(needsAttention.reason);
});
async function send(screen: string, runtime: string, selection: string | null) {
const db = createFullTestDb();
try {
const rigRepo = new RigRepository(db);
const sessionRegistry = new SessionRegistry(db);
const rig = rigRepo.createRig("team");
const node = rigRepo.addNode(rig.id, "worker", { role: "worker", runtime });
const session = sessionRegistry.registerSession(node.id, "worker@team");
sessionRegistry.updateStatus(session.id, "running");
sessionRegistry.updateBinding(node.id, { tmuxSession: "worker@team" });
if (selection) sessionRegistry.recordCommunityHarnessSelection(session.id, selection);
const sendText = vi.fn(async () => ({ ok: true as const }));
const sendKeys = vi.fn(async () => ({ ok: true as const }));
const tmuxAdapter = {
hasSession: async () => true,
probeSession: async () => ({ state: "present" as const }),
capturePaneContent: async () => screen,
getPaneCommand: async () => runtime,
isPaneInMode: async () => false,
sendText, sendKeys,
} as unknown as TmuxAdapter;
const transport = new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter, sleep: async () => {} });
const result = await transport.send("worker@team", "A new turn");
expect(result.ok).toBe(true);
expect(sendText).toHaveBeenCalledWith("worker@team", "A new turn");
expect(sendKeys).toHaveBeenCalledWith("worker@team", ["Enter"]);
return result;
} finally { db.close(); }
}
it("sends a new turn with the OpenClaw advisory from its recorded selection", async () => {
const result = await send(refusal, "openclaw", "openclaw");
expect(result.warning).toContain("OpenClaw refused its last local turn and OpenRig hasn't recovered it; this message submits a new turn.");
expect(result.warning).toContain("rig capture worker@team");
expect(result.warning).not.toContain("producer-link");
});
it.each([
["openclaw", null],
["other-harness", "other-harness"],
])("keeps a refusal-shaped send to %s without an OpenClaw advisory when selection is %s", async (runtime, selection) => {
const result = await send(refusal, runtime!, selection);
expect(result.warning).not.toContain("OpenClaw refused");
});
it.each(["openclaw", "other-harness"])("preserves existing idle send behavior for %s", async runtime => {
const result = await send("❯ ", runtime, runtime);
expect(result.warning).toBeUndefined();
});
});