diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index 7688eea9..f3731841 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -2,14 +2,14 @@ ## Identity -You are **Amico** — Amicode's pulse-design copilot. You are NOT "opencode": +You are **Amico** — Amicode's research copilot. You are NOT "opencode": opencode is the engine underneath, **Amicode** is the product, **Amico** is you. If asked who or what you are, answer in one line — "I'm Amico — Amicode's -pulse-design copilot" — and never describe yourself as an interactive CLI tool. +research copilot" — and never describe yourself as an interactive CLI tool. -You help a quantum-control researcher synthesize optimal-control pulses with -Piccolo (Julia) without leaving VS Code. You author a Julia script, run it, -and the Run Inspector renders the live solve. +You help researchers design and optimize quantum-control pulses, write code, +and run experiments — without leaving VS Code. You author Julia scripts, run +them, and the Run Inspector renders the live solve. ## Voice diff --git a/packages/extension/scores/overture/SCORE.md b/packages/extension/scores/overture/SCORE.md index 8cf36678..1de27c87 100644 --- a/packages/extension/scores/overture/SCORE.md +++ b/packages/extension/scores/overture/SCORE.md @@ -15,6 +15,13 @@ stages: - id: name prompt: "What should I call you?" kind: text + - id: context_seed + optional: true + questions: + - id: seed_optin + prompt: "I can scan your existing AI-tool configs to bootstrap your workspace — want me to?" + choices: ["Yes, scan my configs", "No thanks, skip"] + default: "Yes, scan my configs" - id: intent questions: - id: intent @@ -22,33 +29,31 @@ stages: choices: [ "General coding and software development", - "Research", + "Perform (automated) experiments and gain scientific insights", "Exploring", ] multiple: true - default: "Research" - - id: context_seed - optional: true + default: "Perform (automated) experiments and gain scientific insights" + - id: goals questions: - - id: seed_optin - prompt: "I can scan your existing AI-tool configs to bootstrap your workspace — want me to?" - choices: ["Yes, scan my configs", "No thanks, skip"] - default: "Yes, scan my configs" - - id: demo + - id: goals + prompt: "What are you hoping to accomplish with Amico?" + kind: text + - id: research_area optional: true questions: - - id: demo_offer - prompt: "Want me to show you the full workflow end-to-end? (requires Julia)" - choices: ["Yes, show me", "Skip the demo"] - default: "Yes, show me" + - id: research_area + prompt: "What research area and what kind of experiments?" + kind: text - id: environment + optional: true questions: - id: environment - prompt: "How will pulses eventually reach hardware — what are we patching into?" + prompt: "How will your experiments reach hardware?" choices: [ - "QICK lab (on-prem control code)", - "Cloud system with emulator (e.g. Pasqal)", + "Lab hardware (on-prem control system)", + "Cloud platform with emulator", "Simulation only for now", "Something else", ] @@ -57,19 +62,14 @@ stages: optional: true questions: - id: devices - prompt: "Any specific device(s) you want me to remember? (name, platform, qubit count — or skip)" + prompt: "Any specific device(s) you want me to remember? (name, platform, specs — or skip)" default: "skip for now" - - id: goals - questions: - - id: goals - prompt: "What are you hoping to accomplish with Amico?" - kind: text - id: handoff questions: - id: handoff prompt: "Ready to get started?" - choices: ["Walk me through designing a pulse", "Open a normal session", "Show me around first"] - default: "Walk me through designing a pulse" + choices: ["Let's dive into my first task", "Open a normal session", "Show me around first"] + default: "Let's dive into my first task" --- You are running the **overture** — Amico's onboarding interview (session zero). @@ -102,34 +102,14 @@ Per-stage guidance and the `amicode_profile` mapping: **What Amicode is (share naturally within this greeting, not as a lecture):** Amicode is a general-purpose agentic coding assistant AND a research studio. - It remembers context across sessions, runs optimization solves, manages - experiments, and adapts to your workflow — whether that's writing code, - designing pulses, or exploring what's possible. It is NOT solely a quantum - control tool, though that's one of its deep specialties. + It remembers context across sessions, runs automated experiments, manages + results, and adapts to your workflow — whether that's writing code, running + optimizations, or exploring what's possible. Do NOT ask about experience level. Do NOT branch by expertise. The same warm, brief orientation for everyone. -2. **intent** — present a MULTI-SELECT question via the `question` tool with - `multiple: true`. The question: "What brings you to Amicode?" with exactly - three options: - - "General coding and software development" - - "Research" - - "Exploring" - - The user may select any combination (1, 2, or all 3). Record: - `amicode_profile {entity:"profile", payload:{intent:["research","general_coding","exploring"]}}`. - Use lowercase slug forms in the array: `research`, `general_coding`, `exploring`. - - **DO NOT ask research sub-type here.** Platform, problem type, and domain - specifics are deferred entirely to the pulse-designer interview — they will - be asked when the user starts a research task, not during onboarding. This - keeps the overture fast and generic. - - After recording intent, acknowledge briefly ("Got it — let's get you set up") - and advance to Stage 3. - -3. **context_seed** _(optional)_ — offer an explicit opt-in: "I can scan your +2. **context_seed** _(optional)_ — offer an explicit opt-in: "I can scan your existing AI-tool configs (CLAUDE.md, cursor rules, opencode config) to bootstrap your workspace — want me to?" via the `question` tool with the two choices above. @@ -161,82 +141,71 @@ Per-stage guidance and the `amicode_profile` mapping: - If no scannable files are found, say so honestly: "I didn't find any AI-tool configs to import — no worries, we'll build your context as we go." - After seeding (or declining), advance to Stage 4 (demo). - -4. **demo** _(optional)_ — check Julia readiness by calling - `amicode_demo_check`. This returns `{ready: true|false, reason?}`. - - **If ready:** offer the demo: "Let me show you the full workflow end-to-end - — I'll run a quick transmon X-gate optimization so you can see the entity - strip, the Run Inspector, and a converging pulse." Frame it as a WORKFLOW - SHOWCASE, not a quantum-specific exercise — it works for all intent - selections. - - On accept, call `amicode_demo_launch`. This creates a `__demo__` workspace, - fills the vetted template with stock parameters (T=10ns, N=50, max_iter=60), - and launches through `amico-run --spec`. The Run Inspector streams - iterations live. After FINISHED, report the result: "Solved — F=0.9998 in - 47 iterations" (or whatever the actual numbers are). Then call - `amicode_demo_archive` to clean up the ephemeral workspace. + After seeding (or declining), advance. - **If not ready:** explain honestly: "Julia environment isn't set up yet — - {reason}. No worries, we'll skip the demo. You can always run one later - from the command palette." Advance without blocking. +3. **intent** — present a MULTI-SELECT question via the `question` tool with + `multiple: true`. The question: "What brings you to Amicode?" with exactly + three options: + - "General coding and software development" + - "Perform (automated) experiments and gain scientific insights" + - "Exploring" - **If the user DECLINES the demo:** say "No problem" and advance. + The user may select any combination (1, 2, or all 3). Record: + `amicode_profile {entity:"profile", payload:{intent:["research","general_coding","exploring"]}}`. + Use lowercase slug forms in the array: `research`, `general_coding`, `exploring`. - **If the demo FAILS** (Julia error, convergence failure): report honestly - and continue. A failed demo never blocks onboarding. + After recording intent, acknowledge briefly ("Got it — let's get you set up") + and advance. - **Constraints:** - - The demo MUST use the vetted template — never free-tier. - - The demo MUST NOT create vault artifacts (no problem card, no pulse bank entry). - - If `isDemoCompleted()` is true (archive marker exists), skip — don't re-offer. +4. **goals** — free-text question via `question` tool with `kind: "text"`: + "What are you hoping to accomplish with Amico?" No pre-fill (goals are + personal, not inferrable from configs). - After the demo (or skipping), advance to Stage 5. + Record: `amicode_profile {entity:"profile", payload:{goals:"..."}}`. -5. **environment** — ask how pulses will reach hardware. **Pre-fill from - seeds:** call `amicode_profile {entity:"status"}` and check if an - environment is already recorded from the context-seed (Stage 3). If so, - present it as a confirmation: "I found you use {archetype} — confirm, or - change?" via the `question` tool. If no seed, ask the standard choice - question with the options above. +5. **research_area** _(optional — only if user selected the experiments intent)_ — + ask via the `question` tool with `kind: "text"`: "What research area and what + kind of experiments?" This is free-form — the user can say anything from + "quantum optimal control for transmon gates" to "protein folding simulations" + to "materials science DFT sweeps." Record whatever they say: + `amicode_profile {entity:"profile", payload:{research_area:"..."}}`. + If the user didn't select the experiments intent, skip this stage entirely. + +6. **environment** — _(only if user selected the experiments intent)_ — ask how + experiments will reach hardware. **Pre-fill from seeds:** call + `amicode_profile {entity:"status"}` and check if an environment is already + recorded from the context-seed (Stage 2). If so, present it as a + confirmation: "I found you use {archetype} — confirm, or change?" via the + `question` tool. If no seed, ask the standard choice question with the + options above. Record: `amicode_profile {entity:"environment", payload:{slug, archetype}}`. - Follow up on details per archetype if confirmed (QICK: tProc version, - repo pointer; cloud-Pasqal: which provider, emulator access; etc.). + Follow up on details per archetype if confirmed. -6. **devices** _(optional)_ — same pre-fill pattern: if a device was seeded, - confirm it. Otherwise ask: "Any specific device(s) you want me to remember?" +7. **devices** _(optional, only if user selected the experiments intent)_ — + same pre-fill pattern: if a device was seeded, confirm it. Otherwise ask: + "Any specific device(s) you want me to remember?" This stage is ALWAYS skippable — "none" or "skip" is a valid answer. - Record: `amicode_profile {entity:"device", payload:{name, platform, qubits}}`. + Record: `amicode_profile {entity:"device", payload:{name, platform, specs}}`. If skipped, move on without recording. -7. **goals** — free-text question via `question` tool with `kind: "text"`: - "What are you hoping to accomplish with Amico?" No pre-fill (goals are - personal, not inferrable from configs). +8. **handoff** — the terminal stage. FIRST, **auto-generate a description** from + what you've learned (name, goals, research_area, intent, environment) — a + concise 1–2 sentence summary of the user written in third person, suitable + for the "About you" card. Example: "Aaron is a quantum-control researcher + focused on high-fidelity transmon gates, working in simulation." Record: + `amicode_profile {entity:"profile", payload:{description:"..."}}`. - Record: `amicode_profile {entity:"profile", payload:{goals:"..."}}`. - -8. **handoff** — the terminal stage. FIRST, record the completion marker: + Then record the completion marker: `amicode_profile {entity:"onboarding_completed"}` (exactly once — this is what lets Amico remember them next time and triggers the distiller to materialize the vault). - Then route by the user's intent selections (from Stage 2 — read from the - events stream, do NOT re-ask): - - - **Research** selected (alone or combined) → "Let's design your first - pulse" → continue straight into the **pulse-designer interview** in this - same session. Use everything learned (platform, environment, device) to - skip pulse-design questions already answered. - - **Research + General coding** → same as above, but acknowledge: "I'm also - your general coding companion — you can switch modes any time." - - **General coding only** (no Research) → open a normal session: "You're all - set — I'll remember your context across sessions. Ask me anything." - Highlight memory + vault features briefly. - - **Exploring only** → "Welcome aboard — want a quick tour of what I can do, - or just dive in?" Offer a brief orientation tour. - - The handoff does NOT re-ask intent — it reads what was recorded and routes. + Then tell the user onboarding is complete: "You're all set — your About You + card on the dashboard is now populated with what you told me. To see it, + reload the window (Cmd+Shift+P → 'Reload Window', or Cmd+R). After that, + start a new session anytime to explore what Amico can do." + + Do NOT auto-chain into another interview or open a new session. The + onboarding ends here. The user is in control of what happens next. diff --git a/packages/extension/src/bug_report.ts b/packages/extension/src/bug_report.ts index 3cf84531..963362b5 100644 --- a/packages/extension/src/bug_report.ts +++ b/packages/extension/src/bug_report.ts @@ -299,16 +299,14 @@ export class BugReportManager { /** Arm: the report-a-bug slash command as the session's first turn. * - * `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. */ + * The model field is always omitted — the server resolves its own default + * from the first connected provider's best model. Passing a stale + * `amicode.defaultModel` that references an unconnected provider causes a + * 500 (the server can't route to a disconnected provider). */ 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: "", ...(model ? { model } : {}) }, + body: { command: REPORT_A_BUG_SKILL, arguments: "" }, }); if (!res.ok) throw new Error(`couldn't arm the report-a-bug skill (HTTP ${res.status})`); } diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index cd2fa2e0..010434c3 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -48,6 +48,12 @@ export class ChatPanel { * 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; + /** One-shot flag: when true, the next openOrReveal posts a navigate message + * to start a new session with the onboarding greeting auto-sent. Cleared + * after use. Set by the onboarding panel after config-success/confirm-import. */ + private static pendingOnboardingGreeting = false; + /** Callbacks fired when the app signals ready (app-ready message from iframe). */ + private static appReadyCallbacks: Array<() => void> = []; private readonly disposables: vscode.Disposable[] = []; /** Subscribe to live-panel count changes. Used by the workspace tree to mute the chat button. */ @@ -61,8 +67,11 @@ export class ChatPanel { opencodeUrl: URL, authToken?: string, hideProjectDir?: string, + withSplash?: boolean, ) { - this.panel.webview.html = this.renderHtml(opencodeUrl, authToken, hideProjectDir); + this.panel.webview.html = withSplash + ? this.renderTransitionHtml(opencodeUrl, authToken, hideProjectDir) + : this.renderHtml(opencodeUrl, authToken, hideProjectDir); ChatPanel.live.add(this); ChatPanel.onLiveChangeCallback?.(ChatPanel.live.size); this.panel.onDidDispose(() => this.dispose(), null, this.disposables); @@ -88,6 +97,14 @@ export class ChatPanel { const serverUrl = opencodeUrl.origin; this.panel.webview.onDidReceiveMessage( (msg) => { + // app-ready: the SolidJS app has mounted and is rendering. Fire + // any registered callbacks (one-shot) and clear the list. + if (msg && msg.source === "amicode" && msg.kind === "app-ready") { + const cbs = ChatPanel.appReadyCallbacks.slice(); + ChatPanel.appReadyCallbacks = []; + for (const cb of cbs) cb(); + return; + } // iframe → extension bridge: the outer webview relay (renderHtml) // forwards the framed app's envelopes here; the shared handler owns the // strict allowlists (chat_bridge.ts, also used by the deck's panes). @@ -122,12 +139,67 @@ export class ChatPanel { setTimeout(() => void this.panel.webview.postMessage(envelope), 1500); } + /** Post a navigate message to open a new session with the onboarding + * prompt auto-sent. Waits for the app-ready signal before posting (the app + * must be mounted to handle the navigate). Falls back to a timeout if + * app-ready never fires. */ + postOnboardingGreeting(timeoutMs = 10_000): void { + const prompt = encodeURIComponent("Let's begin onboarding."); + const path = `/new-session?prompt=${prompt}&autoSend=1`; + const envelope = { source: "amicode", kind: "navigate", path }; + let sent = false; + const send = () => { + if (sent) return; + sent = true; + void this.panel.webview.postMessage(envelope); + }; + ChatPanel.onAppReady(send); + // Fallback: if app-ready never fires (server hung, iframe broken), + // post after timeout so the user isn't stuck on the splash forever. + setTimeout(send, timeoutMs); + } + + /** Post an arbitrary message to the webview (relayed to the iframe). */ + postMessage(msg: unknown): Thenable { + return this.panel.webview.postMessage(msg); + } + /** AC5's gate setter — called after each session prep with * bugReportSkillStaged(project.skillPaths). */ static setBugReportAvailable(available: boolean): void { ChatPanel.bugReportAvailable = available; } + /** Set by the onboarding panel after config is written — the next + * openOrReveal will post a navigate message to auto-send the greeting. */ + static setPendingOnboardingGreeting(pending: boolean): void { + ChatPanel.pendingOnboardingGreeting = pending; + } + + /** Clear the pending greeting flag (test cleanup / manual reset). */ + static clearPendingOnboardingGreeting(): void { + ChatPanel.pendingOnboardingGreeting = false; + } + + /** Register a one-shot callback for when the app signals ready. + * All registered callbacks fire once on the first app-ready message, + * then the list is cleared. */ + static onAppReady(cb: () => void): void { + ChatPanel.appReadyCallbacks.push(cb); + } + + /** Clear app-ready callbacks (test cleanup). */ + static clearAppReadyCallbacks(): void { + ChatPanel.appReadyCallbacks = []; + } + + /** Consume and clear the pending greeting flag. Returns true if it was set. */ + static consumePendingOnboardingGreeting(): boolean { + if (!ChatPanel.pendingOnboardingGreeting) return false; + ChatPanel.pendingOnboardingGreeting = false; + return true; + } + /** 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 { @@ -199,6 +271,29 @@ export class ChatPanel { return ChatPanel.current; } + /** Adopt an existing WebviewPanel (e.g. the onboarding panel) as the chat + * singleton. Swaps its HTML to the chat iframe with a splash overlay on top, + * wires message relay + bridge, and registers it as the primary ChatPanel. + * No new panel is created — zero tab switching. */ + static adopt( + panel: vscode.WebviewPanel, + ctx: vscode.ExtensionContext, + opencodeUrl: URL, + authToken?: string, + hideProjectDir?: string, + ): ChatPanel { + // If there's already a ChatPanel singleton, dispose it (shouldn't happen in normal flow) + if (ChatPanel.current) { + ChatPanel.current.dispose(); + } + const title = "Amicode Chat"; + const instance = new ChatPanel(panel, title, opencodeUrl, authToken, hideProjectDir, true); + ChatPanel.current = instance; + panel.title = title; + panel.iconPath = tabIconPath(ctx); + return instance; + } + /** Side-by-side sessions: ALWAYS a fresh tab beside the active editor — the * caller pins the tab's session scope via the URL (e.g. the app's * /new-session draft route), so each tab owns its conversation while sharing @@ -278,7 +373,7 @@ 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" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove")) { + 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" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "app-ready")) { vscode.postMessage(d); } return; @@ -287,7 +382,7 @@ export class ChatPanel { // (webview-internal origin, never the opencode origin). Forward only // our own envelopes, pinned to the opencode origin. #351 adds // run:*/device:* envelopes for the Work Column inspector tabs. - 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" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image")) { + if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image")) { var f = document.querySelector("iframe"); if (f && f.contentWindow) f.contentWindow.postMessage(d, ${origin}); } @@ -336,6 +431,183 @@ export class ChatPanel { `; } + /** Render the chat iframe HTML with a splash overlay on top. + * Used by adopt() — the overlay fades out when app-ready fires, revealing + * the fully-loaded chat underneath. Zero tab switching, pure CSS transition. */ + private renderTransitionHtml(opencodeUrl: URL, authToken?: string, hideProjectDir?: string): string { + const nonce = randomBytes(16).toString("base64"); + const csp = [ + "default-src 'none'", + "style-src 'unsafe-inline'", + `script-src 'nonce-${nonce}'`, + `frame-src ${opencodeUrl.origin}`, + "connect-src 'self'", + ].join("; "); + const origin = JSON.stringify(opencodeUrl.origin); + const framed = new URL(opencodeUrl.href); + framed.searchParams.set("colorScheme", themeKindToScheme(vscode.window.activeColorTheme.kind)); + if (authToken) framed.searchParams.set("auth_token", authToken); + if (hideProjectDir) framed.searchParams.set("amicode_hide_project", hideProjectDir); + if (ChatPanel.bugReportAvailable) framed.searchParams.set("amicode_bug_report", "1"); + return /* html */ ` + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Getting Amico ready...
+
+ + + +`; + } + dispose(): void { for (const d of this.disposables) { try { diff --git a/packages/extension/src/credential_scanner.ts b/packages/extension/src/credential_scanner.ts index c79d4e53..c4589a76 100644 --- a/packages/extension/src/credential_scanner.ts +++ b/packages/extension/src/credential_scanner.ts @@ -270,12 +270,33 @@ export function webviewSafeResults(credentials: DetectedCredential[]): SafeCrede }); } +// ─── Key validation (#455) ─────────────────────────────────────────────────── + +/** Known placeholder keys that should never be persisted to config. */ +const PLACEHOLDER_KEYS = new Set(["sk-test"]); + +/** + * Returns true if the API key is valid for writing to config. + * Rejects: empty strings, known placeholders, and keys shorter than 10 chars. + * Empty string is allowed ONLY when the caller explicitly passes it (OAuth + * providers like github-copilot don't use API keys at all — they pass empty + * and the entry is written without options.apiKey). This function is called + * only when a key IS present (non-empty), so empty returns false here. + */ +export function isValidApiKey(key: string): boolean { + if (!key || key.trim() === "") return false; + if (PLACEHOLDER_KEYS.has(key.trim())) return false; + if (key.trim().length < 10) return false; + return true; +} + // ─── Batch config writing (AC7) ────────────────────────────────────────────── /** * Write all detected providers to opencode.json in one pass. * The `activeProvider` becomes the active `model` (using its first model entry). * Uses the same schema as writeOnboardingConfig: provider..options.apiKey, env as string[]. + * Credentials with placeholder or invalid keys are silently skipped (#455). */ export function writeBatchConfig( credentials: DetectedCredential[], @@ -295,12 +316,14 @@ export function writeBatchConfig( // Start fresh if parsing fails } - // Build provider entries - const providerEntry: Record = { - ...(existing.provider as Record ?? {}), - }; + // Build provider entries — replaces the entire provider section + // (on redo, user's selection is the canonical set; old entries don't persist) + const providerEntry: Record = {}; for (const cred of credentials) { + // Skip credentials with invalid/placeholder keys (#455) + if (!isValidApiKey(cred.key)) continue; + const entry: Record = {}; if (cred.key) { entry.options = { apiKey: cred.key }; @@ -312,16 +335,87 @@ export function writeBatchConfig( providerEntry[cred.provider] = entry; } - // Determine active model + // Determine active model — only set when we have a known default. + // Unknown providers (e.g. amazon-bedrock) let the server resolve its own + // default from the connected provider's model list. const activeModels = PROVIDER_MODELS[activeProvider]; - const activeModel = activeModels?.[0]?.id ?? `${activeProvider}/unknown`; + const activeModel = activeModels?.[0]?.id; - const result = { + const result: Record = { ...existing, $schema: "https://opencode.ai/config.json", provider: providerEntry, - model: activeModel, }; + if (activeModel) { + result.model = activeModel; + } else { + // Remove stale model field if it points to an unknown model + delete result.model; + } fs.writeFileSync(targetPath, JSON.stringify(result, null, 2) + "\n"); } + +// ─── Disconnect excluded providers from auth stores ────────────────────────── + +/** + * Remove credentials for excluded providers from opencode's auth stores. + * After a server restart, excluded providers will no longer auto-connect. + */ +export function disconnectProviders( + providers: string[], + options?: { accountJsonPath?: string; authJsonPath?: string }, +): void { + const home = os.homedir(); + const dataDir = path.join(home, ".local", "share", "opencode"); + const accountPath = options?.accountJsonPath ?? path.join(dataDir, "account.json"); + const authPath = options?.authJsonPath ?? path.join(dataDir, "auth.json"); + + // Build the set of serviceIDs to remove, including aliases + const excludeSet = new Set(providers); + if (excludeSet.has("opencode")) excludeSet.add("opencode-go"); + + // Remove from account.json (v2) + try { + const raw = fs.readFileSync(accountPath, "utf8"); + const data = JSON.parse(raw); + if (data.version === 2 && typeof data.accounts === "object" && data.accounts !== null) { + let modified = false; + for (const [id, entry] of Object.entries(data.accounts)) { + const acct = entry as { serviceID?: string }; + if (acct.serviceID && excludeSet.has(acct.serviceID)) { + delete data.accounts[id]; + if (data.active && acct.serviceID in data.active) { + delete data.active[acct.serviceID]; + } + modified = true; + } + } + if (modified) { + fs.writeFileSync(accountPath, JSON.stringify(data, null, 2) + "\n"); + } + } + } catch { + // Skip if file doesn't exist or is malformed + } + + // Remove from auth.json (v1) + try { + const raw = fs.readFileSync(authPath, "utf8"); + const data = JSON.parse(raw); + if (typeof data === "object" && data !== null) { + let modified = false; + for (const serviceId of excludeSet) { + if (serviceId in data) { + delete data[serviceId]; + modified = true; + } + } + if (modified) { + fs.writeFileSync(authPath, JSON.stringify(data, null, 2) + "\n"); + } + } + } catch { + // Skip if file doesn't exist or is malformed + } +} diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 6fd5a4a2..b46b3e69 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -13,6 +13,7 @@ import { resolveJuliaProject, buildOpencodeConfigContent, resolveModelPin, + validatedModelPin, } from "./opencode_config"; import { parseLibraryRootSpecs } from "./scores/package_skills"; import { resolveAmicoRunBinDir, resolveRunsRoot } from "./opencode_paths"; @@ -38,8 +39,8 @@ import { writeStopFile, savePulseTo, stopPlan, forceStop, runLogMtime } from "./ import { watchSolverMode, applyEntitlementForMode, readSolverModeState } from "./solver_mode"; import { runSetCloudKeyCommand } from "./cloud_key"; import { amicodeOpsDir } from "./substrate/vault_store"; -import { registerOnboardingPanel, onOnboardingComplete, onOnboardingCancelled } from "./onboarding_panel"; -import { isModelConfigured } from "./onboarding_routing"; +import { registerOnboardingPanel, onOnboardingCancelled, getOnboardingPanel, releaseOnboardingPanel } from "./onboarding_panel"; +import { isModelConfigured, writeWelcomeShown } from "./onboarding_routing"; import { stagePasqalConnector } from "./pasqal_assets"; import { needsProvision, pasqalVenvDir, provisionPasqalPython } from "./pasqal_python"; import { createLocalPersonalVault, sanitizeVaultName, suggestVaultName } from "./substrate/vault_setup"; @@ -207,6 +208,39 @@ async function refreshDeviceInspector(channel: vscode.OutputChannel): Promise(); +/** Create a session and arm it with "Let's begin onboarding." via the server API. + * Bypasses the UI model gate (the server resolves its own default model). + * Returns the session ID on success, undefined on failure. */ +async function armOnboardingSession( + serverUrl: URL, + authHeaders: Record, + projectDir?: string, +): Promise { + try { + const collectionUrl = new URL("/session", serverUrl); + if (projectDir) collectionUrl.searchParams.set("directory", projectDir); + const createRes = await fetch(collectionUrl.toString(), { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders }, + body: JSON.stringify({ title: "Onboarding" }), + }); + if (!createRes.ok) return undefined; + const { id } = (await createRes.json()) as { id?: string }; + if (!id) return undefined; + + const commandUrl = new URL(`/session/${id}/command`, serverUrl); + const commandRes = await fetch(commandUrl.toString(), { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders }, + body: JSON.stringify({ command: "Let's begin onboarding.", arguments: "" }), + }); + if (!commandRes.ok) return undefined; + return id; + } catch { + return undefined; + } +} + export async function activate(ctx: vscode.ExtensionContext): Promise { const opencodeChannel = vscode.window.createOutputChannel("Amicode — opencode"); const runsChannel = vscode.window.createOutputChannel("Amicode — runs"); @@ -676,7 +710,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // the user's recent selection, else the provider default. A hardcoded // fallback here used to override the user's own choice. The in-chat // picker still overrides per session. - vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin(), + // Validate: don't inject a pin that references an unconnected provider — + // it causes 500s when the server tries to resolve it. + validatedModelPin(vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin()), // Telemetry gate → experimental.openTelemetry (span generation), coupled // to the exporter env this same spawnEnv resolves. telemetryOpen(), @@ -744,7 +780,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Armonia mount stack (spec-20260707-002846 C1): per-mount read grants. project2.mounts, // Same pin rule as boot: only an explicit amicode.defaultModel pins. - vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin(), + validatedModelPin(vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin()), telemetryOpen(), // gate → experimental.openTelemetry (span generation) // Context plugin: injects live stack state per system-prompt build. [path.resolve(ctx.extensionPath, "opencode-plugin", "amicode_context.ts")], @@ -811,17 +847,32 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // which then opens chat. if (!isModelConfigured() && vscode.workspace.getConfiguration("amicode").get("chat.autoOpen", true)) { void vscode.commands.executeCommand("amicode.onboarding.open"); - // Wire: when onboarding completes, auto-open chat - onOnboardingComplete(() => { - ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir); - }); + // Wire: when onboarding completes, the server restarts and the + // onReady handler (else-if branch below) opens the chat panel. + // We do NOT open chat here — that would race the server restart + // and show behind the transition splash. // Wire: when onboarding is cancelled (X), open chat normally onOnboardingCancelled(() => { ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir); }); } else if (vscode.workspace.getConfiguration("amicode").get("chat.autoOpen", true)) { // Normal path: model configured → open chat directly - ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir); + // Post-onboarding: adopt the onboarding panel as the chat panel (zero + // tab switching — the splash overlay fades out revealing the chat). + if (ChatPanel.consumePendingOnboardingGreeting()) { + const onboardPanel = getOnboardingPanel(); + if (onboardPanel) { + releaseOnboardingPanel(); // detach from onboarding lifecycle + const panel = ChatPanel.adopt(onboardPanel, ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir); + panel.postOnboardingGreeting(); + } else { + // Fallback: no onboarding panel alive (user closed it manually) + const panel = ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir); + panel.postOnboardingGreeting(); + } + } else { + ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir); + } } // Surface ONE explicit LLM-provider signal at boot, read from opencode's // OWN resolution (its live /config/providers) — not a silent hang at the @@ -888,7 +939,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { project2.skillsStageDir, project2.vaultDir, project2.mounts, - vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin(), + validatedModelPin(vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin()), telemetryOpen(), // gate → experimental.openTelemetry (span generation) ), }), @@ -1305,7 +1356,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeProject.skillsStageDir, opencodeProject.vaultDir, opencodeProject.mounts, - vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin(), + validatedModelPin(vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin()), telemetryOpen(), ), }), diff --git a/packages/extension/src/onboarding_panel.ts b/packages/extension/src/onboarding_panel.ts index 61b8c6ea..1dbb9e3e 100644 --- a/packages/extension/src/onboarding_panel.ts +++ b/packages/extension/src/onboarding_panel.ts @@ -17,8 +17,11 @@ import { defaultScanOptions, webviewSafeResults, writeBatchConfig, + disconnectProviders, + isValidApiKey, type DetectedCredential, } from "./credential_scanner"; +import { ChatPanel } from "./chat_panel"; // ─── Provider → Model data (data-driven, not hard-coded conditionals) ──────── @@ -93,7 +96,8 @@ function defaultConfigPath(): string { } /** Write the onboarding config to the opencode config file. - * Creates parent directories if needed. Merges with existing config if present. */ + * Creates parent directories if needed. Merges with existing config if present. + * Rejects placeholder/invalid API keys — the provider entry is not written (#455). */ export function writeOnboardingConfig( config: OnboardingConfig, configPath: string = defaultConfigPath(), @@ -110,6 +114,22 @@ export function writeOnboardingConfig( // If parsing fails, start fresh } + // Reject placeholder/invalid keys (#455) — but allow empty keys (OAuth providers) + if (config.apiKey && !isValidApiKey(config.apiKey)) { + // Key is non-empty but invalid — don't write this provider, just preserve existing config + const result: Record = { + ...existing, + $schema: "https://opencode.ai/config.json", + provider: existing.provider ?? {}, + }; + // Only write model if it's a known valid ID (not empty, not "provider/unknown") + if (config.model && !config.model.endsWith("/unknown")) { + result.model = config.model; + } + fs.writeFileSync(configPath, JSON.stringify(result, null, 2) + "\n"); + return; + } + // Provider-specific key env var name const envVarName = providerKeyEnvVar(config.provider); @@ -129,12 +149,18 @@ export function writeOnboardingConfig( [config.provider]: providerConfig, }; - const result = { + const result: Record = { ...existing, $schema: "https://opencode.ai/config.json", provider: providerEntry, - model: config.model, }; + // Only write model if it's a known valid ID (not empty, not "provider/unknown") + if (config.model && !config.model.endsWith("/unknown")) { + result.model = config.model; + } else { + // Remove stale model field that points to an unknown model + delete result.model; + } fs.writeFileSync(configPath, JSON.stringify(result, null, 2) + "\n"); } @@ -312,6 +338,93 @@ export function _resetForTesting(): void { currentPanel = undefined; } +/** Dismiss the onboarding panel (dispose it). Called by the extension host + * after the chat panel's app signals ready — ends the transition splash. */ +export function dismissOnboardingPanel(): void { + if (currentPanel) { + currentPanel.dispose(); + } +} + +/** Return the live onboarding WebviewPanel (if one exists). Used by the + * transition flow: the extension swaps its HTML and adopts it as the chat + * panel — zero tab switching. */ +export function getOnboardingPanel(): vscode.WebviewPanel | undefined { + return currentPanel; +} + +/** Detach the onboarding panel from this module's lifecycle tracking WITHOUT + * disposing it. Called when ChatPanel.adopt() takes ownership. After this, + * dismissOnboardingPanel() is a no-op and re-opening creates a fresh panel. */ +export function releaseOnboardingPanel(): void { + currentPanel = undefined; +} + +/** Static splash HTML — the happy robot + "Getting Amico ready..." on a plain + * background. Used as an immediate visual while the server restarts. The exact + * same SVG + CSS appears in ChatPanel.renderTransitionHtml's overlay, so when + * adopt() fires there's no visible flash (same pixels). */ +function splashHtml(): string { + return ` + + + + + + + + + + + + + + + + + + + + + + + + + +
Getting Amico ready...
+`; +} + /** Register the onboarding panel command. Call from extension.ts activate(). */ export function registerOnboardingPanel(ctx: vscode.ExtensionContext): void { ctx.subscriptions.push( @@ -349,10 +462,17 @@ export function registerOnboardingPanel(ctx: vscode.ExtensionContext): void { } else if (msg.type === "config-success") { const payload = msg.payload as OnboardingConfig; writeOnboardingConfig(payload); - panel.dispose(); + // Clear stale model pin — the old provider may no longer be connected. + // The server will resolve the new provider's default on its own. + void vscode.workspace.getConfiguration("amicode").update("defaultModel", undefined, vscode.ConfigurationTarget.Global); + // Swap the panel HTML directly to the splash (same as confirm-import) + panel.webview.html = splashHtml(); + // Signal that the next chat panel open should auto-send the onboarding greeting + ChatPanel.setPendingOnboardingGreeting(true); fireOnboardingComplete(); - // Open chat as fallback (in case no completion listener is wired) - void vscode.commands.executeCommand("amicode.openChat"); + // Restart server so it picks up the new provider config. + // Chat opens via the onReady-gated listener in extension.ts. + void vscode.commands.executeCommand("amicode.restartServer"); } else if (msg.type === "cancel") { // User cancelled onboarding — close panel, re-open chat panel.dispose(); @@ -427,12 +547,30 @@ export function registerOnboardingPanel(ctx: vscode.ExtensionContext): void { if (passedCredentials.length > 0) { writeBatchConfig(passedCredentials, payload.activeProvider); } + // If user excluded 'opencode', disconnect it from the auth store. + // This is the only provider that needs file-level removal (it's a + // built-in integration, not in the connections seam). + if (!included.has("opencode") && heldCredentials.some((c) => c.provider === "opencode")) { + disconnectProviders(["opencode"]); + } heldCredentials = []; testResults.clear(); - panel.dispose(); + // Clear stale model pin — the old provider may no longer be connected. + void vscode.workspace.getConfiguration("amicode").update("defaultModel", undefined, vscode.ConfigurationTarget.Global); + // Swap the panel HTML directly to the splash — no webview-side + // DOM manipulation, so there's no flash when adopt() fires later + // (adopt's overlay uses the exact same SVG + CSS). + panel.webview.html = splashHtml(); + // Signal that the next chat panel open should auto-send the onboarding greeting + ChatPanel.setPendingOnboardingGreeting(true); fireOnboardingComplete(); - // Open chat as fallback (in case no completion listener is wired) - void vscode.commands.executeCommand("amicode.openChat"); + // Restart server so it picks up the new provider config. + // Chat opens via the onReady-gated listener in extension.ts. + void vscode.commands.executeCommand("amicode.restartServer"); + } else if (msg.type === "transition-complete") { + // The extension signals that the chat panel is ready — dispose the + // splash now. This is posted by the extension host after app-ready. + panel.dispose(); } }, null, @@ -473,7 +611,7 @@ function buildWebviewHtml( diff --git a/packages/extension/src/onboarding_webview.ts b/packages/extension/src/onboarding_webview.ts index 1807224c..0d3c33cd 100644 --- a/packages/extension/src/onboarding_webview.ts +++ b/packages/extension/src/onboarding_webview.ts @@ -96,31 +96,10 @@ function playWelcomeAnimation(): void { entrance and snap open at 0.90s. */ .amico-mark .eye-lid { opacity: 0; } - /* 0.00s — Amico fades in and drops onto his feet, bouncing twice before - he settles. Volume is roughly conserved: he widens as he flattens. - Per-keyframe easing does the real work — falls accelerate, rises - decelerate; a single curve across the whole thing reads as floaty. */ + /* 0.00s — Amico fades in at constant size. No drop, no bounce, + no scale — just appears. The button fades in after. */ @keyframes amico-enter { - 0% { - opacity: 0; transform: translateY(-260px) scale(0.92, 1.10); - animation-timing-function: cubic-bezier(0.4, 0, 1, 1); - } - 25% { - opacity: 1; transform: translateY(0) scale(1.14, 0.86); - animation-timing-function: cubic-bezier(0, 0, 0.3, 1); - } - 45% { - transform: translateY(-190px) scale(0.96, 1.06); - animation-timing-function: cubic-bezier(0.4, 0, 1, 1); - } - 65% { - transform: translateY(0) scale(1.08, 0.93); - animation-timing-function: cubic-bezier(0, 0, 0.3, 1); - } - 82% { - transform: translateY(-70px) scale(0.99, 1.02); - animation-timing-function: cubic-bezier(0.4, 0, 1, 1); - } + 0% { opacity: 0; } 100% { opacity: 1; transform: translateY(0) scale(1, 1); } } @@ -237,8 +216,8 @@ function playWelcomeAnimation(): void { } @keyframes amico-rise { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } + from { opacity: 0; } + to { opacity: 1; } } @keyframes amico-fade-in { from { opacity: 0; } @@ -327,6 +306,14 @@ function playWelcomeAnimation(): void {

Welcome to Amicode

+ `; @@ -339,19 +326,9 @@ function playWelcomeAnimation(): void { // Show "Get Started" button after text fades in, user clicks to proceed setTimeout(() => { - const btn = document.createElement("button"); - btn.textContent = "Get Started"; - btn.className = "welcome-cta"; - btn.style.cssText = ` - margin-top: 40px; padding: 10px 32px; - font-family: var(--text-font, inherit); font-size: 14px; font-weight: 500; - background: var(--color-accent-fill, #fff676); - color: var(--color-on-accent, #000); - border: var(--border-width, 1px) solid var(--color-on-accent, #000); - border-radius: var(--border-radius, 4px); - cursor: pointer; opacity: 0; transition: opacity 0.5s ease-in, filter 0.16s ease; - `; - logo.appendChild(btn); + const btn = logo.querySelector(".welcome-cta") as HTMLButtonElement; + if (!btn) return; + btn.style.visibility = "visible"; requestAnimationFrame(() => { btn.style.opacity = "1"; }); btn.addEventListener("click", () => { @@ -362,7 +339,7 @@ function playWelcomeAnimation(): void { revealForm(); }, 400); }); - }, 2000); + }, 3000); } // ─── Form ──────────────────────────────────────────────────────────────────── @@ -717,17 +694,17 @@ function buildForm(): void { importPreview.innerHTML = `

- Choose which providers to import and pick your default: + Select which providers to import (tested credentials will be auto-selected):

${providers .map( (p, i) => `
@@ -757,7 +734,8 @@ function buildForm(): void {

`; - // Wire checkbox ↔ radio sync: unchecking a provider disables its radio + // Wire checkbox ↔ radio sync: unchecking a provider disables its radio; + // checking enables it const allCheckboxes = document.querySelectorAll('input[name="import-include"]'); allCheckboxes.forEach((cb) => { cb.addEventListener("change", () => { @@ -783,6 +761,9 @@ function buildForm(): void { } else { if (row) row.style.opacity = "1"; if (radio) radio.disabled = false; + // If no default is selected, select this one + const anyDefault = document.querySelector('input[name="import-default"]:checked:not(:disabled)') as HTMLInputElement | null; + if (!anyDefault && radio) radio.checked = true; } updateConfirmState(); }); @@ -839,11 +820,23 @@ function buildForm(): void { if (ok) { statusEl.textContent = "✓"; statusEl.style.color = "var(--vscode-testing-iconPassed, #73c991)"; + // Auto-check passing providers and enable their radio (#455: opt-in, but + // passing the test is an explicit signal the credential works) + if (rowEl) { + rowEl.style.opacity = "1"; + const checkbox = rowEl.querySelector('input[name="import-include"]') as HTMLInputElement | null; + const radio = rowEl.querySelector('input[name="import-default"]') as HTMLInputElement | null; + if (checkbox && !checkbox.checked) checkbox.checked = true; + if (radio) radio.disabled = false; + // If no default is selected yet, select this one + const anyDefault = document.querySelector('input[name="import-default"]:checked:not(:disabled)') as HTMLInputElement | null; + if (!anyDefault && radio) radio.checked = true; + } } else { statusEl.textContent = "✗"; statusEl.style.color = "var(--vscode-testing-iconFailed, #f14c4c)"; statusEl.title = error ?? "Connection failed"; - // Uncheck and dim failed providers + // Dim failed providers and ensure they stay unchecked if (rowEl) { rowEl.style.opacity = "0.5"; const checkbox = rowEl.querySelector('input[name="import-include"]') as HTMLInputElement | null; @@ -884,6 +877,83 @@ function buildForm(): void { // ─── Boot ──────────────────────────────────────────────────────────────────── +// Listen for the transition-state signal from the host (after confirm-import). +// Hides the form, keeps the animation (Amico idle), and shows "Getting Amico ready..." +window.addEventListener("message", (event) => { + const msg = event.data; + if (msg?.type === "show-transition") { + // Hide the form + formEl.classList.remove("visible"); + formEl.style.display = "none"; + // Hide the cancel button + const cancelEl = document.getElementById("cancel-btn"); + if (cancelEl) cancelEl.style.display = "none"; + // Remove the "Get Started" button left over from the welcome animation + const ctaBtn = animationEl.querySelector(".welcome-cta"); + if (ctaBtn) ctaBtn.remove(); + // Remove the welcome text ("Welcome" / subtitle) + const welcomeText = animationEl.querySelector(".welcome-text"); + if (welcomeText) welcomeText.remove(); + // Show the animation container (restore from the post-animation hidden state) + animationEl.style.display = "flex"; + animationEl.style.opacity = "1"; + animationEl.style.transition = "none"; + + // Swap the face to happy expression: remove bottom eye bars + add pixelated open grin + const svg = animationEl.querySelector(".amico-mark"); + if (svg) { + // Remove the bottom bar from each eye (makes ∩ shape = happy closed eyes) + const leftEye = svg.querySelector(".left-eye"); + const rightEye = svg.querySelector(".right-eye"); + if (leftEye) { + // The 4th rect in eye-ring is the bottom bar (y ≈ 1870) + const rects = leftEye.querySelectorAll(".eye-ring rect"); + if (rects.length >= 4) rects[3].remove(); + // Remove the lid too (not needed for happy eyes) + const lid = leftEye.querySelector(".eye-lid"); + if (lid) lid.remove(); + } + if (rightEye) { + const rects = rightEye.querySelectorAll(".eye-ring rect"); + if (rects.length >= 4) rects[3].remove(); + const lid = rightEye.querySelector(".eye-lid"); + if (lid) lid.remove(); + } + // Stop eye animations (happy eyes don't blink) + if (leftEye) { + const ring = leftEye.querySelector(".eye-ring") as HTMLElement; + if (ring) ring.style.animation = "none"; + } + if (rightEye) { + const ring = rightEye.querySelector(".eye-ring") as HTMLElement; + if (ring) ring.style.animation = "none"; + } + + // Find the inner-most animated group to switch animation + const enterGroup = svg.querySelector(".mark-enter") || svg.querySelector(".mark-breathe"); + + // Switch from idle animations to excited jump + const breatheGroup = svg.querySelector(".mark-breathe") as HTMLElement; + if (breatheGroup) { + breatheGroup.style.animation = "amico-jump 2.0s ease-in-out infinite"; + } + } + + // Add "Getting Amico ready..." text below the animation + let transitionText = document.getElementById("transition-text"); + if (!transitionText) { + transitionText = document.createElement("div"); + transitionText.id = "transition-text"; + transitionText.style.cssText = ` + text-align: center; margin-top: 24px; font-size: 14px; + color: var(--vscode-descriptionForeground, #999); + `; + transitionText.textContent = "Getting Amico ready..."; + animationEl.parentElement!.insertBefore(transitionText, animationEl.nextSibling); + } + } +}); + // Wire the cancel button const cancelBtn = document.getElementById("cancel-btn"); if (cancelBtn) { diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index 71e35b14..3e60b4a9 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -381,6 +381,29 @@ export function resolveModelPin(): string | undefined { return undefined; } +/** Validate a model pin against the user's configured providers. + * Returns the pin unchanged if its provider is configured, otherwise undefined. + * This prevents injecting a stale pin that references a disconnected provider + * (which causes 500s when the server tries to resolve it). */ +export function validatedModelPin(pin: string | undefined): string | undefined { + if (!pin) return undefined; + const providerID = pin.split("/")[0]; + if (!providerID) return undefined; + // Read the user's global opencode.json to check configured providers + const configPath = path.join(os.homedir(), ".config", "opencode", "opencode.json"); + try { + if (!fs.existsSync(configPath)) return pin; // no config → trust the pin (first boot) + const raw = JSON.parse(fs.readFileSync(configPath, "utf8")); + const providers = Object.keys(raw?.provider ?? {}); + if (providers.length === 0) return pin; // no providers section → trust the pin + // The pin's provider must be in the configured set + if (providers.includes(providerID)) return pin; + return undefined; // provider not configured — don't inject stale pin + } catch { + return pin; // can't read config → trust the pin + } +} + export function buildOpencodeConfigContent( agentsPath: string, templatePath: string, diff --git a/packages/extension/src/scores/router.ts b/packages/extension/src/scores/router.ts index 2c52fc01..1c80fe1a 100644 --- a/packages/extension/src/scores/router.ts +++ b/packages/extension/src/scores/router.ts @@ -15,7 +15,12 @@ export function buildRouterSection(visible: Score[]): string { const lines: string[] = [ "## Onset router", "", - "When a session opens without a specific request (a greeting, \"who are", + 'When a session opens with an explicit onboarding request ("Let\'s begin', + 'onboarding", "begin onboarding", "start onboarding", or similar), **skip this', + "router entirely** and go straight into the overture (Stage 1 below) — the user", + "has already chosen.", + "", + "Otherwise, when a session opens without a specific request (a greeting, \"who are", "you?\", \"what is this?\"), do NOT default to the pulse-designer interview —", "build the moment from the live state. After your one-line Amico intro (name from", "the profile when one is recorded), ask exactly ONE question —", diff --git a/packages/extension/test/bug_report.test.ts b/packages/extension/test/bug_report.test.ts index 82e0d1f8..53c33568 100644 --- a/packages/extension/test/bug_report.test.ts +++ b/packages/extension/test/bug_report.test.ts @@ -89,7 +89,7 @@ describe("amicode.reportBug — create, arm, open (AC1)", () => { }); 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(arm[0].body).toEqual({ command: "report-a-bug", arguments: "" }); expect(posted).toEqual([{ source: "amicode", kind: "open-bug-report", sessionID: "ses_bug1" }]); }); diff --git a/packages/extension/test/chat_panel.test.ts b/packages/extension/test/chat_panel.test.ts index 0b1744e0..91c23b5a 100644 --- a/packages/extension/test/chat_panel.test.ts +++ b/packages/extension/test/chat_panel.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import * as vscode from "vscode"; import { ChatPanel } from "../src/chat_panel"; import { mintServerPassword, serverAuthToken } from "../src/server_auth"; @@ -118,3 +118,213 @@ describe("ChatPanel — the amicode_bug_report boot param (amicode#250 AC5)", () expect(html).toContain('"close-bug-report"'); }); }); + +describe("ChatPanel — onboarding greeting auto-send (#449)", () => { + let restore: (() => void) | undefined; + let created: CapturedPanel[] = []; + afterEach(() => { + for (const p of created) p.dispose(); + restore?.(); + restore = undefined; + created = []; + ChatPanel.clearPendingOnboardingGreeting(); + ChatPanel.clearAppReadyCallbacks(); + }); + + it("posts the navigate message only AFTER app-ready fires (event-driven, not blind timer)", async () => { + const cap = capturePanel(); + restore = cap.restore; + created = cap.created; + + const panel = ChatPanel.openOrReveal(fakeCtx(), new URL("http://127.0.0.1:43117/")); + + const messages: unknown[] = []; + const webview = cap.created[0] as unknown as { webview: { postMessage: (m: unknown) => Promise; _simulateMessage: (msg: unknown) => void } }; + webview.webview.postMessage = (m: unknown) => { messages.push(m); return Promise.resolve(true); }; + + // Call postOnboardingGreeting — should NOT post immediately + panel.postOnboardingGreeting(); + + // Wait a tick — no navigate message yet (no blind timer should fire this fast) + await new Promise((r) => setTimeout(r, 50)); + const earlyNavigate = messages.find( + (m) => (m as { kind?: string }).kind === "navigate", + ); + expect(earlyNavigate).toBeUndefined(); + + // Now simulate app-ready — the message should fire + webview.webview._simulateMessage({ source: "amicode", kind: "app-ready" }); + await new Promise((r) => setTimeout(r, 50)); + + const navigateMsg = messages.find( + (m) => (m as { source?: string; kind?: string }).source === "amicode" && (m as { kind?: string }).kind === "navigate", + ) as { source: string; kind: string; path: string } | undefined; + + expect(navigateMsg).toBeDefined(); + expect(navigateMsg!.path).toContain("/new-session"); + expect(navigateMsg!.path).toContain("autoSend=1"); + expect(navigateMsg!.path).toContain("prompt=" + encodeURIComponent("Let's begin onboarding.")); + }); + + it("does NOT post navigate when postOnboardingGreeting was not called (even after app-ready)", async () => { + const cap = capturePanel(); + restore = cap.restore; + created = cap.created; + + // Open panel WITHOUT calling postOnboardingGreeting + ChatPanel.openOrReveal(fakeCtx(), new URL("http://127.0.0.1:43117/")); + + const messages: unknown[] = []; + const webview = cap.created[0] as unknown as { webview: { postMessage: (m: unknown) => Promise; _simulateMessage: (msg: unknown) => void } }; + webview.webview.postMessage = (m: unknown) => { messages.push(m); return Promise.resolve(true); }; + + // Simulate app-ready + webview.webview._simulateMessage({ source: "amicode", kind: "app-ready" }); + await new Promise((r) => setTimeout(r, 50)); + + const navigateMsg = messages.find( + (m) => (m as { source?: string; kind?: string }).source === "amicode" && (m as { kind?: string }).kind === "navigate", + ); + expect(navigateMsg).toBeUndefined(); + }); + + it("consumePendingOnboardingGreeting returns true once then false", () => { + ChatPanel.setPendingOnboardingGreeting(true); + expect(ChatPanel.consumePendingOnboardingGreeting()).toBe(true); + expect(ChatPanel.consumePendingOnboardingGreeting()).toBe(false); + }); + + it("the relay admits app-ready from the iframe (Lane 1 allowlist)", () => { + 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; + // app-ready must be in the Lane 1 allowlist (iframe → extension) + expect(html).toContain('"app-ready"'); + }); + + it("fires onAppReady callback when app-ready message arrives from iframe", async () => { + const cap = capturePanel(); + restore = cap.restore; + created = cap.created; + + const readyFired: boolean[] = []; + ChatPanel.onAppReady(() => readyFired.push(true)); + + const panel = ChatPanel.openOrReveal(fakeCtx(), new URL("http://127.0.0.1:43117/")); + + // Simulate the app-ready message arriving from the iframe + const webview = cap.created[0] as unknown as { webview: { _simulateMessage: (msg: unknown) => void } }; + webview.webview._simulateMessage({ source: "amicode", kind: "app-ready" }); + + await new Promise((r) => setTimeout(r, 10)); + expect(readyFired).toHaveLength(1); + }); + + it("postOnboardingGreeting falls back to posting after timeout if app-ready never fires", async () => { + const cap = capturePanel(); + restore = cap.restore; + created = cap.created; + + const panel = ChatPanel.openOrReveal(fakeCtx(), new URL("http://127.0.0.1:43117/")); + + const messages: unknown[] = []; + const webview = cap.created[0] as unknown as { webview: { postMessage: (m: unknown) => Promise; _simulateMessage: (msg: unknown) => void } }; + webview.webview.postMessage = (m: unknown) => { messages.push(m); return Promise.resolve(true); }; + + // Call with a short timeout for testing (pass timeout override) + panel.postOnboardingGreeting(200); + + // No app-ready — wait for the timeout fallback + await new Promise((r) => setTimeout(r, 300)); + + const navigateMsg = messages.find( + (m) => (m as { source?: string; kind?: string }).source === "amicode" && (m as { kind?: string }).kind === "navigate", + ); + expect(navigateMsg).toBeDefined(); + }); +}); + +describe("ChatPanel.adopt — transforms an existing panel into the chat singleton", () => { + let restore: (() => void) | undefined; + let created: CapturedPanel[] = []; + afterEach(() => { + for (const p of created) p.dispose(); + restore?.(); + restore = undefined; + created = []; + ChatPanel.clearPendingOnboardingGreeting(); + ChatPanel.clearAppReadyCallbacks(); + }); + + it("adopt wraps an existing WebviewPanel as the ChatPanel singleton (no new panel created)", () => { + // Create a panel externally BEFORE installing the capture spy + const existingPanel = vscode.window.createWebviewPanel( + "amicode.onboarding", "Amicode Setup", vscode.ViewColumn.One, { enableScripts: true }, + ) as unknown as CapturedPanel; + created.push(existingPanel); + + // Now install the spy — any new panel creation will be captured + const cap = capturePanel(); + restore = cap.restore; + + // Adopt it + const chatPanel = ChatPanel.adopt( + existingPanel as unknown as import("vscode").WebviewPanel, + fakeCtx(), + new URL("http://127.0.0.1:43117/"), + ); + + expect(chatPanel).toBeDefined(); + // No NEW panel should have been created via createWebviewPanel + expect(cap.created).toHaveLength(0); + // openOrReveal should now return the adopted panel (it's the singleton) + const revealed = ChatPanel.openOrReveal(fakeCtx(), new URL("http://127.0.0.1:43117/")); + expect(revealed).toBe(chatPanel); + // Still no new panel + expect(cap.created).toHaveLength(0); + }); + + it("adopt sets the panel HTML to the chat iframe content with splash overlay", () => { + const existingPanel = vscode.window.createWebviewPanel( + "amicode.onboarding", "Amicode Setup", vscode.ViewColumn.One, { enableScripts: true }, + ) as unknown as CapturedPanel; + created.push(existingPanel); + + ChatPanel.adopt( + existingPanel as unknown as import("vscode").WebviewPanel, + fakeCtx(), + new URL("http://127.0.0.1:43117/"), + ); + + // The HTML should contain both the iframe and the splash overlay + const html = existingPanel.webview.html; + expect(html).toContain("iframe"); + expect(html).toContain("splash-overlay"); + expect(html).toContain("127.0.0.1:43117"); + }); + + it("adopt wires app-ready so it fires onAppReady callbacks", async () => { + const existingPanel = vscode.window.createWebviewPanel( + "amicode.onboarding", "Amicode Setup", vscode.ViewColumn.One, { enableScripts: true }, + ) as unknown as CapturedPanel; + created.push(existingPanel); + + const readyFired: boolean[] = []; + ChatPanel.onAppReady(() => readyFired.push(true)); + + ChatPanel.adopt( + existingPanel as unknown as import("vscode").WebviewPanel, + fakeCtx(), + new URL("http://127.0.0.1:43117/"), + ); + + // Simulate app-ready arriving from the iframe + const webview = existingPanel as unknown as { webview: { _simulateMessage: (msg: unknown) => void } }; + webview.webview._simulateMessage({ source: "amicode", kind: "app-ready" }); + + await new Promise((r) => setTimeout(r, 10)); + expect(readyFired).toHaveLength(1); + }); +}); diff --git a/packages/extension/test/credential_scanner.test.ts b/packages/extension/test/credential_scanner.test.ts index 66d36a89..eddf9936 100644 --- a/packages/extension/test/credential_scanner.test.ts +++ b/packages/extension/test/credential_scanner.test.ts @@ -12,6 +12,7 @@ import * as os from "node:os"; import { scanCredentials, defaultScanOptions, + disconnectProviders, type DetectedCredential, type ScanOptions, type ScanResult, @@ -467,7 +468,6 @@ describe("scanCredentials — end-to-end with real default paths", () => { it("finds credentials from this machine's actual opencode install", async () => { const result = await scanCredentials(defaultScanOptions()); - // This machine has opencode configured — scan should find at least one provider console.log(` [e2e] Found ${result.credentials.length} credential(s):`); for (const c of result.credentials) { console.log(` ${c.provider} (from ${c.source}) — key ${c.key.slice(0, 6)}...`); @@ -590,9 +590,9 @@ describe("scanCredentials — batch config writing integration (AC7)", () => { // Simulate the panel filtering: only passed providers get written const allCredentials: DetectedCredential[] = [ - { provider: "anthropic", key: "sk-ant-pass", source: "env" }, - { provider: "openai", key: "sk-openai-fail", source: "env" }, - { provider: "google", key: "AIza-pass", source: "env" }, + { provider: "anthropic", key: "sk-ant-pass-valid-key", source: "env" }, + { provider: "openai", key: "sk-openai-fail-valid-key", source: "env" }, + { provider: "google", key: "AIza-pass-valid-key-123", source: "env" }, ]; // Simulate testResults: anthropic=true, openai=false, google=true @@ -612,3 +612,270 @@ describe("scanCredentials — batch config writing integration (AC7)", () => { expect(written.provider.openai).toBeUndefined(); }); }); + +// ─── #455: Only write user-selected providers ──────────────────────────────── + +describe("writeBatchConfig — placeholder key rejection (#455 AC5)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("rejects 'sk-test' placeholder key — does not write provider", async () => { + const { writeBatchConfig } = await import("../src/credential_scanner"); + const credentials: DetectedCredential[] = [ + { provider: "anthropic", key: "sk-test", source: "env" }, + { provider: "openai", key: "sk-openai-real-key-12345", source: "env" }, + ]; + const configPath = path.join(tmpDir, "opencode.json"); + writeBatchConfig(credentials, "openai", configPath); + + const written = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(written.provider.anthropic).toBeUndefined(); + expect(written.provider.openai).toBeDefined(); + }); + + it("rejects empty string keys — does not write provider", async () => { + const { writeBatchConfig } = await import("../src/credential_scanner"); + const credentials: DetectedCredential[] = [ + { provider: "anthropic", key: "", source: "env" }, + { provider: "openai", key: "sk-openai-real-key-12345", source: "env" }, + ]; + const configPath = path.join(tmpDir, "opencode.json"); + writeBatchConfig(credentials, "openai", configPath); + + const written = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(written.provider.anthropic).toBeUndefined(); + expect(written.provider.openai).toBeDefined(); + }); + + it("rejects keys shorter than 10 characters — does not write provider", async () => { + const { writeBatchConfig } = await import("../src/credential_scanner"); + const credentials: DetectedCredential[] = [ + { provider: "anthropic", key: "short", source: "env" }, + { provider: "openai", key: "sk-openai-real-key-12345", source: "env" }, + ]; + const configPath = path.join(tmpDir, "opencode.json"); + writeBatchConfig(credentials, "openai", configPath); + + const written = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(written.provider.anthropic).toBeUndefined(); + expect(written.provider.openai).toBeDefined(); + }); +}); + +describe("writeOnboardingConfig — placeholder key rejection (#455 AC5)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("rejects 'sk-test' placeholder key — does not write provider entry", async () => { + const { writeOnboardingConfig } = await import("../src/onboarding_panel"); + const configPath = path.join(tmpDir, "opencode.json"); + writeOnboardingConfig( + { provider: "anthropic", model: "anthropic/claude-sonnet-4-5", apiKey: "sk-test" }, + configPath, + ); + + const written = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(written.provider?.anthropic).toBeUndefined(); + }); + + it("rejects keys shorter than 10 characters — does not write provider entry", async () => { + const { writeOnboardingConfig } = await import("../src/onboarding_panel"); + const configPath = path.join(tmpDir, "opencode.json"); + writeOnboardingConfig( + { provider: "anthropic", model: "anthropic/claude-sonnet-4-5", apiKey: "tiny" }, + configPath, + ); + + const written = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(written.provider?.anthropic).toBeUndefined(); + }); + + it("allows valid keys (>= 10 chars, not placeholder)", async () => { + const { writeOnboardingConfig } = await import("../src/onboarding_panel"); + const configPath = path.join(tmpDir, "opencode.json"); + writeOnboardingConfig( + { provider: "anthropic", model: "anthropic/claude-sonnet-4-5", apiKey: "sk-ant-valid-key-123456" }, + configPath, + ); + + const written = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(written.provider.anthropic).toBeDefined(); + expect(written.provider.anthropic.options.apiKey).toBe("sk-ant-valid-key-123456"); + }); + + it("allows empty key for OAuth providers like github-copilot", async () => { + const { writeOnboardingConfig } = await import("../src/onboarding_panel"); + const configPath = path.join(tmpDir, "opencode.json"); + writeOnboardingConfig( + { provider: "github-copilot", model: "github-copilot/claude-sonnet-4-5", apiKey: "" }, + configPath, + ); + + const written = JSON.parse(fs.readFileSync(configPath, "utf8")); + // OAuth providers write with empty key (no options.apiKey) — that's valid + expect(written.provider["github-copilot"]).toBeDefined(); + }); +}); + +describe("writeBatchConfig — replaces provider section (redo overwrites)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("replaces existing providers with only the selected ones", async () => { + const { writeBatchConfig } = await import("../src/credential_scanner"); + const credentials: DetectedCredential[] = [ + { provider: "openai", key: "sk-openai-real-key-12345", source: "env" }, + ]; + const configPath = path.join(tmpDir, "opencode.json"); + + // Pre-populate with existing bedrock config (as if from a previous onboarding) + fs.writeFileSync(configPath, JSON.stringify({ + provider: { "amazon-bedrock": { options: { apiKey: "ABSK-service-credential-xyz" } } }, + permission: { bash: "allow" }, + })); + + writeBatchConfig(credentials, "openai", configPath); + + const written = JSON.parse(fs.readFileSync(configPath, "utf8")); + // Old provider NOT preserved — user didn't select it this time + expect(written.provider["amazon-bedrock"]).toBeUndefined(); + // Only the user-selected provider is present + expect(written.provider.openai).toBeDefined(); + expect(written.provider.openai.options.apiKey).toBe("sk-openai-real-key-12345"); + // Non-provider settings are still preserved + expect(written.permission).toEqual({ bash: "allow" }); + }); + + it("writeBatchConfig never modifies auth stores (account.json / auth.json)", async () => { + const { writeBatchConfig } = await import("../src/credential_scanner"); + const credentials: DetectedCredential[] = [ + { provider: "openai", key: "sk-openai-real-key-12345", source: "env" }, + ]; + const configPath = path.join(tmpDir, "opencode.json"); + + // Set up fake auth stores and record their content + const accountPath = path.join(tmpDir, "account.json"); + const authPath = path.join(tmpDir, "auth.json"); + const accountContent = JSON.stringify({ + version: 2, + accounts: { acc1: { id: "acc1", serviceID: "opencode", credential: { type: "api", key: "sk-oc" } } }, + active: { opencode: "acc1" }, + }); + const authContent = JSON.stringify({ + "opencode-go": { type: "api", key: "sk-old" }, + "amazon-bedrock": { type: "api", key: "aws-key" }, + }); + fs.writeFileSync(accountPath, accountContent); + fs.writeFileSync(authPath, authContent); + + // Write batch config (only touches opencode.json) + writeBatchConfig(credentials, "openai", configPath); + + // Auth stores must be UNTOUCHED + expect(fs.readFileSync(accountPath, "utf8")).toBe(accountContent); + expect(fs.readFileSync(authPath, "utf8")).toBe(authContent); + }); +}); + +// ─── disconnectProviders — remove excluded providers from auth stores ──────── + +describe("disconnectProviders — removes credentials from opencode auth stores", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("removes excluded provider from account.json v2", () => { + const accountPath = writeJson(tmpDir, "account.json", { + version: 2, + accounts: { + acc1: { id: "acc1", serviceID: "opencode", credential: { type: "api", key: "sk-oc" } }, + acc2: { id: "acc2", serviceID: "amazon-bedrock", credential: { type: "api", key: "aws-key" } }, + }, + active: { opencode: "acc1", "amazon-bedrock": "acc2" }, + }); + + disconnectProviders(["opencode"], { accountJsonPath: accountPath, authJsonPath: "/nonexistent" }); + + const result = JSON.parse(fs.readFileSync(accountPath, "utf8")); + // opencode removed + expect(result.accounts.acc1).toBeUndefined(); + expect(result.active.opencode).toBeUndefined(); + // amazon-bedrock preserved + expect(result.accounts.acc2).toBeDefined(); + expect(result.active["amazon-bedrock"]).toBe("acc2"); + }); + + it("removes excluded provider from auth.json v1", () => { + const authPath = writeJson(tmpDir, "auth.json", { + "opencode-go": { type: "api", key: "sk-oc" }, + "amazon-bedrock": { type: "api", key: "aws-key" }, + }); + + disconnectProviders(["opencode"], { accountJsonPath: "/nonexistent", authJsonPath: authPath }); + + const result = JSON.parse(fs.readFileSync(authPath, "utf8")); + // opencode-go removed (alias of opencode) + expect(result["opencode-go"]).toBeUndefined(); + // amazon-bedrock preserved + expect(result["amazon-bedrock"]).toBeDefined(); + }); + + it("handles missing files gracefully", () => { + // Should not throw + expect(() => + disconnectProviders(["opencode"], { + accountJsonPath: "/nonexistent/account.json", + authJsonPath: "/nonexistent/auth.json", + }), + ).not.toThrow(); + }); + + it("only removes the specified provider — others are preserved", () => { + const accountPath = writeJson(tmpDir, "account.json", { + version: 2, + accounts: { + acc1: { id: "acc1", serviceID: "opencode", credential: { type: "api", key: "sk-oc" } }, + acc2: { id: "acc2", serviceID: "opencode-go", credential: { type: "api", key: "sk-oc-go" } }, + acc3: { id: "acc3", serviceID: "amazon-bedrock", credential: { type: "api", key: "aws-key" } }, + }, + active: { opencode: "acc1", "opencode-go": "acc2", "amazon-bedrock": "acc3" }, + }); + + // Only disconnect opencode — bedrock must survive + disconnectProviders(["opencode"], { accountJsonPath: accountPath, authJsonPath: "/nonexistent" }); + + const result = JSON.parse(fs.readFileSync(accountPath, "utf8")); + // opencode AND opencode-go removed (alias) + expect(result.accounts.acc1).toBeUndefined(); + expect(result.accounts.acc2).toBeUndefined(); + expect(result.active.opencode).toBeUndefined(); + expect(result.active["opencode-go"]).toBeUndefined(); + // amazon-bedrock PRESERVED + expect(result.accounts.acc3).toBeDefined(); + expect(result.accounts.acc3.serviceID).toBe("amazon-bedrock"); + expect(result.active["amazon-bedrock"]).toBe("acc3"); + }); +}); diff --git a/packages/extension/test/onboarding_panel.test.ts b/packages/extension/test/onboarding_panel.test.ts index 39fcad66..a738c27a 100644 --- a/packages/extension/test/onboarding_panel.test.ts +++ b/packages/extension/test/onboarding_panel.test.ts @@ -18,6 +18,9 @@ import { writeOnboardingConfig, testConnection, onOnboardingComplete, + dismissOnboardingPanel, + getOnboardingPanel, + releaseOnboardingPanel, _resetForTesting, } from "../src/onboarding_panel"; @@ -63,6 +66,20 @@ describe("OnboardingPanel — panel lifecycle (AC1, AC6, AC7)", () => { spy.mockRestore(); }); + it("getOnboardingPanel returns the live panel, releaseOnboardingPanel detaches it", async () => { + expect(getOnboardingPanel()).toBeUndefined(); // no panel yet + const spy = vi.spyOn(vscode.window, "createWebviewPanel"); + await vscode.commands.executeCommand("amicode.onboarding.open"); + const panel = getOnboardingPanel(); + expect(panel).toBeDefined(); + // Release detaches without disposing + releaseOnboardingPanel(); + expect(getOnboardingPanel()).toBeUndefined(); + // Panel is still alive (not disposed) + expect((panel as any).webview).toBeDefined(); + spy.mockRestore(); + }); + it("AC7: fires an event after onboarding completes", async () => { const fired: boolean[] = []; const disposable = onOnboardingComplete(() => { @@ -197,7 +214,7 @@ describe("writeOnboardingConfig — config file writing (AC5)", () => { fs.writeFileSync(configPath, JSON.stringify({ permission: { bash: "allow" } })); writeOnboardingConfig( - { provider: "anthropic", model: "anthropic/claude-sonnet-4-5", apiKey: "sk-x" }, + { provider: "anthropic", model: "anthropic/claude-sonnet-4-5", apiKey: "sk-ant-valid-key-123456" }, configPath, ); @@ -458,7 +475,7 @@ describe("Credential import — panel message handling (AC2, AC8, AC12, AC14)", spy.mockRestore(); }); - it("confirm-import writes batch config and disposes panel", async () => { + it("confirm-import keeps the panel alive as a transition splash (not disposed immediately)", async () => { const spy = vi.spyOn(vscode.window, "createWebviewPanel"); await vscode.commands.executeCommand("amicode.onboarding.open"); const panel = spy.mock.results[0].value as { @@ -482,18 +499,98 @@ describe("Credential import — panel message handling (AC2, AC8, AC12, AC14)", panel.webview._simulateMessage({ type: "scan-credentials" }); await new Promise((r) => setTimeout(r, 50)); - // Now confirm import (even if scan found nothing in test env, the handler should work) + // Now confirm import + panel.webview._simulateMessage({ + type: "confirm-import", + payload: { activeProvider: "anthropic" }, + }); + await new Promise((r) => setTimeout(r, 50)); + + // Panel should NOT have been disposed yet — it's showing the transition splash + expect(disposeSpy).not.toHaveBeenCalled(); + + // Instead, the panel HTML should have been swapped to the splash + expect(panel.webview.html).toContain("Getting Amico ready"); + expect(panel.webview.html).toContain("splash-mark"); + + spy.mockRestore(); + }); + + it("dismissOnboardingPanel disposes the transition splash", async () => { + const spy = vi.spyOn(vscode.window, "createWebviewPanel"); + await vscode.commands.executeCommand("amicode.onboarding.open"); + const panel = spy.mock.results[0].value as { + webview: { + postMessage: ReturnType; + _simulateMessage: (msg: unknown) => void; + }; + dispose: ReturnType; + }; + + const disposeSpy = vi.fn(); + const origDispose = panel.dispose; + panel.dispose = (...args: unknown[]) => { + disposeSpy(); + return (origDispose as Function).apply(panel, args); + }; + + const postSpy = vi.fn().mockResolvedValue(true); + panel.webview.postMessage = postSpy; + + // Trigger scan + confirm to enter transition state + panel.webview._simulateMessage({ type: "scan-credentials" }); + await new Promise((r) => setTimeout(r, 50)); panel.webview._simulateMessage({ type: "confirm-import", payload: { activeProvider: "anthropic" }, }); await new Promise((r) => setTimeout(r, 50)); - // Panel should have been disposed (onboarding complete) + // Panel still alive + expect(disposeSpy).not.toHaveBeenCalled(); + + // Now dismiss (extension calls this after app-ready) + dismissOnboardingPanel(); + + // Panel should now be disposed expect(disposeSpy).toHaveBeenCalled(); spy.mockRestore(); }); + + it("confirm-import restarts server but does NOT open chat directly (waits for ready)", async () => { + // Clear command execution history + (vscode.commands as { executed: string[] }).executed = []; + + const spy = vi.spyOn(vscode.window, "createWebviewPanel"); + await vscode.commands.executeCommand("amicode.onboarding.open"); + const panel = spy.mock.results[0].value as { + webview: { + postMessage: ReturnType; + _simulateMessage: (msg: unknown) => void; + }; + }; + + const postSpy = vi.fn().mockResolvedValue(true); + panel.webview.postMessage = postSpy; + + // Trigger scan then confirm + panel.webview._simulateMessage({ type: "scan-credentials" }); + await new Promise((r) => setTimeout(r, 50)); + panel.webview._simulateMessage({ + type: "confirm-import", + payload: { activeProvider: "anthropic", includedProviders: ["anthropic"] }, + }); + await new Promise((r) => setTimeout(r, 50)); + + const executed = (vscode.commands as { executed: string[] }).executed; + // Should restart the server + expect(executed).toContain("amicode.restartServer"); + // Should NOT open chat directly (that causes the fetch-failed error) + expect(executed).not.toContain("amicode.openChat"); + + spy.mockRestore(); + }); }); describe("Webview HTML generation (AC2, AC9)", () => { diff --git a/packages/extension/test/scores/compiler.test.ts b/packages/extension/test/scores/compiler.test.ts index f8c84b89..1c8c52ea 100644 --- a/packages/extension/test/scores/compiler.test.ts +++ b/packages/extension/test/scores/compiler.test.ts @@ -82,7 +82,7 @@ describe("compileChainedScore (real overture → pulse-designer)", () => { expect(md).not.toContain("plain text"); }); it("keeps the overture's choice questions as option cards, default first (amicode#245 AC6 regression)", () => { - expect(md).toContain("Research (recommended)"); + expect(md).toContain("Perform (automated) experiments and gain scientific insights (recommended)"); expect(md).toContain("General coding and software development"); }); }); diff --git a/packages/extension/test/scores/golden/compile-chained.md b/packages/extension/test/scores/golden/compile-chained.md index 844ca185..397d7057 100644 --- a/packages/extension/test/scores/golden/compile-chained.md +++ b/packages/extension/test/scores/golden/compile-chained.md @@ -14,20 +14,20 @@ gate's checks pass. 1. **orientation** - Q `name`: "What should I call you?" -2. **intent** - - Q `intent`: "What brings you to Amicode?" — options: General coding and software development | Research (recommended) | Exploring -3. **context_seed** (optional) +2. **context_seed** (optional) - Q `seed_optin`: "I can scan your existing AI-tool configs to bootstrap your workspace — want me to?" — options: Yes, scan my configs (recommended) | No thanks, skip -4. **demo** (optional) - - Q `demo_offer`: "Want me to show you the full workflow end-to-end? (requires Julia)" — options: Yes, show me (recommended) | Skip the demo -5. **environment** - - Q `environment`: "How will pulses eventually reach hardware — what are we patching into?" — options: QICK lab (on-prem control code) | Cloud system with emulator (e.g. Pasqal) | Simulation only for now (recommended) | Something else -6. **devices** (optional) - - Q `devices`: "Any specific device(s) you want me to remember? (name, platform, qubit count — or skip)" — default: skip for now -7. **goals** +3. **intent** + - Q `intent`: "What brings you to Amicode?" — options: General coding and software development | Perform (automated) experiments and gain scientific insights (recommended) | Exploring +4. **goals** - Q `goals`: "What are you hoping to accomplish with Amico?" +5. **research_area** (optional) + - Q `research_area`: "What research area and what kind of experiments?" +6. **environment** (optional) + - Q `environment`: "How will your experiments reach hardware?" — options: Lab hardware (on-prem control system) | Cloud platform with emulator | Simulation only for now (recommended) | Something else +7. **devices** (optional) + - Q `devices`: "Any specific device(s) you want me to remember? (name, platform, specs — or skip)" — default: skip for now 8. **handoff** - - Q `handoff`: "Ready to get started?" — options: Walk me through designing a pulse (recommended) | Open a normal session | Show me around first + - Q `handoff`: "Ready to get started?" — options: Let's dive into my first task (recommended) | Open a normal session | Show me around first 9. **platform** - Q `platform`: "What kind of system are you working with?" — options: transmon (recommended) | neutral-atom Rydberg | cavity / bosonic | other 10. **model** @@ -85,34 +85,14 @@ Per-stage guidance and the `amicode_profile` mapping: **What Amicode is (share naturally within this greeting, not as a lecture):** Amicode is a general-purpose agentic coding assistant AND a research studio. - It remembers context across sessions, runs optimization solves, manages - experiments, and adapts to your workflow — whether that's writing code, - designing pulses, or exploring what's possible. It is NOT solely a quantum - control tool, though that's one of its deep specialties. + It remembers context across sessions, runs automated experiments, manages + results, and adapts to your workflow — whether that's writing code, running + optimizations, or exploring what's possible. Do NOT ask about experience level. Do NOT branch by expertise. The same warm, brief orientation for everyone. -2. **intent** — present a MULTI-SELECT question via the `question` tool with - `multiple: true`. The question: "What brings you to Amicode?" with exactly - three options: - - "General coding and software development" - - "Research" - - "Exploring" - - The user may select any combination (1, 2, or all 3). Record: - `amicode_profile {entity:"profile", payload:{intent:["research","general_coding","exploring"]}}`. - Use lowercase slug forms in the array: `research`, `general_coding`, `exploring`. - - **DO NOT ask research sub-type here.** Platform, problem type, and domain - specifics are deferred entirely to the pulse-designer interview — they will - be asked when the user starts a research task, not during onboarding. This - keeps the overture fast and generic. - - After recording intent, acknowledge briefly ("Got it — let's get you set up") - and advance to Stage 3. - -3. **context_seed** _(optional)_ — offer an explicit opt-in: "I can scan your +2. **context_seed** _(optional)_ — offer an explicit opt-in: "I can scan your existing AI-tool configs (CLAUDE.md, cursor rules, opencode config) to bootstrap your workspace — want me to?" via the `question` tool with the two choices above. @@ -144,85 +124,74 @@ Per-stage guidance and the `amicode_profile` mapping: - If no scannable files are found, say so honestly: "I didn't find any AI-tool configs to import — no worries, we'll build your context as we go." - After seeding (or declining), advance to Stage 4 (demo). + After seeding (or declining), advance. -4. **demo** _(optional)_ — check Julia readiness by calling - `amicode_demo_check`. This returns `{ready: true|false, reason?}`. - - **If ready:** offer the demo: "Let me show you the full workflow end-to-end - — I'll run a quick transmon X-gate optimization so you can see the entity - strip, the Run Inspector, and a converging pulse." Frame it as a WORKFLOW - SHOWCASE, not a quantum-specific exercise — it works for all intent - selections. - - On accept, call `amicode_demo_launch`. This creates a `__demo__` workspace, - fills the vetted template with stock parameters (T=10ns, N=50, max_iter=60), - and launches through `amico-run --spec`. The Run Inspector streams - iterations live. After FINISHED, report the result: "Solved — F=0.9998 in - 47 iterations" (or whatever the actual numbers are). Then call - `amicode_demo_archive` to clean up the ephemeral workspace. - - **If not ready:** explain honestly: "Julia environment isn't set up yet — - {reason}. No worries, we'll skip the demo. You can always run one later - from the command palette." Advance without blocking. +3. **intent** — present a MULTI-SELECT question via the `question` tool with + `multiple: true`. The question: "What brings you to Amicode?" with exactly + three options: + - "General coding and software development" + - "Perform (automated) experiments and gain scientific insights" + - "Exploring" - **If the user DECLINES the demo:** say "No problem" and advance. + The user may select any combination (1, 2, or all 3). Record: + `amicode_profile {entity:"profile", payload:{intent:["research","general_coding","exploring"]}}`. + Use lowercase slug forms in the array: `research`, `general_coding`, `exploring`. - **If the demo FAILS** (Julia error, convergence failure): report honestly - and continue. A failed demo never blocks onboarding. + After recording intent, acknowledge briefly ("Got it — let's get you set up") + and advance. - **Constraints:** - - The demo MUST use the vetted template — never free-tier. - - The demo MUST NOT create vault artifacts (no problem card, no pulse bank entry). - - If `isDemoCompleted()` is true (archive marker exists), skip — don't re-offer. +4. **goals** — free-text question via `question` tool with `kind: "text"`: + "What are you hoping to accomplish with Amico?" No pre-fill (goals are + personal, not inferrable from configs). - After the demo (or skipping), advance to Stage 5. + Record: `amicode_profile {entity:"profile", payload:{goals:"..."}}`. -5. **environment** — ask how pulses will reach hardware. **Pre-fill from - seeds:** call `amicode_profile {entity:"status"}` and check if an - environment is already recorded from the context-seed (Stage 3). If so, - present it as a confirmation: "I found you use {archetype} — confirm, or - change?" via the `question` tool. If no seed, ask the standard choice - question with the options above. +5. **research_area** _(optional — only if user selected the experiments intent)_ — + ask via the `question` tool with `kind: "text"`: "What research area and what + kind of experiments?" This is free-form — the user can say anything from + "quantum optimal control for transmon gates" to "protein folding simulations" + to "materials science DFT sweeps." Record whatever they say: + `amicode_profile {entity:"profile", payload:{research_area:"..."}}`. + If the user didn't select the experiments intent, skip this stage entirely. + +6. **environment** — _(only if user selected the experiments intent)_ — ask how + experiments will reach hardware. **Pre-fill from seeds:** call + `amicode_profile {entity:"status"}` and check if an environment is already + recorded from the context-seed (Stage 2). If so, present it as a + confirmation: "I found you use {archetype} — confirm, or change?" via the + `question` tool. If no seed, ask the standard choice question with the + options above. Record: `amicode_profile {entity:"environment", payload:{slug, archetype}}`. - Follow up on details per archetype if confirmed (QICK: tProc version, - repo pointer; cloud-Pasqal: which provider, emulator access; etc.). + Follow up on details per archetype if confirmed. -6. **devices** _(optional)_ — same pre-fill pattern: if a device was seeded, - confirm it. Otherwise ask: "Any specific device(s) you want me to remember?" +7. **devices** _(optional, only if user selected the experiments intent)_ — + same pre-fill pattern: if a device was seeded, confirm it. Otherwise ask: + "Any specific device(s) you want me to remember?" This stage is ALWAYS skippable — "none" or "skip" is a valid answer. - Record: `amicode_profile {entity:"device", payload:{name, platform, qubits}}`. + Record: `amicode_profile {entity:"device", payload:{name, platform, specs}}`. If skipped, move on without recording. -7. **goals** — free-text question via `question` tool with `kind: "text"`: - "What are you hoping to accomplish with Amico?" No pre-fill (goals are - personal, not inferrable from configs). - - Record: `amicode_profile {entity:"profile", payload:{goals:"..."}}`. +8. **handoff** — the terminal stage. FIRST, **auto-generate a description** from + what you've learned (name, goals, research_area, intent, environment) — a + concise 1–2 sentence summary of the user written in third person, suitable + for the "About you" card. Example: "Aaron is a quantum-control researcher + focused on high-fidelity transmon gates, working in simulation." Record: + `amicode_profile {entity:"profile", payload:{description:"..."}}`. -8. **handoff** — the terminal stage. FIRST, record the completion marker: + Then record the completion marker: `amicode_profile {entity:"onboarding_completed"}` (exactly once — this is what lets Amico remember them next time and triggers the distiller to materialize the vault). - Then route by the user's intent selections (from Stage 2 — read from the - events stream, do NOT re-ask): - - - **Research** selected (alone or combined) → "Let's design your first - pulse" → continue straight into the **pulse-designer interview** in this - same session. Use everything learned (platform, environment, device) to - skip pulse-design questions already answered. - - **Research + General coding** → same as above, but acknowledge: "I'm also - your general coding companion — you can switch modes any time." - - **General coding only** (no Research) → open a normal session: "You're all - set — I'll remember your context across sessions. Ask me anything." - Highlight memory + vault features briefly. - - **Exploring only** → "Welcome aboard — want a quick tour of what I can do, - or just dive in?" Offer a brief orientation tour. - - The handoff does NOT re-ask intent — it reads what was recorded and routes. + Then tell the user onboarding is complete: "You're all set — your About You + card on the dashboard is now populated with what you told me. To see it, + reload the window (Cmd+Shift+P → 'Reload Window', or Cmd+R). After that, + start a new session anytime to explore what Amico can do." + + Do NOT auto-chain into another interview or open a new session. The + onboarding ends here. The user is in control of what happens next. --- diff --git a/packages/extension/test/scores/golden/router-section.md b/packages/extension/test/scores/golden/router-section.md index fca1bef2..5dc716fc 100644 --- a/packages/extension/test/scores/golden/router-section.md +++ b/packages/extension/test/scores/golden/router-section.md @@ -1,6 +1,11 @@ ## Onset router -When a session opens without a specific request (a greeting, "who are +When a session opens with an explicit onboarding request ("Let's begin +onboarding", "begin onboarding", "start onboarding", or similar), **skip this +router entirely** and go straight into the overture (Stage 1 below) — the user +has already chosen. + +Otherwise, when a session opens without a specific request (a greeting, "who are you?", "what is this?"), do NOT default to the pulse-designer interview — build the moment from the live state. After your one-line Amico intro (name from the profile when one is recorded), ask exactly ONE question — diff --git a/packages/extension/test/scores/overture_rewrite.test.ts b/packages/extension/test/scores/overture_rewrite.test.ts index 75725e05..722e3d6a 100644 --- a/packages/extension/test/scores/overture_rewrite.test.ts +++ b/packages/extension/test/scores/overture_rewrite.test.ts @@ -36,20 +36,24 @@ describe("overture SCORE.md — loads and compiles (AC1)", () => { expect(ov.manifest.schema_version).toBe(1); }); - it("has the new stage structure: orientation, intent, context_seed, demo, environment, devices, goals, handoff", () => { + it("has the new stage structure: orientation, context_seed, intent, goals, research_area, environment, devices, handoff", () => { const ov = overture(); const stageIds = ov.manifest.stages.map((s: { id: string }) => s.id); expect(stageIds).toContain("orientation"); - expect(stageIds).toContain("intent"); expect(stageIds).toContain("context_seed"); - expect(stageIds).toContain("demo"); + expect(stageIds).toContain("intent"); + expect(stageIds).toContain("goals"); + expect(stageIds).toContain("research_area"); expect(stageIds).toContain("environment"); expect(stageIds).toContain("devices"); - expect(stageIds).toContain("goals"); expect(stageIds).toContain("handoff"); - // Old stage name is gone + // Old/removed stages are gone + expect(stageIds).not.toContain("demo"); expect(stageIds).not.toContain("platforms"); expect(stageIds).not.toContain("identity"); + // Verify order: context_seed before intent, intent before goals + expect(stageIds.indexOf("context_seed")).toBeLessThan(stageIds.indexOf("intent")); + expect(stageIds.indexOf("intent")).toBeLessThan(stageIds.indexOf("goals")); }); it("compiles to markdown without error (standalone)", () => { @@ -97,7 +101,7 @@ describe("overture compiled content — Stage 2 intent (AC4, AC5, AC6)", () => { it("AC4: presents exactly three options for multi-select", () => { expect(md).toContain("General coding and software development"); - expect(md).toContain("Research"); + expect(md).toContain("Perform (automated) experiments and gain scientific insights"); expect(md).toContain("Exploring"); }); @@ -110,8 +114,8 @@ describe("overture compiled content — Stage 2 intent (AC4, AC5, AC6)", () => { expect(md).toMatch(/intent.*\[.*research.*general_coding.*exploring.*\]/s); }); - it("AC6: does NOT ask research sub-type (deferred to pulse-designer)", () => { - expect(md).toContain("DO NOT ask research sub-type"); + it("AC6: asks research area as free-form, does NOT ask platform-specific sub-types", () => { + expect(md).toContain("What research area and what kind of experiments?"); expect(md).not.toContain("Which platform"); expect(md).not.toContain("qubit platforms"); }); @@ -148,11 +152,11 @@ describe("overture compiled content — resume (AC8)", () => { describe("overture compiled content — complete flow (AC9)", () => { const md = compileScore(overture()); - it("the overture score is complete: all 8 stages defined end-to-end", () => { + it("the overture score is complete: all stages defined end-to-end", () => { expect(md).toContain("orientation"); expect(md).toContain("intent"); + expect(md).toContain("research_area"); expect(md).toContain("context_seed"); - expect(md).toContain("demo"); expect(md).toContain("environment"); expect(md).toContain("goals"); expect(md).toContain("handoff");