Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions packages/extension/src/bug_report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
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})`);
}
Expand Down
3 changes: 3 additions & 0 deletions packages/extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
},
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<string>("defaultModel", "").trim() || undefined,
});
ctx.subscriptions.push({ dispose: () => unregisterBugReport() });

Expand Down
37 changes: 37 additions & 0 deletions packages/extension/test/bug_report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function deps(overrides: Partial<BugReportDeps>, fetchImpl: typeof fetch) {
postDown: (m) => posted.push(m),
showError: (m) => errors.push(m),
fetchImpl,
defaultModel: () => "opencode/deepseek-v4-pro",
...overrides,
};
return { d, posted, errors };
Expand Down Expand Up @@ -75,13 +76,49 @@ 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);
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 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: [] },
Expand Down
2 changes: 1 addition & 1 deletion packages/extension/test/chat_bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading