@yeaft/webchat-agent 1.0.284 → 1.0.286

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.
@@ -548,6 +548,13 @@ export async function handleAgentOutput(agentId, agent, msg) {
548
548
  bytes: Buffer.byteLength(JSON.stringify(msg)),
549
549
  });
550
550
  }
551
+ // History request completions are client-scoped; live Session output stays
552
+ // broadcast to every authenticated tab owned by this user.
553
+ const hasRequestedClient = typeof msg._requestClientId === 'string' && !!msg._requestClientId;
554
+ const requestedClient = hasRequestedClient ? webClients.get(msg._requestClientId) : null;
555
+ const outputClients = hasRequestedClient
556
+ ? (requestedClient ? [[msg._requestClientId, requestedClient]] : [])
557
+ : webClients;
551
558
  // Forward Yeaft Session output to all authenticated clients of this agent's owner.
552
559
  // Payload carries { conversationId, data } (assistant output frame) or { event } (metadata).
553
560
  //
@@ -572,12 +579,13 @@ export async function handleAgentOutput(agentId, agent, msg) {
572
579
  // pass through whatever was stamped, including empty strings. IDs
573
580
  // are non-empty in practice, but we don't want the relay silently
574
581
  // eating a legitimate "" or 0 if one ever shows up.
575
- for (const [cId, c] of webClients) {
582
+ for (const [cId, c] of outputClients) {
576
583
  if (c.authenticated && (CONFIG.skipAuth || c.userId === agent.ownerId)) {
577
584
  await sendToWebClient(c, {
578
585
  type: 'yeaft_output',
579
586
  conversationId: msg.conversationId,
580
587
  ...(msg.perfTraceId != null ? { perfTraceId: msg.perfTraceId } : {}),
588
+ ...(msg.requestId != null ? { requestId: msg.requestId } : {}),
581
589
  // Stamp the source agent so the web sessions store can keep
582
590
  // per-agent rosters (cross-agent listing in the unified
583
591
  // sidebar). Older web bundles ignore the extra field.
@@ -695,13 +703,19 @@ export async function handleAgentOutput(agentId, agent, msg) {
695
703
  detail: { mode: msg.mode || 'older', count: messages.length },
696
704
  });
697
705
  }
698
- for (const [cId, c] of webClients) {
706
+ const hasRequestedClient = typeof msg._requestClientId === 'string' && !!msg._requestClientId;
707
+ const requestedClient = hasRequestedClient ? webClients.get(msg._requestClientId) : null;
708
+ const historyClients = hasRequestedClient
709
+ ? (requestedClient ? [[msg._requestClientId, requestedClient]] : [])
710
+ : webClients;
711
+ for (const [cId, c] of historyClients) {
699
712
  if (c.authenticated && (CONFIG.skipAuth || c.userId === agent.ownerId)) {
700
713
  await sendToWebClient(c, {
701
714
  type: 'yeaft_history_chunk',
702
715
  agentId,
703
716
  conversationId: msg.conversationId,
704
717
  ...(msg.perfTraceId != null ? { perfTraceId: msg.perfTraceId } : {}),
718
+ ...(msg.requestId != null ? { requestId: msg.requestId } : {}),
705
719
  ...(msg.sessionId != null ? { sessionId: msg.sessionId } : {}),
706
720
  messages,
707
721
  mode: msg.mode || 'older',
@@ -184,71 +184,138 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
184
184
  });
185
185
  }
186
186
  }
187
- await broadcastAgentList();
188
187
  // Yeaft session rows are only actionable while their owning Agent is
189
188
  // connected. Keep offline rows in the DB for reconnect recovery, but do
190
189
  // not hydrate them into the sidebar where remove/settings cannot work.
191
- // Group by agentId so the web store can keep using per-agent snapshots.
192
- if (client.userId) {
193
- try {
194
- const allRows = yeaftSessionDb.getByUser(client.userId);
195
- const byAgent = groupOnlineYeaftSessions(allRows);
196
- for (const [agentId, sessions] of Object.entries(byAgent)) {
197
- await sendToWebClient(client, {
198
- type: 'yeaft_session_hydrate',
199
- agentId,
200
- sessions,
201
- fromDb: true,
202
- });
203
- }
204
- } catch (e) {
205
- console.warn('[Server] yeaft session hydrate failed:', e?.message || e);
190
+ // Send every authoritative slice before a single completion frame; the UI
191
+ // must not promote a partial multi-Agent inventory to its visible state.
192
+ let hydrateError = null;
193
+ try {
194
+ if (!client.userId) throw new Error('session owner unavailable');
195
+ const allRows = yeaftSessionDb.getByUser(client.userId);
196
+ const byAgent = groupOnlineYeaftSessions(allRows);
197
+ for (const [agentId, agent] of agents) {
198
+ const visible = agent?.ws?.readyState === 1 && (
199
+ CONFIG.skipAuth
200
+ || agent.ownerId === client.userId
201
+ || (!agent.ownerId && client.role === 'admin')
202
+ );
203
+ if (visible && !byAgent[agentId]) byAgent[agentId] = [];
206
204
  }
205
+ for (const [agentId, sessions] of Object.entries(byAgent)) {
206
+ await sendToWebClient(client, {
207
+ type: 'yeaft_session_hydrate',
208
+ ...(typeof msg.requestId === 'string' && msg.requestId ? { requestId: msg.requestId } : {}),
209
+ agentId,
210
+ sessions,
211
+ fromDb: true,
212
+ });
213
+ }
214
+ if (Object.keys(byAgent).length === 0) {
215
+ await sendToWebClient(client, {
216
+ type: 'yeaft_session_hydrate',
217
+ ...(typeof msg.requestId === 'string' && msg.requestId ? { requestId: msg.requestId } : {}),
218
+ agentId: null,
219
+ sessions: [],
220
+ fromDb: true,
221
+ });
222
+ }
223
+ } catch (e) {
224
+ hydrateError = e?.message || String(e);
225
+ console.warn('[Server] yeaft session hydrate failed:', hydrateError);
207
226
  }
227
+ await broadcastAgentList();
228
+ await sendToWebClient(client, {
229
+ type: 'yeaft_session_hydrate_complete',
230
+ ...(typeof msg.requestId === 'string' && msg.requestId ? { requestId: msg.requestId } : {}),
231
+ ok: hydrateError === null,
232
+ ...(hydrateError ? { error: hydrateError } : {}),
233
+ });
208
234
  break;
209
235
 
210
236
  case 'select_agent': {
211
- if (!await checkAgentAccess(msg.agentId)) break;
212
- const agent = agents.get(msg.agentId);
213
- if (agent && agent.ws.readyState === 1 /* WebSocket.OPEN */) {
214
- client.currentAgent = msg.agentId;
215
- if (!msg.silent) {
216
- client.currentConversation = null;
237
+ const silentSelection = msg.silent === true;
238
+ const explicitGenerationAtStart = Number(client.agentSelectionGeneration || 0);
239
+ let selectionGeneration;
240
+ if (silentSelection) {
241
+ // Background reconnect/restore is advisory. It cannot supersede a user
242
+ // selection, but concurrent silent restores still obey newest-wins.
243
+ if (client.pendingAgentSelectionGeneration != null) break;
244
+ selectionGeneration = Number(client.silentAgentSelectionGeneration || 0) + 1;
245
+ client.silentAgentSelectionGeneration = selectionGeneration;
246
+ } else {
247
+ selectionGeneration = explicitGenerationAtStart + 1;
248
+ client.agentSelectionGeneration = selectionGeneration;
249
+ client.pendingAgentSelectionGeneration = selectionGeneration;
250
+ }
251
+ const selectionIsCurrent = () => silentSelection
252
+ ? client.pendingAgentSelectionGeneration == null
253
+ && Number(client.agentSelectionGeneration || 0) === explicitGenerationAtStart
254
+ && client.silentAgentSelectionGeneration === selectionGeneration
255
+ : client.agentSelectionGeneration === selectionGeneration
256
+ && client.pendingAgentSelectionGeneration === selectionGeneration;
257
+ try {
258
+ const allowed = await checkAgentAccess(msg.agentId);
259
+ if (!selectionIsCurrent()) break;
260
+ if (!allowed) {
261
+ if (typeof msg.requestId === 'string' && msg.requestId) {
262
+ await sendToWebClient(client, {
263
+ type: 'agent_selected', requestId: msg.requestId, agentId: msg.agentId, ok: false,
264
+ });
265
+ }
266
+ break;
217
267
  }
218
-
219
- if (msg.silent) break;
220
-
221
- const filteredConvs = Array.from(agent.conversations.values()).filter(c =>
222
- CONFIG.skipAuth || !c.userId || c.userId === client.userId
223
- ).map(c => {
224
- // fix-chat-title-sticky: lazy-hydrate the title AND the
225
- // sticky bit on send. This catches conversations rebuilt
226
- // before the bit was wired through (older code paths,
227
- if (!c.title || c.customTitle === undefined) {
228
- const dbSession = sessionDb.get(c.id);
229
- if (dbSession?.title && !c.title) c.title = dbSession.title;
230
- if (c.customTitle === undefined) c.customTitle = !!dbSession?.customTitle;
268
+ const agent = agents.get(msg.agentId);
269
+ if (agent && agent.ws.readyState === 1 /* WebSocket.OPEN */) {
270
+ client.currentAgent = msg.agentId;
271
+ if (!silentSelection) {
272
+ client.currentConversation = null;
231
273
  }
232
- return c;
233
- });
234
- await sendToWebClient(client, {
235
- type: 'agent_selected',
236
- agentId: msg.agentId,
237
- agentName: agent.name,
238
- workDir: agent.workDir,
239
- capabilities: agent.capabilities || ['terminal', 'file_editor', 'background_tasks'],
240
- version: agent.version || null,
241
- conversations: filteredConvs,
242
- slashCommands: agent.slashCommands || [],
243
- slashCommandDescriptions: agent.slashCommandDescriptions || {}
244
- });
245
274
 
246
- // If slash commands cache is empty, ask Agent to reload from filesystem
247
- if (!agent.slashCommands?.length) {
248
- await forwardToAgent(msg.agentId, { type: 'request_slash_commands' });
275
+ if (silentSelection) break;
276
+
277
+ const filteredConvs = Array.from(agent.conversations.values()).filter(c =>
278
+ CONFIG.skipAuth || !c.userId || c.userId === client.userId
279
+ ).map(c => {
280
+ // fix-chat-title-sticky: lazy-hydrate the title AND the
281
+ // sticky bit on send. This catches conversations rebuilt
282
+ // before the bit was wired through (older code paths,
283
+ if (!c.title || c.customTitle === undefined) {
284
+ const dbSession = sessionDb.get(c.id);
285
+ if (dbSession?.title && !c.title) c.title = dbSession.title;
286
+ if (c.customTitle === undefined) c.customTitle = !!dbSession?.customTitle;
287
+ }
288
+ return c;
289
+ });
290
+ await sendToWebClient(client, {
291
+ type: 'agent_selected',
292
+ ...(typeof msg.requestId === 'string' && msg.requestId ? { requestId: msg.requestId } : {}),
293
+ agentId: msg.agentId,
294
+ agentName: agent.name,
295
+ workDir: agent.workDir,
296
+ capabilities: agent.capabilities || ['terminal', 'file_editor', 'background_tasks'],
297
+ version: agent.version || null,
298
+ conversations: filteredConvs,
299
+ slashCommands: agent.slashCommands || [],
300
+ slashCommandDescriptions: agent.slashCommandDescriptions || {}
301
+ });
302
+
303
+ // If slash commands cache is empty, ask Agent to reload from filesystem
304
+ if (!agent.slashCommands?.length) {
305
+ await forwardToAgent(msg.agentId, { type: 'request_slash_commands' });
306
+ }
307
+ } else {
308
+ await sendToWebClient(client, { type: 'error', message: 'Agent not found or offline' });
309
+ if (typeof msg.requestId === 'string' && msg.requestId) {
310
+ await sendToWebClient(client, {
311
+ type: 'agent_selected', requestId: msg.requestId, agentId: msg.agentId, ok: false,
312
+ });
313
+ }
314
+ }
315
+ } finally {
316
+ if (!silentSelection && client.pendingAgentSelectionGeneration === selectionGeneration) {
317
+ client.pendingAgentSelectionGeneration = null;
249
318
  }
250
- } else {
251
- await sendToWebClient(client, { type: 'error', message: 'Agent not found or offline' });
252
319
  }
253
320
  break;
254
321
  }
@@ -1067,9 +1134,11 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
1067
1134
  type: 'yeaft_load_history',
1068
1135
  limit: msg.limit,
1069
1136
  sessionId: msg.sessionId || null,
1137
+ ...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}),
1070
1138
  ...(typeof msg.perfTraceId === 'string' ? { perfTraceId: msg.perfTraceId } : {}),
1071
1139
  ...(Number.isFinite(msg.afterSeq) ? { afterSeq: msg.afterSeq } : {}),
1072
1140
  ...(typeof msg.afterMessageId === 'string' ? { afterMessageId: msg.afterMessageId } : {}),
1141
+ _requestClientId: clientId,
1073
1142
  };
1074
1143
  if (forwarded.perfTraceId) {
1075
1144
  recordPerfTraceEvent({
@@ -1152,9 +1221,11 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
1152
1221
  await forwardToAgent(moreAgentId, {
1153
1222
  type: 'yeaft_load_more_history',
1154
1223
  sessionId: msg.sessionId || null,
1224
+ requestId: typeof msg.requestId === 'string' ? msg.requestId : null,
1155
1225
  beforeSeq: typeof msg.beforeSeq === 'number' ? msg.beforeSeq : null,
1156
1226
  turns: typeof msg.turns === 'number' ? msg.turns : 20,
1157
1227
  ...(typeof msg.perfTraceId === 'string' ? { perfTraceId: msg.perfTraceId } : {}),
1228
+ _requestClientId: clientId,
1158
1229
  });
1159
1230
  break;
1160
1231
  }
@@ -93,7 +93,8 @@ export function handleWebConnection(ws, url, req = {}) {
93
93
  success: true,
94
94
  sessionKey: sessionKey ? encodeKey(sessionKey) : null,
95
95
  role,
96
- acceptPlaintext: true
96
+ acceptPlaintext: true,
97
+ yeaftSessionInventoryComplete: true,
97
98
  }));
98
99
  setTimeout(() => broadcastAgentList(), 100);
99
100
  } else {
@@ -1 +1 @@
1
- {"version":"1.0.284"}
1
+ {"version":"1.0.286"}