diff --git a/docs/LOCAL_SHELL_SESSION.md b/docs/LOCAL_SHELL_SESSION.md index 06763df..8225145 100644 --- a/docs/LOCAL_SHELL_SESSION.md +++ b/docs/LOCAL_SHELL_SESSION.md @@ -77,6 +77,43 @@ tools. Online account-agent and ATS chats remain separate surfaces. review again to retry. Later chat prompts do not automatically include it. Ordinary chat history still honors `AETHER_NO_HISTORY=1`. +## Manage pending console entries + +TTY type-ahead and `/queue ` use a strict memory-only FIFO with stable +local IDs such as `q3`. `/queue` or `/queue list` displays the running entry +as immutable, then every pending entry's ID, type, approval state and text. +List text is terminal-escaped for safe inspection. Shell commands remain user +shell actions; listing, editing or queueing them never turns them into chat. + +- `/queue edit ` edits a pending entry in place. Shell edits + require `!command`; a chat cannot become a shell/slash action. Active entries + and shell resets cannot be edited. Invalid edits leave the entry unchanged +- `/queue remove ` discards only that pending entry; `/queue clear` discards + all pending entries. Both are local and neither interrupts the active turn +- `/queue edit ` on a shell-share keeps the original + capture binding but revokes its old Send approval. The new exact preview is + shown. Use `/queue send ` after reviewing it; an empty selection cannot + be sent. FIFO pauses at an unapproved head rather than skipping it +- After removing a paused head, `/queue run` explicitly resumes ready pending + entries. List/edit/remove/clear never launch a model or process by themselves +- The bound is 32 pending entries and 64 KiB of UTF-8 serialized input, including + retained capture provenance. Enqueue and edits enforce both limits; oversized + edits preserve the previous entry. New rejected submissions are not queued +- Cancellation, turn/slash failure, auth-new, shell state loss, checkout/session + changes and exit discard pending work and display the discarded IDs/types and + remaining count. Discarded IDs never silently restart after auth repair or + shell reset. A separately saved failed active chat still follows explicit + authentication-repair controls; it does not contain the discarded queue +- Terminal handoff is refused while entries remain pending; run or clear them + first so an old queued command cannot inherit a reconciled terminal checkout +- Queue management and queued shell text are excluded from ordinary history. + Typing ahead is preserved while the active operation finishes or fails + +Pipe/non-TTY mode remains sequential: it finishes one input line before reading +the next. It has no editable pending queue; queue-management commands print +local guidance and never invoke a model. Use TTY mode for responsive management +while streaming. Explicit shell-preview commands work in both modes. + ## Interactive Linux terminal Use `/terminal ` in the local coding console when a program needs diff --git a/docs/generated/commands.md b/docs/generated/commands.md index 80dc662..7451bd7 100644 --- a/docs/generated/commands.md +++ b/docs/generated/commands.md @@ -1,5 +1,5 @@ - + # Generated command reference This reference is generated from the validated, versioned command manifest. Availability is evaluated at runtime; a listed command may still require authentication, a hosted capability, or local tooling. @@ -519,9 +519,9 @@ show the current local terminal ID and state Permission: `unknown` · Availability: `runtime-dependent` · Telemetry: `slash.terminal-status` -#### `/queue ` +#### `/queue [ | list | edit | remove | clear | send | run]` -queue a task \(runs when current finishes\) +inspect and manage bounded, typed pending console entries Permission: `unknown` · Availability: `runtime-dependent` · Telemetry: `slash.queue` diff --git a/docs/issue-284-validation.md b/docs/issue-284-validation.md new file mode 100644 index 0000000..bc948c7 --- /dev/null +++ b/docs/issue-284-validation.md @@ -0,0 +1,44 @@ +# Issue 284: bounded pending console management + +## Contract + +TTY pending entries have stable local IDs and retained types (chat, user shell, +shell reset, or reviewed shell-share). The active entry is immutable. List, +edit, remove and clear are local controls handled before history and ordinary +submission; malformed management syntax fails closed. Pending entries are +bounded at 32 entries and 64 KiB of UTF-8 serialized input, including retained +attachment provenance. Edits enforce the same bound and preserve position. + +A reviewed shell-share keeps its capture identity. Editing it revokes prior +Send approval and pauses strict FIFO at that entry until explicit `/queue send`. +Removal never dispatches later work; `/queue run` is the explicit ready-FIFO +resume control. Empty attachment selections cannot be approved. Failure, +cancellation, checkout/session changes, auth-new, and exit display discarded +IDs/types and pending count; discarded entries never resume after recovery. +Terminal handoff requires an empty pending queue to prevent inherited checkout +changes. Existing model-switch controls remain reachable and preserve a saved +draft without overtaking earlier queued entries. + +Pipe mode is intentionally sequential. Queue-management input prints local +TTY guidance and makes no model requests; it does not imply responsive pipe +queue editing while a prior turn is running. + +## Regression coverage + +Focused suite: 57 tests pass across console input/shell, attachment/line editor, +pending queue, and model continuation. + +- Real synthetic TTY streaming with local list/edit/remove/clear operations +- Model and process spies prove removed/cleared/cancelled entries do not execute +- Active immutability, invalid reclassification, count/UTF-8 edit bounds +- Strict mixed FIFO and paused edited attachment requiring new explicit Send +- 401, ordinary failure, active cancellation, shell loss and checkout reset +- Draft preservation, cancelled model-switch restore, and exit while paused +- Malformed pasted controls cannot become a model prompt or ordinary history +- Non-TTY local guidance; all #281 privacy/binding/line-editor regressions rerun + +All fixtures are synthetic. No paid model call, real credential, live service +qualification, branch-protection change, or CI-gate weakening is involved. +Final exact-tree aggregate/CI evidence belongs with the PR. Baseline smoke DNS, +release packet, billing, runner availability and artifact-quota failures must +be reported separately rather than relabeled as feature acceptance passes. diff --git a/src/commands/chat.ts b/src/commands/chat.ts index 509e45a..d9ab6e8 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -55,6 +55,7 @@ import { isLocalModelId, localModelId, ollamaTagFromId, resolveHostedModel, reso import type { Brain } from "../core/brain.js"; import type { RunOptions, ToolResult } from "../core/tool_executor.js"; import { ToolExecutor } from "../core/tool_executor.js"; +import { ConsoleQueue, parseQueueControl, queueKind, type QueuedInput, type QueueEntry } from "./console_queue.js"; import { ConsoleShell, classifyConsoleInput, type ConsoleInput } from "./console_input.js"; import { HostRenderer } from "../ui/host_render.js"; import type { TaskCommand } from "../core/brain.js"; @@ -1312,7 +1313,8 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P let pasting = false; let pasteAcc = ""; let carry = ""; // partial escape sequence held across chunk boundaries - const queue: ConsoleInput[] = []; + const queue = new ConsoleQueue(); + const discardQueue = (reason: string): void => { process.stdout.write(queue.clear(reason) + "\n"); }; let steering: string | null = null; const btwNotes: string[] = []; let turnAbort: AbortController | null = null; // live while a local/cloud turn runs @@ -1348,7 +1350,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P process.stdout.removeListener("resize", onResize); }; const finish = (code: number): void => { - turnAbort?.abort(); slashAbort?.abort(); queue.length = 0; + turnAbort?.abort(); slashAbort?.abort(); discardQueue("closed"); cleanup(); process.stdout.write("\n"); resolve(code); @@ -1364,7 +1366,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P turnAbort = new AbortController(); try { const result = await consoleShell.run(input, turnAbort.signal); - if (result !== "completed") queue.length = 0; + return result; } finally { turnAbort = null; } } @@ -1451,7 +1453,6 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P continuation.recordTurn(text, toolResults, afterChanges.filter(change => !beforeChanges.includes(change)), outcome.state, !sharedShellResult && !authContinuation); if (ctx.flags.json) process.stdout.write(turnOutcomeJson(outcome) + "\n"); if (outcome.state === "cancelled") { - queue.length = 0; if (!ctx.flags.json) process.stdout.write("\n" + theme.dim("✗ turn aborted") + "\n"); return "aborted"; } @@ -1460,8 +1461,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P const afterChanges = observedWorkspaceChanges(ctx.flags.cwd); continuation.recordTurn(text, toolResults, afterChanges.filter(change => !beforeChanges.includes(change)), "failed", !sharedShellResult && !authContinuation); if (isAbortError(err)) { - // User said stop: drop the queued follow-ups too. - queue.length = 0; + // The drain owner reports/discards pending follow-ups. const outcome = turnOutcomeForError(err); if (ctx.flags.json && outcome) process.stdout.write(turnOutcomeJson(outcome) + "\n"); else process.stdout.write("\n" + theme.dim("✗ turn aborted") + "\n"); @@ -1502,17 +1502,35 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P } }; - const runAndDrain = async (input: ConsoleInput, authContinuation = false): Promise => { + const runAndDrain = async (input: ConsoleInput, authContinuation = false, queued?: QueueEntry): Promise => { try { getRegistry().startAgentTimer(); - let result = await runQueuedTurn(input, authContinuation); - while (result === "completed" && queue.length > 0) { - const next = queue.shift()!; - const preview = next.kind === "chat" ? next.text : next.kind === "shell" ? "!" + next.command : next.kind === "attachment" ? "reviewed shell attachment" : "/shell-reset"; - process.stdout.write(`\n→ Queued: "${previewLine(preview)}"\n`); - result = await runQueuedTurn(next); + if (input.kind === "share" || input.kind === "empty" || input.kind === "error") return; + if (!queued) queue.start(input); + let current = input as QueuedInput; + let continuingAuth = authContinuation; + while (true) { + const sessionId = consoleShell.session.id; + const result = await runQueuedTurn(current, continuingAuth); + continuingAuth = false; + queue.finish(); + if (result !== "completed") { discardQueue(result === "aborted" ? "cancelled" : "failed"); break; } + if (consoleShell.session.id !== sessionId || consoleShell.session.state !== "ready") { + discardQueue("shell state changed"); break; + } + const next = queue.take(); + if (!next) { + if (queue.waitingForApproval) process.stdout.write(`Queue paused: ${queue.length} pending; first shell-share requires explicit /queue send . Nothing skipped.\n`); + break; + } + process.stdout.write(`\n→ Queued ${next.id} (${queueKind(next.input)})\n`); + current = next.input; } + } catch (error) { + discardQueue("operation failed"); + printError(error, ctx.cfg.baseUrl); } finally { + queue.finish(); busy = false; getRegistry().startUserTimer(); } @@ -1535,6 +1553,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P return; case "abort-turn": active!.abort(); + discardQueue("cancelled by user"); ctrlCArmedAt = now; return; case "arm-quit": @@ -1556,13 +1575,27 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P const onSubmit = async (): Promise => { const raw = buf.value; + if (!busy && queue.length && (raw.trim() === "/exit" || raw.trim() === "/quit")) { + buf.clear(); finish(0); return; + } + const control = parseQueueControl(raw); + if (control) { + buf.clear(); // Queue management (including edit bodies) is local, never history. + const result = queue.control(control); + process.stdout.write("\n" + result.message + "\n"); + if (!busy && !continuation.pending && result.drain) { + const next = queue.take(); + if (next) { busy = true; await runAndDrain(next.input, false, next); } + } + repaint(); return; + } if (continuation.pending && !raw.trim().startsWith("/switch")) { process.stdout.write("\nChoose /switch continue, fresh, cancel, brief, or edit first. Draft preserved.\n"); repaint(); return; } if (ConsoleShell.isTerminalCommand(raw)) { - if (busy) { process.stdout.write("\nWait for the current operation before terminal handoff.\n"); return; } + if (busy || queue.length) { process.stdout.write("\nFinish the active operation and run or clear pending entries before terminal handoff.\n"); return; } buf.clear(); // Explicit terminal commands never enter model/history. busy = true; terminalOwnsInput = true; try { await consoleShell.terminalCommand(raw); } @@ -1585,12 +1618,13 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P let t = input.kind === "chat" || input.kind === "attachment" ? input.text : ""; if (t && heldDraft === t && !t.startsWith("/auth")) heldDraft = null; if (input.kind !== "chat" && input.kind !== "empty") { - buf.clear(); // shell commands never enter chat history or prompt context - if (busy) { - queue.push(input); - process.stdout.write(`\n⏳ Local shell queued (${queue.length}).\n`); + if (busy || queue.length) { + const added = queue.enqueue(input); + process.stdout.write(typeof added === "string" ? added + "\n" : `\n⏳ Local shell queued ${added.id} (${queue.length}; ${queueKind(added.input)}).\n`); + if (typeof added !== "string") buf.clear(); return; } + buf.clear(); // shell commands never enter chat history or prompt context process.stdout.write("\n"); busy = true; await runAndDrain(input); @@ -1598,7 +1632,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P return; } // ── mid-turn Enter: bypass commands + type-ahead queueing ── - if (busy) { + if (busy || (queue.length && !t.startsWith("/switch"))) { if (t.startsWith("/steer ")) { steering = t.slice(7).trim() || steering; remember(buf.value); @@ -1617,11 +1651,11 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P return; } if (t.startsWith("/queue ")) t = t.slice(7).trim(); - remember(buf.value); - buf.commit(buf.value); - if (!t || t.startsWith("/")) return; // other slashes wait for the turn - queue.push({ kind: "chat", text: t }); - process.stdout.write(`\n⏳ Queued (${queue.length}): "${previewLine(t)}"\n`); + if (!t || t.startsWith("/")) { buf.clear(); return; } // other slashes wait for the turn + const added = queue.enqueue({ kind: "chat", text: t }); + if (typeof added === "string") { process.stdout.write(added + "\n"); return; } + remember(buf.value); buf.commit(buf.value); + process.stdout.write(`\n⏳ Queued ${added.id} (${queue.length}; chat): "${previewLine(t)}"\n`); return; } @@ -1725,7 +1759,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P } else process.stdout.write("No safely rejected task is ready. Use /auth status for details.\n"); } else if (sub === "new") { process.stdout.write(authRepair.startNewConversation()); - queue.length = 0; steering = null; btwNotes.length = 0; + discardQueue("new conversation"); steering = null; btwNotes.length = 0; } else if (sub === "draft") { if (buf.value) process.stdout.write("Current draft is still in the input line; clear it before restoring the earlier draft.\n"); else if (!heldDraft) process.stdout.write("No earlier type-ahead draft is saved.\n"); @@ -1746,11 +1780,11 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P try { const res = await handleSlash(ctx, t, process.stdout, slashAbort.signal); if (res.exit) { - cleanup(); - resolve(0); + finish(0); return; } if (res.restart) { + discardQueue("session restarted"); applyRestart(ctx.flags, res.restart); process.stdout.write(theme.dim("session restarted — context cleared.\n")); } @@ -1767,9 +1801,10 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P } } catch (err) { if (isAbortError(err)) { - queue.length = 0; + discardQueue("cancelled"); process.stdout.write(theme.dim("✗ canceled") + "\n"); } else { + discardQueue("command failed"); printError(err, ctx.cfg.baseUrl); } } finally { @@ -1778,7 +1813,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P busy = false; slashAbort = null; } - if (queue.length && !continuation.pending) { busy = true; await runAndDrain(queue.shift()!); } + if (queue.length && !continuation.pending) { const next = queue.take(); if (next) { busy = true; await runAndDrain(next.input, false, next); } } renderHudLine(); repaint(); return; @@ -2038,6 +2073,11 @@ export async function replLines(ctx: AppContext, skillOpts: TurnSkillOptions = { try { if (p) process.stdout.write(p + consoleShell.prompt()); for await (const line of rl) { + if (parseQueueControl(line)) { + process.stdout.write("Line mode runs inputs sequentially; there is no editable pending queue. Use a TTY for /queue list/edit/remove/clear/send. Nothing sent.\n"); + if (p) process.stdout.write(p + consoleShell.prompt()); + continue; + } if (continuation.pending && !line.trim().startsWith("/switch")) { process.stdout.write("Choose /switch continue, fresh, cancel, brief, or edit first; input was not sent.\n"); if (p) process.stdout.write(p + consoleShell.prompt()); diff --git a/src/commands/command_manifest_data.ts b/src/commands/command_manifest_data.ts index dbc4ee2..afc2b68 100644 --- a/src/commands/command_manifest_data.ts +++ b/src/commands/command_manifest_data.ts @@ -4290,9 +4290,9 @@ export const COMMAND_MANIFEST_SOURCE: readonly CommandManifestEntry[] = [ "aliases": [], "compatibilityAliases": [], "deprecatedAliases": [], - "args": "", - "summary": "queue a task (runs when current finishes)", - "detailedHelp": "/queue \nqueue a task (runs when current finishes)", + "args": "[ | list | edit | remove | clear | send | run]", + "summary": "inspect and manage bounded, typed pending console entries", + "detailedHelp": "/queue [ | list | edit | remove | clear | send | run]\nTTY: manage pending entries locally while streaming. Active entries are immutable. Shell edits need !command; edited shell-shares require explicit send. Bound: 32 entries and 64 KiB. Failure/cancel discards pending IDs. Pipes execute sequentially without an editable queue.", "section": "Steering", "hidden": false, "permissionClass": "unknown", @@ -4314,13 +4314,13 @@ export const COMMAND_MANIFEST_SOURCE: readonly CommandManifestEntry[] = [ "module": "src/commands/command_manifest_data.ts", "symbol": "COMMAND_MANIFEST_SOURCE", "target": "queue", - "usage": "/queue ", + "usage": "/queue [ | list | edit | remove | clear | send | run]", "visible": true, "disposition": "generated" }, "release": { - "disposition": "existing", - "note": null + "disposition": "changed", + "note": "Bounded typed pending queue with local management, explicit edited-attachment approval, and discard disposition." } }, { diff --git a/src/commands/console_input.ts b/src/commands/console_input.ts index d8edbea..d02b6de 100644 --- a/src/commands/console_input.ts +++ b/src/commands/console_input.ts @@ -135,7 +135,7 @@ export class ConsoleShell { const visible = streamed ? result.output.split("\n", 1)[0]! : result.output; this.write(this.json ? JSON.stringify({ type: "shell_result", sessionId: this.session.id, ...result }) + "\n" : sanitizeServerText(visible) + "\n"); // A normal nonzero exit returns to chat and may drain later submissions. - return result.exitCode === 130 ? "aborted" : this.session.state === "lost" ? "failed" : "completed"; + return result.exitCode === 130 ? "aborted" : (this.session.state === "lost" || this.session.id !== captureSession) ? "failed" : "completed"; } private shareNotice(message: string, code: string): void { this.write(this.json ? JSON.stringify({ type: "shell_share", code, message }) + "\n" : message + "\n"); @@ -151,9 +151,9 @@ export class ConsoleShell { return typeof sent === "string" ? { kind: "error", message: sent } : sent; } if (input.action === "cancel") { - this.attachmentPreview.cancel(); + const cancelled = this.attachmentPreview.cancel(); this.scriptSendAvailable = false; - this.shareNotice("Shell preview cancelled; nothing sent.", "cancelled"); + this.shareNotice(cancelled ? "Shell preview cancelled; nothing sent." : "No unsent shell preview. Use /queue list to inspect any already-submitted attachment.", "cancelled"); return { kind: "empty" }; } let preview = this.attachmentPreview.preview(this.result); diff --git a/src/commands/console_queue.ts b/src/commands/console_queue.ts new file mode 100644 index 0000000..6752f7d --- /dev/null +++ b/src/commands/console_queue.ts @@ -0,0 +1,126 @@ +import { classifyConsoleInput, type ConsoleInput } from "./console_input.js"; +import { sanitizeShellAttachment, shellAttachment, SHELL_ATTACHMENT_BODY_BYTES } from "./shell_attachment.js"; +import { terminalSafeReview } from "../core/tool_approval.js"; + +export const CONSOLE_QUEUE_MAX_ENTRIES = 32; +export const CONSOLE_QUEUE_MAX_BYTES = 64 * 1024; +export type QueuedInput = Extract; +export interface QueueEntry { + readonly id: string; + readonly input: QueuedInput; + readonly approved: boolean; +} +export type QueueControl = { action: "list" | "clear" | "remove" | "edit" | "send" | "run" | "invalid"; id?: string; text?: string }; + +/** Parse management before the legacy /queue prefix or chat history. */ +export function parseQueueControl(raw: string): QueueControl | null { + const match = /^\s*\/queue(?:\s+(list|clear|remove|edit|send|run)(?:[ \t]+(\S+))?(?:[ \t]+([\s\S]*))?)?\s*$/.exec(raw); + if (!match) return /^\s*\/queue\s+(?:list|clear|remove|edit|send|run)(?:\s|$)/.test(raw) ? { action: "invalid" } : null; + if (match[3] && !match[2]) return { action: "invalid" }; + return { action: (match[1] ?? "list") as QueueControl["action"], ...(match[2] ? { id: match[2] } : {}), ...(match[3] !== undefined ? { text: match[3] } : {}) }; +} + +export function queueKind(input: QueuedInput): string { + return input.kind === "shell" ? "user shell" : input.kind === "attachment" ? "shell-share" : input.kind === "reset-shell" ? "shell reset" : "chat"; +} + +// Include retained provenance/body copies, not just the model-facing selection. +function bytes(input: QueuedInput): number { return Buffer.byteLength(JSON.stringify(input), "utf8"); } +function entry(id: string, input: QueuedInput, approved = true): QueueEntry { + return Object.freeze({ id, input: Object.freeze({ ...input }), approved }); +} + +/** Bounded, memory-only FIFO. Active entries are never editable or removable. */ +export class ConsoleQueue { + private pending: QueueEntry[] = []; + private nextId = 1; + active: QueueEntry | null = null; + constructor(readonly maxEntries = CONSOLE_QUEUE_MAX_ENTRIES, readonly maxBytes = CONSOLE_QUEUE_MAX_BYTES) {} + get length(): number { return this.pending.length; } + get byteLength(): number { return this.pending.reduce((total, item) => total + bytes(item.input), 0); } + get waitingForApproval(): boolean { return this.pending[0]?.approved === false; } + list(): readonly QueueEntry[] { return [...this.pending]; } + enqueue(input: QueuedInput): QueueEntry | string { + if (this.length >= this.maxEntries || this.byteLength + bytes(input) > this.maxBytes) return `Queue full: maximum ${this.maxEntries} pending entries / ${this.maxBytes} UTF-8 bytes. Submission not queued; remove or clear pending entries.`; + const added = entry(`q${this.nextId++}`, input); + this.pending.push(added); + return added; + } + start(input: QueuedInput): QueueEntry { return this.active = entry(`q${this.nextId++}`, input); } + /** Stop at an unapproved head; never skip/reorder it to execute later work. */ + take(): QueueEntry | null { + if (!this.pending[0]?.approved) return null; + return this.active = this.pending.shift()!; + } + finish(): void { this.active = null; } + private index(id: string): number { return this.pending.findIndex(item => item.id === id); } + private missing(id: string): string { + return this.active?.id === id ? `${id} is active and immutable; use Ctrl+C to cancel the active operation.` : `No pending entry ${id}.`; + } + remove(id: string): string { + const index = this.index(id); + if (index < 0) return this.missing(id); + const [removed] = this.pending.splice(index, 1); + return `Discarded ${removed!.id} (${queueKind(removed!.input)}); ${this.length} pending. Nothing executed.`; + } + clear(reason: string): string { + const discarded = this.pending.map(item => `${item.id} (${queueKind(item.input)})`); + this.pending = []; + return `Queue ${reason}: discarded ${discarded.length ? discarded.join(", ") : "none"}; 0 pending. No discarded entry will resume.`; + } + edit(id: string, text: string): string { + const index = this.index(id); + if (index < 0) return this.missing(id); + const old = this.pending[index]!; + let replacement: QueuedInput; + if (old.input.kind === "attachment") { + const body = sanitizeShellAttachment(text); + if (Buffer.byteLength(body) > SHELL_ATTACHMENT_BODY_BYTES) return `Edit refused: attachment body exceeds ${SHELL_ATTACHMENT_BODY_BYTES} UTF-8 bytes; pending entry unchanged.`; + replacement = shellAttachment(old.input.capture, body, true); + } else { + const parsed = classifyConsoleInput(text); + if (old.input.kind === "reset-shell" || parsed.kind !== old.input.kind || (parsed.kind === "chat" && parsed.text.startsWith("/"))) return `Edit refused: ${id} must remain ${queueKind(old.input)} (shell edits require !command); entry unchanged.`; + replacement = parsed as QueuedInput; + } + if (this.byteLength - bytes(old.input) + bytes(replacement) > this.maxBytes) return `Edit refused: queue exceeds ${this.maxBytes} UTF-8 bytes; pending entry unchanged.`; + this.pending[index] = entry(id, replacement, replacement.kind !== "attachment"); + return replacement.kind === "attachment" + ? `Updated ${id} in place; old send approval revoked. Review this exact attachment, then /queue send ${id}:\n${replacement.text}` + : `Updated ${id} (${queueKind(replacement)}) in place; ${this.length} pending.`; + } + approve(id: string): string { + const index = this.index(id); + if (index < 0) return this.missing(id); + const old = this.pending[index]!; + if (old.input.kind !== "attachment") return `${id} is not a shell-share entry.`; + if (!old.input.body.trim()) return `Empty shell selection ${id} remains unsent; use /queue remove ${id}.`; + this.pending[index] = entry(id, old.input); + return `Reviewed attachment ${id} approved for its existing FIFO position.`; + } + describe(): string { + const active = this.active ? `${this.active.id} (${queueKind(this.active.input)}, immutable)` : "none"; + const lines = [`Active: ${active}. Pending: ${this.length}/${this.maxEntries}, ${this.byteLength}/${this.maxBytes} UTF-8 bytes.`]; + for (const item of this.pending) { + const input = item.input; + const text = input.kind === "chat" || input.kind === "attachment" ? input.text : input.kind === "shell" ? "!" + input.command : "/shell-reset"; + lines.push(`${item.id} | ${queueKind(input)} | ${item.approved ? "ready" : "awaiting explicit /queue send"}${input.kind === "attachment" ? " | bound capture " + input.capture.id : ""}\n${terminalSafeReview(text)}`); + } + return lines.join("\n") + "\n"; + } + control(command: QueueControl): { message: string; drain: boolean } { + const local = (message: string) => ({ message, drain: false }); + if (command.action === "invalid") return local("Invalid queue control; use /queue list, edit , remove , clear, send , or run. Nothing queued."); + if (command.action === "list" || command.action === "clear" || command.action === "run") { + if (command.id || command.text) return local(`usage: /queue ${command.action}`); + if (command.action === "run") return { message: this.waitingForApproval ? "Queue remains paused: first shell-share needs explicit /queue send ." : "Running ready pending entries in FIFO order.", drain: true }; + return local(command.action === "list" ? this.describe() : this.clear("cleared by user")); + } + if (!command.id || (command.action !== "edit" && command.text)) return local("usage: /queue remove|send ; /queue edit "); + if (command.action === "remove") return local(this.remove(command.id)); + if (command.action === "send") { + const input = this.pending[this.index(command.id)]?.input; + return { message: this.approve(command.id), drain: input?.kind === "attachment" && Boolean(input.body.trim()) }; + } + return local(this.edit(command.id, command.text ?? "")); + } +} diff --git a/src/commands/shell_attachment.ts b/src/commands/shell_attachment.ts index bbcbee7..f6622cc 100644 --- a/src/commands/shell_attachment.ts +++ b/src/commands/shell_attachment.ts @@ -84,7 +84,8 @@ export class ShellAttachmentPreview { } return this.edit(this.pending.body); } - cancel(): void { this.pending = null; } + cancel(): boolean { const existed = this.pending !== null; this.pending = null; return existed; } + send(): ShellAttachment | string { const pending = this.pending; this.pending = null; diff --git a/test/console_input.test.ts b/test/console_input.test.ts index 3812446..1c9871f 100644 --- a/test/console_input.test.ts +++ b/test/console_input.test.ts @@ -216,8 +216,8 @@ for (const tty of [false, true]) for (const failure of [0, 401, 500]) { test("console submit routing remains before history/rewrite and uses typed queue", () => { const code = readFileSync("src/commands/chat.ts", "utf8"); - assert.ok(code.includes("const queue: ConsoleInput[]")); + assert.ok(code.includes("const queue = new ConsoleQueue()")); assert.ok(code.indexOf("classifyConsoleInput(queuePrefix") < code.indexOf("const commit =")); - assert.ok(code.includes("result = await runQueuedTurn(next)")); + assert.ok(code.includes("const result = await runQueuedTurn(current, continuingAuth)")); assert.ok(code.includes("appendHistory(line.trim(), historyPath(ctx.flags.cwd))"), "save the original escaped input, not its model prompt"); }); diff --git a/test/console_queue.test.ts b/test/console_queue.test.ts new file mode 100644 index 0000000..499262a --- /dev/null +++ b/test/console_queue.test.ts @@ -0,0 +1,91 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ConsoleQueue, parseQueueControl } from "../src/commands/console_queue.js"; +import { captureShellResult, shellAttachment } from "../src/commands/shell_attachment.js"; + +test("queue keeps stable typed IDs, FIFO order, and an immutable active entry", () => { + const queue = new ConsoleQueue(); + const active = queue.start({ kind: "chat", text: "active" }); + const shell = queue.enqueue({ kind: "shell", command: "echo before" }); + const chat = queue.enqueue({ kind: "chat", text: "after" }); + assert.notEqual(typeof shell, "string"); assert.notEqual(typeof chat, "string"); + if (typeof shell === "string" || typeof chat === "string") return; + assert.match(queue.edit(active.id, "changed"), /active and immutable/); + assert.match(queue.remove(active.id), /active and immutable/); + assert.equal(queue.active?.input.kind, "chat"); + assert.match(queue.edit(shell.id, "!echo edited"), /Updated/); + assert.equal(queue.list()[0]?.id, shell.id); + assert.match(queue.edit(shell.id, "accidental chat"), /Edit refused/); + assert.match(queue.edit(chat.id, "!echo accidental shell"), /Edit refused/); + assert.match(queue.edit(chat.id, "/shell-result send"), /Edit refused/); + assert.equal(queue.take()?.id, shell.id); + assert.deepEqual(queue.active?.input, { kind: "shell", command: "echo edited" }); + assert.equal(queue.take()?.id, chat.id); + assert.equal(queue.take(), null); +}); + +test("remove/clear are local and cannot resurrect discarded entries", () => { + const queue = new ConsoleQueue(); + const first = queue.enqueue({ kind: "shell", command: "never run" }); + const second = queue.enqueue({ kind: "chat", text: "never sent" }); + if (typeof first === "string" || typeof second === "string") throw new Error("unexpected limit"); + assert.match(queue.remove(first.id), /Nothing executed/); + assert.match(queue.clear("failed"), new RegExp(second.id)); + assert.equal(queue.length, 0); assert.equal(queue.byteLength, 0); + assert.equal(queue.take(), null); + assert.match(queue.remove(first.id), /No pending/); + assert.match(queue.approve(second.id), /No pending/); +}); + +test("queue count and UTF-8 byte limits include edits and retained attachment provenance", () => { + const queue = new ConsoleQueue(2, 100); + const first = queue.enqueue({ kind: "chat", text: "small" }); + if (typeof first === "string") throw new Error(first); + assert.equal(typeof queue.enqueue({ kind: "chat", text: "😀".repeat(100) }), "string"); + assert.match(queue.edit(first.id, "😀".repeat(100)), /Edit refused/); + assert.deepEqual(queue.list()[0]?.input, { kind: "chat", text: "small" }); + queue.enqueue({ kind: "chat", text: "second" }); + assert.equal(typeof queue.enqueue({ kind: "chat", text: "third" }), "string"); + const source = captureShellResult("s", "c", "metadata", "x".repeat(7000), 0); + assert.equal(typeof new ConsoleQueue(32, 1000).enqueue(shellAttachment(source, "tiny edit", true)), "string"); +}); + +test("editing a reviewed shell-share revokes consent and blocks FIFO until explicit send", () => { + const queue = new ConsoleQueue(); + const source = captureShellResult("sessionA", "commandA", "private cwd", "resultA", 12); + const original = shellAttachment(source); + const first = queue.enqueue(original); + const second = queue.enqueue({ kind: "shell", command: "echo later" }); + if (typeof first === "string" || typeof second === "string") throw new Error("unexpected limit"); + assert.match(queue.edit(first.id, "edited resultA"), /old send approval revoked/); + assert.equal(queue.take(), null); + assert.equal(queue.waitingForApproval, true); + assert.match(queue.describe(), /awaiting explicit/); + assert.match(queue.approve(first.id), /approved/); + const sent = queue.take()!; + assert.equal(sent.id, first.id); + assert.equal(sent.input.kind, "attachment"); + if (sent.input.kind === "attachment") { + assert.equal(sent.input.capture, source); + assert.ok(sent.input.text.endsWith("edited resultA")); + assert.notEqual(sent.input, original); + } + assert.equal(queue.take()?.id, second.id); + const empty = queue.enqueue(original); + if (typeof empty === "string") throw new Error(empty); + queue.edit(empty.id, ""); + assert.match(queue.approve(empty.id), /Empty shell selection/); + assert.equal(queue.take(), null); +}); + +test("queue operations parse before the legacy task prefix, with safe local rendering", () => { + assert.deepEqual(parseQueueControl("/queue"), { action: "list" }); + assert.deepEqual(parseQueueControl("/queue edit q1 !echo hello"), { action: "edit", id: "q1", text: "!echo hello" }); + assert.equal(parseQueueControl("/queue ordinary task"), null); + assert.deepEqual(parseQueueControl("/queue edit q1\n!echo never"), { action: "invalid" }); + const queue = new ConsoleQueue(); + queue.enqueue({ kind: "shell", command: "echo \x1b]52;c;fixture\x07" }); + assert.doesNotMatch(queue.describe(), /\x1b|\x07/); + assert.match(queue.describe(), /user shell/); + assert.match(queue.control({ action: "remove" }).message, /usage/); +}); diff --git a/test/console_shell.test.ts b/test/console_shell.test.ts index 1ded099..91a60f1 100644 --- a/test/console_shell.test.ts +++ b/test/console_shell.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { PassThrough } from "node:stream"; +import { spawnSync } from "node:child_process"; import { mkdtempSync, mkdirSync, readFileSync, rmSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -12,7 +13,7 @@ import type { AppContext } from "../src/core/context.js"; import type { TokenStore } from "../src/core/auth.js"; import type { Brain, TaskCommand } from "../src/core/brain.js"; import type { BrainEvent } from "../src/core/brain_protocol.js"; -import type { ToolResult } from "../src/core/tool_executor.js"; +import { ToolExecutor, type ToolResult } from "../src/core/tool_executor.js"; import { historyPath } from "../src/core/history_store.js"; const supported = process.platform === "linux" || process.platform === "darwin"; @@ -43,9 +44,12 @@ test("line console shell commands make zero model calls, keep output out of prom process.stdout.write = ((text: string | Uint8Array) => { output += String(text); return true; }) as typeof process.stdout.write; try { const run = replLines(context(root), { noSkills: true }, shell, input); - input.write("!cd subdir\n!export DEMO=local-only\n!printf '%s' \"$DEMO\" | cat\n!false\n!pwd\n!\n"); + input.write("!cd subdir\n!export DEMO=local-only\n!printf '%s' \"$DEMO\" | cat\n!false\n!pwd\n/queue list\n/queue edit q1 !echo never\n/queue clear\n!\n"); while (!output.includes("usage: !")) await new Promise(resolve => setTimeout(resolve, 5)); assert.equal(bodies.length, 0); + input.write("/queue list\n/queue edit q1 !echo never\n/queue clear\n"); + while (!output.includes("there is no editable pending queue")) await new Promise(resolve => setTimeout(resolve, 5)); + assert.equal(bodies.length, 0); input.end("a normal question\n/exit\n"); assert.equal(await run, 0); assert.equal(bodies.length, 1); @@ -112,10 +116,16 @@ test("user command does not authorize model commands; an approved local model us } finally { globalThis.fetch = oldFetch; shell.close(); rmSync(root, { recursive: true, force: true }); } }); -test("raw TTY queues shell while a model turn is busy, preserves the draft and never sends shell input to the model", { skip: !supported }, async () => { +test("raw TTY manages pending chat/shell during a model stream, preserves draft, and executes only edited FIFO entries", { skip: !supported }, async () => { const root = mkdtempSync(join(tmpdir(), "aether-console-tty-")); const oldFetch = globalThis.fetch; const oldWrite = process.stdout.write; + const originalRun = ToolExecutor.prototype.runUserCommand; + const executed: string[] = []; + ToolExecutor.prototype.runUserCommand = async function (this: ToolExecutor, command, options) { + executed.push(command); + return await originalRun.call(this, command, options); + }; const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); const raw = Object.getOwnPropertyDescriptor(process.stdin, "setRawMode"); let output = ""; @@ -148,23 +158,59 @@ test("raw TTY queues shell while a model turn is busy, preserves the draft and n submit("model question"); await until(() => release !== null); submit("!printf queued-once >> queued.txt"); + submit("!printf removed > removed.txt"); + submit("removed chat must never reach model"); + submit("/queue list"); + await until(() => output.includes("Pending: 3/32")); + const ids = [...output.matchAll(/(q\d+) \| (?:user shell|chat) \| ready/g)].map(match => match[1]!); + assert.equal(ids.length, 3); + const activeId = /Active: (q\d+) \(chat, immutable\)/.exec(output)![1]!; + submit(`/queue edit ${activeId} cannot change active`); + submit(`/queue edit ${ids[0]} accidental chat reclassification`); + submit(`/queue edit ${ids[0]} !printf queued-edited >> queued.txt`); + submit(`/queue remove ${ids[1]}`); + submit(`/queue remove ${ids[2]}`); + submit("!printf cleared > cleared.txt"); + submit("/queue list"); + await until(() => output.includes("Pending: 2/32")); + const lastId = [...output.matchAll(/(q\d+) \| user shell \| ready/g)].at(-1)![1]!; + submit(`/queue remove ${lastId}`); + assert.equal(bodies.length, 1, "all management remains local while streaming"); + assert.match(output, /active and immutable/); + assert.match(output, /Edit refused/); process.stdin.emit("data", Buffer.from("new draft")); assert.equal(existsSync(join(root, "queued.txt")), false); release!(); release = null; await until(() => output.match(/"type":"shell_result"/g)?.length === 2); await new Promise(resolve => setImmediate(resolve)); - assert.equal(readFileSync(join(root, "queued.txt"), "utf8"), "queued-once"); + assert.equal(readFileSync(join(root, "queued.txt"), "utf8"), "queued-edited"); + assert.equal(existsSync(join(root, "removed.txt")), false, "removed shell made zero process calls"); + assert.equal(existsSync(join(root, "cleared.txt")), false); + assert.equal(executed.filter(command => /removed\.txt|cleared\.txt/.test(command)).length, 0, "process spy: removed shell entries never reach execution"); assert.equal(bodies.length, 1); - assert.doesNotMatch(bodies[0]!, /queued-once|user-first|new draft/); + assert.doesNotMatch(bodies[0]!, /queued-once|queued-edited|user-first|new draft|removed chat/); assert.match(output, /new draft/); // Clear the retained draft before leaving, then exercise shell cancellation. process.stdin.emit("data", Buffer.from("\x03")); submit("!sleep 1; touch never-replayed"); await until(() => output.includes('"command":"sleep 1; touch never-replayed"')); + submit("!touch cleared-by-user"); + submit("cleared chat by user"); + submit("/queue clear"); + await until(() => output.includes("Queue cleared by user: discarded")); + assert.equal(existsSync(join(root, "cleared-by-user")), false); + assert.equal(bodies.length, 1); + submit("!touch discarded-after-cancel"); + submit("discarded chat after cancel"); + submit("/queue list"); + await until(() => output.includes("discarded chat after cancel")); process.stdin.emit("data", Buffer.from("\x03")); await until(() => output.includes('"state":"cancelled"')); await new Promise(resolve => setImmediate(resolve)); assert.equal(existsSync(join(root, "never-replayed")), false); + assert.equal(existsSync(join(root, "discarded-after-cancel")), false); + assert.equal(executed.filter(command => /cleared-by-user|discarded-after-cancel/.test(command)).length, 0, "process spy: cleared/cancelled entries never reach execution"); + assert.match(output, /Queue cancelled by user: discarded q\d+ \(user shell\), q\d+ \(chat\); 0 pending/); submit("/shell-reset"); await until(() => output.includes('"type":"shell_reset"')); await new Promise(resolve => setImmediate(resolve)); @@ -177,6 +223,7 @@ test("raw TTY queues shell while a model turn is busy, preserves the draft and n process.stdin.emit("data", Buffer.from("\x03\x03\x04")); await Promise.race([pending.catch(() => {}), new Promise(resolve => setTimeout(resolve, 1000))]); } + ToolExecutor.prototype.runUserCommand = originalRun; globalThis.fetch = oldFetch; process.stdout.write = oldWrite; if (tty) Object.defineProperty(process.stdin, "isTTY", tty); else delete (process.stdin as unknown as { isTTY?: boolean }).isTTY; if (raw) Object.defineProperty(process.stdin, "setRawMode", raw); else delete (process.stdin as unknown as { setRawMode?: unknown }).setRawMode; @@ -218,3 +265,125 @@ test("local model receives only the explicitly sent exact edited shell attachmen shell.close(); input.destroy(); rmSync(historyPath(root), { force: true }); rmSync(root, { recursive: true, force: true }); } }); + +for (const mode of ["edit-share", "failure", "auth-failure", "shell-loss", "checkout-change", "exit-paused"] as const) { + test(`TTY queue ${mode}: explicit disposition and no silent pending replay`, { skip: !supported }, async () => { + const root = mkdtempSync(join(tmpdir(), "aether-queue-state-")); + if (mode === "checkout-change") { + assert.equal(spawnSync("git", ["init", "-q"], { cwd: root }).status, 0); + assert.equal(spawnSync("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--allow-empty", "-qm", "fixture"], { cwd: root }).status, 0); + } + const oldFetch = globalThis.fetch; + const oldWrite = process.stdout.write; + const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + const raw = Object.getOwnPropertyDescriptor(process.stdin, "setRawMode"); + const bodies: string[] = []; + let output = ""; + let release: (() => void) | null = null; + let pending: Promise | null = null; + const until = async (predicate: () => boolean): Promise => { + const deadline = Date.now() + 8000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("queue test timed out: " + output.slice(-2500)); + await new Promise(resolve => setTimeout(resolve, 5)); + } + await new Promise(resolve => setImmediate(resolve)); + }; + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdin, "setRawMode", { value: () => process.stdin, configurable: true }); + process.stdout.write = ((text: string | Uint8Array) => { output += String(text); return true; }) as typeof process.stdout.write; + globalThis.fetch = (async (url, init) => { + if (!String(url).includes("/agent/chat/stream")) return Response.json({ account_id: "fixture-account", models: [] }); + bodies.push(String(init?.body ?? "")); + await new Promise(resolve => { release = resolve; }); + if (mode === "failure" || mode === "auth-failure") return Response.json({ detail: "fixture failure" }, { status: mode === "failure" ? 500 : 401 }); + return turnResponse(); + }) as typeof fetch; + const submit = (text: string): void => { process.stdin.emit("data", Buffer.from(text + "\r")); }; + const resume = (): void => { const callback = release; release = null; callback!(); }; + try { + pending = repl(context(root), { noSkills: true }); + await until(() => output.includes("\x1b[?2004h")); + submit("!printf original-share"); + await until(() => output.includes('"type":"shell_result"')); + if (mode === "edit-share" || mode === "exit-paused") { submit("/shell-result"); await until(() => output.includes('"type":"shell_share_preview"')); } + submit("active chat"); + await until(() => release !== null); + if (mode === "edit-share" || mode === "exit-paused") { + submit("/shell-result send"); + submit("!printf following >> following.txt"); + submit("/queue list"); + await until(() => output.includes("shell-share | ready")); + const id = /(q\d+) \| shell-share \| ready/.exec(output)![1]!; + submit(`/queue edit ${id} EDITED_APPROVAL_REQUIRED`); + assert.match(output, /old send approval revoked/); + resume(); + await until(() => output.includes("Queue paused: 2 pending")); + assert.equal(bodies.length, 1, "editing never inherits old send approval"); + assert.equal(existsSync(join(root, "following.txt")), false, "FIFO cannot skip unapproved head"); + submit("/terminal-status"); + assert.match(output, /run or clear pending entries before terminal handoff/); + process.stdin.emit("data", Buffer.from("\x03")); // Terminal refusal preserves the entered command. + submit("/queue send nonexistent"); + assert.equal(bodies.length, 1, "invalid management cannot drain pending work"); + if (mode === "exit-paused") { + submit("/exit"); + assert.equal(await pending, 0); pending = null; + assert.equal(bodies.length, 1); + assert.equal(existsSync(join(root, "following.txt")), false); + assert.match(output, /Queue closed: discarded q\d+ \(shell-share\), q\d+ \(user shell\); 0 pending/); + return; + } + submit(`/queue send ${id}`); + await until(() => bodies.length === 2 && release !== null); + assert.match(bodies[1]!, /EDITED_APPROVAL_REQUIRED/); + assert.doesNotMatch(bodies[1]!, /original-share/); + resume(); + await until(() => existsSync(join(root, "following.txt"))); + await until(() => output.match(/"type":"shell_result"/g)?.length === 2); + assert.equal(readFileSync(join(root, "following.txt"), "utf8"), "following"); + } else { + if (mode === "shell-loss") submit("!exit"); + if (mode === "checkout-change") submit("!git switch -c changed-checkout"); + submit("!touch never-after-failure"); + submit("pending chat never submitted"); + submit("/queue list"); + await until(() => output.includes("pending chat never submitted")); + process.stdin.emit("data", Buffer.from("unsent draft")); + resume(); + await until(() => output.includes("Queue failed: discarded")); + assert.match(output, /discarded q\d+ \(user shell\), q\d+ \(chat\); 0 pending/); + assert.equal(existsSync(join(root, "never-after-failure")), false); + assert.equal(bodies.length, 1, "failure never drains or retries pending work"); + assert.match(output, /unsent draft/); + process.stdin.emit("data", Buffer.from("\x03")); + submit("/queue run"); + assert.equal(bodies.length, 1, "explicit run cannot resurrect discarded work"); + if (mode === "auth-failure") { + submit("/auth new"); + await until(() => output.includes("Queue new conversation")); + process.stdin.emit("data", Buffer.from("\x03")); // Existing auth repair restores its separately held draft. + submit("/queue list"); + assert.equal(bodies.length, 1, "new auth conversation never replays pending work"); + } + if (mode === "shell-loss") { + submit("/shell-reset"); + await until(() => output.includes('"type":"shell_reset"')); + assert.equal(existsSync(join(root, "never-after-failure")), false); + } + } + submit("/exit"); + assert.equal(await pending, 0); pending = null; + } finally { + (release as (() => void) | null)?.(); + if (pending) { + process.stdin.emit("data", Buffer.from("\x03\x03\x04")); + await Promise.race([pending.catch(() => {}), new Promise(resolve => setTimeout(resolve, 1000))]); + } + globalThis.fetch = oldFetch; process.stdout.write = oldWrite; + if (tty) Object.defineProperty(process.stdin, "isTTY", tty); else delete (process.stdin as unknown as { isTTY?: boolean }).isTTY; + if (raw) Object.defineProperty(process.stdin, "setRawMode", raw); else delete (process.stdin as unknown as { setRawMode?: unknown }).setRawMode; + rmSync(historyPath(root), { force: true }); rmSync(root, { recursive: true, force: true }); + } + }); +} diff --git a/test/model_continuation.test.ts b/test/model_continuation.test.ts index 915dbf7..04b66ce 100644 --- a/test/model_continuation.test.ts +++ b/test/model_continuation.test.ts @@ -264,11 +264,13 @@ test("raw console cancellation restores a draft typed while the model catalog lo await until(() => output.slice(output.lastIndexOf("Model switch cancelled")).includes(" draft")); await new Promise(resolve => setImmediate(resolve)); assert.equal(modelCalls, 0); - submit(""); // explicitly send the restored draft as a new turn + submit(""); // Submit the restored draft behind the older pending entry. + assert.equal(modelCalls, 0); + submit("/queue run"); // Explicitly resume the paused FIFO after cancelling the switch. await until(() => modelCalls === 2); - assert.match(bodies[0]!, /unsent draft/); + assert.match(bodies[0]!, /queued follow-up/); assert.doesNotMatch(bodies[0]!, /Accepted console continuation brief/); - assert.match(bodies[1]!, /queued follow-up/); + assert.match(bodies[1]!, /unsent draft/); await until(() => output.includes("turn_outcome")); submit("/exit"); assert.equal(await running, 0);