@assistkick/create 1.32.0 → 1.34.0

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.
Files changed (26) hide show
  1. package/package.json +1 -1
  2. package/templates/assistkick-product-system/package.json +3 -1
  3. package/templates/assistkick-product-system/packages/backend/src/server.ts +1 -1
  4. package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts +41 -16
  5. package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts +25 -5
  6. package/templates/assistkick-product-system/packages/frontend/src/components/GraphLegend.tsx +10 -3
  7. package/templates/assistkick-product-system/packages/frontend/src/constants/graph.ts +8 -1
  8. package/templates/assistkick-product-system/packages/frontend/src/hooks/use_chat_stream.ts +23 -32
  9. package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.test.ts +105 -0
  10. package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.ts +48 -1
  11. package/templates/assistkick-product-system/packages/shared/db/migrate.ts +46 -2
  12. package/templates/assistkick-product-system/packages/shared/db/migrations/0003_solid_manta.sql +1 -0
  13. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0003_snapshot.json +1836 -0
  14. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +7 -0
  15. package/templates/assistkick-product-system/packages/shared/db/schema.ts +1 -0
  16. package/templates/assistkick-product-system/packages/shared/lib/constants.ts +12 -0
  17. package/templates/assistkick-product-system/packages/shared/lib/embedding_service.test.ts +75 -0
  18. package/templates/assistkick-product-system/packages/shared/lib/embedding_service.ts +100 -0
  19. package/templates/assistkick-product-system/packages/shared/lib/relevance_search.ts +50 -38
  20. package/templates/assistkick-product-system/packages/shared/package.json +1 -0
  21. package/templates/assistkick-product-system/packages/shared/tools/add_node.ts +11 -3
  22. package/templates/assistkick-product-system/packages/shared/tools/delete_note.ts +50 -0
  23. package/templates/assistkick-product-system/packages/shared/tools/save_note.ts +79 -0
  24. package/templates/assistkick-product-system/packages/shared/tools/search_nodes.ts +6 -1
  25. package/templates/assistkick-product-system/packages/shared/tools/search_notes.ts +99 -0
  26. package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +15 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistkick/create",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "description": "Scaffold assistkick-product-system into any project",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,9 @@
17
17
  "remotion:render": "pnpm --filter @assistkick/video render"
18
18
  },
19
19
  "pnpm": {
20
- "onlyBuiltDependencies": ["esbuild", "node-pty", "isolated-vm"]
20
+ "onlyBuiltDependencies": [
21
+ "onnxruntime-node"
22
+ ]
21
23
  },
22
24
  "devDependencies": {
23
25
  "@libsql/client": "^0.17.0",
@@ -269,7 +269,7 @@ const chatWss = new WebSocketServer({ noServer: true });
269
269
  const chatCliBridge = new ChatCliBridge({ projectRoot: paths.projectRoot, workspacesDir: paths.workspacesDir, dataDir: paths.runtimeDataDir, log });
270
270
  const permissionService = new PermissionService({ getDb, log });
271
271
  const titleGeneratorService = new TitleGeneratorService({ log });
272
- const chatHandler = new ChatWsHandler({ wss: chatWss, authService, chatCliBridge, permissionService, chatMessageRepository, chatSessionService, titleGeneratorService, mcpConfigService, log });
272
+ const chatHandler = new ChatWsHandler({ wss: chatWss, authService, chatCliBridge, permissionService, chatMessageRepository, chatSessionService, titleGeneratorService, mcpConfigService, agentService, log });
273
273
 
274
274
  // Resume any CLI sessions left running by the previous server instance
275
275
  chatHandler.resumeSessions();
@@ -22,6 +22,26 @@ import { tmpdir } from 'node:os';
22
22
 
23
23
  export type PermissionMode = 'skip' | 'allowed_tools';
24
24
 
25
+ /** The well-known ID for the default chat agent (seeded in migrate.ts). */
26
+ export const CHAT_AGENT_ID = 'chat-agent-default';
27
+
28
+ /** Fallback grounding used when no chat agent is found in the database. */
29
+ const DEFAULT_CHAT_GROUNDING = `We are working on project-id {{projectId}}
30
+
31
+ ## Project Workspace
32
+ The project workspace is at: {{workspacePath}}
33
+ All file edits (write, edit, delete, create) MUST target files inside the workspace directory above.
34
+ You may read files from anywhere in the project root for context, but all changes go into the workspace.
35
+
36
+ ## Git Repository Structure
37
+ The workspace ({{workspacePath}}) has its OWN independent git repo.
38
+ When committing and pushing changes, always use the workspace git repo, not the top-level repo.
39
+
40
+ ## Skills
41
+ Project skills (e.g. assistkick-interview, assistkick-developer, etc.) are available via the Skill tool.
42
+ To invoke a skill, always use the Skill tool (e.g. Skill with skill: "assistkick-interview"). Do NOT manually read or execute skill files — the Skill tool handles loading and execution.
43
+ Only edit code and project files — never modify skill definition files.`;
44
+
25
45
  export interface ChatCliBridgeDeps {
26
46
  projectRoot: string;
27
47
  workspacesDir: string;
@@ -40,6 +60,8 @@ export interface SpawnOptions {
40
60
  continuationContext?: string;
41
61
  /** Path to a temporary MCP config JSON file to pass via --mcp-config */
42
62
  mcpConfigPath?: string;
63
+ /** Agent grounding template with {{placeholder}} variables. When provided, replaces the hardcoded system prompt. */
64
+ agentGrounding?: string;
43
65
  onEvent: (event: Record<string, unknown>) => void;
44
66
  }
45
67
 
@@ -117,7 +139,7 @@ export class ChatCliBridge {
117
139
  };
118
140
 
119
141
  buildArgs = (options: SpawnOptions): { args: string[]; systemPrompt: string | null } => {
120
- const { projectId, claudeSessionId, isNewSession, permissionMode, allowedTools, continuationContext } = options;
142
+ const { projectId, claudeSessionId, isNewSession, permissionMode, allowedTools, continuationContext, agentGrounding } = options;
121
143
  const args: string[] = [];
122
144
  let systemPrompt: string | null = null;
123
145
 
@@ -150,22 +172,17 @@ export class ChatCliBridge {
150
172
  if (continuationContext) {
151
173
  systemParts.push(continuationContext);
152
174
  }
153
- const wsPath = this.getWorkspacePath(projectId);
154
- systemParts.push(`We are working on project-id ${projectId}
155
-
156
- ## Project Workspace
157
- The project workspace is at: ${wsPath}
158
- All file edits (write, edit, delete, create) MUST target files inside the workspace directory above.
159
- You may read files from anywhere in the project root for context, but all changes go into the workspace.
160
175
 
161
- ## Git Repository Structure
162
- The workspace (${wsPath}) has its OWN independent git repo.
163
- When committing and pushing changes, always use the workspace git repo, not the top-level repo.
164
-
165
- ## Skills
166
- Project skills (e.g. assistkick-interview, assistkick-developer, etc.) are available via the Skill tool.
167
- To invoke a skill, always use the Skill tool (e.g. Skill with skill: "assistkick-interview"). Do NOT manually read or execute skill files — the Skill tool handles loading and execution.
168
- Only edit code and project files never modify skill definition files.`);
176
+ const wsPath = this.getWorkspacePath(projectId);
177
+ const vars: Record<string, string> = { projectId, workspacePath: wsPath };
178
+
179
+ if (agentGrounding) {
180
+ // Use agent grounding with resolved {{placeholder}} variables
181
+ systemParts.push(this.resolveTemplate(agentGrounding, vars));
182
+ } else {
183
+ // Fallback: hardcoded default (should not happen once chat agent is seeded)
184
+ systemParts.push(this.resolveTemplate(DEFAULT_CHAT_GROUNDING, vars));
185
+ }
169
186
 
170
187
  systemPrompt = systemParts.join('\n\n');
171
188
  args.push('--append-system-prompt', systemPrompt);
@@ -174,6 +191,14 @@ export class ChatCliBridge {
174
191
  return { args, systemPrompt };
175
192
  };
176
193
 
194
+ /** Replace {{placeholder}} variables in a template string. */
195
+ private resolveTemplate = (template: string, vars: Record<string, string>): string => {
196
+ return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
197
+ if (key in vars) return vars[key];
198
+ return `{{${key}}}`;
199
+ });
200
+ };
201
+
177
202
  buildEnv = (): Record<string, string> => {
178
203
  const env = { ...process.env } as Record<string, string>;
179
204
  const home = env.HOME || '/root';
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * Server → Client messages:
14
14
  * { type: 'stream_start', claudeSessionId }
15
- * { type: 'stream_event', event } — forwarded parsed stream-json event
15
+ * { type: 'stream_event', event, claudeSessionId } — forwarded parsed stream-json event
16
16
  * { type: 'stream_end', exitCode, claudeSessionId }
17
17
  * { type: 'stream_cancelled', claudeSessionId } — user-initiated stop
18
18
  * { type: 'stream_error', error }
@@ -30,6 +30,8 @@ import type { ChatMessageRepository } from './chat_message_repository.js';
30
30
  import type { ChatSessionService } from './chat_session_service.js';
31
31
  import type { TitleGeneratorService } from './title_generator_service.js';
32
32
  import type { McpConfigService } from './mcp_config_service.js';
33
+ import type { AgentService } from './agent_service.js';
34
+ import { CHAT_AGENT_ID } from './chat_cli_bridge.js';
33
35
  import { writeFileSync, unlinkSync } from 'node:fs';
34
36
  import { tmpdir } from 'node:os';
35
37
  import { join } from 'node:path';
@@ -43,6 +45,7 @@ export interface ChatWsHandlerDeps {
43
45
  chatSessionService?: ChatSessionService;
44
46
  titleGeneratorService?: TitleGeneratorService;
45
47
  mcpConfigService?: McpConfigService;
48
+ agentService?: AgentService;
46
49
  log: (tag: string, ...args: unknown[]) => void;
47
50
  }
48
51
 
@@ -90,6 +93,7 @@ export interface StreamStartMessage {
90
93
  export interface StreamEventMessage {
91
94
  type: 'stream_event';
92
95
  event: Record<string, unknown>;
96
+ claudeSessionId: string;
93
97
  }
94
98
 
95
99
  export interface StreamEndMessage {
@@ -243,6 +247,7 @@ export class ChatWsHandler {
243
247
  private readonly chatSessionService: ChatSessionService | null;
244
248
  private readonly titleGeneratorService: TitleGeneratorService | null;
245
249
  private readonly mcpConfigService: McpConfigService | null;
250
+ private readonly agentService: AgentService | null;
246
251
  private readonly log: ChatWsHandlerDeps['log'];
247
252
  private readonly activeStreams = new Map<WebSocket, Set<string>>();
248
253
 
@@ -258,7 +263,7 @@ export class ChatWsHandler {
258
263
  /** Map WebSocket connections to their authenticated user ID. */
259
264
  private readonly wsUserIds = new Map<WebSocket, string>();
260
265
 
261
- constructor({ wss, authService, chatCliBridge, permissionService, chatMessageRepository, chatSessionService, titleGeneratorService, mcpConfigService, log }: ChatWsHandlerDeps) {
266
+ constructor({ wss, authService, chatCliBridge, permissionService, chatMessageRepository, chatSessionService, titleGeneratorService, mcpConfigService, agentService, log }: ChatWsHandlerDeps) {
262
267
  this.wss = wss;
263
268
  this.authService = authService;
264
269
  this.chatCliBridge = chatCliBridge;
@@ -267,6 +272,7 @@ export class ChatWsHandler {
267
272
  this.chatSessionService = chatSessionService || null;
268
273
  this.titleGeneratorService = titleGeneratorService || null;
269
274
  this.mcpConfigService = mcpConfigService || null;
275
+ this.agentService = agentService || null;
270
276
  this.log = log;
271
277
 
272
278
  // Register the permission request handler to route requests to the right WebSocket
@@ -285,7 +291,7 @@ export class ChatWsHandler {
285
291
  // Forward to connected WS (if any)
286
292
  const currentWs = this.sessionToWs.get(claudeSessionId);
287
293
  if (currentWs) {
288
- this.sendWsMessage(currentWs, { type: 'stream_event', event });
294
+ this.sendWsMessage(currentWs, { type: 'stream_event', event, claudeSessionId });
289
295
  }
290
296
  // Accumulate content in stream context
291
297
  this.accumulateEvent(claudeSessionId, event);
@@ -524,6 +530,19 @@ export class ChatWsHandler {
524
530
  }
525
531
  }
526
532
 
533
+ // Load chat agent grounding from database (if available)
534
+ let agentGrounding: string | undefined;
535
+ if (isNewSession && this.agentService) {
536
+ try {
537
+ const chatAgent = await this.agentService.getById(CHAT_AGENT_ID);
538
+ if (chatAgent) {
539
+ agentGrounding = chatAgent.grounding;
540
+ }
541
+ } catch (err: any) {
542
+ this.log('CHAT_WS', `Failed to load chat agent grounding: ${err.message}`);
543
+ }
544
+ }
545
+
527
546
  try {
528
547
  const { completion, systemPrompt } = this.chatCliBridge.spawn({
529
548
  projectId,
@@ -534,11 +553,12 @@ export class ChatWsHandler {
534
553
  allowedTools: msg.allowedTools,
535
554
  continuationContext,
536
555
  mcpConfigPath,
556
+ agentGrounding,
537
557
  onEvent: (event) => {
538
558
  // Forward to current WS (may have been re-attached after disconnect)
539
559
  const currentWs = this.sessionToWs.get(claudeSessionId);
540
560
  if (currentWs) {
541
- this.sendWsMessage(currentWs, { type: 'stream_event', event });
561
+ this.sendWsMessage(currentWs, { type: 'stream_event', event, claudeSessionId });
542
562
  }
543
563
  this.accumulateEvent(claudeSessionId, event);
544
564
  },
@@ -939,7 +959,7 @@ export class ChatWsHandler {
939
959
  type: 'assistant',
940
960
  message: { content: JSON.parse(ctx.lastAssistantContent) },
941
961
  };
942
- this.sendWsMessage(ws, { type: 'stream_event', event: catchUpEvent });
962
+ this.sendWsMessage(ws, { type: 'stream_event', event: catchUpEvent, claudeSessionId });
943
963
  }
944
964
 
945
965
  this.log('CHAT_WS', `Client re-attached to in-flight stream for session ${claudeSessionId}`);
@@ -1,5 +1,5 @@
1
- import React, { useState, useCallback } from 'react';
2
- import { NODE_COLORS, EDGE_COLORS, EDGE_LABELS, LEGEND_LABELS, NODE_SHAPES, nodeShapePath } from '../constants/graph';
1
+ import React, { useState, useCallback, useEffect } from 'react';
2
+ import { NODE_COLORS, EDGE_COLORS, EDGE_LABELS, LEGEND_LABELS, NODE_SHAPES, nodeShapePath, DEFAULT_HIDDEN_TYPES } from '../constants/graph';
3
3
 
4
4
  interface GraphLegendProps {
5
5
  visible: boolean;
@@ -8,12 +8,19 @@ interface GraphLegendProps {
8
8
  }
9
9
 
10
10
  export function GraphLegend({ visible, onTypeToggle, onSearchChange }: GraphLegendProps) {
11
- const [hiddenTypes, setHiddenTypes] = useState<Set<string>>(new Set());
11
+ const [hiddenTypes, setHiddenTypes] = useState<Set<string>>(() => new Set(DEFAULT_HIDDEN_TYPES));
12
12
  const [edgesCollapsed, setEdgesCollapsed] = useState(() => {
13
13
  const stored = localStorage.getItem('legend-edges-collapsed');
14
14
  return stored === null ? true : stored === 'true';
15
15
  });
16
16
 
17
+ // Apply default hidden types on mount
18
+ useEffect(() => {
19
+ if (hiddenTypes.size > 0) {
20
+ onTypeToggle(hiddenTypes);
21
+ }
22
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
23
+
17
24
  const handleTypeClick = useCallback((type: string) => {
18
25
  setHiddenTypes(prev => {
19
26
  const next = new Set(prev);
@@ -16,6 +16,7 @@ export const NODE_COLORS: Record<string, string> = {
16
16
  flow: '#66d9e8',
17
17
  assumption: '#adb5bd',
18
18
  open_question: '#ffe066',
19
+ note: '#a9e34b',
19
20
  };
20
21
 
21
22
  export const EDGE_LABELS: Record<string, string> = {
@@ -60,6 +61,7 @@ export const TYPE_LABELS: Record<string, string> = {
60
61
  flow: 'FLOW',
61
62
  assumption: 'ASMP',
62
63
  open_question: 'OQ',
64
+ note: 'NOTE',
63
65
  };
64
66
 
65
67
  export const LEGEND_LABELS: Record<string, string> = {
@@ -76,6 +78,7 @@ export const LEGEND_LABELS: Record<string, string> = {
76
78
  flow: 'Flow',
77
79
  assumption: 'Assumption',
78
80
  open_question: 'Open Question',
81
+ note: 'Note',
79
82
  };
80
83
 
81
84
  export const NODE_SHAPES: Record<string, string> = {
@@ -92,6 +95,7 @@ export const NODE_SHAPES: Record<string, string> = {
92
95
  flow: 'arrow',
93
96
  assumption: 'triangle-down',
94
97
  open_question: 'octagon',
98
+ note: 'circle',
95
99
  };
96
100
 
97
101
  export const nodeShapePath = (shape: string, r: number): string => {
@@ -174,9 +178,12 @@ export const nodeShapePath = (shape: string, r: number): string => {
174
178
  export const ALL_NODE_TYPES = [
175
179
  'feature', 'epic', 'component', 'data_entity', 'decision', 'tech_choice',
176
180
  'non_functional_requirement', 'design_token', 'design_pattern',
177
- 'user_role', 'flow', 'assumption', 'open_question',
181
+ 'user_role', 'flow', 'assumption', 'open_question', 'note',
178
182
  ];
179
183
 
184
+ /** Node types hidden by default in graph visualization. */
185
+ export const DEFAULT_HIDDEN_TYPES = new Set(['note']);
186
+
180
187
  export const COLUMNS = [
181
188
  { id: 'backlog', label: 'Backlog' },
182
189
  { id: 'todo', label: 'Todo' },
@@ -25,7 +25,7 @@ import {useRef, useState, useCallback} from 'react';
25
25
  import type {PermissionDecision} from '../components/PermissionDialog';
26
26
  import {apiClient} from '../api/client';
27
27
  import {extractContextUsage} from '../lib/context_usage_helpers';
28
- import {markOrCreateStreamingAssistant} from '../lib/chat_message_helpers';
28
+ import {markOrCreateStreamingAssistant, mergeAssistantContent} from '../lib/chat_message_helpers';
29
29
  import {useChatSessionStore} from '../stores/useChatSessionStore';
30
30
 
31
31
  // --- Server → Client message types ---
@@ -38,6 +38,7 @@ interface StreamStartMessage {
38
38
  interface StreamEventMessage {
39
39
  type: 'stream_event';
40
40
  event: Record<string, unknown>;
41
+ claudeSessionId: string;
41
42
  }
42
43
 
43
44
  interface StreamEndMessage {
@@ -349,6 +350,8 @@ const normalizeContentBlock = (block: Record<string, unknown>): ContentBlock | n
349
350
  export const useChatStream = (): UseChatStreamReturn => {
350
351
  const wsRef = useRef<WebSocket | null>(null);
351
352
  const streamEndCallbackRef = useRef<(() => void) | null>(null);
353
+ /** Tracks the claudeSessionId that this hook is actively streaming for. */
354
+ const activeClaudeSessionRef = useRef<string | null>(null);
352
355
  const [connected, setConnected] = useState(false);
353
356
  const [streaming, setStreaming] = useState(false);
354
357
  const [messages, setMessages] = useState<ChatMessage[]>([]);
@@ -392,6 +395,8 @@ export const useChatStream = (): UseChatStreamReturn => {
392
395
 
393
396
  switch (msg.type) {
394
397
  case 'stream_start':
398
+ // Only process if this event is for our active session
399
+ if (activeClaudeSessionRef.current && msg.claudeSessionId !== activeClaudeSessionRef.current) break;
395
400
  setStreaming(true);
396
401
  setError(null);
397
402
  // Create an empty assistant message that will accumulate content
@@ -402,11 +407,16 @@ export const useChatStream = (): UseChatStreamReturn => {
402
407
  break;
403
408
 
404
409
  case 'stream_event':
410
+ // Only process events for our active session — prevents cross-session leaking
411
+ if (activeClaudeSessionRef.current && msg.claudeSessionId !== activeClaudeSessionRef.current) break;
405
412
  handleStreamEvent(msg.event);
406
413
  break;
407
414
 
408
415
  case 'stream_end':
416
+ // Only process if this event is for our active session
417
+ if (activeClaudeSessionRef.current && msg.claudeSessionId !== activeClaudeSessionRef.current) break;
409
418
  setStreaming(false);
419
+ activeClaudeSessionRef.current = null;
410
420
  // Surface error details from non-zero exit codes
411
421
  if (msg.error) {
412
422
  setError(msg.error);
@@ -427,7 +437,10 @@ export const useChatStream = (): UseChatStreamReturn => {
427
437
  break;
428
438
 
429
439
  case 'stream_cancelled':
440
+ // Only process if this event is for our active session
441
+ if (activeClaudeSessionRef.current && msg.claudeSessionId !== activeClaudeSessionRef.current) break;
430
442
  setStreaming(false);
443
+ activeClaudeSessionRef.current = null;
431
444
  // Discard the partial assistant response and the triggering user message.
432
445
  // Prepopulate the cancelled user message text for the input field.
433
446
  setMessages(prev => {
@@ -460,6 +473,8 @@ export const useChatStream = (): UseChatStreamReturn => {
460
473
 
461
474
  case 'stream_resumed':
462
475
  // Re-attached to an in-flight stream after page reload or session switch.
476
+ // Update active session to the one we're re-attaching to.
477
+ activeClaudeSessionRef.current = msg.claudeSessionId;
463
478
  // Mark the last assistant message as streaming so new events update it.
464
479
  // If no assistant message exists (e.g. messages not yet persisted to DB),
465
480
  // create an empty streaming assistant message for catch-up content.
@@ -526,39 +541,9 @@ export const useChatStream = (): UseChatStreamReturn => {
526
541
 
527
542
  setMessages(prev => {
528
543
  const updated = [...prev];
529
- // Find the last streaming assistant message
530
544
  for (let i = updated.length - 1; i >= 0; i--) {
531
545
  if (updated[i].role === 'assistant' && updated[i].isStreaming) {
532
- // Each assistant event is cumulative within its turn, but a new
533
- // turn (after tool use) starts fresh. Detect turn boundaries by
534
- // comparing tool_use IDs: if existing content has tool_use blocks
535
- // not present in the new blocks, we've moved to a new turn.
536
- const existing = updated[i].content;
537
-
538
- const existingToolUseIds = new Set(
539
- existing
540
- .filter((b): b is ToolUseBlock => b.type === 'tool_use')
541
- .map(b => b.id),
542
- );
543
- const newToolUseIds = new Set(
544
- blocks
545
- .filter((b): b is ToolUseBlock => b.type === 'tool_use')
546
- .map(b => b.id),
547
- );
548
-
549
- const isNewTurn = existingToolUseIds.size > 0 &&
550
- ![...existingToolUseIds].some(id => newToolUseIds.has(id));
551
-
552
- if (isNewTurn) {
553
- // New turn: preserve all existing content, append new blocks
554
- updated[i] = {...updated[i], content: [...existing, ...blocks]};
555
- } else {
556
- // Same turn: replace with cumulative snapshot, preserving tool_result blocks
557
- const existingToolResults = existing.filter(
558
- (b): b is ToolResultBlock => b.type === 'tool_result',
559
- );
560
- updated[i] = {...updated[i], content: [...blocks, ...existingToolResults]};
561
- }
546
+ updated[i] = {...updated[i], content: mergeAssistantContent(updated[i].content, blocks)};
562
547
  break;
563
548
  }
564
549
  }
@@ -631,6 +616,9 @@ export const useChatStream = (): UseChatStreamReturn => {
631
616
  return;
632
617
  }
633
618
 
619
+ // Track which session we're streaming for so event filtering works
620
+ activeClaudeSessionRef.current = options.claudeSessionId;
621
+
634
622
  // Build user message content blocks: text + any attachment blocks
635
623
  const userContent: ContentBlock[] = [{type: 'text', text: message}];
636
624
  if (options.attachmentBlocks && options.attachmentBlocks.length > 0) {
@@ -689,6 +677,9 @@ export const useChatStream = (): UseChatStreamReturn => {
689
677
 
690
678
  if (texts.length === 0) return;
691
679
 
680
+ // Track which session we're streaming for so event filtering works
681
+ activeClaudeSessionRef.current = options.claudeSessionId;
682
+
692
683
  // Add each queued message as a separate user bubble
693
684
  setMessages(prev => [
694
685
  ...prev,
@@ -9,6 +9,7 @@ import {
9
9
  buildStreamOptions,
10
10
  prepareMergedQueueText,
11
11
  markOrCreateStreamingAssistant,
12
+ mergeAssistantContent,
12
13
  } from './chat_message_helpers.ts';
13
14
  import type { ChatMessage, ContentBlock } from '../hooks/use_chat_stream.ts';
14
15
  import type { QueuedMessage } from './message_queue.ts';
@@ -303,3 +304,107 @@ describe('markOrCreateStreamingAssistant', () => {
303
304
  assert.deepEqual(result[1].content, content);
304
305
  });
305
306
  });
307
+
308
+ describe('mergeAssistantContent', () => {
309
+ it('replaces content with cumulative snapshot when new blocks have text', () => {
310
+ const existing: ContentBlock[] = [
311
+ { type: 'text', text: 'partial text' },
312
+ ];
313
+ const newBlocks: ContentBlock[] = [
314
+ { type: 'text', text: 'full text response' },
315
+ ];
316
+ const result = mergeAssistantContent(existing, newBlocks);
317
+ assert.equal(result.length, 1);
318
+ assert.equal((result[0] as any).text, 'full text response');
319
+ });
320
+
321
+ it('preserves text when partial event only has tool_use (Bug 1 fix)', () => {
322
+ const existing: ContentBlock[] = [
323
+ { type: 'text', text: 'Let me read the file.' },
324
+ ];
325
+ // Partial event during tool_use streaming — only contains the tool_use block
326
+ const newBlocks: ContentBlock[] = [
327
+ { type: 'tool_use', id: 'tu_1', name: 'Read', input: {} },
328
+ ];
329
+ const result = mergeAssistantContent(existing, newBlocks);
330
+ // Text block should be preserved
331
+ assert.equal(result.length, 2);
332
+ assert.equal(result[0].type, 'text');
333
+ assert.equal((result[0] as any).text, 'Let me read the file.');
334
+ assert.equal(result[1].type, 'tool_use');
335
+ });
336
+
337
+ it('replaces text when cumulative event includes both text and tool_use', () => {
338
+ const existing: ContentBlock[] = [
339
+ { type: 'text', text: 'old partial' },
340
+ ];
341
+ const newBlocks: ContentBlock[] = [
342
+ { type: 'text', text: 'Let me read the file.' },
343
+ { type: 'tool_use', id: 'tu_1', name: 'Read', input: { file_path: '/etc/hosts' } },
344
+ ];
345
+ const result = mergeAssistantContent(existing, newBlocks);
346
+ assert.equal(result.length, 2);
347
+ assert.equal((result[0] as any).text, 'Let me read the file.');
348
+ assert.equal(result[1].type, 'tool_use');
349
+ });
350
+
351
+ it('preserves tool_result blocks from existing content in same turn', () => {
352
+ const existing: ContentBlock[] = [
353
+ { type: 'text', text: 'Reading file...' },
354
+ { type: 'tool_use', id: 'tu_1', name: 'Read', input: {} },
355
+ { type: 'tool_result', toolUseId: 'tu_1', content: 'file content', isError: false },
356
+ ];
357
+ const newBlocks: ContentBlock[] = [
358
+ { type: 'text', text: 'Reading file...' },
359
+ { type: 'tool_use', id: 'tu_1', name: 'Read', input: { file_path: '/etc/hosts' } },
360
+ ];
361
+ const result = mergeAssistantContent(existing, newBlocks);
362
+ assert.equal(result.length, 3);
363
+ assert.equal(result[2].type, 'tool_result');
364
+ assert.equal((result[2] as any).toolUseId, 'tu_1');
365
+ });
366
+
367
+ it('appends new blocks on new turn (different tool_use IDs)', () => {
368
+ const existing: ContentBlock[] = [
369
+ { type: 'text', text: 'First action' },
370
+ { type: 'tool_use', id: 'tu_1', name: 'Read', input: {} },
371
+ { type: 'tool_result', toolUseId: 'tu_1', content: 'ok', isError: false },
372
+ ];
373
+ // New turn — completely different tool_use ID
374
+ const newBlocks: ContentBlock[] = [
375
+ { type: 'text', text: 'Now writing...' },
376
+ { type: 'tool_use', id: 'tu_2', name: 'Write', input: {} },
377
+ ];
378
+ const result = mergeAssistantContent(existing, newBlocks);
379
+ // Should preserve all existing + append new
380
+ assert.equal(result.length, 5);
381
+ assert.equal(result[0].type, 'text');
382
+ assert.equal((result[0] as any).text, 'First action');
383
+ assert.equal(result[3].type, 'text');
384
+ assert.equal((result[3] as any).text, 'Now writing...');
385
+ });
386
+
387
+ it('handles empty existing content', () => {
388
+ const newBlocks: ContentBlock[] = [
389
+ { type: 'text', text: 'Hello' },
390
+ ];
391
+ const result = mergeAssistantContent([], newBlocks);
392
+ assert.equal(result.length, 1);
393
+ assert.equal((result[0] as any).text, 'Hello');
394
+ });
395
+
396
+ it('preserves text when new blocks have empty text block', () => {
397
+ const existing: ContentBlock[] = [
398
+ { type: 'text', text: 'I will help you.' },
399
+ ];
400
+ // Event with empty text + tool_use
401
+ const newBlocks: ContentBlock[] = [
402
+ { type: 'text', text: '' },
403
+ { type: 'tool_use', id: 'tu_1', name: 'Read', input: {} },
404
+ ];
405
+ const result = mergeAssistantContent(existing, newBlocks);
406
+ // Should carry forward the existing non-empty text
407
+ assert.equal(result[0].type, 'text');
408
+ assert.equal((result[0] as any).text, 'I will help you.');
409
+ });
410
+ });
@@ -5,7 +5,7 @@
5
5
  * Includes stream options building, message filtering, and text extraction.
6
6
  */
7
7
 
8
- import type { ChatMessage, ContentBlock, TextBlock, ToolResultBlock } from '../hooks/use_chat_stream';
8
+ import type { ChatMessage, ContentBlock, TextBlock, ToolUseBlock, ToolResultBlock } from '../hooks/use_chat_stream';
9
9
  import type { QueuedMessage } from './message_queue';
10
10
 
11
11
  /**
@@ -97,6 +97,53 @@ export const prepareMergedQueueText = (messages: QueuedMessage[]): string => {
97
97
  * Used by the stream_resumed handler when re-attaching to an in-flight stream.
98
98
  * Returns a new array (does not mutate the input).
99
99
  */
100
+ /**
101
+ * Merge new content blocks from a streaming assistant event into existing content.
102
+ * Handles same-turn cumulative replacement and new-turn appending.
103
+ *
104
+ * Key behavior: when partial events during tool_use streaming omit prior text blocks,
105
+ * existing text blocks are preserved so they don't disappear from the UI.
106
+ */
107
+ export const mergeAssistantContent = (
108
+ existing: ContentBlock[],
109
+ newBlocks: ContentBlock[],
110
+ ): ContentBlock[] => {
111
+ const existingToolUseIds = new Set(
112
+ existing
113
+ .filter((b): b is ToolUseBlock => b.type === 'tool_use')
114
+ .map(b => b.id),
115
+ );
116
+ const newToolUseIds = new Set(
117
+ newBlocks
118
+ .filter((b): b is ToolUseBlock => b.type === 'tool_use')
119
+ .map(b => b.id),
120
+ );
121
+
122
+ const isNewTurn = existingToolUseIds.size > 0 &&
123
+ ![...existingToolUseIds].some(id => newToolUseIds.has(id));
124
+
125
+ if (isNewTurn) {
126
+ // New turn: preserve all existing content, append new blocks
127
+ return [...existing, ...newBlocks];
128
+ }
129
+
130
+ // Same turn: replace with cumulative snapshot, preserving tool_result blocks.
131
+ // Also preserve existing text blocks when partial events omit them
132
+ // (partial events during tool_use streaming may only contain the tool_use block).
133
+ const existingToolResults = existing.filter(
134
+ (b): b is ToolResultBlock => b.type === 'tool_result',
135
+ );
136
+ const hasNewText = newBlocks.some(
137
+ b => b.type === 'text' && (b as TextBlock).text.length > 0,
138
+ );
139
+ if (hasNewText) {
140
+ return [...newBlocks, ...existingToolResults];
141
+ }
142
+ // No text in new event — carry forward existing text blocks
143
+ const existingText = existing.filter(b => b.type === 'text');
144
+ return [...existingText, ...newBlocks, ...existingToolResults];
145
+ };
146
+
100
147
  export const markOrCreateStreamingAssistant = (
101
148
  messages: ChatMessage[],
102
149
  nextId: string,