From 6c01d10f39795440a110390d13c626761a130301 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 10:24:54 +0000 Subject: [PATCH 1/2] test(ios): reproduce delegated thread encoding failure (#17643) Co-Authored-By: Claude Opus 5.5 --- .../CoreTests/DualOrchestrationClientTests.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/swift-ios/Tests/CoreTests/DualOrchestrationClientTests.swift b/apps/swift-ios/Tests/CoreTests/DualOrchestrationClientTests.swift index 7e3ae42b18f7c..949c1f9eac2ec 100644 --- a/apps/swift-ios/Tests/CoreTests/DualOrchestrationClientTests.swift +++ b/apps/swift-ios/Tests/CoreTests/DualOrchestrationClientTests.swift @@ -94,6 +94,18 @@ final class V2OrchestrationClientTests: XCTestCase { XCTAssertEqual(history.value(forHTTPHeaderField: "x-t3-orchestration-protocol"), "2") } + func testThreadReadEncodesLiteralPercentInThreadIDOnce() async throws { + let transport = try V2ClientHTTPTransport() + let client = makeClient(transport: transport) + _ = try? await client.threadSnapshot(id: "thread:delegated-task:command%3Amcp%3Aissue-17643") + let requests = await transport.requests + let read = try XCTUnwrap(requests.last { $0.url?.path.hasSuffix("/bounded") == true }) + XCTAssertEqual( + URLComponents(url: try XCTUnwrap(read.url), resolvingAgainstBaseURL: false)?.percentEncodedPath, + "/api/orchestration/threads/thread:delegated-task:command%253Amcp%253Aissue-17643/bounded" + ) + } + func testV2ConnectionSendsCanonicalLaunchCommand() async throws { let transport = try V2ClientHTTPTransport() let socket = V2ClientCommandSocket() From f8dadbc5f683cfad29828db7f9ab651b1228ceb6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 10:27:17 +0000 Subject: [PATCH 2/2] fix(ios): thread reads keep literal % in thread IDs (#17643) threadPath already percent-escapes the thread ID, but makeRequest assigned the result to URLComponents.path, which escaped every % again. Delegated child IDs such as command%3Amcp%3A... then 404ed. Environment request paths are now percent-encoded by contract and set via percentEncodedPath. Co-Authored-By: Claude Opus 5.5 --- apps/swift-ios/Core/HTTP.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/swift-ios/Core/HTTP.swift b/apps/swift-ios/Core/HTTP.swift index 08b714f4298fc..04335640f6a24 100644 --- a/apps/swift-ios/Core/HTTP.swift +++ b/apps/swift-ios/Core/HTTP.swift @@ -368,6 +368,8 @@ public actor EnvironmentAPI { ) } + /// `path` is percent-encoded: callers escape dynamic segments themselves so + /// an ID containing `%` or `/` stays one literal segment. func authorized( environment: Environment, path: String, @@ -504,7 +506,7 @@ public actor EnvironmentAPI { headers: [String: String] = [:] ) -> URLRequest { var request = URLRequest( - url: endpoint(environment.httpBaseURL, path: path, queryItems: queryItems) + url: endpoint(environment.httpBaseURL, percentEncodedPath: path, queryItems: queryItems) ) request.httpMethod = method request.httpBody = body @@ -723,3 +725,11 @@ func endpoint(_ baseURL: URL, path: String, queryItems: [URLQueryItem] = []) -> components.fragment = nil return components.url! } + +func endpoint(_ baseURL: URL, percentEncodedPath: String, queryItems: [URLQueryItem] = []) -> URL { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + components.percentEncodedPath = percentEncodedPath + components.queryItems = queryItems.isEmpty ? nil : queryItems + components.fragment = nil + return components.url! +}