diff --git a/src/lib/__tests__/agent-runner-ask.test.ts b/src/lib/__tests__/agent-runner-ask.test.ts new file mode 100644 index 00000000..edb02f81 --- /dev/null +++ b/src/lib/__tests__/agent-runner-ask.test.ts @@ -0,0 +1,30 @@ +import { shouldDisableAsk } from '../agent/agent-runner'; + +const baseSession = { ci: false, signup: false }; +const baseConfig = {}; + +describe('shouldDisableAsk', () => { + it('enables wizard_ask in interactive runs by default', () => { + expect(shouldDisableAsk(baseSession, baseConfig)).toBe(false); + }); + + it('auto-disables when running in CI mode', () => { + expect(shouldDisableAsk({ ci: true, signup: false }, baseConfig)).toBe( + true, + ); + }); + + it('auto-disables during the signup flow (which is non-interactive at the prompt layer)', () => { + expect(shouldDisableAsk({ ci: false, signup: true }, baseConfig)).toBe( + true, + ); + }); + + it('honors an explicit disableAsk override on the workflow', () => { + expect(shouldDisableAsk(baseSession, { disableAsk: true })).toBe(true); + }); + + it('treats disableAsk=false as not disabling', () => { + expect(shouldDisableAsk(baseSession, { disableAsk: false })).toBe(false); + }); +}); diff --git a/src/lib/__tests__/wizard-ask-bridge.test.ts b/src/lib/__tests__/wizard-ask-bridge.test.ts new file mode 100644 index 00000000..a325bc5d --- /dev/null +++ b/src/lib/__tests__/wizard-ask-bridge.test.ts @@ -0,0 +1,199 @@ +import { + CANCELLED_SENTINEL, + createWizardAskBridge, +} from '../wizard-ask-bridge'; +import { analytics } from '../../utils/analytics'; +import type { AskAnswers, PendingQuestion } from '../wizard-session'; + +jest.mock('../../utils/analytics', () => ({ + analytics: { + wizardCapture: jest.fn(), + }, +})); + +const wizardCaptureMock = analytics.wizardCapture as jest.Mock; + +beforeEach(() => { + wizardCaptureMock.mockClear(); +}); + +describe('createWizardAskBridge', () => { + it('forwards questions to showQuestion and resolves with the captured answers', async () => { + const captured: PendingQuestion[] = []; + let resolveAnswers!: (answers: AskAnswers) => void; + const showQuestion = (q: PendingQuestion): Promise => { + captured.push(q); + return new Promise((r) => { + resolveAnswers = r; + }); + }; + + const bridge = createWizardAskBridge({ + getSource: () => 'creating-product-tours', + showQuestion, + }); + + const requestPromise = bridge.request({ + questions: [{ id: 'goal', prompt: 'Goal?', kind: 'text' }], + }); + + expect(captured).toHaveLength(1); + expect(captured[0].questions).toEqual([ + { id: 'goal', prompt: 'Goal?', kind: 'text' }, + ]); + expect(captured[0].source).toBe('creating-product-tours'); + expect(captured[0].id).toMatch(/.+/); + + resolveAnswers({ goal: 'Help users find the export button' }); + + await expect(requestPromise).resolves.toEqual({ + goal: 'Help users find the export button', + }); + }); + + it('stamps a unique id per request', async () => { + const ids: string[] = []; + const showQuestion = (q: PendingQuestion): Promise => { + ids.push(q.id); + return Promise.resolve({}); + }; + + const bridge = createWizardAskBridge({ + getSource: () => 'skill', + showQuestion, + }); + + await bridge.request({ + questions: [{ id: 'a', prompt: 'A', kind: 'text' }], + }); + await bridge.request({ + questions: [{ id: 'a', prompt: 'A', kind: 'text' }], + }); + + expect(ids).toHaveLength(2); + expect(ids[0]).not.toBe(ids[1]); + }); + + it('reads source from getSource at call time so late-bound skillIds work', async () => { + let source = 'first-skill'; + const captured: PendingQuestion[] = []; + const showQuestion = (q: PendingQuestion): Promise => { + captured.push(q); + return Promise.resolve({}); + }; + + const bridge = createWizardAskBridge({ + getSource: () => source, + showQuestion, + }); + + await bridge.request({ + questions: [{ id: 'a', prompt: 'A', kind: 'text' }], + }); + source = 'second-skill'; + await bridge.request({ + questions: [{ id: 'b', prompt: 'B', kind: 'text' }], + }); + + expect(captured[0].source).toBe('first-skill'); + expect(captured[1].source).toBe('second-skill'); + }); + + describe('analytics', () => { + it('emits `wizard_ask answered` with duration and question count', async () => { + let resolveAnswers!: (answers: AskAnswers) => void; + const bridge = createWizardAskBridge({ + getSource: () => 'product-tours', + showQuestion: () => + new Promise((r) => { + resolveAnswers = r; + }), + }); + + const p = bridge.request({ + questions: [ + { id: 'a', prompt: 'A', kind: 'text' }, + { id: 'b', prompt: 'B', kind: 'text' }, + ], + }); + resolveAnswers({ a: 'x', b: 'y' }); + await p; + + expect(wizardCaptureMock).toHaveBeenCalledWith( + 'wizard_ask answered', + expect.objectContaining({ + source: 'product-tours', + question_count: 2, + duration_ms: expect.any(Number), + }), + ); + }); + + it('emits `wizard_ask cancelled` when every field comes back as the cancelled sentinel', async () => { + const bridge = createWizardAskBridge({ + getSource: () => 'product-tours', + showQuestion: () => + Promise.resolve({ a: CANCELLED_SENTINEL, b: CANCELLED_SENTINEL }), + }); + + await bridge.request({ + questions: [ + { id: 'a', prompt: 'A', kind: 'text' }, + { id: 'b', prompt: 'B', kind: 'text' }, + ], + }); + + const cancelledCall = wizardCaptureMock.mock.calls.find( + ([name]) => name === 'wizard_ask cancelled', + ); + expect(cancelledCall).toBeDefined(); + expect(cancelledCall?.[1]).toMatchObject({ + source: 'product-tours', + question_count: 2, + timed_out: false, + }); + + // It is cancelled, not answered. + expect( + wizardCaptureMock.mock.calls.some( + ([name]) => name === 'wizard_ask answered', + ), + ).toBe(false); + }); + }); + + describe('timeout', () => { + it('resolves every field with the cancelled sentinel when the user does not answer in time', async () => { + jest.useFakeTimers(); + try { + // showQuestion intentionally never resolves — the timeout has to win. + const bridge = createWizardAskBridge({ + getSource: () => 'product-tours', + showQuestion: () => new Promise(() => undefined), + timeoutMs: 1000, + }); + + const promise = bridge.request({ + questions: [ + { id: 'goal', prompt: 'Goal?', kind: 'text' }, + { id: 'audience', prompt: 'Who?', kind: 'text' }, + ], + }); + + jest.advanceTimersByTime(1000); + + await expect(promise).resolves.toEqual({ + goal: CANCELLED_SENTINEL, + audience: CANCELLED_SENTINEL, + }); + + const cancelledCall = wizardCaptureMock.mock.calls.find( + ([name]) => name === 'wizard_ask cancelled', + ); + expect(cancelledCall?.[1]).toMatchObject({ timed_out: true }); + } finally { + jest.useRealTimers(); + } + }); + }); +}); diff --git a/src/lib/__tests__/wizard-can-use-tool.test.ts b/src/lib/__tests__/wizard-can-use-tool.test.ts new file mode 100644 index 00000000..c7d04f2d --- /dev/null +++ b/src/lib/__tests__/wizard-can-use-tool.test.ts @@ -0,0 +1,55 @@ +import { wizardCanUseTool } from '../agent/agent-interface'; + +jest.mock('../../utils/analytics', () => ({ + analytics: { + wizardCapture: jest.fn(), + }, +})); +jest.mock('../../utils/debug'); + +describe('wizardCanUseTool — wizard_ask pending guard', () => { + for (const tool of ['Write', 'Edit'] as const) { + it(`denies ${tool} while a wizard_ask overlay is pending`, () => { + const result = wizardCanUseTool( + tool, + { file_path: 'src/app.ts', content: 'x' }, + { wizardAskPending: true }, + ); + expect(result).toEqual({ + behavior: 'deny', + message: expect.stringMatching(/wizard_ask question is open/), + }); + }); + + it(`allows ${tool} when no overlay is pending`, () => { + const result = wizardCanUseTool( + tool, + { file_path: 'src/app.ts', content: 'x' }, + { wizardAskPending: false }, + ); + expect(result.behavior).toBe('allow'); + }); + } + + it('still allows Read while a wizard_ask overlay is pending (read-only is safe)', () => { + const result = wizardCanUseTool( + 'Read', + { file_path: 'src/app.ts' }, + { wizardAskPending: true }, + ); + expect(result.behavior).toBe('allow'); + }); + + it('defaults to no guard when context is omitted (preserves pre-Phase-3 callers)', () => { + const result = wizardCanUseTool('Write', { file_path: 'src/app.ts' }); + expect(result.behavior).toBe('allow'); + }); + + it('still denies Write on .env files even when no overlay is pending', () => { + const result = wizardCanUseTool('Write', { file_path: '.env.local' }); + expect(result).toEqual({ + behavior: 'deny', + message: expect.stringMatching(/wizard-tools MCP server/), + }); + }); +}); diff --git a/src/lib/__tests__/wizard-tools.test.ts b/src/lib/__tests__/wizard-tools.test.ts index a725033e..83b13b0e 100644 --- a/src/lib/__tests__/wizard-tools.test.ts +++ b/src/lib/__tests__/wizard-tools.test.ts @@ -2,9 +2,12 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { + ASK_BATCH_THRESHOLD, + DEFAULT_ASK_MAX_QUESTIONS, WIZARD_TOOL_NAMES, __test, ensureGitignoreCoverage, + evaluateAskCap, mergeEnvValues, parseEnvKeys, resolveEnvPath, @@ -298,4 +301,43 @@ describe('WIZARD_TOOL_NAMES', () => { it('exposes audit_add_checks so future workflows can append checks through the MCP server', () => { expect(WIZARD_TOOL_NAMES).toContain('wizard-tools:audit_add_checks'); }); + + it('exposes wizard_ask so skills can collect structured input from the user', () => { + expect(WIZARD_TOOL_NAMES).toContain('wizard-tools:wizard_ask'); + }); +}); + +describe('evaluateAskCap', () => { + const MAX = DEFAULT_ASK_MAX_QUESTIONS; + + it('allows calls under both the adjacency threshold and the max cap', () => { + for (let i = 0; i < ASK_BATCH_THRESHOLD; i++) { + expect(evaluateAskCap(i, MAX)).toEqual({ kind: 'ok' }); + } + }); + + it('returns the adjacency error once the threshold is hit', () => { + expect(evaluateAskCap(ASK_BATCH_THRESHOLD, MAX)).toEqual({ + kind: 'capped', + reason: 'adjacency', + message: expect.stringMatching(/batch/i), + }); + }); + + it('escalates to the max_questions reason once the cap is reached', () => { + expect(evaluateAskCap(MAX, MAX)).toEqual({ + kind: 'capped', + reason: 'max_questions', + message: expect.stringMatching(/cap reached/i), + }); + }); + + it('honors a custom maxQuestions override smaller than the adjacency threshold', () => { + // With maxQuestions=2 (below ASK_BATCH_THRESHOLD), the per-run cap wins. + expect(evaluateAskCap(2, 2)).toEqual({ + kind: 'capped', + reason: 'max_questions', + message: expect.any(String), + }); + }); }); diff --git a/src/lib/agent/__tests__/__snapshots__/commandments.test.ts.snap b/src/lib/agent/__tests__/__snapshots__/commandments.test.ts.snap new file mode 100644 index 00000000..54d67111 --- /dev/null +++ b/src/lib/agent/__tests__/__snapshots__/commandments.test.ts.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getWizardCommandments matches the published commandment list 1`] = ` +"Never hallucinate a PostHog project token, host, or any other secret. Always use the real values that have been configured for this project (for example via environment variables). +Never write API keys, access tokens, or other secrets directly into source code. Always reference environment variables instead, and rely on the wizard-tools MCP server (check_env_keys / set_env_values) to create or update .env files. +Always use the detect_package_manager tool from the wizard-tools MCP server to determine the package manager. Do not guess based on lockfiles or hard-code npm, yarn, pnpm, bun, pip, etc. +When installing packages, start the installation as a background task and then continue with other work. Do not block waiting for installs to finish unless explicitly instructed. +Before writing to any file, you MUST read that exact file immediately beforehand using the Read tool, even if you have already read it earlier in the run. This avoids tool failures and stale edits. +Treat feature flags, custom properties, and event names as part of an analytics contract. Prefer reusing existing names and patterns in the project. When you must introduce new ones, make them clear, descriptive, and consistent with existing conventions, and avoid scattering the same flag or property across many unrelated callsites. +Prefer minimal, targeted edits that achieve the requested behavior while preserving existing structure and style. Avoid large refactors, broad reformatting, or unrelated changes unless explicitly requested. +Do not spawn subagents unless explicitly instructed to do so. +After reading the integration skill, use the TodoWrite tool to track your progress. Create a todo list at the describing the high-level areas of work, mark each as in_progress when you begin it, and completed when done. Describe high-level tasks from the workflow ("inserting event tracking code", etc), not specific files or lines. For each new stage of the integration, create additional todos if necessary. Create a final "wrapping up" step and mark it completed when the integration is complete. +Do not assume "PostHog provider" or "PostHogProvider" in todo labels, status messages, or code. Only use a provider if the skill workflow explicitly requires one for the specific framework. +If you see a [YARA WARNING] or [YARA VIOLATION] message, handle it silently. Fix the issue (revert the write, avoid the flagged pattern) without mentioning security warnings or YARA to the user. These are internal guardrails, not user-facing alerts. Never show scary security language to the user. +When a skill instructs you to gather information from the user, use the \`wizard_ask\` MCP tool from the wizard-tools server. Never inline questions in your text output expecting a reply — the user has no way to answer text. +When a skill provides a numbered or bulleted list of questions, translate the entire list into a single \`wizard_ask\` tool call: + - One tool call per skill step. Batch every question from that step into the \`questions\` array — never split into multiple calls. + - Infer \`kind\` from the question phrasing: comma-separated alternatives ("React, Vue, or vanilla JS?") → \`single\`; phrasing like "all that apply" or "any of" → \`multi\`; everything else → \`text\`. + - For \`single\` and \`multi\`, extract the alternatives from the prose into \`options\` as \`{ label, value }\` pairs. Use the human phrase as \`label\` and a lowercase-hyphenated form as \`value\` (e.g., \`label: "Vanilla JS"\`, \`value: "vanilla-js"\`). + - Use a kebab-case slug of the question label as \`id\` (e.g., "Tech stack" → \`tech-stack\`, "Show frequency" → \`show-frequency\`). + - Do not invent fields the schema does not define (no \`source\`, \`category\`, \`priority\`, etc.) — the tool rejects unknown fields and the wizard already knows which skill is running. +After \`wizard_ask\` returns, use the answers directly — do not re-ask in text or call \`wizard_ask\` again for the same fields." +`; diff --git a/src/lib/agent/__tests__/commandments.test.ts b/src/lib/agent/__tests__/commandments.test.ts new file mode 100644 index 00000000..d38961cc --- /dev/null +++ b/src/lib/agent/__tests__/commandments.test.ts @@ -0,0 +1,46 @@ +import { getWizardCommandments } from '../commandments'; + +describe('getWizardCommandments', () => { + // The commandment text is load-bearing — the agent reads these rules as + // part of its system prompt and they steer every workflow's behavior. + // Snapshotting makes any edit visible in the PR diff so the change can + // be reviewed alongside the behavior it affects. + it('matches the published commandment list', () => { + expect(getWizardCommandments()).toMatchSnapshot(); + }); + + // Targeted assertions for the wizard_ask Path A translation rules. + // These are the rules a skill author depends on when leaving their prose + // unchanged — they need to keep working as the commandment list evolves. + describe('wizard_ask Path A rules', () => { + const text = getWizardCommandments(); + + it('names the tool explicitly', () => { + expect(text).toMatch(/`wizard_ask`/); + }); + + it('forbids inlining questions in text output', () => { + expect(text).toMatch(/never inline questions/i); + }); + + it('requires batching prose lists into one call', () => { + expect(text).toMatch(/single `wizard_ask` tool call/i); + expect(text).toMatch(/never split/i); + }); + + it('describes how to infer `kind`', () => { + expect(text).toMatch(/`single`/); + expect(text).toMatch(/`multi`/); + expect(text).toMatch(/`text`/); + }); + + it('describes how to derive options and ids', () => { + expect(text).toMatch(/kebab-case/i); + expect(text).toMatch(/label.*value/i); + }); + + it('tells the agent to use answers directly without re-asking', () => { + expect(text).toMatch(/do not re-ask/i); + }); + }); +}); diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index ccabb5fd..627f173e 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -296,6 +296,15 @@ export type AgentConfig = { wizardMetadata?: Record; /** Workflow identifier — selects the model for that workflow. */ integrationLabel?: string; + /** Bridge that drives the `wizard_ask` overlay. Omit in non-interactive hosts. */ + askBridge?: import('../wizard-ask-bridge').WizardAskBridge; + /** Per-run cap on `wizard_ask` invocations. Defaults to 10. */ + askMaxQuestions?: number; + /** + * Read accessor for the active pending question. Used by canUseTool to + * block Write/Edit while the overlay is open (defense in depth). + */ + getPendingQuestion?: () => import('../wizard-session').PendingQuestion | null; }; /** @@ -372,6 +381,11 @@ type AgentRunConfig = { model: string; wizardFlags?: Record; wizardMetadata?: Record; + /** + * Read accessor for the active pending question. canUseTool reads this + * to block Write/Edit while the overlay is open. + */ + getPendingQuestion?: () => import('../wizard-session').PendingQuestion | null; }; /** @@ -497,13 +511,31 @@ function matchesAllowedPrefix(command: string): boolean { * - Build/typecheck/lint commands for verification * - Piping to tail/head for output limiting is allowed * - Stderr redirection (2>&1) is allowed + * + * `wizardAskPending` is true while a wizard_ask overlay is open — when set, + * Write/Edit calls are denied as a defense-in-depth measure against a + * misbehaving agent that races to mutate files before the question is + * answered. The SDK's tool-result protocol already pauses the agent here; + * this guard is a belt-and-suspenders second line. */ export function wizardCanUseTool( toolName: string, input: Record, + context: { wizardAskPending?: boolean } = {}, ): | { behavior: 'allow'; updatedInput: Record } | { behavior: 'deny'; message: string } { + if ( + context.wizardAskPending && + (toolName === 'Write' || toolName === 'Edit') + ) { + logToFile(`Denying ${toolName} while wizard_ask overlay is open`); + return { + behavior: 'deny', + message: `${toolName} is paused while a wizard_ask question is open. Wait for the user's answer to come back as a tool result before writing files.`, + }; + } + // Block direct reads/writes of .env files — use wizard-tools MCP instead if (toolName === 'Read' || toolName === 'Write' || toolName === 'Edit') { const filePath = typeof input.file_path === 'string' ? input.file_path : ''; @@ -683,6 +715,8 @@ export async function initializeAgent( workingDirectory: config.workingDirectory, detectPackageManager: config.detectPackageManager, skillsBaseUrl: config.skillsBaseUrl, + askBridge: config.askBridge, + askMaxQuestions: config.askMaxQuestions, }); mcpServers['wizard-tools'] = wizardToolsServer; @@ -701,6 +735,7 @@ export async function initializeAgent( model, wizardFlags: config.wizardFlags, wizardMetadata: config.wizardMetadata, + getPendingQuestion: config.getPendingQuestion, }; logToFile('Agent config:', { @@ -954,6 +989,7 @@ export async function runAgent( const result = wizardCanUseTool( toolName, input as Record, + { wizardAskPending: agentConfig.getPendingQuestion?.() != null }, ); logToFile('canUseTool result:', result); return Promise.resolve(result); diff --git a/src/lib/agent/agent-runner.ts b/src/lib/agent/agent-runner.ts index d55e4c98..c3c8eaf6 100644 --- a/src/lib/agent/agent-runner.ts +++ b/src/lib/agent/agent-runner.ts @@ -54,6 +54,7 @@ import type { PackageManagerDetector } from '../detection/package-manager'; import { getSkillsBaseUrl } from '../constants'; import { runtimeEnv } from '@env'; import { installSkillById, type InstallSkillResult } from '../wizard-tools'; +import { createWizardAskBridge } from '../wizard-ask-bridge'; import type { WizardOptions } from '../../utils/types'; import type { WorkflowConfig } from '../workflows/workflow-step'; @@ -111,10 +112,33 @@ export interface WorkflowRun { credentials: Credentials, cloudRegion: import('../../utils/types').CloudRegion | undefined, ) => WizardSession['outroData']; + /** + * Disable the `wizard_ask` tool for this workflow. The tool stays registered + * but every call returns an error telling the agent to proceed without + * input. Use for workflows that should never need user interaction. + */ + disableAsk?: boolean; + /** + * Per-run cap on `wizard_ask` invocations. Defaults to 10. The 4th call + * always returns a "batch your questions" error regardless of the cap. + */ + maxQuestions?: number; } // ── Helpers ────────────────────────────────────────────────────────── +/** + * Decide whether the `wizard_ask` overlay should be wired for this run. + * Disabled in non-interactive modes (CI, signup) and whenever the workflow + * explicitly opts out. Extracted so the policy can be unit-tested directly. + */ +export function shouldDisableAsk( + session: Pick, + config: Pick, +): boolean { + return session.ci || session.signup || config.disableAsk === true; +} + function sessionToOptions(session: WizardSession): WizardOptions { return { installDir: session.installDir, @@ -300,6 +324,17 @@ export async function runWorkflow( getUI().startRun(); + // wizard_ask is only available in interactive mode. CI/signup users have + // no way to answer; we omit the bridge so the tool returns an actionable + // error rather than hanging on a never-resolving prompt. + const askDisabled = shouldDisableAsk(session, config); + const askBridge = askDisabled + ? undefined + : createWizardAskBridge({ + getSource: () => session.skillId ?? config.integrationLabel, + showQuestion: (q) => getUI().requestQuestion(q), + }); + const agent = await initializeAgent( { workingDirectory: session.installDir, @@ -313,6 +348,9 @@ export async function runWorkflow( wizardFlags, wizardMetadata, integrationLabel: config.integrationLabel, + askBridge, + askMaxQuestions: config.maxQuestions, + getPendingQuestion: () => session.pendingQuestion, }, sessionToOptions(session), ); diff --git a/src/lib/agent/commandments.ts b/src/lib/agent/commandments.ts index 23d6fbd9..b4c89b5a 100644 --- a/src/lib/agent/commandments.ts +++ b/src/lib/agent/commandments.ts @@ -26,6 +26,21 @@ const WIZARD_COMMANDMENTS = [ 'Do not assume "PostHog provider" or "PostHogProvider" in todo labels, status messages, or code. Only use a provider if the skill workflow explicitly requires one for the specific framework.', 'If you see a [YARA WARNING] or [YARA VIOLATION] message, handle it silently. Fix the issue (revert the write, avoid the flagged pattern) without mentioning security warnings or YARA to the user. These are internal guardrails, not user-facing alerts. Never show scary security language to the user.', + + // wizard_ask Path A — translate prose question lists into a single tool call. + // The skill prose is intentionally underspecified; this commandment carries + // most of the discipline. Tightening: prefer adding rules here over editing + // individual skill markdown. + [ + 'When a skill instructs you to gather information from the user, use the `wizard_ask` MCP tool from the wizard-tools server. Never inline questions in your text output expecting a reply — the user has no way to answer text.', + 'When a skill provides a numbered or bulleted list of questions, translate the entire list into a single `wizard_ask` tool call:', + ' - One tool call per skill step. Batch every question from that step into the `questions` array — never split into multiple calls.', + ' - Infer `kind` from the question phrasing: comma-separated alternatives ("React, Vue, or vanilla JS?") → `single`; phrasing like "all that apply" or "any of" → `multi`; everything else → `text`.', + ' - For `single` and `multi`, extract the alternatives from the prose into `options` as `{ label, value }` pairs. Use the human phrase as `label` and a lowercase-hyphenated form as `value` (e.g., `label: "Vanilla JS"`, `value: "vanilla-js"`).', + ' - Use a kebab-case slug of the question label as `id` (e.g., "Tech stack" → `tech-stack`, "Show frequency" → `show-frequency`).', + ' - Do not invent fields the schema does not define (no `source`, `category`, `priority`, etc.) — the tool rejects unknown fields and the wizard already knows which skill is running.', + 'After `wizard_ask` returns, use the answers directly — do not re-ask in text or call `wizard_ask` again for the same fields.', + ].join('\n'), ].join('\n'); export function getWizardCommandments(): string { diff --git a/src/lib/wizard-ask-bridge.ts b/src/lib/wizard-ask-bridge.ts new file mode 100644 index 00000000..c2c6d929 --- /dev/null +++ b/src/lib/wizard-ask-bridge.ts @@ -0,0 +1,122 @@ +/** + * WizardAskBridge — host-side promise broker for the `wizard_ask` MCP tool. + * + * The `wizard_ask` tool needs to (a) read information from the wizard + * session (the active skill id, used as the analytics `source`) and + * (b) drive the TUI overlay. Wiring `wizard-tools.ts` directly to either + * would couple our pure-data MCP server to the runtime UI layer. + * + * The bridge is the seam: `wizard-tools.ts` depends on this interface, + * and `agent-runner.ts` constructs an implementation that knows about + * both the session and `getUI()`. + */ +import { randomUUID } from 'crypto'; + +import { analytics } from '../utils/analytics'; +import type { + AskAnswers, + AskQuestion, + PendingQuestion, +} from './wizard-session'; + +export interface WizardAskRequest { + questions: AskQuestion[]; +} + +export interface WizardAskBridge { + /** + * Open the WizardAsk overlay and resolve with the user's answers. + * One answer per question id (string for `single`/`text`, string[] for + * `multi`). Cancelled fields come back as the literal `"__cancelled__"`. + */ + request(req: WizardAskRequest): Promise; +} + +export interface WizardAskBridgeOptions { + /** Returns the active skill id, used as the analytics `source` on the request. */ + getSource: () => string; + /** Opens the overlay and resolves once the user submits or cancels. */ + showQuestion: (question: PendingQuestion) => Promise; + /** + * Per-question timeout in milliseconds. When the user takes longer than + * this to answer, every unanswered field resolves with the + * {@link CANCELLED_SENTINEL} value. Defaults to {@link DEFAULT_ASK_TIMEOUT_MS}. + */ + timeoutMs?: number; +} + +/** Sentinel returned for unanswered fields on cancellation or timeout. */ +export const CANCELLED_SENTINEL = '__cancelled__'; + +/** Default per-question timeout (5 minutes). */ +export const DEFAULT_ASK_TIMEOUT_MS = 5 * 60 * 1000; + +function buildCancelledAnswers(questions: AskQuestion[]): AskAnswers { + const out: AskAnswers = {}; + for (const q of questions) { + out[q.id] = CANCELLED_SENTINEL; + } + return out; +} + +function isFullyCancelled(answers: AskAnswers): boolean { + const values = Object.values(answers); + if (values.length === 0) return false; + return values.every((v) => v === CANCELLED_SENTINEL); +} + +export function createWizardAskBridge( + opts: WizardAskBridgeOptions, +): WizardAskBridge { + const timeoutMs = opts.timeoutMs ?? DEFAULT_ASK_TIMEOUT_MS; + + return { + async request({ questions }) { + const pending: PendingQuestion = { + id: randomUUID(), + questions, + source: opts.getSource(), + }; + + const startedAt = Date.now(); + let timer: ReturnType | undefined; + + // Race the user against the timeout. Whichever fires first wins; the + // other branch is harmless because the overlay still resolves via the + // store when the user eventually submits (and the answers are simply + // discarded). + const timeoutPromise = new Promise((resolve) => { + timer = setTimeout(() => { + resolve(buildCancelledAnswers(questions)); + }, timeoutMs); + }); + + try { + const answers = await Promise.race([ + opts.showQuestion(pending), + timeoutPromise, + ]); + const durationMs = Date.now() - startedAt; + + if (isFullyCancelled(answers)) { + analytics.wizardCapture('wizard_ask cancelled', { + source: pending.source, + question_count: questions.length, + duration_ms: durationMs, + timed_out: durationMs >= timeoutMs, + }); + } else { + analytics.wizardCapture('wizard_ask answered', { + source: pending.source, + question_count: questions.length, + duration_ms: durationMs, + }); + } + + return answers; + } finally { + if (timer) clearTimeout(timer); + } + }, + }; +} diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index 7888c121..36cc070f 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -94,6 +94,30 @@ export interface OutroData { dashboardUrl?: string; } +/** A single question rendered by the WizardAsk overlay. */ +export interface AskQuestion { + /** Key for the response map */ + id: string; + prompt: string; + /** text = single-line free input; single/multi = picker */ + kind: 'single' | 'multi' | 'text'; + /** Required for `single` and `multi`. Ignored for `text`. */ + options?: { label: string; value: string }[]; + /** Defaults to true */ + required?: boolean; +} + +/** Map of question id → answer (string for single/text, string[] for multi). */ +export type AskAnswers = Record; + +/** A pending wizard_ask request held by the store. */ +export interface PendingQuestion { + id: string; + questions: AskQuestion[]; + /** Skill id of the caller. Set by the wizard from session.skillId. */ + source: string; +} + /** * PostHog dashboard URL emitted by the agent during a workflow run. * Populated via the `[DASHBOARD_URL]` text marker in agent assistant messages @@ -183,6 +207,9 @@ export interface WizardSession { // Resolved framework config (set after integration is known) frameworkConfig: FrameworkConfig | null; + + /** Active wizard_ask request, set by the bridge when the agent calls the tool. */ + pendingQuestion: PendingQuestion | null; } /** @@ -251,5 +278,6 @@ export function buildSession(args: { workflowLabel: null, skillId: null, frameworkConfig: null, + pendingQuestion: null, }; } diff --git a/src/lib/wizard-tools.ts b/src/lib/wizard-tools.ts index 6ff1fc56..7c69abd3 100644 --- a/src/lib/wizard-tools.ts +++ b/src/lib/wizard-tools.ts @@ -14,6 +14,7 @@ import fs from 'fs'; import { execFileSync } from 'child_process'; import { z } from 'zod'; import { logToFile } from '../utils/debug'; +import { analytics } from '../utils/analytics'; import { skillTmpPath } from '../utils/paths'; import type { PackageManagerDetector } from './detection/package-manager'; import { @@ -22,6 +23,7 @@ import { type AuditCheck, type AuditStatus, } from './workflows/audit/types'; +import type { WizardAskBridge } from './wizard-ask-bridge'; // --------------------------------------------------------------------------- // SDK dynamic import (ESM module loaded once, cached) @@ -175,6 +177,61 @@ export interface WizardToolsOptions { /** Base URL for the skills server (e.g. http://localhost:8765 or GitHub releases URL) */ skillsBaseUrl: string; + + /** + * Bridge that drives the `wizard_ask` overlay. When omitted, the + * `wizard_ask` tool is still registered but returns an error explaining + * the host is non-interactive — keeps the tool surface stable across + * CI/dev environments. + */ + askBridge?: WizardAskBridge; + + /** + * Per-run cap on `wizard_ask` invocations. Defaults to {@link DEFAULT_ASK_MAX_QUESTIONS}. + * The 4th call always returns a "batch your questions" error regardless + * of this cap — see {@link ASK_BATCH_THRESHOLD}. + */ + askMaxQuestions?: number; +} + +/** Default per-run cap on wizard_ask calls when no override is provided. */ +export const DEFAULT_ASK_MAX_QUESTIONS = 10; +/** Calls past this number always return a batch-it error. */ +export const ASK_BATCH_THRESHOLD = 3; + +export type AskCapDecision = + | { kind: 'ok' } + | { + kind: 'capped'; + reason: 'max_questions' | 'adjacency'; + message: string; + }; + +/** + * Pure decision function for the wizard_ask caps. Returns whether the + * upcoming call should proceed and, if not, the error message to surface + * to the agent. Extracted so the policy can be unit-tested without + * spinning up an MCP server. + */ +export function evaluateAskCap( + callCount: number, + maxQuestions: number, +): AskCapDecision { + if (callCount >= maxQuestions) { + return { + kind: 'capped', + reason: 'max_questions', + message: `Error: wizard_ask cap reached (${maxQuestions} calls in this run). Proceed with sensible defaults using the answers you already have, or emit [ABORT] requirements-incomplete.`, + }; + } + if (callCount >= ASK_BATCH_THRESHOLD) { + return { + kind: 'capped', + reason: 'adjacency', + message: `Error: too many wizard_ask calls in a row (${callCount} so far). Batch the remaining questions into a single call — the schema accepts up to 8 questions per invocation.`, + }; + } + return { kind: 'ok' }; } // --------------------------------------------------------------------------- @@ -431,10 +488,19 @@ const SERVER_NAME = 'wizard-tools'; * Must be called asynchronously because the SDK is an ESM module loaded via dynamic import. */ export async function createWizardToolsServer(options: WizardToolsOptions) { - const { workingDirectory, detectPackageManager, skillsBaseUrl } = options; + const { + workingDirectory, + detectPackageManager, + skillsBaseUrl, + askBridge, + askMaxQuestions = DEFAULT_ASK_MAX_QUESTIONS, + } = options; const sdk = await getSDKModule(); const { tool, createSdkMcpServer } = sdk; + // Per-server counter for wizard_ask call accounting (adjacency + total cap). + let askCallCount = 0; + // Pre-fetch skill menu so category names are available in the tool schema let cachedSkillMenu: Record = {}; let categoryNames: [string, ...string[]] = ['integration']; @@ -811,6 +877,135 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { }, ); + // -- wizard_ask ----------------------------------------------------------- + + const askQuestionSchema = z.object({ + id: z + .string() + .min(1) + .describe('Stable key for the answer in the response map'), + prompt: z.string().min(1).describe('Question text shown to the user'), + kind: z + .enum(['single', 'multi', 'text']) + .describe( + "'single' = pick one option, 'multi' = pick any, 'text' = free-form single-line answer", + ), + options: z + .array(z.object({ label: z.string(), value: z.string() })) + .optional() + .describe('Required for kind=single|multi; ignored for kind=text'), + required: z.boolean().optional().describe('Defaults to true'), + }); + + const wizardAsk = tool( + 'wizard_ask', + 'Ask the user one or more structured questions and wait for their answers. ' + + 'Use this whenever you would otherwise inline a question in your text output. ' + + 'Batch related questions into a single call — do not call this multiple times in a row.', + { + questions: z.array(askQuestionSchema).min(1).max(8), + }, + async (args: { + questions: Array<{ + id: string; + prompt: string; + kind: 'single' | 'multi' | 'text'; + options?: { label: string; value: string }[]; + required?: boolean; + }>; + }) => { + if (!askBridge) { + return { + content: [ + { + type: 'text' as const, + text: 'Error: wizard_ask is not available in this environment (CI / non-interactive). Proceed with sensible defaults or emit [ABORT] requirements-incomplete.', + }, + ], + isError: true, + }; + } + + const capDecision = evaluateAskCap(askCallCount, askMaxQuestions); + if (capDecision.kind === 'capped') { + analytics.wizardCapture('wizard_ask capped', { + reason: capDecision.reason, + call_count: askCallCount, + max_questions: askMaxQuestions, + }); + return { + content: [{ type: 'text' as const, text: capDecision.message }], + isError: true, + }; + } + + // Validate that single/multi questions include options. The schema + // alone can't enforce a per-kind requirement. + for (const q of args.questions) { + if ( + (q.kind === 'single' || q.kind === 'multi') && + (!q.options || q.options.length === 0) + ) { + return { + content: [ + { + type: 'text' as const, + text: `Error: question "${q.id}" has kind="${q.kind}" but no options. Provide at least one { label, value } option, or change kind to "text".`, + }, + ], + isError: true, + }; + } + } + + const ids = new Set(); + for (const q of args.questions) { + if (ids.has(q.id)) { + return { + content: [ + { + type: 'text' as const, + text: `Error: duplicate question id "${q.id}". Each question must have a unique id.`, + }, + ], + isError: true, + }; + } + ids.add(q.id); + } + + askCallCount += 1; + + try { + const answers = await askBridge.request({ questions: args.questions }); + logToFile( + `wizard_ask: resolved ${Object.keys(answers).length} answer(s) for ${ + args.questions.length + } question(s)`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ answers }, null, 2), + }, + ], + }; + } catch (err: any) { + logToFile(`wizard_ask: error: ${err?.message ?? err}`); + return { + content: [ + { + type: 'text' as const, + text: `Error: wizard_ask failed: ${err?.message ?? String(err)}`, + }, + ], + isError: true, + }; + } + }, + ); + // -- Assemble server ------------------------------------------------------ return createSdkMcpServer({ @@ -825,6 +1020,7 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { auditSeedChecks, auditAddChecks, auditResolveChecks, + wizardAsk, ], }); } @@ -839,6 +1035,7 @@ export const WIZARD_TOOL_NAMES = [ `${SERVER_NAME}:audit_seed_checks`, `${SERVER_NAME}:audit_add_checks`, `${SERVER_NAME}:audit_resolve_checks`, + `${SERVER_NAME}:wizard_ask`, ]; // --------------------------------------------------------------------------- diff --git a/src/ui/logging-ui.ts b/src/ui/logging-ui.ts index 488e7a37..55fcbdd9 100644 --- a/src/ui/logging-ui.ts +++ b/src/ui/logging-ui.ts @@ -16,7 +16,11 @@ import { getBlockingServiceKeys, SERVICE_LABELS, } from '../lib/health-checks/readiness.js'; -import type { OutroData } from '../lib/wizard-session'; +import type { + AskAnswers, + OutroData, + PendingQuestion, +} from '../lib/wizard-session'; export class LoggingUI implements WizardUI { intro(message: string): void { @@ -143,6 +147,15 @@ export class LoggingUI implements WizardUI { return Promise.resolve(); } + requestQuestion(_question: PendingQuestion): Promise { + return Promise.reject( + new Error( + 'wizard_ask is not available in CI / non-interactive mode. ' + + 'Re-run the wizard without --ci to answer interactively.', + ), + ); + } + showAuthError(detail?: AuthErrorDetail): void { console.log(`✖ Authentication failed (401)`); if (detail?.hasSettingsConflict) { diff --git a/src/ui/tui/__tests__/store.test.ts b/src/ui/tui/__tests__/store.test.ts index 9e262131..204ba2c7 100644 --- a/src/ui/tui/__tests__/store.test.ts +++ b/src/ui/tui/__tests__/store.test.ts @@ -497,6 +497,86 @@ describe('WizardStore', () => { }); }); + // ── wizard_ask overlay ─────────────────────────────────────────── + + describe('requestQuestion / resolvePendingQuestion', () => { + const pending = { + id: 'req-1', + source: 'creating-product-tours', + questions: [ + { id: 'goal', prompt: 'Goal?', kind: 'text' as const }, + { + id: 'audience', + prompt: 'Who?', + kind: 'single' as const, + options: [ + { label: 'All users', value: 'all' }, + { label: 'New users', value: 'new' }, + ], + }, + ], + }; + + it('requestQuestion pushes WizardAsk overlay and stores pending payload', () => { + const store = createStore(); + void store.requestQuestion(pending); + expect(store.currentScreen).toBe(Overlay.WizardAsk); + expect(store.session.pendingQuestion).toEqual(pending); + }); + + it('resolvePendingQuestion resolves the promise with the answers and pops overlay', async () => { + const store = createStore(); + const promise = store.requestQuestion(pending); + + store.resolvePendingQuestion({ goal: 'Find export', audience: 'new' }); + + await expect(promise).resolves.toEqual({ + goal: 'Find export', + audience: 'new', + }); + expect(store.session.pendingQuestion).toBeNull(); + expect(store.currentScreen).not.toBe(Overlay.WizardAsk); + }); + + it('throws when requestQuestion is called while another is pending', () => { + const store = createStore(); + void store.requestQuestion(pending); + expect(() => store.requestQuestion(pending)).toThrow( + /another wizard_ask request is pending/, + ); + }); + + it('cancelPendingQuestion resolves all fields with the cancelled sentinel', async () => { + const store = createStore(); + const promise = store.requestQuestion(pending); + + store.cancelPendingQuestion(); + + await expect(promise).resolves.toEqual({ + goal: '__cancelled__', + audience: '__cancelled__', + }); + expect(store.session.pendingQuestion).toBeNull(); + }); + + it('cancelPendingQuestion is a no-op when nothing is pending', () => { + const store = createStore(); + expect(() => store.cancelPendingQuestion()).not.toThrow(); + expect(store.session.pendingQuestion).toBeNull(); + }); + + it('fires `wizard_ask shown` analytics with source, question_count, and kinds', () => { + const store = createStore(); + void store.requestQuestion(pending); + + expect(wizardCaptureMock).toHaveBeenCalledWith('wizard_ask shown', { + source: 'creating-product-tours', + question_count: 2, + kinds: ['text', 'single'], + }); + }); + }); + // ── Agent observation state ────────────────────────────────────── describe('statusMessages', () => { diff --git a/src/ui/tui/components/LearnCard.tsx b/src/ui/tui/components/LearnCard.tsx index 7fe4a344..760b7100 100644 --- a/src/ui/tui/components/LearnCard.tsx +++ b/src/ui/tui/components/LearnCard.tsx @@ -347,7 +347,36 @@ export const LearnCard = ({ store, onComplete }: LearnCardProps) => { const peekedRef = useRef(false); const [columns, rows] = useStdoutDimensions(); - const blocks = useMemo( + // Skill-runner workflows (audit, revenue-analytics, future ones) don't need + // the full PostHog onboarding narrative — they're not running an integration. + // Show a short three-line sequence and let TipsCard take over instead. + const isSkillWorkflow = + store?.session.workflowLabel != null && + store.session.workflowLabel !== 'posthog-integration'; + const skillId = store?.session.skillId ?? 'unknown'; + + const skillBlocks = useMemo( + () => [ + { + content: 'Welcome.', + pause: 3000, + mode: TextRevealMode.Typewriter, + animationInterval: 160, + }, + { content: 'The Wizard is an agent.', pause: 4000 }, + { + pause: 60000, + content: ( + + Running the {skillId} skill... + + ), + }, + ], + [skillId], + ); + + const integrationBlocks = useMemo( () => [ { content: 'Welcome.', @@ -468,6 +497,8 @@ export const LearnCard = ({ store, onComplete }: LearnCardProps) => { [store], ); + const blocks = isSkillWorkflow ? skillBlocks : integrationBlocks; + // Dynamic status bar height: messages + border when present const hasStatus = store ? store.statusMessages.length > 0 : false; const statusBarRows = hasStatus diff --git a/src/ui/tui/ink-ui.ts b/src/ui/tui/ink-ui.ts index a0b00936..c14ace90 100644 --- a/src/ui/tui/ink-ui.ts +++ b/src/ui/tui/ink-ui.ts @@ -10,7 +10,11 @@ import type { WizardUI, SpinnerHandle, AuthErrorDetail } from '../wizard-ui.js'; import type { WizardStore } from './store.js'; import type { SettingsConflict } from '../../lib/agent/agent-interface.js'; import type { WizardReadinessResult } from '../../lib/health-checks/readiness.js'; -import type { OutroData } from '../../lib/wizard-session.js'; +import type { + AskAnswers, + OutroData, + PendingQuestion, +} from '../../lib/wizard-session.js'; import { RunPhase, OutroKind } from '../../lib/wizard-session.js'; // Strip ANSI escape codes (chalk formatting) from strings @@ -126,6 +130,10 @@ export class InkUI implements WizardUI { this.store.showAuthError(detail); } + requestQuestion(question: PendingQuestion): Promise { + return this.store.requestQuestion(question); + } + startRun(): void { this.store.setRunPhase(RunPhase.Running); } diff --git a/src/ui/tui/router.ts b/src/ui/tui/router.ts index 6cc054cc..1ea3b82b 100644 --- a/src/ui/tui/router.ts +++ b/src/ui/tui/router.ts @@ -27,6 +27,7 @@ export enum Overlay { ManagedSettings = 'managed-settings', PortConflict = 'port-conflict', AuthError = 'auth-error', + WizardAsk = 'wizard-ask', } /** Union of all screen names */ diff --git a/src/ui/tui/screen-registry.tsx b/src/ui/tui/screen-registry.tsx index 20a508a3..7bea2952 100644 --- a/src/ui/tui/screen-registry.tsx +++ b/src/ui/tui/screen-registry.tsx @@ -35,6 +35,7 @@ import { KeepSkillsScreen } from './screens/KeepSkillsScreen.js'; import { OutroScreen } from './screens/OutroScreen.js'; import { ExitScreen } from './screens/ExitScreen.js'; import { AuthErrorScreen } from './screens/AuthErrorScreen.js'; +import { WizardAskScreen } from './screens/WizardAskScreen.js'; import { createMcpInstaller } from './services/mcp-installer.js'; import type { McpInstaller } from './services/mcp-installer.js'; @@ -58,6 +59,7 @@ export function createScreens( [Overlay.ManagedSettings]: , [Overlay.PortConflict]: , [Overlay.AuthError]: , + [Overlay.WizardAsk]: , // Wizard flow [Screen.Intro]: , diff --git a/src/ui/tui/screens/WizardAskScreen.tsx b/src/ui/tui/screens/WizardAskScreen.tsx new file mode 100644 index 00000000..f688e393 --- /dev/null +++ b/src/ui/tui/screens/WizardAskScreen.tsx @@ -0,0 +1,146 @@ +/** + * WizardAskScreen — Overlay for the `wizard_ask` MCP tool. + * + * Walks the agent's question list one at a time and accumulates answers. + * When the user submits the last question, the store resolves the + * pending request and the overlay pops, returning the agent to its run. + */ + +import { Box, Text } from 'ink'; +import { TextInput } from '@inkjs/ui'; +import { useState, useSyncExternalStore } from 'react'; +import type { WizardStore } from '../store.js'; +import { ModalOverlay, PickerMenu } from '../primitives/index.js'; +import { Colors, Icons } from '../styles.js'; +import type { AskAnswers, AskQuestion } from '../../../lib/wizard-session.js'; + +interface WizardAskScreenProps { + store: WizardStore; +} + +export const WizardAskScreen = ({ store }: WizardAskScreenProps) => { + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + + const pending = store.session.pendingQuestion; + + // Index of the question currently being answered. Resets when a fresh + // pending request arrives. + const [index, setIndex] = useState(0); + const [answers, setAnswers] = useState({}); + const [lastPendingId, setLastPendingId] = useState(null); + + if (!pending) return null; + + // Reset accumulator state when the agent opens a new request mid-session. + if (pending.id !== lastPendingId) { + setLastPendingId(pending.id); + setIndex(0); + setAnswers({}); + return null; + } + + const question = pending.questions[index]; + if (!question) return null; + + const total = pending.questions.length; + const progress = total > 1 ? `Question ${index + 1} of ${total}` : null; + + const submit = (value: string | string[]) => { + const next: AskAnswers = { ...answers, [question.id]: value }; + if (index + 1 < total) { + setAnswers(next); + setIndex(index + 1); + return; + } + store.resolvePendingQuestion(next); + }; + + return ( + + {progress && ( + + {progress} + + )} + + {question.prompt} + + + {/* `key` forces React to remount the input when the question changes + so per-question internal state (typed buffer, picker focus) doesn't + bleed across questions. */} + + + + ); +}; + +interface QuestionInputProps { + question: AskQuestion; + onSubmit: (value: string | string[]) => void; +} + +const QuestionInput = ({ question, onSubmit }: QuestionInputProps) => { + switch (question.kind) { + case 'single': + return ( + + options={(question.options ?? []).map((o) => ({ + label: o.label, + value: o.value, + }))} + onSelect={(value) => { + const v = Array.isArray(value) ? value[0] : value; + onSubmit(v); + }} + /> + ); + + case 'multi': + return ( + + mode="multi" + options={(question.options ?? []).map((o) => ({ + label: o.label, + value: o.value, + }))} + onSelect={(value) => { + const v = Array.isArray(value) ? value : [value]; + onSubmit(v); + }} + /> + ); + + case 'text': + return ( + // `width="100%"` on both the column and the hint row anchors them to + // the modal's content width — without it, Ink/Yoga shrinks the column + // to fit its widest child, so the right-aligned hint walks left/right + // as the typed text changes width. + + onSubmit(value)} + /> + + + ENTER + submit + + + + ); + } +}; diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index e2da730b..8ae16c2a 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -23,6 +23,8 @@ import { type WizardSession, type OutroData, type DiscoveredFeature, + type PendingQuestion, + type AskAnswers, AdditionalFeature, McpOutcome, RunPhase, @@ -100,6 +102,10 @@ export class WizardStore { /** Blocks OAuth flow until the port-conflict overlay is dismissed. */ private _resolvePortConflict: (() => void) | null = null; + /** Resolves the in-flight wizard_ask request. */ + private _resolvePendingQuestion: ((answers: AskAnswers) => void) | null = + null; + constructor(flow: Flow = Flow.PostHogIntegration) { this.router = new WizardRouter(flow); this._initFromWorkflow(flow); @@ -365,6 +371,57 @@ export class WizardStore { this._resolvePortConflict = null; } + /** + * Open the WizardAsk overlay with a set of questions and return a promise + * that resolves once the user submits answers (or the request is cancelled). + * + * Only one request is in flight at a time — calling this while a request + * is already pending throws. + */ + requestQuestion(question: PendingQuestion): Promise { + if (this._resolvePendingQuestion) { + throw new Error( + 'requestQuestion called while another wizard_ask request is pending', + ); + } + this.$session.setKey('pendingQuestion', question); + this.pushOverlay(Overlay.WizardAsk); + analytics.wizardCapture('wizard_ask shown', { + source: question.source, + question_count: question.questions.length, + kinds: question.questions.map((q) => q.kind), + }); + return new Promise((resolve) => { + this._resolvePendingQuestion = resolve; + }); + } + + /** + * Resolve the in-flight wizard_ask request with the user's answers and + * dismiss the overlay. Answers flow back to the agent as the tool result. + */ + resolvePendingQuestion(answers: AskAnswers): void { + const resolve = this._resolvePendingQuestion; + this._resolvePendingQuestion = null; + this.$session.setKey('pendingQuestion', null); + this.popOverlay(); + resolve?.(answers); + } + + /** + * Cancel the in-flight wizard_ask request — the bridge sends a sentinel + * answer ("__cancelled__") so the skill can decide how to handle it. + */ + cancelPendingQuestion(): void { + const pending = this.session.pendingQuestion; + if (!pending) return; + const cancelled: AskAnswers = {}; + for (const q of pending.questions) { + cancelled[q.id] = '__cancelled__'; + } + this.resolvePendingQuestion(cancelled); + } + /** * Back up .claude/settings.json. Dismisses the overlay on success. */ diff --git a/src/ui/wizard-ui.ts b/src/ui/wizard-ui.ts index ef88cf40..829d448f 100644 --- a/src/ui/wizard-ui.ts +++ b/src/ui/wizard-ui.ts @@ -10,7 +10,11 @@ import type { SettingsConflict } from '../lib/agent/agent-interface'; import type { WizardReadinessResult } from '../lib/health-checks/readiness.js'; -import type { OutroData } from '../lib/wizard-session'; +import type { + AskAnswers, + OutroData, + PendingQuestion, +} from '../lib/wizard-session'; export enum TaskStatus { Pending = 'pending', @@ -110,6 +114,13 @@ export interface WizardUI { /** Show auth error overlay when Anthropic API returns 401. */ showAuthError(detail?: AuthErrorDetail): void; + /** + * Open the wizard_ask overlay and resolve with the user's answers. + * Implementations that can't ask (CI/logging) reject so the bridge can + * surface a clear "not available" error to the agent. + */ + requestQuestion(question: PendingQuestion): Promise; + // ── Display state ────────────────────────────────────────────────── /** Set the detected framework label (e.g., "Django with Wagtail CMS") */ setDetectedFramework(label: string): void;