Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Sources/CodeIsland/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
67 changes: 65 additions & 2 deletions Tests/CodeIslandTests/AppStateQuestionFlowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Data, Never> {
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<Data, Never> {
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 {
Expand Down Expand Up @@ -502,15 +562,18 @@ 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",
"tool_input": [
"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")
Expand Down