From 2028412e2f534a7b14451d627e473dad532b4911 Mon Sep 17 00:00:00 2001 From: Gennadi Ryan Date: Fri, 7 Aug 2026 07:33:42 +0000 Subject: [PATCH] feat(extension): bug-session orchestration + lifecycle (#251), chore(opencode.lock): pin to v1.18.10-amicode.4 (#245) --- packages/extension/src/bug_report.ts | 20 +++++++++-- packages/extension/src/extension.ts | 3 ++ packages/extension/test/bug_report.test.ts | 37 +++++++++++++++++++++ packages/extension/test/chat_bridge.test.ts | 2 +- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/extension/src/bug_report.ts b/packages/extension/src/bug_report.ts index ff43bd4..ad8856c 100644 --- a/packages/extension/src/bug_report.ts +++ b/packages/extension/src/bug_report.ts @@ -79,6 +79,16 @@ export interface BugReportDeps { showError(message: string): void; log?(line: string): void; fetchImpl?: typeof fetch; + /** The session-level model pin as `provider/model`, or undefined to let the + * server resolve its own default. Same rule as everywhere else in the + * extension: ONLY an explicit `amicode.defaultModel` pins (see + * extension.ts's boot/restart pins) — we never guess a model. + * + * This is the SESSION-level half of the model handoff. The COMPOSER-level + * half — carrying the user's live in-chat selection onto the command, via + * `extractReportBugModel` in chat_bridge.ts — is deliberately not wired + * here; it crosses the iframe boundary and is tracked separately. */ + defaultModel?(): string | undefined; } /** The bridge sink the chat/deck panels wire into their BridgeIo — the two @@ -262,11 +272,17 @@ export class BugReportManager { } /** Arm: the report-a-bug slash command as the session's first turn. - * Uses the server's default model (whatever the user has configured). */ + * + * `model` is optional on POST /session/:id/command (a `provider/model` + * string; the route also takes `variant`). We send it only when + * `amicode.defaultModel` is explicitly set — otherwise the field is omitted + * entirely and the server resolves its own default, which is the documented + * behaviour for an unpinned install. */ private async armSession(server: BugReportServer, sessionID: string): Promise { + const model = this.deps.defaultModel?.()?.trim(); const res = await this.fetch(new URL(`/session/${sessionID}/command`, server.url), server, { method: "POST", - body: { command: REPORT_A_BUG_SKILL, arguments: "" }, + body: { command: REPORT_A_BUG_SKILL, arguments: "", ...(model ? { model } : {}) }, }); if (!res.ok) throw new Error(`couldn't arm the report-a-bug skill (HTTP ${res.status})`); } diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 0e5894d..2f77b71 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -539,6 +539,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }, showError: (m) => void vscode.window.showErrorMessage(m), log: (line) => opencodeChannel.appendLine(line), + // Same pin rule as the boot/restart config pins above: ONLY an explicit + // amicode.defaultModel pins a model; empty means "let the server decide". + defaultModel: () => vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || undefined, }); ctx.subscriptions.push({ dispose: () => unregisterBugReport() }); diff --git a/packages/extension/test/bug_report.test.ts b/packages/extension/test/bug_report.test.ts index 83f16be..fd939e8 100644 --- a/packages/extension/test/bug_report.test.ts +++ b/packages/extension/test/bug_report.test.ts @@ -48,6 +48,7 @@ function deps(overrides: Partial, fetchImpl: typeof fetch) { postDown: (m) => posted.push(m), showError: (m) => errors.push(m), fetchImpl, + defaultModel: () => "opencode/deepseek-v4-pro", ...overrides, }; return { d, posted, errors }; @@ -75,6 +76,11 @@ describe("amicode.reportBug — create, arm, open (AC1)", () => { origin_session_id: "ses_origin", }, }, + // The question tool is denied for bug sessions (ADR-0004): the dock owns + // dialogue via its textarea + session.prompt, not the question tool's + // structured Q&A. Production has sent this since #251; the assertion + // simply had not caught up. + permission: [{ permission: "question", pattern: "*", action: "deny" }], }); const arm = calls.filter((c) => c.url.endsWith("/session/ses_bug1/command")); expect(arm).toHaveLength(1); @@ -82,6 +88,37 @@ describe("amicode.reportBug — create, arm, open (AC1)", () => { expect(posted).toEqual([{ source: "amicode", kind: "open-bug-report", sessionID: "ses_bug1" }]); }); + it("omits model entirely when amicode.defaultModel is unset — the server resolves its own", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 200, body: { id: "ses_bug_nm" } }, + "POST /session/ses_bug_nm/command": { status: 200, body: {} }, + }); + const { d } = deps({ defaultModel: () => undefined }, fetchImpl); + + await new BugReportManager(d).reportBug(); + + const arm = calls.filter((c) => c.url.endsWith("/session/ses_bug_nm/command")); + expect(arm).toHaveLength(1); + // Omitted, not sent as empty/null: `model` is optional on the route, and an + // empty string would pin the session to a nonexistent model. + expect(arm[0].body).toEqual({ command: "report-a-bug", arguments: "" }); + }); + + it("treats a whitespace-only defaultModel as unset", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 200, body: { id: "ses_bug_ws" } }, + "POST /session/ses_bug_ws/command": { status: 200, body: {} }, + }); + const { d } = deps({ defaultModel: () => " " }, fetchImpl); + + await new BugReportManager(d).reportBug(); + + const arm = calls.filter((c) => c.url.endsWith("/session/ses_bug_ws/command")); + expect(arm[0].body).toEqual({ command: "report-a-bug", arguments: "" }); + }); + 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: [] }, diff --git a/packages/extension/test/chat_bridge.test.ts b/packages/extension/test/chat_bridge.test.ts index 979175c..271dba0 100644 --- a/packages/extension/test/chat_bridge.test.ts +++ b/packages/extension/test/chat_bridge.test.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { handleAmicodeBridgeMessage, type BridgeIo } from "../src/chat_bridge"; +import { handleAmicodeBridgeMessage, extractReportBugModel, type BridgeIo } from "../src/chat_bridge"; // ============================================================================ // The shared iframe⇄extension bridge: strict allowlists, https-only externals,