@assistkick/create 1.9.0 → 1.11.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 (206) hide show
  1. package/dist/src/scaffolder.d.ts +12 -1
  2. package/dist/src/scaffolder.js +40 -3
  3. package/dist/src/scaffolder.js.map +1 -1
  4. package/package.json +1 -1
  5. package/templates/assistkick-product-system/package.json +1 -1
  6. package/templates/assistkick-product-system/packages/backend/package.json +1 -0
  7. package/templates/assistkick-product-system/packages/backend/src/mcp/permission_mcp_server.ts +196 -0
  8. package/templates/assistkick-product-system/packages/backend/src/routes/agents.ts +31 -7
  9. package/templates/assistkick-product-system/packages/backend/src/routes/auth.ts +15 -12
  10. package/templates/assistkick-product-system/packages/backend/src/routes/chat_files.test.ts +95 -0
  11. package/templates/assistkick-product-system/packages/backend/src/routes/chat_files.ts +97 -0
  12. package/templates/assistkick-product-system/packages/backend/src/routes/chat_permission.ts +94 -0
  13. package/templates/assistkick-product-system/packages/backend/src/routes/chat_sessions.ts +189 -0
  14. package/templates/assistkick-product-system/packages/backend/src/routes/chat_upload.test.ts +131 -0
  15. package/templates/assistkick-product-system/packages/backend/src/routes/chat_upload.ts +94 -0
  16. package/templates/assistkick-product-system/packages/backend/src/routes/files.test.ts +12 -3
  17. package/templates/assistkick-product-system/packages/backend/src/routes/files.ts +2 -2
  18. package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +391 -23
  19. package/templates/assistkick-product-system/packages/backend/src/routes/git_branches.test.ts +306 -0
  20. package/templates/assistkick-product-system/packages/backend/src/routes/git_connect.test.ts +133 -0
  21. package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +66 -9
  22. package/templates/assistkick-product-system/packages/backend/src/routes/preview.ts +204 -0
  23. package/templates/assistkick-product-system/packages/backend/src/routes/projects.test.ts +205 -0
  24. package/templates/assistkick-product-system/packages/backend/src/routes/projects.ts +37 -9
  25. package/templates/assistkick-product-system/packages/backend/src/routes/skills.test.ts +139 -0
  26. package/templates/assistkick-product-system/packages/backend/src/routes/skills.ts +95 -0
  27. package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +5 -4
  28. package/templates/assistkick-product-system/packages/backend/src/routes/users.ts +4 -4
  29. package/templates/assistkick-product-system/packages/backend/src/routes/video.ts +8 -8
  30. package/templates/assistkick-product-system/packages/backend/src/routes/workflow_groups.ts +5 -5
  31. package/templates/assistkick-product-system/packages/backend/src/routes/workflows.ts +6 -6
  32. package/templates/assistkick-product-system/packages/backend/src/server.ts +107 -27
  33. package/templates/assistkick-product-system/packages/backend/src/services/agent_service.test.ts +105 -203
  34. package/templates/assistkick-product-system/packages/backend/src/services/agent_service.ts +76 -266
  35. package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.test.ts +427 -0
  36. package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts +345 -0
  37. package/templates/assistkick-product-system/packages/backend/src/services/chat_message_repository.test.ts +170 -0
  38. package/templates/assistkick-product-system/packages/backend/src/services/chat_message_repository.ts +106 -0
  39. package/templates/assistkick-product-system/packages/backend/src/services/chat_session_service.test.ts +217 -0
  40. package/templates/assistkick-product-system/packages/backend/src/services/chat_session_service.ts +188 -0
  41. package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.test.ts +1243 -0
  42. package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts +894 -0
  43. package/templates/assistkick-product-system/packages/backend/src/services/coherence-review.ts +3 -3
  44. package/templates/assistkick-product-system/packages/backend/src/services/dev_command_detector.test.ts +85 -0
  45. package/templates/assistkick-product-system/packages/backend/src/services/dev_command_detector.ts +54 -0
  46. package/templates/assistkick-product-system/packages/backend/src/services/email_service.ts +13 -10
  47. package/templates/assistkick-product-system/packages/backend/src/services/init.ts +11 -3
  48. package/templates/assistkick-product-system/packages/backend/src/services/invitation_service.ts +1 -1
  49. package/templates/assistkick-product-system/packages/backend/src/services/password_reset_service.ts +1 -1
  50. package/templates/assistkick-product-system/packages/backend/src/services/permission_service.test.ts +243 -0
  51. package/templates/assistkick-product-system/packages/backend/src/services/permission_service.ts +259 -0
  52. package/templates/assistkick-product-system/packages/backend/src/services/preview_server_manager.test.ts +172 -0
  53. package/templates/assistkick-product-system/packages/backend/src/services/preview_server_manager.ts +225 -0
  54. package/templates/assistkick-product-system/packages/backend/src/services/project_service.test.ts +29 -0
  55. package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +17 -0
  56. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +255 -0
  57. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +300 -25
  58. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +44 -0
  59. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +62 -7
  60. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.test.ts +77 -6
  61. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.ts +149 -14
  62. package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +2 -1
  63. package/templates/assistkick-product-system/packages/backend/src/services/title_generator_service.test.ts +45 -0
  64. package/templates/assistkick-product-system/packages/backend/src/services/title_generator_service.ts +157 -0
  65. package/templates/assistkick-product-system/packages/backend/src/services/tts_service.ts +4 -3
  66. package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.ts +3 -3
  67. package/templates/assistkick-product-system/packages/frontend/package.json +5 -0
  68. package/templates/assistkick-product-system/packages/frontend/src/App.tsx +2 -0
  69. package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +336 -5
  70. package/templates/assistkick-product-system/packages/frontend/src/components/AgentsView.tsx +192 -12
  71. package/templates/assistkick-product-system/packages/frontend/src/components/AttachmentPreviewList.tsx +98 -0
  72. package/templates/assistkick-product-system/packages/frontend/src/components/AutocompleteDropdown.tsx +65 -0
  73. package/templates/assistkick-product-system/packages/frontend/src/components/ChatAttachButton.tsx +56 -0
  74. package/templates/assistkick-product-system/packages/frontend/src/components/ChatDropZone.tsx +80 -0
  75. package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageBubble.tsx +155 -0
  76. package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageContent.tsx +182 -0
  77. package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageInput.tsx +233 -0
  78. package/templates/assistkick-product-system/packages/frontend/src/components/ChatSessionSidebar.tsx +218 -0
  79. package/templates/assistkick-product-system/packages/frontend/src/components/ChatStopButton.tsx +32 -0
  80. package/templates/assistkick-product-system/packages/frontend/src/components/ChatTodoSidebar.tsx +113 -0
  81. package/templates/assistkick-product-system/packages/frontend/src/components/ChatView.tsx +842 -0
  82. package/templates/assistkick-product-system/packages/frontend/src/components/CommitMessageModal.tsx +82 -0
  83. package/templates/assistkick-product-system/packages/frontend/src/components/DiagramOverlay.tsx +160 -0
  84. package/templates/assistkick-product-system/packages/frontend/src/components/EditorTabBar.tsx +5 -5
  85. package/templates/assistkick-product-system/packages/frontend/src/components/FileTree.tsx +9 -10
  86. package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeInlineInput.tsx +5 -5
  87. package/templates/assistkick-product-system/packages/frontend/src/components/FilesView.tsx +112 -41
  88. package/templates/assistkick-product-system/packages/frontend/src/components/GraphLegend.tsx +2 -2
  89. package/templates/assistkick-product-system/packages/frontend/src/components/HighlightedText.tsx +87 -0
  90. package/templates/assistkick-product-system/packages/frontend/src/components/ImageLightbox.tsx +192 -0
  91. package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +2 -2
  92. package/templates/assistkick-product-system/packages/frontend/src/components/MentionPill.tsx +33 -0
  93. package/templates/assistkick-product-system/packages/frontend/src/components/MermaidBlock.tsx +148 -0
  94. package/templates/assistkick-product-system/packages/frontend/src/components/PermissionDialog.tsx +91 -0
  95. package/templates/assistkick-product-system/packages/frontend/src/components/PermissionModeSelector.tsx +229 -0
  96. package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +249 -83
  97. package/templates/assistkick-product-system/packages/frontend/src/components/QueuedMessageBubble.tsx +38 -0
  98. package/templates/assistkick-product-system/packages/frontend/src/components/SidePanel.tsx +212 -117
  99. package/templates/assistkick-product-system/packages/frontend/src/components/SystemPromptAccordion.tsx +48 -0
  100. package/templates/assistkick-product-system/packages/frontend/src/components/TaskIcon.tsx +11 -0
  101. package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +25 -9
  102. package/templates/assistkick-product-system/packages/frontend/src/components/ToolDiffView.tsx +114 -0
  103. package/templates/assistkick-product-system/packages/frontend/src/components/ToolResultCard.tsx +87 -0
  104. package/templates/assistkick-product-system/packages/frontend/src/components/ToolUseCard.tsx +149 -0
  105. package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +25 -8
  106. package/templates/assistkick-product-system/packages/frontend/src/components/UnifiedGitWidget.tsx +722 -0
  107. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GroupNode.tsx +2 -0
  108. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/NodePalette.tsx +2 -1
  109. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/ProgrammableNode.tsx +178 -0
  110. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowCanvas.tsx +3 -0
  111. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowMonitorModal.tsx +103 -9
  112. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/monitor_nodes.tsx +26 -2
  113. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.ts +42 -1
  114. package/templates/assistkick-product-system/packages/frontend/src/hooks/useDocumentTitle.ts +11 -0
  115. package/templates/assistkick-product-system/packages/frontend/src/hooks/useProjects.ts +1 -0
  116. package/templates/assistkick-product-system/packages/frontend/src/hooks/use_chat_stream.ts +826 -0
  117. package/templates/assistkick-product-system/packages/frontend/src/hooks/use_file_tree_cache.ts +69 -0
  118. package/templates/assistkick-product-system/packages/frontend/src/hooks/use_mention_autocomplete.ts +284 -0
  119. package/templates/assistkick-product-system/packages/frontend/src/lib/attachment_manager.test.ts +183 -0
  120. package/templates/assistkick-product-system/packages/frontend/src/lib/attachment_manager.ts +150 -0
  121. package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.test.ts +305 -0
  122. package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.ts +113 -0
  123. package/templates/assistkick-product-system/packages/frontend/src/lib/context_usage_helpers.test.ts +157 -0
  124. package/templates/assistkick-product-system/packages/frontend/src/lib/context_usage_helpers.ts +95 -0
  125. package/templates/assistkick-product-system/packages/frontend/src/lib/mermaid_helpers.test.ts +65 -0
  126. package/templates/assistkick-product-system/packages/frontend/src/lib/mermaid_helpers.ts +110 -0
  127. package/templates/assistkick-product-system/packages/frontend/src/lib/message_queue.ts +66 -0
  128. package/templates/assistkick-product-system/packages/frontend/src/lib/tool_use_summary.test.ts +124 -0
  129. package/templates/assistkick-product-system/packages/frontend/src/lib/tool_use_summary.ts +112 -0
  130. package/templates/assistkick-product-system/packages/frontend/src/routes/AgentsRoute.tsx +2 -0
  131. package/templates/assistkick-product-system/packages/frontend/src/routes/ChatRoute.tsx +8 -0
  132. package/templates/assistkick-product-system/packages/frontend/src/routes/CoherenceRoute.tsx +2 -0
  133. package/templates/assistkick-product-system/packages/frontend/src/routes/DashboardLayout.tsx +0 -4
  134. package/templates/assistkick-product-system/packages/frontend/src/routes/DesignSystemRoute.tsx +2 -0
  135. package/templates/assistkick-product-system/packages/frontend/src/routes/FilesRoute.tsx +2 -0
  136. package/templates/assistkick-product-system/packages/frontend/src/routes/GraphRoute.tsx +2 -0
  137. package/templates/assistkick-product-system/packages/frontend/src/routes/KanbanRoute.tsx +2 -0
  138. package/templates/assistkick-product-system/packages/frontend/src/routes/TerminalRoute.tsx +2 -0
  139. package/templates/assistkick-product-system/packages/frontend/src/routes/UsersRoute.tsx +2 -0
  140. package/templates/assistkick-product-system/packages/frontend/src/routes/VideographyRoute.tsx +2 -0
  141. package/templates/assistkick-product-system/packages/frontend/src/routes/WorkflowsRoute.tsx +2 -0
  142. package/templates/assistkick-product-system/packages/frontend/src/routes/accept_invitation.tsx +2 -0
  143. package/templates/assistkick-product-system/packages/frontend/src/routes/forgot_password.tsx +2 -0
  144. package/templates/assistkick-product-system/packages/frontend/src/routes/login.tsx +2 -0
  145. package/templates/assistkick-product-system/packages/frontend/src/routes/register.tsx +2 -0
  146. package/templates/assistkick-product-system/packages/frontend/src/routes/reset_password.tsx +2 -0
  147. package/templates/assistkick-product-system/packages/frontend/src/stores/useAttachmentStore.ts +66 -0
  148. package/templates/assistkick-product-system/packages/frontend/src/stores/useChatSessionStore.ts +107 -0
  149. package/templates/assistkick-product-system/packages/frontend/src/stores/useMessageQueueStore.ts +110 -0
  150. package/templates/assistkick-product-system/packages/frontend/src/stores/usePreviewStore.ts +78 -0
  151. package/templates/assistkick-product-system/packages/frontend/src/stores/useProjectStore.ts +7 -0
  152. package/templates/assistkick-product-system/packages/frontend/src/stores/useSidePanelStore.ts +6 -1
  153. package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +30 -357
  154. package/templates/assistkick-product-system/packages/frontend/src/utils/parse_node_markdown.test.ts +115 -0
  155. package/templates/assistkick-product-system/packages/frontend/src/utils/parse_node_markdown.ts +91 -0
  156. package/templates/assistkick-product-system/packages/frontend/src/utils/preview_utils.test.ts +30 -0
  157. package/templates/assistkick-product-system/packages/frontend/src/utils/preview_utils.ts +3 -0
  158. package/templates/assistkick-product-system/packages/shared/db/migrations/0015_magenta_jazinda.sql +1 -0
  159. package/templates/assistkick-product-system/packages/shared/db/migrations/0016_giant_xorn.sql +1 -0
  160. package/templates/assistkick-product-system/packages/shared/db/migrations/0017_sloppy_mentor.sql +6 -0
  161. package/templates/assistkick-product-system/packages/shared/db/migrations/0018_vengeful_kabuki.sql +9 -0
  162. package/templates/assistkick-product-system/packages/shared/db/migrations/0019_careful_sentinels.sql +8 -0
  163. package/templates/assistkick-product-system/packages/shared/db/migrations/0020_clever_spot.sql +27 -0
  164. package/templates/assistkick-product-system/packages/shared/db/migrations/0021_graceful_hex.sql +1 -0
  165. package/templates/assistkick-product-system/packages/shared/db/migrations/0022_short_kingpin.sql +1 -0
  166. package/templates/assistkick-product-system/packages/shared/db/migrations/0023_ambiguous_sharon_carter.sql +1 -0
  167. package/templates/assistkick-product-system/packages/shared/db/migrations/0024_fat_unus.sql +1 -0
  168. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0015_snapshot.json +1552 -0
  169. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0016_snapshot.json +1560 -0
  170. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0017_snapshot.json +1598 -0
  171. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0018_snapshot.json +1657 -0
  172. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0019_snapshot.json +1709 -0
  173. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0020_snapshot.json +1733 -0
  174. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0021_snapshot.json +1740 -0
  175. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0022_snapshot.json +1755 -0
  176. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0023_snapshot.json +1762 -0
  177. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0024_snapshot.json +1769 -0
  178. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +70 -0
  179. package/templates/assistkick-product-system/packages/shared/db/schema.ts +40 -1
  180. package/templates/assistkick-product-system/packages/shared/lib/claude-service.test.ts +236 -0
  181. package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +46 -5
  182. package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +65 -39
  183. package/templates/assistkick-product-system/packages/shared/lib/programmable_node_executor.test.ts +173 -0
  184. package/templates/assistkick-product-system/packages/shared/lib/programmable_node_executor.ts +213 -0
  185. package/templates/assistkick-product-system/packages/shared/lib/validator.test.ts +70 -0
  186. package/templates/assistkick-product-system/packages/shared/lib/validator.ts +17 -1
  187. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.test.ts +803 -27
  188. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.ts +502 -68
  189. package/templates/assistkick-product-system/packages/shared/lib/workflow_orchestrator.ts +4 -4
  190. package/templates/assistkick-product-system/packages/shared/package.json +2 -1
  191. package/templates/assistkick-product-system/packages/shared/test_fixtures/hanging_stream.mjs +46 -0
  192. package/templates/assistkick-product-system/packages/shared/tools/add_node.test.ts +44 -0
  193. package/templates/assistkick-product-system/packages/shared/tools/add_node.ts +7 -0
  194. package/templates/assistkick-product-system/packages/shared/tools/remove_node.ts +2 -1
  195. package/templates/assistkick-product-system/packages/shared/tools/resolve_question.ts +2 -1
  196. package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -1
  197. package/templates/assistkick-product-system/tests/message_queue.test.ts +178 -0
  198. package/templates/assistkick-product-system/tests/message_queue_per_session.test.ts +143 -0
  199. package/templates/skills/assistkick-bootstrap/SKILL.md +26 -26
  200. package/templates/skills/assistkick-code-reviewer/SKILL.md +45 -46
  201. package/templates/skills/assistkick-db-explorer/SKILL.md +13 -13
  202. package/templates/skills/assistkick-debugger/SKILL.md +23 -23
  203. package/templates/skills/assistkick-developer/SKILL.md +59 -63
  204. package/templates/skills/assistkick-interview/SKILL.md +26 -26
  205. package/templates/skills/assistkick-video-composition-agent/SKILL.md +231 -0
  206. package/templates/skills/assistkick-video-script-writer/SKILL.md +136 -0
@@ -0,0 +1,1243 @@
1
+ import { describe, it, mock, beforeEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { ChatWsHandler, type SendMessagePayload, type ChatServerMessage } from './chat_ws_handler.ts';
4
+
5
+ /**
6
+ * Create a mock WebSocket with controllable readyState and message capture.
7
+ */
8
+ const createMockWs = (readyState = 1) => {
9
+ const messages: string[] = [];
10
+ const listeners: Record<string, Function[]> = {};
11
+
12
+ return {
13
+ OPEN: 1,
14
+ readyState,
15
+ send: mock.fn((data: string) => { messages.push(data); }),
16
+ close: mock.fn(),
17
+ on: mock.fn((event: string, cb: Function) => {
18
+ if (!listeners[event]) listeners[event] = [];
19
+ listeners[event].push(cb);
20
+ }),
21
+ _messages: messages,
22
+ _emit: (event: string, ...args: unknown[]) => {
23
+ for (const cb of listeners[event] || []) cb(...args);
24
+ },
25
+ _getParsedMessages: (): ChatServerMessage[] => messages.map(m => JSON.parse(m)),
26
+ };
27
+ };
28
+
29
+ /**
30
+ * Create a mock WebSocketServer.
31
+ */
32
+ const createMockWss = () => ({
33
+ handleUpgrade: mock.fn((_req: unknown, _socket: unknown, _head: unknown, cb: Function) => {
34
+ cb(createMockWs());
35
+ }),
36
+ });
37
+
38
+ /**
39
+ * Create a mock AuthService.
40
+ */
41
+ const createMockAuthService = (role = 'admin') => ({
42
+ verifyToken: mock.fn(async () => ({ sub: 'user-1', email: 'admin@test.com', role })),
43
+ });
44
+
45
+ /**
46
+ * Create a mock ChatCliBridge.
47
+ */
48
+ const createMockBridge = () => {
49
+ let lastOnEvent: ((event: Record<string, unknown>) => void) | null = null;
50
+ let resolveCompletion: ((result: { exitCode: number | null; claudeSessionId: string; sessionId: string | null }) => void) | null = null;
51
+ let rejectCompletion: ((err: Error) => void) | null = null;
52
+
53
+ return {
54
+ spawn: mock.fn((options: Record<string, unknown>) => {
55
+ lastOnEvent = options.onEvent as (event: Record<string, unknown>) => void;
56
+ const completion = new Promise<{ exitCode: number | null; claudeSessionId: string; sessionId: string | null }>((resolve, reject) => {
57
+ resolveCompletion = resolve;
58
+ rejectCompletion = reject;
59
+ });
60
+ return { childProcess: {}, completion, systemPrompt: options.isNewSession ? 'test system prompt' : null };
61
+ }),
62
+ kill: mock.fn(() => true),
63
+ isActive: mock.fn(() => false),
64
+ _emitEvent: (event: Record<string, unknown>) => {
65
+ if (lastOnEvent) lastOnEvent(event);
66
+ },
67
+ _completeStream: (exitCode: number | null, claudeSessionId: string) => {
68
+ if (resolveCompletion) resolveCompletion({ exitCode, claudeSessionId, sessionId: null });
69
+ },
70
+ _failStream: (err: Error) => {
71
+ if (rejectCompletion) rejectCompletion(err);
72
+ },
73
+ };
74
+ };
75
+
76
+ /**
77
+ * Create a mock ChatMessageRepository for testing persistence.
78
+ */
79
+ const createMockMessageRepository = () => {
80
+ let msgCounter = 0;
81
+ return {
82
+ getNextOrderIndex: mock.fn(async () => 0),
83
+ saveMessage: mock.fn(async (sessionId: string, role: string, content: string, orderIndex: number) => ({
84
+ id: `cmsg_mock_${++msgCounter}`,
85
+ sessionId,
86
+ role,
87
+ content,
88
+ orderIndex,
89
+ createdAt: new Date().toISOString(),
90
+ })),
91
+ updateMessageContent: mock.fn(async () => {}),
92
+ getMessages: mock.fn(async () => []),
93
+ deleteBySessionId: mock.fn(async () => 0),
94
+ };
95
+ };
96
+
97
+ /**
98
+ * Create a mock IncomingMessage with cookie headers.
99
+ */
100
+ const createMockRequest = (path: string, token?: string) => ({
101
+ url: path,
102
+ headers: {
103
+ cookie: token ? `access_token=${token}` : undefined,
104
+ },
105
+ });
106
+
107
+ describe('ChatWsHandler', () => {
108
+ let handler: ChatWsHandler;
109
+ let mockWss: ReturnType<typeof createMockWss>;
110
+ let mockAuthService: ReturnType<typeof createMockAuthService>;
111
+ let mockBridge: ReturnType<typeof createMockBridge>;
112
+ let logMock: ReturnType<typeof mock.fn>;
113
+
114
+ beforeEach(() => {
115
+ logMock = mock.fn();
116
+ mockWss = createMockWss();
117
+ mockAuthService = createMockAuthService();
118
+ mockBridge = createMockBridge();
119
+
120
+ handler = new ChatWsHandler({
121
+ wss: mockWss as any,
122
+ authService: mockAuthService as any,
123
+ chatCliBridge: mockBridge as any,
124
+ log: logMock,
125
+ });
126
+ });
127
+
128
+ describe('handleUpgrade', () => {
129
+ it('calls wss.handleUpgrade for /api/chat path', () => {
130
+ const req = createMockRequest('/api/chat', 'valid-token');
131
+ const socket = {} as any;
132
+ const head = Buffer.from('');
133
+
134
+ handler.handleUpgrade(req as any, socket, head);
135
+
136
+ assert.equal(mockWss.handleUpgrade.mock.calls.length, 1);
137
+ });
138
+
139
+ it('ignores non-chat paths', () => {
140
+ const req = createMockRequest('/api/terminal', 'valid-token');
141
+ const socket = {} as any;
142
+ const head = Buffer.from('');
143
+
144
+ handler.handleUpgrade(req as any, socket, head);
145
+
146
+ assert.equal(mockWss.handleUpgrade.mock.calls.length, 0);
147
+ });
148
+
149
+ it('ignores other paths like /api/other', () => {
150
+ const req = createMockRequest('/api/other', 'valid-token');
151
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
152
+ assert.equal(mockWss.handleUpgrade.mock.calls.length, 0);
153
+ });
154
+ });
155
+
156
+ describe('authentication', () => {
157
+ it('rejects connection with no access token', async () => {
158
+ const ws = createMockWs();
159
+ const req = createMockRequest('/api/chat');
160
+
161
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws));
162
+ handler = new ChatWsHandler({
163
+ wss: mockWss as any,
164
+ authService: mockAuthService as any,
165
+ chatCliBridge: mockBridge as any,
166
+ log: logMock,
167
+ });
168
+
169
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
170
+
171
+ // Allow async onConnection to complete
172
+ await new Promise(resolve => setTimeout(resolve, 10));
173
+
174
+ assert.equal(ws.close.mock.calls.length, 1);
175
+ assert.equal(ws.close.mock.calls[0].arguments[0], 4001);
176
+ });
177
+
178
+ it('rejects connection with invalid token', async () => {
179
+ const ws = createMockWs();
180
+ const req = createMockRequest('/api/chat', 'bad-token');
181
+
182
+ const failAuthService = {
183
+ verifyToken: mock.fn(async () => { throw new Error('Invalid'); }),
184
+ };
185
+
186
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws));
187
+ handler = new ChatWsHandler({
188
+ wss: mockWss as any,
189
+ authService: failAuthService as any,
190
+ chatCliBridge: mockBridge as any,
191
+ log: logMock,
192
+ });
193
+
194
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
195
+ await new Promise(resolve => setTimeout(resolve, 10));
196
+
197
+ assert.equal(ws.close.mock.calls.length, 1);
198
+ assert.equal(ws.close.mock.calls[0].arguments[0], 4001);
199
+ });
200
+
201
+ it('rejects non-admin users', async () => {
202
+ const ws = createMockWs();
203
+ const req = createMockRequest('/api/chat', 'valid-token');
204
+
205
+ const nonAdminAuth = createMockAuthService('viewer');
206
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws));
207
+ handler = new ChatWsHandler({
208
+ wss: mockWss as any,
209
+ authService: nonAdminAuth as any,
210
+ chatCliBridge: mockBridge as any,
211
+ log: logMock,
212
+ });
213
+
214
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
215
+ await new Promise(resolve => setTimeout(resolve, 10));
216
+
217
+ assert.equal(ws.close.mock.calls.length, 1);
218
+ assert.equal(ws.close.mock.calls[0].arguments[0], 4003);
219
+ });
220
+
221
+ it('accepts admin users and registers message listener', async () => {
222
+ const ws = createMockWs();
223
+ const req = createMockRequest('/api/chat', 'valid-token');
224
+
225
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws));
226
+ handler = new ChatWsHandler({
227
+ wss: mockWss as any,
228
+ authService: mockAuthService as any,
229
+ chatCliBridge: mockBridge as any,
230
+ log: logMock,
231
+ });
232
+
233
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
234
+ await new Promise(resolve => setTimeout(resolve, 10));
235
+
236
+ assert.equal(ws.close.mock.calls.length, 0);
237
+ // Should have registered 'message', 'close', 'error' listeners
238
+ const registeredEvents = ws.on.mock.calls.map((c: any) => c.arguments[0]);
239
+ assert.ok(registeredEvents.includes('message'));
240
+ assert.ok(registeredEvents.includes('close'));
241
+ assert.ok(registeredEvents.includes('error'));
242
+ });
243
+ });
244
+
245
+ describe('send_message handling', () => {
246
+ let ws: ReturnType<typeof createMockWs>;
247
+
248
+ beforeEach(async () => {
249
+ ws = createMockWs();
250
+ const req = createMockRequest('/api/chat', 'valid-token');
251
+
252
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws));
253
+ handler = new ChatWsHandler({
254
+ wss: mockWss as any,
255
+ authService: mockAuthService as any,
256
+ chatCliBridge: mockBridge as any,
257
+ log: logMock,
258
+ });
259
+
260
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
261
+ await new Promise(resolve => setTimeout(resolve, 10));
262
+ });
263
+
264
+ it('spawns CLI and sends stream_start on send_message', () => {
265
+ const msg: SendMessagePayload = {
266
+ type: 'send_message',
267
+ message: 'Hello Claude',
268
+ projectId: 'proj_test',
269
+ claudeSessionId: 'session-1',
270
+ isNewSession: true,
271
+ };
272
+
273
+ ws._emit('message', JSON.stringify(msg));
274
+
275
+ // Should have called bridge.spawn
276
+ assert.equal(mockBridge.spawn.mock.calls.length, 1);
277
+ const spawnArgs = mockBridge.spawn.mock.calls[0].arguments[0];
278
+ assert.equal(spawnArgs.message, 'Hello Claude');
279
+ assert.equal(spawnArgs.projectId, 'proj_test');
280
+ assert.equal(spawnArgs.claudeSessionId, 'session-1');
281
+ assert.equal(spawnArgs.isNewSession, true);
282
+ assert.equal(spawnArgs.permissionMode, 'skip');
283
+
284
+ // Should have sent stream_start
285
+ const messages = ws._getParsedMessages();
286
+ assert.equal(messages[0].type, 'stream_start');
287
+ assert.equal((messages[0] as any).claudeSessionId, 'session-1');
288
+ });
289
+
290
+ it('forwards stream events to the WebSocket', () => {
291
+ const msg: SendMessagePayload = {
292
+ type: 'send_message',
293
+ message: 'Hello',
294
+ projectId: 'proj_test',
295
+ claudeSessionId: 'session-1',
296
+ isNewSession: true,
297
+ };
298
+
299
+ ws._emit('message', JSON.stringify(msg));
300
+
301
+ // Simulate CLI emitting an assistant event
302
+ const assistantEvent = {
303
+ type: 'assistant',
304
+ message: { content: [{ type: 'text', text: 'Hi there!' }] },
305
+ };
306
+ mockBridge._emitEvent(assistantEvent);
307
+
308
+ const messages = ws._getParsedMessages();
309
+ // stream_start + system_prompt + stream_event
310
+ assert.equal(messages.length, 3);
311
+ assert.equal(messages[2].type, 'stream_event');
312
+ assert.deepEqual((messages[2] as any).event, assistantEvent);
313
+ });
314
+
315
+ it('sends stream_end on successful completion', async () => {
316
+ const msg: SendMessagePayload = {
317
+ type: 'send_message',
318
+ message: 'Hello',
319
+ projectId: 'proj_test',
320
+ claudeSessionId: 'session-1',
321
+ isNewSession: true,
322
+ };
323
+
324
+ ws._emit('message', JSON.stringify(msg));
325
+ mockBridge._completeStream(0, 'session-1');
326
+
327
+ // Wait for promise resolution
328
+ await new Promise(resolve => setTimeout(resolve, 10));
329
+
330
+ const messages = ws._getParsedMessages();
331
+ const endMsg = messages.find((m: any) => m.type === 'stream_end');
332
+ assert.ok(endMsg);
333
+ assert.equal((endMsg as any).exitCode, 0);
334
+ assert.equal((endMsg as any).claudeSessionId, 'session-1');
335
+ });
336
+
337
+ it('sends stream_error on CLI failure', async () => {
338
+ const msg: SendMessagePayload = {
339
+ type: 'send_message',
340
+ message: 'Hello',
341
+ projectId: 'proj_test',
342
+ claudeSessionId: 'session-1',
343
+ isNewSession: true,
344
+ };
345
+
346
+ ws._emit('message', JSON.stringify(msg));
347
+ mockBridge._failStream(new Error('CLI crashed'));
348
+
349
+ await new Promise(resolve => setTimeout(resolve, 10));
350
+
351
+ const messages = ws._getParsedMessages();
352
+ const errMsg = messages.find((m: any) => m.type === 'stream_error');
353
+ assert.ok(errMsg);
354
+ assert.equal((errMsg as any).error, 'CLI crashed');
355
+ });
356
+
357
+ it('rejects concurrent messages on the same claude session', async () => {
358
+ const msg: SendMessagePayload = {
359
+ type: 'send_message',
360
+ message: 'First message',
361
+ projectId: 'proj_test',
362
+ claudeSessionId: 'session-1',
363
+ isNewSession: true,
364
+ };
365
+
366
+ ws._emit('message', JSON.stringify(msg));
367
+
368
+ // Send second message to the SAME session before first completes
369
+ const msg2: SendMessagePayload = {
370
+ type: 'send_message',
371
+ message: 'Second message',
372
+ projectId: 'proj_test',
373
+ claudeSessionId: 'session-1',
374
+ isNewSession: false,
375
+ };
376
+
377
+ ws._emit('message', JSON.stringify(msg2));
378
+
379
+ // Should only have spawned once
380
+ assert.equal(mockBridge.spawn.mock.calls.length, 1);
381
+
382
+ // Should have sent error for second message
383
+ const messages = ws._getParsedMessages();
384
+ const errMsg = messages.find((m: any) => m.type === 'error');
385
+ assert.ok(errMsg);
386
+ assert.ok((errMsg as any).message.includes('already being processed'));
387
+ });
388
+
389
+ it('allows concurrent messages to different claude sessions on the same connection', async () => {
390
+ const msg1: SendMessagePayload = {
391
+ type: 'send_message',
392
+ message: 'First session message',
393
+ projectId: 'proj_test',
394
+ claudeSessionId: 'session-1',
395
+ isNewSession: true,
396
+ };
397
+
398
+ ws._emit('message', JSON.stringify(msg1));
399
+
400
+ // Send message to a DIFFERENT session while first is still streaming
401
+ const msg2: SendMessagePayload = {
402
+ type: 'send_message',
403
+ message: 'Second session message',
404
+ projectId: 'proj_test',
405
+ claudeSessionId: 'session-2',
406
+ isNewSession: true,
407
+ };
408
+
409
+ ws._emit('message', JSON.stringify(msg2));
410
+
411
+ // Should have spawned twice — one per session
412
+ assert.equal(mockBridge.spawn.mock.calls.length, 2);
413
+ assert.equal(mockBridge.spawn.mock.calls[0].arguments[0].claudeSessionId, 'session-1');
414
+ assert.equal(mockBridge.spawn.mock.calls[1].arguments[0].claudeSessionId, 'session-2');
415
+
416
+ // Should have sent two stream_start messages
417
+ const messages = ws._getParsedMessages();
418
+ const starts = messages.filter((m: any) => m.type === 'stream_start');
419
+ assert.equal(starts.length, 2);
420
+
421
+ // No error messages
422
+ const errors = messages.filter((m: any) => m.type === 'error');
423
+ assert.equal(errors.length, 0);
424
+ });
425
+
426
+ it('allows new message after stream completes', async () => {
427
+ const msg: SendMessagePayload = {
428
+ type: 'send_message',
429
+ message: 'First',
430
+ projectId: 'proj_test',
431
+ claudeSessionId: 'session-1',
432
+ isNewSession: true,
433
+ };
434
+
435
+ ws._emit('message', JSON.stringify(msg));
436
+ mockBridge._completeStream(0, 'session-1');
437
+ await new Promise(resolve => setTimeout(resolve, 10));
438
+
439
+ // Now send second message — should work
440
+ const msg2: SendMessagePayload = {
441
+ type: 'send_message',
442
+ message: 'Second',
443
+ projectId: 'proj_test',
444
+ claudeSessionId: 'session-1',
445
+ isNewSession: false,
446
+ };
447
+
448
+ ws._emit('message', JSON.stringify(msg2));
449
+
450
+ assert.equal(mockBridge.spawn.mock.calls.length, 2);
451
+ });
452
+
453
+ it('rejects messages with missing required fields', () => {
454
+ const msg = {
455
+ type: 'send_message',
456
+ message: '',
457
+ projectId: 'proj_test',
458
+ claudeSessionId: 'session-1',
459
+ isNewSession: true,
460
+ };
461
+
462
+ ws._emit('message', JSON.stringify(msg));
463
+
464
+ assert.equal(mockBridge.spawn.mock.calls.length, 0);
465
+ const messages = ws._getParsedMessages();
466
+ const errMsg = messages.find((m: any) => m.type === 'error');
467
+ assert.ok(errMsg);
468
+ assert.ok((errMsg as any).message.includes('Missing required fields'));
469
+ });
470
+
471
+ it('sends error on invalid JSON', () => {
472
+ ws._emit('message', 'not valid json');
473
+
474
+ const messages = ws._getParsedMessages();
475
+ assert.equal(messages[0].type, 'error');
476
+ assert.equal((messages[0] as any).message, 'Invalid JSON');
477
+ });
478
+
479
+ it('sends system_prompt event for new sessions', () => {
480
+ const msg: SendMessagePayload = {
481
+ type: 'send_message',
482
+ message: 'Hello Claude',
483
+ projectId: 'proj_test',
484
+ claudeSessionId: 'session-1',
485
+ isNewSession: true,
486
+ };
487
+
488
+ ws._emit('message', JSON.stringify(msg));
489
+
490
+ const messages = ws._getParsedMessages();
491
+ // stream_start + system_prompt
492
+ const systemPromptMsg = messages.find((m: any) => m.type === 'system_prompt');
493
+ assert.ok(systemPromptMsg, 'Expected a system_prompt message for new session');
494
+ assert.equal((systemPromptMsg as any).prompt, 'test system prompt');
495
+ });
496
+
497
+ it('does not send system_prompt event for resumed sessions', () => {
498
+ // First send a new session message to create the stream
499
+ ws._emit('message', JSON.stringify({
500
+ type: 'send_message',
501
+ message: 'First',
502
+ projectId: 'proj_test',
503
+ claudeSessionId: 'session-1',
504
+ isNewSession: true,
505
+ }));
506
+
507
+ // Complete the stream
508
+ mockBridge._completeStream(0, 'session-1');
509
+
510
+ // Clear messages
511
+ ws._messages.length = 0;
512
+
513
+ // Send resumed message — mock bridge returns systemPrompt: null for isNewSession: false
514
+ ws._emit('message', JSON.stringify({
515
+ type: 'send_message',
516
+ message: 'Second',
517
+ projectId: 'proj_test',
518
+ claudeSessionId: 'session-1',
519
+ isNewSession: false,
520
+ }));
521
+
522
+ const messages = ws._getParsedMessages();
523
+ const systemPromptMsg = messages.find((m: any) => m.type === 'system_prompt');
524
+ assert.equal(systemPromptMsg, undefined, 'Should not send system_prompt for resumed sessions');
525
+ });
526
+
527
+ it('passes custom permission mode from message', () => {
528
+ const msg: SendMessagePayload = {
529
+ type: 'send_message',
530
+ message: 'Hello',
531
+ projectId: 'proj_test',
532
+ claudeSessionId: 'session-1',
533
+ isNewSession: true,
534
+ permissionMode: 'allowed_tools',
535
+ allowedTools: ['Read', 'Glob'],
536
+ };
537
+
538
+ ws._emit('message', JSON.stringify(msg));
539
+
540
+ const spawnArgs = mockBridge.spawn.mock.calls[0].arguments[0];
541
+ assert.equal(spawnArgs.permissionMode, 'allowed_tools');
542
+ assert.deepEqual(spawnArgs.allowedTools, ['Read', 'Glob']);
543
+ });
544
+ });
545
+
546
+ describe('connection cleanup', () => {
547
+ it('does NOT kill CLI process when WebSocket closes — lets agent continue', async () => {
548
+ const ws = createMockWs();
549
+ const req = createMockRequest('/api/chat', 'valid-token');
550
+
551
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws));
552
+ handler = new ChatWsHandler({
553
+ wss: mockWss as any,
554
+ authService: mockAuthService as any,
555
+ chatCliBridge: mockBridge as any,
556
+ log: logMock,
557
+ });
558
+
559
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
560
+ await new Promise(resolve => setTimeout(resolve, 10));
561
+
562
+ // Start a stream
563
+ const msg: SendMessagePayload = {
564
+ type: 'send_message',
565
+ message: 'Hello',
566
+ projectId: 'proj_test',
567
+ claudeSessionId: 'session-1',
568
+ isNewSession: true,
569
+ };
570
+ ws._emit('message', JSON.stringify(msg));
571
+
572
+ // Close the WebSocket
573
+ ws._emit('close');
574
+
575
+ // CLI should NOT be killed — the agent continues running
576
+ assert.equal(mockBridge.kill.mock.calls.length, 0);
577
+ });
578
+
579
+ it('does NOT kill CLI process on WebSocket error — lets agent continue', async () => {
580
+ const ws = createMockWs();
581
+ const req = createMockRequest('/api/chat', 'valid-token');
582
+
583
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws));
584
+ handler = new ChatWsHandler({
585
+ wss: mockWss as any,
586
+ authService: mockAuthService as any,
587
+ chatCliBridge: mockBridge as any,
588
+ log: logMock,
589
+ });
590
+
591
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
592
+ await new Promise(resolve => setTimeout(resolve, 10));
593
+
594
+ // Start a stream
595
+ const msg: SendMessagePayload = {
596
+ type: 'send_message',
597
+ message: 'Hello',
598
+ projectId: 'proj_test',
599
+ claudeSessionId: 'session-1',
600
+ isNewSession: true,
601
+ };
602
+ ws._emit('message', JSON.stringify(msg));
603
+
604
+ // Trigger error
605
+ ws._emit('error', new Error('Connection lost'));
606
+
607
+ // CLI should NOT be killed — the agent continues running
608
+ assert.equal(mockBridge.kill.mock.calls.length, 0);
609
+ });
610
+
611
+ it('does not call kill when no active stream on close', async () => {
612
+ const ws = createMockWs();
613
+ const req = createMockRequest('/api/chat', 'valid-token');
614
+
615
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws));
616
+ handler = new ChatWsHandler({
617
+ wss: mockWss as any,
618
+ authService: mockAuthService as any,
619
+ chatCliBridge: mockBridge as any,
620
+ log: logMock,
621
+ });
622
+
623
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
624
+ await new Promise(resolve => setTimeout(resolve, 10));
625
+
626
+ ws._emit('close');
627
+
628
+ assert.equal(mockBridge.kill.mock.calls.length, 0);
629
+ });
630
+
631
+ it('persists partial messages when WebSocket disconnects mid-stream', async () => {
632
+ const ws = createMockWs();
633
+ const req = createMockRequest('/api/chat', 'valid-token');
634
+
635
+ const mockRepo = createMockMessageRepository();
636
+
637
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws));
638
+ handler = new ChatWsHandler({
639
+ wss: mockWss as any,
640
+ authService: mockAuthService as any,
641
+ chatCliBridge: mockBridge as any,
642
+ chatMessageRepository: mockRepo as any,
643
+ log: logMock,
644
+ });
645
+
646
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
647
+ await new Promise(resolve => setTimeout(resolve, 10));
648
+
649
+ // Start a stream with a sessionId
650
+ ws._emit('message', JSON.stringify({
651
+ type: 'send_message',
652
+ message: 'Build something',
653
+ projectId: 'proj_test',
654
+ sessionId: 'csess_abc',
655
+ claudeSessionId: 'session-1',
656
+ isNewSession: true,
657
+ }));
658
+
659
+ // Emit some assistant content
660
+ mockBridge._emitEvent({
661
+ type: 'assistant',
662
+ message: { content: [{ type: 'text', text: 'Working on it...' }] },
663
+ });
664
+
665
+ // Close the WebSocket (simulates page refresh)
666
+ ws._emit('close');
667
+
668
+ await new Promise(resolve => setTimeout(resolve, 20));
669
+
670
+ // Should have persisted partial messages
671
+ assert.equal(mockRepo.getNextOrderIndex.mock.calls.length, 1);
672
+ assert.equal(mockRepo.saveMessage.mock.calls.length, 2);
673
+
674
+ // First call: user message
675
+ assert.equal(mockRepo.saveMessage.mock.calls[0].arguments[0], 'csess_abc');
676
+ assert.equal(mockRepo.saveMessage.mock.calls[0].arguments[1], 'user');
677
+
678
+ // Second call: assistant partial response
679
+ assert.equal(mockRepo.saveMessage.mock.calls[1].arguments[0], 'csess_abc');
680
+ assert.equal(mockRepo.saveMessage.mock.calls[1].arguments[1], 'assistant');
681
+ });
682
+
683
+ it('updates assistant message with final content when CLI finishes after disconnect', async () => {
684
+ const ws = createMockWs();
685
+ const req = createMockRequest('/api/chat', 'valid-token');
686
+
687
+ const mockRepo = createMockMessageRepository();
688
+
689
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws));
690
+ handler = new ChatWsHandler({
691
+ wss: mockWss as any,
692
+ authService: mockAuthService as any,
693
+ chatCliBridge: mockBridge as any,
694
+ chatMessageRepository: mockRepo as any,
695
+ log: logMock,
696
+ });
697
+
698
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
699
+ await new Promise(resolve => setTimeout(resolve, 10));
700
+
701
+ ws._emit('message', JSON.stringify({
702
+ type: 'send_message',
703
+ message: 'Build something',
704
+ projectId: 'proj_test',
705
+ sessionId: 'csess_abc',
706
+ claudeSessionId: 'session-1',
707
+ isNewSession: true,
708
+ }));
709
+
710
+ // Emit partial content
711
+ mockBridge._emitEvent({
712
+ type: 'assistant',
713
+ message: { content: [{ type: 'text', text: 'Partial...' }] },
714
+ });
715
+
716
+ // WS disconnects
717
+ ws._emit('close');
718
+ await new Promise(resolve => setTimeout(resolve, 20));
719
+
720
+ // More content arrives after disconnect
721
+ mockBridge._emitEvent({
722
+ type: 'assistant',
723
+ message: { content: [{ type: 'text', text: 'Full response complete!' }] },
724
+ });
725
+
726
+ // CLI finishes
727
+ mockBridge._completeStream(0, 'session-1');
728
+ await new Promise(resolve => setTimeout(resolve, 20));
729
+
730
+ // Should have called updateMessageContent with the final content
731
+ assert.equal(mockRepo.updateMessageContent.mock.calls.length, 1);
732
+ // The assistant message is the 2nd saved (after user message), so it gets cmsg_mock_2
733
+ assert.equal(mockRepo.updateMessageContent.mock.calls[0].arguments[0], 'cmsg_mock_2');
734
+ });
735
+ });
736
+
737
+ describe('attach_session handling', () => {
738
+ it('re-attaches to in-flight stream and sends catch-up content', async () => {
739
+ const ws1 = createMockWs();
740
+ const req1 = createMockRequest('/api/chat', 'valid-token');
741
+
742
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws1));
743
+ handler = new ChatWsHandler({
744
+ wss: mockWss as any,
745
+ authService: mockAuthService as any,
746
+ chatCliBridge: mockBridge as any,
747
+ log: logMock,
748
+ });
749
+
750
+ handler.handleUpgrade(req1 as any, {} as any, Buffer.from(''));
751
+ await new Promise(resolve => setTimeout(resolve, 10));
752
+
753
+ // Start a stream
754
+ ws1._emit('message', JSON.stringify({
755
+ type: 'send_message',
756
+ message: 'Hello',
757
+ projectId: 'proj_test',
758
+ claudeSessionId: 'session-1',
759
+ isNewSession: true,
760
+ }));
761
+
762
+ // Emit some content
763
+ mockBridge._emitEvent({
764
+ type: 'assistant',
765
+ message: { content: [{ type: 'text', text: 'Working on it...' }] },
766
+ });
767
+
768
+ // WS disconnects
769
+ ws1._emit('close');
770
+
771
+ // New WS connects and attaches
772
+ const ws2 = createMockWs();
773
+ const req2 = createMockRequest('/api/chat', 'valid-token');
774
+
775
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws2));
776
+ handler.handleUpgrade(req2 as any, {} as any, Buffer.from(''));
777
+ await new Promise(resolve => setTimeout(resolve, 10));
778
+
779
+ ws2._emit('message', JSON.stringify({
780
+ type: 'attach_session',
781
+ claudeSessionId: 'session-1',
782
+ }));
783
+
784
+ const messages = ws2._getParsedMessages();
785
+ // Should have received: stream_resumed + stream_event (catch-up)
786
+ const resumed = messages.find((m: any) => m.type === 'stream_resumed');
787
+ assert.ok(resumed, 'Expected stream_resumed message');
788
+ assert.equal((resumed as any).claudeSessionId, 'session-1');
789
+
790
+ const catchUp = messages.find((m: any) => m.type === 'stream_event');
791
+ assert.ok(catchUp, 'Expected catch-up stream_event');
792
+ assert.equal((catchUp as any).event.type, 'assistant');
793
+ });
794
+
795
+ it('forwards new events to re-attached WS', async () => {
796
+ const ws1 = createMockWs();
797
+ const req1 = createMockRequest('/api/chat', 'valid-token');
798
+
799
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws1));
800
+ handler = new ChatWsHandler({
801
+ wss: mockWss as any,
802
+ authService: mockAuthService as any,
803
+ chatCliBridge: mockBridge as any,
804
+ log: logMock,
805
+ });
806
+
807
+ handler.handleUpgrade(req1 as any, {} as any, Buffer.from(''));
808
+ await new Promise(resolve => setTimeout(resolve, 10));
809
+
810
+ // Start a stream
811
+ ws1._emit('message', JSON.stringify({
812
+ type: 'send_message',
813
+ message: 'Hello',
814
+ projectId: 'proj_test',
815
+ claudeSessionId: 'session-1',
816
+ isNewSession: true,
817
+ }));
818
+
819
+ // WS disconnects
820
+ ws1._emit('close');
821
+
822
+ // New WS connects and attaches
823
+ const ws2 = createMockWs();
824
+ const req2 = createMockRequest('/api/chat', 'valid-token');
825
+
826
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws2));
827
+ handler.handleUpgrade(req2 as any, {} as any, Buffer.from(''));
828
+ await new Promise(resolve => setTimeout(resolve, 10));
829
+
830
+ ws2._emit('message', JSON.stringify({
831
+ type: 'attach_session',
832
+ claudeSessionId: 'session-1',
833
+ }));
834
+
835
+ // Clear messages from re-attachment
836
+ ws2._messages.length = 0;
837
+
838
+ // New event arrives from CLI
839
+ mockBridge._emitEvent({
840
+ type: 'assistant',
841
+ message: { content: [{ type: 'text', text: 'New content after re-attach' }] },
842
+ });
843
+
844
+ const messages = ws2._getParsedMessages();
845
+ assert.equal(messages.length, 1);
846
+ assert.equal(messages[0].type, 'stream_event');
847
+ assert.equal((messages[0] as any).event.message.content[0].text, 'New content after re-attach');
848
+ });
849
+
850
+ it('does nothing when attaching to a session with no active stream', async () => {
851
+ const ws = createMockWs();
852
+ const req = createMockRequest('/api/chat', 'valid-token');
853
+
854
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws));
855
+ handler = new ChatWsHandler({
856
+ wss: mockWss as any,
857
+ authService: mockAuthService as any,
858
+ chatCliBridge: mockBridge as any,
859
+ log: logMock,
860
+ });
861
+
862
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
863
+ await new Promise(resolve => setTimeout(resolve, 10));
864
+
865
+ ws._emit('message', JSON.stringify({
866
+ type: 'attach_session',
867
+ claudeSessionId: 'nonexistent-session',
868
+ }));
869
+
870
+ // Should not send stream_resumed
871
+ const messages = ws._getParsedMessages();
872
+ const resumed = messages.find((m: any) => m.type === 'stream_resumed');
873
+ assert.equal(resumed, undefined);
874
+ });
875
+
876
+ it('sends stream_end to re-attached WS when CLI completes', async () => {
877
+ const ws1 = createMockWs();
878
+ const req1 = createMockRequest('/api/chat', 'valid-token');
879
+
880
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws1));
881
+ handler = new ChatWsHandler({
882
+ wss: mockWss as any,
883
+ authService: mockAuthService as any,
884
+ chatCliBridge: mockBridge as any,
885
+ log: logMock,
886
+ });
887
+
888
+ handler.handleUpgrade(req1 as any, {} as any, Buffer.from(''));
889
+ await new Promise(resolve => setTimeout(resolve, 10));
890
+
891
+ // Start a stream
892
+ ws1._emit('message', JSON.stringify({
893
+ type: 'send_message',
894
+ message: 'Hello',
895
+ projectId: 'proj_test',
896
+ claudeSessionId: 'session-1',
897
+ isNewSession: true,
898
+ }));
899
+
900
+ // WS disconnects
901
+ ws1._emit('close');
902
+
903
+ // New WS connects and attaches
904
+ const ws2 = createMockWs();
905
+ const req2 = createMockRequest('/api/chat', 'valid-token');
906
+
907
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws2));
908
+ handler.handleUpgrade(req2 as any, {} as any, Buffer.from(''));
909
+ await new Promise(resolve => setTimeout(resolve, 10));
910
+
911
+ ws2._emit('message', JSON.stringify({
912
+ type: 'attach_session',
913
+ claudeSessionId: 'session-1',
914
+ }));
915
+
916
+ // CLI finishes
917
+ mockBridge._completeStream(0, 'session-1');
918
+ await new Promise(resolve => setTimeout(resolve, 10));
919
+
920
+ // Should have sent stream_end to ws2
921
+ const messages = ws2._getParsedMessages();
922
+ const endMsg = messages.find((m: any) => m.type === 'stream_end');
923
+ assert.ok(endMsg, 'Expected stream_end on re-attached WS');
924
+ assert.equal((endMsg as any).exitCode, 0);
925
+ });
926
+ });
927
+
928
+ describe('multiple stream events', () => {
929
+ it('forwards text, tool_use, and result events in order', async () => {
930
+ const ws = createMockWs();
931
+ const req = createMockRequest('/api/chat', 'valid-token');
932
+
933
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws));
934
+ handler = new ChatWsHandler({
935
+ wss: mockWss as any,
936
+ authService: mockAuthService as any,
937
+ chatCliBridge: mockBridge as any,
938
+ log: logMock,
939
+ });
940
+
941
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
942
+ await new Promise(resolve => setTimeout(resolve, 10));
943
+
944
+ ws._emit('message', JSON.stringify({
945
+ type: 'send_message',
946
+ message: 'Build something',
947
+ projectId: 'proj_test',
948
+ claudeSessionId: 'session-1',
949
+ isNewSession: true,
950
+ }));
951
+
952
+ // Simulate streaming events from CLI
953
+ mockBridge._emitEvent({
954
+ type: 'assistant',
955
+ message: { content: [{ type: 'text', text: 'I will ' }] },
956
+ });
957
+
958
+ mockBridge._emitEvent({
959
+ type: 'assistant',
960
+ message: { content: [{ type: 'text', text: 'read the file.' }] },
961
+ });
962
+
963
+ mockBridge._emitEvent({
964
+ type: 'assistant',
965
+ message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/test.ts' } }] },
966
+ });
967
+
968
+ mockBridge._emitEvent({
969
+ type: 'result',
970
+ session_id: 'session-1',
971
+ cost_usd: 0.05,
972
+ duration_ms: 3000,
973
+ });
974
+
975
+ const messages = ws._getParsedMessages();
976
+ // stream_start + system_prompt + 4 stream_events
977
+ assert.equal(messages.length, 6);
978
+ assert.equal(messages[0].type, 'stream_start');
979
+ assert.equal(messages[1].type, 'system_prompt');
980
+ assert.equal(messages[2].type, 'stream_event');
981
+ assert.equal(messages[3].type, 'stream_event');
982
+ assert.equal(messages[4].type, 'stream_event');
983
+ assert.equal(messages[5].type, 'stream_event');
984
+
985
+ // Verify event contents
986
+ assert.equal((messages[2] as any).event.type, 'assistant');
987
+ assert.equal((messages[4] as any).event.type, 'assistant');
988
+ assert.equal((messages[4] as any).event.message.content[0].type, 'tool_use');
989
+ assert.equal((messages[5] as any).event.type, 'result');
990
+ });
991
+ });
992
+
993
+ describe('permission handling', () => {
994
+ it('forwards permission_response to permissionService', async () => {
995
+ const ws = createMockWs();
996
+ const req = createMockRequest('/api/chat', 'valid-token');
997
+ const mockPermissionService = {
998
+ setPermissionRequestHandler: mock.fn(),
999
+ resolvePermission: mock.fn(async () => true),
1000
+ cancelPendingRequests: mock.fn(),
1001
+ };
1002
+
1003
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws));
1004
+ handler = new ChatWsHandler({
1005
+ wss: mockWss as any,
1006
+ authService: mockAuthService as any,
1007
+ chatCliBridge: mockBridge as any,
1008
+ permissionService: mockPermissionService as any,
1009
+ log: logMock,
1010
+ });
1011
+
1012
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
1013
+ await new Promise(resolve => setTimeout(resolve, 10));
1014
+
1015
+ // Send a permission response
1016
+ ws._emit('message', JSON.stringify({
1017
+ type: 'permission_response',
1018
+ requestId: 'perm_123',
1019
+ projectId: 'proj_test',
1020
+ decision: 'allow_once',
1021
+ }));
1022
+
1023
+ await new Promise(resolve => setTimeout(resolve, 10));
1024
+
1025
+ assert.equal(mockPermissionService.resolvePermission.mock.calls.length, 1);
1026
+ const resolveArgs = mockPermissionService.resolvePermission.mock.calls[0].arguments;
1027
+ assert.equal(resolveArgs[0], 'perm_123');
1028
+ assert.equal(resolveArgs[1], 'proj_test');
1029
+ assert.equal(resolveArgs[2], 'allow_once');
1030
+ });
1031
+
1032
+ it('cancels pending permission requests on WebSocket close', async () => {
1033
+ const ws = createMockWs();
1034
+ const req = createMockRequest('/api/chat', 'valid-token');
1035
+ const mockPermissionService = {
1036
+ setPermissionRequestHandler: mock.fn(),
1037
+ resolvePermission: mock.fn(async () => true),
1038
+ cancelPendingRequests: mock.fn(),
1039
+ };
1040
+
1041
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws));
1042
+ handler = new ChatWsHandler({
1043
+ wss: mockWss as any,
1044
+ authService: mockAuthService as any,
1045
+ chatCliBridge: mockBridge as any,
1046
+ permissionService: mockPermissionService as any,
1047
+ log: logMock,
1048
+ });
1049
+
1050
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
1051
+ await new Promise(resolve => setTimeout(resolve, 10));
1052
+
1053
+ // Start a stream so there's an active session
1054
+ ws._emit('message', JSON.stringify({
1055
+ type: 'send_message',
1056
+ message: 'Hello',
1057
+ projectId: 'proj_test',
1058
+ claudeSessionId: 'session-1',
1059
+ isNewSession: true,
1060
+ }));
1061
+
1062
+ // Close the WebSocket
1063
+ ws._emit('close');
1064
+
1065
+ assert.equal(mockPermissionService.cancelPendingRequests.mock.calls.length, 1);
1066
+ assert.equal(mockPermissionService.cancelPendingRequests.mock.calls[0].arguments[0], 'session-1');
1067
+ });
1068
+
1069
+ it('works without permissionService (backward compatible)', async () => {
1070
+ const ws = createMockWs();
1071
+ const req = createMockRequest('/api/chat', 'valid-token');
1072
+
1073
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws));
1074
+ handler = new ChatWsHandler({
1075
+ wss: mockWss as any,
1076
+ authService: mockAuthService as any,
1077
+ chatCliBridge: mockBridge as any,
1078
+ log: logMock,
1079
+ });
1080
+
1081
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
1082
+ await new Promise(resolve => setTimeout(resolve, 10));
1083
+
1084
+ // Sending permission_response without service should not throw
1085
+ ws._emit('message', JSON.stringify({
1086
+ type: 'permission_response',
1087
+ requestId: 'perm_123',
1088
+ projectId: 'proj_test',
1089
+ decision: 'allow_once',
1090
+ }));
1091
+
1092
+ // Should not crash — just log a warning
1093
+ assert.ok(true);
1094
+ });
1095
+ });
1096
+
1097
+ describe('stop_stream handling', () => {
1098
+ let ws: ReturnType<typeof createMockWs>;
1099
+
1100
+ beforeEach(async () => {
1101
+ ws = createMockWs();
1102
+ const req = createMockRequest('/api/chat', 'valid-token');
1103
+
1104
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws));
1105
+ handler = new ChatWsHandler({
1106
+ wss: mockWss as any,
1107
+ authService: mockAuthService as any,
1108
+ chatCliBridge: mockBridge as any,
1109
+ log: logMock,
1110
+ });
1111
+
1112
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
1113
+ await new Promise(resolve => setTimeout(resolve, 10));
1114
+ });
1115
+
1116
+ it('kills CLI process and sends stream_cancelled on stop_stream', async () => {
1117
+ // Start a stream
1118
+ ws._emit('message', JSON.stringify({
1119
+ type: 'send_message',
1120
+ message: 'Hello',
1121
+ projectId: 'proj_test',
1122
+ claudeSessionId: 'session-1',
1123
+ isNewSession: true,
1124
+ }));
1125
+
1126
+ // Send stop_stream
1127
+ ws._emit('message', JSON.stringify({ type: 'stop_stream' }));
1128
+
1129
+ // Should have called kill
1130
+ assert.equal(mockBridge.kill.mock.calls.length, 1);
1131
+ assert.equal(mockBridge.kill.mock.calls[0].arguments[0], 'session-1');
1132
+
1133
+ // Simulate CLI process exiting after kill
1134
+ mockBridge._completeStream(null, 'session-1');
1135
+ await new Promise(resolve => setTimeout(resolve, 10));
1136
+
1137
+ // Should have sent stream_cancelled (not stream_end)
1138
+ const messages = ws._getParsedMessages();
1139
+ const cancelled = messages.find((m: any) => m.type === 'stream_cancelled');
1140
+ assert.ok(cancelled, 'Expected a stream_cancelled message');
1141
+ assert.equal((cancelled as any).claudeSessionId, 'session-1');
1142
+
1143
+ // Should NOT have sent stream_end
1144
+ const ended = messages.find((m: any) => m.type === 'stream_end');
1145
+ assert.equal(ended, undefined, 'Should not send stream_end for cancelled stream');
1146
+ });
1147
+
1148
+ it('sends error when no active stream to stop', () => {
1149
+ ws._emit('message', JSON.stringify({ type: 'stop_stream' }));
1150
+
1151
+ const messages = ws._getParsedMessages();
1152
+ const errMsg = messages.find((m: any) => m.type === 'error');
1153
+ assert.ok(errMsg);
1154
+ assert.ok((errMsg as any).message.includes('No active stream'));
1155
+ });
1156
+
1157
+ it('allows new message after stop_stream completes', async () => {
1158
+ // Start a stream
1159
+ ws._emit('message', JSON.stringify({
1160
+ type: 'send_message',
1161
+ message: 'Hello',
1162
+ projectId: 'proj_test',
1163
+ claudeSessionId: 'session-1',
1164
+ isNewSession: true,
1165
+ }));
1166
+
1167
+ // Stop the stream
1168
+ ws._emit('message', JSON.stringify({ type: 'stop_stream' }));
1169
+ mockBridge._completeStream(null, 'session-1');
1170
+ await new Promise(resolve => setTimeout(resolve, 10));
1171
+
1172
+ // Send another message — should work
1173
+ ws._emit('message', JSON.stringify({
1174
+ type: 'send_message',
1175
+ message: 'Try again',
1176
+ projectId: 'proj_test',
1177
+ claudeSessionId: 'session-1',
1178
+ isNewSession: false,
1179
+ }));
1180
+
1181
+ assert.equal(mockBridge.spawn.mock.calls.length, 2);
1182
+ });
1183
+
1184
+ it('cancels pending permission requests on stop_stream', async () => {
1185
+ const mockPermissionService = {
1186
+ setPermissionRequestHandler: mock.fn(),
1187
+ resolvePermission: mock.fn(async () => true),
1188
+ cancelPendingRequests: mock.fn(),
1189
+ };
1190
+
1191
+ const ws2 = createMockWs();
1192
+ const req = createMockRequest('/api/chat', 'valid-token');
1193
+
1194
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws2));
1195
+ handler = new ChatWsHandler({
1196
+ wss: mockWss as any,
1197
+ authService: mockAuthService as any,
1198
+ chatCliBridge: mockBridge as any,
1199
+ permissionService: mockPermissionService as any,
1200
+ log: logMock,
1201
+ });
1202
+
1203
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
1204
+ await new Promise(resolve => setTimeout(resolve, 10));
1205
+
1206
+ // Start a stream
1207
+ ws2._emit('message', JSON.stringify({
1208
+ type: 'send_message',
1209
+ message: 'Hello',
1210
+ projectId: 'proj_test',
1211
+ claudeSessionId: 'session-2',
1212
+ isNewSession: true,
1213
+ }));
1214
+
1215
+ // Stop the stream
1216
+ ws2._emit('message', JSON.stringify({ type: 'stop_stream' }));
1217
+
1218
+ assert.equal(mockPermissionService.cancelPendingRequests.mock.calls.length, 1);
1219
+ assert.equal(mockPermissionService.cancelPendingRequests.mock.calls[0].arguments[0], 'session-2');
1220
+ });
1221
+ });
1222
+
1223
+ describe('does not send to closed WebSocket', () => {
1224
+ it('skips sending when readyState is not OPEN', async () => {
1225
+ const ws = createMockWs(3); // CLOSED
1226
+ const req = createMockRequest('/api/chat', 'valid-token');
1227
+
1228
+ mockWss.handleUpgrade = mock.fn((_r: any, _s: any, _h: any, cb: Function) => cb(ws));
1229
+ handler = new ChatWsHandler({
1230
+ wss: mockWss as any,
1231
+ authService: mockAuthService as any,
1232
+ chatCliBridge: mockBridge as any,
1233
+ log: logMock,
1234
+ });
1235
+
1236
+ handler.handleUpgrade(req as any, {} as any, Buffer.from(''));
1237
+ await new Promise(resolve => setTimeout(resolve, 10));
1238
+
1239
+ // No messages should be sent since readyState is not OPEN
1240
+ assert.equal(ws._messages.length, 0);
1241
+ });
1242
+ });
1243
+ });