diff --git a/macos/CursorAPI/Sources/CursorAPICore/LocalAPIServer.swift b/macos/CursorAPI/Sources/CursorAPICore/LocalAPIServer.swift index 45da03a..7c0edef 100644 --- a/macos/CursorAPI/Sources/CursorAPICore/LocalAPIServer.swift +++ b/macos/CursorAPI/Sources/CursorAPICore/LocalAPIServer.swift @@ -399,7 +399,10 @@ public final class LocalAPIServer: @unchecked Sendable { usage: usage )) } - let output = try await harness.complete(prepared: prepared, settings: settings, authorization: request.header("authorization")) + var output = try await harness.complete(prepared: prepared, settings: settings, authorization: request.header("authorization")) + if let repaired = try? await repairedOutput(for: output, prepared: prepared, settings: settings, authorization: request.header("authorization")) { + output = repaired + } return try .response(withCORS(HTTPResponse.json(OpenAICompatibility.chatCompletionResponse(id: id, created: created, prepared: prepared, output: output)))) } if method == "POST", path == "/v1/responses" { @@ -585,7 +588,17 @@ public final class LocalAPIServer: @unchecked Sendable { let bufferTextUntilToolDecision = shouldBufferTextUntilToolDecision(prepared) var emittedText = "" var emittedToolCalls: [CursorToolCall] = [] + var emittedToolCallChunks = 0 var finalOutput: CursorSDKOutput? + // A tool call that cannot be mapped to a client tool produces + // no chunk. Counting it as emitted would close the stream with + // finish_reason tool_calls and nothing in it. + let yieldToolCall: (CursorToolCall, Int) -> Bool = { toolCall, index in + let chunk = OpenAICompatibility.chatCompletionStreamToolCall(id: id, created: created, prepared: prepared, toolCall: toolCall, index: index) + guard !chunk.isEmpty else { return false } + continuation.yield(chunk) + return true + } for try await event in harness.stream(prepared: prepared, settings: settings, authorization: authorization) { switch event { @@ -595,35 +608,49 @@ public final class LocalAPIServer: @unchecked Sendable { continuation.yield(OpenAICompatibility.chatCompletionStreamText(id: id, created: created, model: prepared.model, delta: delta)) } case .toolCall(let toolCall): - let index = emittedToolCalls.count emittedToolCalls.append(toolCall) - continuation.yield(OpenAICompatibility.chatCompletionStreamToolCall(id: id, created: created, prepared: prepared, toolCall: toolCall, index: index)) + if yieldToolCall(toolCall, emittedToolCallChunks) { + emittedToolCallChunks += 1 + } case .done(let output): finalOutput = output } } - let output = resolvedOutput(finalOutput: finalOutput, emittedText: emittedText, emittedToolCalls: emittedToolCalls) + var output = resolvedOutput(finalOutput: finalOutput, emittedText: emittedText, emittedToolCalls: emittedToolCalls) + if emittedToolCallChunks == 0, + let repaired = try? await repairedOutput(for: output, prepared: prepared, settings: settings, authorization: authorization) { + output = repaired + } usage?.set(Self.usage(fromObject: OpenAICompatibility.chatCompletionResponse(id: id, created: created, prepared: prepared, output: output))) - let shouldEmitText = output.toolCalls.isEmpty + if output.toolCalls.count > emittedToolCalls.count { + for toolCall in output.toolCalls.dropFirst(emittedToolCalls.count) { + if yieldToolCall(toolCall, emittedToolCallChunks) { + emittedToolCallChunks += 1 + } + } + } + let shouldEmitText = emittedToolCallChunks == 0 if shouldEmitText, output.text.count > emittedText.count, output.text.hasPrefix(emittedText) { let suffix = String(output.text.dropFirst(emittedText.count)) continuation.yield(OpenAICompatibility.chatCompletionStreamText(id: id, created: created, model: prepared.model, delta: suffix)) emittedText += suffix } else if shouldEmitText, (bufferTextUntilToolDecision || emittedText.isEmpty), !output.text.isEmpty { continuation.yield(OpenAICompatibility.chatCompletionStreamText(id: id, created: created, model: prepared.model, delta: output.text)) + emittedText = output.text } - if output.toolCalls.count > emittedToolCalls.count { - for (offset, toolCall) in output.toolCalls.dropFirst(emittedToolCalls.count).enumerated() { - let index = emittedToolCalls.count + offset - continuation.yield(OpenAICompatibility.chatCompletionStreamToolCall(id: id, created: created, prepared: prepared, toolCall: toolCall, index: index)) - } + // Last line of defence: never close a stream that carries + // neither content nor a usable tool call. + if emittedToolCallChunks == 0, emittedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let fallback = OpenAICompatibility.nonEmptyAssistantText(prepared: prepared, output: output) + continuation.yield(OpenAICompatibility.chatCompletionStreamText(id: id, created: created, model: prepared.model, delta: fallback)) + emittedText = fallback } continuation.yield(OpenAICompatibility.chatCompletionStreamFinish( id: id, created: created, model: prepared.model, - emittedToolCallCount: max(emittedToolCalls.count, output.toolCalls.count) + emittedToolCallCount: emittedToolCallChunks )) if prepared.streamIncludeUsage { continuation.yield(OpenAICompatibility.chatCompletionStreamUsage(id: id, created: created, prepared: prepared, output: output)) @@ -757,6 +784,29 @@ public final class LocalAPIServer: @unchecked Sendable { } } + /// One extra upstream run when the model called a tool the client cannot + /// execute, telling it which tools actually exist. Returns nil when a repair + /// is not warranted or did not improve on the original output. + private func repairedOutput( + for output: CursorSDKOutput, + prepared: PreparedChatRequest, + settings: CursorAPISettings, + authorization: String? + ) async throws -> CursorSDKOutput? { + guard OpenAICompatibility.needsToolCallRepair(output, prepared: prepared) else { return nil } + let attempted = OpenAICompatibility.unresolvableToolCallNames(output.toolCalls, prepared: prepared) + guard let repairedRequest = OpenAICompatibility.repairedRequest(prepared, attemptedToolNames: attempted) else { return nil } + let retried = try await harness.complete(prepared: repairedRequest, settings: settings, authorization: authorization) + if !retried.toolCalls.isEmpty, + OpenAICompatibility.unresolvableToolCallNames(retried.toolCalls, prepared: prepared).count < retried.toolCalls.count { + return retried + } + if retried.toolCalls.isEmpty, !retried.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return retried + } + return nil + } + private func resolvedOutput( finalOutput: CursorSDKOutput?, emittedText: String, diff --git a/macos/CursorAPI/Sources/CursorAPICore/OpenAICompatibility.swift b/macos/CursorAPI/Sources/CursorAPICore/OpenAICompatibility.swift index 574da44..48a64fb 100644 --- a/macos/CursorAPI/Sources/CursorAPICore/OpenAICompatibility.swift +++ b/macos/CursorAPI/Sources/CursorAPICore/OpenAICompatibility.swift @@ -627,6 +627,61 @@ public enum OpenAICompatibility { ] } + /// Names of SDK tool calls that cannot be expressed as one of the client's + /// declared tools. Forwarding them as `tool_calls` would be rejected and + /// dropping them silently leaves an empty assistant turn, so callers use + /// this to decide between a repair run and a text fallback. + public static func unresolvableToolCallNames(_ toolCalls: [CursorToolCall], prepared: PreparedChatRequest) -> [String] { + toolCalls.compactMap { toolCall in + guard resolveToolCall(toolCall, tools: prepared.tools, context: prepared.toolContext) == nil else { return nil } + return normalizeSDKToolCall(toolCall).name + } + } + + /// True when every tool call the model produced was unmappable, which is the + /// only case worth spending a second upstream run on. + public static func needsToolCallRepair(_ output: CursorSDKOutput, prepared: PreparedChatRequest) -> Bool { + guard !output.toolCalls.isEmpty else { return false } + return unresolvableToolCallNames(output.toolCalls, prepared: prepared).count == output.toolCalls.count + } + + /// A copy of the request with a hint naming the client's actual tools, for + /// one retry after the model reached for a tool that does not exist here. + public static func repairedRequest(_ prepared: PreparedChatRequest, attemptedToolNames: [String]) -> PreparedChatRequest? { + guard !prepared.tools.isEmpty else { return nil } + let attempted = attemptedToolNames.filter { !$0.isEmpty } + let attemptedList = attempted.isEmpty ? "a tool" : attempted.map { "\"\($0)\"" }.joined(separator: ", ") + let hint = [ + "", + "TOOL CALL NOT AVAILABLE:", + "Your last turn called \(attemptedList), which the outer client cannot execute.", + "Call one of these client tools instead, by exact name: \(prepared.tools.map(\.name).joined(separator: ", ")).", + "Emit exactly one client tool call with arguments matching that tool's schema, and no prose." + ].joined(separator: "\n") + var repaired = prepared + repaired.prompt += hint + // The bridge reuses `incrementalPrompt` whenever it already has a warm + // agent for this session, so the hint has to be present in both. + repaired.incrementalPrompt = (prepared.incrementalPrompt ?? prepared.prompt) + hint + repaired.promptCharacters = repaired.prompt.count + return repaired + } + + /// Assistant text that is never empty. Clients treat a turn with no content + /// and no usable tool call as a provider failure, so the model's narration is + /// used when present and an explanation otherwise. + public static func nonEmptyAssistantText(prepared: PreparedChatRequest, output: CursorSDKOutput) -> String { + if !output.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return output.text + } + let attempted = unresolvableToolCallNames(output.toolCalls, prepared: prepared).filter { !$0.isEmpty } + if !attempted.isEmpty { + let names = attempted.map { "`\($0)`" }.joined(separator: ", ") + return "I tried to use \(names), which is not one of the tools available here, so nothing was run. Tell me to retry and I will use an available tool." + } + return "The upstream model returned no content for this turn. Nothing was run. Retrying usually resolves it." + } + public static func chatCompletionResponse( id: String, created: Int, @@ -634,7 +689,7 @@ public enum OpenAICompatibility { output: CursorSDKOutput ) -> [String: Any] { let toolCalls = toOpenAIToolCalls(output.toolCalls, tools: prepared.tools, responseID: id, context: prepared.toolContext) - let content: Any = toolCalls.isEmpty ? output.text : NSNull() + let content: Any = toolCalls.isEmpty ? nonEmptyAssistantText(prepared: prepared, output: output) : NSNull() return [ "id": id, "object": "chat.completion", diff --git a/macos/CursorAPI/Tests/CursorAPITests/LocalAPIServerTests.swift b/macos/CursorAPI/Tests/CursorAPITests/LocalAPIServerTests.swift index 2b944f2..fe876cf 100644 --- a/macos/CursorAPI/Tests/CursorAPITests/LocalAPIServerTests.swift +++ b/macos/CursorAPI/Tests/CursorAPITests/LocalAPIServerTests.swift @@ -10465,6 +10465,183 @@ final class LocalAPIServerTests: XCTestCase { XCTAssertFalse(text.contains("event: response.content_part.added")) XCTAssertFalse(text.contains(#""type":"message""#)) } + + private func preparedRequestWithReadTool() throws -> PreparedChatRequest { + try OpenAICompatibility.prepareChatRequest(Data(#""" + { + "model":"composer-2.5", + "messages":[{"role":"user","content":"read src/App.tsx"}], + "tools":[ + { + "type":"function", + "function":{ + "name":"read_file", + "parameters":{ + "type":"object", + "properties":{"path":{"type":"string"}}, + "required":["path"] + } + } + } + ] + } + """#.utf8)) + } + + func testUnresolvableToolCallNamesReportsOnlyUnmappableCalls() throws { + let prepared = try preparedRequestWithReadTool() + let mappable = CursorToolCall(name: "read", arguments: ["path": .string("src/App.tsx")]) + let unmappable = CursorToolCall(name: "recordScreen", arguments: ["mode": .string("START_RECORDING")]) + + XCTAssertEqual(OpenAICompatibility.unresolvableToolCallNames([mappable], prepared: prepared), []) + XCTAssertEqual(OpenAICompatibility.unresolvableToolCallNames([unmappable], prepared: prepared), ["recordScreen"]) + } + + func testNeedsToolCallRepairOnlyWhenEveryCallIsUnmappable() throws { + let prepared = try preparedRequestWithReadTool() + let mappable = CursorToolCall(name: "read", arguments: ["path": .string("src/App.tsx")]) + let unmappable = CursorToolCall(name: "recordScreen", arguments: ["mode": .string("START_RECORDING")]) + + XCTAssertFalse(OpenAICompatibility.needsToolCallRepair( + CursorSDKOutput(text: "", toolCalls: [], agentID: "agent-test", runID: "run-test"), + prepared: prepared + )) + XCTAssertFalse(OpenAICompatibility.needsToolCallRepair( + CursorSDKOutput(text: "", toolCalls: [mappable], agentID: "agent-test", runID: "run-test"), + prepared: prepared + )) + XCTAssertTrue(OpenAICompatibility.needsToolCallRepair( + CursorSDKOutput(text: "", toolCalls: [unmappable], agentID: "agent-test", runID: "run-test"), + prepared: prepared + )) + } + + func testRepairedRequestNamesTheClientToolsInBothPrompts() throws { + let prepared = try preparedRequestWithReadTool() + let repaired = try XCTUnwrap(OpenAICompatibility.repairedRequest(prepared, attemptedToolNames: ["recordScreen"])) + + XCTAssertTrue(repaired.prompt.contains("TOOL CALL NOT AVAILABLE:")) + XCTAssertTrue(repaired.prompt.contains("\"recordScreen\"")) + XCTAssertTrue(repaired.prompt.contains("read_file")) + XCTAssertEqual(repaired.promptCharacters, repaired.prompt.count) + // The bridge prefers incrementalPrompt for warm agents, so the hint has + // to reach both prompts or the retry repeats the same mistake. + let incremental = try XCTUnwrap(repaired.incrementalPrompt) + XCTAssertTrue(incremental.contains("TOOL CALL NOT AVAILABLE:")) + } + + func testRepairedRequestIsSkippedWithoutClientTools() throws { + let prepared = try OpenAICompatibility.prepareChatRequest(Data(#""" + {"model":"composer-2.5","messages":[{"role":"user","content":"hello"}]} + """#.utf8)) + + XCTAssertNil(OpenAICompatibility.repairedRequest(prepared, attemptedToolNames: ["recordScreen"])) + } + + func testNonEmptyAssistantTextPrefersModelNarration() throws { + let prepared = try preparedRequestWithReadTool() + let output = CursorSDKOutput(text: "Reading the file now.", toolCalls: [], agentID: "agent-test", runID: "run-test") + + XCTAssertEqual( + OpenAICompatibility.nonEmptyAssistantText(prepared: prepared, output: output), + "Reading the file now." + ) + } + + func testNonEmptyAssistantTextExplainsAnUnavailableTool() throws { + let prepared = try preparedRequestWithReadTool() + let unmappable = CursorToolCall(name: "recordScreen", arguments: ["mode": .string("START_RECORDING")]) + let output = CursorSDKOutput(text: "", toolCalls: [unmappable], agentID: "agent-test", runID: "run-test") + + let text = OpenAICompatibility.nonEmptyAssistantText(prepared: prepared, output: output) + XCTAssertTrue(text.contains("recordScreen")) + XCTAssertFalse(text.isEmpty) + } + + func testChatCompletionsStreamingNeverEndsWithoutContentOrToolCall() async throws { + let port = try unusedTCPPort() + // recordScreen has no counterpart among the declared client tools, so it + // cannot be forwarded. It used to be dropped silently, leaving a stream + // that reported finish_reason tool_calls while carrying nothing at all. + let unmappable = CursorToolCall(name: "recordScreen", arguments: ["mode": .string("START_RECORDING")]) + let server = LocalAPIServer(settingsProvider: { CursorAPISettings(port: port) }, harness: MockHarness(events: [ + .toolCall(unmappable), + .done(CursorSDKOutput(text: "", toolCalls: [unmappable], agentID: "agent-test", runID: "run-test")) + ])) + try server.start(port: port) + defer { server.stop() } + try await Task.sleep(nanoseconds: 150_000_000) + + var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/v1/chat/completions")!) + request.httpMethod = "POST" + request.httpBody = Data(#""" + { + "model":"composer-2.5", + "stream":true, + "messages":[{"role":"user","content":"read src/App.tsx"}], + "tools":[{"type":"function","function":{"name":"read_file","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}}}] + } + """#.utf8) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let (data, response) = try await URLSession.shared.data(for: request) + + XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200) + let text = String(data: data, encoding: .utf8) ?? "" + XCTAssertTrue(text.contains("recordScreen")) + XCTAssertTrue(text.contains(#""finish_reason":"stop""#)) + XCTAssertFalse(text.contains(#""finish_reason":"tool_calls""#)) + } + + func testChatCompletionsStreamingStillForwardsMappableToolCalls() async throws { + let port = try unusedTCPPort() + let toolCall = CursorToolCall(name: "read", arguments: ["path": .string("src/App.tsx")]) + let server = LocalAPIServer(settingsProvider: { CursorAPISettings(port: port) }, harness: MockHarness(events: [ + .toolCall(toolCall), + .done(CursorSDKOutput(text: "", toolCalls: [toolCall], agentID: "agent-test", runID: "run-test")) + ])) + try server.start(port: port) + defer { server.stop() } + try await Task.sleep(nanoseconds: 150_000_000) + + var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/v1/chat/completions")!) + request.httpMethod = "POST" + request.httpBody = Data(#""" + { + "model":"composer-2.5", + "stream":true, + "messages":[{"role":"user","content":"read src/App.tsx"}], + "tools":[{"type":"function","function":{"name":"read_file","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}}}] + } + """#.utf8) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let (data, response) = try await URLSession.shared.data(for: request) + + XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200) + let text = String(data: data, encoding: .utf8) ?? "" + XCTAssertTrue(text.contains(#""name":"read_file""#)) + XCTAssertTrue(text.contains(#""finish_reason":"tool_calls""#)) + } + + func testChatCompletionResponseNeverReturnsEmptyContent() throws { + let prepared = try preparedRequestWithReadTool() + let unmappable = CursorToolCall(name: "recordScreen", arguments: ["mode": .string("START_RECORDING")]) + + let object = OpenAICompatibility.chatCompletionResponse( + id: "chatcmpl_test", + created: 1, + prepared: prepared, + output: CursorSDKOutput(text: "", toolCalls: [unmappable], agentID: "agent-test", runID: "run-test") + ) + + let choices = try XCTUnwrap(object["choices"] as? [[String: Any]]) + let choice = try XCTUnwrap(choices.first) + let message = try XCTUnwrap(choice["message"] as? [String: Any]) + let content = try XCTUnwrap(message["content"] as? String) + + XCTAssertFalse(content.isEmpty) + XCTAssertEqual(choice["finish_reason"] as? String, "stop") + XCTAssertEqual((message["tool_calls"] as? [[String: Any]])?.isEmpty, true) + } } private func sendResponseRequest(port: UInt16, body: String) async throws {