Skip to content

Commit c9d0ce9

Browse files
committed
Pre-release 0.44.152
1 parent 8770b9e commit c9d0ce9

26 files changed

+494
-103
lines changed

Core/Sources/ChatService/ChatService.swift

Lines changed: 91 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ struct ToolCallRequest {
4949
let completion: (AnyJSONRPCResponse) -> Void
5050
}
5151

52+
struct ConversationTurnTrackingState {
53+
var turnParentMap: [String: String] = [:] // Maps subturn ID to parent turn ID
54+
var validConversationIds: Set<String> = [] // Tracks all valid conversation IDs including subagents
55+
56+
mutating func reset() {
57+
turnParentMap.removeAll()
58+
validConversationIds.removeAll()
59+
}
60+
}
61+
5262
public final class ChatService: ChatServiceType, ObservableObject {
5363

5464
public var memory: ContextAwareAutoManagedChatMemory
@@ -69,6 +79,9 @@ public final class ChatService: ChatServiceType, ObservableObject {
6979
private var lastUserRequest: ConversationRequest?
7080
private var isRestored: Bool = false
7181
private var pendingToolCallRequests: [String: ToolCallRequest] = [:]
82+
// Workaround: toolConfirmation request does not have parent turnId
83+
private var conversationTurnTracking = ConversationTurnTrackingState()
84+
7285
init(provider: any ConversationServiceProvider,
7386
memory: ContextAwareAutoManagedChatMemory = ContextAwareAutoManagedChatMemory(),
7487
conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared,
@@ -136,7 +149,15 @@ public final class ChatService: ChatServiceType, ObservableObject {
136149

137150
private func subscribeToClientToolConfirmationEvent() {
138151
ClientToolHandlerImpl.shared.onClientToolConfirmationEvent.sink(receiveValue: { [weak self] (request, completion) in
139-
guard let params = request.params, params.conversationId == self?.conversationId else { return }
152+
guard let params = request.params else { return }
153+
154+
// Check if this conversationId is valid (main conversation or subagent conversation)
155+
guard let validIds = self?.conversationTurnTracking.validConversationIds, validIds.contains(params.conversationId) else {
156+
return
157+
}
158+
159+
let parentTurnId = self?.conversationTurnTracking.turnParentMap[params.turnId]
160+
140161
let editAgentRounds: [AgentRound] = [
141162
AgentRound(roundId: params.roundId,
142163
reply: "",
@@ -145,7 +166,7 @@ public final class ChatService: ChatServiceType, ObservableObject {
145166
]
146167
)
147168
]
148-
self?.appendToolCallHistory(turnId: params.turnId, editAgentRounds: editAgentRounds)
169+
self?.appendToolCallHistory(turnId: params.turnId, editAgentRounds: editAgentRounds, parentTurnId: parentTurnId)
149170
self?.pendingToolCallRequests[params.toolCallId] = ToolCallRequest(
150171
requestId: request.id,
151172
turnId: params.turnId,
@@ -157,7 +178,13 @@ public final class ChatService: ChatServiceType, ObservableObject {
157178

158179
private func subscribeToClientToolInvokeEvent() {
159180
ClientToolHandlerImpl.shared.onClientToolInvokeEvent.sink(receiveValue: { [weak self] (request, completion) in
160-
guard let params = request.params, params.conversationId == self?.conversationId else { return }
181+
guard let params = request.params else { return }
182+
183+
// Check if this conversationId is valid (main conversation or subagent conversation)
184+
guard let validIds = self?.conversationTurnTracking.validConversationIds, validIds.contains(params.conversationId) else {
185+
return
186+
}
187+
161188
guard let copilotTool = CopilotToolRegistry.shared.getTool(name: params.name) else {
162189
completion(AnyJSONRPCResponse(id: request.id,
163190
result: JSONValue.array([
@@ -173,11 +200,11 @@ public final class ChatService: ChatServiceType, ObservableObject {
173200
return
174201
}
175202

176-
copilotTool.invokeTool(request, completion: completion, contextProvider: self)
203+
_ = copilotTool.invokeTool(request, completion: completion, contextProvider: self)
177204
}).store(in: &cancellables)
178205
}
179206

180-
func appendToolCallHistory(turnId: String, editAgentRounds: [AgentRound], fileEdits: [FileEdit] = []) {
207+
func appendToolCallHistory(turnId: String, editAgentRounds: [AgentRound], fileEdits: [FileEdit] = [], parentTurnId: String? = nil) {
181208
let chatTabId = self.chatTabInfo.id
182209
Task {
183210
let turnStatus: ChatMessage.TurnStatus? = {
@@ -196,6 +223,7 @@ public final class ChatService: ChatServiceType, ObservableObject {
196223
assistantMessageWithId: turnId,
197224
chatTabID: chatTabId,
198225
editAgentRounds: editAgentRounds,
226+
parentTurnId: parentTurnId,
199227
fileEdits: fileEdits,
200228
turnStatus: turnStatus
201229
)
@@ -229,75 +257,64 @@ public final class ChatService: ChatServiceType, ObservableObject {
229257
self.isRestored = true
230258
}
231259

260+
/// Updates the status of a tool call (accepted, cancelled, etc.) and notifies the server
261+
///
262+
/// This method handles two key responsibilities:
263+
/// 1. Sends confirmation response back to the server when user accepts/cancels
264+
/// 2. Updates the tool call status in chat history UI (including subagent tool calls)
232265
public func updateToolCallStatus(toolCallId: String, status: AgentToolCall.ToolCallStatus, payload: Any? = nil) {
233-
// Send the tool call result back to the server
234-
if let toolCallRequest = self.pendingToolCallRequests[toolCallId], status == .accepted || status == .cancelled {
266+
// Capture the pending request info before removing it from the dictionary
267+
let toolCallRequest = self.pendingToolCallRequests[toolCallId]
268+
269+
// Step 1: Send confirmation response to server (for accept/cancel actions only)
270+
if let toolCallRequest = toolCallRequest, status == .accepted || status == .cancelled {
235271
self.pendingToolCallRequests.removeValue(forKey: toolCallId)
236-
let toolResult = LanguageModelToolConfirmationResult(
237-
result: status == .accepted ? .Accept : .Dismiss
238-
)
239-
let jsonResult = try? JSONEncoder().encode(toolResult)
240-
let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null
241-
toolCallRequest.completion(
242-
AnyJSONRPCResponse(
243-
id: toolCallRequest.requestId,
244-
result: JSONValue.array([
245-
jsonValue,
246-
JSONValue.null
247-
])
248-
)
249-
)
272+
sendToolConfirmationResponse(toolCallRequest, accepted: status == .accepted)
250273
}
251274

252-
// Update the tool call status in the chat history
275+
// Step 2: Update the tool call status in chat history UI
253276
Task {
254-
guard let lastMessage = await memory.history.last, lastMessage.role == .assistant else {
277+
guard let targetMessage = await ToolCallStatusUpdater.findMessageContainingToolCall(
278+
toolCallRequest,
279+
conversationTurnTracking: conversationTurnTracking,
280+
history: await memory.history
281+
) else {
255282
return
256283
}
257-
258-
var updatedAgentRounds: [AgentRound] = []
259-
for i in 0..<lastMessage.editAgentRounds.count {
260-
if lastMessage.editAgentRounds[i].toolCalls == nil {
261-
continue
262-
}
263-
for j in 0..<lastMessage.editAgentRounds[i].toolCalls!.count {
264-
if lastMessage.editAgentRounds[i].toolCalls![j].id == toolCallId {
265-
updatedAgentRounds.append(
266-
AgentRound(roundId: lastMessage.editAgentRounds[i].roundId,
267-
reply: "",
268-
toolCalls: [
269-
AgentToolCall(id: toolCallId,
270-
name: lastMessage.editAgentRounds[i].toolCalls![j].name,
271-
status: status)
272-
]
273-
)
274-
)
275-
break
276-
}
277-
}
278-
if !updatedAgentRounds.isEmpty {
279-
break
280-
}
281-
}
282-
283-
if !updatedAgentRounds.isEmpty {
284-
let message = ChatMessage(
285-
id: lastMessage.id,
286-
chatTabID: lastMessage.chatTabID,
287-
clsTurnID: lastMessage.clsTurnID,
288-
role: .assistant,
289-
content: "",
290-
references: [],
291-
steps: [],
292-
editAgentRounds: updatedAgentRounds,
293-
turnStatus: .inProgress
284+
285+
// Search for the tool call in main rounds or subagent rounds
286+
if let updatedRound = ToolCallStatusUpdater.findAndUpdateToolCall(
287+
toolCallId: toolCallId,
288+
newStatus: status,
289+
in: targetMessage.editAgentRounds
290+
) {
291+
let message = ToolCallStatusUpdater.createMessageUpdate(
292+
targetMessage: targetMessage,
293+
updatedRound: updatedRound
294294
)
295-
296-
await self.memory.appendMessage(message)
295+
await memory.appendMessage(message)
297296
}
298297
}
299298
}
300299

300+
// MARK: - Helper Methods for Tool Call Status Updates
301+
302+
/// Sends the confirmation response (accept/dismiss) back to the server
303+
private func sendToolConfirmationResponse(_ request: ToolCallRequest, accepted: Bool) {
304+
let toolResult = LanguageModelToolConfirmationResult(
305+
result: accepted ? .Accept : .Dismiss
306+
)
307+
let jsonResult = try? JSONEncoder().encode(toolResult)
308+
let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null
309+
310+
request.completion(
311+
AnyJSONRPCResponse(
312+
id: request.requestId,
313+
result: JSONValue.array([jsonValue, JSONValue.null])
314+
)
315+
)
316+
}
317+
301318
public enum ChatServiceError: Error, LocalizedError {
302319
case conflictingImageFormats(String)
303320

@@ -676,9 +693,18 @@ public final class ChatService: ChatServiceType, ObservableObject {
676693
if progress.parentTurnId == nil {
677694
conversationId = progress.conversationId
678695
}
696+
697+
// Track all valid conversation IDs for the current turn (main conversation + its subturns)
698+
conversationTurnTracking.validConversationIds.insert(progress.conversationId)
699+
679700
let turnId = progress.turnId
680701
let parentTurnId = progress.parentTurnId
681702

703+
// Track parent-subturn relationship
704+
if let parentTurnId = parentTurnId {
705+
conversationTurnTracking.turnParentMap[turnId] = parentTurnId
706+
}
707+
682708
Task {
683709
if var lastUserMessage = await memory.history.last(where: { $0.role == .user }) {
684710

@@ -870,6 +896,9 @@ public final class ChatService: ChatServiceType, ObservableObject {
870896
activeRequestId = nil
871897
isReceivingMessage = false
872898
requestType = nil
899+
900+
// Clear turn tracking data
901+
conversationTurnTracking.reset()
873902

874903
// cancel all pending tool call requests
875904
for (_, request) in pendingToolCallRequests {
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import ChatAPIService
2+
import ConversationServiceProvider
3+
import Foundation
4+
5+
/// Helper methods for updating tool call status in chat history
6+
/// Handles both main turn tool calls and subagent tool calls
7+
struct ToolCallStatusUpdater {
8+
/// Finds the message containing the tool call, handling both main turns and subturns
9+
static func findMessageContainingToolCall(
10+
_ toolCallRequest: ToolCallRequest?,
11+
conversationTurnTracking: ConversationTurnTrackingState,
12+
history: [ChatMessage]
13+
) async -> ChatMessage? {
14+
guard let request = toolCallRequest else { return nil }
15+
16+
// If this is a subturn, find the parent turn; otherwise use the request's turnId
17+
let turnIdToFind = conversationTurnTracking.turnParentMap[request.turnId] ?? request.turnId
18+
19+
return history.first(where: { $0.id == turnIdToFind && $0.role == .assistant })
20+
}
21+
22+
/// Searches for a tool call in agent rounds (including nested subagent rounds) and creates an update
23+
///
24+
/// Note: Parent turns can have multiple sequential subturns, but they don't appear simultaneously.
25+
/// Subturns are merged into the parent's last round's subAgentRounds array by ChatMemory.
26+
static func findAndUpdateToolCall(
27+
toolCallId: String,
28+
newStatus: AgentToolCall.ToolCallStatus,
29+
in agentRounds: [AgentRound]
30+
) -> AgentRound? {
31+
// First, search in main rounds (for regular tool calls)
32+
for round in agentRounds {
33+
if let toolCalls = round.toolCalls {
34+
for toolCall in toolCalls where toolCall.id == toolCallId {
35+
return AgentRound(
36+
roundId: round.roundId,
37+
reply: "",
38+
toolCalls: [
39+
AgentToolCall(
40+
id: toolCallId,
41+
name: toolCall.name,
42+
status: newStatus
43+
),
44+
]
45+
)
46+
}
47+
}
48+
}
49+
50+
// If not found in main rounds, search in subagent rounds (for subturn tool calls)
51+
// Subturns are nested under the parent round's subAgentRounds
52+
for round in agentRounds {
53+
guard let subAgentRounds = round.subAgentRounds else { continue }
54+
55+
for subRound in subAgentRounds {
56+
guard let toolCalls = subRound.toolCalls else { continue }
57+
58+
for toolCall in toolCalls where toolCall.id == toolCallId {
59+
// Create an update that will be merged into the parent's subAgentRounds
60+
// ChatMemory.appendMessage will handle the merging logic
61+
let subagentRound = AgentRound(
62+
roundId: subRound.roundId,
63+
reply: "",
64+
toolCalls: [
65+
AgentToolCall(
66+
id: toolCallId,
67+
name: toolCall.name,
68+
status: newStatus
69+
),
70+
]
71+
)
72+
return AgentRound(
73+
roundId: round.roundId,
74+
reply: "",
75+
toolCalls: [],
76+
subAgentRounds: [subagentRound]
77+
)
78+
}
79+
}
80+
}
81+
82+
return nil
83+
}
84+
85+
/// Creates a message update with the new tool call status
86+
static func createMessageUpdate(
87+
targetMessage: ChatMessage,
88+
updatedRound: AgentRound
89+
) -> ChatMessage {
90+
return ChatMessage(
91+
id: targetMessage.id,
92+
chatTabID: targetMessage.chatTabID,
93+
clsTurnID: targetMessage.clsTurnID,
94+
role: .assistant,
95+
content: "",
96+
references: [],
97+
steps: [],
98+
editAgentRounds: [updatedRound],
99+
turnStatus: .inProgress
100+
)
101+
}
102+
}

Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButton.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ import Persist
44
import SharedUIComponents
55
import SwiftUI
66

7+
// MARK: - Custom NSButton that accepts clicks anywhere within its bounds
8+
class ClickThroughButton: NSButton {
9+
override func hitTest(_ point: NSPoint) -> NSView? {
10+
// If the point is within our bounds, return self (the button)
11+
// This ensures clicks on subviews are handled by the button
12+
if self.bounds.contains(point) {
13+
return self
14+
}
15+
return super.hitTest(point)
16+
}
17+
}
18+
719
// MARK: - Agent Mode Button
820

921
struct AgentModeButton: NSViewRepresentable {
@@ -33,7 +45,7 @@ struct AgentModeButton: NSViewRepresentable {
3345
let containerView = NSView()
3446
containerView.translatesAutoresizingMaskIntoConstraints = false
3547

36-
let button = NSButton()
48+
let button = ClickThroughButton()
3749
button.title = ""
3850
button.bezelStyle = .inline
3951
button.setButtonType(.momentaryPushIn)

Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ class AgentModeButtonMenuItem: NSView {
101101
isSelected: Bool,
102102
menuHasSelection: Bool,
103103
fontScale: Double = 1.0,
104+
fixedWidth: CGFloat? = nil,
104105
onSelect: @escaping () -> Void,
105106
onEdit: (() -> Void)? = nil,
106107
onDelete: (() -> Void)? = nil
@@ -114,8 +115,8 @@ class AgentModeButtonMenuItem: NSView {
114115
self.onEdit = onEdit
115116
self.onDelete = onDelete
116117

117-
// Calculate dynamic width based on content
118-
let calculatedWidth = Self.calculateWidth(
118+
// Use fixed width if provided, otherwise calculate dynamically
119+
let calculatedWidth = fixedWidth ?? Self.calculateMenuItemWidth(
119120
name: name,
120121
hasIcon: iconName != nil,
121122
isSelected: isSelected,
@@ -133,7 +134,7 @@ class AgentModeButtonMenuItem: NSView {
133134
fatalError("init(coder:) has not been implemented")
134135
}
135136

136-
private static func calculateWidth(
137+
static func calculateMenuItemWidth(
137138
name: String,
138139
hasIcon: Bool,
139140
isSelected: Bool,

0 commit comments

Comments
 (0)