@yeaft/webchat-agent 1.0.50 → 1.0.51

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/history.js CHANGED
@@ -2,6 +2,7 @@ import { homedir } from 'os';
2
2
  import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, closeSync, fstatSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import ctx from './context.js';
5
+ import { normalizeClaudeMessage } from './sdk/message-normalize.js';
5
6
  import { getProvider, DEFAULT_PROVIDER } from './providers/index.js';
6
7
 
7
8
  // Claude 项目目录
@@ -227,7 +228,7 @@ function readTailMessages(filePath, limit) {
227
228
  const line = lines[i];
228
229
  if (!line || !line.trim()) continue;
229
230
  try {
230
- const data = JSON.parse(line);
231
+ const data = normalizeClaudeMessage(JSON.parse(line));
231
232
  if (data.type === 'user' || data.type === 'assistant') {
232
233
  collected.push(data);
233
234
  if (collected.length >= limit) break;
@@ -241,7 +242,7 @@ function readTailMessages(filePath, limit) {
241
242
  const headLine = carry.toString('utf-8').trim();
242
243
  if (headLine) {
243
244
  try {
244
- const data = JSON.parse(headLine);
245
+ const data = normalizeClaudeMessage(JSON.parse(headLine));
245
246
  if (data.type === 'user' || data.type === 'assistant') {
246
247
  collected.push(data);
247
248
  }
@@ -287,7 +288,7 @@ export function loadSessionHistory(workDir, claudeSessionId, limit = 500) {
287
288
 
288
289
  for (const line of lines) {
289
290
  try {
290
- const data = JSON.parse(line);
291
+ const data = normalizeClaudeMessage(JSON.parse(line));
291
292
  if (data.type === 'user' || data.type === 'assistant') {
292
293
  messages.push(data);
293
294
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.50",
3
+ "version": "1.0.51",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,129 @@
1
+ const TOOL_LIKE_BLOCK_TYPES = new Set([
2
+ 'tool_use',
3
+ 'tool_call',
4
+ 'function_call',
5
+ 'server_tool_use',
6
+ 'call',
7
+ ]);
8
+
9
+ /**
10
+ * Claude Code stream-json has changed its tool-call block spelling more than
11
+ * once. Normalize every known tool-like assistant content block to the
12
+ * Anthropic-compatible `tool_use` shape used by the rest of the app.
13
+ *
14
+ * @param {unknown} block
15
+ * @returns {unknown}
16
+ */
17
+ export function normalizeAssistantContentBlock(block) {
18
+ if (!block || typeof block !== 'object') return block;
19
+ if (block.type === 'tool_use') return block;
20
+ if (!TOOL_LIKE_BLOCK_TYPES.has(block.type)) return block;
21
+
22
+ const input = normalizeToolInput(block);
23
+ const name = block.name
24
+ || block.tool_name
25
+ || block.toolName
26
+ || block.function?.name
27
+ || block.action?.name
28
+ || input?.name
29
+ || 'tool';
30
+ const id = block.id
31
+ || block.tool_use_id
32
+ || block.toolUseId
33
+ || block.tool_call_id
34
+ || block.toolCallId
35
+ || block.call_id
36
+ || block.callId
37
+ || stableFallbackToolId(name, block);
38
+
39
+ return {
40
+ type: 'tool_use',
41
+ id,
42
+ name,
43
+ input,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * @param {unknown} content
49
+ * @returns {unknown}
50
+ */
51
+ export function normalizeAssistantContent(content) {
52
+ if (!Array.isArray(content)) return content;
53
+ return content.map(normalizeAssistantContentBlock);
54
+ }
55
+
56
+ /**
57
+ * @param {unknown} message
58
+ * @returns {unknown}
59
+ */
60
+ export function normalizeClaudeMessage(message) {
61
+ if (!message || typeof message !== 'object') return message;
62
+ if (message.type !== 'assistant' || !message.message) return message;
63
+ const content = message.message.content;
64
+ const normalized = normalizeAssistantContent(content);
65
+ if (normalized === content) return message;
66
+ return {
67
+ ...message,
68
+ message: {
69
+ ...message.message,
70
+ content: normalized,
71
+ },
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Stream events should only emit text deltas from actual text blocks. Newer
77
+ * Claude Code builds may emit text-looking deltas while a tool-call block is
78
+ * being assembled (`call`, transient id, JSON argument text). Those bytes are
79
+ * transport detail, not assistant prose; the final complete assistant message
80
+ * carries the real tool_use block.
81
+ *
82
+ * @param {string|undefined|null} blockType
83
+ * @returns {boolean}
84
+ */
85
+ export function shouldForwardTextDeltaForBlockType(blockType) {
86
+ if (!blockType) return true; // Back-compat for older CLIs without start events.
87
+ return blockType === 'text';
88
+ }
89
+
90
+ function stableFallbackToolId(name, block) {
91
+ const raw = stableStringify(block);
92
+ let hash = 2166136261;
93
+ for (let i = 0; i < raw.length; i++) {
94
+ hash ^= raw.charCodeAt(i);
95
+ hash = Math.imul(hash, 16777619) >>> 0;
96
+ }
97
+ return `${name}-${hash.toString(36)}`;
98
+ }
99
+
100
+ function stableStringify(value) {
101
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
102
+ if (value && typeof value === 'object') {
103
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
104
+ }
105
+ return JSON.stringify(value);
106
+ }
107
+
108
+ function normalizeToolInput(block) {
109
+ const raw = block.input
110
+ ?? block.arguments
111
+ ?? block.args
112
+ ?? block.parameters
113
+ ?? block.function?.arguments
114
+ ?? block.action?.arguments
115
+ ?? {};
116
+
117
+ if (typeof raw === 'string') {
118
+ const trimmed = raw.trim();
119
+ if (!trimmed) return {};
120
+ try {
121
+ const parsed = JSON.parse(trimmed);
122
+ return parsed && typeof parsed === 'object' ? parsed : { value: parsed };
123
+ } catch {
124
+ return { arguments: raw };
125
+ }
126
+ }
127
+ if (raw && typeof raw === 'object') return raw;
128
+ return {};
129
+ }
package/sdk/query.js CHANGED
@@ -7,6 +7,7 @@ import { spawn } from 'child_process';
7
7
  import { createInterface } from 'readline';
8
8
  import { Stream } from './stream.js';
9
9
  import { AbortError } from './types.js';
10
+ import { normalizeClaudeMessage, shouldForwardTextDeltaForBlockType } from './message-normalize.js';
10
11
  import { getCleanEnv, logDebug, streamToStdin, resolveClaudeCommand } from './utils.js';
11
12
 
12
13
 
@@ -82,6 +83,7 @@ export class Query {
82
83
  // Track whether we've forwarded text deltas for the current assistant turn.
83
84
  // When true, the next complete `assistant` message's text blocks are redundant.
84
85
  let hasStreamedTextDeltas = false;
86
+ const streamBlockTypes = new Map();
85
87
 
86
88
  try {
87
89
  for await (const line of rl) {
@@ -114,36 +116,52 @@ export class Query {
114
116
  const event = message.event;
115
117
  if (!event) continue;
116
118
 
117
- // content_block_delta with text_delta → convert to assistant message for streaming
119
+ if (event.type === 'content_block_start') {
120
+ streamBlockTypes.set(event.index, event.content_block?.type || null);
121
+ continue;
122
+ }
123
+ if (event.type === 'content_block_stop') {
124
+ streamBlockTypes.delete(event.index);
125
+ continue;
126
+ }
127
+ // content_block_delta with text_delta → convert to assistant message for streaming,
128
+ // but only while the active block is actually text. Some Claude Code builds
129
+ // expose tool-call assembly as text-looking deltas; forwarding those leaks
130
+ // raw `call`, transient ids, and JSON arguments into the chat transcript.
118
131
  if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta' && event.delta.text) {
119
- hasStreamedTextDeltas = true;
120
- this.inputStream.enqueue({
121
- type: 'assistant',
122
- message: {
123
- role: 'assistant',
124
- content: [{ type: 'text', text: event.delta.text }]
125
- }
126
- });
132
+ const blockType = streamBlockTypes.get(event.index);
133
+ if (shouldForwardTextDeltaForBlockType(blockType)) {
134
+ hasStreamedTextDeltas = true;
135
+ this.inputStream.enqueue({
136
+ type: 'assistant',
137
+ message: {
138
+ role: 'assistant',
139
+ content: [{ type: 'text', text: event.delta.text }]
140
+ }
141
+ });
142
+ }
127
143
  }
128
- // All other stream events (message_start, content_block_start,
129
- // input_json_delta, content_block_stop, message_stop) are ignored.
130
- // tool_use is handled via the complete assistant message.
144
+ // All other stream events (message_start, input_json_delta,
145
+ // message_stop) are ignored. Tool use is handled via the complete
146
+ // assistant message after block normalization below.
131
147
  continue;
132
148
  }
133
149
 
150
+ const normalizedMessage = normalizeClaudeMessage(message);
151
+
134
152
  // Deduplicate: when a complete assistant message arrives after we've
135
153
  // already streamed text deltas, strip the text blocks (already sent).
136
154
  // Keep tool_use blocks which are NOT sent incrementally.
137
- if (message.type === 'assistant' && hasStreamedTextDeltas) {
155
+ if (normalizedMessage.type === 'assistant' && hasStreamedTextDeltas) {
138
156
  hasStreamedTextDeltas = false; // Reset for next assistant turn
139
157
 
140
- const content = message.message?.content;
158
+ const content = normalizedMessage.message?.content;
141
159
  if (Array.isArray(content)) {
142
160
  const nonTextBlocks = content.filter(b => b.type !== 'text');
143
161
  if (nonTextBlocks.length > 0) {
144
162
  // Forward only tool_use blocks (text already sent via deltas)
145
- message.message.content = nonTextBlocks;
146
- this.inputStream.enqueue(message);
163
+ normalizedMessage.message.content = nonTextBlocks;
164
+ this.inputStream.enqueue(normalizedMessage);
147
165
  } else {
148
166
  // Pure text message fully streamed — send finish-streaming signal
149
167
  // so frontend clears isStreaming and typing dots can reappear
@@ -165,11 +183,12 @@ export class Query {
165
183
 
166
184
  // Reset delta tracking on non-assistant messages
167
185
  // (e.g., user, result, system — a new turn boundary)
168
- if (message.type !== 'assistant') {
186
+ if (normalizedMessage.type !== 'assistant') {
169
187
  hasStreamedTextDeltas = false;
188
+ streamBlockTypes.clear();
170
189
  }
171
190
 
172
- this.inputStream.enqueue(message);
191
+ this.inputStream.enqueue(normalizedMessage);
173
192
  } catch (e) {
174
193
  logDebug(`Non-JSON line: ${line.substring(0, 100)}`);
175
194
  }