@assistkick/create 1.33.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.
- package/package.json +1 -1
- package/templates/assistkick-product-system/packages/backend/src/server.ts +1 -1
- package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts +41 -16
- package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts +25 -5
- package/templates/assistkick-product-system/packages/frontend/src/hooks/use_chat_stream.ts +23 -32
- package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.test.ts +105 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.ts +48 -1
- package/templates/assistkick-product-system/packages/shared/db/migrate.ts +25 -0
package/package.json
CHANGED
|
@@ -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();
|
package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts
CHANGED
|
@@ -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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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';
|
package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Server → Client messages:
|
|
14
14
|
* { type: 'stream_start', claudeSessionId }
|
|
15
|
-
* { type: 'stream_event', 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}`);
|
|
@@ -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
|
-
|
|
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,
|
package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.ts
CHANGED
|
@@ -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,
|
|
@@ -156,6 +156,31 @@ async function seedDefaults() {
|
|
|
156
156
|
if (check.rows.length > 0) seeded++;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// Default Chat Agent — system prompt for the chat bridge
|
|
160
|
+
const chatGrounding = `We are working on project-id {{projectId}}
|
|
161
|
+
|
|
162
|
+
## Project Workspace
|
|
163
|
+
The project workspace is at: {{workspacePath}}
|
|
164
|
+
All file edits (write, edit, delete, create) MUST target files inside the workspace directory above.
|
|
165
|
+
You may read files from anywhere in the project root for context, but all changes go into the workspace.
|
|
166
|
+
|
|
167
|
+
## Git Repository Structure
|
|
168
|
+
The workspace ({{workspacePath}}) has its OWN independent git repo.
|
|
169
|
+
When committing and pushing changes, always use the workspace git repo, not the top-level repo.
|
|
170
|
+
|
|
171
|
+
## Skills
|
|
172
|
+
Project skills (e.g. assistkick-interview, assistkick-developer, etc.) are available via the Skill tool.
|
|
173
|
+
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.
|
|
174
|
+
Only edit code and project files — never modify skill definition files.`;
|
|
175
|
+
|
|
176
|
+
const chatGroundingEscaped = sqlEscape(chatGrounding);
|
|
177
|
+
await client.execute(
|
|
178
|
+
`INSERT OR IGNORE INTO agents (id, name, model, skills, grounding, default_grounding, project_id, is_default, created_at, updated_at)
|
|
179
|
+
VALUES ('chat-agent-default', 'Chat Agent', 'claude-opus-4-6', '[]', '${chatGroundingEscaped}', '${chatGroundingEscaped}', NULL, 1, '${now}', '${now}')`
|
|
180
|
+
);
|
|
181
|
+
const chatCheck = await client.execute(`SELECT id FROM agents WHERE id = 'chat-agent-default'`);
|
|
182
|
+
if (chatCheck.rows.length > 0) seeded++;
|
|
183
|
+
|
|
159
184
|
// Default Pipeline workflow
|
|
160
185
|
const defaultPipelineGraph = JSON.stringify({
|
|
161
186
|
nodes: [
|