diff --git a/packages/extension/src/onboarding_routing.ts b/packages/extension/src/onboarding_routing.ts new file mode 100644 index 00000000..b99f5a5e --- /dev/null +++ b/packages/extension/src/onboarding_routing.ts @@ -0,0 +1,175 @@ +// Onboarding routing — session auto-launch and routing logic (#434) +// +// Pure routing predicate + launcher with at-most-once guard. +// Given (modelConfigured, welcomeShown, onboardingCompleted, partialStage), +// determines the correct action for the session. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface OnboardingFlags { + /** True if the opencode config has at least one provider entry with credentials. */ + modelConfigured: boolean; + /** True if the Stage 0 welcome animation has been played this install. */ + welcomeShown: boolean; + /** True if the full onboarding flow (through Stage 8) has completed. */ + onboardingCompleted: boolean; + /** If partially completed, the last finished stage number (1-based). undefined = none. */ + partialStage: number | undefined; +} + +/** The action the activation flow should take. */ +export type OnboardingAction = + | "show-webview" // No model configured → open Stage 0 webview + | "open-chat" // Model present, onboarding not done → open chat (overture runs inside) + | "resume-chat-at-stage" // Model present, partial progress → open chat at resume point + | "normal-session"; // Onboarding complete → normal session (no onboarding UI) + +// ─── Routing predicate (pure, testable) ────────────────────────────────────── + +/** Determine what the session should do at activation. + * This is a PURE function of its inputs — no side effects, no I/O. */ +export function resolveOnboardingAction(flags: OnboardingFlags): OnboardingAction { + // Terminal state: onboarding is done → normal session + if (flags.onboardingCompleted) return "normal-session"; + + // No model → must configure before chat can work + if (!flags.modelConfigured) return "show-webview"; + + // Model present, partial progress → resume + if (flags.partialStage !== undefined) return "resume-chat-at-stage"; + + // Model present, no progress → start the agentic onboarding in chat + return "open-chat"; +} + +// ─── Model-presence check ──────────────────────────────────────────────────── + +/** Check if the opencode config has a model/provider configured. + * Reads the config file at the given path (default: ~/.config/opencode/opencode.json). + * Returns true if there's at least one provider entry. */ +export function isModelConfigured( + configPath: string = defaultConfigPath(), +): boolean { + try { + if (!fs.existsSync(configPath)) return false; + const content = fs.readFileSync(configPath, "utf8"); + // Strip single-line comments for JSONC tolerance + const stripped = content.replace(/^\s*\/\/.*$/gm, ""); + const config = JSON.parse(stripped) as Record; + const provider = config.provider; + if (!provider || typeof provider !== "object") return false; + return Object.keys(provider as object).length > 0; + } catch { + return false; + } +} + +/** Also check secondary locations where env-based providers resolve: + * - process.env has ANTHROPIC_API_KEY, OPENAI_API_KEY, etc. + * These are resolved by opencode itself at runtime. */ +export function hasProviderEnvVar(): boolean { + const keys = [ + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GOOGLE_API_KEY", + "AWS_ACCESS_KEY_ID", + "OPENROUTER_API_KEY", + ]; + return keys.some((k) => { + const v = process.env[k]; + return typeof v === "string" && v.trim() !== ""; + }); +} + +// ─── welcome_shown persistence ─────────────────────────────────────────────── + +const WELCOME_STATE_FILE = "onboarding_state.json"; + +/** Read whether the welcome animation has been shown (persisted across sessions). */ +export function readWelcomeShown( + statePath: string = path.join(os.homedir(), ".amico", "amicode", WELCOME_STATE_FILE), +): boolean { + try { + const data = JSON.parse(fs.readFileSync(statePath, "utf8")) as Record; + return data.welcome_shown === true; + } catch { + return false; + } +} + +/** Mark the welcome animation as shown. */ +export function writeWelcomeShown( + statePath: string = path.join(os.homedir(), ".amico", "amicode", WELCOME_STATE_FILE), +): void { + try { + fs.mkdirSync(path.dirname(statePath), { recursive: true }); + let existing: Record = {}; + try { + existing = JSON.parse(fs.readFileSync(statePath, "utf8")); + } catch { /* fresh file */ } + fs.writeFileSync(statePath, JSON.stringify({ ...existing, welcome_shown: true }, null, 2) + "\n"); + } catch { + // Non-critical — don't crash the extension + } +} + +// ─── Launcher (at-most-once guard) ────────────────────────────────────────── + +export interface LauncherCallbacks { + resolveFlags: () => OnboardingFlags; + showWebview: () => void; + openChat: () => void; + openChatAtStage: (stage: number) => void; +} + +/** Encapsulates the at-most-once launch logic for a VS Code window. + * Calling tryLaunch() multiple times fires the action only once. + * After webview success, onWebviewSuccess() opens chat. */ +export class OnboardingLauncher { + private launched = false; + private callbacks: LauncherCallbacks; + + constructor(callbacks: LauncherCallbacks) { + this.callbacks = callbacks; + } + + /** Attempt to launch the onboarding flow. Fires at most once per instance. */ + tryLaunch(): void { + if (this.launched) return; + + const flags = this.callbacks.resolveFlags(); + const action = resolveOnboardingAction(flags); + + if (action === "normal-session") return; // nothing to do + + this.launched = true; + + switch (action) { + case "show-webview": + this.callbacks.showWebview(); + break; + case "open-chat": + this.callbacks.openChat(); + break; + case "resume-chat-at-stage": + this.callbacks.openChatAtStage(flags.partialStage ?? 1); + break; + } + } + + /** Called when the Stage 0 webview completes successfully. + * Transitions to the chat panel. */ + onWebviewSuccess(): void { + this.callbacks.openChat(); + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function defaultConfigPath(): string { + return path.join(os.homedir(), ".config", "opencode", "opencode.json"); +} diff --git a/packages/extension/test/onboarding_routing.test.ts b/packages/extension/test/onboarding_routing.test.ts new file mode 100644 index 00000000..46b070e0 --- /dev/null +++ b/packages/extension/test/onboarding_routing.test.ts @@ -0,0 +1,239 @@ +// Onboarding auto-launch and session routing tests (#434) +// +// Tests the routing predicate (pure), the at-most-once guard, the model-presence +// check, and the Stage 0 → chat transition wiring. + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import * as vscode from "vscode"; + +import { + type OnboardingFlags, + resolveOnboardingAction, + type OnboardingAction, + isModelConfigured, + OnboardingLauncher, + readWelcomeShown, + writeWelcomeShown, +} from "../src/onboarding_routing"; + +// ─── AC10: Routing predicate (pure function, table-driven) ─────────────────── + +describe("resolveOnboardingAction — routing predicate (AC10)", () => { + const cases: Array<{ name: string; flags: OnboardingFlags; expected: OnboardingAction }> = [ + { + name: "fresh install, no model → show-webview", + flags: { modelConfigured: false, welcomeShown: false, onboardingCompleted: false, partialStage: undefined }, + expected: "show-webview", + }, + { + name: "no model, welcome already shown → show-webview (need model before chat)", + flags: { modelConfigured: false, welcomeShown: true, onboardingCompleted: false, partialStage: undefined }, + expected: "show-webview", + }, + { + name: "model configured, no onboarding done → open-chat (overture will run inside)", + flags: { modelConfigured: true, welcomeShown: false, onboardingCompleted: false, partialStage: undefined }, + expected: "open-chat", + }, + { + name: "model configured, partial at stage 2 → resume-chat-at-stage", + flags: { modelConfigured: true, welcomeShown: true, onboardingCompleted: false, partialStage: 2 }, + expected: "resume-chat-at-stage", + }, + { + name: "model configured, onboarding completed → normal-session", + flags: { modelConfigured: true, welcomeShown: true, onboardingCompleted: true, partialStage: undefined }, + expected: "normal-session", + }, + { + name: "onboarding completed (regardless of other flags) → normal-session", + flags: { modelConfigured: true, welcomeShown: false, onboardingCompleted: true, partialStage: undefined }, + expected: "normal-session", + }, + { + name: "model configured, welcome shown, no partial stage → open-chat", + flags: { modelConfigured: true, welcomeShown: true, onboardingCompleted: false, partialStage: undefined }, + expected: "open-chat", + }, + ]; + + for (const { name, flags, expected } of cases) { + it(name, () => { + expect(resolveOnboardingAction(flags)).toBe(expected); + }); + } +}); + +// ─── AC9: isModelConfigured ────────────────────────────────────────────────── + +describe("isModelConfigured — model-presence check (AC9)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "model-chk-")); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns false when config file does not exist", () => { + expect(isModelConfigured(path.join(tmpDir, "nonexistent.json"))).toBe(false); + }); + + it("returns false when config has no provider section", () => { + fs.writeFileSync(path.join(tmpDir, "config.json"), JSON.stringify({ model: "x/y" })); + expect(isModelConfigured(path.join(tmpDir, "config.json"))).toBe(false); + }); + + it("returns false when provider section is empty", () => { + fs.writeFileSync(path.join(tmpDir, "config.json"), JSON.stringify({ provider: {} })); + expect(isModelConfigured(path.join(tmpDir, "config.json"))).toBe(false); + }); + + it("returns true when provider section has at least one entry", () => { + fs.writeFileSync( + path.join(tmpDir, "config.json"), + JSON.stringify({ provider: { anthropic: { apiKey: "sk-x" } } }), + ); + expect(isModelConfigured(path.join(tmpDir, "config.json"))).toBe(true); + }); + + it("returns true for JSONC content (ignores comments gracefully)", () => { + // The actual config might be opencode.jsonc — we strip comments or use tolerant parse + fs.writeFileSync( + path.join(tmpDir, "config.json"), + '{\n "provider": { "anthropic": {} }\n}\n', + ); + expect(isModelConfigured(path.join(tmpDir, "config.json"))).toBe(true); + }); +}); + +// ─── AC4: At-most-once guard ───────────────────────────────────────────────── + +describe("OnboardingLauncher — at-most-once guard (AC4)", () => { + it("fires the launch callback at most once per instance", () => { + const launches: string[] = []; + const launcher = new OnboardingLauncher({ + resolveFlags: () => ({ + modelConfigured: false, + welcomeShown: false, + onboardingCompleted: false, + partialStage: undefined, + }), + showWebview: () => { launches.push("webview"); }, + openChat: () => { launches.push("chat"); }, + openChatAtStage: () => { launches.push("resume"); }, + }); + + launcher.tryLaunch(); + launcher.tryLaunch(); + launcher.tryLaunch(); + + expect(launches).toEqual(["webview"]); // only once + }); + + it("does not fire for normal-session action", () => { + const launches: string[] = []; + const launcher = new OnboardingLauncher({ + resolveFlags: () => ({ + modelConfigured: true, + welcomeShown: true, + onboardingCompleted: true, + partialStage: undefined, + }), + showWebview: () => { launches.push("webview"); }, + openChat: () => { launches.push("chat"); }, + openChatAtStage: () => { launches.push("resume"); }, + }); + + launcher.tryLaunch(); + expect(launches).toEqual([]); // normal session → no action + }); + + it("routes to openChat when model is configured", () => { + const launches: string[] = []; + const launcher = new OnboardingLauncher({ + resolveFlags: () => ({ + modelConfigured: true, + welcomeShown: false, + onboardingCompleted: false, + partialStage: undefined, + }), + showWebview: () => { launches.push("webview"); }, + openChat: () => { launches.push("chat"); }, + openChatAtStage: () => { launches.push("resume"); }, + }); + + launcher.tryLaunch(); + expect(launches).toEqual(["chat"]); + }); + + it("routes to openChatAtStage for partial state", () => { + const launches: string[] = []; + const launcher = new OnboardingLauncher({ + resolveFlags: () => ({ + modelConfigured: true, + welcomeShown: true, + onboardingCompleted: false, + partialStage: 3, + }), + showWebview: () => { launches.push("webview"); }, + openChat: () => { launches.push("chat"); }, + openChatAtStage: (_n) => { launches.push("resume"); }, + }); + + launcher.tryLaunch(); + expect(launches).toEqual(["resume"]); + }); +}); + +// ─── AC5: Stage 0 success → chat auto-open ─────────────────────────────────── + +describe("OnboardingLauncher — webview success triggers chat (AC5)", () => { + it("onWebviewSuccess opens chat", () => { + const launches: string[] = []; + const launcher = new OnboardingLauncher({ + resolveFlags: () => ({ + modelConfigured: false, + welcomeShown: false, + onboardingCompleted: false, + partialStage: undefined, + }), + showWebview: () => { launches.push("webview"); }, + openChat: () => { launches.push("chat"); }, + openChatAtStage: () => { launches.push("resume"); }, + }); + + launcher.tryLaunch(); // shows webview + expect(launches).toEqual(["webview"]); + + launcher.onWebviewSuccess(); // webview completed → open chat + expect(launches).toEqual(["webview", "chat"]); + }); +}); + +// ─── AC6: welcome_shown persistence ────────────────────────────────────────── + +describe("welcome_shown flag semantics (AC6)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "welcome-")); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("reading from non-existent file returns false", () => { + expect(readWelcomeShown(path.join(tmpDir, "state.json"))).toBe(false); + }); + + it("writing and reading round-trips", () => { + const file = path.join(tmpDir, "state.json"); + writeWelcomeShown(file); + expect(readWelcomeShown(file)).toBe(true); + }); +});