diff --git a/packages/extension/package.json b/packages/extension/package.json index 7bcf081..237fc52 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -104,6 +104,11 @@ "title": "Amicode: Open Chat Deck (Panes in One Tab)", "icon": "$(layout)" }, + { + "command": "amicode.reportBug", + "title": "Amicode: Report a Bug", + "icon": "$(bug)" + }, { "command": "amicode.setupVault", "title": "Amicode: Set up a personal vault" diff --git a/packages/extension/skills/report-a-bug/SKILL.md b/packages/extension/skills/report-a-bug/SKILL.md index 9e44739..9f5db66 100644 --- a/packages/extension/skills/report-a-bug/SKILL.md +++ b/packages/extension/skills/report-a-bug/SKILL.md @@ -1,6 +1,6 @@ --- name: report-a-bug -description: File a sanitized, intake-grade bug issue from a live Amicode session — auto-collected diagnostics, exactly one question, confirm gate, silent dedup, pin-aware upstream check for the vendored engine. Use when the user hits a bug in Amicode (the extension, the run gate, the Run Inspector, vetted templates, the engine, or a toolchain package) and wants to report it. +description: File a sanitized, intake-grade bug issue from a live Amicode session — auto-collected diagnostics, at least one question (accepts user follow-ups), confirm gate, silent dedup, pin-aware upstream check for the vendored engine. Use when the user hits a bug in Amicode (the extension, the run gate, the Run Inspector, vetted templates, the engine, or a toolchain package) and wants to report it. agents: [] surface: public --- @@ -9,9 +9,17 @@ surface: public **Announce at start:** "I'm using the report-a-bug skill to file this." -File an **intake-grade** bug issue from a live session. Capture stays cheap — auto-collected, sanitized diagnostics plus exactly one user question — and readiness is earned later at review, per the **maturity contract** below. This skill is capture-only: it files intake issues and publishes the contract a reviewer matures them by. Feature ideas, designs, and specs are out of scope — a bug filer has a symptom, not a resolved design. +File an **intake-grade** bug issue from a live session. Capture stays cheap — auto-collected, sanitized diagnostics plus at least one user question — and readiness is earned later at review, per the **maturity contract** below. This skill is capture-only: it files intake issues and publishes the contract a reviewer matures them by. Feature ideas, designs, and specs are out of scope — a bug filer has a symptom, not a resolved design. -**The flow:** classify the surface → capture (one question) → sanitize → dedup → upstream check (fork surfaces) → compose → confirm gate → file. **Nothing posts before the confirm gate.** +**The flow:** read the context envelope (when the session carries one) → classify the surface → capture (at least one question) → sanitize → dedup → upstream check (fork surfaces) → compose → confirm gate → file → print the filed sentinel. **Nothing posts before the confirm gate; the sentinel prints after any successful GitHub action (issue created OR comment posted).** + +## 0. Read the context envelope (when the session carries one) + +A bug session may carry a **context envelope** in its session metadata: a `bug_report` key with fields `project` (string, optional), `run_pointer` (string, optional), and `origin_session_id` (string, optional), written by the extension that spawned the session. **Read it first, silently — it precedes every context question and is never a user prompt.** + +- **Envelope present** → collect from the envelope *in place of* the live session: `project` seeds the step-1 surface classification; `run_pointer` (when present) is the run-id pointer for the step-2 diagnostics — platform/tier and the bounded log tail resolve locally through it. Never ask the user for run context the envelope already carries; the one content question (step 2) is still asked, unchanged. `origin_session_id` is local provenance for the session the bug came from — it is never posted. +- **Envelope absent** → collect from the live session exactly as the steps below describe; behavior is unchanged. +- **Pointer-only, always** — envelope run context travels as a pointer only: never expand `run_pointer` (or anything resolved through it) into absolute paths in any artifact, including the confirm-gate draft. Envelope content is already scrub-safe by construction; the step-3 sanitize pass applies to it unchanged. ## 1. Classify the owning surface (silent — never a user prompt) @@ -21,15 +29,19 @@ Decide where the bug lives, from the session context: - **The vendored engine** (a fork-vendored component) → the fork repo (`harmoniqs/opencode`), and step 4's upstream check applies. - **A toolchain package** → its owning repo. Repo inference for toolchain packages runs on the **internal path only** (step 7); on the public path the filing lands in the product repo and triage re-routes. -## 2. Capture — exactly one content question +## 2. Capture — at least one content question, plus user-initiated follow-ups + +Ask the user directly, as plain text output: **"What happened, and what did you expect?"** Do not use the `question` tool — the bug dock handles the dialogue natively. Output the question as your message text and wait for the user's reply. Everything else is auto-collected or deferred to maturation review. Never ask follow-up questions yourself — gaps are the reviewer's job. + +**After this question, the user may send unsolicited follow-up messages** through the always-available textbox — additional context, clarifications, or corrections. Accept them silently (never ask another question) and fold them into the draft as extra detail. The user drives any follow-up; the agent never escalates. -Ask **one** question, via the `question` tool (free-form answer): **"What happened, and what did you expect?"** Everything else is auto-collected or deferred to maturation review. Never ask follow-ups at capture — gaps are the reviewer's job. (The dedup-hit and upstream-hit offers and the confirm gate are gates, not content questions.) +(The dedup-hit and upstream-hit offers and the confirm gate are gates, not content questions.) **Auto-collect, locally, held unposted until scrubbed:** - Extension version (`code --list-extensions --show-versions`, or the installed `harmoniqs.amicode-*` extension directory name) and OS (`uname -srm`). - The engine pin: `opencode.lock.json` under the installed extension directory — its `version`, `tag` (e.g. `v1.18.10-amicode.1`), and `repo`. -- **When a run is active** (a solve this session, or the bug is about a run): platform and tier from the problem workspace (`~/.amico/problems//` — `solvespec.json` / `events.jsonl`), the **run-id pointer** (`runs//`, relative to `~/.amico/` — a pointer, never an absolute path), and a **bounded log tail** (last ~40 lines of that run's `run.log`). +- **When a run is active** (a solve this session, or the bug is about a run): platform and tier from the problem workspace (`~/.amico/problems//` — `solvespec.json` / `events.jsonl`), the **run-id pointer** (`runs//`, relative to `~/.amico/` — a pointer, never an absolute path), and a **bounded log tail** (last ~40 lines of that run's `run.log`). When the session carries the `bug_report` envelope (step 0), this run context comes from the envelope's `run_pointer` instead of live-session discovery — same pointer form, same bounded tail. - **Never collected:** absolute paths, binaries or screenshots, vault contents, and lab secrets (device frequencies, calibration values) — lab context travels as run-id pointers only. ## 3. Sanitize — classify → scrub → compose @@ -82,7 +94,9 @@ The `intake: not-ready` footer (plus the intake label/column on the internal pat ## 6. Confirm gate — nothing posts before it -Show the user **the exact final body, the target repo, and the `suggested_path`**. The user may **edit or veto** there; a veto files nothing. No issue, comment, or unscrubbed query leaves the machine before this gate — and the draft shown at the gate is already scrubbed, so nothing proprietary is displayed either. +Show the user **the exact final body, the target repo, and the `suggested_path`** — the draft is already scrubbed, so nothing proprietary is displayed either. Tell the user, as plain text output: **"The draft above is ready. Reply `file it` to submit as a new issue, `edit: ` to modify before filing, `comment: # ` to add a clarifying comment to an existing issue, or `veto` to cancel."** Do not use the `question` tool. Wait for the user's reply. Never ask follow-ups — the confirm gate is a single prompt. + +On answer: starts with "file it" → file exactly as drafted (step 7). Starts with "edit:" → incorporate the text after "edit:" and file (step 7). Starts with "comment:" → parse `# ` (e.g. `comment: harmoniqs/amicode#123 Thanks — confirmed on my end`), post the comment via `gh issue comment --repo --body `, then **print the sentinel on its own line exactly as**: `AMICODE_BUG_FILED https://github.com//issues/` (the full URL of the issue you commented on — construct it from the repo and issue number), and end. Starts with "veto" → file nothing, no sentinel. **The sentinel prints after EVERY successful action — new issue OR comment. The only path that skips the sentinel is "veto."** No issue, comment, or unscrubbed query leaves the machine before this gate. ## 7. File — the runtime org-tail fork @@ -113,6 +127,15 @@ gh api graphql -f query='mutation($project:ID!, $item:ID!, $field:ID!, $opt:Stri `` = `gh issue view --repo --json id --jq .id`. No status or assignee prompts — the intake defaults are fixed (intake column, unassigned). +**The filed sentinel — the terminal contract.** This is a **lifecycle signal**, not a semantic judgment. Print it after ANY successful GitHub action in this session — new issue created, comment posted, chore filed — regardless of whether the content is a "bug." The sentinel tells the dock "you're done; show the end-state." Without it the dock stays open forever. + +- **After creating an issue via `gh`**: print `AMICODE_BUG_FILED ` with the new issue's URL, e.g. + `AMICODE_BUG_FILED https://github.com/harmoniqs/amicode/issues/123`. +- **After posting a comment via `gh issue comment`**: print `AMICODE_BUG_FILED https://github.com//issues/` with the issue's URL. +- **After the browser fallback**: print the sentinel carrying `filed-via-browser` — or the pre-filled new-issue URL as the token when one was opened. + +This line is a machine contract, not prose. The sentinel is **its own line** in your text output, the marker and its single token separated by a space, and nothing else on the line (no markdown, no backticks, no punctuation glued to the token). **Never print it before a successful GitHub action** — a veto means nothing was posted and no sentinel ever appears. The sentinel is the lifecycle trigger that archives the bug session; printing it without an action would archive a session whose report never reached GitHub. + ## The maturity contract (the reviewer-facing interface) Intake issues **mature in place** to ready-for-agent, through one of two paths delineated by bug shape: @@ -135,8 +158,10 @@ Until a reviewer-agent skill exists to execute maturation, humans mature intake ## Invariants -- **Exactly one content question** at capture; everything else is auto-collected, a gate, or a conditional offer. +- **At least one content question** at capture — the agent asks exactly one opening question and accepts unsolicited user follow-ups as additional context; never asks a second question itself. Everything else is auto-collected, a gate, or a conditional offer. - **Nothing posts before the confirm gate** — and the gate's draft is already scrubbed. +- The **filed sentinel prints after any successful GitHub action** (issue created, comment posted, chore filed) — never before the confirm gate; a veto means no sentinel, ever. The sentinel is a lifecycle signal, not a semantic judgment about whether the content is a "bug." +- **Envelope context is pointer-only** — `run_pointer` is never expanded into absolute paths in any artifact, including the confirm-gate draft; envelope absent → live-session collection, unchanged. - Intake issues are **visibly marked not-ready** (footer; label + column on the internal path). - **No proprietary implementation details or package names** in any artifact that leaves the machine — including the draft shown to the user. - **No lab secrets in payloads** — run-id pointers only. diff --git a/packages/extension/src/bug_report.ts b/packages/extension/src/bug_report.ts new file mode 100644 index 0000000..ff43bd4 --- /dev/null +++ b/packages/extension/src/bug_report.ts @@ -0,0 +1,399 @@ +import * as path from "node:path"; + +// ============================================================================ +// Bug-report orchestration (amicode#250, lifecycle spec: docs/adr/0004). +// +// The extension owns the bug session end-to-end — no reverse lookup, no +// app-reported ids. `reportBug()` creates the session (title "Bug report", +// metadata envelope `bug_report: {project, run_pointer?, origin_session_id}`), +// arms it with the report-a-bug slash command (the session command API idiom: +// create + command), and tells the app to open the dock. The lifecycle is +// machine-managed: `bug-filed` → archive (the soft hide) + close the dock; +// `bug-report-closed` before filing → abort the in-flight turn, then hard +// delete; a partial orchestration failure deletes the session it created (no +// orphans). One bug session at a time per window: a second invocation reveals +// (re-posts open-bug-report with the EXISTING id — the dock treats a same-id +// open as reveal) instead of creating. +// +// Bridge contract (mirrored by the fork slices #116/#117): +// DOWN {source:"amicode", kind:"open-bug-report", sessionID} +// DOWN {source:"amicode", kind:"close-bug-report", sessionID} +// UP {source:"amicode", kind:"bug-filed", sessionID, url} +// UP {source:"amicode", kind:"bug-report-closed", sessionID} +// +// Sanitization invariant: run context travels as run-id POINTERS only (never +// an absolute path, never a payload); the originating session id is a +// provenance pointer — it is only ever READ (the list call) and embedded, never +// mutated, navigated, or closed (AC6). +// ============================================================================ + +export const REPORT_BUG_COMMAND = "amicode.reportBug"; +export const BUG_REPORT_TITLE = "Bug report"; +/** The staged skill whose name is also its slash-command (opencode command + * API: skills register as commands under their frontmatter `name`). */ +export const REPORT_A_BUG_SKILL = "report-a-bug"; + +export const OPEN_BUG_REPORT_KIND = "open-bug-report"; +export const CLOSE_BUG_REPORT_KIND = "close-bug-report"; +export const BUG_FILED_KIND = "bug-filed"; +export const BUG_REPORT_CLOSED_KIND = "bug-report-closed"; +/** UP: the app posts this on boot when the bug-report flag is on — the + * catch-up half of the open contract. A one-shot open-bug-report can land + * before the app's listener mounts (cold window, webview reload), so a live + * bug session re-opens its dock on every app boot until it terminates. */ +export const BUG_REPORT_POKE_KIND = "bug-report-poke"; + +/** The DOWN envelopes this module ever posts. */ +export interface BugReportDownMessage { + source: "amicode"; + kind: typeof OPEN_BUG_REPORT_KIND | typeof CLOSE_BUG_REPORT_KIND; + sessionID: string; +} + +/** The `amicode_bug_report=1` boot param is set iff the staged skill set + * includes report-a-bug (AC5) — the composer button never renders without the + * skill that answers it. Staged paths are `//SKILL.md` and the + * library/package layouts share the name-matches-folder rule, so the parent + * dir basename IS the skill name. */ +export function bugReportSkillStaged(skillPaths: readonly string[]): boolean { + return skillPaths.some((p) => path.basename(path.dirname(p)) === REPORT_A_BUG_SKILL); +} + +/** The running local opencode server + the #163 boot credential header value. */ +export interface BugReportServer { + url: string; + authorization: string; +} + +export interface BugReportDeps { + /** undefined while the server is down — the command then fails actionably. */ + server(): BugReportServer | undefined; + /** The VS Code workspace folder (the project the app's sessions live in); + * undefined in a no-folder window (→ the server's own cwd scope). */ + workspaceDir(): string | undefined; + /** The active (inspector-selected) run's POINTER — the registry runId, + * relative to the runs root, never an absolute path; undefined when none. */ + activeRunPointer(): string | undefined; + /** DOWN lane to the main app surface (the dock's host). */ + postDown(msg: BugReportDownMessage): void; + showError(message: string): void; + log?(line: string): void; + fetchImpl?: typeof fetch; +} + +/** The bridge sink the chat/deck panels wire into their BridgeIo — the two + * up-kinds, shape-validated by the bridge before they reach here. */ +export interface BugReportBridgeSink { + filed(sessionID: string, url: string): void; + closed(sessionID: string): void; + poke(): void; +} + +export class BugReportManager { + /** The one live bug session (single-open invariant). Cleared BEFORE the + * terminal call on both lifecycle paths, so a late/duplicate message for + * the same id reads as unknown and is dropped. */ + private current?: string; + /** In-flight create+arm — concurrent invocations join it, never double-create. */ + private opening?: Promise; + + constructor(private readonly deps: BugReportDeps) {} + + /** Stable sink object for BridgeIo wiring. */ + readonly sink: BugReportBridgeSink = { + filed: (sessionID, url) => void this.onBugFiled(sessionID, url), + closed: (sessionID) => void this.onBugReportClosed(sessionID), + poke: () => void this.onPoke(), + }; + + /** The app's boot catch-up: it pokes on every boot with the flag on, so a + * lost open-bug-report (cold-boot race, webview reload) self-heals — a live + * bug session re-posts its open; no live session is silence (cheap, once + * per app frame boot). A poke during an in-flight create joins it, exactly + * like a second command invocation. */ + private async onPoke(): Promise { + if (this.opening) await this.opening; + if (!this.current) { + this.deps.log?.(`[bug] poke — no live bug session`); + return; + } + this.deps.log?.(`[bug] poke — re-opening the dock for ${this.current}`); + this.deps.postDown({ source: "amicode", kind: OPEN_BUG_REPORT_KIND, sessionID: this.current }); + } + + /** The `amicode.reportBug` command: reveal the open bug session, else + * create + arm + open a new one. Never two bug sessions. */ + async reportBug(): Promise { + if (this.current) { + // Verify before revealing (amicode#249 QA): a session closed while the + // bridge was down (disposed webview, dead window) leaves `current` + // pinned to a ghost — every later click would reveal nothing and never + // create. A dead memory clears itself here. + const server = this.deps.server(); + const alive = server ? await this.sessionExists(server, this.current) : true; + if (alive) { + this.deps.postDown({ source: "amicode", kind: OPEN_BUG_REPORT_KIND, sessionID: this.current }); + return; + } + this.deps.log?.(`[bug] remembered session ${this.current} is gone — clearing`); + this.current = undefined; + } + if (this.opening) { + // A concurrent invocation joins the in-flight open, then reveals. + await this.opening; + if (this.current) { + this.deps.postDown({ source: "amicode", kind: OPEN_BUG_REPORT_KIND, sessionID: this.current }); + } + return; + } + this.opening = this.open(); + try { + await this.opening; + } finally { + this.opening = undefined; + } + } + + /** Cheap liveness probe for the reveal path (read-only, 404-tolerant). */ + private async sessionExists(server: BugReportServer, sessionID: string): Promise { + try { + const res = await this.fetch(new URL(`/session/${sessionID}`, server.url), server, { method: "GET" }); + return res.ok; + } catch { + return false; + } + } + + // -------- internal -------- + + private async open(): Promise { + const server = this.deps.server(); + if (!server) { + this.deps.showError( + "Amicode: opencode server isn't ready yet. Check the 'Amicode — opencode' output channel.", + ); + return; + } + // Context envelope (pointer-only): assembled BEFORE create so the new bug + // session can never be picked as its own origin. Each field degrades to + // absent rather than failing the report. + const envelope = await this.buildEnvelope(server); + let sessionID: string | undefined; + try { + sessionID = await this.createSession(server, envelope); + await this.armSession(server, sessionID); + } catch (e) { + // No orphans: a created-but-unarmed (or ambiguous) session is deleted. + if (sessionID) await this.deleteSession(server, sessionID); + this.deps.showError(`Amicode: couldn't start the bug report — ${(e as Error).message}`); + return; + } + this.current = sessionID; + this.deps.log?.(`[bug] opened ${sessionID} — posting open-bug-report`); + this.deps.postDown({ source: "amicode", kind: OPEN_BUG_REPORT_KIND, sessionID }); + } + + private async buildEnvelope(server: BugReportServer): Promise> { + const envelope: Record = {}; + const dir = this.deps.workspaceDir(); + if (dir) envelope.project = path.basename(dir); + const runPointer = this.deps.activeRunPointer(); + if (runPointer && !path.isAbsolute(runPointer)) envelope.run_pointer = runPointer; + const origin = await this.findOriginSession(server); + if (origin) envelope.origin_session_id = origin; + return envelope; + } + + /** Session-collection URL scoped to the app's project (the VS Code workspace + * folder) — without it the server defaults to its OWN cwd scope, where the + * user's sessions don't live. Member routes (/session/:id/…) need no scope: + * the server routes those by the session's own directory. */ + private collectionUrl(server: BugReportServer): URL { + const url = new URL("/session", server.url); + const dir = this.deps.workspaceDir(); + if (dir) url.searchParams.set("directory", dir); + return url; + } + + /** The originating session, best-effort: the most recently updated root + * session in the app's project scope that isn't itself a bug session (the + * list is updated-DESC and excludes archived). READ-ONLY provenance — this + * id is never a mutation target (AC6). undefined when unknowable. */ + private async findOriginSession(server: BugReportServer): Promise { + try { + const res = await this.fetch(this.collectionUrl(server), server, { method: "GET" }); + if (!res.ok) return undefined; + const sessions = (await res.json()) as Array<{ + id?: unknown; + parentID?: unknown; + metadata?: unknown; + }>; + if (!Array.isArray(sessions)) return undefined; + const origin = sessions.find( + (s) => + typeof s?.id === "string" && + !s.parentID && + !(s.metadata && typeof s.metadata === "object" && "bug_report" in s.metadata), + ); + return origin?.id as string | undefined; + } catch { + return undefined; // best-effort: a failed list never blocks the report + } + } + + private async createSession(server: BugReportServer, envelope: Record): Promise { + const res = await this.fetch(this.collectionUrl(server), server, { + method: "POST", + body: { + title: BUG_REPORT_TITLE, + metadata: { bug_report: envelope }, + // Hard guardrail: the question tool is hidden from the model for + // bug sessions (amicode#249). The bug dock handles dialogue via the + // permanent textarea + session.prompt, not the question tool's + // structured Q&A. Same pattern as the CLI's non-interactive mode. + permission: [{ permission: "question", pattern: "*", action: "deny" }], + }, + }); + const body = res.ok ? ((await res.json()) as { id?: unknown }) : undefined; + if (!body || typeof body.id !== "string" || body.id === "") { + throw new Error(`session create failed (HTTP ${res.status})`); + } + return body.id; + } + + /** Arm: the report-a-bug slash command as the session's first turn. + * Uses the server's default model (whatever the user has configured). */ + private async armSession(server: BugReportServer, sessionID: string): Promise { + const res = await this.fetch(new URL(`/session/${sessionID}/command`, server.url), server, { + method: "POST", + body: { command: REPORT_A_BUG_SKILL, arguments: "" }, + }); + if (!res.ok) throw new Error(`couldn't arm the report-a-bug skill (HTTP ${res.status})`); + } + + /** Filed → archive (the soft hide, restorable) and close the dock. Unknown + * ids — including a late bug-filed for an already-terminal session — drop. */ + private async onBugFiled(sessionID: string, url: string): Promise { + if (sessionID !== this.current) return; + this.current = undefined; // terminal latch: later messages for this id are unknown + // The url rides the log only; it is app-supplied (LLM-adjacent) — bound it. + this.deps.log?.(`[bug] filed (${url.slice(0, 300)}) — archiving ${sessionID}`); + const server = this.deps.server(); + if (server) { + try { + const res = await this.fetch(new URL(`/session/${sessionID}`, server.url), server, { + method: "PATCH", + body: { time: { archived: Date.now() } }, + }); + if (!res.ok) this.deps.log?.(`[bug] archive failed (HTTP ${res.status}) — closing the dock anyway`); + } catch (e) { + this.deps.log?.(`[bug] archive failed (${(e as Error).message}) — closing the dock anyway`); + } + } + this.deps.postDown({ source: "amicode", kind: CLOSE_BUG_REPORT_KIND, sessionID }); + } + + /** Closed before filing → abort the in-flight turn, then hard delete. */ + private async onBugReportClosed(sessionID: string): Promise { + // Join a concurrent open (amicode#249 QA): the dock's sync watch can + // open the dock before arm completes, and a close arriving in that + // window races open() — this.current is still undefined, the zombie + // guard would reap the in-flight session as an orphan, and open() + // finishes on a deleted session ("opening" + "reaping" in the logs). + // Joining the open first means close always sees the post-open state. + if (this.opening) await this.opening; + if (sessionID !== this.current) { + // Zombie guard (QA: amicode#249 preview): the dock's sync watch can + // surface a bug session ORPHANED by a dead extension host (killed + // mid-session — its manager state died with it). If the user closes + // that dock, dropping the message as "unknown id" would leave an + // immortal session the watch keeps resurrecting. The envelope is the + // ground truth: a session carrying bug_report metadata IS a bug + // session, so the abandon path applies — abort + hard delete. + await this.reapOrphan(sessionID); + return; + } + this.current = undefined; + const server = this.deps.server(); + if (!server) return; + // Abort is best-effort (an idle session may 400 — nothing to abort); the + // hard delete is the guarantee. + try { + await this.fetch(new URL(`/session/${sessionID}/abort`, server.url), server, { method: "POST" }); + } catch (e) { + this.deps.log?.(`[bug] abort failed (${(e as Error).message}) — deleting anyway`); + } + await this.deleteSession(server, sessionID); + } + + /** Close-of-unknown-id: reaps the session iff it proves to be a bug + * session (bug_report metadata) that is NOT archived — an archived one is + * filed and restorable, never a delete target. Genuinely foreign ids drop + * silently. */ + private async reapOrphan(sessionID: string): Promise { + const server = this.deps.server(); + if (!server) return; + let isBug = false; + try { + const res = await this.fetch(new URL(`/session/${sessionID}`, server.url), server, { method: "GET" }); + if (res.ok) { + const info = (await res.json()) as { metadata?: unknown; time?: { archived?: unknown } }; + isBug = + !!info.metadata && + typeof info.metadata === "object" && + "bug_report" in info.metadata && + !info.time?.archived; + } + } catch { + return; // a read failure never guesses at deletion + } + if (!isBug) return; + this.deps.log?.(`[bug] reaping orphaned bug session ${sessionID}`); + try { + await this.fetch(new URL(`/session/${sessionID}/abort`, server.url), server, { method: "POST" }); + } catch { + /* an idle orphan may have nothing to abort */ + } + await this.deleteSession(server, sessionID); + } + + private async deleteSession(server: BugReportServer, sessionID: string): Promise { + try { + const res = await this.fetch(new URL(`/session/${sessionID}`, server.url), server, { method: "DELETE" }); + if (!res.ok) this.deps.log?.(`[bug] delete ${sessionID} failed (HTTP ${res.status})`); + } catch (e) { + this.deps.log?.(`[bug] delete ${sessionID} failed (${(e as Error).message})`); + } + } + + private fetch(url: URL, server: BugReportServer, init: { method: string; body?: unknown }): Promise { + const fetchImpl = this.deps.fetchImpl ?? fetch; + return fetchImpl(url.toString(), { + method: init.method, + headers: { + Authorization: server.authorization, + ...(init.body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}), + }); + } +} + +// -------- module singleton (per-window; the extension host IS the window) -------- + +let active: BugReportManager | undefined; + +/** Register the window's manager (extension activation). Returns it for the + * command wiring. */ +export function registerBugReport(deps: BugReportDeps): BugReportManager { + active = new BugReportManager(deps); + return active; +} + +export function getBugReport(): BugReportManager | undefined { + return active; +} + +export function unregisterBugReport(): void { + active = undefined; +} diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index 1cc9bbe..186bbd4 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -23,23 +23,58 @@ export const BRIDGE_ALLOWED_COMMANDS: ReadonlySet = new Set([ "amicode.savePulse", "amicode.openRunDir", "amicode.openInspector", + // The composer's report-a-bug button (fork #116) posts this over the command + // lane; the registered command owns the bug session end-to-end (#250). + "amicode.reportBug", // ⌘⇧P inside the chat iframe lands in the APP's palette, not VS Code's — // the fork forwards it here so the editor's Command Palette (where every // Amicode: command lives) opens as users expect. "workbench.action.showCommands", ]); +/** The bug-session lifecycle sink (amicode#250) — the panels wire the + * BugReportManager's. Structural, so the bridge never imports the manager. */ +export interface BugReportSink { + filed(sessionID: string, url: string): void; + closed(sessionID: string): void; + poke(): void; +} + /** Side channels the handler needs from its host panel. */ export interface BridgeIo { /** Clipboard reads only answer while the user can see the chat. */ visible(): boolean; /** Replies (clipboard text) go back to the host webview; `tab` echoes along. */ postToWebview(msg: unknown): void; + /** Bug-session lifecycle (bug-filed / bug-report-closed). Undefined until the + * manager registers at activation; the kinds are consumed regardless. */ + bugReport?: BugReportSink; } const isAmicode = (msg: unknown): msg is { source: "amicode"; kind: string; tab?: string } => !!msg && typeof msg === "object" && (msg as { source?: unknown }).source === "amicode"; +/** The optional model selection on the report-a-bug command (amicode#249): + * providerID + modelID + optional variant, all bounded strings. Returns + * undefined for absent/malformed — a bad model field never blocks the + * command; the manager just falls back to the server default. */ +export function extractReportBugModel( + msg: unknown, +): { providerID: string; modelID: string; variant?: string } | undefined { + const model = (msg as { model?: unknown }).model; + if (!model || typeof model !== "object") return undefined; + const providerID = (model as { providerID?: unknown }).providerID; + const modelID = (model as { modelID?: unknown }).modelID; + const variant = (model as { variant?: unknown }).variant; + if (typeof providerID !== "string" || providerID === "" || providerID.length > 200) return undefined; + if (typeof modelID !== "string" || modelID === "" || modelID.length > 200) return undefined; + return { + providerID, + modelID, + ...(typeof variant === "string" && variant !== "" && variant.length <= 200 ? { variant } : {}), + }; +} + /** Handle one envelope from a framed app. Returns true when the message was * consumed (hosts log the rest). */ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean { @@ -141,12 +176,40 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean if (msg.kind === "command") { const command = (msg as unknown as { command?: unknown }).command; if (typeof command === "string" && BRIDGE_ALLOWED_COMMANDS.has(command)) { + // amicode#249 QA: the report-a-bug command may carry the composer's live + // model selection (providerID + modelID + variant — the bug session + // runs what the user was running). Shape-validated, bounded; anything + // malformed is stripped, never fatal to the command. void vscode.commands.executeCommand(command); return true; } return false; } + // Bug-session lifecycle up-kinds (#250): the dock's sentinel watcher reports + // a filing, the close control reports a pre-file abandon. The manager owns + // the known-id check (unknown ids drop there); we only shape-validate. + // Consumed either way — these are our envelopes, never foreign noise. + if (msg.kind === "bug-filed") { + const sessionID = (msg as unknown as { sessionID?: unknown }).sessionID; + const url = (msg as unknown as { url?: unknown }).url; + if (typeof sessionID === "string" && sessionID !== "") { + io.bugReport?.filed(sessionID, typeof url === "string" ? url : ""); + } + return true; + } + if (msg.kind === "bug-report-closed") { + const sessionID = (msg as unknown as { sessionID?: unknown }).sessionID; + if (typeof sessionID === "string" && sessionID !== "") io.bugReport?.closed(sessionID); + return true; + } + // The app's boot catch-up: re-post open-bug-report when a bug session is + // live (heals a lost one-shot open — cold-boot race, webview reload). + if (msg.kind === "bug-report-poke") { + io.bugReport?.poke(); + return true; + } + // Dashboard "Default model" control mirrors its choice into the // amicode.defaultModel setting, so the config pin (headless / first turn) // tracks the UI. "provider/model-id" only, bounded — untrusted. diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index 58d4380..cccf473 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { randomBytes } from "node:crypto"; import { handleAmicodeBridgeMessage } from "./chat_bridge"; +import { getBugReport } from "./bug_report"; // ============================================================================ // ChatPanel — a WebviewPanel that iframes opencode's SolidJS chat at @@ -40,6 +41,10 @@ export class ChatPanel { private static current?: ChatPanel; /** Every live chat tab (primary included) — drives tab-title numbering. */ private static readonly live = new Set(); + /** The `amicode_bug_report=1` boot-param gate (amicode#250 AC5): set from the + * staged skill set after every session prep; the composer button renders + * only when the report-a-bug skill is there to answer it. */ + private static bugReportAvailable = false; private readonly disposables: vscode.Disposable[] = []; private constructor( @@ -72,6 +77,10 @@ export class ChatPanel { const handled = handleAmicodeBridgeMessage(msg, { visible: () => this.panel.visible, postToWebview: (m) => void this.panel.webview.postMessage(m), + // Bug-session lifecycle (#250): the dock's bug-filed / + // bug-report-closed route to the window's manager (undefined until + // activation registers it; the bridge consumes the kinds regardless). + bugReport: getBugReport()?.sink, }); if (!handled) console.log("[amicode/chat] webview msg:", msg); }, @@ -95,6 +104,37 @@ export class ChatPanel { setTimeout(() => void this.panel.webview.postMessage(envelope), 1500); } + /** AC5's gate setter — called after each session prep with + * bugReportSkillStaged(project.skillPaths). */ + static setBugReportAvailable(available: boolean): void { + ChatPanel.bugReportAvailable = available; + } + + /** The primary panel if one is live (never creates) — the down lane's + * fallback when the server is mid-restart and no ready URL exists. */ + static peek(): ChatPanel | undefined { + return ChatPanel.current; + } + + /** DOWN lane for the bug-report dock (amicode#250): open-bug-report / + * close-bug-report. Same idiom as postComputeConnect — posted twice (now + + * 1.5s) because a freshly created panel's iframe may not be listening yet; + * both kinds are idempotent app-side (same-id open = reveal, close of a + * closed dock = no-op), so the re-post is pure reliability. Never throws: + * the panel can be disposed between the two posts (window closing, + * mid-flight reload) — a lost down-post heals via the app's boot poke. */ + postToApp(envelope: { source: "amicode"; kind: string; sessionID: string }): void { + const post = () => { + try { + void this.panel.webview.postMessage(envelope); + } catch { + /* disposed webview — the app's poke/sync-watch covers the loss */ + } + }; + post(); + setTimeout(post, 1500); + } + /** Lowest free tab label: the lone tab reads "Amicode Chat"; extras take the * smallest unused "Amicode Chat N" (N ≥ 2). Numbers free up on dispose, so a * closed tab's number is reused — existing tabs are never retitled. */ @@ -183,6 +223,10 @@ export class ChatPanel { // phantom project. Only amicode sets this — standalone opencode is // unaffected (its cwd IS the user's project). if (hideProjectDir) framed.searchParams.set("amicode_hide_project", hideProjectDir); + // amicode#250 AC5: arm the composer's report-a-bug button ONLY when the + // staged skill set includes report-a-bug. The dock iframe (pane bug-dock) + // never carries this — it is the main app surface's gate alone. + if (ChatPanel.bugReportAvailable) framed.searchParams.set("amicode_bug_report", "1"); return /* html */ ` @@ -216,15 +260,15 @@ export class ChatPanel { replyClipboardImage(d.nonce); return; } - if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model")) { + if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke")) { vscode.postMessage(d); } return; } - // Lane 2 — extension → iframe (theme): posted by the extension host + // Lane 2 — extension → iframe: posted by the extension host // (webview-internal origin, never the opencode origin). Forward only - // our own theme envelope, pinned to the opencode origin. - if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "open-compute-connect")) { + // our own envelopes, pinned to the opencode origin. + if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report")) { var f = document.querySelector("iframe"); if (f && f.contentWindow) f.contentWindow.postMessage(d, ${origin}); } diff --git a/packages/extension/src/deck_panel.ts b/packages/extension/src/deck_panel.ts index 2e17dbb..732513f 100644 --- a/packages/extension/src/deck_panel.ts +++ b/packages/extension/src/deck_panel.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; import { randomBytes } from "node:crypto"; import { handleAmicodeBridgeMessage } from "./chat_bridge"; import { tabIconPath, themeKindToScheme } from "./chat_panel"; +import { getBugReport } from "./bug_report"; // ============================================================================ // DeckPanel — the Chat Deck: MANY chat panes inside ONE editor tab. The heavy @@ -46,6 +47,10 @@ export class DeckPanel { const handled = handleAmicodeBridgeMessage(msg, { visible: () => this.panel.visible, postToWebview: (m) => void this.panel.webview.postMessage(m), + // Bug-session lifecycle (#250) — deck panes never carry the + // amicode_bug_report boot param, so no dock lives here; wired for + // uniformity (the manager drops unknown ids anyway). + bugReport: getBugReport()?.sink, }); if (!handled) console.log("[amicode/deck] webview msg:", msg); }, diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 1af702a..0e5894d 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -56,6 +56,12 @@ import { import { probeCommand, formatHealthReport, type HealthResult } from "./healthcheck"; import { resolveMountStack, personalMount, defaultVaultsRoot } from "./substrate/mount_store"; import { initDistillerTransport, triggerRunDistill, triggerSweep, type DistillerSetup } from "./substrate/distiller"; +import { + registerBugReport, + unregisterBugReport, + bugReportSkillStaged, + REPORT_BUG_COMMAND, +} from "./bug_report"; import * as os from "node:os"; import { readTomlSafe } from "./run_dir_reader"; import { parse as parseYaml } from "yaml"; @@ -453,6 +459,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeChannel.appendLine(`[boot] opencode project dir: ${opencodeProject.projectDir}`); opencodeChannel.appendLine(`[boot] AGENTS.md: ${opencodeProject.agentsPath}`); opencodeChannel.appendLine(`[boot] template: ${opencodeProject.templatePath}`); + // amicode#250 AC5: the composer bug button gates on the staged skill set — + // re-pinned after EVERY session prep (boot, solver switch, vault respawn). + ChatPanel.setBugReportAvailable(bugReportSkillStaged(opencodeProject.skillPaths)); opencodeChannel.appendLine( `[boot] armonia mounts: ${opencodeProject.mounts.length} (${opencodeProject.mounts.map((m) => m.name).join(", ")})`, ); @@ -505,6 +514,34 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // (via the app's ?auth_token= bootstrap). const serverAuthHeaders = { Authorization: serverAuthHeader(serverPassword) }; + // Bug-report orchestration (amicode#250, ADR 0004): the window's ONE + // BugReportManager — owns the bug session's id end-to-end (create / arm / + // open), the machine-managed lifecycle (archive-on-filed, abort+delete on + // abandon), and the single-open invariant. Deps are closures over live + // activation state (ready URL, runs manager), so respawns need no rewire. + const bugReport = registerBugReport({ + server: () => + opencodeReadyUrl ? { url: opencodeReadyUrl.toString(), authorization: serverAuthHeaders.Authorization } : undefined, + workspaceDir: () => vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, + activeRunPointer: () => runsManager?.getActiveRunPointer(), + postDown: (msg) => { + // The dock lives in the main app surface — ensure a chat panel when we + // can (palette invocation with none open), else post to the live one. + const url = opencodeReadyUrl; + const panel = url + ? ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir) + : ChatPanel.peek(); + if (!panel) { + opencodeChannel.appendLine("[bug] no chat surface for the dock message — dropped"); + return; + } + panel.postToApp(msg); + }, + showError: (m) => void vscode.window.showErrorMessage(m), + log: (line) => opencodeChannel.appendLine(line), + }); + ctx.subscriptions.push({ dispose: () => unregisterBugReport() }); + if (binary !== undefined) { // amico-run is argv-only (β.1) — no AMICO_* env propagation (S37), with ONE // recorded exception: AMICO_PYTHON (Pasqal python provisioning) rides the @@ -590,6 +627,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { skillLibraryRoots: cfgLibraryRoots(), vaultDir: vscode.workspace.getConfiguration("amicode").get("vaultDir", "") || undefined, }); + ChatPanel.setBugReportAvailable(bugReportSkillStaged(project2.skillPaths)); // #250 AC5 await serverManager?.stop(); serverManager = new ServerManager({ binary: binary!, @@ -721,6 +759,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vaultDir: vscode.workspace.getConfiguration("amicode").get("vaultDir", "") || undefined, projectDir: path.join((ctx.storageUri ?? ctx.globalStorageUri).fsPath, "opencode-project"), }); + ChatPanel.setBugReportAvailable(bugReportSkillStaged(project2.skillPaths)); // #250 AC5 await serverManager.stop(); serverManager = new ServerManager({ binary, @@ -1104,6 +1143,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } DeckPanel.openOrReveal(ctx, readyUrl, serverAuthToken(serverPassword), opencodeProject.projectDir); }), + // Report a Bug (amicode#250): the palette entry + the composer bug button's + // bridge command share this one handler — the manager owns create/arm/open, + // the lifecycle, and the single-open invariant. + vscode.commands.registerCommand(REPORT_BUG_COMMAND, () => void bugReport.reportBug()), vscode.commands.registerCommand("amicode.openInspector", async () => { await revealInspector(); }), diff --git a/packages/extension/src/runs_manager.ts b/packages/extension/src/runs_manager.ts index fd2fad8..5191b17 100644 --- a/packages/extension/src/runs_manager.ts +++ b/packages/extension/src/runs_manager.ts @@ -308,6 +308,13 @@ export class RunsManager implements vscode.Disposable { return this.selected ? this.registry.get(this.selected)?.runDir : undefined; } + /** The active run as a POINTER (amicode#250's bug-report envelope): the + * registry runId — relative to the runs root by construction, never an + * absolute path. undefined when no run is selected. */ + getActiveRunPointer(): string | undefined { + return this.selected ? this.registry.get(this.selected)?.runId : undefined; + } + /** Release an explicit pin and resume latest-follow: jump to the newest LIVE * run if one exists (registration order = creation order), else stay put. * Backs the run picker's "Follow latest" entry. */ diff --git a/packages/extension/test/bug_report.test.ts b/packages/extension/test/bug_report.test.ts new file mode 100644 index 0000000..83f16be --- /dev/null +++ b/packages/extension/test/bug_report.test.ts @@ -0,0 +1,513 @@ +import { describe, it, expect } from "vitest"; +import * as vscode from "vscode"; +import { BugReportManager, bugReportSkillStaged, type BugReportDeps } from "../src/bug_report"; + +// ============================================================================ +// amicode#250 — the extension owns the bug session end-to-end: create (title +// "Bug report" + the context-envelope metadata), arm (the report-a-bug slash +// command), open (open-bug-report down the bridge), and the machine-managed +// lifecycle (archive on filed, abort+delete on abandon, delete on partial +// failure). The server API is mocked at the fetch seam (cloud_key precedent). +// ============================================================================ + +const SERVER_URL = "http://127.0.0.1:43117/"; +const BOOT_AUTH = "Basic b3BlbmNvZGU6dGVzdC1ib290LXBhc3N3b3Jk"; + +type Call = { method: string; url: string; body?: unknown }; + +/** Fetch stub routing per (method, path-prefix); records every call. */ +function mockFetch(routes: Record): { + fetchImpl: typeof fetch; + calls: Call[]; +} { + const calls: Call[] = []; + const fetchImpl = (async (input: string | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? "GET"; + const body = typeof init?.body === "string" ? (JSON.parse(init.body) as unknown) : undefined; + calls.push({ method, url, body }); + const path = new URL(url).pathname; + const route = routes[`${method} ${path}`]; + const status = route?.status ?? 404; + return { + ok: status >= 200 && status < 300, + status, + json: async () => route?.body, + } as Response; + }) as unknown as typeof fetch; + return { fetchImpl, calls }; +} + +function deps(overrides: Partial, fetchImpl: typeof fetch) { + const posted: unknown[] = []; + const errors: string[] = []; + const d: BugReportDeps = { + server: () => ({ url: SERVER_URL, authorization: BOOT_AUTH }), + workspaceDir: () => "/home/researcher/emerald-q3", + activeRunPointer: () => undefined, + postDown: (m) => posted.push(m), + showError: (m) => errors.push(m), + fetchImpl, + ...overrides, + }; + return { d, posted, errors }; +} + +describe("amicode.reportBug — create, arm, open (AC1)", () => { + it("creates exactly one session with the title + metadata envelope, arms it, and sends one open-bug-report", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [{ id: "ses_origin", time: { created: 1, updated: 2 } }] }, + "POST /session": { status: 200, body: { id: "ses_bug1" } }, + "POST /session/ses_bug1/command": { status: 200, body: {} }, + }); + const { d, posted } = deps({ activeRunPointer: () => "default/20260803-104655-x-gate" }, fetchImpl); + + await new BugReportManager(d).reportBug(); + + const creates = calls.filter((c) => c.method === "POST" && new URL(c.url).pathname === "/session"); + expect(creates).toHaveLength(1); + expect(creates[0].body).toEqual({ + title: "Bug report", + metadata: { + bug_report: { + project: "emerald-q3", + run_pointer: "default/20260803-104655-x-gate", + origin_session_id: "ses_origin", + }, + }, + }); + const arm = calls.filter((c) => c.url.endsWith("/session/ses_bug1/command")); + expect(arm).toHaveLength(1); + expect(arm[0].body).toEqual({ command: "report-a-bug", arguments: "", model: "opencode/deepseek-v4-pro" }); + expect(posted).toEqual([{ source: "amicode", kind: "open-bug-report", sessionID: "ses_bug1" }]); + }); + + it("omits run_pointer when no run is active, and never sends an absolute path", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 200, body: { id: "ses_bug2" } }, + "POST /session/ses_bug2/command": { status: 200, body: {} }, + }); + const { d } = deps({ activeRunPointer: () => undefined }, fetchImpl); + + await new BugReportManager(d).reportBug(); + + const create = calls.find((c) => c.method === "POST" && new URL(c.url).pathname === "/session"); + const meta = (create?.body as { metadata: { bug_report: Record } }).metadata.bug_report; + expect("run_pointer" in meta).toBe(false); + expect(String(meta.run_pointer ?? "")).not.toMatch(/^\//); + }); + + it("authenticates every server call with the per-boot credential (#163)", async () => { + const authed: Array = []; + const fetchImpl = (async (input: string | URL, init?: RequestInit) => { + authed.push((init?.headers as Record | undefined)?.Authorization); + const path = new URL(String(input)).pathname; + const body = path === "/session" && init?.method === "POST" ? { id: "ses_bug3" } : path === "/session" ? [] : {}; + return { ok: true, status: 200, json: async () => body } as Response; + }) as unknown as typeof fetch; + const { d } = deps({}, fetchImpl); + + await new BugReportManager(d).reportBug(); + + expect(authed.length).toBeGreaterThan(0); + expect(new Set(authed)).toEqual(new Set([BOOT_AUTH])); + }); +}); + +/** Drive a manager to the open state with bug session `ses_bug`; returns the + * recorded traffic for the lifecycle assertions. */ +async function openBugSession(overrides: Partial = {}, extraRoutes: Record = {}) { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "GET /session/ses_bug": { status: 200, body: { id: "ses_bug" } }, + "POST /session": { status: 200, body: { id: "ses_bug" } }, + "POST /session/ses_bug/command": { status: 200, body: {} }, + "PATCH /session/ses_bug": { status: 200, body: { id: "ses_bug" } }, + "POST /session/ses_bug/abort": { status: 200, body: true }, + "DELETE /session/ses_bug": { status: 200, body: true }, + ...extraRoutes, + }); + const { d, posted, errors } = deps(overrides, fetchImpl); + const manager = new BugReportManager(d); + await manager.reportBug(); + calls.length = 0; + posted.length = 0; + return { manager, calls, posted, errors }; +} + +describe("bug-session lifecycle (AC2)", () => { + it("bug-filed for the known id archives the session (soft hide) and tells the app to close the dock", async () => { + const { manager, calls, posted } = await openBugSession(); + + manager.sink.filed("ses_bug", "https://github.com/harmoniqs/amicode/issues/251"); + await new Promise((r) => setTimeout(r, 0)); + + const archive = calls.filter((c) => c.method === "PATCH" && c.url.endsWith("/session/ses_bug")); + expect(archive).toHaveLength(1); + const archived = (archive[0].body as { time: { archived: unknown } }).time.archived; + expect(typeof archived).toBe("number"); // ms epoch — the soft-hide timestamp + // No abort, no delete on the filed path — the transcript stays restorable. + expect(calls.filter((c) => c.method === "DELETE")).toEqual([]); + expect(calls.filter((c) => c.url.endsWith("/abort"))).toEqual([]); + expect(posted).toEqual([ + { source: "amicode", kind: "close-bug-report", sessionID: "ses_bug" }, + ]); + }); + + it("bug-report-closed before any filing aborts, then deletes, the session", async () => { + const { manager, calls, posted } = await openBugSession(); + + manager.sink.closed("ses_bug"); + await new Promise((r) => setTimeout(r, 0)); + + const methods = calls.map((c) => `${c.method} ${new URL(c.url).pathname}`); + expect(methods).toEqual(["POST /session/ses_bug/abort", "DELETE /session/ses_bug"]); + // Never archived on the abandon path; the dock already dismissed itself. + expect(calls.filter((c) => c.method === "PATCH")).toEqual([]); + expect(posted).toEqual([]); + }); +}); + +describe("the originating session is never modified, navigated, or closed (AC6)", () => { + it("no path mutates the origin session — it is only ever READ (the list call) and embedded as a pointer", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { + status: 200, + body: [ + { id: "ses_origin", time: { created: 1, updated: 9 } }, + { id: "ses_older", time: { created: 1, updated: 2 } }, + ], + }, + "POST /session": { status: 200, body: { id: "ses_bug" } }, + "POST /session/ses_bug/command": { status: 200, body: {} }, + "PATCH /session/ses_bug": { status: 200, body: { id: "ses_bug" } }, + "POST /session/ses_bug/abort": { status: 200, body: true }, + "DELETE /session/ses_bug": { status: 200, body: true }, + }); + const { d, posted } = deps({}, fetchImpl); + const manager = new BugReportManager(d); + + // Full lifecycle coverage: open → filed; open again → abandoned. + await manager.reportBug(); + manager.sink.filed("ses_bug", "https://github.com/harmoniqs/amicode/issues/251"); + await new Promise((r) => setTimeout(r, 0)); + await manager.reportBug(); + manager.sink.closed("ses_bug"); + await new Promise((r) => setTimeout(r, 0)); + + // The envelope DID carry the origin pointer (provenance works)… + const creates = calls.filter((c) => c.method === "POST" && new URL(c.url).pathname === "/session"); + for (const create of creates) { + expect((create.body as { metadata: { bug_report: { origin_session_id?: string } } }).metadata.bug_report.origin_session_id).toBe( + "ses_origin", + ); + } + // …but the only call that so much as NAMES a non-bug session is the + // read-only list. Every mutation targets the bug session alone. + const foreign = calls.filter((c) => c.url.includes("ses_origin") || c.url.includes("ses_older")); + expect(foreign).toEqual([]); + for (const c of calls.filter((c) => c.method !== "GET")) { + // POST /session is the bug-session CREATE (the one session we may make); + // every other mutation carries the bug id and nothing else. + expect(new URL(c.url).pathname).toMatch(/^\/session(\/ses_bug(\/command|\/abort)?)?$/); + } + // And nothing reaches for a navigation/session-switch command — the main + // chat continues uninterrupted while the dock lives (and dies). + expect((vscode.commands as unknown as { executed: string[] }).executed ?? []).toEqual([]); + // The down lane carries ONLY dock open/close for the bug session — never + // an instruction about the origin. + for (const m of posted) { + expect((m as { sessionID: string }).sessionID).toBe("ses_bug"); + expect(["open-bug-report", "close-bug-report"]).toContain((m as { kind: string }).kind); + } + }); +}); + +describe("bugReportSkillStaged — the boot-param gate (AC5)", () => { + it("true iff a staged skill path belongs to report-a-bug (library or package layout)", () => { + expect( + bugReportSkillStaged([ + "/ext/vendor/skills-public/skills/transmon/SKILL.md", + "/ext/vendor/skills-public/skills/report-a-bug/SKILL.md", + ]), + ).toBe(true); + expect(bugReportSkillStaged(["/home/dev/harmoniqs/packages/Piccolissimo.jl/skills/report-a-bug/SKILL.md"])).toBe(true); + expect(bugReportSkillStaged(["/ext/vendor/skills-public/skills/transmon/SKILL.md"])).toBe(false); + expect(bugReportSkillStaged([])).toBe(false); + }); +}); + +describe("single-open invariant (AC3)", () => { + it("a second invocation while a bug session is open reveals with the SAME id — never a second create", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "GET /session/ses_bug": { status: 200, body: { id: "ses_bug" } }, + "POST /session": { status: 200, body: { id: "ses_bug" } }, + "POST /session/ses_bug/command": { status: 200, body: {} }, + }); + const { d, posted } = deps({}, fetchImpl); + const manager = new BugReportManager(d); + + await manager.reportBug(); + await manager.reportBug(); + await manager.reportBug(); + + expect(calls.filter((c) => c.method === "POST" && new URL(c.url).pathname === "/session")).toHaveLength(1); + expect(posted).toEqual([ + { source: "amicode", kind: "open-bug-report", sessionID: "ses_bug" }, + { source: "amicode", kind: "open-bug-report", sessionID: "ses_bug" }, + { source: "amicode", kind: "open-bug-report", sessionID: "ses_bug" }, + ]); + }); + + it("concurrent invocations join the in-flight open — exactly one session created", async () => { + let releaseCreate: (() => void) | undefined; + const gate = new Promise((r) => (releaseCreate = r)); + const calls: Call[] = []; + const fetchImpl = (async (input: string | URL, init?: RequestInit) => { + const url = String(input); + const path = new URL(url).pathname; + calls.push({ method: init?.method ?? "GET", url }); + if (init?.method === "POST" && path === "/session") await gate; // hold the create + const body = path === "/session" && init?.method === "POST" ? { id: "ses_bug" } : []; + return { ok: true, status: 200, json: async () => body } as Response; + }) as unknown as typeof fetch; + const { d, posted } = deps({}, fetchImpl); + const manager = new BugReportManager(d); + + const first = manager.reportBug(); + const second = manager.reportBug(); + releaseCreate!(); + await Promise.all([first, second]); + + expect(calls.filter((c) => c.method === "POST" && new URL(c.url).pathname === "/session")).toHaveLength(1); + // Both callers end at the open dock: one create-post, one reveal-post. + expect(posted).toEqual([ + { source: "amicode", kind: "open-bug-report", sessionID: "ses_bug" }, + { source: "amicode", kind: "open-bug-report", sessionID: "ses_bug" }, + ]); + }); +}); + +describe("failure cleanup + unknown-id drops (AC4)", () => { + it("an arming failure deletes the partial session and surfaces an error — no orphan, no open message", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 200, body: { id: "ses_bug" } }, + "POST /session/ses_bug/command": { status: 500, body: { error: "boom" } }, + "DELETE /session/ses_bug": { status: 200, body: true }, + }); + const { d, posted, errors } = deps({}, fetchImpl); + + await new BugReportManager(d).reportBug(); + + expect(calls.filter((c) => c.method === "DELETE" && c.url.endsWith("/session/ses_bug"))).toHaveLength(1); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("couldn't start the bug report"); + expect(posted).toEqual([]); + }); + + it("a create failure surfaces an error and never opens; a network throw on create also notifies", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 503, body: {} }, + }); + const { d, posted, errors } = deps({}, fetchImpl); + + await new BugReportManager(d).reportBug(); + + expect(errors).toHaveLength(1); + expect(posted).toEqual([]); + // Nothing was created — there is no id to delete, and none is attempted. + expect(calls.filter((c) => c.method === "DELETE")).toEqual([]); + + const throwing = (async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch; + const second = deps({}, throwing); + await new BugReportManager(second.d).reportBug(); + expect(second.errors).toHaveLength(1); + expect(second.posted).toEqual([]); + }); + + it("with the server down the command fails actionably and touches nothing", async () => { + const { fetchImpl, calls } = mockFetch({}); + const { d, posted, errors } = deps({ server: () => undefined }, fetchImpl); + + await new BugReportManager(d).reportBug(); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("isn't ready"); + expect(calls).toEqual([]); + expect(posted).toEqual([]); + }); + + it("bridge messages for unknown session ids are dropped — no archive, no abort, no delete, no down-post", async () => { + const { manager, calls, posted } = await openBugSession(); + + manager.sink.filed("ses_someone_else", "https://github.com/x/issues/9"); + manager.sink.closed("ses_someone_else"); + await new Promise((r) => setTimeout(r, 0)); + + // The zombie guard may READ an unknown closed session (metadata probe) — + // but a 404/foreign session is never mutated, and nothing posts down. + expect(calls.filter((c) => c.method !== "GET")).toEqual([]); + expect(posted).toEqual([]); + }); + + it("a bug-report-closed arriving AFTER filing (the filed end-state's close) is already-terminal — dropped", async () => { + const { manager, calls, posted } = await openBugSession({}, { + // The session now reads archived (filed): the zombie guard must NOT + // reap it — archived bug sessions are restorable, never delete targets. + "GET /session/ses_bug": { status: 200, body: { id: "ses_bug", metadata: { bug_report: {} }, time: { archived: 1700000000000 } } }, + }); + + manager.sink.filed("ses_bug", "filed-via-browser"); + await new Promise((r) => setTimeout(r, 0)); + calls.length = 0; + posted.length = 0; + + manager.sink.closed("ses_bug"); // late close for the archived session + await new Promise((r) => setTimeout(r, 0)); + + expect(calls.filter((c) => c.method !== "GET")).toEqual([]); // no abort, no delete — the filed session is never hard-deleted + expect(posted).toEqual([]); + }); + + it("a duplicate bug-filed for the same id archives exactly once", async () => { + const { manager, calls, posted } = await openBugSession(); + + manager.sink.filed("ses_bug", "https://github.com/x/issues/1"); + manager.sink.filed("ses_bug", "https://github.com/x/issues/1"); + await new Promise((r) => setTimeout(r, 0)); + + expect(calls.filter((c) => c.method === "PATCH")).toHaveLength(1); + expect(posted).toHaveLength(1); + }); +}); + +describe("bug-report-poke — the app's boot catch-up (QA: lost-open race)", () => { it("a poke with a live bug session re-posts open-bug-report for it", async () => { + const { manager, posted } = await openBugSession(); + + manager.sink.poke(); + await new Promise((r) => setTimeout(r, 0)); + + expect(posted).toEqual([{ source: "amicode", kind: "open-bug-report", sessionID: "ses_bug" }]); + }); + + it("a poke with no live bug session is silence (no posts, no server calls)", async () => { + const { fetchImpl, calls } = mockFetch({}); + const { d, posted } = deps({}, fetchImpl); + + new BugReportManager(d).sink.poke(); + await new Promise((r) => setTimeout(r, 0)); + + expect(posted).toEqual([]); + expect(calls).toEqual([]); + }); + + it("a poke after the session terminated is silence (the dock stays gone)", async () => { + const { manager, posted } = await openBugSession(); + manager.sink.filed("ses_bug", "https://github.com/x/issues/1"); + await new Promise((r) => setTimeout(r, 0)); + posted.length = 0; + + manager.sink.poke(); + await new Promise((r) => setTimeout(r, 0)); + + expect(posted).toEqual([]); + }); + + it("a poke during an in-flight create joins it — one create, every open for the new id", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 200, body: { id: "ses_bug" } }, + "POST /session/ses_bug/command": { status: 200, body: {} }, + }); + const { d, posted } = deps({}, fetchImpl); + const manager = new BugReportManager(d); + const opening = manager.reportBug(); // in flight — the poke must not double-create + manager.sink.poke(); + await opening; + await new Promise((r) => setTimeout(r, 0)); + + const creates = calls.filter((c) => c.method === "POST" && new URL(c.url).pathname === "/session"); + expect(creates).toHaveLength(1); + const opens = posted.filter((m) => (m as { kind?: unknown }).kind === "open-bug-report"); + // The open from create+arm plus the poke's re-post — same id, idempotent + // app-side (same-id open is a reveal). + expect(opens).toHaveLength(2); + expect(opens.every((m) => (m as { sessionID?: unknown }).sessionID === "ses_bug")).toBe(true); + }); +}); + +describe("zombie guard — closing an orphaned bug session (QA: amicode#249 preview)", () => { + it("an unknown-id close reaps the session when it carries the bug_report envelope", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session/ses_orphan": { status: 200, body: { id: "ses_orphan", metadata: { bug_report: { project: "x" } } } }, + "POST /session/ses_orphan/abort": { status: 200, body: true }, + "DELETE /session/ses_orphan": { status: 200, body: true }, + }); + const { d } = deps({}, fetchImpl); + + new BugReportManager(d).sink.closed("ses_orphan"); + await new Promise((r) => setTimeout(r, 0)); + + const methods = calls.map((c) => `${c.method} ${new URL(c.url).pathname}`); + expect(methods).toEqual(["GET /session/ses_orphan", "POST /session/ses_orphan/abort", "DELETE /session/ses_orphan"]); + }); + + it("an unknown-id close for a genuinely foreign session is dropped — never a delete", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session/ses_chat": { status: 200, body: { id: "ses_chat", metadata: {} } }, + }); + const { d } = deps({}, fetchImpl); + + new BugReportManager(d).sink.closed("ses_chat"); + await new Promise((r) => setTimeout(r, 0)); + + expect(calls.filter((c) => c.method === "DELETE")).toEqual([]); + expect(calls.filter((c) => c.url.endsWith("/abort"))).toEqual([]); + }); +}); + +describe("the ghost-session guard (amicode#249 QA: closed while the bridge was down)", () => { + it("a remembered session that 404s clears itself and creates fresh", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 200, body: { id: "ses_old" } }, + "POST /session/ses_old/command": { status: 200, body: {} }, + // ses_old is NOT registered as GET-able → 404 on the reveal probe + }); + const { d, posted } = deps({}, fetchImpl); + const manager = new BugReportManager(d); + await manager.reportBug(); // opens ses_old + + posted.length = 0; + calls.length = 0; + // Second click: the remembered session is gone (the GET 404s) → a fresh + // session must be created, never a reveal of the ghost. + const { fetchImpl: f2, calls: calls2 } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 200, body: { id: "ses_new" } }, + "POST /session/ses_new/command": { status: 200, body: {} }, + }); + (d as { fetchImpl?: typeof fetch }).fetchImpl = f2; + await manager.reportBug(); + + expect(calls2.filter((c) => c.method === "POST" && new URL(c.url).pathname === "/session")).toHaveLength(1); + expect(posted).toEqual([{ source: "amicode", kind: "open-bug-report", sessionID: "ses_new" }]); + }); + + it("a live remembered session reveals as before (probe 200 → no create)", async () => { + const { manager, calls, posted } = await openBugSession(); + + await manager.reportBug(); + + expect(posted).toEqual([{ source: "amicode", kind: "open-bug-report", sessionID: "ses_bug" }]); + expect(calls.filter((c) => c.method === "POST" && new URL(c.url).pathname === "/session")).toEqual([]); + // The liveness probe is the only new call — read-only. + expect(calls.filter((c) => c.method !== "GET")).toEqual([]); + }); +}); diff --git a/packages/extension/test/chat_bridge.test.ts b/packages/extension/test/chat_bridge.test.ts index ea75c60..979175c 100644 --- a/packages/extension/test/chat_bridge.test.ts +++ b/packages/extension/test/chat_bridge.test.ts @@ -130,6 +130,14 @@ describe("amicode bridge — commands & settings", () => { expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "command", command: "workbench.action.terminal.kill" }, host)).toBe(false); }); + it("allowlists amicode.reportBug — the composer bug button's command lane (amicode#250)", async () => { + const host = io(); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "command", command: "amicode.reportBug" }, host)).toBe(true); + await flush(); + const ran = (vscode.commands as unknown as { executed: string[] }).executed ?? []; + expect(ran).toContain("amicode.reportBug"); + }); + it("set-default-model accepts provider/model-id shapes and mirrors them to config", () => { const host = io(); expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "set-default-model", model: "anthropic/claude-sonnet-5" }, host)).toBe(true); @@ -145,3 +153,82 @@ describe("amicode bridge — commands & settings", () => { expect(handleAmicodeBridgeMessage("a string", host)).toBe(false); }); }); + +describe("amicode bridge — bug-report lifecycle kinds (amicode#250)", () => { + /** BridgeIo with the bug-report sink wired (the panels pass the manager's). */ + function ioWithSink(visible = true) { + const host = io(visible); + const filed: Array<{ sessionID: string; url: string }> = []; + const closed: string[] = []; + let pokes = 0; + host.bugReport = { + filed: (sessionID, url) => filed.push({ sessionID, url }), + closed: (sessionID) => closed.push(sessionID), + poke: () => { + pokes += 1; + }, + }; + return { host, filed, closed, pokes: () => pokes }; + } + + it("bug-filed routes sessionID + url to the sink (the browser-fallback token included)", () => { + const { host, filed } = ioWithSink(); + expect( + handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-filed", sessionID: "ses_1", url: "https://github.com/x/issues/1" }, host), + ).toBe(true); + expect( + handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-filed", sessionID: "ses_2", url: "filed-via-browser" }, host), + ).toBe(true); + expect(filed).toEqual([ + { sessionID: "ses_1", url: "https://github.com/x/issues/1" }, + { sessionID: "ses_2", url: "filed-via-browser" }, + ]); + }); + + it("bug-report-closed routes the sessionID to the sink", () => { + const { host, closed } = ioWithSink(); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-report-closed", sessionID: "ses_9" }, host)).toBe(true); + expect(closed).toEqual(["ses_9"]); + }); + + it("bug-report-poke routes to the sink's catch-up (consumed, payload-free)", () => { + const { host, pokes } = ioWithSink(); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-report-poke" }, host)).toBe(true); + expect(pokes()).toBe(1); + // Without a sink: still consumed, never foreign-noise. + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-report-poke" }, io())).toBe(true); + }); + + it("malformed lifecycle envelopes are consumed and dropped — the sink never fires", () => { + const { host, filed, closed } = ioWithSink(); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-filed", url: "https://x.test/1" }, host)).toBe(true); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-filed", sessionID: 7, url: "u" }, host)).toBe(true); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-report-closed" }, host)).toBe(true); + expect(filed).toEqual([]); + expect(closed).toEqual([]); + }); + + it("without a sink the kinds are still consumed (never fall through to foreign-envelope logging)", () => { + const host = io(); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-filed", sessionID: "ses_1", url: "u" }, host)).toBe(true); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-report-closed", sessionID: "ses_1" }, host)).toBe(true); + }); +}); + +describe("extractReportBugModel — the command's optional model payload (amicode#249)", () => { + it("passes a well-formed selection; strips malformed ones; tolerates absence", () => { + expect(extractReportBugModel({ model: { providerID: "opencode-go", modelID: "kimi-k3", variant: "default" } })).toEqual({ + providerID: "opencode-go", + modelID: "kimi-k3", + variant: "default", + }); + expect(extractReportBugModel({ model: { providerID: "opencode-go", modelID: "kimi-k3" } })).toEqual({ + providerID: "opencode-go", + modelID: "kimi-k3", + }); + expect(extractReportBugModel({})).toBeUndefined(); + expect(extractReportBugModel({ model: "kimi-k3" })).toBeUndefined(); + expect(extractReportBugModel({ model: { providerID: 7, modelID: "x" } })).toBeUndefined(); + expect(extractReportBugModel({ model: { providerID: "p" } })).toBeUndefined(); + }); +}); diff --git a/packages/extension/test/chat_panel.test.ts b/packages/extension/test/chat_panel.test.ts index 6a48e78..0b1744e 100644 --- a/packages/extension/test/chat_panel.test.ts +++ b/packages/extension/test/chat_panel.test.ts @@ -74,3 +74,47 @@ describe("ChatPanel — auth_token carriage to the fork app (#163)", () => { expect(iframeSrc(cap.created[0].webview.html).searchParams.has("auth_token")).toBe(false); }); }); + +describe("ChatPanel — the amicode_bug_report boot param (amicode#250 AC5)", () => { + let restore: (() => void) | undefined; + let created: CapturedPanel[] = []; + afterEach(() => { + for (const p of created) p.dispose(); + restore?.(); + restore = undefined; + created = []; + ChatPanel.setBugReportAvailable(false); // static feature flag — reset between tests + }); + + it("sets amicode_bug_report=1 on the iframe src when the staged skills include report-a-bug", () => { + const cap = capturePanel(); + restore = cap.restore; + created = cap.created; + ChatPanel.setBugReportAvailable(true); + ChatPanel.openOrReveal(fakeCtx(), new URL("http://127.0.0.1:43117/")); + expect(iframeSrc(cap.created[0].webview.html).searchParams.get("amicode_bug_report")).toBe("1"); + }); + + it("omits the param when report-a-bug was not staged — no dead button on Marketplace installs", () => { + const cap = capturePanel(); + restore = cap.restore; + created = cap.created; + ChatPanel.setBugReportAvailable(false); + ChatPanel.openOrReveal(fakeCtx(), new URL("http://127.0.0.1:43117/")); + expect(iframeSrc(cap.created[0].webview.html).searchParams.has("amicode_bug_report")).toBe(false); + }); + + it("the relay admits the bug-report kinds on both lanes", () => { + const cap = capturePanel(); + restore = cap.restore; + created = cap.created; + ChatPanel.openOrReveal(fakeCtx(), new URL("http://127.0.0.1:43117/")); + const html = cap.created[0].webview.html; + // Lane 1 (app → extension): the dock's lifecycle reports. + expect(html).toContain('"bug-filed"'); + expect(html).toContain('"bug-report-closed"'); + // Lane 2 (extension → app): dock open/close. + expect(html).toContain('"open-bug-report"'); + expect(html).toContain('"close-bug-report"'); + }); +}); diff --git a/packages/extension/test/runs_manager.test.ts b/packages/extension/test/runs_manager.test.ts index b2d781e..7100967 100644 --- a/packages/extension/test/runs_manager.test.ts +++ b/packages/extension/test/runs_manager.test.ts @@ -122,6 +122,21 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { m.dispose(); }); + it("getActiveRunPointer is the selected run's runId (relative pointer, never an absolute path) or undefined (amicode#250)", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + expect(m.getActiveRunPointer()).toBeUndefined(); // nothing tracked yet + stageRun(root, "default/20260803-104655-x-gate"); + tick(m); + expect(m.selectedRun).toBe("default/20260803-104655-x-gate"); + const pointer = m.getActiveRunPointer(); + expect(pointer).toBe("default/20260803-104655-x-gate"); + expect(pointer).not.toContain(root); // pointer form only — the bug-report envelope + expect(pointer!.startsWith("/")).toBe(false); + m.dispose(); + }); + it("live tail forwards meta and each record in order as they land (#66), runId-tagged", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); const run = stageRun(root, "p1");