agentgui 1.0.645 → 1.0.646

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,279 @@
1
+ # AgentGUI Sync-to-Display Architecture Report
2
+
3
+ ## Executive Summary
4
+
5
+ The sync-to-display system handles real-time synchronization of execution state from server to client display. Current implementation has been improved through multiple fixes, particularly for queue display consistency and steering support. This document consolidates the architecture and identifies remaining optimization opportunities.
6
+
7
+ ## Architecture Overview
8
+
9
+ ### 1. Broadcast System (server.js:4269-4317)
10
+
11
+ **BROADCAST_TYPES** - Global broadcast events sent to ALL connected clients:
12
+ - `message_created`, `conversation_created/updated/deleted`
13
+ - `queue_status`, `queue_updated`
14
+ - `streaming_start`, `streaming_progress`, `streaming_complete`, `streaming_error`
15
+ - `rate_limit_hit`, `rate_limit_clear`
16
+ - Tool-related events (`tool_*`, `tools_*`)
17
+ - Voice/speech events
18
+ - PM2 monitoring events
19
+
20
+ **broadcastSync()** function (line 4288):
21
+ ```
22
+ 1. Check if event.type is in BROADCAST_TYPES
23
+ 2. If broadcast: send to ALL syncClients
24
+ 3. If targeted: send to sessionId or conversationId subscribers only
25
+ 4. Also dispatch to SSE stream handlers if active
26
+ ```
27
+
28
+ ### 2. Client-Side Message Reception (static/js/client.js)
29
+
30
+ **handleWsMessage()** routes events by type:
31
+ ```javascript
32
+ case 'message_created': handleMessageCreated(data)
33
+ case 'queue_status': handleQueueStatus(data)
34
+ case 'streaming_start': handleStreamingStart(data)
35
+ case 'streaming_progress': handleStreamingProgress(data)
36
+ ```
37
+
38
+ ### 3. Display Rendering Layers
39
+
40
+ **Layer 1: Message Rendering** (handleMessageCreated)
41
+ - Checks if message already exists (prevents duplicates)
42
+ - For user messages: finds optimistic message, updates it
43
+ - For assistant messages: skips if streaming (handled by chunks)
44
+
45
+ **Layer 2: Streaming Progress** (handleStreamingProgress)
46
+ - Adds chunks to queue
47
+ - Batches rendering via StreamingRenderer
48
+ - Handles thinking blocks directly
49
+
50
+ **Layer 3: Queue Indicator** (fetchAndRenderQueue)
51
+ - Polls via `q.ls` RPC every interval
52
+ - Displays queued items in yellow control blocks
53
+ - Renders with Edit/Delete/Steer buttons
54
+
55
+ **Layer 4: Conversation State** (handleConversationUpdated)
56
+ - Updates isStreaming flag
57
+ - Refreshes UI based on new state
58
+
59
+ ## Recent Fixes & Status
60
+
61
+ ### Fixed Issues
62
+
63
+ ✅ **Queue Message Duplication** (commit fbfd1ad)
64
+ - Problem: Queued messages appeared as both user prompt + queue item
65
+ - Solution: Skip optimistic message when conversation is streaming
66
+ - Impact: Clean queue display, no duplication
67
+
68
+ ✅ **Steering Support** (commit 81d83af)
69
+ - Problem: Steering showed "Process not available" error
70
+ - Solution: Keep stdin open (supportsStdin=true, closeStdin=false)
71
+ - Impact: Users can send follow-up prompts during execution
72
+
73
+ ✅ **Chunk Rendering Race** (Session 3a)
74
+ - Problem: Early chunks missed before polling started
75
+ - Solution: Immediate chunk fetch on streaming_start
76
+ - Impact: No missing initial output
77
+
78
+ ✅ **Dark Mode Selection** (fsbrowse)
79
+ - Problem: Selection states hard to see in dark mode
80
+ - Solution: Theme-aware colors for hover/focus
81
+ - Impact: Better visual feedback in both themes
82
+
83
+ ### Remaining Consistency Issues
84
+
85
+ ⚠️ **Queue State Sync Timing**
86
+ - Queue created on server via enqueue()
87
+ - message_created broadcast may arrive before queue is populated
88
+ - Client fetchAndRenderQueue() polls but timing is unpredictable
89
+ - No atomic operation ensuring client sees both message and queue
90
+
91
+ ⚠️ **Multiple Sources of Truth**
92
+ - Server: database, active execution map, queue map
93
+ - Client: message list, streaming set, queue indicator
94
+ - Conversation state: isStreaming flag can desync if connection drops
95
+ - No single authoritative state object
96
+
97
+ ⚠️ **Optimistic Message Updates**
98
+ - Client creates optimistic "sending" message on submit
99
+ - Server creates actual message and broadcasts
100
+ - handleMessageCreated() finds and updates optimistic message
101
+ - If network is slow, both may briefly exist
102
+
103
+ ⚠️ **Queue Execution Transition**
104
+ - Queue items don't explicitly transition to executed
105
+ - Client must poll queue indicator to see it disappear
106
+ - No event indicating "queue item now executing"
107
+ - Confusing UX when queue suddenly empties
108
+
109
+ ## Data Flow Examples
110
+
111
+ ### Normal Execution (Ctrl+Enter with no active stream)
112
+
113
+ ```
114
+ User input: "Write a function"
115
+
116
+ startExecution()
117
+ → _showOptimisticMessage(pendingId, content) // yellow "sending..." message
118
+
119
+ streamToConversation() → msg.stream RPC
120
+
121
+ Server: msg.stream handler
122
+ → createMessage(p.id, 'user', prompt)
123
+ → broadcastSync({ type: 'message_created', ... })
124
+ → startExecution() -> streaming_start broadcast
125
+
126
+ Client: message_created event
127
+ → handleMessageCreated()
128
+ → Finds optimistic message by ID
129
+ → Updates it (removes "sending" style, adds timestamp)
130
+
131
+ Client: streaming_start event
132
+ → Disables input, shows "thinking..."
133
+ → Subscribes to session
134
+ → Starts chunk polling
135
+ ```
136
+
137
+ ### Queue Case (Ctrl+Enter while streaming) - NOW FIXED
138
+
139
+ ```
140
+ User input: "Now add subtraction" during execution
141
+
142
+ startExecution()
143
+ → isStreaming = true
144
+ → SKIP _showOptimisticMessage ✓ (FIXED)
145
+
146
+ streamToConversation() → msg.stream RPC
147
+
148
+ Server: msg.stream handler
149
+ → createMessage(p.id, 'user', prompt)
150
+ → broadcastSync({ type: 'message_created', ... })
151
+ → activeExecutions.has(p.id) = true
152
+ → enqueue(p.id, prompt, ...)
153
+ → broadcastSync({ type: 'queue_status', ... })
154
+
155
+ Client: message_created event
156
+ → handleMessageCreated()
157
+ → Message is not in current conversation display
158
+ → Emit event but don't render
159
+
160
+ Client: queue_status event
161
+ → handleQueueStatus()
162
+ → fetchAndRenderQueue()
163
+ → Displays in yellow queue indicator ✓
164
+
165
+ Result: Message only in queue, no duplication ✓
166
+ ```
167
+
168
+ ### Steering (Ctrl+Enter + Steer button during execution)
169
+
170
+ ```
171
+ User clicks Steer button on queued message
172
+
173
+ Client: conv.steer RPC with JSON-RPC format
174
+
175
+ Server: conv.steer handler
176
+ → Finds active process stdin
177
+ → Writes JSON-RPC prompt request
178
+ → Removes from queue (optional)
179
+
180
+ Claude Code: Receives JSON-RPC on stdin
181
+ → Processes prompt immediately
182
+ → Continues execution with new direction
183
+
184
+ Client: receives streaming_progress chunks
185
+ → Renders new content below existing output
186
+ ```
187
+
188
+ ## Consistency Guarantees
189
+
190
+ ### What IS Consistent
191
+ - Messages don't duplicate (handled in handleMessageCreated)
192
+ - Streaming chunks are ordered (via session ID subscription)
193
+ - Queue items maintain order (FIFO in server queue map)
194
+ - Tool installations tracked atomically (per-tool in database)
195
+
196
+ ### What IS NOT Guaranteed
197
+ - Client queue display and server queue state may briefly desync
198
+ - If connection drops mid-queue, client state becomes stale
199
+ - No explicit confirmation that user message was queued
200
+ - Queue item execution not signaled with explicit event
201
+
202
+ ## Recommendations for Further Improvement
203
+
204
+ ### Priority 1: Explicit Queue Lifecycle
205
+ Add dedicated broadcast events:
206
+ ```javascript
207
+ 'message_queued' // Sent when msg added to queue
208
+ 'queue_item_executing' // Sent when queue item becomes active
209
+ 'queue_item_completed' // Sent when queue item finished
210
+ ```
211
+
212
+ ### Priority 2: Atomic State Snapshots
213
+ Periodically broadcast conversation state:
214
+ ```javascript
215
+ {
216
+ type: 'conversation_state',
217
+ conversationId,
218
+ state: {
219
+ isStreaming: boolean,
220
+ messageCount: number,
221
+ queueLength: number,
222
+ lastMessageId: string,
223
+ lastUpdate: timestamp
224
+ }
225
+ }
226
+ ```
227
+
228
+ ### Priority 3: Deterministic Client State Machine
229
+ Each conversation has explicit state mode:
230
+ ```javascript
231
+ state = {
232
+ mode: 'IDLE' | 'STREAMING' | 'QUEUED',
233
+ messages: [],
234
+ queue: [],
235
+ isTransitioning: boolean // true when queue→execute
236
+ }
237
+ ```
238
+
239
+ ### Priority 4: Connection Recovery
240
+ After reconnect, fetch complete conversation state:
241
+ ```javascript
242
+ await wsClient.rpc('conv.sync', { id: conversationId })
243
+ // Returns: { messages, queue, isStreaming, chunks }
244
+ ```
245
+
246
+ ## Testing Matrix
247
+
248
+ | Scenario | Before Fix | After Fix | Status |
249
+ |----------|-----------|-----------|--------|
250
+ | Ctrl+Enter (not streaming) | ✓ Works | ✓ Works | ✓ OK |
251
+ | Ctrl+Enter (streaming) | ✗ Duplicate | ✓ Single queue item | ✓ FIXED |
252
+ | Queue indicator updates | ⚠️ Polling | ⚠️ Polling | ⚠️ Could improve |
253
+ | Steering works | ✗ "Not available" | ✓ Works | ✓ FIXED |
254
+ | Multiple queued items | ⚠️ Order unclear | ⚠️ Order unclear | ⚠️ OK but could confirm |
255
+ | Page reload with queue | ✗ Queue lost | ✓ Persisted | ✓ OK |
256
+ | Network disconnect | ⚠️ State stale | ⚠️ State stale | ⚠️ Could improve |
257
+
258
+ ## Files Modified in Latest Session
259
+
260
+ 1. **lib/claude-runner.js** - Steering support
261
+ - Changed `supportsStdin: false` → `true`
262
+ - Changed `closeStdin: true` → `false`
263
+ - Removed positional prompt argument
264
+
265
+ 2. **static/js/client.js** - Queue display fix
266
+ - Skip optimistic message when streaming
267
+ - Skip confirm/fail handlers for queued messages
268
+
269
+ 3. **static/index.html** - Hamburger animation
270
+ - Added `transition: none` to .sidebar
271
+
272
+ 4. **fsbrowse/public/style.css** - Dark mode colors
273
+ - Improved hover states
274
+ - Theme-aware modal focus colors
275
+
276
+ ## Conclusion
277
+
278
+ The sync-to-display system is now more consistent with the queue display fix and steering support. The architecture handles the primary use cases well, but could benefit from explicit lifecycle events and atomic state snapshots for guaranteed consistency. The current implementation is pragmatic and performant, trading off some guarantees for simplicity and responsiveness.
279
+
@@ -0,0 +1,279 @@
1
+ # AgentGUI Sync-to-Display Architecture Report
2
+
3
+ ## Executive Summary
4
+
5
+ The sync-to-display system handles real-time synchronization of execution state from server to client display. Current implementation has been improved through multiple fixes, particularly for queue display consistency and steering support. This document consolidates the architecture and identifies remaining optimization opportunities.
6
+
7
+ ## Architecture Overview
8
+
9
+ ### 1. Broadcast System (server.js:4269-4317)
10
+
11
+ **BROADCAST_TYPES** - Global broadcast events sent to ALL connected clients:
12
+ - `message_created`, `conversation_created/updated/deleted`
13
+ - `queue_status`, `queue_updated`
14
+ - `streaming_start`, `streaming_progress`, `streaming_complete`, `streaming_error`
15
+ - `rate_limit_hit`, `rate_limit_clear`
16
+ - Tool-related events (`tool_*`, `tools_*`)
17
+ - Voice/speech events
18
+ - PM2 monitoring events
19
+
20
+ **broadcastSync()** function (line 4288):
21
+ ```
22
+ 1. Check if event.type is in BROADCAST_TYPES
23
+ 2. If broadcast: send to ALL syncClients
24
+ 3. If targeted: send to sessionId or conversationId subscribers only
25
+ 4. Also dispatch to SSE stream handlers if active
26
+ ```
27
+
28
+ ### 2. Client-Side Message Reception (static/js/client.js)
29
+
30
+ **handleWsMessage()** routes events by type:
31
+ ```javascript
32
+ case 'message_created': handleMessageCreated(data)
33
+ case 'queue_status': handleQueueStatus(data)
34
+ case 'streaming_start': handleStreamingStart(data)
35
+ case 'streaming_progress': handleStreamingProgress(data)
36
+ ```
37
+
38
+ ### 3. Display Rendering Layers
39
+
40
+ **Layer 1: Message Rendering** (handleMessageCreated)
41
+ - Checks if message already exists (prevents duplicates)
42
+ - For user messages: finds optimistic message, updates it
43
+ - For assistant messages: skips if streaming (handled by chunks)
44
+
45
+ **Layer 2: Streaming Progress** (handleStreamingProgress)
46
+ - Adds chunks to queue
47
+ - Batches rendering via StreamingRenderer
48
+ - Handles thinking blocks directly
49
+
50
+ **Layer 3: Queue Indicator** (fetchAndRenderQueue)
51
+ - Polls via `q.ls` RPC every interval
52
+ - Displays queued items in yellow control blocks
53
+ - Renders with Edit/Delete/Steer buttons
54
+
55
+ **Layer 4: Conversation State** (handleConversationUpdated)
56
+ - Updates isStreaming flag
57
+ - Refreshes UI based on new state
58
+
59
+ ## Recent Fixes & Status
60
+
61
+ ### Fixed Issues
62
+
63
+ ✅ **Queue Message Duplication** (commit fbfd1ad)
64
+ - Problem: Queued messages appeared as both user prompt + queue item
65
+ - Solution: Skip optimistic message when conversation is streaming
66
+ - Impact: Clean queue display, no duplication
67
+
68
+ ✅ **Steering Support** (commit 81d83af)
69
+ - Problem: Steering showed "Process not available" error
70
+ - Solution: Keep stdin open (supportsStdin=true, closeStdin=false)
71
+ - Impact: Users can send follow-up prompts during execution
72
+
73
+ ✅ **Chunk Rendering Race** (Session 3a)
74
+ - Problem: Early chunks missed before polling started
75
+ - Solution: Immediate chunk fetch on streaming_start
76
+ - Impact: No missing initial output
77
+
78
+ ✅ **Dark Mode Selection** (fsbrowse)
79
+ - Problem: Selection states hard to see in dark mode
80
+ - Solution: Theme-aware colors for hover/focus
81
+ - Impact: Better visual feedback in both themes
82
+
83
+ ### Remaining Consistency Issues
84
+
85
+ ⚠️ **Queue State Sync Timing**
86
+ - Queue created on server via enqueue()
87
+ - message_created broadcast may arrive before queue is populated
88
+ - Client fetchAndRenderQueue() polls but timing is unpredictable
89
+ - No atomic operation ensuring client sees both message and queue
90
+
91
+ ⚠️ **Multiple Sources of Truth**
92
+ - Server: database, active execution map, queue map
93
+ - Client: message list, streaming set, queue indicator
94
+ - Conversation state: isStreaming flag can desync if connection drops
95
+ - No single authoritative state object
96
+
97
+ ⚠️ **Optimistic Message Updates**
98
+ - Client creates optimistic "sending" message on submit
99
+ - Server creates actual message and broadcasts
100
+ - handleMessageCreated() finds and updates optimistic message
101
+ - If network is slow, both may briefly exist
102
+
103
+ ⚠️ **Queue Execution Transition**
104
+ - Queue items don't explicitly transition to executed
105
+ - Client must poll queue indicator to see it disappear
106
+ - No event indicating "queue item now executing"
107
+ - Confusing UX when queue suddenly empties
108
+
109
+ ## Data Flow Examples
110
+
111
+ ### Normal Execution (Ctrl+Enter with no active stream)
112
+
113
+ ```
114
+ User input: "Write a function"
115
+
116
+ startExecution()
117
+ → _showOptimisticMessage(pendingId, content) // yellow "sending..." message
118
+
119
+ streamToConversation() → msg.stream RPC
120
+
121
+ Server: msg.stream handler
122
+ → createMessage(p.id, 'user', prompt)
123
+ → broadcastSync({ type: 'message_created', ... })
124
+ → startExecution() -> streaming_start broadcast
125
+
126
+ Client: message_created event
127
+ → handleMessageCreated()
128
+ → Finds optimistic message by ID
129
+ → Updates it (removes "sending" style, adds timestamp)
130
+
131
+ Client: streaming_start event
132
+ → Disables input, shows "thinking..."
133
+ → Subscribes to session
134
+ → Starts chunk polling
135
+ ```
136
+
137
+ ### Queue Case (Ctrl+Enter while streaming) - NOW FIXED
138
+
139
+ ```
140
+ User input: "Now add subtraction" during execution
141
+
142
+ startExecution()
143
+ → isStreaming = true
144
+ → SKIP _showOptimisticMessage ✓ (FIXED)
145
+
146
+ streamToConversation() → msg.stream RPC
147
+
148
+ Server: msg.stream handler
149
+ → createMessage(p.id, 'user', prompt)
150
+ → broadcastSync({ type: 'message_created', ... })
151
+ → activeExecutions.has(p.id) = true
152
+ → enqueue(p.id, prompt, ...)
153
+ → broadcastSync({ type: 'queue_status', ... })
154
+
155
+ Client: message_created event
156
+ → handleMessageCreated()
157
+ → Message is not in current conversation display
158
+ → Emit event but don't render
159
+
160
+ Client: queue_status event
161
+ → handleQueueStatus()
162
+ → fetchAndRenderQueue()
163
+ → Displays in yellow queue indicator ✓
164
+
165
+ Result: Message only in queue, no duplication ✓
166
+ ```
167
+
168
+ ### Steering (Ctrl+Enter + Steer button during execution)
169
+
170
+ ```
171
+ User clicks Steer button on queued message
172
+
173
+ Client: conv.steer RPC with JSON-RPC format
174
+
175
+ Server: conv.steer handler
176
+ → Finds active process stdin
177
+ → Writes JSON-RPC prompt request
178
+ → Removes from queue (optional)
179
+
180
+ Claude Code: Receives JSON-RPC on stdin
181
+ → Processes prompt immediately
182
+ → Continues execution with new direction
183
+
184
+ Client: receives streaming_progress chunks
185
+ → Renders new content below existing output
186
+ ```
187
+
188
+ ## Consistency Guarantees
189
+
190
+ ### What IS Consistent
191
+ - Messages don't duplicate (handled in handleMessageCreated)
192
+ - Streaming chunks are ordered (via session ID subscription)
193
+ - Queue items maintain order (FIFO in server queue map)
194
+ - Tool installations tracked atomically (per-tool in database)
195
+
196
+ ### What IS NOT Guaranteed
197
+ - Client queue display and server queue state may briefly desync
198
+ - If connection drops mid-queue, client state becomes stale
199
+ - No explicit confirmation that user message was queued
200
+ - Queue item execution not signaled with explicit event
201
+
202
+ ## Recommendations for Further Improvement
203
+
204
+ ### Priority 1: Explicit Queue Lifecycle
205
+ Add dedicated broadcast events:
206
+ ```javascript
207
+ 'message_queued' // Sent when msg added to queue
208
+ 'queue_item_executing' // Sent when queue item becomes active
209
+ 'queue_item_completed' // Sent when queue item finished
210
+ ```
211
+
212
+ ### Priority 2: Atomic State Snapshots
213
+ Periodically broadcast conversation state:
214
+ ```javascript
215
+ {
216
+ type: 'conversation_state',
217
+ conversationId,
218
+ state: {
219
+ isStreaming: boolean,
220
+ messageCount: number,
221
+ queueLength: number,
222
+ lastMessageId: string,
223
+ lastUpdate: timestamp
224
+ }
225
+ }
226
+ ```
227
+
228
+ ### Priority 3: Deterministic Client State Machine
229
+ Each conversation has explicit state mode:
230
+ ```javascript
231
+ state = {
232
+ mode: 'IDLE' | 'STREAMING' | 'QUEUED',
233
+ messages: [],
234
+ queue: [],
235
+ isTransitioning: boolean // true when queue→execute
236
+ }
237
+ ```
238
+
239
+ ### Priority 4: Connection Recovery
240
+ After reconnect, fetch complete conversation state:
241
+ ```javascript
242
+ await wsClient.rpc('conv.sync', { id: conversationId })
243
+ // Returns: { messages, queue, isStreaming, chunks }
244
+ ```
245
+
246
+ ## Testing Matrix
247
+
248
+ | Scenario | Before Fix | After Fix | Status |
249
+ |----------|-----------|-----------|--------|
250
+ | Ctrl+Enter (not streaming) | ✓ Works | ✓ Works | ✓ OK |
251
+ | Ctrl+Enter (streaming) | ✗ Duplicate | ✓ Single queue item | ✓ FIXED |
252
+ | Queue indicator updates | ⚠️ Polling | ⚠️ Polling | ⚠️ Could improve |
253
+ | Steering works | ✗ "Not available" | ✓ Works | ✓ FIXED |
254
+ | Multiple queued items | ⚠️ Order unclear | ⚠️ Order unclear | ⚠️ OK but could confirm |
255
+ | Page reload with queue | ✗ Queue lost | ✓ Persisted | ✓ OK |
256
+ | Network disconnect | ⚠️ State stale | ⚠️ State stale | ⚠️ Could improve |
257
+
258
+ ## Files Modified in Latest Session
259
+
260
+ 1. **lib/claude-runner.js** - Steering support
261
+ - Changed `supportsStdin: false` → `true`
262
+ - Changed `closeStdin: true` → `false`
263
+ - Removed positional prompt argument
264
+
265
+ 2. **static/js/client.js** - Queue display fix
266
+ - Skip optimistic message when streaming
267
+ - Skip confirm/fail handlers for queued messages
268
+
269
+ 3. **static/index.html** - Hamburger animation
270
+ - Added `transition: none` to .sidebar
271
+
272
+ 4. **fsbrowse/public/style.css** - Dark mode colors
273
+ - Improved hover states
274
+ - Theme-aware modal focus colors
275
+
276
+ ## Conclusion
277
+
278
+ The sync-to-display system is now more consistent with the queue display fix and steering support. The architecture handles the primary use cases well, but could benefit from explicit lifecycle events and atomic state snapshots for guaranteed consistency. The current implementation is pragmatic and performant, trading off some guarantees for simplicity and responsiveness.
279
+
@@ -205,13 +205,17 @@ export function register(router, deps) {
205
205
  const idempotencyKey = p.idempotencyKey || null;
206
206
  const message = queries.createMessage(p.id, 'user', p.content, idempotencyKey);
207
207
  queries.createEvent('message.created', { role: 'user', messageId: message.id }, p.id);
208
- broadcastSync({ type: 'message_created', conversationId: p.id, message, timestamp: Date.now() });
209
- if (activeExecutions.has(p.id)) {
210
- const qp = enqueue(p.id, p.content, agentId, model, message.id, subAgent);
211
- return { message, queued: true, queuePosition: qp, idempotencyKey };
208
+
209
+ // Only broadcast message_created if NOT queuing - queued messages show in queue indicator instead
210
+ if (!activeExecutions.has(p.id)) {
211
+ broadcastSync({ type: 'message_created', conversationId: p.id, message, timestamp: Date.now() });
212
+ const session = startExecution(p.id, message, agentId, model, p.content, subAgent);
213
+ return { message, session, idempotencyKey };
212
214
  }
213
- const session = startExecution(p.id, message, agentId, model, p.content, subAgent);
214
- return { message, session, idempotencyKey };
215
+
216
+ // Message is queued - don't broadcast as message_created, let queue_status handle the UI update
217
+ const qp = enqueue(p.id, p.content, agentId, model, message.id, subAgent);
218
+ return { message, queued: true, queuePosition: qp, idempotencyKey };
215
219
  });
216
220
 
217
221
  router.handle('msg.get', (p) => {
@@ -229,13 +233,17 @@ export function register(router, deps) {
229
233
  const subAgent = p.subAgent || conv.subAgent || null;
230
234
  const userMessage = queries.createMessage(p.id, 'user', prompt);
231
235
  queries.createEvent('message.created', { role: 'user', messageId: userMessage.id }, p.id);
232
- broadcastSync({ type: 'message_created', conversationId: p.id, message: userMessage, timestamp: Date.now() });
233
- if (activeExecutions.has(p.id)) {
234
- const qp = enqueue(p.id, prompt, agentId, model, userMessage.id, subAgent);
235
- return { message: userMessage, queued: true, queuePosition: qp };
236
+
237
+ // Only broadcast message_created if NOT queuing - queued messages show in queue indicator instead
238
+ if (!activeExecutions.has(p.id)) {
239
+ broadcastSync({ type: 'message_created', conversationId: p.id, message: userMessage, timestamp: Date.now() });
240
+ const session = startExecution(p.id, userMessage, agentId, model, prompt, subAgent);
241
+ return { message: userMessage, session, streamId: session.id };
236
242
  }
237
- const session = startExecution(p.id, userMessage, agentId, model, prompt, subAgent);
238
- return { message: userMessage, session, streamId: session.id };
243
+
244
+ // Message is queued - don't broadcast as message_created, let queue_status handle the UI update
245
+ const qp = enqueue(p.id, prompt, agentId, model, userMessage.id, subAgent);
246
+ return { message: userMessage, queued: true, queuePosition: qp };
239
247
  });
240
248
 
241
249
  router.handle('q.ls', (p) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.645",
3
+ "version": "1.0.646",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -4179,7 +4179,16 @@ function drainMessageQueue(conversationId) {
4179
4179
  const next = queue.shift();
4180
4180
  if (queue.length === 0) messageQueues.delete(conversationId);
4181
4181
 
4182
- debugLog(`[queue] Draining next message for ${conversationId}`);
4182
+ debugLog(`[queue] Draining next message for ${conversationId}, messageId=${next.messageId}`);
4183
+
4184
+ // Broadcast queue_item_dequeued so client can update UI immediately
4185
+ broadcastSync({
4186
+ type: 'queue_item_dequeued',
4187
+ conversationId,
4188
+ messageId: next.messageId,
4189
+ queueLength: queue?.length || 0,
4190
+ timestamp: Date.now()
4191
+ });
4183
4192
 
4184
4193
  const session = queries.createSession(conversationId);
4185
4194
  queries.createEvent('session.created', { messageId: next.messageId, sessionId: session.id }, conversationId, session.id);
@@ -691,18 +691,23 @@ class AgentGUIClient {
691
691
 
692
692
  switch (data.type) {
693
693
  case 'streaming_start':
694
+ window.syncDebugger?.logEvent('streaming_start', data);
694
695
  this.handleStreamingStart(data).catch(e => console.error('handleStreamingStart error:', e));
695
696
  break;
696
697
  case 'streaming_resumed':
698
+ window.syncDebugger?.logEvent('streaming_resumed', data);
697
699
  this.handleStreamingResumed(data).catch(e => console.error('handleStreamingResumed error:', e));
698
700
  break;
699
701
  case 'streaming_progress':
702
+ window.syncDebugger?.logEvent('streaming_progress', { sessionId: data.sessionId });
700
703
  this.handleStreamingProgress(data);
701
704
  break;
702
705
  case 'streaming_complete':
706
+ window.syncDebugger?.logEvent('streaming_complete', data);
703
707
  this.handleStreamingComplete(data);
704
708
  break;
705
709
  case 'streaming_error':
710
+ window.syncDebugger?.logEvent('streaming_error', data);
706
711
  this.handleStreamingError(data);
707
712
  break;
708
713
  case 'conversation_created':
@@ -712,14 +717,22 @@ class AgentGUIClient {
712
717
  this.handleAllConversationsDeleted(data);
713
718
  break;
714
719
  case 'message_created':
720
+ window.syncDebugger?.logEvent('message_created', data);
715
721
  this.handleMessageCreated(data);
716
722
  break;
723
+ case 'conversation_updated':
724
+ window.syncDebugger?.logEvent('conversation_updated', data);
725
+ this.handleConversationUpdated(data);
726
+ break;
717
727
  case 'queue_status':
718
728
  this.handleQueueStatus(data);
719
729
  break;
720
730
  case 'queue_updated':
721
731
  this.handleQueueUpdated(data);
722
732
  break;
733
+ case 'queue_item_dequeued':
734
+ this.handleQueueItemDequeued(data);
735
+ break;
723
736
  case 'rate_limit_hit':
724
737
  this.handleRateLimitHit(data);
725
738
  break;
@@ -778,6 +791,7 @@ class AgentGUIClient {
778
791
  if (this.state.currentConversation?.id !== data.conversationId) {
779
792
  console.log('Streaming started for non-active conversation:', data.conversationId);
780
793
  this.state.streamingConversations.set(data.conversationId, true);
794
+ console.log('[SYNC] streaming_start - non-active conv:', { convId: data.conversationId, sessionId: data.sessionId, streamingCount: this.state.streamingConversations.size });
781
795
  this.updateBusyPromptArea(data.conversationId);
782
796
  this.emit('streaming:start', data);
783
797
 
@@ -1140,21 +1154,31 @@ class AgentGUIClient {
1140
1154
  if (conversationId && this.state.currentConversation?.id !== conversationId) {
1141
1155
  console.log('Streaming completed for non-active conversation:', conversationId);
1142
1156
  this.state.streamingConversations.delete(conversationId);
1157
+ console.log('[SYNC] streaming_complete - non-active conv:', { convId: conversationId, streamingCount: this.state.streamingConversations.size });
1143
1158
  this.updateBusyPromptArea(conversationId);
1144
1159
  this.emit('streaming:complete', data);
1145
1160
  return;
1146
1161
  }
1147
1162
 
1148
1163
  this.state.streamingConversations.delete(conversationId);
1164
+ console.log('[SYNC] streaming_complete - active conv:', { convId: conversationId, streamingCount: this.state.streamingConversations.size, interrupted: data.interrupted });
1149
1165
  this.updateBusyPromptArea(conversationId);
1150
1166
 
1167
+ const sessionId = data.sessionId || this.state.currentSession?.id;
1151
1168
 
1169
+ // Unsubscribe from session to prevent subscription leak
1170
+ if (sessionId && this.wsManager) {
1171
+ try {
1172
+ this.wsManager.unsubscribeFromSession(sessionId);
1173
+ } catch (e) {
1174
+ // Session may not exist, ignore
1175
+ }
1176
+ }
1152
1177
 
1153
1178
  // Clear queue indicator when streaming completes
1154
1179
  const queueEl = document.querySelector('.queue-indicator');
1155
1180
  if (queueEl) queueEl.remove();
1156
1181
 
1157
- const sessionId = data.sessionId || this.state.currentSession?.id;
1158
1182
  // Remove ALL streaming indicators from the entire messages container
1159
1183
  const outputEl2 = document.getElementById('output');
1160
1184
  if (outputEl2) {
@@ -1220,6 +1244,13 @@ class AgentGUIClient {
1220
1244
  return;
1221
1245
  }
1222
1246
 
1247
+ console.log('[SYNC] message_created:', { msgId: data.message.id, role: data.message.role, convId: data.conversationId });
1248
+
1249
+ // Update messageCount in current conversation state for user messages
1250
+ if (data.message.role === 'user' && this.state.currentConversation) {
1251
+ this.state.currentConversation.messageCount = (this.state.currentConversation.messageCount || 0) + 1;
1252
+ }
1253
+
1223
1254
  if (data.message.role === 'assistant' && this.state.streamingConversations.has(data.conversationId)) {
1224
1255
  this.emit('message:created', data);
1225
1256
  return;
@@ -1279,6 +1310,15 @@ class AgentGUIClient {
1279
1310
  this.emit('message:created', data);
1280
1311
  }
1281
1312
 
1313
+ handleConversationUpdated(data) {
1314
+ // Update current conversation metadata if this is the active conversation
1315
+ if (data.conversation && data.conversation.id === this.state.currentConversation?.id) {
1316
+ this.state.currentConversation = data.conversation;
1317
+ }
1318
+ // Emit event for sidebar/other listeners
1319
+ this.emit('conversation:updated', data);
1320
+ }
1321
+
1282
1322
  handleQueueStatus(data) {
1283
1323
  if (data.conversationId !== this.state.currentConversation?.id) return;
1284
1324
  this.fetchAndRenderQueue(data.conversationId);
@@ -1289,6 +1329,13 @@ class AgentGUIClient {
1289
1329
  this.fetchAndRenderQueue(data.conversationId);
1290
1330
  }
1291
1331
 
1332
+ handleQueueItemDequeued(data) {
1333
+ if (data.conversationId !== this.state.currentConversation?.id) return;
1334
+ // Item was dequeued and execution started - remove from queue indicator
1335
+ // and update queue display
1336
+ this.fetchAndRenderQueue(data.conversationId);
1337
+ }
1338
+
1292
1339
  async fetchAndRenderQueue(conversationId) {
1293
1340
  const outputEl = document.querySelector('.conversation-messages');
1294
1341
  if (!outputEl) return;
@@ -1848,7 +1895,26 @@ class AgentGUIClient {
1848
1895
  latencyEma: self.wsManager?._latencyEma || null,
1849
1896
  serverProcessingEstimate: self._serverProcessingEstimate,
1850
1897
  latencyTrend: self.wsManager?.latency?.trend || null
1851
- })
1898
+ }),
1899
+
1900
+ // Sync-to-display debugging
1901
+ getSyncState: () => ({
1902
+ currentConversation: self.state.currentConversation,
1903
+ isStreaming: self.state.currentConversation && self.state.streamingConversations.has(self.state.currentConversation.id),
1904
+ streamingConversations: Array.from(self.state.streamingConversations),
1905
+ rendererEventQueueLength: self.renderer?.eventQueue?.length || 0,
1906
+ rendererEventHistoryLength: self.renderer?.eventHistory?.length || 0,
1907
+ }),
1908
+
1909
+ // Message DOM state
1910
+ getMessageState: () => {
1911
+ const output = document.querySelector('.conversation-messages');
1912
+ if (!output) return { error: 'No conversation output found' };
1913
+ const messageCount = output.querySelectorAll('.message').length;
1914
+ const queueItems = output.querySelectorAll('.queue-item').length;
1915
+ const pendingMessages = output.querySelectorAll('.message-sending').length;
1916
+ return { messageCount, queueItems, pendingMessages };
1917
+ }
1852
1918
  };
1853
1919
  }
1854
1920
 
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Sync-to-Display Debug System
3
+ * Comprehensive logging and state validation for sync/render pipeline
4
+ */
5
+
6
+ class SyncDebugger {
7
+ constructor() {
8
+ this.enabled = false;
9
+ this.eventLog = [];
10
+ this.maxLogSize = 500;
11
+ this.stateSnapshots = [];
12
+ this.processingStack = [];
13
+ this.startTime = Date.now();
14
+
15
+ // Event tracking
16
+ this.processedEventIds = new Set();
17
+ this.eventCounts = {};
18
+
19
+ // Timing
20
+ this.timingMarkers = {};
21
+ }
22
+
23
+ enable() {
24
+ this.enabled = true;
25
+ console.log('[SyncDebug] Enabled');
26
+ window.SyncDebugger = this;
27
+ }
28
+
29
+ disable() {
30
+ this.enabled = false;
31
+ console.log('[SyncDebug] Disabled');
32
+ }
33
+
34
+ logEvent(type, data) {
35
+ if (!this.enabled) return;
36
+
37
+ const timestamp = Date.now() - this.startTime;
38
+ const eventId = data?.id || data?.messageId || data?.conversationId || '?';
39
+
40
+ // Check for duplicates
41
+ const isDuplicate = this.processedEventIds.has(eventId) &&
42
+ this.eventLog.some(e => e.type === type && e.eventId === eventId);
43
+
44
+ const entry = {
45
+ timestamp,
46
+ type,
47
+ eventId,
48
+ isDuplicate,
49
+ dataKeys: data ? Object.keys(data) : [],
50
+ dataSize: JSON.stringify(data || {}).length
51
+ };
52
+
53
+ this.eventLog.push(entry);
54
+ this.eventCounts[type] = (this.eventCounts[type] || 0) + 1;
55
+ this.processedEventIds.add(eventId);
56
+
57
+ if (this.eventLog.length > this.maxLogSize) {
58
+ this.eventLog.shift();
59
+ }
60
+
61
+ if (isDuplicate) {
62
+ console.warn(`[SyncDebug] DUPLICATE EVENT: ${type} - ${eventId}`, entry);
63
+ } else {
64
+ console.log(`[SyncDebug] Event: ${type} (${timestamp}ms)`, entry);
65
+ }
66
+ }
67
+
68
+ logStateChange(name, before, after) {
69
+ if (!this.enabled) return;
70
+
71
+ const timestamp = Date.now() - this.startTime;
72
+ const snapshot = {
73
+ timestamp,
74
+ name,
75
+ before: JSON.parse(JSON.stringify(before)),
76
+ after: JSON.parse(JSON.stringify(after)),
77
+ changed: JSON.stringify(before) !== JSON.stringify(after)
78
+ };
79
+
80
+ this.stateSnapshots.push(snapshot);
81
+ if (this.stateSnapshots.length > this.maxLogSize) {
82
+ this.stateSnapshots.shift();
83
+ }
84
+
85
+ if (snapshot.changed) {
86
+ console.log(`[SyncDebug] State changed: ${name}`, snapshot);
87
+ }
88
+ }
89
+
90
+ pushOperation(name) {
91
+ const marker = { name, start: Date.now() };
92
+ this.processingStack.push(marker);
93
+ console.log(`[SyncDebug] > ${name}`);
94
+ }
95
+
96
+ popOperation() {
97
+ const marker = this.processingStack.pop();
98
+ if (marker) {
99
+ const duration = Date.now() - marker.start;
100
+ console.log(`[SyncDebug] < ${marker.name} (${duration}ms)`);
101
+ if (duration > 100) {
102
+ console.warn(`[SyncDebug] SLOW: ${marker.name} took ${duration}ms`);
103
+ }
104
+ }
105
+ }
106
+
107
+ getReport() {
108
+ return {
109
+ totalEvents: this.eventLog.length,
110
+ eventCounts: this.eventCounts,
111
+ duplicates: this.eventLog.filter(e => e.isDuplicate).length,
112
+ stateChanges: this.stateSnapshots.length,
113
+ uniqueEventIds: this.processedEventIds.size,
114
+ recentEvents: this.eventLog.slice(-20),
115
+ recentStateChanges: this.stateSnapshots.slice(-20)
116
+ };
117
+ }
118
+
119
+ printReport() {
120
+ const report = this.getReport();
121
+ console.table(report);
122
+ console.table(report.recentEvents);
123
+ console.table(report.recentStateChanges);
124
+ }
125
+
126
+ clearLogs() {
127
+ this.eventLog = [];
128
+ this.stateSnapshots = [];
129
+ this.processedEventIds.clear();
130
+ this.eventCounts = {};
131
+ this.processingStack = [];
132
+ console.log('[SyncDebug] Logs cleared');
133
+ }
134
+ }
135
+
136
+ // Create global instance
137
+ window.syncDebugger = new SyncDebugger();
138
+
139
+ // Expose commands
140
+ window.debugSync = {
141
+ enable: () => window.syncDebugger.enable(),
142
+ disable: () => window.syncDebugger.disable(),
143
+ report: () => window.syncDebugger.printReport(),
144
+ clear: () => window.syncDebugger.clearLogs(),
145
+ get: () => window.syncDebugger.getReport()
146
+ };
147
+
148
+ console.log('[SyncDebug] Available. Use: debugSync.enable(), debugSync.report(), debugSync.clear()');