mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
870845aa22 | ||
|
|
cff094230b |
No files matched your search
@@ -217,8 +217,10 @@ function findClaudeComposer(paneContent: string, options: ClassifyPaneOptions =
|
||||
|
||||
let liveStatus: string | null = null;
|
||||
let headSeen = false;
|
||||
let statusHead = statusStart;
|
||||
for (let i = statusStart; i >= 0; i--) {
|
||||
if (!lines[i]!.startsWith(indent)) break;
|
||||
statusHead = i;
|
||||
const line = lines[i]!.slice(indent.length);
|
||||
const livePatterns = options.timerlessStatusIsLive
|
||||
? [CLAUDE_LIVE_STATUS_PATTERN, CLAUDE_TIMERLESS_STATUS_PATTERN, ...MID_WORK_PATTERNS]
|
||||
@@ -232,7 +234,7 @@ function findClaudeComposer(paneContent: string, options: ClassifyPaneOptions =
|
||||
// or completed status ends this block; do not revive an older work row.
|
||||
if (!/^\s/.test(line)) { headSeen = true; break; }
|
||||
}
|
||||
return { text: prompt.slice(indent.length), bar: lines[bar]!.trim(), framed, hasWarnings: bar < lines.length - 1, supportedWarningFooter, modeFooter, headSeen, liveStatus };
|
||||
return { text: prompt.slice(indent.length), bar: lines[bar]!.trim(), framed, hasWarnings: bar < lines.length - 1, supportedWarningFooter, modeFooter, headSeen, liveStatus, currentLines: lines.slice(statusHead) };
|
||||
}
|
||||
|
||||
function findPromptDraftBeforeFooter(paneContent: string): string | null {
|
||||
@@ -364,7 +366,16 @@ export function classifyPaneActivity(paneContent: string, options: ClassifyPaneO
|
||||
// OPR.0.4.1.10 (FR-1c): a permission/confirmation question is attention even when its selector is
|
||||
// not in view — checked before the idle short-circuits so a permission block above an idle-looking
|
||||
// footer never reads as idle (which would let a default send land on it).
|
||||
const permissionPromptEvidence = findPatternEvidence(promptScanLines, PERMISSION_PROMPT_PATTERNS);
|
||||
// A complete, empty Claude frame can separate completed output from its current status block.
|
||||
// Require a recognized completed summary, not merely an unindented row. Keep a current question
|
||||
// in the scan. A bare prompt, draft, live status, unsupported footer or exhausted status scan
|
||||
// proves no such boundary.
|
||||
const permissionLines = claudeComposer?.framed && claudeComposer.headSeen && !claudeComposer.liveStatus &&
|
||||
CLAUDE_ANCHORED_MODE_FOOTER.test(claudeComposer.bar) &&
|
||||
IDLE_PROMPT_PATTERNS.some(pattern => pattern.test(claudeComposer.text)) &&
|
||||
CLAUDE_COMPLETED_STATUS_ROW.test(claudeComposer.currentLines[0]?.trim() ?? "")
|
||||
? claudeComposer.currentLines.slice(-PROMPT_SCAN_LINES) : promptScanLines;
|
||||
const permissionPromptEvidence = findPatternEvidence(permissionLines, PERMISSION_PROMPT_PATTERNS);
|
||||
if (permissionPromptEvidence) {
|
||||
return {
|
||||
state: "attention",
|
||||
@@ -1135,6 +1146,9 @@ const CLAUDE_DURATION = `(?:0\\.0s|${CLAUDE_SIXTY}s|${CLAUDE_NONZERO_SIXTY}m ${C
|
||||
+ `|${CLAUDE_NONZERO_HOURS}h ${CLAUDE_SIXTY}m ${CLAUDE_SIXTY}s|[1-9][0-9]*d ${CLAUDE_HOURS}h ${CLAUDE_SIXTY}m)`;
|
||||
const CLAUDE_CLOCK = "(?:(?:[1-9]|1[0-2]):[0-5][0-9] [AP]M|(?:[01][0-9]|2[0-3]):[0-5][0-9]Z?)";
|
||||
const CLAUDE_COMPLETION_ROW = new RegExp(`^✻ (?:${CLAUDE_COMPLETION_VERBS.join("|")}) for ${CLAUDE_DURATION} · done ${CLAUDE_CLOCK}$`);
|
||||
// Permission-history boundaries also accept the older short completed summaries in warning-footer
|
||||
// layouts. Reuse the completion grammar; the input-box idle allow-list above remains exact.
|
||||
const CLAUDE_COMPLETED_STATUS_ROW = new RegExp(`^✻ (?:${CLAUDE_COMPLETION_VERBS.join("|")}) for ${CLAUDE_DURATION}(?: · done ${CLAUDE_CLOCK})?$`);
|
||||
|
||||
/** The idle allow-list. Each entry comes from real Claude Code 2.1.296 and Codex 0.160.0 frames
|
||||
* (test/fixtures/input-box, including idle seats OpenRig launched: openrig-seat-*), or, where no frame was captured,
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// SYNTHETIC, not a replay: no incident pane capture was retained. Composer shape follows the
|
||||
// checked-in Claude 2.1.296 idle fixtures and #814 warning-footer tests. The prose is constructed.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { classifyPaneActivity, probeSessionActivity, SessionTransport } from "../src/domain/session-transport.js";
|
||||
import { SeatStructuralActivityService } from "../src/domain/seat-structural-activity-service.js";
|
||||
|
||||
const SEAT = "dev-impl@quoted-fixture";
|
||||
const BAR = "─".repeat(70);
|
||||
const QUESTION_FOOTER = "Enter to select · ↑/↓ to navigate · Esc to cancel";
|
||||
const MODE = " ⏵⏵ accept edits on (shift+tab to cycle)";
|
||||
const QUOTE = "● Earlier report: the peer saw a permission prompt (Do you want to proceed?).";
|
||||
const DONE = "✻ Crunched for 2s";
|
||||
const NOW = new Date("2026-10-15T11:30:00.000Z");
|
||||
const frame = (history: string[], head = DONE, prompt = "❯\u00a0", footer = MODE, warnings: string[] = []) =>
|
||||
[...history, "", head, "", `${"─".repeat(40)} dev-impl@quoted-fixture ─`, prompt, BAR, footer, ...warnings].join("\n");
|
||||
const historyPane = (withFooter: boolean) => frame(withFooter ? [QUOTE, QUESTION_FOOTER] : [QUOTE]);
|
||||
const DBS: Database.Database[] = [];
|
||||
afterEach(() => { for (const db of DBS.splice(0)) db.close(); vi.restoreAllMocks(); });
|
||||
|
||||
function fixture(pane: string | null) {
|
||||
const db = createFullTestDb(); DBS.push(db);
|
||||
const rigRepo = new RigRepository(db);
|
||||
const sessionRegistry = new SessionRegistry(db);
|
||||
const rig = rigRepo.createRig("quoted-fixture");
|
||||
const node = rigRepo.addNode(rig.id, "dev.impl", { role: "worker", runtime: "claude-code" });
|
||||
const session = sessionRegistry.registerSession(node.id, SEAT);
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: SEAT });
|
||||
const sendText = vi.fn(async () => ({ ok: true as const }));
|
||||
const sendKeys = vi.fn(async () => ({ ok: true as const }));
|
||||
const capturePaneContent = vi.fn(async () => pane);
|
||||
const tmux = {
|
||||
hasSession: async () => true, probeSession: async () => ({ state: "present" as const }),
|
||||
sendText, sendKeys, capturePaneContent, listPanes: async () => [],
|
||||
getPanePid: async () => null, getPaneCommand: async () => null,
|
||||
isPaneInMode: async () => false, cancelPaneMode: async () => ({ ok: true as const }),
|
||||
} as unknown as TmuxAdapter;
|
||||
const eventBus = new EventBus(db);
|
||||
const hooks = new AgentActivityStore({ db, eventBus });
|
||||
const transport = new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux,
|
||||
agentActivityStore: hooks, eventBus, now: () => NOW, sleep: async () => {}, waitForIdlePollMs: 1 });
|
||||
return { db, tmux, hooks, transport, sendText, sendKeys, capturePaneContent };
|
||||
}
|
||||
|
||||
describe("quoted permission history regression", () => {
|
||||
it("keeps the complete later boundary under supported mode and warning layouts", () => {
|
||||
const warnings = [" ✘ Auto-update failed: no write permission to npm prefix · Run claude doctor",
|
||||
" tmux focus-events off · add 'set -g focus-events on' to ~/.tmux.conf and re…",
|
||||
" You've used 96% of your weekly limit · resets 12pm (UTC)"];
|
||||
for (const footer of [MODE, " -- INSERT -- ⏵⏵ bypass permissions on (shift+tab to cycle)",
|
||||
" ⏵⏵ auto mode on (shift+tab to cycle)", " ⏸ plan mode on (shift+tab to cycle)", " ? for shortcuts"]) {
|
||||
for (const trailers of [[], warnings]) {
|
||||
expect(classifyPaneActivity(frame([QUOTE], DONE, "❯ ", footer, trailers))).toMatchObject({ state: "agent_idle" });
|
||||
}
|
||||
}
|
||||
});
|
||||
for (const withFooter of [false, true]) {
|
||||
const label = withFooter ? "with historical question footer" : "without historical question footer";
|
||||
it(`classifier: ${label}`, () => {
|
||||
expect(classifyPaneActivity(historyPane(withFooter))).toMatchObject({ state: "agent_idle" });
|
||||
});
|
||||
it(`ordinary probe: ${label}`, async () => {
|
||||
const f = fixture(historyPane(withFooter));
|
||||
expect(await probeSessionActivity({ sessionName: SEAT, runtime: "claude-code", attachmentType: "tmux", tmuxAdapter: f.tmux, now: NOW }))
|
||||
.toMatchObject({ state: "idle", evidenceSource: "pane_heuristic" });
|
||||
});
|
||||
it(`ordinary send: ${label}`, async () => {
|
||||
const f = fixture(historyPane(withFooter));
|
||||
expect(await f.transport.send(SEAT, "ordinary synthetic status message")).toMatchObject({ ok: true });
|
||||
expect(f.sendText).toHaveBeenCalledTimes(1); expect(f.sendKeys).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it(`structural cache: ${label}`, async () => {
|
||||
const f = fixture(historyPane(withFooter));
|
||||
const service = new SeatStructuralActivityService(f.tmux, () => NOW);
|
||||
expect(await service.pollSeat(SEAT)).toMatchObject({ state: "agent_idle" });
|
||||
expect(service.getStructuralActivity(SEAT)?.state).toBe("agent_idle");
|
||||
});
|
||||
it(`idle-wait consumer: ${label}`, async () => {
|
||||
const f = fixture(historyPane(withFooter));
|
||||
expect(await f.transport.waitUntilIdle(SEAT, 100)).toMatchObject({ ok: true, activity: { state: "idle" } });
|
||||
expect(f.sendText).not.toHaveBeenCalled(); expect(f.sendKeys).not.toHaveBeenCalled();
|
||||
expect(await f.transport.send(SEAT, "ordinary pane-only wait message", { waitForIdleMs: 100, readinessFromPaneOnly: true }))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(f.sendText).toHaveBeenCalledTimes(1); expect(f.sendKeys).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("quoted permission history preservation", () => {
|
||||
const permission = frame([], "Do you want to proceed?");
|
||||
const selection = ["Choose an option", "❯ 1. Continue", " 2. Cancel", QUESTION_FOOTER].join("\n");
|
||||
const tallPrompt = ["Do you want to proceed?", "❯ 1. Yes", " 2. No", " 3. Explain", " Use ↑↓ to choose, enter to confirm", BAR, "❯ ", BAR, MODE].join("\n");
|
||||
it.each([permission, selection, tallPrompt])("still refuses a current permission or selection prompt", async (pane) => {
|
||||
expect(classifyPaneActivity(pane).state).toBe("attention");
|
||||
const f = fixture(pane);
|
||||
expect(await f.transport.send(SEAT, "ordinary message")).toMatchObject({ ok: false, reason: "target_needs_input" });
|
||||
expect(f.sendText).not.toHaveBeenCalled(); expect(f.sendKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not use a bare glyph or footer as a permission-history boundary", () => {
|
||||
for (const pane of [QUOTE + "\n❯ ", QUOTE + "\n❯ \n" + MODE,
|
||||
frame([QUOTE]).replace(`${"─".repeat(40)} dev-impl@quoted-fixture ─\n`, ""),
|
||||
frame([QUOTE], DONE, "❯ ", "unrecognized footer"),
|
||||
[QUOTE, ...Array.from({ length: 20 }, () => " indented unfinished block"), BAR, "❯ ", BAR, MODE].join("\n")]) {
|
||||
expect(classifyPaneActivity(pane).state).not.toBe("agent_idle");
|
||||
}
|
||||
});
|
||||
it("keeps permission evidence in the current status head or its indented task block", () => {
|
||||
for (const head of ["Do you want to proceed?", "Do you want to proceed?\n waiting for your answer", "Ready\n Do you want to proceed?"]) {
|
||||
expect(classifyPaneActivity(frame([], head))).toMatchObject({ state: "attention", reason: "permission_prompt" });
|
||||
}
|
||||
});
|
||||
it("does not erase a draft or live work, including a running hook", async () => {
|
||||
expect(classifyPaneActivity(frame([QUOTE], DONE, "❯ typed draft")).state).toBe("attention");
|
||||
expect(classifyPaneActivity(frame([], "✻ Thinking… (15s)"))).toMatchObject({ state: "agent_active" });
|
||||
expect(classifyPaneActivity(frame([QUOTE], "✻ Thinking… (15s)")).state).not.toBe("agent_idle");
|
||||
const f = fixture(historyPane(false));
|
||||
f.hooks.recordHookEvent({ runtime: "claude-code", sessionName: SEAT, hookEvent: "UserPromptSubmit", occurredAt: NOW.toISOString() });
|
||||
const result = await f.transport.send(SEAT, "ordinary message");
|
||||
expect(result).toMatchObject({ ok: true }); expect(result.warning).toContain("busy is advisory");
|
||||
});
|
||||
it("fresh permission hook remains authoritative over the complete empty composer", async () => {
|
||||
const f = fixture(historyPane(false));
|
||||
f.hooks.recordHookEvent({ runtime: "claude-code", sessionName: SEAT, hookEvent: "Notification", subtype: "permission_prompt", occurredAt: NOW.toISOString() });
|
||||
expect(await f.transport.send(SEAT, "ordinary message")).toMatchObject({ ok: false, reason: "target_needs_input", activity: { evidenceSource: "runtime_hook" } });
|
||||
expect(f.sendText).not.toHaveBeenCalled(); expect(f.sendKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
it("old permission hook and unknown pane retain the old fallback, without treating unknown as idle", async () => {
|
||||
const f = fixture("unrecognized synthetic pane");
|
||||
f.hooks.recordHookEvent({ runtime: "claude-code", sessionName: SEAT, hookEvent: "Notification", subtype: "permission_prompt", occurredAt: new Date(NOW.getTime() - 36_000).toISOString() });
|
||||
expect(await f.transport.send(SEAT, "ordinary message")).toMatchObject({ ok: false, reason: "target_needs_input" });
|
||||
expect(f.sendText).not.toHaveBeenCalled();
|
||||
expect(await probeSessionActivity({ sessionName: SEAT, runtime: "claude-code", attachmentType: "tmux", tmuxAdapter: f.tmux, now: NOW })).toMatchObject({ state: "unknown" });
|
||||
});
|
||||
it("current permission stays authoritative with supported warnings and every mode footer", () => {
|
||||
for (const mode of [MODE, " -- INSERT -- ⏵⏵ bypass permissions on (shift+tab to cycle)", " ⏵⏵ auto mode on (shift+tab to cycle)", " ⏸ plan mode on (shift+tab to cycle)", " ? for shortcuts"]) {
|
||||
expect(classifyPaneActivity(frame([], "Do you want to proceed?", "❯ ", mode))).toMatchObject({ state: "attention", reason: "permission_prompt" });
|
||||
expect(classifyPaneActivity(frame([], "Do you want to proceed?", "❯ ", mode, [" ✘ Auto-update failed: no write permission to npm prefix · Run claude doctor"])))
|
||||
.toMatchObject({ state: "attention", reason: "permission_prompt" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Synthetic ambiguity and timerless-work controls: none supplies a completed-status boundary.
|
||||
describe("quoted permission history keeps ambiguous and timerless panes guarded", () => {
|
||||
const cases = [
|
||||
[
|
||||
"ambiguous-instruction",
|
||||
"Do you want to proceed?\nPress Enter to confirm\n──────────────────────────────────────────────────\n❯ \n──────────────────────────────────────────────────\n ⏵⏵ accept edits on (shift+tab to cycle)",
|
||||
],
|
||||
[
|
||||
"ambiguous-flat-option",
|
||||
"Do you want to proceed?\n1. Yes\n──────────────────────────────────────────────────\n❯ \n──────────────────────────────────────────────────\n ⏵⏵ accept edits on (shift+tab to cycle)",
|
||||
],
|
||||
[
|
||||
"ambiguous-bordered-detail",
|
||||
"Do you want to proceed?\n│ Current request\n──────────────────────────────────────────────────\n❯ \n──────────────────────────────────────────────────\n ⏵⏵ accept edits on (shift+tab to cycle)",
|
||||
],
|
||||
[
|
||||
"ambiguous-footer",
|
||||
"Do you want to proceed?\nEnter to select · ↑/↓ to navigate · Esc to cancel\n──────────────────────────────────────────────────\n❯ \n──────────────────────────────────────────────────\n ⏵⏵ accept edits on (shift+tab to cycle)",
|
||||
],
|
||||
[
|
||||
"selector-historical-footer",
|
||||
"Do you want to proceed?\n❯ 1. Yes\n 2. No\nEnter to select · ↑/↓ to navigate · Esc to cancel\n──────────────────────────────────────────────────\n❯ \n──────────────────────────────────────────────────\n ⏵⏵ accept edits on (shift+tab to cycle)",
|
||||
],
|
||||
[
|
||||
"live-timerless",
|
||||
"● Earlier report quoted: Do you want to proceed?\n✻ Thinking…\n──────────────────────────────────────────────────\n❯ \n──────────────────────────────────────────────────\n ⏵⏵ accept edits on (shift+tab to cycle)",
|
||||
],
|
||||
] as const;
|
||||
it.each(cases)("%s: default and strict consumers keep the permission refusal", async (_name, pane) => {
|
||||
for (const timerlessStatusIsLive of [false, true]) {
|
||||
expect(classifyPaneActivity(pane, { timerlessStatusIsLive }))
|
||||
.toMatchObject({ state: "attention", reason: "permission_prompt" });
|
||||
const f = fixture(pane);
|
||||
expect(await probeSessionActivity({ sessionName: SEAT, runtime: "claude-code", attachmentType: "tmux",
|
||||
tmuxAdapter: f.tmux, now: NOW, timerlessStatusIsLive }))
|
||||
.toMatchObject({ state: "needs_input", reason: "permission_prompt" });
|
||||
}
|
||||
const f = fixture(pane);
|
||||
expect(await f.transport.send(SEAT, "ordinary message"))
|
||||
.toMatchObject({ ok: false, reason: "target_needs_input" });
|
||||
expect(await f.transport.send(SEAT, "pane-only message", { waitForIdleMs: 100, readinessFromPaneOnly: true }))
|
||||
.toMatchObject({ ok: false, reason: "target_needs_input" });
|
||||
expect(await f.transport.waitUntilIdle(SEAT, 100))
|
||||
.toMatchObject({ ok: false, reason: "target_needs_input" });
|
||||
const service = new SeatStructuralActivityService(f.tmux, () => NOW);
|
||||
expect(await service.pollSeat(SEAT)).toMatchObject({ state: "attention", reason: "permission_prompt" });
|
||||
expect(service.getStructuralActivity(SEAT)?.state).toBe("attention");
|
||||
for (const age of [90_000, 360_000]) {
|
||||
f.hooks.recordHookEvent({ runtime: "claude-code", sessionName: SEAT, hookEvent: "Notification",
|
||||
subtype: "permission_prompt", occurredAt: new Date(NOW.getTime() - age).toISOString() });
|
||||
expect(await f.transport.send(SEAT, "ordinary message with an old hook"))
|
||||
.toMatchObject({ ok: false, reason: "target_needs_input" });
|
||||
expect(await f.transport.send(SEAT, "pane-only message with an old hook", { waitForIdleMs: 100, readinessFromPaneOnly: true }))
|
||||
.toMatchObject({ ok: false, reason: "target_needs_input" });
|
||||
}
|
||||
expect(f.sendText).not.toHaveBeenCalled(); expect(f.sendKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user