diff --git a/Sources/CodeIsland/AppState.swift b/Sources/CodeIsland/AppState.swift index 33a8bf53..93ec0919 100644 --- a/Sources/CodeIsland/AppState.swift +++ b/Sources/CodeIsland/AppState.swift @@ -50,6 +50,9 @@ final class AppState { private(set) var recentHookEvents: [DiagnosticHookEvent] = [] @ObservationIgnored private let maxRecentHookEvents = 100 + @ObservationIgnored + var questionTerminalFrontmostDetector: (SessionSnapshot) -> Bool = + TerminalVisibilityDetector.isTerminalFrontmostForSession func recordHookEvent( source: String?, @@ -904,10 +907,18 @@ final class AppState { } private func shouldAutoOpenQuestionSurface(for event: HookEvent) -> Bool { - // AskUserQuestion holds the provider/CLI until its continuation resolves, - // so there is no parallel terminal prompt for Smart Suppress to defer to. - if event.toolName == "AskUserQuestion" { return true } - return shouldAutoOpenPendingSurface(for: event.sessionId ?? "default") + let source = SessionSnapshot.normalizedSupportedSource(event.rawJSON["_source"] as? String) + let nativeAskIsRacing = event.rawJSON["_codeisland_native_ask_racing"] as? Bool == true + // Only OMP v3 explicitly guarantees that its native ask dialog races + // CodeIsland. Pi and legacy OMP block here, so hiding their card deadlocks. + if event.toolName == "AskUserQuestion", + (source != "pi" || !nativeAskIsRacing) { + return true + } + return shouldAutoOpenPendingSurface( + for: event.sessionId ?? "default", + isTerminalFrontmost: questionTerminalFrontmostDetector + ) } private func showCompletion(_ sessionId: String) { @@ -1662,6 +1673,19 @@ final class AppState { } func answerQuestionMulti(_ answers: [(question: String, answer: String)]) { + answerQuestionMulti( + answers.map { + AskUserQuestionAnswer( + question: $0.question, + answer: $0.answer, + selectedOptions: [], + customInput: nil + ) + } + ) + } + + func answerQuestionMulti(_ answers: [AskUserQuestionAnswer]) { guard !questionQueue.isEmpty else { return } // Codex app-server questions reply over the JSON-RPC client, not a hook. if questionQueue[0].isCodexAppServer { @@ -1687,11 +1711,23 @@ final class AppState { let responseData: Data if pending.isFromPermission { var answersDict: [String: String] = [:] + var answerDetails: [String: [String: Any]] = [:] if let askState = pending.askUserQuestionState { // Match by position — wizard collects answers in the same order as items for (index, item) in askState.items.enumerated() { if index < answers.count { - answersDict[item.answerKey] = answers[index].answer + let submitted = answers[index] + answersDict[item.answerKey] = submitted.answer + var details: [String: Any] = [:] + if !submitted.selectedOptions.isEmpty { + details["selectedOptions"] = submitted.selectedOptions + } + if let customInput = submitted.customInput { + details["customInput"] = customInput + } + if !details.isEmpty { + answerDetails[item.answerKey] = details + } } } } else { @@ -1702,7 +1738,8 @@ final class AppState { event: pending.event, answers: answersDict, answer: answers.first?.answer, - originalQuestions: pending.event.toolInput?["questions"] as? [[String: Any]] + originalQuestions: pending.event.toolInput?["questions"] as? [[String: Any]], + answerDetails: answerDetails ) let obj: [String: Any] = [ "hookSpecificOutput": [ @@ -1735,7 +1772,8 @@ final class AppState { event: HookEvent, answers: [String: String], answer: String?, - originalQuestions: [[String: Any]]? + originalQuestions: [[String: Any]]?, + answerDetails: [String: [String: Any]] = [:] ) -> [String: Any] { var updatedInput = event.toolInput ?? [:] // `questions` must always be present in updatedInput. Claude Code's @@ -1748,6 +1786,11 @@ final class AppState { if let answer { updatedInput["answer"] = answer } + if !answerDetails.isEmpty, + SessionSnapshot.normalizedSupportedSource(event.rawJSON["_source"] as? String) == "pi", + event.toolUseId != nil { + updatedInput["_codeislandAnswerDetails"] = answerDetails + } return updatedInput } @@ -1842,6 +1885,12 @@ final class AppState { activeSessionId = sid if shouldAutoOpenQuestionSurface(for: next.event) { surface = .questionCard(sessionId: sid) + } else if case .questionCard = surface { + // Smart Suppress wants this card collapsed (e.g. an OMP ask + // whose terminal dialog is racing). Fold an inherited + // question-card surface so the promoted card does not render + // expanded on top of the previous question's surface. + surface = .collapsed } return true } else if !completionQueue.isEmpty { diff --git a/Sources/CodeIsland/ConfigInstaller.swift b/Sources/CodeIsland/ConfigInstaller.swift index 1470b67e..ec294a8b 100644 --- a/Sources/CodeIsland/ConfigInstaller.swift +++ b/Sources/CodeIsland/ConfigInstaller.swift @@ -2679,6 +2679,9 @@ struct ConfigInstaller { /// Current pi extension version — bump when codeisland-pi.ts changes. private static let piExtensionVersion = "v2" + /// Current omp extension version — bump when codeisland-omp.ts changes. + private static let ompExtensionVersion = "v4" + private static func piExtensionSource() -> String? { if let url = Bundle.appModule.url(forResource: "codeisland-pi", withExtension: "ts", subdirectory: "Resources"), let src = try? String(contentsOf: url) { return src } @@ -2758,7 +2761,12 @@ struct ConfigInstaller { ompExtensionPath: String = ompExtensionPath, fm: FileManager ) -> Bool { - isPiExtensionInstalled(piExtensionPath: ompExtensionPath, fm: fm) + guard fm.fileExists(atPath: ompExtensionPath), + let data = fm.contents(atPath: ompExtensionPath), + let content = String(data: data, encoding: .utf8) + else { return false } + return content.contains("CodeIsland pi extension") + && content.contains("// version: \(ompExtensionVersion)") } // MARK: - OpenClaw plugin (#235) diff --git a/Sources/CodeIsland/Models.swift b/Sources/CodeIsland/Models.swift index 986691f0..af4114f5 100644 --- a/Sources/CodeIsland/Models.swift +++ b/Sources/CodeIsland/Models.swift @@ -14,6 +14,13 @@ struct AskUserQuestionItem { let multiSelect: Bool } +struct AskUserQuestionAnswer { + let question: String + let answer: String + let selectedOptions: [String] + let customInput: String? +} + struct AskUserQuestionState { let items: [AskUserQuestionItem] var answers: [String: String] diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index 66d74d80..e16f4fe2 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -1166,6 +1166,19 @@ private struct ApprovalBar: View { // MARK: - Question Bar (below notch, auto-expanded) +func makeQuestionBarFreeTextAnswer( + question: String, + text: String +) -> AskUserQuestionAnswer? { + guard !text.isEmpty else { return nil } + return AskUserQuestionAnswer( + question: question, + answer: text, + selectedOptions: [], + customInput: text + ) +} + private struct QuestionBar: View { let question: String let options: [String]? @@ -1177,7 +1190,7 @@ private struct QuestionBar: View { let queuePosition: Int let queueTotal: Int let onAnswer: (String) -> Void - let onAnswerMulti: ([(question: String, answer: String)]) -> Void + let onAnswerMulti: ([AskUserQuestionAnswer]) -> Void let onSkip: () -> Void @State private var textInput = "" @@ -1186,7 +1199,7 @@ private struct QuestionBar: View { // Multi-question wizard state @State private var currentQuestionIndex: Int = 0 - @State private var collectedAnswers: [(question: String, answer: String)] = [] + @State private var collectedAnswers: [AskUserQuestionAnswer] = [] @State private var selectedIndices: Set = [] @State private var showOtherInput: Bool = false @State private var otherText: String = "" @@ -1291,7 +1304,7 @@ private struct QuestionBar: View { isSelected: selectedIndex == idx, accent: cyan) { selectedIndex = idx showOtherInput = false - advanceWithAnswer(option) + advanceWithAnswer(option, selectedOptions: [option]) } } } @@ -1312,7 +1325,7 @@ private struct QuestionBar: View { .focused($otherFocused) .onSubmit { if !item.multiSelect && !otherText.isEmpty { - advanceWithAnswer(otherText) + advanceWithAnswer(otherText, customInput: otherText) } } } @@ -1340,9 +1353,7 @@ private struct QuestionBar: View { .font(.system(size: 10.5)) .foregroundStyle(.white) .focused($isFocused) - .onSubmit { - if !textInput.isEmpty { advanceWithAnswer(textInput) } - } + .onSubmit(submitFreeText) } .padding(.horizontal, 10) .padding(.vertical, 6) @@ -1373,21 +1384,21 @@ private struct QuestionBar: View { border: Color.white.opacity(0.12), action: onSkip ) - if item.multiSelect { + if item.payload.options?.isEmpty != false { PixelButton( - label: L10n.shared["confirm"], + label: L10n.shared["submit"], fg: .white.opacity(0.95), bg: Color(red: 0.16, green: 0.38, blue: 0.18), border: Color(red: 0.28, green: 0.62, blue: 0.32), - action: confirmMultiSelect + action: submitFreeText ) - } else if item.payload.options == nil || item.payload.options?.isEmpty == true { + } else if item.multiSelect { PixelButton( - label: L10n.shared["submit"], + label: L10n.shared["confirm"], fg: .white.opacity(0.95), bg: Color(red: 0.16, green: 0.38, blue: 0.18), border: Color(red: 0.28, green: 0.62, blue: 0.32), - action: { if !textInput.isEmpty { advanceWithAnswer(textInput) } } + action: confirmMultiSelect ) } else if showOtherInput && !item.multiSelect { PixelButton( @@ -1395,7 +1406,7 @@ private struct QuestionBar: View { fg: .white.opacity(0.95), bg: Color(red: 0.16, green: 0.38, blue: 0.18), border: Color(red: 0.28, green: 0.62, blue: 0.32), - action: { if !otherText.isEmpty { advanceWithAnswer(otherText) } } + action: { if !otherText.isEmpty { advanceWithAnswer(otherText, customInput: otherText) } } ) } } @@ -1423,9 +1434,31 @@ private struct QuestionBar: View { // MARK: - Navigation - private func advanceWithAnswer(_ answer: String) { + private func submitFreeText() { + guard let item = currentItem, + let answer = makeQuestionBarFreeTextAnswer( + question: item.payload.question, + text: textInput + ) else { return } + advance(with: answer) + } + + private func advanceWithAnswer( + _ answer: String, + selectedOptions: [String] = [], + customInput: String? = nil + ) { guard let item = currentItem else { return } - collectedAnswers.append((question: item.payload.question, answer: answer)) + advance(with: AskUserQuestionAnswer( + question: item.payload.question, + answer: answer, + selectedOptions: selectedOptions, + customInput: customInput + )) + } + + private func advance(with answer: AskUserQuestionAnswer) { + collectedAnswers.append(answer) if currentQuestionIndex + 1 < allQuestions.count { withAnimation(NotchAnimation.micro) { @@ -1439,14 +1472,17 @@ private struct QuestionBar: View { private func confirmMultiSelect() { guard let item = currentItem, let opts = item.payload.options else { return } - var parts: [String] = selectedIndices.sorted().compactMap { idx in + let selectedOptions = selectedIndices.sorted().compactMap { idx in opts.indices.contains(idx) ? opts[idx] : nil } - if showOtherInput && !otherText.isEmpty { - parts.append(otherText) - } + let customInput = showOtherInput && !otherText.isEmpty ? otherText : nil + let parts = selectedOptions + (customInput.map { [$0] } ?? []) guard !parts.isEmpty else { return } - advanceWithAnswer(parts.joined(separator: ", ")) + advanceWithAnswer( + parts.joined(separator: ", "), + selectedOptions: selectedOptions, + customInput: customInput + ) } private func goBack() { diff --git a/Sources/CodeIsland/Resources/codeisland-omp.ts b/Sources/CodeIsland/Resources/codeisland-omp.ts index b15fa6c6..4fd7d132 100644 --- a/Sources/CodeIsland/Resources/codeisland-omp.ts +++ b/Sources/CodeIsland/Resources/codeisland-omp.ts @@ -1,5 +1,5 @@ // CodeIsland pi extension -// version: v2 +// version: v4 // OMP-compatible install /** @@ -10,11 +10,25 @@ */ import { execFile, execFileSync } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; import { existsSync } from "node:fs"; import { connect } from "node:net"; import { homedir } from "node:os"; import { getuid } from "node:process"; -import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types"; +import type { + AgentToolContext, + AgentToolResult, +} from "@oh-my-pi/pi-agent-core"; +import type { + ExtensionAPI, + ExtensionContext, + ToolDefinition, +} from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types"; +import type { + AskToolDetails, + QuestionResult, +} from "@oh-my-pi/pi-coding-agent/tools/ask"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; // ── Socket / bridge constants ───────────────────────────────────────────────── @@ -121,6 +135,12 @@ function sendToSocket(payload: object): Promise { }); } +/** Result of a cancellable bridge call: the response promise plus a cancel handle. */ +interface CancellableBridge { + promise: Promise | null>; + cancel: () => void; +} + /** * Sends a JSON payload via the bridge binary and waits for CodeIsland's response. * Used exclusively for blocking permission/question requests. @@ -133,34 +153,57 @@ function sendAndWaitResponse( payload: object, timeoutMs = 30_000, ): Promise | null> { - return new Promise((resolve) => { - if (!existsSync(BRIDGE_PATH)) { - resolve(null); - return; - } - try { - const child = execFile( - BRIDGE_PATH, - [], - { timeout: timeoutMs, maxBuffer: 1_048_576 }, - (error, stdout) => { - if (error) { - resolve(null); - return; - } - try { - resolve(JSON.parse(stdout)); - } catch { - resolve(null); - } - }, - ); - child.stdin!.write(JSON.stringify(payload)); - child.stdin!.end(); - } catch { - resolve(null); + return sendAndWaitResponseCancellable(payload, timeoutMs).promise; +} + +/** + * Same as {@link sendAndWaitResponse} but exposes a `cancel()` that SIGKILLs + * the bridge child process so the caller can abort a pending request when + * the answer arrives from another source (e.g. the TUI dialog). + */ +function sendAndWaitResponseCancellable( + payload: object, + timeoutMs = 30_000, +): CancellableBridge { + const { promise, resolve } = Promise.withResolvers | null>(); + + if (!existsSync(BRIDGE_PATH)) { + resolve(null); + return { promise, cancel: () => {} }; + } + + let child: ChildProcess | undefined; + try { + child = execFile( + BRIDGE_PATH, + [], + { timeout: timeoutMs, maxBuffer: 1_048_576 }, + (error, stdout) => { + if (error) { + resolve(null); + return; + } + try { + resolve(JSON.parse(stdout)); + } catch { + resolve(null); + } + }, + ); + child.stdin!.write(JSON.stringify(payload)); + child.stdin!.end(); + } catch { + resolve(null); + } + + const cancel = () => { + if (child && child.pid) { + try { child.kill("SIGKILL"); } catch { /* already dead */ } } - }); + resolve(null); + }; + + return { promise, cancel }; } // ── Event builders ──────────────────────────────────────────────────────────── @@ -220,6 +263,11 @@ function extractLastAssistantText( // ── Extension ───────────────────────────────────────────────────────────────── export default function codeislandExtension(pi: ExtensionAPI) { + const askToolRenderer = pi.pi.askToolRenderer; + + class ToolAbortError extends Error { + override name = "ToolAbortError"; + } /** TTY path detected once at startup. */ const tty = detectTty(); @@ -247,106 +295,411 @@ export default function codeislandExtension(pi: ExtensionAPI) { } + // ── Shadow "ask" tool (#244 v3: native rendering + parallel answering) ───── + // + // Registers a custom "ask" that races CodeIsland against OMP's own AskTool. + // Reusing AskTool keeps terminal rendering, navigation, timeout, speech, and + // future OMP behavior in one implementation. The first real answer wins. + + interface RawQuestion { + id: string; + question: string; + header?: string; + options: { label: string; description?: string; preview?: string }[]; + multi?: boolean; + recommended?: number; + } + type CompatibleQuestionResult = QuestionResult & { note?: string }; + type CompatibleAskToolDetails = AskToolDetails & { + note?: string; + chatRedirect?: boolean; + questions?: string[]; + results?: CompatibleQuestionResult[]; + }; + + function isPlanModeEnabled(ctx: ExtensionContext): boolean { + const entries = ctx.sessionManager.getEntries(); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry?.type === "mode_change") return entry.mode === "plan"; + } + return false; + } + + function createNativeAskTool(ctx?: ExtensionContext) { + const session: ToolSession = { + cwd: ctx?.cwd ?? process.cwd(), + hasUI: ctx?.hasUI ?? true, + getSessionFile: () => ctx?.sessionManager.getSessionFile() ?? null, + getSessionSpawns: () => null, + settings: pi.pi.settings, + getPlanModeState: () => ({ + enabled: ctx ? isPlanModeEnabled(ctx) : false, + planFilePath: "local://PLAN.md", + }), + }; + return new pi.pi.AskTool(session); + } + + function createNativeAskContext( + ctx: ExtensionContext, + onAbort: () => void, + ): AgentToolContext { + return { + sessionManager: ctx.sessionManager, + modelRegistry: ctx.modelRegistry, + model: ctx.model, + isIdle: () => ctx.isIdle(), + hasQueuedMessages: () => ctx.hasPendingMessages(), + abort: onAbort, + settings: pi.pi.settings, + ui: ctx.ui, + hasUI: ctx.hasUI, + }; + } + /** - * Forwards an `ask` tool call to CodeIsland as an AskUserQuestion and waits - * for the user's on-island answer (#244). - * - * @returns A block result carrying the answers when the user answered in - * CodeIsland, or `null` when the question should fall through to - * OMP's own TUI dialog (skipped, denied, or CodeIsland not running). + * CodeIsland deduplicates repeated question text with `_2`, `_3`… suffixes. + * Reproduce that keying so we can translate answers back to OMP question ids. */ - async function forwardAskToCodeIsland( - event: { input: Record; toolCallId: string }, - ctx: { cwd: string }, - sessionId: string, - sid: string, - tty: string | null, - ): Promise<{ block: true; reason: string } | null> { - const rawQuestions = Array.isArray(event.input.questions) - ? (event.input.questions as Array>) - : []; - if (rawQuestions.length === 0) return null; - - // Map OMP's ask schema → Claude-style AskUserQuestion input. - const questions = rawQuestions.map((q) => { - const options = Array.isArray(q.options) - ? (q.options as Array>) - .map((o) => ({ - label: typeof o.label === "string" ? o.label : "", - ...(typeof o.description === "string" - ? { description: o.description } - : {}), - })) - .filter((o) => o.label.length > 0) - : []; - return { - question: typeof q.question === "string" ? q.question : "Question", - ...(typeof q.id === "string" && q.id ? { header: q.id } : {}), - multiSelect: q.multi === true, - options, - }; - }); - - // CodeIsland keys answers by question text, deduping repeats with `_2`, - // `_3`… suffixes — reproduce that here so we can translate back to ids. - const usedKeys = new Set(); - const answerKeys = questions.map(({ question }) => { + function computeAnswerKeys(questions: { question: string }[]): string[] { + const used: Record = {}; + return questions.map(({ question }) => { let key = question; - if (usedKeys.has(key)) { + if (used[key]) { let suffix = 2; - while (usedKeys.has(`${question}_${suffix}`)) suffix += 1; + while (used[`${question}_${suffix}`]) suffix += 1; key = `${question}_${suffix}`; } - usedKeys.add(key); + used[key] = true; return key; }); + } - pendingPermissionSessions.add(sid); - let response: Record | null = null; - try { - response = await sendAndWaitResponse( + /** Converts CodeIsland's answer map into typed question results. */ + function islandAnswersToResults( + answers: Record, + answerDetails: Record, + answerKeys: string[], + questions: RawQuestion[], + ): CompatibleQuestionResult[] { + return questions.map((q, i) => { + const answerKey = answerKeys[i]; + const value = answers[answerKey]; + const optionLabels = q.options.map((o) => o.label); + const rawDetails = answerDetails[answerKey]; + const details = rawDetails && typeof rawDetails === "object" + ? rawDetails as Record + : undefined; + const detailedSelected = Array.isArray(details?.selectedOptions) + ? details.selectedOptions.map(String) + : undefined; + const detailedCustomInput = typeof details?.customInput === "string" + ? details.customInput + : undefined; + const hasStructuredDetails = detailedSelected !== undefined + || detailedCustomInput !== undefined; + + let selectedOptions: string[] = []; + let customInput: string | undefined; + if (hasStructuredDetails) { + selectedOptions = detailedSelected ?? []; + customInput = detailedCustomInput; + } else if (Array.isArray(value)) { + const values = value.map(String); + selectedOptions = values.filter((candidate) => optionLabels.includes(candidate)); + const customValues = values.filter((candidate) => !optionLabels.includes(candidate)); + if (customValues.length > 0) customInput = customValues.join("\n"); + } else if (typeof value === "string") { + if (optionLabels.includes(value) || q.multi) { + // Legacy CodeIsland versions flatten multi-select values into one string. + // Keep that value intact rather than guessing at comma boundaries. + selectedOptions = [value]; + } else { + customInput = value; + } + } + + return { + id: q.id, + question: q.question, + options: optionLabels, + multi: q.multi ?? false, + selectedOptions, + ...(customInput !== undefined ? { customInput } : {}), + }; + }); + } + + /** Mirrors built-in AskTool.formatQuestionResult for CodeIsland answers. */ + function formatQuestionResult(result: CompatibleQuestionResult): string { + const noteSuffix = result.note ? ` (note: ${result.note})` : ""; + if (result.customInput !== undefined) { + return `${result.id}: "${result.customInput}"${noteSuffix}`; + } + if (result.selectedOptions.length > 0) { + const suffix = `${result.timedOut ? " (auto-selected after timeout)" : ""}${noteSuffix}`; + return result.multi + ? `${result.id}: [${result.selectedOptions.join(", ")}]${suffix}` + : `${result.id}: ${result.selectedOptions[0]}${suffix}`; + } + return `${result.id}: (cancelled)${noteSuffix}`; + } + + /** Mirrors built-in AskTool.formatSingleQuestionResponse for CodeIsland answers. */ + function formatSingleQuestionResponse(result: CompatibleQuestionResult): string { + const parts: string[] = []; + if (result.selectedOptions.length > 0) { + const selectedText = result.multi + ? `User selected: ${result.selectedOptions.join(", ")}` + : `User selected: ${result.selectedOptions[0]}`; + parts.push(result.timedOut ? `${selectedText} (auto-selected after timeout)` : selectedText); + } + if (result.customInput !== undefined) { + parts.push( + result.customInput.includes("\n") + ? `User provided custom input:\n${result.customInput.split("\n").map((l: string) => ` ${l}`).join("\n")}` + : `User provided custom input: ${result.customInput}`, + ); + } + if (result.note) { + parts.push( + result.note.includes("\n") + ? `User added note:\n${result.note.split("\n").map((l: string) => ` ${l}`).join("\n")}` + : `User added note: ${result.note}`, + ); + } + return parts.length > 0 ? parts.join("\n") : "User cancelled the selection"; + } + + /** Builds an AskTool-compatible result for answers returned by CodeIsland. */ + function buildAskResult( + results: CompatibleQuestionResult[], + ): AgentToolResult { + if (results.length === 1) { + const r = results[0]; + return { + content: [{ type: "text", text: formatSingleQuestionResponse(r) }], + details: { + question: r.question, + options: r.options, + multi: r.multi, + selectedOptions: r.selectedOptions, + ...(r.customInput !== undefined ? { customInput: r.customInput } : {}), + ...(r.note !== undefined ? { note: r.note } : {}), + ...(r.timedOut ? { timedOut: true } : {}), + }, + }; + } + return { + content: [{ type: "text", text: `User answers:\n${results.map(formatQuestionResult).join("\n")}` }], + details: { results }, + }; + } + + /** Gate outcome: a winning source, a genuine failure, or user cancellation. */ + type GateOutcome = + | { source: "island" | "tui"; result: AgentToolResult } + | { source: "error"; error: unknown } + | { source: "cancel" }; + + const reservedAskOptionLabels: Record = { + "Other (type your own)": true, + "Chat about this": true, + "Next →": true, + }; + + // Keep the v3 input additions for OMP 16.3.x, whose native Ask schema does + // not yet expose header/preview even though its executor accepts the fields. + const askOptionParameters = pi.zod.object({ + label: pi.zod.string(), + description: pi.zod.string().optional(), + preview: pi.zod.string().optional(), + }).refine( + (option) => reservedAskOptionLabels[option.label] !== true, + { message: "Option label collides with a reserved Ask UI action" }, + ); + const askParameters = pi.zod.object({ + questions: pi.zod.array( + pi.zod.object({ + id: pi.zod.string(), + question: pi.zod.string(), + header: pi.zod.string().optional(), + options: pi.zod.array(askOptionParameters), + multi: pi.zod.boolean().optional(), + recommended: pi.zod.number().optional(), + }), + ).min(1), + }); + + // Reuse the native AskTool's LLM-facing label and description so the shadow + // tool preserves OMP's behavioral guidance (default action, check existing + // info first, only ask on major tradeoffs, never hand-write "Other", etc.). + const nativeAskMetadata = createNativeAskTool(); + + const askToolDefinition: ToolDefinition< + typeof askParameters, + CompatibleAskToolDetails + > & { + concurrency: "exclusive"; + mergeCallAndResult: boolean; + strict: true; + approval: "read"; + } = { + name: "ask", + label: nativeAskMetadata.label, + description: nativeAskMetadata.description, + parameters: askParameters, + strict: true, + approval: "read", + concurrency: "exclusive", + mergeCallAndResult: askToolRenderer.mergeCallAndResult, + renderCall: askToolRenderer.renderCall, + renderResult: askToolRenderer.renderResult, + async execute(toolCallId, params, signal, onUpdate, ctx) { + const sessionId = ctx.sessionManager.getSessionId(); + const sid = `pi-${sessionId}`; + const questions = params.questions; + + if (!ctx.hasUI) { + ctx.abort(); + throw new ToolAbortError("Ask tool requires interactive mode"); + } + + const islandQuestions = questions.map((question) => ({ + question: question.question, + ...(question.header ? { header: question.header } : {}), + multiSelect: question.multi ?? false, + options: question.options.map((option) => ({ + label: option.label, + ...(option.description ? { description: option.description } : {}), + })), + })); + const answerKeys = computeAnswerKeys(questions); + + const tuiAbort = new AbortController(); + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + + const islandBridge = sendAndWaitResponseCancellable( base(sessionId, ctx.cwd, { hook_event_name: "PermissionRequest", tool_name: "AskUserQuestion", - tool_input: { questions }, - _pi_tool_call_id: event.toolCallId, + tool_input: { questions: islandQuestions }, + _pi_tool_call_id: toolCallId, + _codeisland_native_ask_racing: true, }, tty), - 86_400_000, // waiting on a human — same 24h budget as PermissionRequest hooks + 86_400_000, ); - } finally { - pendingPermissionSessions.delete(sid); - } - const decision = ( - response?.hookSpecificOutput as Record | undefined - )?.decision as Record | undefined; - if (decision?.behavior !== "allow") return null; - - const updatedInput = decision.updatedInput as - | Record - | undefined; - const answers = (updatedInput?.answers ?? {}) as Record; - - const lines = rawQuestions.map((q, i) => { - const id = typeof q.id === "string" && q.id ? q.id : `q${i + 1}`; - const value = answers[answerKeys[i]]; - const text = Array.isArray(value) - ? value.map(String).join(", ") - : typeof value === "string" - ? value - : ""; - return `${id}: ${text || "(no answer)"}`; - }); + const handleExternalAbort = () => { + islandBridge.cancel(); + tuiAbort.abort(); + settle({ source: "cancel" }); + }; + if (signal?.aborted) { + islandBridge.cancel(); + ctx.abort(); + throw new ToolAbortError("Ask tool was cancelled by the user"); + } + signal?.addEventListener("abort", handleExternalAbort, { once: true }); - return { - block: true, - reason: - "The user already answered these questions through the CodeIsland desktop app. " + - "Their answers:\n" + - lines.join("\n") + - "\nDo not ask again — proceed using these answers.", - }; - } + pendingPermissionSessions.add(sid); + + const islandPromise = islandBridge.promise.then( + (response): CompatibleQuestionResult[] | null => { + const decision = ( + response?.hookSpecificOutput as Record | undefined + )?.decision as Record | undefined; + if (decision?.behavior !== "allow") return null; + const updatedInput = decision.updatedInput as + | Record + | undefined; + const answers = (updatedInput?.answers ?? {}) as Record; + const answerDetails = ( + updatedInput?._codeislandAnswerDetails ?? {} + ) as Record; + return islandAnswersToResults( + answers, + answerDetails, + answerKeys, + questions, + ); + }, + ); + + const nativeAsk = createNativeAskTool(ctx); + const nativeContext = createNativeAskContext(ctx, () => undefined); + const tuiPromise = nativeAsk.execute( + toolCallId, + params, + tuiAbort.signal, + onUpdate, + nativeContext, + ).catch((error: unknown): AgentToolResult | null => { + if ( + tuiAbort.signal.aborted + || (error instanceof Error && error.name === "ToolAbortError") + ) { + return null; + } + throw error; + }); + + // Any side finishing (answer, skip, cancel, or failure) immediately + // cancels the other and settles. settle() is a no-op after the first + // call (Promise resolver semantics), so concurrent completions are safe. + islandPromise.then( + (results) => { + if (results && results.length > 0) { + tuiAbort.abort(); + settle({ source: "island", result: buildAskResult(results) }); + } else { + // Island skipped/denied — cancel the TUI dialog immediately. + tuiAbort.abort(); + settle({ source: "cancel" }); + } + }, + (error: unknown) => { + tuiAbort.abort(); + settle({ source: "error", error }); + }, + ); + + tuiPromise.then( + (result) => { + if (result) { + islandBridge.cancel(); + settle({ source: "tui", result }); + } else { + // TUI cancelled — cancel the island bridge immediately so we do + // not wait 24h for a card the user may not be able to reach + // (e.g. Smart Suppress collapsed it). + islandBridge.cancel(); + settle({ source: "cancel" }); + } + }, + (error: unknown) => { + islandBridge.cancel(); + settle({ source: "error", error }); + }, + ); + + try { + const winner = await settled; + if (winner.source === "error") throw winner.error; + if (winner.source === "cancel") { + ctx.abort(); + throw new ToolAbortError("Ask tool was cancelled by the user"); + } + return winner.result; + } finally { + signal?.removeEventListener("abort", handleExternalAbort); + pendingPermissionSessions.delete(sid); + } + }, + }; + pi.registerTool(askToolDefinition); // ── Session lifecycle ────────────────────────────────────────────────────── @@ -419,18 +772,6 @@ export default function codeislandExtension(pi: ExtensionAPI) { if (path) toolInput.file_path = path; } - // `ask` tool → mirror the question into CodeIsland's question UI (#244). - // tool_call fires BEFORE the TUI dialog opens and OMP awaits this handler, - // so we can hold the tool, let the user answer on the island (or watch/ - // phone), and feed the answers back by blocking the tool with a result - // message. Skip/deny or an unreachable CodeIsland falls through to OMP's - // own TUI dialog — graceful degradation, never a lost question. - if (event.toolName === "ask") { - const answered = await forwardAskToCodeIsland(event, ctx, sessionId, sid, tty); - if (answered) return answered; - return undefined; - } - // Dangerous bash → send blocking PermissionRequest via bridge. if ( event.toolName === "bash" && diff --git a/Sources/CodeIslandCore/Models.swift b/Sources/CodeIslandCore/Models.swift index ccfe0a73..338af568 100644 --- a/Sources/CodeIslandCore/Models.swift +++ b/Sources/CodeIslandCore/Models.swift @@ -171,7 +171,7 @@ public struct HookEvent { self.toolName = HookEvent.firstString(in: json, keys: ["tool_name", "toolName", "tool", "name"]) ?? HookEvent.firstString(inNestedDictionary: json, containerKeys: ["tool", "payload", "data"], keys: ["name", "tool_name", "toolName"]) ?? HookEvent.firstString(inNestedDictionary: json, containerKeys: ["toolCall"], keys: ["name"]) - self.toolUseId = HookEvent.firstString(in: json, keys: ["tool_use_id", "toolUseId"]) + self.toolUseId = HookEvent.firstString(in: json, keys: ["tool_use_id", "toolUseId", "_pi_tool_call_id"]) ?? HookEvent.firstString(inNestedDictionary: json, containerKeys: ["tool", "tool_use", "toolUse", "payload", "data"], keys: ["id", "tool_use_id", "toolUseId"]) self.toolInput = HookEvent.firstDictionary(in: json, keys: ["tool_input", "toolInput", "input", "arguments", "args", "params"]) ?? HookEvent.firstDictionary(inNestedDictionary: json, containerKeys: ["tool", "payload", "data"], keys: ["input", "tool_input", "toolInput", "arguments", "args", "params"]) diff --git a/Tests/CodeIslandCoreTests/HookEventToolUseIdTests.swift b/Tests/CodeIslandCoreTests/HookEventToolUseIdTests.swift index d7742057..a7f53a42 100644 --- a/Tests/CodeIslandCoreTests/HookEventToolUseIdTests.swift +++ b/Tests/CodeIslandCoreTests/HookEventToolUseIdTests.swift @@ -23,6 +23,16 @@ final class HookEventToolUseIdTests: XCTestCase { XCTAssertEqual(event.toolUseId, "toolu_xyz789") } + func testParsesOMPToolCallId() throws { + let event = try decode([ + "hook_event_name": "PermissionRequest", + "session_id": "pi-s1", + "tool_name": "AskUserQuestion", + "_pi_tool_call_id": "omp_call_123" + ]) + XCTAssertEqual(event.toolUseId, "omp_call_123") + } + func testParsesNestedToolUseIdInToolContainer() throws { let event = try decode([ "hook_event_name": "PreToolUse", diff --git a/Tests/CodeIslandTests/AppStateQuestionFlowTests.swift b/Tests/CodeIslandTests/AppStateQuestionFlowTests.swift index 9143fe68..9b3e9586 100644 --- a/Tests/CodeIslandTests/AppStateQuestionFlowTests.swift +++ b/Tests/CodeIslandTests/AppStateQuestionFlowTests.swift @@ -60,6 +60,97 @@ final class AppStateQuestionFlowTests: XCTestCase { XCTAssertEqual(answers["你更喜欢我用哪种回答风格?"] as? String, "平衡") } + func testStructuredAnswerDetailsPreserveMultiSelectAndCustomInput() async throws { + let appState = AppState() + var multiQuestion = question( + header: "组合选择", + text: "请选择多个选项,也可以补充自定义内容", + options: ["Alpha, Beta", "Gamma"] + ) + multiQuestion["multiSelect"] = true + let event = try makeAskUserQuestionEvent( + sessionId: "s-structured", + questions: [multiQuestion], + piToolCallId: "omp-structured" + ) + + let responseTask = Task { + await withCheckedContinuation { continuation in + appState.handleAskUserQuestion(event, continuation: continuation) + } + } + + await Task.yield() + appState.answerQuestionMulti([ + AskUserQuestionAnswer( + question: "请选择多个选项,也可以补充自定义内容", + answer: "Alpha, Beta, Gamma, custom, value", + selectedOptions: ["Alpha, Beta", "Gamma"], + customInput: "custom, value" + ), + ]) + + let responseData = await responseTask.value + let updatedInput = try extractUpdatedInput(from: responseData) + let answers = try XCTUnwrap(updatedInput["answers"] as? [String: Any]) + XCTAssertEqual(answers["请选择多个选项,也可以补充自定义内容"] as? String, "Alpha, Beta, Gamma, custom, value") + + let details = try XCTUnwrap(updatedInput["_codeislandAnswerDetails"] as? [String: Any]) + let answerDetails = try XCTUnwrap(details["请选择多个选项,也可以补充自定义内容"] as? [String: Any]) + XCTAssertEqual(answerDetails["selectedOptions"] as? [String], ["Alpha, Beta", "Gamma"]) + XCTAssertEqual(answerDetails["customInput"] as? String, "custom, value") + } + + func testMultiSelectWithoutOptionsPreservesFreeTextAsCustomInput() async throws { + let appState = AppState() + var freeTextQuestion = question( + header: "补充说明", + text: "请填写补充内容", + options: [] + ) + freeTextQuestion["multiSelect"] = true + let event = try makeAskUserQuestionEvent( + sessionId: "s-free-text-multi", + questions: [freeTextQuestion], + piToolCallId: "omp-free-text-multi" + ) + + let responseTask = Task { + await withCheckedContinuation { continuation in + appState.handleAskUserQuestion(event, continuation: continuation) + } + } + + await Task.yield() + appState.answerQuestionMulti([ + AskUserQuestionAnswer( + question: "请填写补充内容", + answer: "自由输入", + selectedOptions: [], + customInput: "自由输入" + ), + ]) + + let responseData = await responseTask.value + let updatedInput = try extractUpdatedInput(from: responseData) + let details = try XCTUnwrap(updatedInput["_codeislandAnswerDetails"] as? [String: Any]) + let answerDetails = try XCTUnwrap(details["请填写补充内容"] as? [String: Any]) + XCTAssertNil(answerDetails["selectedOptions"]) + XCTAssertEqual(answerDetails["customInput"] as? String, "自由输入") + } + + func testQuestionBarFreeTextAnswerPreservesCustomInput() throws { + let answer = try XCTUnwrap( + makeQuestionBarFreeTextAnswer(question: "请填写补充内容", text: "自由输入") + ) + + XCTAssertEqual(answer.question, "请填写补充内容") + XCTAssertEqual(answer.answer, "自由输入") + XCTAssertEqual(answer.selectedOptions, []) + XCTAssertEqual(answer.customInput, "自由输入") + XCTAssertNil(makeQuestionBarFreeTextAnswer(question: "请填写补充内容", text: "")) + } + // MARK: - Single question func testAskUserQuestionSingleQuestionWorks() async throws { @@ -79,23 +170,31 @@ final class AppStateQuestionFlowTests: XCTestCase { await Task.yield() appState.answerQuestionMulti([ - (question: "你希望我主要使用哪种语言回复?", answer: "中文"), + AskUserQuestionAnswer( + question: "你希望我主要使用哪种语言回复?", + answer: "中文", + selectedOptions: ["中文"], + customInput: nil + ), ]) let responseData = await responseTask.value let answers = try extractAnswers(from: responseData) XCTAssertEqual(answers["你希望我主要使用哪种语言回复?"] as? String, "中文") + let updatedInput = try extractUpdatedInput(from: responseData) + XCTAssertNil(updatedInput["_codeislandAnswerDetails"]) } func testAskUserQuestionOpensQuestionCardWhenSmartSuppressSeesGhosttyFrontmost() async throws { UserDefaults.standard.set(true, forKey: SettingsKey.smartSuppress) let appState = AppState() + appState.questionTerminalFrontmostDetector = { _ in true } let sessionId = "s-smart-question" var session = SessionSnapshot() session.termApp = "Ghostty" - session.termBundleId = try XCTUnwrap(NSWorkspace.shared.frontmostApplication?.bundleIdentifier) + session.termBundleId = "com.mitchellh.ghostty" appState.sessions[sessionId] = session - XCTAssertFalse(appState.shouldAutoOpenPendingSurface(for: sessionId), "test setup must model Smart Suppress considering the Ghostty-backed session frontmost") + XCTAssertFalse(appState.shouldAutoOpenPendingSurface(for: sessionId, isTerminalFrontmost: { _ in true }), "test setup must model Smart Suppress considering the Ghostty-backed session frontmost") let event = try makeAskUserQuestionEvent( sessionId: sessionId, @@ -127,7 +226,8 @@ final class AppStateQuestionFlowTests: XCTestCase { func testAskUserQuestionQueueOpensNextQuestionCardWhenSmartSuppressSeesGhosttyFrontmost() async throws { UserDefaults.standard.set(true, forKey: SettingsKey.smartSuppress) let appState = AppState() - let frontmostBundleId = try XCTUnwrap(NSWorkspace.shared.frontmostApplication?.bundleIdentifier) + appState.questionTerminalFrontmostDetector = { _ in true } + let frontmostBundleId = "com.mitchellh.ghostty" let firstSessionId = "s-smart-question-first" let secondSessionId = "s-smart-question-second" var firstSession = SessionSnapshot() @@ -138,8 +238,8 @@ final class AppStateQuestionFlowTests: XCTestCase { secondSession.termBundleId = frontmostBundleId appState.sessions[firstSessionId] = firstSession appState.sessions[secondSessionId] = secondSession - XCTAssertFalse(appState.shouldAutoOpenPendingSurface(for: firstSessionId), "test setup must model Smart Suppress considering the first Ghostty-backed session frontmost") - XCTAssertFalse(appState.shouldAutoOpenPendingSurface(for: secondSessionId), "test setup must model Smart Suppress considering the second Ghostty-backed session frontmost") + XCTAssertFalse(appState.shouldAutoOpenPendingSurface(for: firstSessionId, isTerminalFrontmost: { _ in true }), "test setup must model Smart Suppress considering the first Ghostty-backed session frontmost") + XCTAssertFalse(appState.shouldAutoOpenPendingSurface(for: secondSessionId, isTerminalFrontmost: { _ in true }), "test setup must model Smart Suppress considering the second Ghostty-backed session frontmost") let firstEvent = try makeAskUserQuestionEvent( sessionId: firstSessionId, @@ -186,6 +286,212 @@ final class AppStateQuestionFlowTests: XCTestCase { ) } + func testPiAskUserQuestionStaysCollapsedWhenSmartSuppressSeesTerminalFrontmost() async throws { + UserDefaults.standard.set(true, forKey: SettingsKey.smartSuppress) + let appState = AppState() + appState.questionTerminalFrontmostDetector = { _ in true } + let sessionId = "s-smart-pi-question" + var session = SessionSnapshot() + session.termApp = "Ghostty" + session.termBundleId = "com.mitchellh.ghostty" + appState.sessions[sessionId] = session + XCTAssertFalse(appState.shouldAutoOpenPendingSurface(for: sessionId, isTerminalFrontmost: { _ in true })) + + let event = try makeAskUserQuestionEvent( + sessionId: sessionId, + questions: [ + question(header: "继续吗", text: "需要用户确认下一步吗?", options: ["继续", "停止"]) + ], + piToolCallId: "omp-smart-suppressed", + nativeAskRacing: true + ) + + let responseTask = Task { + await withCheckedContinuation { continuation in + appState.handleAskUserQuestion(event, continuation: continuation) + } + } + + await Task.yield() + XCTAssertEqual(appState.questionQueue.count, 1) + let surfaceAfterRequest = appState.surface + + appState.skipQuestion() + _ = await responseTask.value + + XCTAssertEqual( + surfaceAfterRequest, + .collapsed, + "OMP v3 keeps its native ask dialog available in parallel, so Smart Suppress should not force the duplicate CodeIsland card open." + ) + } + + func testPiAndLegacyOmpAskWithoutNativeRaceMarkerOpensWhenTerminalIsFrontmost() async throws { + UserDefaults.standard.set(true, forKey: SettingsKey.smartSuppress) + let appState = AppState() + appState.questionTerminalFrontmostDetector = { _ in true } + let sessionId = "s-smart-legacy-pi-question" + var session = SessionSnapshot() + session.termApp = "Ghostty" + session.termBundleId = "com.mitchellh.ghostty" + appState.sessions[sessionId] = session + + let event = try makeAskUserQuestionEvent( + sessionId: sessionId, + questions: [ + question(header: "继续吗", text: "需要用户确认下一步吗?", options: ["继续", "停止"]) + ], + piToolCallId: "legacy-pi-blocking-ask" + ) + + let responseTask = Task { + await withCheckedContinuation { continuation in + appState.handleAskUserQuestion(event, continuation: continuation) + } + } + + await Task.yield() + let surfaceAfterRequest = appState.surface + appState.skipQuestion() + _ = await responseTask.value + + XCTAssertEqual( + surfaceAfterRequest, + .questionCard(sessionId: sessionId), + "Pi and legacy OMP block their native prompt while CodeIsland waits, so an unmarked AskUserQuestion must force-open." + ) + } + + func testPiAskUserQuestionQueueKeepsNextCardCollapsedWhenTerminalIsFrontmost() async throws { + UserDefaults.standard.set(true, forKey: SettingsKey.smartSuppress) + let appState = AppState() + appState.questionTerminalFrontmostDetector = { _ in true } + let frontmostBundleId = "com.mitchellh.ghostty" + let firstSessionId = "s-smart-pi-question-first" + let secondSessionId = "s-smart-pi-question-second" + var firstSession = SessionSnapshot() + firstSession.termApp = "Ghostty" + firstSession.termBundleId = frontmostBundleId + var secondSession = SessionSnapshot() + secondSession.termApp = "Ghostty" + secondSession.termBundleId = frontmostBundleId + appState.sessions[firstSessionId] = firstSession + appState.sessions[secondSessionId] = secondSession + XCTAssertFalse(appState.shouldAutoOpenPendingSurface(for: firstSessionId, isTerminalFrontmost: { _ in true })) + XCTAssertFalse(appState.shouldAutoOpenPendingSurface(for: secondSessionId, isTerminalFrontmost: { _ in true })) + + let firstEvent = try makeAskUserQuestionEvent( + sessionId: firstSessionId, + questions: [ + question(header: "第一步", text: "先处理第一个问题吗?", options: ["继续", "停止"]) + ], + piToolCallId: "omp-smart-first", + nativeAskRacing: true + ) + let secondEvent = try makeAskUserQuestionEvent( + sessionId: secondSessionId, + questions: [ + question(header: "第二步", text: "现在处理第二个问题吗?", options: ["继续", "停止"]) + ], + piToolCallId: "omp-smart-second", + nativeAskRacing: true + ) + + let firstResponseTask = Task { + await withCheckedContinuation { continuation in + appState.handleAskUserQuestion(firstEvent, continuation: continuation) + } + } + await Task.yield() + let secondResponseTask = Task { + await withCheckedContinuation { continuation in + appState.handleAskUserQuestion(secondEvent, continuation: continuation) + } + } + await Task.yield() + + appState.skipQuestion() + _ = await firstResponseTask.value + await Task.yield() + let queueCountAfterPromotingSecond = appState.questionQueue.count + let surfaceAfterPromotingSecond = appState.surface + + appState.skipQuestion() + _ = await secondResponseTask.value + + XCTAssertEqual(queueCountAfterPromotingSecond, 1) + XCTAssertEqual( + surfaceAfterPromotingSecond, + .collapsed, + "Queued OMP questions should keep honoring Smart Suppress after promotion because the native terminal dialog remains usable." + ) + } + + func testPromotedOmpAskCollapsesVisibleNonOmpQuestionCardWhenSmartSuppressed() async throws { + UserDefaults.standard.set(true, forKey: SettingsKey.smartSuppress) + let appState = AppState() + appState.questionTerminalFrontmostDetector = { _ in true } + let legacySessionId = "s-smart-legacy-first" + let ompSessionId = "s-smart-omp-second" + var legacySession = SessionSnapshot() + legacySession.termApp = "Ghostty" + legacySession.termBundleId = "com.mitchellh.ghostty" + var ompSession = SessionSnapshot() + ompSession.termApp = "Ghostty" + ompSession.termBundleId = "com.mitchellh.ghostty" + appState.sessions[legacySessionId] = legacySession + appState.sessions[ompSessionId] = ompSession + + // Legacy Pi ask (no race marker) — must force-open. + let legacyEvent = try makeAskUserQuestionEvent( + sessionId: legacySessionId, + questions: [ + question(header: "旧版", text: "旧版 OMP 会阻塞吗?", options: ["是", "否"]) + ], + piToolCallId: "legacy-pi-1" + ) + // OMP v4 ask (with race marker) — should be Smart Suppressed. + let ompEvent = try makeAskUserQuestionEvent( + sessionId: ompSessionId, + questions: [ + question(header: "新版", text: "新版 OMP 是否并行?", options: ["是", "否"]) + ], + piToolCallId: "omp-racing-2", + nativeAskRacing: true + ) + + let legacyResponseTask = Task { + await withCheckedContinuation { continuation in + appState.handleAskUserQuestion(legacyEvent, continuation: continuation) + } + } + await Task.yield() + // Legacy question must have forced the card open. + XCTAssertEqual(appState.surface, .questionCard(sessionId: legacySessionId)) + + let ompResponseTask = Task { + await withCheckedContinuation { continuation in + appState.handleAskUserQuestion(ompEvent, continuation: continuation) + } + } + await Task.yield() + + // Skip the legacy question — the OMP question should be promoted. + appState.skipQuestion() + _ = await legacyResponseTask.value + await Task.yield() + let surfaceAfterPromotion = appState.surface + + appState.skipQuestion() + _ = await ompResponseTask.value + + XCTAssertEqual( + surfaceAfterPromotion, + .collapsed, + "A Smart Suppress decision must replace the previous question-card surface when a queued OMP ask is promoted." + ) + } + // MARK: - Skip returns deny func testSkipAskUserQuestionReturnsDeny() async throws { @@ -502,8 +808,13 @@ final class AppStateQuestionFlowTests: XCTestCase { // MARK: - Helpers - private func makeAskUserQuestionEvent(sessionId: String, questions: [[String: Any]]) throws -> HookEvent { - let payload: [String: Any] = [ + private func makeAskUserQuestionEvent( + sessionId: String, + questions: [[String: Any]], + piToolCallId: String? = nil, + nativeAskRacing: Bool = false + ) throws -> HookEvent { + var payload: [String: Any] = [ "hook_event_name": "PermissionRequest", "session_id": sessionId, "tool_name": "AskUserQuestion", @@ -511,6 +822,13 @@ final class AppStateQuestionFlowTests: XCTestCase { "questions": questions ] ] + if let piToolCallId { + payload["_source"] = "pi" + payload["_pi_tool_call_id"] = piToolCallId + } + if nativeAskRacing { + payload["_codeisland_native_ask_racing"] = true + } let data = try JSONSerialization.data(withJSONObject: payload) guard let event = HookEvent(from: data) else { XCTFail("Failed to parse HookEvent") diff --git a/Tests/CodeIslandTests/ConfigInstallerTests.swift b/Tests/CodeIslandTests/ConfigInstallerTests.swift index a4e26cb7..31b7a962 100644 --- a/Tests/CodeIslandTests/ConfigInstallerTests.swift +++ b/Tests/CodeIslandTests/ConfigInstallerTests.swift @@ -1195,7 +1195,7 @@ hooks: let contents = try String(contentsOf: ompExtensionPath) XCTAssertTrue(contents.contains("CodeIsland pi extension")) - XCTAssertTrue(contents.contains("// version: v2")) + XCTAssertTrue(contents.contains("// version: v4")) XCTAssertTrue(contents.contains("@oh-my-pi/pi-coding-agent")) XCTAssertFalse(contents.contains("@earendil-works/pi-coding-agent")) XCTAssertTrue(ConfigInstaller.isOmpExtensionInstalled(ompExtensionPath: ompExtensionPath.path, fm: fm))