Skip to content

Commit 32dcf1e

Browse files
committed
feat(doctor): doctor-gated onboarding — interactive preflight with install guidance and auto-promote
Ships the Growth Bet 1 intervention end-to-end: - DoctorModal surveys all 14 agent backends with per-OS install commands (previously only claude-code and codex had hints; 12 others fell back to generic prose). - Three severity rules drive the modal UX: default missing → FAIL (blocks session, non-dismissible modal); default present but others missing → WARN (informational amber banner); all needed backends installed → silent. - Auto-promote on install rc=0: one targeted shutil.which recheck, writes Settings.default_agent_backend, emits BACKEND_AUTO_PROMOTED telemetry, dismisses the modal if no FAILs remain. - Telemetry to measure activation lift: DOCTOR_WARNED on every doctor run (failing_check_names / warn_count / fail_count) and FIRST_SESSION_SUCCESS on first completed session per install. - cli/doctor.py refactor: 445 → 233 lines; env/plugin checks split into their own collectors, doctor.py becomes a thin orchestrator. - Typed BackendCommand schema (frozen dataclass, per-OS actions mapping, "*" fallback) replaces the ad-hoc install_hint/auth_hint strings. Metric read scheduled for 2026-05-03 via analytics_session_timeline.
1 parent d771418 commit 32dcf1e

48 files changed

Lines changed: 5299 additions & 314 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/vscode/src/api/client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
CreateTaskInput,
66
DiffFile,
77
DiffStats,
8+
DoctorReportResponse,
89
ReviewDecisionInput,
910
ReviewDecisionResponse,
1011
ReviewStatusResponse,
@@ -293,6 +294,10 @@ export class KaganClient {
293294
await this.getSettings();
294295
}
295296

297+
getDoctor(): Promise<DoctorReportResponse> {
298+
return this.get<DoctorReportResponse>("/api/doctor");
299+
}
300+
296301
private async get<T>(path: string): Promise<T> {
297302
return this.request<T>("GET", path);
298303
}

packages/vscode/src/api/types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,3 +285,21 @@ export interface SSESessionEvent {
285285
}
286286

287287
export type SSEMessage = SSETaskUpdated | SSESessionEvent;
288+
289+
// Doctor / preflight
290+
export interface DoctorCheckResponse {
291+
name: string;
292+
status: string;
293+
message: string;
294+
fix_hint: string;
295+
verify_hint: string;
296+
category: string;
297+
is_blocking: boolean;
298+
}
299+
300+
export interface DoctorReportResponse {
301+
checks: DoctorCheckResponse[];
302+
ok: boolean;
303+
fail_count: number;
304+
warn_count: number;
305+
}

packages/vscode/src/extension.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { ReviewCommentProvider, ReviewDocumentProvider } from "./providers/review.comments.js";
1515
import { AgentTerminalProvider } from "./providers/tasks.terminal.js";
1616
import { registerChatParticipant } from "./providers/chat.participant.js";
17+
import { DoctorStatusProvider } from "./providers/doctor.status.js";
1718
import { StatusBar } from "./status/bar.js";
1819
import { SSE_TYPE, type SSEMessage } from "./api/types.js";
1920
import { LocalServerSupervisor } from "./server/supervisor.js";
@@ -36,6 +37,7 @@ export function activate(context: vscode.ExtensionContext): void {
3637
const reviewProvider = new ReviewCommentProvider();
3738
const terminalProvider = new AgentTerminalProvider(client);
3839
const statusBar = new StatusBar();
40+
const doctorStatus = new DoctorStatusProvider(client, statusBar);
3941
const serverLog = vscode.window.createOutputChannel("Kagan Server");
4042
const serverSupervisor = new LocalServerSupervisor(serverLog);
4143

@@ -162,19 +164,25 @@ export function activate(context: vscode.ExtensionContext): void {
162164
statusBar.showDisconnected();
163165
void vscode.commands.executeCommand("setContext", "kagan.connected", false);
164166

165-
if (config.get<boolean>("autoConnect", true)) {
166-
void connect(
167-
client,
168-
sse,
169-
boardProvider,
170-
statusBar,
171-
serverLog,
172-
serverSupervisor,
173-
getServerLaunchSettings(),
174-
);
175-
}
167+
// Preflight runs first so its showReady/Degraded/SetupNeeded cannot clobber
168+
// the task-count display that connect() writes via showConnected().
169+
void (async () => {
170+
await doctorStatus.runPreflight();
171+
172+
if (config.get<boolean>("autoConnect", true)) {
173+
void connect(
174+
client,
175+
sse,
176+
boardProvider,
177+
statusBar,
178+
serverLog,
179+
serverSupervisor,
180+
getServerLaunchSettings(),
181+
);
182+
}
176183

177-
void detectAttachContext(client, sse);
184+
void detectAttachContext(client, sse);
185+
})();
178186

179187
function getServerLaunchSettings(): { autoStartServer: boolean; serverCommand: string } {
180188
const nextConfig = vscode.workspace.getConfiguration("kagan");
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
// vscode must be mocked before importing any module that imports it.
4+
// vi.mock is hoisted — factory cannot reference outer-scope variables.
5+
vi.mock("vscode", () => {
6+
const ThemeColor = class {
7+
constructor(public readonly id: string) {}
8+
};
9+
10+
const statusBarItem = {
11+
text: "",
12+
tooltip: undefined as string | undefined,
13+
backgroundColor: undefined as unknown,
14+
command: undefined as string | undefined,
15+
show: vi.fn(),
16+
hide: vi.fn(),
17+
dispose: vi.fn(),
18+
};
19+
20+
const terminal = {
21+
show: vi.fn(),
22+
sendText: vi.fn(),
23+
};
24+
25+
return {
26+
window: {
27+
createStatusBarItem: vi.fn(() => statusBarItem),
28+
showWarningMessage: vi.fn(),
29+
createTerminal: vi.fn(() => terminal),
30+
},
31+
workspace: {
32+
getConfiguration: vi.fn(() => ({
33+
get: (_key: string, def: unknown) => def,
34+
})),
35+
},
36+
env: {
37+
openExternal: vi.fn(),
38+
},
39+
StatusBarAlignment: { Left: 1 },
40+
ThemeColor,
41+
Uri: {
42+
parse: (s: string) => ({ toString: () => s }),
43+
},
44+
};
45+
});
46+
47+
import * as vscode from "vscode";
48+
import { DoctorStatusProvider } from "./doctor.status.js";
49+
import { StatusBar } from "../status/bar.js";
50+
import type { KaganClient } from "../api/client.js";
51+
import type { DoctorReportResponse } from "../api/types.js";
52+
53+
function makeReport(overrides: Partial<DoctorReportResponse> = {}): DoctorReportResponse {
54+
return {
55+
checks: [],
56+
ok: true,
57+
fail_count: 0,
58+
warn_count: 0,
59+
...overrides,
60+
};
61+
}
62+
63+
function makeClient(report?: DoctorReportResponse | null): Pick<KaganClient, "getDoctor"> {
64+
if (report === null) {
65+
return { getDoctor: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")) };
66+
}
67+
return { getDoctor: vi.fn().mockResolvedValue(report ?? makeReport()) };
68+
}
69+
70+
describe("DoctorStatusProvider", () => {
71+
let statusBar: StatusBar;
72+
73+
beforeEach(() => {
74+
vi.clearAllMocks();
75+
statusBar = new StatusBar();
76+
});
77+
78+
afterEach(() => {
79+
vi.restoreAllMocks();
80+
});
81+
82+
it("shows ready when all checks pass", async () => {
83+
const client = makeClient(makeReport({ ok: true, fail_count: 0, warn_count: 0 }));
84+
const provider = new DoctorStatusProvider(client as unknown as KaganClient, statusBar);
85+
86+
await provider.runPreflight();
87+
88+
const item = (vscode.window.createStatusBarItem as ReturnType<typeof vi.fn>).mock.results[0]
89+
.value as { text: string };
90+
expect(item.text).toBe("Kagan: ready");
91+
expect(vscode.window.showWarningMessage).not.toHaveBeenCalled();
92+
});
93+
94+
it("shows degraded and no notification on WARN", async () => {
95+
const client = makeClient(makeReport({ ok: true, fail_count: 0, warn_count: 2 }));
96+
const provider = new DoctorStatusProvider(client as unknown as KaganClient, statusBar);
97+
98+
await provider.runPreflight();
99+
100+
const item = (vscode.window.createStatusBarItem as ReturnType<typeof vi.fn>).mock.results[0]
101+
.value as { text: string };
102+
expect(item.text).toBe("$(alert) Kagan: degraded");
103+
expect(vscode.window.showWarningMessage).not.toHaveBeenCalled();
104+
});
105+
106+
it("shows setup needed and fires notification on FAIL", async () => {
107+
vi.mocked(vscode.window.showWarningMessage).mockResolvedValue(undefined as never);
108+
109+
const client = makeClient(makeReport({ ok: false, fail_count: 1, warn_count: 0 }));
110+
const provider = new DoctorStatusProvider(client as unknown as KaganClient, statusBar);
111+
112+
await provider.runPreflight();
113+
114+
const item = (vscode.window.createStatusBarItem as ReturnType<typeof vi.fn>).mock.results[0]
115+
.value as { text: string };
116+
expect(item.text).toBe("$(warning) Kagan: setup needed");
117+
await vi.waitFor(() =>
118+
expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(
119+
"Kagan: setup needed — one or more required checks failed.",
120+
"Open TUI",
121+
"Open Web",
122+
),
123+
);
124+
});
125+
126+
it("does not show notification on WARN (only on FAIL)", async () => {
127+
const client = makeClient(makeReport({ ok: true, fail_count: 0, warn_count: 3 }));
128+
const provider = new DoctorStatusProvider(client as unknown as KaganClient, statusBar);
129+
130+
await provider.runPreflight();
131+
132+
expect(vscode.window.showWarningMessage).not.toHaveBeenCalled();
133+
});
134+
135+
it("opens a terminal with 'kagan tui' when Open TUI is chosen", async () => {
136+
vi.mocked(vscode.window.showWarningMessage).mockResolvedValue("Open TUI" as never);
137+
138+
const client = makeClient(makeReport({ ok: false, fail_count: 1, warn_count: 0 }));
139+
const provider = new DoctorStatusProvider(client as unknown as KaganClient, statusBar);
140+
141+
await provider.runPreflight();
142+
await vi.waitFor(() => expect(vscode.window.createTerminal).toHaveBeenCalled());
143+
144+
expect(vscode.window.createTerminal).toHaveBeenCalledWith({ name: "Kagan TUI" });
145+
const terminal = vi.mocked(vscode.window.createTerminal).mock.results[0].value as {
146+
show: () => void;
147+
sendText: (text: string, addNewLine: boolean) => void;
148+
};
149+
expect(terminal.show).toHaveBeenCalled();
150+
expect(terminal.sendText).toHaveBeenCalledWith("kagan tui", true);
151+
});
152+
153+
it("calls openExternal when Open Web is chosen", async () => {
154+
vi.mocked(vscode.window.showWarningMessage).mockResolvedValue("Open Web" as never);
155+
156+
const client = makeClient(makeReport({ ok: false, fail_count: 1, warn_count: 0 }));
157+
const provider = new DoctorStatusProvider(client as unknown as KaganClient, statusBar);
158+
159+
await provider.runPreflight();
160+
await vi.waitFor(() => expect(vscode.env.openExternal).toHaveBeenCalled());
161+
162+
const calledWith = vi.mocked(vscode.env.openExternal).mock.calls[0][0] as {
163+
toString(): string;
164+
};
165+
expect(calledWith.toString()).toBe("http://localhost:8765");
166+
});
167+
168+
it("silently stays offline when server is unreachable", async () => {
169+
const client = makeClient(null);
170+
const provider = new DoctorStatusProvider(client as unknown as KaganClient, statusBar);
171+
172+
// Must not throw and must not show a notification.
173+
await expect(provider.runPreflight()).resolves.toBeUndefined();
174+
expect(vscode.window.showWarningMessage).not.toHaveBeenCalled();
175+
});
176+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// DoctorStatusProvider — calls GET /api/doctor once on activation and
2+
// reflects the result in the status bar. On FAIL, surfaces a Quick Fix
3+
// notification with "Open TUI" and "Open Web" actions.
4+
//
5+
// Runs exactly once; does not poll. Server-unreachable errors are caught
6+
// and mapped to the "offline" state — no unhandled exceptions.
7+
8+
import * as vscode from "vscode";
9+
import type { KaganClient } from "../api/client.js";
10+
import type { StatusBar } from "../status/bar.js";
11+
12+
export class DoctorStatusProvider {
13+
constructor(
14+
private readonly client: KaganClient,
15+
private readonly statusBar: StatusBar,
16+
) {}
17+
18+
async runPreflight(): Promise<void> {
19+
let report: Awaited<ReturnType<KaganClient["getDoctor"]>>;
20+
try {
21+
report = await this.client.getDoctor();
22+
} catch {
23+
// Server unreachable or returned an unexpected error — stay offline.
24+
return;
25+
}
26+
27+
const failCount = report.fail_count;
28+
const warnCount = report.warn_count;
29+
30+
if (failCount > 0) {
31+
this.statusBar.showSetupNeeded(failCount);
32+
void this.showFailNotification();
33+
return;
34+
}
35+
36+
if (warnCount > 0) {
37+
this.statusBar.showDegraded(warnCount);
38+
return;
39+
}
40+
41+
this.statusBar.showReady();
42+
}
43+
44+
private async showFailNotification(): Promise<void> {
45+
const choice = await vscode.window.showWarningMessage(
46+
"Kagan: setup needed — one or more required checks failed.",
47+
"Open TUI",
48+
"Open Web",
49+
);
50+
51+
if (choice === "Open TUI") {
52+
const terminal = vscode.window.createTerminal({ name: "Kagan TUI" });
53+
terminal.show();
54+
terminal.sendText("kagan tui", true);
55+
return;
56+
}
57+
58+
if (choice === "Open Web") {
59+
const config = vscode.workspace.getConfiguration("kagan");
60+
const serverUrl = config.get<string>("serverUrl", "localhost:8765");
61+
const protocol = config.get<string>("protocol", "http");
62+
await vscode.env.openExternal(vscode.Uri.parse(`${protocol}://${serverUrl}`));
63+
}
64+
}
65+
}

packages/vscode/src/status/bar.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,32 @@ export class StatusBar implements vscode.Disposable {
4949
this.item.show();
5050
}
5151

52+
// Doctor health states — called once on activation after GET /api/doctor.
53+
54+
showReady(): void {
55+
this.item.text = "Kagan: ready";
56+
this.item.tooltip = "All checks passed";
57+
this.item.backgroundColor = undefined;
58+
this.item.command = undefined;
59+
this.item.show();
60+
}
61+
62+
showDegraded(warnCount: number): void {
63+
this.item.text = "$(alert) Kagan: degraded";
64+
this.item.tooltip = `${warnCount} warning${warnCount === 1 ? "" : "s"} — run 'kagan doctor' for details`;
65+
this.item.backgroundColor = new vscode.ThemeColor("statusBarItem.warningBackground");
66+
this.item.command = undefined;
67+
this.item.show();
68+
}
69+
70+
showSetupNeeded(failCount: number): void {
71+
this.item.text = "$(warning) Kagan: setup needed";
72+
this.item.tooltip = `${failCount} check${failCount === 1 ? "" : "s"} failed — run 'kagan doctor' for details`;
73+
this.item.backgroundColor = new vscode.ThemeColor("statusBarItem.warningBackground");
74+
this.item.command = undefined;
75+
this.item.show();
76+
}
77+
5278
dispose(): void {
5379
this.item.dispose();
5480
}

0 commit comments

Comments
 (0)