@myrialabs/clopen 0.0.7 → 0.1.1

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 (54) hide show
  1. package/backend/index.ts +28 -10
  2. package/backend/lib/chat/stream-manager.ts +130 -10
  3. package/backend/lib/database/queries/message-queries.ts +47 -0
  4. package/backend/lib/engine/adapters/claude/stream.ts +65 -1
  5. package/backend/lib/engine/adapters/opencode/message-converter.ts +35 -77
  6. package/backend/lib/engine/types.ts +6 -0
  7. package/backend/lib/files/file-operations.ts +2 -2
  8. package/backend/lib/files/file-reading.ts +2 -2
  9. package/backend/lib/files/path-browsing.ts +2 -2
  10. package/backend/lib/terminal/pty-session-manager.ts +1 -1
  11. package/backend/lib/terminal/shell-utils.ts +4 -4
  12. package/backend/lib/terminal/stream-manager.ts +6 -3
  13. package/backend/ws/chat/background.ts +3 -0
  14. package/backend/ws/chat/stream.ts +43 -1
  15. package/backend/ws/terminal/session.ts +48 -0
  16. package/bin/clopen.ts +10 -0
  17. package/bun.lock +258 -383
  18. package/frontend/lib/components/chat/ChatInterface.svelte +8 -1
  19. package/frontend/lib/components/chat/formatters/MessageFormatter.svelte +20 -0
  20. package/frontend/lib/components/chat/formatters/TextMessage.svelte +3 -15
  21. package/frontend/lib/components/chat/formatters/Tools.svelte +15 -8
  22. package/frontend/lib/components/chat/input/ChatInput.svelte +70 -21
  23. package/frontend/lib/components/chat/input/components/LoadingIndicator.svelte +23 -11
  24. package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +1 -1
  25. package/frontend/lib/components/chat/message/ChatMessage.svelte +13 -1
  26. package/frontend/lib/components/chat/message/ChatMessages.svelte +2 -2
  27. package/frontend/lib/components/chat/message/DateSeparator.svelte +1 -1
  28. package/frontend/lib/components/chat/message/MessageBubble.svelte +2 -2
  29. package/frontend/lib/components/chat/message/MessageHeader.svelte +14 -12
  30. package/frontend/lib/components/chat/tools/AgentTool.svelte +95 -0
  31. package/frontend/lib/components/chat/tools/AskUserQuestionTool.svelte +396 -0
  32. package/frontend/lib/components/chat/tools/BashTool.svelte +9 -4
  33. package/frontend/lib/components/chat/tools/EnterPlanModeTool.svelte +24 -0
  34. package/frontend/lib/components/chat/tools/ExitPlanModeTool.svelte +4 -7
  35. package/frontend/lib/components/chat/tools/{KillShellTool.svelte → TaskStopTool.svelte} +6 -6
  36. package/frontend/lib/components/chat/tools/index.ts +5 -2
  37. package/frontend/lib/components/checkpoint/TimelineModal.svelte +7 -2
  38. package/frontend/lib/components/history/HistoryModal.svelte +13 -5
  39. package/frontend/lib/components/workspace/DesktopNavigator.svelte +2 -1
  40. package/frontend/lib/components/workspace/MobileNavigator.svelte +2 -1
  41. package/frontend/lib/services/chat/chat.service.ts +146 -12
  42. package/frontend/lib/services/terminal/project.service.ts +65 -10
  43. package/frontend/lib/services/terminal/terminal.service.ts +19 -0
  44. package/frontend/lib/stores/core/app.svelte.ts +77 -0
  45. package/frontend/lib/stores/features/terminal.svelte.ts +10 -0
  46. package/frontend/lib/utils/chat/message-grouper.ts +94 -12
  47. package/frontend/lib/utils/chat/message-processor.ts +37 -4
  48. package/frontend/lib/utils/chat/tool-handler.ts +96 -5
  49. package/package.json +4 -5
  50. package/shared/constants/engines.ts +1 -1
  51. package/shared/types/database/schema.ts +1 -0
  52. package/shared/types/messaging/index.ts +15 -13
  53. package/shared/types/messaging/tool.ts +185 -361
  54. package/shared/utils/message-formatter.ts +1 -0
@@ -11,7 +11,8 @@
11
11
  * - Proper presence synchronization
12
12
  */
13
13
 
14
- import { appState } from '$frontend/lib/stores/core/app.svelte';
14
+ import { appState, updateSessionProcessState } from '$frontend/lib/stores/core/app.svelte';
15
+ import type { SessionProcessState } from '$frontend/lib/stores/core/app.svelte';
15
16
  import { chatModelState } from '$frontend/lib/stores/ui/chat-model.svelte';
16
17
  import { projectState } from '$frontend/lib/stores/core/projects.svelte';
17
18
  import { sessionState, setCurrentSession, createSession, updateSession } from '$frontend/lib/stores/core/sessions.svelte';
@@ -23,6 +24,14 @@ import { buildMetadataFromTransport } from '$shared/utils/message-formatter';
23
24
  import { debug } from '$shared/utils/logger';
24
25
  import ws from '$frontend/lib/utils/ws';
25
26
 
27
+ /**
28
+ * Tools that block the SDK waiting for user interaction.
29
+ * When these tools appear in an assistant message without a result,
30
+ * and the stream is active, the chat status switches to "waiting for input".
31
+ * Extend this list for future interactive tools.
32
+ */
33
+ const INTERACTIVE_TOOLS = new Set(['AskUserQuestion']);
34
+
26
35
  class ChatService {
27
36
  private activeProcessId: string | null = null;
28
37
  private streamCompleted: boolean = false;
@@ -58,6 +67,30 @@ class ChatService {
58
67
  this.setupWebSocketHandlers();
59
68
  }
60
69
 
70
+ /**
71
+ * Update process state for a session.
72
+ * Writes to both the per-session map (for multi-session support)
73
+ * and global convenience flags (for backward-compatible single-session components).
74
+ *
75
+ * @param update - Partial state to merge
76
+ * @param sessionId - Override session ID (defaults to this.currentSessionId or current session)
77
+ */
78
+ private setProcessState(
79
+ update: Partial<SessionProcessState>,
80
+ sessionId?: string | null
81
+ ): void {
82
+ const resolvedId = sessionId ?? this.currentSessionId ?? sessionState.currentSession?.id;
83
+ if (resolvedId) {
84
+ updateSessionProcessState(resolvedId, update);
85
+ }
86
+ // Always sync to global convenience flags
87
+ if ('isLoading' in update) appState.isLoading = update.isLoading!;
88
+ if ('isWaitingInput' in update) appState.isWaitingInput = update.isWaitingInput!;
89
+ if ('isCancelling' in update) appState.isCancelling = update.isCancelling!;
90
+ if ('isRestoring' in update) appState.isRestoring = update.isRestoring!;
91
+ if ('error' in update && update.error !== undefined) appState.error = update.error;
92
+ }
93
+
61
94
  /**
62
95
  * Check if event should be skipped (sequence-based deduplication)
63
96
  */
@@ -137,8 +170,10 @@ class ChatService {
137
170
 
138
171
  this.streamCompleted = true;
139
172
  this.reconnected = false;
140
- appState.isLoading = false;
141
- appState.isCancelling = false;
173
+ this.setProcessState({ isLoading: false, isWaitingInput: false, isCancelling: false });
174
+
175
+ // Mark any tool_use blocks that never got a tool_result
176
+ this.markInterruptedTools();
142
177
 
143
178
  // Stream completed successfully — all old cancelled streams' events
144
179
  // have definitely been delivered by now, so clear the blacklist.
@@ -161,8 +196,10 @@ class ChatService {
161
196
  this.streamCompleted = true;
162
197
  this.reconnected = false;
163
198
  this.activeProcessId = null;
164
- appState.isLoading = false;
165
- appState.isCancelling = false;
199
+ this.setProcessState({ isLoading: false, isWaitingInput: false, isCancelling: false });
200
+
201
+ // Mark any tool_use blocks that never got a tool_result
202
+ this.markInterruptedTools();
166
203
 
167
204
  // Notifications handled by GlobalStreamMonitor via chat:stream-finished
168
205
  });
@@ -178,8 +215,10 @@ class ChatService {
178
215
  // (e.g. from multiple subscriptions or late-arriving events with different processId/seq)
179
216
  this.streamCompleted = true;
180
217
  this.reconnected = false;
181
- appState.isLoading = false;
182
- appState.isCancelling = false;
218
+ this.setProcessState({ isLoading: false, isWaitingInput: false, isCancelling: false });
219
+
220
+ // Mark any tool_use blocks that never got a tool_result
221
+ this.markInterruptedTools();
183
222
 
184
223
  // Don't show notification for cancel-triggered errors
185
224
  if (data.error === 'Stream cancelled') return;
@@ -277,11 +316,10 @@ class ChatService {
277
316
  }
278
317
 
279
318
  // Set loading state
280
- appState.isLoading = true;
281
- appState.isCancelling = false;
282
319
  this.streamCompleted = false;
283
320
  this.reconnected = false;
284
321
  this.currentSessionId = sessionState.currentSession.id;
322
+ this.setProcessState({ isLoading: true, isWaitingInput: false, isCancelling: false });
285
323
  // DON'T clear cancelledProcessIds — late events from previously cancelled
286
324
  // streams must still be blocked. The set is cleared on stream complete.
287
325
  // Clear sequence tracking for new stream
@@ -432,9 +470,9 @@ class ChatService {
432
470
  this.currentSessionId = null;
433
471
  this.streamCompleted = true;
434
472
  this.reconnected = false;
435
- appState.isLoading = false;
436
- // Prevent presence effect from re-enabling loading before server confirms cancel
437
- appState.isCancelling = true;
473
+ // Update per-session map with captured ID (before it was nulled above)
474
+ // and global flags cancel sets isCancelling=true to prevent presence re-enabling
475
+ this.setProcessState({ isLoading: false, isWaitingInput: false, isCancelling: true }, chatSessionId);
438
476
 
439
477
  // Clean up stale stream_events from the cancelled stream.
440
478
  // Without this, stale stream_events remain in the messages array and cause
@@ -465,6 +503,7 @@ class ChatService {
465
503
  this.reconnected = false;
466
504
  this.lastEventSeq.clear();
467
505
  appState.isLoading = false;
506
+ appState.isWaitingInput = false;
468
507
  appState.isCancelling = false;
469
508
  }
470
509
 
@@ -549,6 +588,27 @@ class ChatService {
549
588
  })
550
589
  };
551
590
 
591
+ // Detect interactive tool_use blocks (e.g., AskUserQuestion) and set waiting status.
592
+ // When the SDK is blocked on canUseTool, partial events stop and the user must interact.
593
+ if (sdkMessage.type === 'assistant' && sdkMessage.message?.content) {
594
+ const content = Array.isArray(sdkMessage.message.content) ? sdkMessage.message.content : [];
595
+ const hasInteractiveTool = content.some(
596
+ (item: any) => item.type === 'tool_use' && INTERACTIVE_TOOLS.has(item.name)
597
+ );
598
+ if (hasInteractiveTool) {
599
+ this.setProcessState({ isWaitingInput: true });
600
+ }
601
+ }
602
+
603
+ // When a user message with tool_result arrives, the SDK is unblocked — clear waiting status
604
+ if (sdkMessage.type === 'user' && sdkMessage.message?.content) {
605
+ const content = Array.isArray(sdkMessage.message.content) ? sdkMessage.message.content : [];
606
+ const hasToolResult = content.some((item: any) => item.type === 'tool_result');
607
+ if (hasToolResult && appState.isWaitingInput) {
608
+ this.setProcessState({ isWaitingInput: false });
609
+ }
610
+ }
611
+
552
612
  // For reasoning messages that couldn't find a matching stream_event,
553
613
  // insert BEFORE trailing non-reasoning assistant messages (tools/text)
554
614
  // to preserve reasoning-before-tool ordering within the same turn.
@@ -671,6 +731,80 @@ class ChatService {
671
731
  }
672
732
  }
673
733
 
734
+ /**
735
+ * Detect whether any interactive tool (e.g. AskUserQuestion) is pending in the current messages.
736
+ * Used after browser refresh / catchup to restore the isWaitingInput state.
737
+ */
738
+ detectPendingInteractiveTools(): void {
739
+ if (!appState.isLoading) return;
740
+
741
+ // Collect all tool_use IDs that have a matching tool_result
742
+ const answeredToolIds = new Set<string>();
743
+ for (const msg of sessionState.messages) {
744
+ const msgAny = msg as any;
745
+ if (msgAny.type !== 'user' || !msgAny.message?.content) continue;
746
+ const content = Array.isArray(msgAny.message.content) ? msgAny.message.content : [];
747
+ for (const item of content) {
748
+ if (item.type === 'tool_result' && item.tool_use_id) {
749
+ answeredToolIds.add(item.tool_use_id);
750
+ }
751
+ }
752
+ }
753
+
754
+ // Check if any interactive tool is unanswered
755
+ for (const msg of sessionState.messages) {
756
+ const msgAny = msg as any;
757
+ if (msgAny.type !== 'assistant' || !msgAny.message?.content) continue;
758
+ const content = Array.isArray(msgAny.message.content) ? msgAny.message.content : [];
759
+ const hasPendingInteractive = content.some(
760
+ (item: any) => item.type === 'tool_use' && INTERACTIVE_TOOLS.has(item.name) && item.id && !answeredToolIds.has(item.id)
761
+ );
762
+ if (hasPendingInteractive) {
763
+ this.setProcessState({ isWaitingInput: true });
764
+ return;
765
+ }
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Mark assistant messages with unanswered tool_use blocks as interrupted.
771
+ * Sets metadata.interrupted at the MESSAGE level (not tool_use content level).
772
+ * Called when stream ends (complete/error/cancel) for immediate in-memory update.
773
+ * The backend persists this to DB via stream:lifecycle for durability.
774
+ */
775
+ private markInterruptedTools(): void {
776
+ // Collect all tool_use IDs that have a matching tool_result
777
+ const answeredToolIds = new Set<string>();
778
+
779
+ for (const msg of sessionState.messages) {
780
+ const msgAny = msg as any;
781
+ if (msgAny.type !== 'user' || !msgAny.message?.content) continue;
782
+ const content = Array.isArray(msgAny.message.content) ? msgAny.message.content : [];
783
+
784
+ for (const item of content) {
785
+ if (item.type === 'tool_result' && item.tool_use_id) {
786
+ answeredToolIds.add(item.tool_use_id);
787
+ }
788
+ }
789
+ }
790
+
791
+ // Mark messages with unanswered tool_use blocks as interrupted (message-level metadata)
792
+ for (const msg of sessionState.messages) {
793
+ const msgAny = msg as any;
794
+ if (msgAny.type !== 'assistant' || !msgAny.message?.content) continue;
795
+ const content = Array.isArray(msgAny.message.content) ? msgAny.message.content : [];
796
+
797
+ const hasUnansweredTool = content.some(
798
+ (item: any) => item.type === 'tool_use' && item.id && !answeredToolIds.has(item.id)
799
+ );
800
+
801
+ if (hasUnansweredTool && !msgAny.metadata?.interrupted) {
802
+ if (!msgAny.metadata) msgAny.metadata = {};
803
+ msgAny.metadata.interrupted = true;
804
+ }
805
+ }
806
+ }
807
+
674
808
  /**
675
809
  * Handle general errors
676
810
  */
@@ -98,28 +98,83 @@ class TerminalProjectManager {
98
98
 
99
99
  /**
100
100
  * Create initial terminal sessions for a project
101
+ * First checks backend for existing PTY sessions (e.g., after browser refresh)
101
102
  */
102
103
  private async createProjectTerminalSessions(projectId: string, projectPath: string): Promise<void> {
103
- // Creating terminal session for project
104
-
105
104
  const context = this.getOrCreateProjectContext(projectId, projectPath);
106
-
107
- // Create only 1 terminal session by default with correct project path and projectId
105
+
106
+ // Check backend for existing PTY sessions (survives browser refresh)
107
+ const existingBackendSessions = await terminalService.listProjectSessions(projectId);
108
+
109
+ if (existingBackendSessions.length > 0) {
110
+ debug.log('terminal', `Found ${existingBackendSessions.length} existing PTY sessions for project ${projectId}`);
111
+
112
+ // Sort by sessionId to maintain consistent order (terminal-1, terminal-2, etc.)
113
+ existingBackendSessions.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
114
+
115
+ // Restore all existing sessions as tabs
116
+ for (const backendSession of existingBackendSessions) {
117
+ const sessionParts = backendSession.sessionId.split('-');
118
+ const terminalNumber = sessionParts[sessionParts.length - 1] || '1';
119
+
120
+ const terminalSession: TerminalSession = {
121
+ id: backendSession.sessionId,
122
+ name: `Terminal ${terminalNumber}`,
123
+ directory: backendSession.cwd || projectPath,
124
+ lines: [],
125
+ commandHistory: [],
126
+ isActive: false,
127
+ createdAt: new Date(backendSession.createdAt),
128
+ lastUsedAt: new Date(backendSession.lastActivityAt),
129
+ shellType: 'Unknown',
130
+ terminalBuffer: undefined,
131
+ projectId: projectId,
132
+ projectPath: projectPath
133
+ };
134
+
135
+ terminalStore.addSession(terminalSession);
136
+ terminalSessionManager.createSession(backendSession.sessionId, projectId, projectPath, backendSession.cwd || projectPath);
137
+ context.sessionIds.push(backendSession.sessionId);
138
+
139
+ // Update nextSessionId to avoid ID conflicts
140
+ const match = backendSession.sessionId.match(/terminal-(\d+)/);
141
+ if (match) {
142
+ terminalStore.updateNextSessionId(parseInt(match[1], 10) + 1);
143
+ }
144
+ }
145
+
146
+ // Restore previously active session from sessionStorage, or default to first
147
+ let activeSessionId = existingBackendSessions[0].sessionId;
148
+ if (typeof sessionStorage !== 'undefined') {
149
+ try {
150
+ const savedActiveId = sessionStorage.getItem(`terminal-active-session-${projectId}`);
151
+ if (savedActiveId && context.sessionIds.includes(savedActiveId)) {
152
+ activeSessionId = savedActiveId;
153
+ }
154
+ } catch {
155
+ // sessionStorage not available
156
+ }
157
+ }
158
+ context.activeSessionId = activeSessionId;
159
+ terminalStore.switchToSession(context.activeSessionId);
160
+ this.persistContexts();
161
+ return;
162
+ }
163
+
164
+ // No existing backend sessions, create 1 new terminal session
108
165
  const sessionId = terminalStore.createNewSession(projectPath, projectPath, projectId);
109
-
110
- // Update the session's directory to ensure it's correct
166
+
111
167
  const session = terminalStore.getSession(sessionId);
112
168
  if (session) {
113
169
  session.directory = projectPath;
114
170
  }
115
-
116
- // Create a fresh session in terminalSessionManager with correct project association
171
+
117
172
  terminalSessionManager.createSession(sessionId, projectId, projectPath, projectPath);
118
-
173
+
119
174
  context.sessionIds.push(sessionId);
120
175
  context.activeSessionId = sessionId;
121
176
  terminalStore.switchToSession(sessionId);
122
-
177
+
123
178
  this.persistContexts();
124
179
  }
125
180
 
@@ -339,6 +339,25 @@ export class TerminalService {
339
339
  }
340
340
  }
341
341
 
342
+ /**
343
+ * List active PTY sessions for a project on the backend
344
+ * Used after browser refresh to discover existing sessions
345
+ */
346
+ async listProjectSessions(projectId: string): Promise<Array<{
347
+ sessionId: string;
348
+ pid: number;
349
+ cwd: string;
350
+ createdAt: string;
351
+ lastActivityAt: string;
352
+ }>> {
353
+ try {
354
+ const data = await ws.http('terminal:list-sessions', { projectId }, 5000);
355
+ return data.sessions || [];
356
+ } catch {
357
+ return [];
358
+ }
359
+ }
360
+
342
361
  /**
343
362
  * Cleanup listeners for a session
344
363
  */
@@ -15,14 +15,41 @@ interface PageInfo {
15
15
  actions?: import('svelte').Snippet;
16
16
  }
17
17
 
18
+ /**
19
+ * Per-session process state.
20
+ * Tracks loading/waiting/cancelling state for each chat session independently,
21
+ * enabling correct multi-session and multi-project support.
22
+ */
23
+ export interface SessionProcessState {
24
+ isLoading: boolean;
25
+ isWaitingInput: boolean;
26
+ isRestoring: boolean;
27
+ isCancelling: boolean;
28
+ error: string | null;
29
+ }
30
+
31
+ const DEFAULT_SESSION_STATE: SessionProcessState = {
32
+ isLoading: false,
33
+ isWaitingInput: false,
34
+ isRestoring: false,
35
+ isCancelling: false,
36
+ error: null,
37
+ };
38
+
18
39
  interface AppState {
19
40
  // UI Navigation
20
41
  currentView: string;
42
+
43
+ // Current session process state (convenience — synced from sessionStates for the active session)
21
44
  isLoading: boolean;
45
+ isWaitingInput: boolean;
22
46
  isRestoring: boolean;
23
47
  isCancelling: boolean;
24
48
  error: string | null;
25
49
 
50
+ // Per-session process states (source of truth for multi-session support)
51
+ sessionStates: Record<string, SessionProcessState>;
52
+
26
53
  // Page Information
27
54
  pageInfo: PageInfo;
28
55
 
@@ -36,10 +63,14 @@ export const appState = $state<AppState>({
36
63
  // UI Navigation
37
64
  currentView: 'chat',
38
65
  isLoading: false,
66
+ isWaitingInput: false,
39
67
  isRestoring: false,
40
68
  isCancelling: false,
41
69
  error: null,
42
70
 
71
+ // Per-session process states
72
+ sessionStates: {},
73
+
43
74
  // Page Information
44
75
  pageInfo: {
45
76
  title: 'Claude Code',
@@ -52,6 +83,52 @@ export const appState = $state<AppState>({
52
83
  isAppInitialized: false
53
84
  });
54
85
 
86
+ // ========================================
87
+ // PER-SESSION PROCESS STATE MANAGEMENT
88
+ // ========================================
89
+
90
+ /**
91
+ * Get the process state for a specific session.
92
+ * Returns default (idle) state if the session has no entry.
93
+ */
94
+ export function getSessionProcessState(sessionId: string): SessionProcessState {
95
+ return appState.sessionStates[sessionId] ?? DEFAULT_SESSION_STATE;
96
+ }
97
+
98
+ /**
99
+ * Update process state for a specific session in the per-session map.
100
+ * Does NOT touch global convenience flags — caller is responsible for that.
101
+ */
102
+ export function updateSessionProcessState(
103
+ sessionId: string,
104
+ update: Partial<SessionProcessState>
105
+ ): void {
106
+ if (!appState.sessionStates[sessionId]) {
107
+ appState.sessionStates[sessionId] = { ...DEFAULT_SESSION_STATE };
108
+ }
109
+ Object.assign(appState.sessionStates[sessionId], update);
110
+ }
111
+
112
+ /**
113
+ * Sync global convenience flags from a session's per-session state.
114
+ * Call when switching sessions to derive global state from the new session.
115
+ */
116
+ export function syncGlobalStateFromSession(sessionId: string): void {
117
+ const state = appState.sessionStates[sessionId] ?? DEFAULT_SESSION_STATE;
118
+ appState.isLoading = state.isLoading;
119
+ appState.isWaitingInput = state.isWaitingInput;
120
+ appState.isRestoring = state.isRestoring;
121
+ appState.isCancelling = state.isCancelling;
122
+ appState.error = state.error;
123
+ }
124
+
125
+ /**
126
+ * Remove a session's process state entry (e.g. when deleting a session).
127
+ */
128
+ export function clearSessionProcessState(sessionId: string): void {
129
+ delete appState.sessionStates[sessionId];
130
+ }
131
+
55
132
  // ========================================
56
133
  // UI STATE MANAGEMENT
57
134
  // ========================================
@@ -138,6 +138,16 @@ export const terminalStore = {
138
138
  }));
139
139
 
140
140
  terminalState.activeSessionId = sessionId;
141
+
142
+ // Persist active session ID for restoration after browser refresh
143
+ const session = terminalState.sessions.find(s => s.id === sessionId);
144
+ if (session?.projectId && typeof sessionStorage !== 'undefined') {
145
+ try {
146
+ sessionStorage.setItem(`terminal-active-session-${session.projectId}`, sessionId);
147
+ } catch {
148
+ // sessionStorage not available
149
+ }
150
+ }
141
151
  },
142
152
 
143
153
  async closeSession(sessionId: string): Promise<boolean> {
@@ -2,7 +2,9 @@ import type { SDKMessageFormatter } from '$shared/types/database/schema';
2
2
  import {
3
3
  shouldFilterMessage,
4
4
  extractToolUses,
5
- extractToolResults
5
+ extractToolResults,
6
+ isCompactBoundaryMessage,
7
+ isSyntheticUserMessage
6
8
  } from './message-processor';
7
9
  import { processToolMessage } from './tool-handler';
8
10
 
@@ -21,13 +23,50 @@ export interface BackgroundBashData {
21
23
  // Processed message type
22
24
  export type ProcessedMessage = SDKMessageFormatter;
23
25
 
26
+ // Module-level map for compact summary lookup (rebuilt each groupMessages call)
27
+ let _compactSummaryMap = new WeakMap<SDKMessageFormatter, string>();
28
+
29
+ // Lookup compact summary for a given compact_boundary message
30
+ export function getCompactSummary(message: SDKMessageFormatter): string | undefined {
31
+ return _compactSummaryMap.get(message);
32
+ }
33
+
34
+ // Extract text content from a user message
35
+ function extractUserTextContent(message: SDKMessageFormatter): string {
36
+ if (!('message' in message) || !message.message?.content) return '';
37
+ const content = message.message.content;
38
+ if (typeof content === 'string') return content;
39
+ if (Array.isArray(content)) {
40
+ return content
41
+ .filter((item: any) => typeof item === 'object' && item?.type === 'text')
42
+ .map((item: any) => item.text)
43
+ .join('\n');
44
+ }
45
+ return '';
46
+ }
47
+
48
+ // Get parent_tool_use_id from any message type
49
+ function getParentToolUseId(message: SDKMessageFormatter): string | null {
50
+ if ('parent_tool_use_id' in message) {
51
+ return (message as any).parent_tool_use_id ?? null;
52
+ }
53
+ return null;
54
+ }
55
+
24
56
  // Group tool_use and tool_result messages together
25
57
  export function groupMessages(messages: SDKMessageFormatter[]): {
26
58
  groups: ProcessedMessage[],
27
- toolUseMap: Map<string, ToolGroup>
59
+ toolUseMap: Map<string, ToolGroup>,
60
+ subAgentMap: Map<string, SDKMessageFormatter[]>
28
61
  } {
29
62
  const groups: ProcessedMessage[] = [];
30
63
  const toolUseMap = new Map<string, ToolGroup>();
64
+ const agentToolUseIds = new Set<string>();
65
+ const subAgentMap = new Map<string, SDKMessageFormatter[]>();
66
+ let lastCompactBoundaryIdx = -1;
67
+
68
+ // Rebuild compact summary map each call
69
+ _compactSummaryMap = new WeakMap<SDKMessageFormatter, string>();
31
70
 
32
71
  messages.forEach(message => {
33
72
  // Skip messages that should be filtered
@@ -35,8 +74,30 @@ export function groupMessages(messages: SDKMessageFormatter[]): {
35
74
  return;
36
75
  }
37
76
 
77
+ // Intercept ALL sub-agent messages (any parent_tool_use_id !== null)
78
+ // before normal processing — these belong to Agent tool sub-conversations
79
+ const parentToolId = getParentToolUseId(message);
80
+ if (parentToolId) {
81
+ if (agentToolUseIds.has(parentToolId)) {
82
+ if (!subAgentMap.has(parentToolId)) {
83
+ subAgentMap.set(parentToolId, []);
84
+ }
85
+ subAgentMap.get(parentToolId)!.push(message);
86
+ }
87
+ // Don't add any sub-agent message to main groups
88
+ return;
89
+ }
90
+
91
+ // Handle compact boundary messages — track for synthetic user embedding
92
+ if (isCompactBoundaryMessage(message)) {
93
+ lastCompactBoundaryIdx = groups.length;
94
+ groups.push(message as ProcessedMessage);
95
+ return;
96
+ }
97
+
38
98
  // Handle assistant messages with tool_use
39
99
  if (message.type === 'assistant' && 'message' in message && message.message?.content) {
100
+ lastCompactBoundaryIdx = -1;
40
101
  const toolUses = extractToolUses(message.message.content);
41
102
 
42
103
  if (toolUses.length > 0) {
@@ -47,17 +108,35 @@ export function groupMessages(messages: SDKMessageFormatter[]): {
47
108
  toolUseMessage: message,
48
109
  toolResultMessage: null
49
110
  });
111
+ // Track Agent tool IDs for sub-agent message collection
112
+ if (toolUse.name === 'Agent') {
113
+ agentToolUseIds.add(toolUse.id);
114
+ }
50
115
  }
51
116
  });
52
117
  groups.push(message as ProcessedMessage);
53
118
  } else {
54
119
  groups.push(message as ProcessedMessage);
55
120
  }
121
+ return;
56
122
  }
57
- // Handle user messages with tool_result
58
- else if (message.type === 'user' && 'message' in message && message.message?.content) {
59
- const toolResults = extractToolResults(message.message.content);
60
123
 
124
+ // Handle user messages
125
+ if (message.type === 'user' && 'message' in message && message.message?.content) {
126
+ // Synthetic user messages (after compaction): store summary in WeakMap keyed by compact boundary message
127
+ if (isSyntheticUserMessage(message) && lastCompactBoundaryIdx >= 0) {
128
+ const compactMsg = groups[lastCompactBoundaryIdx];
129
+ const summaryText = extractUserTextContent(message);
130
+ if (summaryText) {
131
+ _compactSummaryMap.set(compactMsg, summaryText);
132
+ }
133
+ lastCompactBoundaryIdx = -1;
134
+ return;
135
+ }
136
+
137
+ lastCompactBoundaryIdx = -1;
138
+
139
+ const toolResults = extractToolResults(message.message.content);
61
140
  if (toolResults.length > 0) {
62
141
  // Group tool_result with corresponding tool_use
63
142
  toolResults.forEach((toolResult: any) => {
@@ -73,20 +152,22 @@ export function groupMessages(messages: SDKMessageFormatter[]): {
73
152
  // Regular user message
74
153
  groups.push(message as ProcessedMessage);
75
154
  }
155
+ return;
76
156
  }
157
+
77
158
  // Include stream_event and other messages
78
- else {
79
- groups.push(message as ProcessedMessage);
80
- }
159
+ lastCompactBoundaryIdx = -1;
160
+ groups.push(message as ProcessedMessage);
81
161
  });
82
162
 
83
- return { groups, toolUseMap };
163
+ return { groups, toolUseMap, subAgentMap };
84
164
  }
85
165
 
86
166
  // Add tool results to messages
87
167
  export function embedToolResults(
88
168
  groups: ProcessedMessage[],
89
- toolUseMap: Map<string, ToolGroup>
169
+ toolUseMap: Map<string, ToolGroup>,
170
+ subAgentMap: Map<string, SDKMessageFormatter[]>
90
171
  ): ProcessedMessage[] {
91
172
  // Track background bash sessions
92
173
  const backgroundBashMap = trackBackgroundBashSessions(groups, toolUseMap);
@@ -100,7 +181,8 @@ export function embedToolResults(
100
181
  const processedMessage = processToolMessage(
101
182
  message,
102
183
  toolUseMap,
103
- backgroundBashMap
184
+ backgroundBashMap,
185
+ subAgentMap
104
186
  );
105
187
  return processedMessage;
106
188
  }
@@ -216,4 +298,4 @@ function trackBashOutput(
216
298
  bashData.bashOutputs.push(toolResult);
217
299
  }
218
300
  }
219
- }
301
+ }