Compare commits

...
2 changed files with 94 additions and 11 deletions

No files matched your search

@@ -9,6 +9,7 @@ import {
COMPOSER_DRAFT_PATTERN,
COMPOSER_EMPTY_CODEX_PLACEHOLDER_PATTERN as CODEX_EMPTY_COMPOSER_PATTERN,
COMPOSER_EMPTY_PATTERN,
COMPOSER_PROMPT_CLASS,
COMPOSER_SELECTION_PATTERN,
COMPOSER_SELECTION_SCAN_PATTERN,
findComposerInputLineIndex,
@@ -682,7 +683,9 @@ function verificationSnippet(text: string): { snippet: string; envelope: boolean
/** Count whole visible envelope blocks, not prefixes of longer messages.
* A new envelope, prompt or same-column output ends an indented user block.
* Whitespace is compared only after retaining boundaries and quote indentation. */
function countEnvelopeOccurrences(transcript: string, expected: string): number {
function countEnvelopeOccurrences(transcript: string, expected: string, promptClass: string): number {
const promptPrefix = new RegExp(`^[${promptClass}]\\s+`);
const promptBoundary = new RegExp(`^[${promptClass}](?:\\s|$)`);
const lines = transcript.split("\n");
let count = 0;
let block: string[] | null = null;
@@ -696,8 +699,8 @@ function countEnvelopeOccurrences(transcript: string, expected: string): number
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
const indent = /^[ \t]*/.exec(line)![0].length;
const prompted = /^[❯›]\s+/.test(line.slice(indent));
const header = line.slice(indent).replace(/^[❯›]\s+/, "");
const prompted = promptPrefix.test(line.slice(indent));
const header = line.slice(indent).replace(promptPrefix, "");
const atBoundary = (!block && !inAgentOutput) || indent <= blockIndent;
const candidate = normalizeVerificationText([header, ...lines.slice(i + 1, i + 5)].join("\n"));
const envelopeHeader = /^From: [^\n]+\nTo: [^\n]+\n(?:Sent: [^\n]+\n)?---\n/.test(candidate);
@@ -714,7 +717,7 @@ function countEnvelopeOccurrences(transcript: string, expected: string): number
// quotes stay in the body, and blank separators do not change its text.
finish();
inAgentOutput = true;
} else if (atBoundary && /^[❯›](?:\s|$)/.test(line.slice(indent))) {
} else if (atBoundary && promptBoundary.test(line.slice(indent))) {
finish();
} else if (block) {
block.push(line);
@@ -727,15 +730,16 @@ function countEnvelopeOccurrences(transcript: string, expected: string): number
/** The last prompt marker owns the current input region. Never count it or its
* continuation lines as submitted transcript, in either capture. If a capture
* ends at a historical prompt with no later input marker, leave it unconfirmed. */
function verificationTranscript(content: string | null): string {
function verificationTranscript(content: string | null, promptClass: string): string {
const indentedPrompt = new RegExp(`^([ \\t]*)[${promptClass}]`);
const lines = (content ?? "").replace(/\r\n/g, "\n").split("\n");
let inputAt = lastPromptLine(lines);
let inputAt = findComposerInputLineIndex(lines, promptClass);
if (inputAt >= 0) {
let indent = /^[ \t]*/.exec(lines[inputAt]!)![0].length;
// Claude indents draft continuation lines. A quoted prompt on one of them
// belongs to the enclosing input, not a new transcript/composer boundary.
for (let i = inputAt - 1; i >= 0; i--) {
const prompt = /^([ \t]*)[❯›]/.exec(lines[i]!);
const prompt = indentedPrompt.exec(lines[i]!);
if (prompt && prompt[1]!.length < indent) {
inputAt = i;
indent = prompt[1]!.length;
@@ -2097,9 +2101,12 @@ export class SessionTransport {
// its original prefix-count verification even while a command is running.
const terminal = runtime === "terminal";
const { snippet, envelope } = terminal ? { snippet: text.slice(0, 40), envelope: false } : verificationSnippet(text);
// Keep Claude's existing verification boundaries; Codex also draws ».
const promptClass = runtime === "claude-code" ? "❯›" : COMPOSER_PROMPT_CLASS;
const transcript = terminal ? (pane: string | null) => pane ?? ""
: envelope ? verificationTranscript : (pane: string | null) => normalizeVerificationText(verificationTranscript(pane));
const count = envelope ? countEnvelopeOccurrences : countOccurrences;
: (pane: string | null) => envelope ? verificationTranscript(pane, promptClass)
: normalizeVerificationText(verificationTranscript(pane, promptClass));
const count = envelope ? (pane: string, expected: string) => countEnvelopeOccurrences(pane, expected, promptClass) : countOccurrences;
const preCount = count(transcript(preVerifyContent), snippet);
const postCount = count(transcript(content), snippet);
const verified = postCount > preCount;
+78 -2
View File
@@ -543,10 +543,10 @@ describe("SessionTransport", () => {
});
}
function seedCanonicalRig() {
function seedCanonicalRig(runtime = "claude-code") {
const rig = rigRepo.createRig("my-rig");
const node = rigRepo.addNode(rig.id, "dev.impl", {
role: "worker", runtime: "claude-code",
role: "worker", runtime,
});
const session = sessionRegistry.registerSession(node.id, "dev-impl@my-rig");
sessionRegistry.updateStatus(session.id, "running");
@@ -894,6 +894,82 @@ describe("SessionTransport", () => {
expect(result).toMatchObject({ ok: true, verified: false, outcome: "rendered-unconfirmed" });
});
it.each(["❯", "›", "»"].flatMap(glyph =>
["raw staged", "raw submitted", "envelope staged", "envelope submitted", "quoted draft", "quoted prompt in draft", "after reply", "flat transcript boundary"]
.map(mode => [glyph, mode] as const),
))("send verification recognises composer %s: %s", async (glyph, mode) => {
seedCanonicalRig(glyph === "❯" ? "claude-code" : "codex");
const message = mode.startsWith("raw") ? "Please check this task."
: "From: sender-agent@demo\nTo: dev-impl@my-rig\n---\nPlease check this task.";
const render = (text: string) => glyph + " " + text.split("\n").join("\n ");
const verified = mode === "raw submitted" || mode === "envelope submitted" || mode === "after reply" || mode === "flat transcript boundary";
let submitted = false;
const tmux = mockTmux({
sendKeys: async () => { submitted = true; return { ok: true }; },
capturePaneContent: async () => {
if (!submitted) return "Prior output\n" + glyph + " ";
if (mode === "quoted draft") return glyph + " Check this envelope:\n " + message.split("\n").join("\n ");
if (mode === "quoted prompt in draft") return render(message) + "\n " + glyph + " quoted prompt";
if (mode === "flat transcript boundary") return message + "\n" + glyph + " previous input\nAgent reply\n" + glyph + " ";
const previous = mode === "after reply"
? render(message.replace("Please check this task.", "A different task.")) + "\nAgent reply\n" : "";
return previous + render(message) + (verified ? "\n" + glyph + " " : "");
},
});
const result = await createTransport(tmux, { sleep: async () => undefined })
.send("dev-impl@my-rig", message, { verify: true });
expect(result).toMatchObject({ ok: true, verified,
outcome: verified ? "delivered" : "rendered-unconfirmed" });
});
it.each([
["raw", "same-column below draft"], ["raw", "below framed draft"],
["envelope", "same-column below draft"], ["envelope", "below framed draft"],
["envelope", "assistant quoted envelope"],
["envelope", "flat body »"], ["envelope", "flat body ›"], ["envelope", "flat body ❯"],
])("Claude verification keeps existing boundaries: %s %s", async (kind, shape) => {
const rig = rigRepo.createRig("verification");
const node = rigRepo.addNode(rig.id, "worker", { role: "worker", runtime: "claude-code" });
const address = "worker@verification";
const session = sessionRegistry.registerSession(node.id, address);
sessionRegistry.updateStatus(session.id, "running");
sessionRegistry.updateBinding(node.id, { tmuxSession: address });
let message = kind === "raw" ? "Check the blue ledger, then report its total."
: `From: sender@fixture\nTo: ${address}\nSent: 10-13 10:00Z\n---\nCheck the blue ledger.\nReport its total.\n---\n↩ Reply: rig send sender@fixture "..."`;
const render = (glyph: string, text: string) => glyph + " " + text.split("\n").join("\n ");
let post = render("❯", message) + "\n» reply quotation below live input";
if (shape === "below framed draft") post = "────────────────────\n" + render("❯", message) + "\n────────────────────\n? for shortcuts\n» later quote";
if (shape === "assistant quoted envelope") post = "❯ Different request\nAssistant reply with quotation:\n" + render("»", message) + "\n❯ ";
if (shape.startsWith("flat body")) {
message = message.replace("Check the blue ledger.", shape.slice(-1) + " Check the blue ledger.");
post = message + "\n❯ ";
}
let submitted = false;
const tmux = mockTmux({
sendKeys: async () => { submitted = true; return { ok: true }; },
capturePaneContent: async () => submitted ? post : "❯ ",
});
const result = await createTransport(tmux, { sleep: async () => undefined }).send(address, message, { verify: true });
const verified = shape === "flat body »";
expect(result).toMatchObject({ ok: true, verified,
outcome: verified ? "delivered" : "rendered-unconfirmed" });
});
it("unknown-runtime verification keeps existing boundaries", async () => {
const { node } = seedCanonicalRig();
db.prepare("UPDATE nodes SET runtime = NULL WHERE id = ?").run(node.id);
expect(db.prepare("SELECT runtime FROM nodes WHERE id = ?").get(node.id)).toEqual({ runtime: null });
const message = "Check the blue ledger, then report its total.";
let submitted = false;
const tmux = mockTmux({
sendKeys: async () => { submitted = true; return { ok: true }; },
capturePaneContent: async () => submitted ? "❯ " + message + "\n» reply quotation below live input" : "❯ ",
});
const result = await createTransport(tmux, { sleep: async () => undefined })
.send("dev-impl@my-rig", message, { verify: true });
expect(result).toMatchObject({ ok: true, verified: false, outcome: "rendered-unconfirmed" });
});
// OPR.99.0.6.3 — honest delivery-outcome vocabulary
it("verify capture throwing after a successful send is the middle outcome, not a failure", async () => {
seedCanonicalRig();