Skip to content

Commit 7888499

Browse files
committed
fix(ui): resolve user collaboration tool reliability and duplicate messages
User collaboration (send button) fixes: - Replace HTTP POST roundtrip with direct in-process call to UserCollaborationTool.submitUserResponse(). Eliminates network stack, Vapor server dependency, @mainactor contention, and auth middleware as failure points when submitting collaboration responses. - Fix Send button disabled during collaboration: shouldDisableInput (for unloaded local models) was overriding isAwaitingUserInput, making the button appear active but swallow taps. - Add diagnostic logging for missing toolCallId/conversationId. Duplicate message fix: - The observeUserInputRequired handler in AgentOrchestrator was emitting the collaboration prompt three ways: persisted via messageBus, yielded as a streaming chunk, and again when ChatWidget parsed the SSE event. Remove the first two - ChatWidget's parseSSEEvent is the single source for adding the prompt message. Also eliminates the 'SUCCESS: User Collaboration:' prefix leak. CI/CD updates: - actions/checkout v4 -> v6 - actions/github-script v7 -> v8 - ubuntu-latest -> ubuntu-24.04 (pinned) Cleanup: - Remove unused math_operations icon case from UniversalToolRegistry - Disambiguate CodeBlock -> Markdown.CodeBlock in MarkdownASTRenderer - Fix unused variable warnings (var -> let, named -> wildcard bindings) - Remove unnecessary nonisolated(unsafe) from static let properties - Add .build-xcode/ and .build-ios/ to .gitignore
1 parent 2363055 commit 7888499

File tree

11 files changed

+75
-145
lines changed

11 files changed

+75
-145
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333

3434
steps:
3535
- name: Checkout code
36-
uses: actions/checkout@v4
36+
uses: actions/checkout@v6
3737
with:
3838
submodules: recursive
3939

.github/workflows/nightly-dev.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@ permissions:
2222
jobs:
2323
check-changes:
2424
name: Check for Changes
25-
runs-on: ubuntu-latest
25+
runs-on: ubuntu-24.04
2626
outputs:
2727
should_build: ${{ steps.check.outputs.has_changes }}
2828
steps:
2929
- name: Checkout code
30-
uses: actions/checkout@v4
30+
uses: actions/checkout@v6
3131
with:
3232
fetch-depth: 0
3333

@@ -64,7 +64,7 @@ jobs:
6464

6565
steps:
6666
- name: Checkout code
67-
uses: actions/checkout@v4
67+
uses: actions/checkout@v6
6868
with:
6969
fetch-depth: 0
7070
submodules: recursive
@@ -178,10 +178,10 @@ jobs:
178178
cleanup-old-releases:
179179
name: Cleanup Old Development Releases
180180
needs: build-and-release
181-
runs-on: ubuntu-latest
181+
runs-on: ubuntu-24.04
182182
steps:
183183
- name: Delete old development releases
184-
uses: actions/github-script@v7
184+
uses: actions/github-script@v8
185185
with:
186186
script: |
187187
const KEEP_COUNT = 7; // Keep last 7 development releases

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727

2828
steps:
2929
- name: Checkout code
30-
uses: actions/checkout@v4
30+
uses: actions/checkout@v6
3131
with:
3232
submodules: recursive
3333
fetch-depth: 0

.github/workflows/update-homebrew-cask.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ permissions:
2121
jobs:
2222
update-cask:
2323
name: Update SAM Homebrew Cask
24-
runs-on: ubuntu-latest
24+
runs-on: ubuntu-24.04
2525
# Only run if the release workflow succeeded (or manual dispatch)
2626
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
2727

@@ -45,7 +45,7 @@ jobs:
4545
4646
- name: Get DMG asset details
4747
id: asset
48-
uses: actions/github-script@v7
48+
uses: actions/github-script@v8
4949
with:
5050
script: |
5151
const version = '${{ steps.version.outputs.version }}';
@@ -80,7 +80,7 @@ jobs:
8080
echo "SHA256: $SHA256"
8181
8282
- name: Checkout homebrew-SAM repo
83-
uses: actions/checkout@v4
83+
uses: actions/checkout@v6
8484
with:
8585
repository: SyntheticAutonomicMind/homebrew-SAM
8686
token: ${{ secrets.HOMEBREW_PAT }}

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,5 @@ scratch/
6666
.clio/*
6767
!.clio/instructions.md
6868
dist/
69+
.build-ios/
70+
.build-xcode/

Info.plist

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@
1919
<key>CFBundlePackageType</key>
2020
<string>APPL</string>
2121
<key>CFBundleShortVersionString</key>
22-
<string>20260313.2</string>
22+
<string>20260313.3</string>
2323
<key>CFBundleVersion</key>
24-
<string>20260313.2</string>
24+
<string>20260313.3</string>
2525
<key>LSApplicationCategoryType</key>
2626
<string>public.app-category.productivity</string>
2727
<key>LSMinimumSystemVersion</key>

Resources/whats-new.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"releases": [
33
{
4-
"version": "20260313.2",
4+
"version": "20260313.3",
55
"release_date": "March 13, 2026",
66
"highlights": [
77
{

Sources/APIFramework/AgentOrchestrator.swift

Lines changed: 3 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2189,58 +2189,10 @@ public class AgentOrchestrator: ObservableObject, IterationController {
21892189
}
21902190

21912191
/// Set up observer for user collaboration notifications.
2192+
/// Only emit the SSE event - ChatWidget's parseSSEEvent handler
2193+
/// adds the prompt as a single assistant message via MessageBus.
21922194
let observer = ToolNotificationCenter.shared.observeUserInputRequired { toolCallId, prompt, context, conversationId in
2193-
/// FIRST: Emit visible assistant message showing the collaboration request
2194-
let collaborationMessage = "SUCCESS: User Collaboration: \(prompt)"
2195-
2196-
/// PERSIST MESSAGE: Add to conversation so it doesn't disappear after UI refresh.
2197-
if let convId = conversationId {
2198-
Task { @MainActor in
2199-
if let conversation = self.conversationManager.conversations.first(where: { $0.id == convId }) {
2200-
let isDuplicate = conversation.messages.contains(where: {
2201-
!$0.isFromUser && $0.content == collaborationMessage
2202-
})
2203-
2204-
if !isDuplicate {
2205-
conversation.messageBus?.addAssistantMessage(
2206-
id: UUID(),
2207-
content: collaborationMessage,
2208-
timestamp: Date(),
2209-
isPinned: true
2210-
)
2211-
self.logger.debug("Persisted collaboration message to conversation (PINNED)", metadata: [
2212-
"toolCallId": .string(toolCallId),
2213-
"conversationId": .string(convId.uuidString)
2214-
])
2215-
}
2216-
}
2217-
}
2218-
}
2219-
2220-
let messageChunk = ServerOpenAIChatStreamChunk(
2221-
id: UUID().uuidString,
2222-
object: "chat.completion.chunk",
2223-
created: Int(Date().timeIntervalSince1970),
2224-
model: model,
2225-
choices: [
2226-
OpenAIChatStreamChoice(
2227-
index: 0,
2228-
delta: OpenAIChatDelta(
2229-
role: "assistant",
2230-
content: collaborationMessage
2231-
),
2232-
finishReason: nil
2233-
)
2234-
]
2235-
)
2236-
continuation.yield(messageChunk)
2237-
2238-
self.logger.debug("Emitted collaboration message to UI", metadata: [
2239-
"toolCallId": .string(toolCallId),
2240-
"message": .string(collaborationMessage)
2241-
])
2242-
2243-
/// THEN: Emit custom SSE event for user input required
2195+
/// Emit SSE event for user input required
22442196
let userInputEvent: [String: Any] = [
22452197
"type": "user_input_required",
22462198
"toolCallId": toolCallId,

Sources/APIFramework/UniversalToolRegistry.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,6 @@ public class UniversalToolRegistry: ObservableObject, ToolRegistryProtocol {
149149
return "xmark.seal"
150150

151151
// Default fallback
152-
case "math_operations":
153-
return "function"
154152
default:
155153
return "wrench.and.screwdriver"
156154
}

Sources/UserInterface/Chat/ChatWidget.swift

Lines changed: 40 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -2021,7 +2021,7 @@ public struct ChatWidget: View {
20212021
.buttonStyle(.borderless)
20222022
.disabled(
20232023
(!isAwaitingUserInput && !isSending && messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) ||
2024-
shouldDisableInput
2024+
(!isAwaitingUserInput && shouldDisableInput)
20252025
)
20262026
.help(
20272027
isAwaitingUserInput ? "Submit response" :
@@ -3174,8 +3174,14 @@ public struct ChatWidget: View {
31743174
}
31753175
logger.info("Created placeholder message + \(toolCardIds.count) tool cards for immediate feedback")
31763176

3177+
/// Update StateManager for the attachment flow
3178+
conversationManager.stateManager.updateState(conversationId: conversation.id) { state in
3179+
state.status = .processing(toolName: "Document Import")
3180+
}
3181+
31773182
/// Import files with tool card updates
3178-
Task { @MainActor in
3183+
/// CRITICAL: Assign to streamingTask so the stop button can cancel it
3184+
streamingTask = Task { @MainActor in
31793185
var importedDocs: [(filename: String, id: String, size: Int)] = []
31803186

31813187
for (index, fileURL) in filesToImport.enumerated() {
@@ -3265,6 +3271,17 @@ public struct ChatWidget: View {
32653271
await MainActor.run {
32663272
isSending = false
32673273
isActivelyStreaming = false
3274+
streamingTask = nil
3275+
currentOrchestrator = nil
3276+
3277+
/// Update StateManager: Mark as idle
3278+
if let conv = activeConversation {
3279+
conv.isProcessing = false
3280+
conversationManager.stateManager.updateState(conversationId: conv.id) { state in
3281+
state.status = .idle
3282+
state.activeTools.removeAll()
3283+
}
3284+
}
32683285
}
32693286
}
32703287

@@ -3348,7 +3365,7 @@ public struct ChatWidget: View {
33483365
private func submitUserResponse() {
33493366
guard let toolCallId = userCollaborationToolCallId,
33503367
let conversationId = activeConversation?.id else {
3351-
logger.error("Cannot submit user response - missing toolCallId or conversationId")
3368+
logger.error("Cannot submit user response - missing toolCallId=\(userCollaborationToolCallId ?? "nil") conversationId=\(activeConversation?.id.uuidString ?? "nil")")
33523369
return
33533370
}
33543371

@@ -3357,56 +3374,29 @@ public struct ChatWidget: View {
33573374

33583375
logger.info("USER_COLLAB: Submitting user response for collaboration tool call: \(toolCallId)")
33593376

3360-
/// Clear message text immediately for UX responsiveness, but keep collaboration
3361-
/// state until HTTP succeeds so we can retry or restore on failure.
3362-
let savedInput = messageText
3377+
/// Clear message text immediately for UX responsiveness.
33633378
messageText = ""
33643379

3365-
/// Submit response to API endpoint
3366-
/// API will add to MessageBus and AgentOrchestrator will emit as streaming chunk
3367-
Task {
3368-
do {
3369-
let url = URL(string: "http://127.0.0.1:8080/api/chat/tool-response")!
3370-
var request = URLRequest(url: url)
3371-
request.httpMethod = "POST"
3372-
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
3373-
request.setValue("SAM-Internal-Communication", forHTTPHeaderField: "X-SAM-Internal")
3374-
3375-
let requestBody: [String: String] = [
3376-
"conversationId": conversationId.uuidString,
3377-
"toolCallId": toolCallId,
3378-
"userInput": userInput
3379-
]
3380-
3381-
request.httpBody = try JSONSerialization.data(withJSONObject: requestBody)
3382-
3383-
let (_, response) = try await URLSession.shared.data(for: request)
3384-
3385-
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
3386-
logger.error("USER_COLLAB: Failed to submit user response - HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)")
3387-
/// Restore input so user can retry
3388-
await MainActor.run {
3389-
self.messageText = savedInput
3390-
}
3391-
return
3392-
}
3380+
/// Direct call - tool runs in-process, no HTTP roundtrip needed.
3381+
/// This eliminates network failures, @MainActor contention with Vapor,
3382+
/// and auth middleware as potential failure points.
3383+
let success = UserCollaborationTool.submitUserResponse(
3384+
toolCallId: toolCallId,
3385+
userInput: userInput
3386+
)
33933387

3394-
logger.debug("User response submitted successfully - clearing collaboration state")
3388+
if success {
3389+
logger.info("USER_COLLAB: Response submitted directly to tool")
33953390

3396-
/// Clear collaboration state only after successful submission
3397-
await MainActor.run {
3398-
self.isAwaitingUserInput = false
3399-
self.userCollaborationPrompt = ""
3400-
self.userCollaborationContext = nil
3401-
self.userCollaborationToolCallId = nil
3402-
}
3403-
} catch {
3404-
logger.error("Failed to submit user response: \(error)")
3405-
/// Restore input so user can retry
3406-
await MainActor.run {
3407-
self.messageText = savedInput
3408-
}
3409-
}
3391+
/// Clear collaboration state
3392+
isAwaitingUserInput = false
3393+
userCollaborationPrompt = ""
3394+
userCollaborationContext = nil
3395+
userCollaborationToolCallId = nil
3396+
} else {
3397+
logger.error("USER_COLLAB: Direct submission failed - toolCallId not found in pending responses")
3398+
/// Restore input so user can retry
3399+
messageText = userInput
34103400
}
34113401
}
34123402

@@ -3480,19 +3470,7 @@ public struct ChatWidget: View {
34803470

34813471
/// Update UI state to show collaboration prompt.
34823472
DispatchQueue.main.async {
3483-
/// Add agent's collaboration prompt as a visible message in the chat This ensures users see what the agent is asking in the conversation history.
3484-
/// FEATURE: Pin collaboration prompts for context persistence
3485-
let collaborationMessage = EnhancedMessage(
3486-
id: UUID(),
3487-
content: prompt,
3488-
isFromUser: false,
3489-
timestamp: Date(),
3490-
processingTime: nil,
3491-
performanceMetrics: nil,
3492-
isToolMessage: false,
3493-
isPinned: true,
3494-
importance: 1.0
3495-
)
3473+
/// Add agent's collaboration prompt as a visible message in the chat
34963474
/// FIXED: Use MessageBus for collaboration prompt with pinning
34973475
activeConversation?.messageBus?.addAssistantMessage(
34983476
content: prompt,

0 commit comments

Comments
 (0)