@yeaft/webchat-agent 0.1.569 → 0.1.572

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.
@@ -36,7 +36,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
36
36
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
37
37
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
38
38
  import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings } from '../unify/config-api.js';
39
- import { handleUnifyChat, handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyTaskMessage, handleUnifyUserMemoryWrite, handleUnifyUserMemoryRemove, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyArchiveGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger } from '../unify/web-bridge.js';
39
+ import { handleUnifyChat, handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyTaskMessage, handleUnifyUserMemoryWrite, handleUnifyUserMemoryRemove, handleUnifyMemoryScopeList, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyArchiveGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger } from '../unify/web-bridge.js';
40
40
 
41
41
  export async function handleMessage(msg) {
42
42
  switch (msg.type) {
@@ -440,6 +440,11 @@ export async function handleMessage(msg) {
440
440
  case 'unify_user_memory_remove':
441
441
  handleUnifyUserMemoryRemove(msg);
442
442
  break;
443
+ // task-fix: list MemoryStore entries (scope-tree folder view) for the
444
+ // Unify "User Memory" page. Replies with `memory_scope_snapshot`.
445
+ case 'unify_memory_scope_list':
446
+ handleUnifyMemoryScopeList(msg);
447
+ break;
443
448
 
444
449
  // task-334m: Group CRUD + D1 seed wiring (§Δ10 334m + R6 §Δ31.2).
445
450
  // All handlers reply via `group_crud_result`; mutating ops additionally
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.569",
3
+ "version": "0.1.572",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -71,11 +71,24 @@ function serializeMessage(msg) {
71
71
  fm.push(`tokens_est: ${tokensEst}`);
72
72
 
73
73
  // Tool calls as YAML array (simplified)
74
+ // task-fix: persist `input` as base64-encoded JSON so multi-line tool
75
+ // arguments round-trip safely (YAML string escaping is brittle for
76
+ // JSON blobs with newlines / quotes). Paired with the parser below.
74
77
  if (msg.toolCalls && msg.toolCalls.length > 0) {
75
78
  fm.push(`toolCalls:`);
76
79
  for (const tc of msg.toolCalls) {
77
80
  fm.push(` - id: ${tc.id}`);
78
81
  fm.push(` name: ${tc.name}`);
82
+ if (tc.input !== undefined) {
83
+ try {
84
+ const b64 = Buffer.from(JSON.stringify(tc.input)).toString('base64');
85
+ fm.push(` inputB64: ${b64}`);
86
+ } catch {
87
+ // best-effort: if input isn't JSON-serializable, skip it;
88
+ // restoring a tool_call without input is still better than
89
+ // dropping the whole record.
90
+ }
91
+ }
79
92
  }
80
93
  }
81
94
 
@@ -138,13 +151,21 @@ export function parseMessage(raw) {
138
151
  for (const entry of entries) {
139
152
  const tc = {};
140
153
  for (const line of entry.split('\n')) {
141
- const trimmed = line.trim();
154
+ // task-fix: the split regex only strips `\n - ` between
155
+ // entries, leaving a leading `- ` on the first line of the
156
+ // first entry. Strip it here so `- id: xxx` parses as `id`.
157
+ const trimmed = line.trim().replace(/^-\s+/, '');
142
158
  const ci = trimmed.indexOf(':');
143
159
  if (ci === -1) continue;
144
160
  const k = trimmed.slice(0, ci).trim();
145
161
  const v = trimmed.slice(ci + 1).trim();
146
162
  if (k === 'id') tc.id = v;
147
163
  if (k === 'name') tc.name = v;
164
+ if (k === 'inputB64') {
165
+ try {
166
+ tc.input = JSON.parse(Buffer.from(v, 'base64').toString('utf8'));
167
+ } catch { /* best-effort: leave input undefined */ }
168
+ }
148
169
  }
149
170
  if (tc.id && tc.name) toolCalls.push(tc);
150
171
  }
@@ -65,19 +65,53 @@ export async function runStopHooks(context) {
65
65
  }
66
66
 
67
67
  // 1. Persist latest messages
68
+ //
69
+ // task-fix: we must persist the complete new turn — including the
70
+ // assistant's `toolCalls` and each paired `role:'tool'` result —
71
+ // otherwise restoring history on session reload drops the pairing
72
+ // and causes "No tool output found for function call" 400s on the
73
+ // next chat-completions request. We walk back from the end of
74
+ // `messages` to find the first `role:'user'` that marks the start
75
+ // of the current turn, then persist everything from there forward.
68
76
  try {
69
77
  if (conversationStore && messages.length > 0) {
70
- const recentMessages = messages.slice(-2); // last user + assistant pair
78
+ // Find the start of the latest turn the last `role:'user'`
79
+ // message in the array. Everything from that index onward is
80
+ // new this turn (user + assistant[+toolCalls] + tool results).
81
+ let turnStart = messages.length - 1;
82
+ for (let i = messages.length - 1; i >= 0; i--) {
83
+ if (messages[i] && messages[i].role === 'user') {
84
+ turnStart = i;
85
+ break;
86
+ }
87
+ }
88
+ const recentMessages = messages.slice(turnStart);
71
89
  for (const msg of recentMessages) {
72
- if (msg.role && msg.content) {
73
- conversationStore.append({
74
- role: msg.role,
75
- content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
76
- mode,
77
- model: persistModel,
78
- });
79
- result.messagesPersisted++;
90
+ if (!msg || !msg.role) continue;
91
+ // Allow empty assistant content when toolCalls are present;
92
+ // tool messages have content by construction.
93
+ const hasContent =
94
+ (typeof msg.content === 'string' && msg.content.length > 0) ||
95
+ (msg.content && typeof msg.content !== 'string') ||
96
+ (Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0) ||
97
+ msg.role === 'tool';
98
+ if (!hasContent) continue;
99
+
100
+ const record = {
101
+ role: msg.role,
102
+ content: typeof msg.content === 'string'
103
+ ? msg.content
104
+ : JSON.stringify(msg.content ?? ''),
105
+ mode,
106
+ model: persistModel,
107
+ };
108
+ if (msg.toolCallId) record.toolCallId = msg.toolCallId;
109
+ if (Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0) {
110
+ record.toolCalls = msg.toolCalls;
80
111
  }
112
+ if (msg.isError) record.isError = true;
113
+ conversationStore.append(record);
114
+ result.messagesPersisted++;
81
115
  }
82
116
  }
83
117
  } catch (err) {
@@ -147,6 +147,16 @@ function _fanout(evt) {
147
147
  function ensureLoader(registry = defaultRegistry) {
148
148
  if (_loaderStarted) return { loader: _loader, fresh: false };
149
149
  _loaderStarted = true;
150
+ // For NON-default registries (unit tests seeding VPs manually) we MUST NOT
151
+ // start VpLoader — its .start() scans DEFAULT_VP_LIB_DIR and push-imports
152
+ // every on-disk VP into the test registry, overwriting/augmenting the
153
+ // fixture. Tests don't need hot-reload anyway; `_broadcastChangeForTest`
154
+ // drives the diff path directly.
155
+ if (registry !== defaultRegistry) {
156
+ _loader = null;
157
+ captureState(registry);
158
+ return { loader: null, fresh: true };
159
+ }
150
160
  try {
151
161
  _loader = new VpLoader({
152
162
  registry,
@@ -86,6 +86,39 @@ function getThreadMessages(threadId) {
86
86
  return arr;
87
87
  }
88
88
 
89
+ /**
90
+ * Restore per-thread message history from persisted conversation store.
91
+ *
92
+ * task-fix: accept `role:'tool'` messages AND preserve `toolCalls` /
93
+ * `toolCallId` fields. Without this, chat-completions serialization
94
+ * emits `tool_calls` without paired `role:'tool'` results, causing
95
+ * "No tool output found for function call" 400s after the first tool
96
+ * use across any restart / session-ready / model-switch event.
97
+ *
98
+ * @param {Array<object>} recent — output of conversationStore.loadRecent()
99
+ */
100
+ function restoreThreadHistoryFromRecent(recent) {
101
+ messagesByThread.clear();
102
+ for (const m of recent) {
103
+ // Keep user, assistant, AND tool messages. Tool messages are required
104
+ // for the chat-completions `tool_call_id` pairing.
105
+ if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool') continue;
106
+ const tid = m.threadId || MAIN_THREAD_ID;
107
+ const bucket = getThreadMessages(tid);
108
+ const entry = { role: m.role, content: m.content };
109
+ if (m.toolCallId) entry.toolCallId = m.toolCallId;
110
+ if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
111
+ entry.toolCalls = m.toolCalls.map(tc => ({
112
+ id: tc.id,
113
+ name: tc.name,
114
+ input: tc.input,
115
+ }));
116
+ }
117
+ if (m.isError) entry.isError = true;
118
+ bucket.push(entry);
119
+ }
120
+ }
121
+
89
122
  /** Whether we've already sent a permission warning to the UI */
90
123
  let _permissionDiagnosticSent = false;
91
124
 
@@ -257,6 +290,41 @@ export function handleUnifyUserMemoryRemove(msg) {
257
290
  _handleUnifyUserMemoryRemove(msg, sendUnifyEvent);
258
291
  }
259
292
 
293
+ /**
294
+ * task-fix: list MemoryStore entries as a scope-tree for the
295
+ * UserMemoryPage "folder view". Entries come from MemoryStore.listEntries()
296
+ * and are grouped client-side by their `scope` (a `/`-separated path like
297
+ * `work/claude-web-chat/auth`).
298
+ *
299
+ * Request shape: { type: 'unify_memory_scope_list', requestId? }
300
+ * Reply shape: { type: 'memory_scope_snapshot',
301
+ * entries: Array<{name,scope,kind,tags,importance,
302
+ * frequency,created_at,updated_at,
303
+ * content}>,
304
+ * requestId? }
305
+ */
306
+ export function handleUnifyMemoryScopeList(msg) {
307
+ const requestId = msg && typeof msg.requestId === 'string' ? msg.requestId : undefined;
308
+ try {
309
+ const store = session && session.memoryStore;
310
+ const entries = store && typeof store.listEntries === 'function'
311
+ ? store.listEntries()
312
+ : [];
313
+ sendUnifyEvent({
314
+ type: 'memory_scope_snapshot',
315
+ entries,
316
+ ...(requestId ? { requestId } : {}),
317
+ });
318
+ } catch (err) {
319
+ sendUnifyEvent({
320
+ type: 'memory_scope_snapshot',
321
+ entries: [],
322
+ error: String(err && err.message || err),
323
+ ...(requestId ? { requestId } : {}),
324
+ });
325
+ }
326
+ }
327
+
260
328
  export function handleUnifyVpRead(msg) {
261
329
  const requestId = msg && msg.requestId;
262
330
  const vpId = msg && msg.vpId;
@@ -754,7 +822,7 @@ function forwardPipelineEvent(ev, ctx) {
754
822
  *
755
823
  * @param {object} event — engine event (text_delta / tool_call / …)
756
824
  * @param {string} threadId — owning thread id (from envelope)
757
- * @param {{assistantTextParts:string[], resetQueryTimer:Function}} hctx
825
+ * @param {{assistantTextParts:string[], toolCallsAccum:Array, toolResultsAccum:Array, resetQueryTimer:Function}} hctx
758
826
  */
759
827
  function handleEngineEvent(event, threadId, hctx) {
760
828
  hctx.resetQueryTimer();
@@ -790,6 +858,16 @@ function handleEngineEvent(event, threadId, hctx) {
790
858
  break;
791
859
 
792
860
  case 'tool_call':
861
+ // Capture tool_call for the assistant message's toolCalls array so the
862
+ // next turn's history correctly pairs `tool_calls` with `role:'tool'`
863
+ // results (fixes "No tool output found for function call" 400s).
864
+ if (hctx.toolCallsAccum) {
865
+ hctx.toolCallsAccum.push({
866
+ id: event.id,
867
+ name: event.name,
868
+ input: event.input,
869
+ });
870
+ }
793
871
  // Finish any in-progress text streaming so UI shows typing dots
794
872
  sendUnifyOutput({
795
873
  type: 'assistant',
@@ -820,6 +898,17 @@ function handleEngineEvent(event, threadId, hctx) {
820
898
  break;
821
899
 
822
900
  case 'tool_end':
901
+ // Capture tool result for the next-turn history so the paired
902
+ // `role:'tool'` message is included when we hand `messages` back to
903
+ // engine.query() (chat-completions requires tool_call_id pairing).
904
+ if (hctx.toolResultsAccum) {
905
+ hctx.toolResultsAccum.push({
906
+ role: 'tool',
907
+ toolCallId: event.id,
908
+ content: typeof event.output === 'string' ? event.output : JSON.stringify(event.output ?? ''),
909
+ isError: !!event.isError,
910
+ });
911
+ }
823
912
  sendUnifyOutput({
824
913
  type: 'user',
825
914
  tool_use_result: [{
@@ -1113,14 +1202,9 @@ export async function handleUnifyChat(msg) {
1113
1202
 
1114
1203
  // Restore per-thread history from persisted conversation store.
1115
1204
  // task-320: bucket by threadId so each thread keeps its own context.
1116
- messagesByThread.clear();
1117
- const recent = session.conversationStore.loadRecent(50);
1118
- for (const m of recent) {
1119
- if (m.role !== 'user' && m.role !== 'assistant') continue;
1120
- const tid = m.threadId || MAIN_THREAD_ID;
1121
- const bucket = getThreadMessages(tid);
1122
- bucket.push({ role: m.role, content: m.content });
1123
- }
1205
+ // task-fix: use restoreThreadHistoryFromRecent() so tool messages
1206
+ // and toolCalls/toolCallId survive the restore.
1207
+ restoreThreadHistoryFromRecent(session.conversationStore.loadRecent(50));
1124
1208
 
1125
1209
  // Notify UI: session is ready with model info + conversationId
1126
1210
  sendUnifyEvent({
@@ -1176,6 +1260,11 @@ export async function handleUnifyChat(msg) {
1176
1260
  try {
1177
1261
  // ─── Collect assistant response for conversation history ──
1178
1262
  let assistantTextParts = [];
1263
+ // task-fix: preserve toolCalls + tool_result pairings across turns so
1264
+ // the next engine.query({messages}) handoff stays valid for OpenAI
1265
+ // chat-completions (avoids "No tool output found for function call").
1266
+ const toolCallsAccum = [];
1267
+ const toolResultsAccum = [];
1179
1268
 
1180
1269
  // task-310: route via Dispatcher pipeline (queue → router → registry →
1181
1270
  // EngineInstance). The input is enqueued first so the UI observes the
@@ -1203,6 +1292,8 @@ export async function handleUnifyChat(msg) {
1203
1292
  const pipelineCtx = {
1204
1293
  onEngineEvent: (event, threadId) => handleEngineEvent(event, threadId, {
1205
1294
  assistantTextParts,
1295
+ toolCallsAccum,
1296
+ toolResultsAccum,
1206
1297
  resetQueryTimer,
1207
1298
  }),
1208
1299
  onError: (err) => { throw err; },
@@ -1226,13 +1317,38 @@ export async function handleUnifyChat(msg) {
1226
1317
 
1227
1318
  // ─── Query complete — accumulate messages for context continuity ──
1228
1319
  // task-320: per-thread history (no cross-thread contamination).
1320
+ // task-fix: when the turn made tool calls, the assistant message MUST
1321
+ // carry `toolCalls` AND each paired `role:'tool'` result must be
1322
+ // appended — otherwise the next turn's chat-completions serializer
1323
+ // emits `tool_calls` without matching `tool` messages → proxy 400
1324
+ // "No tool output found for function call call_xxx".
1229
1325
  const historyThread = resolvedThreadId || MAIN_THREAD_ID;
1230
1326
  const threadMessages = getThreadMessages(historyThread);
1231
1327
  threadMessages.push({ role: 'user', content: cleanedPrompt });
1232
1328
 
1233
1329
  const fullText = assistantTextParts.join('');
1234
- if (fullText) {
1235
- threadMessages.push({ role: 'assistant', content: fullText });
1330
+ if (fullText || toolCallsAccum.length > 0) {
1331
+ const assistantMsg = { role: 'assistant', content: fullText };
1332
+ if (toolCallsAccum.length > 0) {
1333
+ assistantMsg.toolCalls = toolCallsAccum.map(tc => ({
1334
+ id: tc.id,
1335
+ name: tc.name,
1336
+ input: tc.input,
1337
+ }));
1338
+ }
1339
+ threadMessages.push(assistantMsg);
1340
+
1341
+ // Append paired tool results, in order. Chat-completions requires
1342
+ // one `role:'tool'` message per `tool_call_id` right after the
1343
+ // assistant message that emitted them.
1344
+ for (const tr of toolResultsAccum) {
1345
+ threadMessages.push({
1346
+ role: 'tool',
1347
+ toolCallId: tr.toolCallId,
1348
+ content: tr.content,
1349
+ isError: tr.isError,
1350
+ });
1351
+ }
1236
1352
  }
1237
1353
 
1238
1354
  // ─── Signal turn end to UI ──
@@ -1627,14 +1743,9 @@ export async function handleUnifyLoadHistory(msg) {
1627
1743
 
1628
1744
  // Restore per-thread history from persisted conversation store.
1629
1745
  // task-320: bucket by threadId so each thread keeps its own context.
1630
- messagesByThread.clear();
1631
- const recent = session.conversationStore.loadRecent(50);
1632
- for (const m of recent) {
1633
- if (m.role !== 'user' && m.role !== 'assistant') continue;
1634
- const tid = m.threadId || MAIN_THREAD_ID;
1635
- const bucket = getThreadMessages(tid);
1636
- bucket.push({ role: m.role, content: m.content });
1637
- }
1746
+ // task-fix: use restoreThreadHistoryFromRecent() so tool messages
1747
+ // and toolCalls/toolCallId survive the restore.
1748
+ restoreThreadHistoryFromRecent(session.conversationStore.loadRecent(50));
1638
1749
  }
1639
1750
 
1640
1751
  // task-322: replay `session_ready` + `thread_list_updated` UNCONDITIONALLY
@@ -1739,12 +1850,9 @@ export async function resetUnifySession() {
1739
1850
  unifyConversationId = `unify-${Date.now()}`;
1740
1851
 
1741
1852
  // Restore per-thread history for LLM context (task-320).
1742
- const recent = session.conversationStore.loadRecent(50);
1743
- for (const m of recent) {
1744
- if (m.role !== 'user' && m.role !== 'assistant') continue;
1745
- const tid = m.threadId || MAIN_THREAD_ID;
1746
- getThreadMessages(tid).push({ role: m.role, content: m.content });
1747
- }
1853
+ // task-fix: use restoreThreadHistoryFromRecent() so tool messages
1854
+ // and toolCalls/toolCallId survive the restore.
1855
+ restoreThreadHistoryFromRecent(session.conversationStore.loadRecent(50));
1748
1856
 
1749
1857
  sendUnifyEvent({
1750
1858
  type: 'session_ready',