From cc0d8d67f256ca64bfbb394677c3205029a50810 Mon Sep 17 00:00:00 2001 From: ClemCa Date: Sat, 31 Oct 2026 05:00:12 -0400 Subject: [PATCH 1/6] feat: queue edit support --- README.md | 3 ++ src/engine.test.ts | 82 ++++++++++++++++++++++++++++++++++++++++++++++ src/engine.ts | 37 +++++++++++++++++++++ src/parser.ts | 20 ++++++++++- src/types.ts | 1 + 5 files changed, 142 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5cbf7c3..7e6d1b1 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Enter these in OpenCode's normal input: | `/queue !command` | Run a shell command after the session becomes idle. | | `/queue:carry`, `/queue:carry-front` | Transfer following entries into a fresh session at this boundary. | | `/queue:clear`, `/queue:clear 2 4` | Remove all waiting entries or selected numbered entries. | +| `/queue:edit [first\|last\|n] text` | Replace a waiting entry's text in place. | | `/queue:stop`, `/queue:start` | Pause or resume replay. | | `/queue:abort` | Cancel active work and pause replay, including a queued shell or pending model switch. | | `/queue:flush` | Release waiting entries through the next carry boundary. | @@ -42,6 +43,8 @@ Replay releases one entry after a successful final response and an idle transiti `/queue:now` can switch the active model at the next checkpoint: a running tool finishes, then the plugin stops the old execution, switches settings, and continues in the same conversation. Flush groups matching agent/model/variant selections; each new group waits for the previous one to finish successfully. +`/queue:edit` replaces the text of one waiting entry in-place. The last entry is edited when the target is omitted. Numbering matches `/queue:list` and `/queue:clear`. A leading number, `first`, or `last` is read as the target. Replacement text is parsed again. Queue-control text and fresh-session boundaries cannot be edited. Attached files stay attached, but their mention ranges are dropped as they refer to the old text. + Carry boundaries create an empty session in the current directory and transfer the remaining entries, files, and paused state. The TUI follows the new session. A failed transfer leaves the source intact. Flush stops at the boundary. Slash commands resolve when released. If a command was removed, its original text becomes a prompt. Shell commands use `$SHELL` and show output as a plugin message, with a bounded preview and a full log. Failed or cancelled shell commands stay queued for an explicit retry. diff --git a/src/engine.test.ts b/src/engine.test.ts index 638e106..5dcbaea 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -78,6 +78,24 @@ test("parses queue syntax, commands, shells and carries without mutating attachm assert.throws(() => parse({ text: "/queue !pwd", files })) }) +test("queue edits parse an optional target and keep the replacement text intact", () => { + const edit = (text: string, extra: Partial[0]> = {}) => { + const parsed = parse({ text, ...extra }) + assert.ok("control" in parsed && parsed.control === "queue:edit") + return parsed.edit + } + assert.deepEqual(edit("/queue:edit corrected"), { target: "last", text: "corrected" }) + assert.deepEqual(edit("/queue:edit first corrected"), { target: "first", text: "corrected" }) + assert.deepEqual(edit("/queue:edit last 2 apples"), { target: "last", text: "2 apples" }) + assert.deepEqual(edit("/queue:edit 2 /review diff"), { target: 2, text: "/review diff" }) + assert.deepEqual(edit("/queue:edit 3 42"), { target: 3, text: "42" }) + assert.deepEqual(edit("/queue:edit 1e2 apples"), { target: "last", text: "1e2 apples" }) + for (const text of ["/queue:edit", "/queue:edit first", "/queue:edit 2", "/queue:edit 0 corrected", "/queue:edit 999999999999999999 corrected"]) + assert.throws(() => edit(text)) + assert.throws(() => edit("/queue:edit 2 corrected", { files: [{ uri: "file:///image.png" }] })) + assert.throws(() => edit("/queue:edit 2 corrected", { agents: [{ name: "build" }] })) +}) + test("suffix queue markers remain literal prompt text", () => { for (const text of ["hello /q", "hello /queue"]) { const parsed = parse({ text }) @@ -300,6 +318,70 @@ test("front insertion and selective clear validate all indices before mutating", await f.engine.dispose() }) +test("editing replaces text in place and keeps the entry's selection, files and position", async () => { + const f = fixture() + const files = [{ uri: "file:///image.png", name: "image.png", mention: { start: 7, end: 10, text: "two" } }] + try { + await f.submit("/queue:stop"); await f.submit("/queue one"); await f.submit("/queue two", B, { prompt: { text: "/queue two", files } }) + const before = (await f.engine.snapshot("one")).items + await f.submit("/queue:edit last corrected") + const after = (await f.engine.snapshot("one")).items + assert.deepEqual(after.map((item) => item.prompt.text), ["one", "corrected"]) + assert.deepEqual(after.map((item) => item.id), before.map((item) => item.id)) + assert.deepEqual(after[1]!.selection, B) + // Mention offsets describe the old text and are dropped with the edit. + assert.deepEqual(after[1]!.prompt.files, [{ uri: "file:///image.png", name: "image.png" }]) + assert.equal(f.sent.length, 0) + await f.submit("/queue:edit tail") + assert.deepEqual((await f.engine.snapshot("one")).items.map((item) => item.prompt.text), ["one", "tail"]) + assert.equal(f.sent.length, 0) + } finally { await f.engine.dispose() } +}) + +test("editing re-parses entry kinds and refuses queue controls, boundaries and out-of-range targets", async () => { + const f = fixture() + try { + await f.submit("/queue:stop"); await f.submit("/queue one") + await f.submit("/queue:edit first !pwd") + let items = (await f.engine.snapshot("one")).items + assert.equal(items[0]!.kind, "shell") + assert.equal(items[0]!.kind === "shell" ? items[0]!.command : undefined, "pwd") + await f.submit("/queue:edit 1 /review diff") + items = (await f.engine.snapshot("one")).items + assert.equal(items[0]!.kind, "command") + assert.equal(items[0]!.kind === "command" ? items[0]!.command : undefined, "review") + await assert.rejects(f.submit("/queue:edit 1 /queue:start"), /cannot create a queue command/) + await assert.rejects(f.submit("/queue:edit 2 nope"), /out of range/) + await assert.rejects(f.submit("/queue:edit"), /replacement text/) + assert.equal((await f.engine.snapshot("one")).items.length, 1) + await f.submit("/queue:clear"); await f.submit("/queue:carry") + await assert.rejects(f.submit("/queue:edit first nope"), /Fresh-session boundaries/) + } finally { await f.engine.dispose() } +}) + +test("editing numbers skip the entry currently being sent", async () => { + const f = fixture(), started = deferred(), release = deferred() + f.host.send = async (_sessionID, item) => { f.sent.push({ sessionID: "one", item }); started.resolve(); await release.promise } + try { + await f.submit("/queue active"); await started.promise + await f.submit("/queue waiting") + await f.submit("/queue:edit 1 corrected") + assert.deepEqual((await f.engine.snapshot("one")).items.map((item) => item.prompt.text), ["active", "corrected"]) + await assert.rejects(f.submit("/queue:edit 2 nope"), /out of range/) + release.resolve(); await f.engine.settled() + } finally { release.resolve(); await f.engine.dispose() } +}) + +test("a failed edit persists nothing and keeps the original text", async () => { + const f = fixture() + try { + await f.submit("/queue:stop"); await f.submit("/queue original") + f.host.persist = async () => { throw new Error("disk full") } + await assert.rejects(f.submit("/queue:edit first changed"), /disk full/) + assert.deepEqual((await f.engine.snapshot("one")).items.map((item) => item.prompt.text), ["original"]) + } finally { await f.engine.dispose() } +}) + test("disk failures preserve memory and no unpersisted entry is sent", async () => { const f = fixture() f.host.persist = async () => { throw new Error("disk full") } diff --git a/src/engine.ts b/src/engine.ts index a9e7475..780014c 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -122,6 +122,12 @@ export class QueueEngine { saved.items = saved.items.filter((item) => !remove.has(item.id)) }) break + case "queue:edit": { + const edit = op.edit + if (!edit) throw new Error("Queue edit expects replacement text") + await this.edit(id, edit.target, edit.text) + break + } case "queue:apply_model": await this.transaction((draft) => { for (const item of draft.sessions[id]?.items ?? []) if (item.id !== state.flight?.id && item.kind !== "carry") @@ -170,6 +176,37 @@ export class QueueEngine { this.changed(id); this.kick(id) } + // Replace the text of a waiting entry in place. The caller already notified + // and kicked this session once the edit came back through submitInner. + private async edit(id: string, target: number | "first" | "last", text: string): Promise { + const parsed = parse({ text }) + if ("control" in parsed) throw new Error("Queue edit cannot create a queue command") + const { front: _front, delivery: _delivery, ...replacement } = parsed + await this.transaction((draft) => { + const state = this.state(id) + if (state.deleted) throw new Error("Session has been deleted") + const saved = draft.sessions[id] + if (!saved) throw new Error("Queue item number is out of range") + const waiting = saved.items.filter((item) => item.id !== state.flight?.id) + const index = target === "first" ? 0 : target === "last" ? waiting.length - 1 : target - 1 + const previous = waiting[index] + if (!previous) throw new Error("Queue item number is out of range") + if (previous.kind === "carry") throw new Error("Fresh-session boundaries cannot be edited") + // Attachments survive an edit, but their mention offsets refer to the old + // text, so they are dropped before the entry is replayed. + const files = replacement.prompt.files?.length + ? replacement.prompt.files + : previous.prompt.files?.map(({ mention: _mention, ...file }) => file) + const position = saved.items.findIndex((item) => item.id === previous.id) + saved.items[position] = { + ...replacement, + id: previous.id, + selection: structuredClone(previous.selection), + prompt: { ...replacement.prompt, ...(files?.length ? { files } : {}) }, + } as Entry + }) + } + async observe(id: string, event: "started" | "succeeded" | "failed" | "interrupted" | "deleted" | "rejected", sequence?: number): Promise { const state = this.state(id) if (event === "rejected" && sequence !== undefined && sequence <= (state.startedSequence ?? 0)) return diff --git a/src/parser.ts b/src/parser.ts index 080fd53..4a3a0a7 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,6 +1,6 @@ import { commands, type Command, type Prompt, type QueuedInput } from "./types.js" -export type Parsed = { control: Command; indices?: number[] } | QueuedInput & { +export type Parsed = { control: Command; indices?: number[]; edit?: { target: number | "first" | "last"; text: string } } | QueuedInput & { front: boolean; delivery: "queue" | "now" | "normal" } @@ -33,6 +33,24 @@ export function parse(input: Prompt, command?: Command): Parsed { throw new Error("Queue clear expects positive item numbers") return { control, indices: [...new Set(numbers.map(Number))] } } + if (control === "queue:edit") { + if (files || mentions) throw new Error("Queue edit does not accept attachments or mentions") + const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(text) + if (!match) throw new Error("Queue edit expects replacement text") + const token = match[1]! + let target: number | "first" | "last" = "last" + let replacement = text + if (token === "first" || token === "last") { + target = token + replacement = match[2]?.trim() ?? "" + } else if (/^\d+$/.test(token)) { + if (!/^[1-9]\d*$/.test(token) || !Number.isSafeInteger(Number(token))) throw new Error("Queue edit expects positive item numbers") + target = Number(token) + replacement = match[2]?.trim() ?? "" + } + if (!replacement) throw new Error("Queue edit expects replacement text") + return { control, edit: { target, text: replacement } } + } if (control && !["queue", "q", "queue:front", "queue:now", "queue:carry", "queue:carry-front"].includes(control)) { if (text || files || mentions) throw new Error(`${control} does not accept input`) return { control } diff --git a/src/types.ts b/src/types.ts index 17f41c6..4e872fc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,7 @@ export const commands = { "queue:carry-front": "Insert a fresh-session boundary at the front", "queue:list": "Show waiting entries", "queue:clear": "Clear all entries or numbered entries", + "queue:edit": "Replace the text of a waiting entry", "queue:stop": "Pause the queue", "queue:abort": "Stop active work and pause the queue", "queue:start": "Resume the queue", From 4b6a0283b9f6a4694235635a10239a1eb22ef1a8 Mon Sep 17 00:00:00 2001 From: ClemCa Date: Sat, 31 Oct 2026 05:12:29 -0400 Subject: [PATCH 2/6] fix: drop attachments when entry kind changes To align with earlier decisions --- src/engine.test.ts | 22 ++++++++++++++++++++++ src/engine.ts | 10 ++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/engine.test.ts b/src/engine.test.ts index 5dcbaea..b51db30 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -338,6 +338,28 @@ test("editing replaces text in place and keeps the entry's selection, files and } finally { await f.engine.dispose() } }) +test("editing into a shell or compaction entry drops attachments, while commands keep them", async () => { + const f = fixture() + const image = (name: string, text: string) => [{ uri: `file:///${name}.png`, name: `${name}.png`, mention: { start: 7, end: 7 + text.length, text } }] + try { + await f.submit("/queue:stop") + await f.submit("/queue one", A, { prompt: { text: "/queue one", files: image("first", "one") } }) + await f.submit("/queue two", A, { prompt: { text: "/queue two", files: image("second", "two") } }) + await f.submit("/queue:edit 1 /review diff") + let items = (await f.engine.snapshot("one")).items + assert.equal(items[0]!.kind, "command") + assert.deepEqual(items[0]!.prompt.files, [{ uri: "file:///first.png", name: "first.png" }]) + await f.submit("/queue:edit 1 !pwd") + items = (await f.engine.snapshot("one")).items + assert.equal(items[0]!.kind, "shell") + assert.equal(items[0]!.prompt.files, undefined) + await f.submit("/queue:edit 2 /compact") + items = (await f.engine.snapshot("one")).items + assert.equal(items[1]!.kind, "compact") + assert.equal(items[1]!.prompt.files, undefined) + } finally { await f.engine.dispose() } +}) + test("editing re-parses entry kinds and refuses queue controls, boundaries and out-of-range targets", async () => { const f = fixture() try { diff --git a/src/engine.ts b/src/engine.ts index 780014c..93a3ed9 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -192,10 +192,12 @@ export class QueueEngine { const previous = waiting[index] if (!previous) throw new Error("Queue item number is out of range") if (previous.kind === "carry") throw new Error("Fresh-session boundaries cannot be edited") - // Attachments survive an edit, but their mention offsets refer to the old - // text, so they are dropped before the entry is replayed. - const files = replacement.prompt.files?.length - ? replacement.prompt.files + // Shell and compaction entries never carry attachments, and the edit + // input cannot supply new ones. Otherwise the previous files survive, + // without their mention offsets, which refer to the old text. Agent and + // skill references are dropped along with the text that contained them. + const files = replacement.kind === "shell" || replacement.kind === "compact" + ? undefined : previous.prompt.files?.map(({ mention: _mention, ...file }) => file) const position = saved.items.findIndex((item) => item.id === previous.id) saved.items[position] = { From 32e193d48c9f1592e91b9ced536b74a6fc03034e Mon Sep 17 00:00:00 2001 From: ClemCa Date: Sat, 31 Oct 2026 05:19:26 -0400 Subject: [PATCH 3/6] feat: support attachments in queue edits --- README.md | 4 ++-- src/engine.test.ts | 41 +++++++++++++++++++++++++++++++++-------- src/engine.ts | 24 +++++++++++++----------- src/parser.ts | 23 ++++++++++++++--------- 4 files changed, 62 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 7e6d1b1..1e00803 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Enter these in OpenCode's normal input: | `/queue !command` | Run a shell command after the session becomes idle. | | `/queue:carry`, `/queue:carry-front` | Transfer following entries into a fresh session at this boundary. | | `/queue:clear`, `/queue:clear 2 4` | Remove all waiting entries or selected numbered entries. | -| `/queue:edit [first\|last\|n] text` | Replace a waiting entry's text in place. | +| `/queue:edit [first\|last\|n] text` | Replace a waiting entry's text, and optionally its attachments, in place. | | `/queue:stop`, `/queue:start` | Pause or resume replay. | | `/queue:abort` | Cancel active work and pause replay, including a queued shell or pending model switch. | | `/queue:flush` | Release waiting entries through the next carry boundary. | @@ -43,7 +43,7 @@ Replay releases one entry after a successful final response and an idle transiti `/queue:now` can switch the active model at the next checkpoint: a running tool finishes, then the plugin stops the old execution, switches settings, and continues in the same conversation. Flush groups matching agent/model/variant selections; each new group waits for the previous one to finish successfully. -`/queue:edit` replaces the text of one waiting entry in-place. The last entry is edited when the target is omitted. Numbering matches `/queue:list` and `/queue:clear`. A leading number, `first`, or `last` is read as the target. Replacement text is parsed again. Queue-control text and fresh-session boundaries cannot be edited. Attached files stay attached, but their mention ranges are dropped as they refer to the old text. +`/queue:edit` replaces the text of one waiting entry in-place, along with any attachments submitted with the edit. The last entry is edited when the target is omitted. Numbering matches `/queue:list` and `/queue:clear`. A leading number, `first`, or `last` is read as the target. Replacement text is parsed again, and mentions in the replacement are rebased onto the new text. Queue-control text and fresh-session boundaries cannot be edited. New attachments replace the entry's previous files; without new attachments, previous files stay attached when the new entry kind accepts them (prompts and slash commands), but their mention ranges are dropped as they refer to the old text. Shells and compaction entries drop inherited files, and agent and skill references from the old text are removed. Carry boundaries create an empty session in the current directory and transfer the remaining entries, files, and paused state. The TUI follows the new session. A failed transfer leaves the source intact. Flush stops at the boundary. diff --git a/src/engine.test.ts b/src/engine.test.ts index b51db30..356a7df 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -84,16 +84,22 @@ test("queue edits parse an optional target and keep the replacement text intact" assert.ok("control" in parsed && parsed.control === "queue:edit") return parsed.edit } - assert.deepEqual(edit("/queue:edit corrected"), { target: "last", text: "corrected" }) - assert.deepEqual(edit("/queue:edit first corrected"), { target: "first", text: "corrected" }) - assert.deepEqual(edit("/queue:edit last 2 apples"), { target: "last", text: "2 apples" }) - assert.deepEqual(edit("/queue:edit 2 /review diff"), { target: 2, text: "/review diff" }) - assert.deepEqual(edit("/queue:edit 3 42"), { target: 3, text: "42" }) - assert.deepEqual(edit("/queue:edit 1e2 apples"), { target: "last", text: "1e2 apples" }) + assert.deepEqual(edit("/queue:edit corrected"), { target: "last", prompt: { text: "corrected" } }) + assert.deepEqual(edit("/queue:edit first corrected"), { target: "first", prompt: { text: "corrected" } }) + assert.deepEqual(edit("/queue:edit last 2 apples"), { target: "last", prompt: { text: "2 apples" } }) + assert.deepEqual(edit("/queue:edit 2 /review diff"), { target: 2, prompt: { text: "/review diff" } }) + assert.deepEqual(edit("/queue:edit 3 42"), { target: 3, prompt: { text: "42" } }) + assert.deepEqual(edit("/queue:edit 1e2 apples"), { target: "last", prompt: { text: "1e2 apples" } }) for (const text of ["/queue:edit", "/queue:edit first", "/queue:edit 2", "/queue:edit 0 corrected", "/queue:edit 999999999999999999 corrected"]) assert.throws(() => edit(text)) - assert.throws(() => edit("/queue:edit 2 corrected", { files: [{ uri: "file:///image.png" }] })) - assert.throws(() => edit("/queue:edit 2 corrected", { agents: [{ name: "build" }] })) + const text = "/queue:edit 2 use @file" + const files = [{ uri: "file:///image.png", mention: { start: text.indexOf("@file"), end: text.length, text: "@file" } }] + assert.deepEqual(edit(text, { files }), { + target: 2, + prompt: { text: "use @file", files: [{ uri: "file:///image.png", mention: { start: text.indexOf("@file") - text.indexOf("use"), end: "use @file".length, text: "@file" } }] }, + }) + // Mentions inside the control prefix or target token cannot fall within the replacement text. + assert.throws(() => edit("/queue:edit 2 use", { files: [{ uri: "file:///image.png", mention: { start: 12, end: 13, text: "2" } }] }), /Mention is outside/) }) test("suffix queue markers remain literal prompt text", () => { @@ -360,6 +366,25 @@ test("editing into a shell or compaction entry drops attachments, while commands } finally { await f.engine.dispose() } }) +test("editing with new attachments replaces inherited files and keeps their mentions", async () => { + const f = fixture() + try { + await f.submit("/queue:stop") + await f.submit("/queue one", A, { prompt: { text: "/queue one", files: [{ uri: "file:///old.png", name: "old.png", mention: { start: 7, end: 10, text: "one" } }] } }) + const text = "/queue:edit 1 use @new" + await f.submit(text, A, { prompt: { text, files: [{ uri: "file:///new.png", name: "new.png", mention: { start: text.indexOf("@new"), end: text.length, text: "@new" } }] } }) + const item = (await f.engine.snapshot("one")).items[0]! + assert.equal(item.prompt.text, "use @new") + assert.deepEqual(item.prompt.files, [{ + uri: "file:///new.png", + name: "new.png", + mention: { start: text.indexOf("@new") - text.indexOf("use"), end: "use @new".length, text: "@new" }, + }]) + await assert.rejects(f.submit("/queue:edit 1 !pwd", A, { prompt: { text: "/queue:edit 1 !pwd", files: [{ uri: "file:///new.png" }] } }), /cannot have attachments/) + assert.equal((await f.engine.snapshot("one")).items[0]!.prompt.text, "use @new") + } finally { await f.engine.dispose() } +}) + test("editing re-parses entry kinds and refuses queue controls, boundaries and out-of-range targets", async () => { const f = fixture() try { diff --git a/src/engine.ts b/src/engine.ts index 93a3ed9..4d5ef27 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto" import { parse, type Parsed } from "./parser.js" -import { type Command, type Entry, type Input, type Notification, type Selection, type Snapshot } from "./types.js" +import { type Command, type Entry, type Input, type Notification, type Prompt, type Selection, type Snapshot } from "./types.js" export type Session = { id: string; selection?: Selection; directory: string; outcome?: "succeeded" | "failed" | "interrupted" } export interface Host { @@ -125,7 +125,7 @@ export class QueueEngine { case "queue:edit": { const edit = op.edit if (!edit) throw new Error("Queue edit expects replacement text") - await this.edit(id, edit.target, edit.text) + await this.edit(id, edit.target, edit.prompt) break } case "queue:apply_model": @@ -178,8 +178,8 @@ export class QueueEngine { // Replace the text of a waiting entry in place. The caller already notified // and kicked this session once the edit came back through submitInner. - private async edit(id: string, target: number | "first" | "last", text: string): Promise { - const parsed = parse({ text }) + private async edit(id: string, target: number | "first" | "last", input: Prompt): Promise { + const parsed = parse(input) if ("control" in parsed) throw new Error("Queue edit cannot create a queue command") const { front: _front, delivery: _delivery, ...replacement } = parsed await this.transaction((draft) => { @@ -192,13 +192,15 @@ export class QueueEngine { const previous = waiting[index] if (!previous) throw new Error("Queue item number is out of range") if (previous.kind === "carry") throw new Error("Fresh-session boundaries cannot be edited") - // Shell and compaction entries never carry attachments, and the edit - // input cannot supply new ones. Otherwise the previous files survive, - // without their mention offsets, which refer to the old text. Agent and - // skill references are dropped along with the text that contained them. - const files = replacement.kind === "shell" || replacement.kind === "compact" - ? undefined - : previous.prompt.files?.map(({ mention: _mention, ...file }) => file) + // Shell and compaction entries never carry attachments. Otherwise new + // attachments replace the inherited ones and keep their mentions, which + // the parser rebased onto the new text; inherited files outlive the old + // text, but their mention offsets do not. + let files: Prompt["files"] + if (replacement.kind !== "shell" && replacement.kind !== "compact") + files = replacement.prompt.files?.length + ? replacement.prompt.files + : previous.prompt.files?.map(({ mention: _mention, ...file }) => file) const position = saved.items.findIndex((item) => item.id === previous.id) saved.items[position] = { ...replacement, diff --git a/src/parser.ts b/src/parser.ts index 4a3a0a7..c1cdd5c 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,6 +1,6 @@ import { commands, type Command, type Prompt, type QueuedInput } from "./types.js" -export type Parsed = { control: Command; indices?: number[]; edit?: { target: number | "first" | "last"; text: string } } | QueuedInput & { +export type Parsed = { control: Command; indices?: number[]; edit?: { target: number | "first" | "last"; prompt: Prompt } } | QueuedInput & { front: boolean; delivery: "queue" | "now" | "normal" } @@ -34,22 +34,27 @@ export function parse(input: Prompt, command?: Command): Parsed { return { control, indices: [...new Set(numbers.map(Number))] } } if (control === "queue:edit") { - if (files || mentions) throw new Error("Queue edit does not accept attachments or mentions") const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(text) if (!match) throw new Error("Queue edit expects replacement text") const token = match[1]! + const numbered = /^\d+$/.test(token) + const targeted = token === "first" || token === "last" || numbered let target: number | "first" | "last" = "last" - let replacement = text - if (token === "first" || token === "last") { - target = token - replacement = match[2]?.trim() ?? "" - } else if (/^\d+$/.test(token)) { + if (token === "first" || token === "last") target = token + else if (numbered) { if (!/^[1-9]\d*$/.test(token) || !Number.isSafeInteger(Number(token))) throw new Error("Queue edit expects positive item numbers") target = Number(token) - replacement = match[2]?.trim() ?? "" + } + let replacement = text + if (targeted) { + const rest = match[2] ?? "" + replacement = rest.trim() + // Mentions are relative to the submitted text, so move them past the + // control prefix and the target token before they reach the entry. + offset += text.length - rest.length + (rest.length - rest.trimStart().length) } if (!replacement) throw new Error("Queue edit expects replacement text") - return { control, edit: { target, text: replacement } } + return { control, edit: { target, prompt: shiftPrompt(input, replacement, -offset) } } } if (control && !["queue", "q", "queue:front", "queue:now", "queue:carry", "queue:carry-front"].includes(control)) { if (text || files || mentions) throw new Error(`${control} does not accept input`) From 9bc60cd552531c5f06c0a92226772e99f0e1494d Mon Sep 17 00:00:00 2001 From: ClemCa Date: Sun, 1 Nov 2026 05:38:14 -0400 Subject: [PATCH 4/6] fix: offset prompts for summarize & edit --- index.ts | 4 ++-- src/engine.test.ts | 15 ++++++++++++++- src/parser.ts | 6 ++++++ test/integration.test.ts | 15 +++++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index e7d9812..8bf1709 100644 --- a/index.ts +++ b/index.ts @@ -5,7 +5,7 @@ import { Schema } from "effect" import { randomUUID } from "node:crypto" import { join, resolve } from "node:path" import { QueueEngine } from "./src/engine.js" -import { shiftPrompt } from "./src/parser.js" +import { offsetPrompt, shiftPrompt } from "./src/parser.js" import { QueueRpc } from "./src/rpc.js" import { dataDirectory, loadStore, queuePath, writeJSON } from "./src/storage.js" import { shell } from "./src/shell.js" @@ -154,7 +154,7 @@ export default Plugin.define({ const session = await ctx.session.get({ sessionID }) if (!session.agent || !session.model) throw new Error("Select an agent and model before using queue commands") const { snapshot, list } = await engine.submit({ sessionID, requestID: randomUUID(), - selection: { agent: session.agent, model: encodeModel(session.model) }, prompt: encodePrompt(prompt) }, name) + selection: { agent: session.agent, model: encodeModel(session.model) }, prompt: encodePrompt(offsetPrompt(prompt, name)) }, name) const waiting = snapshot.items.filter((item) => !snapshot.sending.includes(item.id)) const status = `${waiting.length} waiting ยท ${snapshot.stopped ? "paused" : snapshot.gate === "interrupted" ? "suspended" : "running"}` const message = [status, ...(snapshot.error ? [snapshot.error] : []), ...(list ? waiting.map((item, i) => { diff --git a/src/engine.test.ts b/src/engine.test.ts index 356a7df..d82970a 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -3,7 +3,7 @@ import { test } from "node:test" import { EventEmitter } from "node:events" import { randomUUID } from "node:crypto" import { QueueEngine, type Host, type Store, type Session } from "./engine.js" -import { parse } from "./parser.js" +import { offsetPrompt, parse } from "./parser.js" import type { Entry, Input, Notification, Selection, Snapshot } from "./types.js" const A: Selection = { agent: "build", model: { providerID: "test", id: "a", variant: "xhigh" } } @@ -122,6 +122,19 @@ test("native command arguments keep nested slash commands and mention ranges", ( assert.equal(input.agents[0]!.mention.start, 9) }) +test("native command arguments rebase mentions measured from the full input line", async () => { + const f = fixture() + try { + await f.submit("/queue:stop"); await f.submit("/queue one"); await f.submit("/queue two") + const text = "2 summarize @notes.txt " + const prompt = { text, files: [{ uri: "file:///notes.txt", name: "notes.txt", mention: { start: 24, end: 34, text: "@notes.txt" } }] } + await f.engine.submit(f.input(text, A, { prompt: offsetPrompt(prompt, "queue:edit") }), "queue:edit") + const item = (await f.engine.snapshot("one")).items[1]! + assert.equal(item.prompt.text, "summarize @notes.txt") + assert.deepEqual(item.prompt.files, [{ uri: "file:///notes.txt", name: "notes.txt", mention: { start: 10, end: 20, text: "@notes.txt" } }]) + } finally { await f.engine.dispose() } +}) + test("native list feedback follows parsed commands and treats attachment-only input as entries", async () => { const f = fixture() try { diff --git a/src/parser.ts b/src/parser.ts index c1cdd5c..a4bb464 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -14,6 +14,12 @@ export function shiftPrompt(input: Prompt, text: string, offset: number): Prompt return prompt } +// Native commands receive the text after "/ ", but their mention ranges +// still count from the start of the full input line. +export function offsetPrompt(input: T, name: string): T { + return shiftPrompt(input, input.text, -(name.length + 2)) as T +} + export function parse(input: Prompt, command?: Command): Parsed { let text = input.text.trim() let offset = input.text.length - input.text.trimStart().length diff --git a/test/integration.test.ts b/test/integration.test.ts index d45f8b8..d504524 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -312,6 +312,21 @@ test("a server restart retains paused queues, attachments and saved selection", const start = events.length; reply(restored); await terminal(id, start) }) +test("native command arguments rebase attachment mentions from the full input line", { timeout: 30_000 }, async () => { + const id = await create(), uri = `file://${join(directory, "notes.txt")}` + await writeFile(join(directory, "notes.txt"), "notes") + await client.session.command({ sessionID: id, name: "queue:stop", text: "" }) + await client.session.command({ sessionID: id, name: "queue", text: "one" }) + await client.session.command({ sessionID: id, name: "queue", text: "two" }) + // The TUI strips "/queue:edit " before invoking the command, but keeps the + // mention range measured from the full input line. + await client.session.command({ sessionID: id, name: "queue:edit", text: "2 summarize @notes.txt ", + files: [{ uri, name: "notes.txt", mention: { start: 24, end: 34, text: "@notes.txt" } }] }) + const saved = await queue.snapshot({ sessionID: id }) + assert.equal(saved.items[1]?.prompt.text, "summarize @notes.txt") + assert.deepEqual(saved.items[1]?.prompt.files, [{ uri, name: "notes.txt", mention: { start: 10, end: 20, text: "@notes.txt" } }]) +}) + test("native TUI slash commands queue privately and list entries in the native dialog", { timeout: 40_000 }, async () => { const id = await create(), launch = join(root, "tui-launch.sh") await client.session.switchModel({ sessionID: id, model: { providerID: "queue-test", id: "a" } }) From a24d5ee7c3f2b420e4f6fd63d341f9608d1850ec Mon Sep 17 00:00:00 2001 From: ClemCa Date: Sun, 1 Nov 2026 05:55:21 -0400 Subject: [PATCH 5/6] chore: added integration tests I was a bit paranoid, but I think it's worth it --- src/engine.test.ts | 12 ++++++++---- test/integration.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/engine.test.ts b/src/engine.test.ts index d82970a..da10d13 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -128,10 +128,14 @@ test("native command arguments rebase mentions measured from the full input line await f.submit("/queue:stop"); await f.submit("/queue one"); await f.submit("/queue two") const text = "2 summarize @notes.txt " const prompt = { text, files: [{ uri: "file:///notes.txt", name: "notes.txt", mention: { start: 24, end: 34, text: "@notes.txt" } }] } - await f.engine.submit(f.input(text, A, { prompt: offsetPrompt(prompt, "queue:edit") }), "queue:edit") - const item = (await f.engine.snapshot("one")).items[1]! - assert.equal(item.prompt.text, "summarize @notes.txt") - assert.deepEqual(item.prompt.files, [{ uri: "file:///notes.txt", name: "notes.txt", mention: { start: 10, end: 20, text: "@notes.txt" } }]) + // Full line -> handler text removes "/queue:edit " (12 chars): mention 12-22. + const rebased = offsetPrompt(prompt, "queue:edit") + assert.deepEqual(rebased.files?.[0]?.mention, { start: 12, end: 22, text: "@notes.txt" }) + // Handler text -> entry replacement removes "2 ": mention 10-20. + await f.engine.submit(f.input(text, A, { prompt: rebased }), "queue:edit") + const saved = await f.engine.snapshot("one") + assert.equal(saved.items[1]?.prompt.text, "summarize @notes.txt") + assert.deepEqual(saved.items[1]?.prompt.files, [{ uri: "file:///notes.txt", name: "notes.txt", mention: { start: 10, end: 20, text: "@notes.txt" } }]) } finally { await f.engine.dispose() } }) diff --git a/test/integration.test.ts b/test/integration.test.ts index d504524..31da406 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -325,6 +325,48 @@ test("native command arguments rebase attachment mentions from the full input li const saved = await queue.snapshot({ sessionID: id }) assert.equal(saved.items[1]?.prompt.text, "summarize @notes.txt") assert.deepEqual(saved.items[1]?.prompt.files, [{ uri, name: "notes.txt", mention: { start: 10, end: 20, text: "@notes.txt" } }]) + const mention = saved.items[1]?.prompt.files?.[0]?.mention + assert.ok(mention && saved.items[1]!.prompt.text.slice(mention.start, mention.end) === "@notes.txt") +}) + +test("queue edits replace numbered and last waiting entries before replay", { timeout: 30_000 }, async () => { + const id = await create(), start = requests.length + await submit(id, "/queue:stop") + await submit(id, "/queue [queue-test:edit-before-first]") + await submit(id, "/queue [queue-test:edit-before-last]") + await client.session.command({ sessionID: id, name: "queue:edit", text: "1 [queue-test:edit-after-first] " }) + await client.session.command({ sessionID: id, name: "queue:edit", text: "[queue-test:edit-after-last] " }) + const saved = await queue.snapshot({ sessionID: id }) + assert.deepEqual(saved.items.map((item) => item.prompt.text), ["[queue-test:edit-after-first]", "[queue-test:edit-after-last]"]) + assert.deepEqual(saved.items.map((item) => item.selection?.model.id), ["a", "a"]) + await submit(id, "/queue:start") + const first = await request("edit-after-first", start) + assert.ok(JSON.stringify(first.body).includes("[queue-test:edit-after-first]")) + assert.ok(!JSON.stringify(first.body).includes("edit-before")) + const firstFinish = events.length; reply(first); await terminal(id, firstFinish) + const last = await request("edit-after-last", start) + assert.ok(JSON.stringify(last.body).includes("[queue-test:edit-after-last]")) + assert.ok(!JSON.stringify(last.body).includes("edit-before")) + const lastFinish = events.length; reply(last); await terminal(id, lastFinish) +}) + +test("queue edits keep inherited files, can become shells, and report invalid targets", { timeout: 30_000 }, async () => { + const id = await create(), uri = `data:text/plain;base64,${Buffer.from("edited attachment").toString("base64")}` + await submit(id, "/queue:stop") + await submit(id, "/queue [queue-test:edit-file]", A, [{ uri }]) + await client.session.command({ sessionID: id, name: "queue:edit", text: "[queue-test:edit-file-text] " }) + let saved = await queue.snapshot({ sessionID: id }) + assert.equal(saved.items[0]?.prompt.text, "[queue-test:edit-file-text]") + assert.deepEqual(saved.items[0]?.prompt.files, [{ uri }]) + await assert.rejects(client.session.command({ sessionID: id, name: "queue:edit", text: "9 nope " }), /out of range/) + await client.session.command({ sessionID: id, name: "queue:edit", text: "!printf queue-edit-shell " }) + saved = await queue.snapshot({ sessionID: id }) + assert.equal(saved.items[0]?.kind, "shell") + assert.equal(saved.items[0]?.prompt.files, undefined) + const start = events.length + await submit(id, "/queue:start") + await wait(() => events.slice(start).find((event) => event.type === "session.synthetic" && (event.data as any).sessionID === id), "edited shell replay") + assert.ok(JSON.stringify(await client.session.context({ sessionID: id })).includes("queue-edit-shell")) }) test("native TUI slash commands queue privately and list entries in the native dialog", { timeout: 40_000 }, async () => { From 89f3df511d56d1b5781c9696c5fb433789e15e4e Mon Sep 17 00:00:00 2001 From: mirsella Date: Sun, 1 Nov 2026 11:54:08 +0100 Subject: [PATCH 6/6] test: check edited shell output on the next model turn opencode keeps shell output in the session inbox until the next model turn, so a shell-only queue never emits session.synthetic. Queue a prompt after the edited shell entry and check that turn instead. Co-Authored-By: Claude Opus 5.5 --- test/integration.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/integration.test.ts b/test/integration.test.ts index 31da406..725bda2 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -363,9 +363,14 @@ test("queue edits keep inherited files, can become shells, and report invalid ta saved = await queue.snapshot({ sessionID: id }) assert.equal(saved.items[0]?.kind, "shell") assert.equal(saved.items[0]?.prompt.files, undefined) - const start = events.length + // opencode holds shell output in the session inbox until the next model + // turn, so queue a prompt after the shell and check that turn. + await submit(id, "/queue [queue-test:after-edit-shell]") + const start = events.length, requestStart = requests.length await submit(id, "/queue:start") - await wait(() => events.slice(start).find((event) => event.type === "session.synthetic" && (event.data as any).sessionID === id), "edited shell replay") + const next = await request("after-edit-shell", requestStart) + assert.ok(JSON.stringify(next.body).includes("queue-edit-shell")) + reply(next); await terminal(id, start) assert.ok(JSON.stringify(await client.session.context({ sessionID: id })).includes("queue-edit-shell")) })