@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 +161 -19
- package/package.json +1 -1
- package/synthetic-tools.js +41 -0
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
|
|
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
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
//
|
|
457
|
-
//
|
|
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 =
|
|
460
|
-
|
|
461
|
-
|
|
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}]
|
|
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
|
@@ -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
|
+
}
|