From b8f1447187dbc1d6118fd80b60e9d1e3008a5946 Mon Sep 17 00:00:00 2001 From: riChar Date: Wed, 5 Aug 2026 12:38:57 +0800 Subject: [PATCH] fix(qoder): omit scalar answer key for AskUserQuestion strict schema --- Sources/CodeIsland/AppState.swift | 11 ++- .../AppStateQuestionFlowTests.swift | 67 ++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/Sources/CodeIsland/AppState.swift b/Sources/CodeIsland/AppState.swift index d3715c7f..5adafb72 100644 --- a/Sources/CodeIsland/AppState.swift +++ b/Sources/CodeIsland/AppState.swift @@ -1385,6 +1385,11 @@ final class AppState { SessionSnapshot.normalizedSupportedSource(event.rawJSON["_source"] as? String) == "zcode" } + nonisolated static func isQoderEvent(_ event: HookEvent) -> Bool { + guard let source = SessionSnapshot.normalizedSupportedSource(event.rawJSON["_source"] as? String) else { return false } + return source == "qoder" || source == "qoder-cli" + } + /// "Always allow" response for a ZCode PermissionRequest hook (#258). /// /// ZCode validates hook stdout with a STRICT schema (unknown keys void the @@ -1882,7 +1887,11 @@ final class AppState { // Fall back to the raw toolInput value when the [[String:Any]] cast fails. updatedInput["questions"] = originalQuestions ?? (event.toolInput?["questions"] ?? [] as [[String: Any]]) updatedInput["answers"] = answers - if let answer { + // Qoder CLI validates updatedInput against the AskUserQuestion schema + // (additionalProperties: false, only questions/answers/annotations/ + // metadata). The scalar `answer` key fails that validation with + // "params must NOT have additional properties", so omit it there. + if let answer, !Self.isQoderEvent(event) { updatedInput["answer"] = answer } return updatedInput diff --git a/Tests/CodeIslandTests/AppStateQuestionFlowTests.swift b/Tests/CodeIslandTests/AppStateQuestionFlowTests.swift index 9143fe68..b20cbb3f 100644 --- a/Tests/CodeIslandTests/AppStateQuestionFlowTests.swift +++ b/Tests/CodeIslandTests/AppStateQuestionFlowTests.swift @@ -332,6 +332,66 @@ final class AppStateQuestionFlowTests: XCTestCase { XCTAssertEqual(fallback, .bashCommand("echo 2")) } + // MARK: - Qoder strict schema + + func testQoderAnswerOmitsScalarAnswerKey() async throws { + // Qoder CLI validates PermissionRequest updatedInput against the + // AskUserQuestion schema (additionalProperties: false). The scalar + // `answer` key trips "params must NOT have additional properties", + // so it must be omitted for qoder sources while `answers` stays. + let appState = AppState() + let event = try makeAskUserQuestionEvent( + sessionId: "s-qoder", + questions: [ + question(header: "确认", text: "继续执行吗?", options: ["继续", "停止"]), + ], + source: "qoder" + ) + + let responseTask = Task { + await withCheckedContinuation { continuation in + appState.handleAskUserQuestion(event, continuation: continuation) + } + } + + await Task.yield() + appState.answerQuestionMulti([ + (question: "继续执行吗?", answer: "继续"), + ]) + + let responseData = await responseTask.value + let updatedInput = try extractUpdatedInput(from: responseData) + XCTAssertNil(updatedInput["answer"], "qoder updatedInput must not carry the extra scalar `answer` key") + let answers = try XCTUnwrap(updatedInput["answers"] as? [String: Any]) + XCTAssertEqual(answers["继续执行吗?"] as? String, "继续") + } + + func testNonQoderAnswerKeepsScalarAnswerKey() async throws { + let appState = AppState() + let event = try makeAskUserQuestionEvent( + sessionId: "s-claude-answer-key", + questions: [ + question(header: "确认", text: "继续执行吗?", options: ["继续", "停止"]), + ], + source: "claude" + ) + + let responseTask = Task { + await withCheckedContinuation { continuation in + appState.handleAskUserQuestion(event, continuation: continuation) + } + } + + await Task.yield() + appState.answerQuestionMulti([ + (question: "继续执行吗?", answer: "继续"), + ]) + + let responseData = await responseTask.value + let updatedInput = try extractUpdatedInput(from: responseData) + XCTAssertEqual(updatedInput["answer"] as? String, "继续") + } + // MARK: - Duplicate question text dedup func testDuplicateQuestionTextGetsDedupedKeys() async throws { @@ -502,8 +562,8 @@ 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]], source: String? = nil) throws -> HookEvent { + var payload: [String: Any] = [ "hook_event_name": "PermissionRequest", "session_id": sessionId, "tool_name": "AskUserQuestion", @@ -511,6 +571,9 @@ final class AppStateQuestionFlowTests: XCTestCase { "questions": questions ] ] + if let source { + payload["_source"] = source + } let data = try JSONSerialization.data(withJSONObject: payload) guard let event = HookEvent(from: data) else { XCTFail("Failed to parse HookEvent")