From 821979e97b564cf42d444e24693c74b2ae262c90 Mon Sep 17 00:00:00 2001 From: MP Date: Mon, 13 Jul 2026 00:27:13 +0800 Subject: [PATCH 1/3] fix: preserve typed context builder instructions (#526) --- .../Fixtures/test-suite-contract-ledger.tsv | 2 + .../MCPContextBuilderToolProvider.swift | 121 ++++++- ...ntextBuilderMCPProgressTimelineTests.swift | 329 +++++++++++++++++- 3 files changed, 429 insertions(+), 23 deletions(-) diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index ac8691a85..fdfe0f6f6 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -1184,6 +1184,7 @@ root/RepoPromptTests.ContextBuilderFollowUpFinalizationMonitorTests/testStalledF root/RepoPromptTests.ContextBuilderFollowUpFinalizationMonitorTests/testTimeoutOutcomeWinsWhenCancellationAlsoCompletesFinalization root Tests/RepoPromptTests/ContextBuilder/ContextBuilderFollowUpFinalizationMonitorTests.swift RepoPromptTests.ContextBuilderFollowUpFinalizationMonitorTests testTimeoutOutcomeWinsWhenCancellationAlsoCompletesFinalization ContextBuilder unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 94 root/RepoPromptTests.ContextBuilderMCPProgressTimelineTests/testCommitAndClearTabContextReportsPersistenceSubphasesInOrder root Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift RepoPromptTests.ContextBuilderMCPProgressTimelineTests testCommitAndClearTabContextReportsPersistenceSubphasesInOrder ContextBuilder unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.190000 unreviewed retain_pending_review 0 initial census source line 371 root/RepoPromptTests.ContextBuilderMCPProgressTimelineTests/testContextBuilderToolProviderReportsDiscoveryAndGenerationStageEnvelope root Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift RepoPromptTests.ContextBuilderMCPProgressTimelineTests testContextBuilderToolProviderReportsDiscoveryAndGenerationStageEnvelope ContextBuilder unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.154000 unreviewed retain_pending_review 0 initial census source line 266 +root/RepoPromptTests.ContextBuilderMCPProgressTimelineTests/testContextBuilderToolProviderUsesCallerPromptWithoutMutatingDiscoveryFallback root Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift RepoPromptTests.ContextBuilderMCPProgressTimelineTests testContextBuilderToolProviderUsesCallerPromptWithoutMutatingDiscoveryFallback ContextBuilder context_builder.typed_prompt.request_local_handoff prompt_authority,state_preservation,hidden_guidance integration_regression root_swiftpm routine 1 Injected follow-up and returned prompt contain caller task/context without discovery guidance or discovery output while the canonical tab retains its committed fallback. Typed MCP responses from empty tabs could fail, leak discovery-only guidance, execute discovery prose, or overwrite canonical tab state. 0.524000 singleton_window_registry window_state retain 0 request-local typed prompt handoff across provider execution and result packaging root/RepoPromptTests.ContextBuilderMCPProgressTimelineTests/testDeferredRunMappingSurvivesCommitUntilCallerCleanup root Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift RepoPromptTests.ContextBuilderMCPProgressTimelineTests testDeferredRunMappingSurvivesCommitUntilCallerCleanup ContextBuilder unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.048000 unreviewed retain_pending_review 0 initial census source line 425 root/RepoPromptTests.ContextBuilderMCPProgressTimelineTests/testPhaseCatalogCoversExpectedDiscoveryAndGenerationSequence root Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift RepoPromptTests.ContextBuilderMCPProgressTimelineTests testPhaseCatalogCoversExpectedDiscoveryAndGenerationSequence ContextBuilder unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 6 root/RepoPromptTests.ContextBuilderMCPProgressTimelineTests/testResponseDispositionRoutesRequestedModesAndFailsClosedOnMissingFollowUpState root Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift RepoPromptTests.ContextBuilderMCPProgressTimelineTests testResponseDispositionRoutesRequestedModesAndFailsClosedOnMissingFollowUpState ContextBuilder completion_routing focused root_swiftpm routine 9 regression retain 0.000000 stable retain 0 committed snapshot response routing and fail-closed follow-up state @@ -1191,6 +1192,7 @@ root/RepoPromptTests.ContextBuilderMCPProgressTimelineTests/testRunMCPPlanOrQues root/RepoPromptTests.ContextBuilderMCPProgressTimelineTests/testSoftStageBoundEmitsOnceWithoutFailingTimeline root Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift RepoPromptTests.ContextBuilderMCPProgressTimelineTests testSoftStageBoundEmitsOnceWithoutFailingTimeline ContextBuilder unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.001500 unreviewed retain_pending_review 0 initial census source line 139 root/RepoPromptTests.ContextBuilderMCPProgressTimelineTests/testSuspendingSinkPreservesEveryTimedTransitionWhenItReentersTimeline root Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift RepoPromptTests.ContextBuilderMCPProgressTimelineTests testSuspendingSinkPreservesEveryTimedTransitionWhenItReentersTimeline ContextBuilder unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000500 unreviewed retain_pending_review 0 initial census source line 66 root/RepoPromptTests.ContextBuilderMCPProgressTimelineTests/testTimelineEmitsTimedTransitionsAndUsesCurrentSubphaseForHeartbeat root Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift RepoPromptTests.ContextBuilderMCPProgressTimelineTests testTimelineEmitsTimedTransitionsAndUsesCurrentSubphaseForHeartbeat ContextBuilder unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000500 unreviewed retain_pending_review 0 initial census source line 28 +root/RepoPromptTests.ContextBuilderMCPProgressTimelineTests/testTypedPromptResolverEnforcesPrecedenceAndReservedMarkupGrammar root Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift RepoPromptTests.ContextBuilderMCPProgressTimelineTests testTypedPromptResolverEnforcesPrecedenceAndReservedMarkupGrammar ContextBuilder context_builder.typed_prompt.resolution prompt_precedence,lazy_sanitization,reserved_markup parser_boundary root_swiftpm routine 16 Table assertions prove committed-prompt precedence, exact lowercase sibling removal, quote-aware attribute rejection, malformed reserved-markup rejection, and caller fallback into typed generation. Malformed or hidden discovery guidance could reach typed generation, or valid committed prompts could be rejected. 0.001000 test_case retain 0 request-local prompt authority and fail-closed reserved-markup grammar root/RepoPromptTests.ContextBuilderModelStartupSelectionTests/testCachedCLIFlagIsNotReadyUntilCurrentProcessVerification root Tests/RepoPromptTests/ContextBuilder/ContextBuilderModelStartupSelectionTests.swift RepoPromptTests.ContextBuilderModelStartupSelectionTests testCachedCLIFlagIsNotReadyUntilCurrentProcessVerification ContextBuilder unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.001000 unreviewed retain_pending_review 0 initial census source line 307 root/RepoPromptTests.ContextBuilderModelStartupSelectionTests/testCursorStartupReadinessJoinsRunningPollWithoutDynamicMetadata root Tests/RepoPromptTests/ContextBuilder/ContextBuilderModelStartupSelectionTests.swift RepoPromptTests.ContextBuilderModelStartupSelectionTests testCursorStartupReadinessJoinsRunningPollWithoutDynamicMetadata ContextBuilder unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.002000 unreviewed retain_pending_review 0 initial census source line 259 root/RepoPromptTests.ContextBuilderModelStartupSelectionTests/testDynamicPersistedSelectionSurvivesAfterACPDiscovery root Tests/RepoPromptTests/ContextBuilder/ContextBuilderModelStartupSelectionTests.swift RepoPromptTests.ContextBuilderModelStartupSelectionTests testDynamicPersistedSelectionSurvivesAfterACPDiscovery ContextBuilder unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.001000 unreviewed retain_pending_review 0 initial census source line 145 diff --git a/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPContextBuilderToolProvider.swift b/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPContextBuilderToolProvider.swift index e19232838..b232dced5 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPContextBuilderToolProvider.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPContextBuilderToolProvider.swift @@ -92,10 +92,102 @@ private struct ContextBuilderToolResult: Codable { enum ContextBuilderResponseDisposition { case contextOnly - case generate(HeadlessMode) + case generate(HeadlessMode, prompt: String) case failed(String) } +enum ContextBuilderTypedPromptResolver { + private static let openingTag = "" + private static let closingTag = "" + private static let reservedName = "discovery_agent-guidelines" + + static func resolve( + effectivePrompt: String, + usedAgentOutputAsPrompt: Bool, + callerInstructions: String + ) -> String? { + if !usedAgentOutputAsPrompt, + !effectivePrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return effectivePrompt + } + return sanitizeCallerInstructions(callerInstructions) + } + + private static func sanitizeCallerInstructions(_ instructions: String) -> String? { + var sanitized = "" + var cursor = instructions.startIndex + var insideGuidelines = false + + while cursor < instructions.endIndex { + guard instructions[cursor] == "<" else { + if !insideGuidelines { + sanitized.append(instructions[cursor]) + } + cursor = instructions.index(after: cursor) + continue + } + + guard let closingAngle = closingAngleIndex(in: instructions, after: cursor) else { + let remainder = instructions[cursor...] + guard remainder.range(of: reservedName, options: .caseInsensitive) == nil else { + return nil + } + if !insideGuidelines { + sanitized.append(contentsOf: remainder) + } + break + } + + let afterTag = instructions.index(after: closingAngle) + let tag = instructions[cursor ..< afterTag] + if tag == openingTag { + guard !insideGuidelines else { return nil } + insideGuidelines = true + } else if tag == closingTag { + guard insideGuidelines else { return nil } + insideGuidelines = false + } else { + // Quote-aware tokenization keeps reserved markup inside attributes from becoming removable blocks. + guard tag.range(of: reservedName, options: .caseInsensitive) == nil else { + return nil + } + if !insideGuidelines { + sanitized.append(contentsOf: tag) + } + } + cursor = afterTag + } + + guard !insideGuidelines else { return nil } + let result = sanitized.trimmingCharacters(in: .whitespacesAndNewlines) + return result.isEmpty ? nil : result + } + + private static func closingAngleIndex( + in instructions: String, + after openingAngle: String.Index + ) -> String.Index? { + var cursor = instructions.index(after: openingAngle) + var activeQuote: Character? + + while cursor < instructions.endIndex { + let character = instructions[cursor] + if let quote = activeQuote { + if character == quote { + activeQuote = nil + } + } else if character == "\"" || character == "'" { + activeQuote = character + } else if character == ">" { + return cursor + } + cursor = instructions.index(after: cursor) + } + return nil + } +} + @MainActor final class MCPContextBuilderToolProvider: MCPWindowToolProviding { let group: MCPWindowToolGroup = .contextBuilder @@ -444,15 +536,18 @@ final class MCPContextBuilderToolProvider: MCPWindowToolProviding { responseType: responseType, terminalDisposition: snapshot.terminalDisposition, usedAgentOutputAsPrompt: snapshot.usedAgentOutputAsPrompt, - effectivePrompt: effectivePrompt + effectivePrompt: effectivePrompt, + callerInstructions: instructions ) + var resultPrompt = effectivePrompt switch responseDisposition { case .contextOnly: break case let .failed(message): throw MCPError.internalError(message) - case let .generate(mode): + case let .generate(mode, prompt): + resultPrompt = prompt try Task.checkCancellation() let finalReviewAuthorization: ContextBuilderFinalReviewAuthorization? if mode == .review, let workspaceContext { @@ -543,7 +638,7 @@ final class MCPContextBuilderToolProvider: MCPWindowToolProviding { tabResolution.agentModeSessionID, tabResolution.agentModeRunID, mode, - effectivePrompt, + prompt, sel, lookupContext, tabResolution.reviewGitContext, @@ -586,7 +681,7 @@ final class MCPContextBuilderToolProvider: MCPWindowToolProviding { ContextBuilderToolResult( tabID: resultTab.id.uuidString, status: status, - prompt: effectivePrompt, + prompt: resultPrompt, fileCount: fileCount, totalTokens: normalizedTokens, userTotalTokens: userTotalTokens, @@ -652,7 +747,8 @@ final class MCPContextBuilderToolProvider: MCPWindowToolProviding { responseType: ContextBuilderResponseType?, terminalDisposition: ContextBuilderRunTerminalOutcome, usedAgentOutputAsPrompt: Bool, - effectivePrompt: String + effectivePrompt: String, + callerInstructions: String ) -> ContextBuilderResponseDisposition { guard responseType?.wantsResponse == true else { return .contextOnly } @@ -660,12 +756,11 @@ final class MCPContextBuilderToolProvider: MCPWindowToolProviding { case .cancelled, .failed: return .contextOnly case .completed: - guard !usedAgentOutputAsPrompt else { - return .failed( - "Context Builder completed without a typed direct response for the requested \(responseType?.rawValue ?? "response")" - ) - } - guard !effectivePrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + guard let prompt = ContextBuilderTypedPromptResolver.resolve( + effectivePrompt: effectivePrompt, + usedAgentOutputAsPrompt: usedAgentOutputAsPrompt, + callerInstructions: callerInstructions + ) else { return .failed( "Context Builder completed without a prompt for the requested \(responseType?.rawValue ?? "response")" ) @@ -673,7 +768,7 @@ final class MCPContextBuilderToolProvider: MCPWindowToolProviding { guard let mode = responseType?.headlessMode else { return .failed("Context Builder requested response mode is unavailable") } - return .generate(mode) + return .generate(mode, prompt: prompt) } } diff --git a/Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift b/Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift index ba8166d60..ecf890b0b 100644 --- a/Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift +++ b/Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift @@ -382,6 +382,291 @@ final class ContextBuilderMCPProgressTimelineTests: XCTestCase { #endif } + @MainActor + func testContextBuilderToolProviderUsesCallerPromptWithoutMutatingDiscoveryFallback() async throws { + #if DEBUG + let discoveryInputRecorder = ContextBuilderDiscoveryInputRecorder() + let provider = ContextBuilderImmediateCompletionProvider( + discoveryInputRecorder: discoveryInputRecorder + ) + let previousAutoStart = GlobalSettingsStore.shared.mcpAutoStart() + GlobalSettingsStore.shared.setMCPAutoStart(false, commit: false) + let window = WindowState { _, _, _ in provider } + GlobalSettingsStore.shared.setMCPAutoStart(previousAutoStart, commit: false) + WindowStatesManager.shared.registerWindowState(window) + defer { WindowStatesManager.shared.unregisterWindowState(window) } + await window.workspaceManager.awaitInitialized() + + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ContextBuilderProviderPromptTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let workspace = window.workspaceManager.createWorkspace( + name: "Context Builder provider prompt test", + repoPaths: [root.path], + ephemeral: true + ) + await window.workspaceManager.switchWorkspace( + to: workspace, + saveState: false, + reason: "ContextBuilderMCPProgressTimelineTests.providerPrompt" + ) + + let activeWorkspace = try XCTUnwrap(window.workspaceManager.activeWorkspace) + let tabID = try XCTUnwrap( + activeWorkspace.activeComposeTabID ?? activeWorkspace.composeTabs.first?.id + ) + let tabIdentity = WorkspaceSelectionIdentity( + workspaceID: activeWorkspace.id, + tabID: tabID + ) + var initialTab = try XCTUnwrap(window.workspaceManager.composeTab(for: tabIdentity)) + initialTab.promptText = "" + window.workspaceManager.updateComposeTab(initialTab, markDirty: false) + window.promptManager.promptText = "" + XCTAssertTrue(initialTab.promptText.isEmpty) + + let stageRecorder = ContextBuilderProviderStageRecorder() + window.mcpServer.installStageProgressSinkForTesting { _, tool, stage, message in + guard tool == MCPWindowToolName.contextBuilder else { return } + await stageRecorder.record(stage: stage, message: message) + } + defer { window.mcpServer.installStageProgressSinkForTesting(nil) } + + let followUpRecorder = ContextBuilderFollowUpInvocationRecorder() + window.contextBuilderAgentViewModel.installRunTestHooks( + ContextBuilderAgentViewModel.RunTestHooks( + beforeProcessingProviderEvent: nil, + providerEventDisposition: nil, + teardownCompleted: nil, + allowSyntheticRoutingWithoutFinalContext: true, + runMCPFollowUp: { mode, prompt, selection in + await followUpRecorder.record( + mode: mode, + prompt: prompt, + selection: selection + ) + let chatID = UUID() + return ChatSendReply( + chatId: chatID, + shortId: String(chatID.uuidString.prefix(8)).lowercased(), + mode: mode.mcpModeName, + response: "deterministic provider follow-up", + errors: nil + ) + } + ) + ) + defer { window.contextBuilderAgentViewModel.installRunTestHooks(nil) } + + let task = "Inspect request-local prompt." + let context = "Keep caller context." + let discoverySentinel = "DISCOVERY_ONLY_SENTINEL" + let instructions = """ + \(task) + \(context) + \(discoverySentinel) + """ + let expectedPrompt = """ + \(task) + \(context) + """ + + let tools = await window.mcpServer.windowMCPTools + let contextBuilder = try XCTUnwrap( + tools.first { $0.name == MCPWindowToolName.contextBuilder } + ) + let result = try await { + do { + return try await contextBuilder([ + "instructions": .string(instructions), + "response_type": .string("plan"), + "context_id": .string(tabID.uuidString) + ]) + } catch { + let stages = await stageRecorder.snapshot().map(\.stage) + XCTFail("Context Builder failed after stages \(stages): \(error)") + throw error + } + }() + + let discoveryInputs = await discoveryInputRecorder.snapshot() + let discoveryInput = try XCTUnwrap(discoveryInputs.last) + XCTAssertTrue(discoveryInput.contains(instructions)) + + let recordedInvocation = await followUpRecorder.snapshot() + let invocation = try XCTUnwrap(recordedInvocation) + XCTAssertEqual(invocation.mode, .plan) + XCTAssertEqual(invocation.prompt, expectedPrompt) + + guard case let .object(resultObject) = result else { + return XCTFail("Expected Context Builder object result") + } + XCTAssertEqual(resultObject["prompt"]?.stringValue, expectedPrompt) + + let storedTab = try XCTUnwrap(window.workspaceManager.composeTab(for: tabIdentity)) + XCTAssertEqual(storedTab.promptText, "Discovery complete") + #else + throw XCTSkip("Provider-path Context Builder injection is DEBUG-only.") + #endif + } + + func testTypedPromptResolverEnforcesPrecedenceAndReservedMarkupGrammar() { + struct Case { + let name: String + let effectivePrompt: String + let usedAgentOutputAsPrompt: Bool + let callerInstructions: String + let expected: String? + } + + let cases = [ + Case( + name: "committed prompt wins without parsing malformed unused instructions", + effectivePrompt: "Committed prompt", + usedAgentOutputAsPrompt: false, + callerInstructions: "unused", + expected: "Committed prompt" + ), + Case( + name: "plain caller instructions", + effectivePrompt: "Discovery output", + usedAgentOutputAsPrompt: true, + callerInstructions: "Caller task", + expected: "Caller task" + ), + Case( + name: "exact lowercase block removed", + effectivePrompt: "Discovery output", + usedAgentOutputAsPrompt: true, + callerInstructions: """ + Caller task + hidden + Caller context + """, + expected: """ + Caller task + + Caller context + """ + ), + Case( + name: "repeated sibling blocks removed", + effectivePrompt: "Discovery output", + usedAgentOutputAsPrompt: true, + callerInstructions: """ + first + Caller task + second + """, + expected: "Caller task" + ), + Case( + name: "blank caller instructions", + effectivePrompt: "", + usedAgentOutputAsPrompt: false, + callerInstructions: " \n", + expected: nil + ), + Case( + name: "guidelines only", + effectivePrompt: "Discovery output", + usedAgentOutputAsPrompt: true, + callerInstructions: "hidden", + expected: nil + ), + Case( + name: "unmatched opening tag", + effectivePrompt: "Discovery output", + usedAgentOutputAsPrompt: true, + callerInstructions: "Caller taskhidden", + expected: nil + ), + Case( + name: "unmatched closing tag", + effectivePrompt: "Discovery output", + usedAgentOutputAsPrompt: true, + callerInstructions: "Caller task", + expected: nil + ), + Case( + name: "nested blocks", + effectivePrompt: "Discovery output", + usedAgentOutputAsPrompt: true, + callerInstructions: "outerinner", + expected: nil + ), + Case( + name: "case variant", + effectivePrompt: "Discovery output", + usedAgentOutputAsPrompt: true, + callerInstructions: "hidden", + expected: nil + ), + Case( + name: "attribute variant", + effectivePrompt: "Discovery output", + usedAgentOutputAsPrompt: true, + callerInstructions: "hidden", + expected: nil + ), + Case( + name: "self-closing variant", + effectivePrompt: "Discovery output", + usedAgentOutputAsPrompt: true, + callerInstructions: "Caller task", + expected: nil + ), + Case( + name: "quoted greater-than before reserved attribute text", + effectivePrompt: "Discovery output", + usedAgentOutputAsPrompt: true, + callerInstructions: "discovery_agent-guidelines: SECRET\">Caller task", + expected: nil + ), + Case( + name: "exact guideline pair embedded in attribute", + effectivePrompt: "Discovery output", + usedAgentOutputAsPrompt: true, + callerInstructions: "hidden\">Caller task", + expected: nil + ), + Case( + name: "reserved name in ordinary prose", + effectivePrompt: "Discovery output", + usedAgentOutputAsPrompt: true, + callerInstructions: "Explain discovery_agent-guidelines behavior.", + expected: "Explain discovery_agent-guidelines behavior." + ) + ] + + for testCase in cases { + XCTAssertEqual( + ContextBuilderTypedPromptResolver.resolve( + effectivePrompt: testCase.effectivePrompt, + usedAgentOutputAsPrompt: testCase.usedAgentOutputAsPrompt, + callerInstructions: testCase.callerInstructions + ), + testCase.expected, + testCase.name + ) + } + + let disposition = MCPContextBuilderToolProvider.responseDisposition( + responseType: .plan, + terminalDisposition: .completed, + usedAgentOutputAsPrompt: true, + effectivePrompt: "Discovery output", + callerInstructions: "Shared caller task" + ) + guard case let .generate(mode, prompt) = disposition else { + return XCTFail("Expected plan to generate from caller authority") + } + XCTAssertEqual(mode, .plan) + XCTAssertEqual(prompt, "Shared caller task") + } + func testResponseDispositionRoutesRequestedModesAndFailsClosedOnMissingFollowUpState() { func assertGenerates( _ responseType: ContextBuilderResponseType, @@ -393,12 +678,14 @@ final class ContextBuilderMCPProgressTimelineTests: XCTestCase { responseType: responseType, terminalDisposition: .completed, usedAgentOutputAsPrompt: false, - effectivePrompt: "Committed prompt" + effectivePrompt: "Committed prompt", + callerInstructions: "" ) - guard case let .generate(mode) = disposition else { + guard case let .generate(mode, prompt) = disposition else { return XCTFail("Expected generate for \(responseType.rawValue)", file: file, line: line) } XCTAssertEqual(mode.mcpModeName, expectedMode, file: file, line: line) + XCTAssertEqual(prompt, "Committed prompt", file: file, line: line) } assertGenerates(.plan, expectedMode: "plan") @@ -410,7 +697,8 @@ final class ContextBuilderMCPProgressTimelineTests: XCTestCase { responseType: responseType, terminalDisposition: .completed, usedAgentOutputAsPrompt: false, - effectivePrompt: "Committed prompt" + effectivePrompt: "Committed prompt", + callerInstructions: "unused" ) else { return XCTFail("Context-only response type must not generate a follow-up") } @@ -424,27 +712,30 @@ final class ContextBuilderMCPProgressTimelineTests: XCTestCase { responseType: .plan, terminalDisposition: terminalDisposition, usedAgentOutputAsPrompt: false, - effectivePrompt: "Committed prompt" + effectivePrompt: "Committed prompt", + callerInstructions: "unused" ) else { return XCTFail("Failed or cancelled discovery must preserve its terminal result") } } - guard case let .failed(directResponseError) = MCPContextBuilderToolProvider.responseDisposition( + guard case let .failed(missingCallerPromptError) = MCPContextBuilderToolProvider.responseDisposition( responseType: .plan, terminalDisposition: .completed, usedAgentOutputAsPrompt: true, - effectivePrompt: "Agent output" + effectivePrompt: "Agent output", + callerInstructions: "hidden" ) else { - return XCTFail("Untyped agent output must not silently satisfy a requested response") + return XCTFail("Discovery output must not silently satisfy a requested response") } - XCTAssertTrue(directResponseError.contains("typed direct response")) + XCTAssertTrue(missingCallerPromptError.contains("without a prompt")) guard case let .failed(emptyPromptError) = MCPContextBuilderToolProvider.responseDisposition( responseType: .review, terminalDisposition: .completed, usedAgentOutputAsPrompt: false, - effectivePrompt: " \n" + effectivePrompt: " \n", + callerInstructions: "" ) else { return XCTFail("Empty committed prompt must fail a requested response") } @@ -564,11 +855,17 @@ final class ContextBuilderMCPProgressTimelineTests: XCTestCase { } private final class ContextBuilderImmediateCompletionProvider: HeadlessAgentProvider { + private let discoveryInputRecorder: ContextBuilderDiscoveryInputRecorder? + + init(discoveryInputRecorder: ContextBuilderDiscoveryInputRecorder? = nil) { + self.discoveryInputRecorder = discoveryInputRecorder + } + func streamAgentMessage( _ message: AgentMessage, runID: UUID? ) async throws -> AsyncThrowingStream { - _ = message + await discoveryInputRecorder?.record(message.userMessage) let stream = AsyncThrowingStream { continuation in continuation.yield(AIStreamResult(type: "content", text: "Discovery complete")) continuation.finish() @@ -582,6 +879,18 @@ private final class ContextBuilderImmediateCompletionProvider: HeadlessAgentProv func dispose() async {} } +private actor ContextBuilderDiscoveryInputRecorder { + private var inputs: [String] = [] + + func record(_ input: String) { + inputs.append(input) + } + + func snapshot() -> [String] { + inputs + } +} + private actor ContextBuilderProviderStageRecorder { struct Entry { let stage: String From 97efadc788ee203cbd9d0ca1d96cc10ba3dbca2d Mon Sep 17 00:00:00 2001 From: MP Date: Mon, 13 Jul 2026 01:56:08 +0800 Subject: [PATCH 2/3] test: stabilize context builder snapshot regression (#526) --- .../ContextBuilderAgentViewModel.swift | 30 ++++++++++++-- ...ntextBuilderMCPProgressTimelineTests.swift | 40 ++++++++++++++++++- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/Sources/RepoPrompt/Features/ContextBuilder/ViewModels/ContextBuilderAgentViewModel.swift b/Sources/RepoPrompt/Features/ContextBuilder/ViewModels/ContextBuilderAgentViewModel.swift index 90859ceec..a22bc3d48 100644 --- a/Sources/RepoPrompt/Features/ContextBuilder/ViewModels/ContextBuilderAgentViewModel.swift +++ b/Sources/RepoPrompt/Features/ContextBuilder/ViewModels/ContextBuilderAgentViewModel.swift @@ -433,6 +433,11 @@ final class ContextBuilderAgentViewModel: ObservableObject { let allowSyntheticRoutingWithoutFinalContext: Bool let resolveMCPFollowUpModel: ((_ mode: String) async throws -> MCPFollowUpModelSelection)? let runMCPFollowUp: MCPFollowUpRunner? + /// Captures the exact committed tab snapshot at the commit seam so tests can assert the + /// authoritative provider provenance instead of the racy live compose-tab UI projection. + let committedTabSnapshotCaptured: ( + (_ runID: UUID, _ snapshot: MCPServerViewModel.ContextBuilderCommittedTabSnapshot) -> Void + )? init( beforeProcessingProviderEvent: ((_ result: AIStreamResult, _ runID: UUID) async -> Void)?, @@ -440,7 +445,10 @@ final class ContextBuilderAgentViewModel: ObservableObject { teardownCompleted: ((_ runID: UUID) -> Void)?, allowSyntheticRoutingWithoutFinalContext: Bool = false, resolveMCPFollowUpModel: ((_ mode: String) async throws -> MCPFollowUpModelSelection)? = nil, - runMCPFollowUp: MCPFollowUpRunner? = nil + runMCPFollowUp: MCPFollowUpRunner? = nil, + committedTabSnapshotCaptured: ( + (_ runID: UUID, _ snapshot: MCPServerViewModel.ContextBuilderCommittedTabSnapshot) -> Void + )? = nil ) { self.beforeProcessingProviderEvent = beforeProcessingProviderEvent self.providerEventDisposition = providerEventDisposition @@ -448,6 +456,7 @@ final class ContextBuilderAgentViewModel: ObservableObject { self.allowSyntheticRoutingWithoutFinalContext = allowSyntheticRoutingWithoutFinalContext self.resolveMCPFollowUpModel = resolveMCPFollowUpModel self.runMCPFollowUp = runMCPFollowUp + self.committedTabSnapshotCaptured = committedTabSnapshotCaptured } } @@ -2761,6 +2770,21 @@ final class ContextBuilderAgentViewModel: ObservableObject { return matches } + /// Retains the exact committed tab snapshot on the owning run record and, in DEBUG, notifies + /// the test capture hook at the precise commit seam. Both the synthetic-routing and real + /// final-context commits route through here so the observable commit provenance is identical + /// across paths. Returns false without notifying when retention fails. + private func retainCommittedTabSnapshot( + _ snapshot: MCPServerViewModel.ContextBuilderCommittedTabSnapshot, + on record: ContextBuilderRunRecord + ) -> Bool { + guard record.installCommittedTabSnapshot(snapshot) else { return false } + #if DEBUG + runTestHooks?.committedTabSnapshotCaptured?(record.runID, snapshot) + #endif + return true + } + private func commitTabContextForAgent( record: ContextBuilderRunRecord ) async -> MCPServerViewModel.ContextBuilderTabContextCommitResult { @@ -2832,7 +2856,7 @@ final class ContextBuilderAgentViewModel: ObservableObject { ), usedAgentOutputAsPrompt: usedAgentOutputAsPrompt ) - guard record.installCommittedTabSnapshot(snapshot) else { + guard retainCommittedTabSnapshot(snapshot, on: record) else { return MCPServerViewModel.ContextBuilderTabContextCommitResult( outcome: .failed("Context Builder could not retain its committed tab snapshot."), committedTab: nil @@ -3008,7 +3032,7 @@ final class ContextBuilderAgentViewModel: ObservableObject { #endif guard result.outcome == .committed, let committedTab = result.committedTab, - record.installCommittedTabSnapshot(committedTab) + retainCommittedTabSnapshot(committedTab, on: record) else { return false } record.session.usedAgentOutputAsPrompt = committedTab.usedAgentOutputAsPrompt return activeAgentRuns.contains(runID) && acceptsEvents(from: record) diff --git a/Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift b/Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift index ecf890b0b..23775eaa2 100644 --- a/Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift +++ b/Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift @@ -435,6 +435,7 @@ final class ContextBuilderMCPProgressTimelineTests: XCTestCase { defer { window.mcpServer.installStageProgressSinkForTesting(nil) } let followUpRecorder = ContextBuilderFollowUpInvocationRecorder() + let committedSnapshotRecorder = ContextBuilderCommittedSnapshotRecorder() window.contextBuilderAgentViewModel.installRunTestHooks( ContextBuilderAgentViewModel.RunTestHooks( beforeProcessingProviderEvent: nil, @@ -455,6 +456,9 @@ final class ContextBuilderMCPProgressTimelineTests: XCTestCase { response: "deterministic provider follow-up", errors: nil ) + }, + committedTabSnapshotCaptured: { runID, snapshot in + committedSnapshotRecorder.record(runID: runID, snapshot: snapshot) } ) ) @@ -505,8 +509,23 @@ final class ContextBuilderMCPProgressTimelineTests: XCTestCase { } XCTAssertEqual(resultObject["prompt"]?.stringValue, expectedPrompt) - let storedTab = try XCTUnwrap(window.workspaceManager.composeTab(for: tabIdentity)) - XCTAssertEqual(storedTab.promptText, "Discovery complete") + // The MCP path's authoritative promptText contract is the immutable committed provider + // snapshot, which the tool provider itself consumes as `resultTab`. The live compose tab + // is only a non-authoritative active-tab UI projection here: it can be overwritten by an + // empty prompt-editor snapshot after the silent stored-only commit, so reading it back is + // racy. Assert the committed provenance captured at the commit seam instead. + let committedCaptures = committedSnapshotRecorder.snapshotAll() + XCTAssertEqual( + committedCaptures.count, + 1, + "Exactly one committed tab snapshot should be retained for the run" + ) + let committed = try XCTUnwrap(committedCaptures.first) + XCTAssertEqual(committed.runID, committed.snapshot.nestedRunID) + XCTAssertEqual(committed.snapshot.identity, tabIdentity) + XCTAssertEqual(committed.snapshot.tab.id, tabID) + XCTAssertEqual(committed.snapshot.tab.promptText, "Discovery complete") + XCTAssertTrue(committed.snapshot.usedAgentOutputAsPrompt) #else throw XCTSkip("Provider-path Context Builder injection is DEBUG-only.") #endif @@ -926,6 +945,23 @@ private actor ContextBuilderFollowUpInvocationRecorder { } } +private final class ContextBuilderCommittedSnapshotRecorder: @unchecked Sendable { + private let lock = NSLock() + private var captures: [(runID: UUID, snapshot: MCPServerViewModel.ContextBuilderCommittedTabSnapshot)] = [] + + func record(runID: UUID, snapshot: MCPServerViewModel.ContextBuilderCommittedTabSnapshot) { + lock.lock() + defer { lock.unlock() } + captures.append((runID: runID, snapshot: snapshot)) + } + + func snapshotAll() -> [(runID: UUID, snapshot: MCPServerViewModel.ContextBuilderCommittedTabSnapshot)] { + lock.lock() + defer { lock.unlock() } + return captures + } +} + private final class ContextBuilderProgressTestClock: @unchecked Sendable { private let lock = NSLock() private var value: TimeInterval = 0 From bd15a1e31c2a287478c9527bc953ee638daaf1df Mon Sep 17 00:00:00 2001 From: MP Date: Mon, 13 Jul 2026 08:23:15 +0800 Subject: [PATCH 3/3] fix: clarify context builder prompt diagnostics (#526) --- .../MCPContextBuilderToolProvider.swift | 100 +++++++++++++++--- ...ntextBuilderMCPProgressTimelineTests.swift | 86 +++++++++++---- 2 files changed, 150 insertions(+), 36 deletions(-) diff --git a/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPContextBuilderToolProvider.swift b/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPContextBuilderToolProvider.swift index b232dced5..531d91a80 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPContextBuilderToolProvider.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPContextBuilderToolProvider.swift @@ -96,28 +96,65 @@ enum ContextBuilderResponseDisposition { case failed(String) } +/// Typed-prompt resolution outcome for a completed Context Builder run. `resolved` carries the prompt +/// handed to the follow-up model; each failure case names a distinct reason the provider can report +/// safely, without echoing withheld caller instructions or discovery-guideline content. +enum ContextBuilderTypedPromptResolution: Equatable { + case resolved(String) + /// The request carried no caller task or context and no reusable committed prompt existed. + case missingCallerTask + /// Caller instructions consisted only of discovery guidelines, which are withheld from the follow-up model. + case discoveryGuidelinesOnly + /// Caller instructions used unsupported or malformed discovery-guideline markup, so resolution failed closed. + case malformedDiscoveryMarkup + /// Only copied discovery output was committed and no independent caller task exists to answer. + case onlyCopiedDiscoveryOutput +} + enum ContextBuilderTypedPromptResolver { private static let openingTag = "" private static let closingTag = "" private static let reservedName = "discovery_agent-guidelines" + /// Outcome of stripping reserved discovery-guideline markup from caller instructions. The empty and + /// guidelines-only cases are kept distinct so resolution can tell "caller supplied nothing" apart from + /// "caller supplied only withheld guidelines" without re-parsing. + private enum SanitizedCallerInstructions { + case prompt(String) + case empty + case onlyGuidelines + case malformed + } + static func resolve( effectivePrompt: String, usedAgentOutputAsPrompt: Bool, callerInstructions: String - ) -> String? { + ) -> ContextBuilderTypedPromptResolution { if !usedAgentOutputAsPrompt, !effectivePrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return effectivePrompt + return .resolved(effectivePrompt) + } + switch sanitizeCallerInstructions(callerInstructions) { + case let .prompt(prompt): + return .resolved(prompt) + case .malformed: + return .malformedDiscoveryMarkup + case .onlyGuidelines: + return .discoveryGuidelinesOnly + case .empty: + // A committed prompt flagged as copied discovery output is deliberately not reused, so an empty + // caller fallback means only that copied output exists; otherwise no caller task was provided at all. + return usedAgentOutputAsPrompt ? .onlyCopiedDiscoveryOutput : .missingCallerTask } - return sanitizeCallerInstructions(callerInstructions) } - private static func sanitizeCallerInstructions(_ instructions: String) -> String? { + private static func sanitizeCallerInstructions(_ instructions: String) -> SanitizedCallerInstructions { var sanitized = "" var cursor = instructions.startIndex var insideGuidelines = false + var removedGuidelines = false while cursor < instructions.endIndex { guard instructions[cursor] == "<" else { @@ -131,7 +168,7 @@ enum ContextBuilderTypedPromptResolver { guard let closingAngle = closingAngleIndex(in: instructions, after: cursor) else { let remainder = instructions[cursor...] guard remainder.range(of: reservedName, options: .caseInsensitive) == nil else { - return nil + return .malformed } if !insideGuidelines { sanitized.append(contentsOf: remainder) @@ -142,15 +179,16 @@ enum ContextBuilderTypedPromptResolver { let afterTag = instructions.index(after: closingAngle) let tag = instructions[cursor ..< afterTag] if tag == openingTag { - guard !insideGuidelines else { return nil } + guard !insideGuidelines else { return .malformed } insideGuidelines = true + removedGuidelines = true } else if tag == closingTag { - guard insideGuidelines else { return nil } + guard insideGuidelines else { return .malformed } insideGuidelines = false } else { // Quote-aware tokenization keeps reserved markup inside attributes from becoming removable blocks. guard tag.range(of: reservedName, options: .caseInsensitive) == nil else { - return nil + return .malformed } if !insideGuidelines { sanitized.append(contentsOf: tag) @@ -159,9 +197,12 @@ enum ContextBuilderTypedPromptResolver { cursor = afterTag } - guard !insideGuidelines else { return nil } + guard !insideGuidelines else { return .malformed } let result = sanitized.trimmingCharacters(in: .whitespacesAndNewlines) - return result.isEmpty ? nil : result + if !result.isEmpty { + return .prompt(result) + } + return removedGuidelines ? .onlyGuidelines : .empty } private static func closingAngleIndex( @@ -756,14 +797,34 @@ final class MCPContextBuilderToolProvider: MCPWindowToolProviding { case .cancelled, .failed: return .contextOnly case .completed: - guard let prompt = ContextBuilderTypedPromptResolver.resolve( + let prompt: String + switch ContextBuilderTypedPromptResolver.resolve( effectivePrompt: effectivePrompt, usedAgentOutputAsPrompt: usedAgentOutputAsPrompt, callerInstructions: callerInstructions - ) else { - return .failed( - "Context Builder completed without a prompt for the requested \(responseType?.rawValue ?? "response")" - ) + ) { + case let .resolved(resolvedPrompt): + prompt = resolvedPrompt + case .missingCallerTask: + return .failed(typedPromptFailure( + responseType, + reason: "the request included no caller task or context to answer" + )) + case .onlyCopiedDiscoveryOutput: + return .failed(typedPromptFailure( + responseType, + reason: "only copied discovery output is available and no independent caller task was provided" + )) + case .discoveryGuidelinesOnly: + return .failed(typedPromptFailure( + responseType, + reason: "the caller instructions contained only discovery guidelines, which are withheld from the follow-up model" + )) + case .malformedDiscoveryMarkup: + return .failed(typedPromptFailure( + responseType, + reason: "the caller instructions used unsupported or malformed discovery-guideline markup" + )) } guard let mode = responseType?.headlessMode else { return .failed("Context Builder requested response mode is unavailable") @@ -772,6 +833,15 @@ final class MCPContextBuilderToolProvider: MCPWindowToolProviding { } } + /// Composes a safe typed-prompt failure message. `reason` names why resolution failed without echoing + /// withheld caller instructions or discovery-guideline content; "without a prompt" stays the stable stem. + private nonisolated static func typedPromptFailure( + _ responseType: ContextBuilderResponseType?, + reason: String + ) -> String { + "Context Builder completed without a prompt for the requested \(responseType?.rawValue ?? "response"): \(reason)" + } + private static func withHeartbeat( connectionID: UUID?, tool: String, diff --git a/Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift b/Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift index 23775eaa2..bb63b509b 100644 --- a/Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift +++ b/Tests/RepoPromptTests/ContextBuilder/ContextBuilderMCPProgressTimelineTests.swift @@ -537,7 +537,7 @@ final class ContextBuilderMCPProgressTimelineTests: XCTestCase { let effectivePrompt: String let usedAgentOutputAsPrompt: Bool let callerInstructions: String - let expected: String? + let expected: ContextBuilderTypedPromptResolution } let cases = [ @@ -546,14 +546,14 @@ final class ContextBuilderMCPProgressTimelineTests: XCTestCase { effectivePrompt: "Committed prompt", usedAgentOutputAsPrompt: false, callerInstructions: "unused", - expected: "Committed prompt" + expected: .resolved("Committed prompt") ), Case( name: "plain caller instructions", effectivePrompt: "Discovery output", usedAgentOutputAsPrompt: true, callerInstructions: "Caller task", - expected: "Caller task" + expected: .resolved("Caller task") ), Case( name: "exact lowercase block removed", @@ -564,11 +564,11 @@ final class ContextBuilderMCPProgressTimelineTests: XCTestCase { hidden Caller context """, - expected: """ + expected: .resolved(""" Caller task Caller context - """ + """) ), Case( name: "repeated sibling blocks removed", @@ -579,84 +579,98 @@ final class ContextBuilderMCPProgressTimelineTests: XCTestCase { Caller task second """, - expected: "Caller task" + expected: .resolved("Caller task") ), Case( name: "blank caller instructions", effectivePrompt: "", usedAgentOutputAsPrompt: false, callerInstructions: " \n", - expected: nil + expected: .missingCallerTask ), Case( name: "guidelines only", effectivePrompt: "Discovery output", usedAgentOutputAsPrompt: true, callerInstructions: "hidden", - expected: nil + expected: .discoveryGuidelinesOnly ), Case( name: "unmatched opening tag", effectivePrompt: "Discovery output", usedAgentOutputAsPrompt: true, callerInstructions: "Caller taskhidden", - expected: nil + expected: .malformedDiscoveryMarkup ), Case( name: "unmatched closing tag", effectivePrompt: "Discovery output", usedAgentOutputAsPrompt: true, callerInstructions: "Caller task", - expected: nil + expected: .malformedDiscoveryMarkup ), Case( name: "nested blocks", effectivePrompt: "Discovery output", usedAgentOutputAsPrompt: true, callerInstructions: "outerinner", - expected: nil + expected: .malformedDiscoveryMarkup ), Case( name: "case variant", effectivePrompt: "Discovery output", usedAgentOutputAsPrompt: true, callerInstructions: "hidden", - expected: nil + expected: .malformedDiscoveryMarkup ), Case( name: "attribute variant", effectivePrompt: "Discovery output", usedAgentOutputAsPrompt: true, callerInstructions: "hidden", - expected: nil + expected: .malformedDiscoveryMarkup ), Case( name: "self-closing variant", effectivePrompt: "Discovery output", usedAgentOutputAsPrompt: true, callerInstructions: "Caller task", - expected: nil + expected: .malformedDiscoveryMarkup ), Case( name: "quoted greater-than before reserved attribute text", effectivePrompt: "Discovery output", usedAgentOutputAsPrompt: true, callerInstructions: "discovery_agent-guidelines: SECRET\">Caller task", - expected: nil + expected: .malformedDiscoveryMarkup ), Case( name: "exact guideline pair embedded in attribute", effectivePrompt: "Discovery output", usedAgentOutputAsPrompt: true, callerInstructions: "hidden\">Caller task", - expected: nil + expected: .malformedDiscoveryMarkup ), Case( name: "reserved name in ordinary prose", effectivePrompt: "Discovery output", usedAgentOutputAsPrompt: true, callerInstructions: "Explain discovery_agent-guidelines behavior.", - expected: "Explain discovery_agent-guidelines behavior." + expected: .resolved("Explain discovery_agent-guidelines behavior.") + ), + Case( + name: "empty committed prompt falls back to caller instructions", + effectivePrompt: "", + usedAgentOutputAsPrompt: false, + callerInstructions: "Caller task", + expected: .resolved("Caller task") + ), + Case( + name: "copied discovery output without independent caller prompt", + effectivePrompt: "Discovery output", + usedAgentOutputAsPrompt: true, + callerInstructions: "", + expected: .onlyCopiedDiscoveryOutput ) ] @@ -738,7 +752,9 @@ final class ContextBuilderMCPProgressTimelineTests: XCTestCase { } } - guard case let .failed(missingCallerPromptError) = MCPContextBuilderToolProvider.responseDisposition( + // Each completed-run failure surfaces the stable "without a prompt" stem plus a distinct, safe reason + // that never echoes the withheld caller instructions or discovery-guideline content. + guard case let .failed(guidelinesOnlyError) = MCPContextBuilderToolProvider.responseDisposition( responseType: .plan, terminalDisposition: .completed, usedAgentOutputAsPrompt: true, @@ -747,9 +763,11 @@ final class ContextBuilderMCPProgressTimelineTests: XCTestCase { ) else { return XCTFail("Discovery output must not silently satisfy a requested response") } - XCTAssertTrue(missingCallerPromptError.contains("without a prompt")) + XCTAssertTrue(guidelinesOnlyError.contains("without a prompt")) + XCTAssertTrue(guidelinesOnlyError.contains("only discovery guidelines")) + XCTAssertFalse(guidelinesOnlyError.contains("hidden")) - guard case let .failed(emptyPromptError) = MCPContextBuilderToolProvider.responseDisposition( + guard case let .failed(missingCallerTaskError) = MCPContextBuilderToolProvider.responseDisposition( responseType: .review, terminalDisposition: .completed, usedAgentOutputAsPrompt: false, @@ -758,7 +776,33 @@ final class ContextBuilderMCPProgressTimelineTests: XCTestCase { ) else { return XCTFail("Empty committed prompt must fail a requested response") } - XCTAssertTrue(emptyPromptError.contains("without a prompt")) + XCTAssertTrue(missingCallerTaskError.contains("without a prompt")) + XCTAssertTrue(missingCallerTaskError.contains("no caller task or context")) + + guard case let .failed(copiedDiscoveryError) = MCPContextBuilderToolProvider.responseDisposition( + responseType: .plan, + terminalDisposition: .completed, + usedAgentOutputAsPrompt: true, + effectivePrompt: "Agent output", + callerInstructions: "" + ) else { + return XCTFail("Copied discovery output without a caller task must fail a requested response") + } + XCTAssertTrue(copiedDiscoveryError.contains("without a prompt")) + XCTAssertTrue(copiedDiscoveryError.contains("only copied discovery output")) + + guard case let .failed(malformedMarkupError) = MCPContextBuilderToolProvider.responseDisposition( + responseType: .question, + terminalDisposition: .completed, + usedAgentOutputAsPrompt: true, + effectivePrompt: "Agent output", + callerInstructions: "Caller taskhidden" + ) else { + return XCTFail("Malformed reserved markup must fail closed for a requested response") + } + XCTAssertTrue(malformedMarkupError.contains("without a prompt")) + XCTAssertTrue(malformedMarkupError.contains("malformed discovery-guideline markup")) + XCTAssertFalse(malformedMarkupError.contains("hidden")) } @MainActor