@yeaft/webchat-agent 0.1.896 → 0.1.897

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.
package/claude.js CHANGED
@@ -4,15 +4,18 @@ import { query, Stream } from './sdk/index.js';
4
4
  import ctx from './context.js';
5
5
  import { sendConversationList, sendOutput, sendError, handleAskUserQuestion } from './conversation.js';
6
6
  import { startSubagentWatcher, stopSubagentWatcher, cleanupSubagentWatchers } from './subagent.js';
7
+ import { SYNTHETIC_TOOL_NAMES } from './synthetic-tools.js';
7
8
 
8
9
  /**
9
10
  * Detect whether a user message is a Claude Code compact summary.
10
- * These appear after context compaction and should not be displayed in the UI.
11
+ * These appear after context compaction and should not be displayed as a
12
+ * normal user bubble — they're surfaced as a synthetic __CompactSummary
13
+ * tool action instead.
11
14
  *
12
15
  * @param {string} text — user message content
13
16
  * @returns {boolean}
14
17
  */
15
- function isCompactSummary(text) {
18
+ export function isCompactSummary(text) {
16
19
  if (!text || text.length < 200) return false;
17
20
  // Claude Code compact summary always starts with this exact text
18
21
  if (text.includes('This session is being continued from a previous conversation')) return true;
@@ -23,6 +26,105 @@ function isCompactSummary(text) {
23
26
  return false;
24
27
  }
25
28
 
29
+ /**
30
+ * Parse a Claude Code background-task notification.
31
+ * Claude CLI injects these as fake user messages after an Agent/Task tool
32
+ * finishes, e.g.:
33
+ *
34
+ * <task-notification>
35
+ * <task-id>...</task-id>
36
+ * <tool-use-id>...</tool-use-id>
37
+ * <output-file>...</output-file>
38
+ * <status>completed</status>
39
+ * <summary>...one-liner...</summary>
40
+ * <result>...full text...</result>
41
+ * </task-notification>
42
+ *
43
+ * We surface them as a synthetic __SubagentResult tool action so the UI
44
+ * doesn't render them as a giant user bubble.
45
+ *
46
+ * Returns null on degenerate input (no closing tag, every interesting field
47
+ * empty) so the caller falls through and the malformed text is at least
48
+ * visible somewhere debuggable rather than emitting a content-less ToolLine.
49
+ *
50
+ * @param {string} text
51
+ * @returns {{ taskId: string, toolUseId: string, outputFile: string, status: string, summary: string, result: string } | null}
52
+ */
53
+ export function parseTaskNotification(text) {
54
+ if (typeof text !== 'string') return null;
55
+ const trimmed = text.trimStart();
56
+ if (!trimmed.startsWith('<task-notification>')) return null;
57
+ // Greedy match — picks the LAST closing tag, so nested
58
+ // <task-notification> mentions inside <result> survive intact.
59
+ const pick = (tag) => {
60
+ const m = text.match(new RegExp(`<${tag}>([\\s\\S]*)<\\/${tag}>`));
61
+ return m ? m[1].trim() : '';
62
+ };
63
+ const parsed = {
64
+ taskId: pick('task-id'),
65
+ toolUseId: pick('tool-use-id'),
66
+ outputFile: pick('output-file'),
67
+ status: pick('status'),
68
+ summary: pick('summary'),
69
+ result: pick('result'),
70
+ };
71
+ // Degenerate notification (truncated stream, no closing tag, etc.) —
72
+ // every field that would carry meaning is empty. Decline to rewrite.
73
+ if (!parsed.status && !parsed.summary && !parsed.result) return null;
74
+ return parsed;
75
+ }
76
+
77
+ /**
78
+ * Extract a plain-text view of a Claude SDK message's content.
79
+ * The SDK is inconsistent about where the content lives and whether it's a
80
+ * string or an array of {type, text} blocks; this helper papers over all
81
+ * four observed shapes so callers can work with a single string.
82
+ *
83
+ * @param {object} message
84
+ * @returns {string}
85
+ */
86
+ export function extractUserText(message) {
87
+ if (!message) return '';
88
+ const candidates = [message.content, message.message?.content];
89
+ for (const c of candidates) {
90
+ if (typeof c === 'string') return c;
91
+ if (Array.isArray(c)) {
92
+ return c
93
+ .map(b => (b && typeof b === 'object' && typeof b.text === 'string') ? b.text : '')
94
+ .join('');
95
+ }
96
+ }
97
+ return '';
98
+ }
99
+
100
+ /**
101
+ * Build a synthetic assistant.tool_use wire message that reuses the existing
102
+ * tool-action persistence and rendering pipeline (`agent-output.js` stores
103
+ * tool_use blocks as DB rows with message_type='tool_use'; the web ToolLine
104
+ * component renders them with collapse/expand).
105
+ *
106
+ * The `synthetic-` id prefix is intentional — any log scraper or debugger
107
+ * can tell synthetic blocks apart from real tool_use blocks (real
108
+ * Anthropic IDs look like `toolu_01ABC...`).
109
+ *
110
+ * @param {string} name — synthetic tool name (one of SYNTHETIC_TOOL_NAMES)
111
+ * @param {object} input — toolInput payload
112
+ * @returns {object} a Claude SDK assistant-shaped message
113
+ */
114
+ export function buildSyntheticToolUseMessage(name, input) {
115
+ return {
116
+ type: 'assistant',
117
+ message: {
118
+ content: [{
119
+ type: 'tool_use',
120
+ id: `synthetic-${name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
121
+ name,
122
+ input,
123
+ }],
124
+ },
125
+ };
126
+ }
127
+
26
128
  /**
27
129
  * Determine maxContextTokens and autoCompactThreshold from model name.
28
130
  * Returns defaults suitable for the model's context window size.
@@ -393,6 +495,9 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
393
495
  if (message.subtype === 'compact_boundary') {
394
496
  state._compacting = false;
395
497
  state._compactSummaryPending = true;
498
+ // Reset content-fallback de-dup so the second compaction in a
499
+ // long-running session still broadcasts "completed".
500
+ state._compactCompleteSent = false;
396
501
  console.log(`[${conversationId}] Compact completed (boundary)`);
397
502
  ctx.sendToServer({
398
503
  type: 'compact_status',
@@ -415,6 +520,7 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
415
520
  if (message.subtype === 'compact_complete' || message.subtype === 'compact_end') {
416
521
  state._compacting = false;
417
522
  state._compactSummaryPending = true;
523
+ state._compactCompleteSent = false;
418
524
  console.log(`[${conversationId}] Compact completed`);
419
525
  ctx.sendToServer({
420
526
  type: 'compact_status',
@@ -443,25 +549,52 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
443
549
  continue;
444
550
  }
445
551
 
446
- // 过滤 compact summary 消息(compact_boundary 之后的 user 消息)
447
- if (message.type === 'user' && state._compactSummaryPending) {
448
- console.log(`[${conversationId}] Filtering compact summary message (pending flag)`);
449
- continue;
450
- }
451
- // compact 后的 <local-command-stdout>Compacted </local-command-stdout> 标记 summary 结束
452
- if (state._compactSummaryPending && message.type !== 'user') {
453
- state._compactSummaryPending = false;
454
- }
455
-
456
- // 兜底过滤: Claude Code compact summary 有时不触发 compact_boundary,
457
- // 直接以 user 消息形式出现。通过内容特征检测过滤。
552
+ // Recognise the two classes of "fake user messages" Claude Code injects
553
+ // back into the main conversation, and re-emit them as synthetic
554
+ // assistant.tool_use blocks so they reuse the standard ToolLine
555
+ // collapse/expand pipeline instead of showing as giant user bubbles.
556
+ //
557
+ // 1. <task-notification>...</task-notification> emitted when a
558
+ // background Task (Agent tool) finishes. Surfaced as
559
+ // __SubagentResult.
560
+ // 2. Compact summaries — emitted after context compaction. Surfaced
561
+ // as __CompactSummary. Two detection paths: the compact_boundary
562
+ // flag (set by the system message handler above) and a content
563
+ // fallback for sessions where Claude Code skips the boundary.
458
564
  if (message.type === 'user') {
459
- const userText = typeof message.content === 'string'
460
- ? message.content
461
- : (Array.isArray(message.content) ? message.content.map(b => b.text || '').join('') : '');
565
+ const userText = extractUserText(message);
566
+
567
+ // 1. <task-notification> from a completed background Agent/Task.
568
+ const parsedTask = parseTaskNotification(userText);
569
+ if (parsedTask) {
570
+ console.log(`[${conversationId}] Rewriting <task-notification> as __SubagentResult tool action (task=${parsedTask.taskId})`);
571
+ sendOutput(conversationId, buildSyntheticToolUseMessage(SYNTHETIC_TOOL_NAMES.SUBAGENT_RESULT, parsedTask));
572
+ continue;
573
+ }
574
+
575
+ // 2a. Compact summary tagged by an earlier compact_boundary system
576
+ // event AND matching the compact-summary content shape. Both signals
577
+ // required — the boundary flag alone is not enough to safely
578
+ // re-classify a user message, because the SDK can interleave other
579
+ // user-shaped messages (tool_result, slash-command metadata, even a
580
+ // genuine user keystroke caught mid-turn) right after the boundary.
581
+ // Without the content sniff we'd silently stuff that text into the
582
+ // synthetic summary field and lose it.
583
+ if (state._compactSummaryPending) {
584
+ if (userText && isCompactSummary(userText)) {
585
+ console.log(`[${conversationId}] Rewriting compact summary (pending flag + content) as __CompactSummary tool action`);
586
+ state._compactSummaryPending = false;
587
+ sendOutput(conversationId, buildSyntheticToolUseMessage(SYNTHETIC_TOOL_NAMES.COMPACT_SUMMARY, { summary: userText }));
588
+ continue;
589
+ }
590
+ // Pending flag set but the user message doesn't look like a compact
591
+ // summary — clear the flag (we missed the window) and fall through.
592
+ state._compactSummaryPending = false;
593
+ }
594
+
595
+ // 2b. Compact summary content fallback (no boundary event was emitted).
462
596
  if (userText && isCompactSummary(userText)) {
463
- console.log(`[${conversationId}] Filtering compact summary message (content match)`);
464
- // 补发 compact 完成通知(如果之前没发过)
597
+ console.log(`[${conversationId}] Rewriting compact summary (content match) as __CompactSummary tool action`);
465
598
  if (!state._compactCompleteSent) {
466
599
  state._compactCompleteSent = true;
467
600
  ctx.sendToServer({
@@ -471,9 +604,18 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
471
604
  message: 'Context compacted successfully'
472
605
  });
473
606
  }
607
+ sendOutput(conversationId, buildSyntheticToolUseMessage(SYNTHETIC_TOOL_NAMES.COMPACT_SUMMARY, { summary: userText }));
474
608
  continue;
475
609
  }
476
610
  }
611
+ // The compact-summary-pending flag was previously cleared by the first
612
+ // non-user message after the boundary. Now we clear it the moment we
613
+ // consume the user message above, so anything else that arrives is
614
+ // treated normally. Keep this defensive clear in case the SDK emits a
615
+ // non-user message before the summary user message lands.
616
+ if (state._compactSummaryPending && message.type !== 'user') {
617
+ state._compactSummaryPending = false;
618
+ }
477
619
 
478
620
  // 捕获 result 消息中的 usage 信息
479
621
  if (message.type === 'result') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.896",
3
+ "version": "0.1.897",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Synthetic tool names — single source of truth for Claude Chat's
3
+ * "fake user message" rewrites.
4
+ *
5
+ * Claude CLI injects two classes of system-generated user messages back into
6
+ * the main conversation:
7
+ *
8
+ * 1. <task-notification>...</task-notification> — emitted when a background
9
+ * Agent/Task tool finishes. Rewritten by `agent/claude.js` into a
10
+ * synthetic assistant.tool_use block with `name = SUBAGENT_RESULT`.
11
+ *
12
+ * 2. Compact summaries ("This session is being continued from a previous
13
+ * conversation...") — emitted after context compaction. Rewritten with
14
+ * `name = COMPACT_SUMMARY`.
15
+ *
16
+ * These names are persisted verbatim into SQLite's `messages.tool_name`
17
+ * column (see `server/handlers/agent-output.js` tool_use branch) and
18
+ * matched verbatim by `web/components/ToolLine.js` to pick the icon and
19
+ * one-line label. They are de-facto schema — renaming requires a DB
20
+ * migration for old rows, so don't rename without one.
21
+ *
22
+ * The `__` prefix is reserved for synthetic / project-internal tool names.
23
+ * Real Claude SDK tools and MCP tools must not use this prefix; if a future
24
+ * tool registry ever needs to enforce this, do it there. Today the
25
+ * convention is documented + relied upon by name collision being absent in
26
+ * practice.
27
+ */
28
+
29
+ export const SYNTHETIC_TOOL_NAMES = Object.freeze({
30
+ SUBAGENT_RESULT: '__SubagentResult',
31
+ COMPACT_SUMMARY: '__CompactSummary',
32
+ });
33
+
34
+ export const SYNTHETIC_TOOL_PREFIX = '__';
35
+
36
+ /** True when `name` is one of the project's synthetic tool sentinels. */
37
+ export function isSyntheticToolName(name) {
38
+ if (typeof name !== 'string') return false;
39
+ return name === SYNTHETIC_TOOL_NAMES.SUBAGENT_RESULT
40
+ || name === SYNTHETIC_TOOL_NAMES.COMPACT_SUMMARY;
41
+ }