@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.
- package/dist/src/scaffolder.d.ts +12 -1
- package/dist/src/scaffolder.js +40 -3
- package/dist/src/scaffolder.js.map +1 -1
- package/package.json +1 -1
- package/templates/assistkick-product-system/package.json +1 -1
- package/templates/assistkick-product-system/packages/backend/package.json +1 -0
- package/templates/assistkick-product-system/packages/backend/src/mcp/permission_mcp_server.ts +196 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/agents.ts +31 -7
- package/templates/assistkick-product-system/packages/backend/src/routes/auth.ts +15 -12
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_files.test.ts +95 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_files.ts +97 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_permission.ts +94 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_sessions.ts +189 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_upload.test.ts +131 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_upload.ts +94 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/files.test.ts +12 -3
- package/templates/assistkick-product-system/packages/backend/src/routes/files.ts +2 -2
- package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +391 -23
- package/templates/assistkick-product-system/packages/backend/src/routes/git_branches.test.ts +306 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/git_connect.test.ts +133 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +66 -9
- package/templates/assistkick-product-system/packages/backend/src/routes/preview.ts +204 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/projects.test.ts +205 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/projects.ts +37 -9
- package/templates/assistkick-product-system/packages/backend/src/routes/skills.test.ts +139 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/skills.ts +95 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +5 -4
- package/templates/assistkick-product-system/packages/backend/src/routes/users.ts +4 -4
- package/templates/assistkick-product-system/packages/backend/src/routes/video.ts +8 -8
- package/templates/assistkick-product-system/packages/backend/src/routes/workflow_groups.ts +5 -5
- package/templates/assistkick-product-system/packages/backend/src/routes/workflows.ts +6 -6
- package/templates/assistkick-product-system/packages/backend/src/server.ts +107 -27
- package/templates/assistkick-product-system/packages/backend/src/services/agent_service.test.ts +105 -203
- package/templates/assistkick-product-system/packages/backend/src/services/agent_service.ts +76 -266
- package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.test.ts +427 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts +345 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_message_repository.test.ts +170 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_message_repository.ts +106 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_session_service.test.ts +217 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_session_service.ts +188 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.test.ts +1243 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts +894 -0
- package/templates/assistkick-product-system/packages/backend/src/services/coherence-review.ts +3 -3
- package/templates/assistkick-product-system/packages/backend/src/services/dev_command_detector.test.ts +85 -0
- package/templates/assistkick-product-system/packages/backend/src/services/dev_command_detector.ts +54 -0
- package/templates/assistkick-product-system/packages/backend/src/services/email_service.ts +13 -10
- package/templates/assistkick-product-system/packages/backend/src/services/init.ts +11 -3
- package/templates/assistkick-product-system/packages/backend/src/services/invitation_service.ts +1 -1
- package/templates/assistkick-product-system/packages/backend/src/services/password_reset_service.ts +1 -1
- package/templates/assistkick-product-system/packages/backend/src/services/permission_service.test.ts +243 -0
- package/templates/assistkick-product-system/packages/backend/src/services/permission_service.ts +259 -0
- package/templates/assistkick-product-system/packages/backend/src/services/preview_server_manager.test.ts +172 -0
- package/templates/assistkick-product-system/packages/backend/src/services/preview_server_manager.ts +225 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_service.test.ts +29 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +17 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +255 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +300 -25
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +44 -0
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +62 -7
- package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.test.ts +77 -6
- package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.ts +149 -14
- package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +2 -1
- package/templates/assistkick-product-system/packages/backend/src/services/title_generator_service.test.ts +45 -0
- package/templates/assistkick-product-system/packages/backend/src/services/title_generator_service.ts +157 -0
- package/templates/assistkick-product-system/packages/backend/src/services/tts_service.ts +4 -3
- package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.ts +3 -3
- package/templates/assistkick-product-system/packages/frontend/package.json +5 -0
- package/templates/assistkick-product-system/packages/frontend/src/App.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +336 -5
- package/templates/assistkick-product-system/packages/frontend/src/components/AgentsView.tsx +192 -12
- package/templates/assistkick-product-system/packages/frontend/src/components/AttachmentPreviewList.tsx +98 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/AutocompleteDropdown.tsx +65 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatAttachButton.tsx +56 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatDropZone.tsx +80 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageBubble.tsx +155 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageContent.tsx +182 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageInput.tsx +233 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatSessionSidebar.tsx +218 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatStopButton.tsx +32 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatTodoSidebar.tsx +113 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatView.tsx +842 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/CommitMessageModal.tsx +82 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/DiagramOverlay.tsx +160 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/EditorTabBar.tsx +5 -5
- package/templates/assistkick-product-system/packages/frontend/src/components/FileTree.tsx +9 -10
- package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeInlineInput.tsx +5 -5
- package/templates/assistkick-product-system/packages/frontend/src/components/FilesView.tsx +112 -41
- package/templates/assistkick-product-system/packages/frontend/src/components/GraphLegend.tsx +2 -2
- package/templates/assistkick-product-system/packages/frontend/src/components/HighlightedText.tsx +87 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ImageLightbox.tsx +192 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +2 -2
- package/templates/assistkick-product-system/packages/frontend/src/components/MentionPill.tsx +33 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/MermaidBlock.tsx +148 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/PermissionDialog.tsx +91 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/PermissionModeSelector.tsx +229 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +249 -83
- package/templates/assistkick-product-system/packages/frontend/src/components/QueuedMessageBubble.tsx +38 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/SidePanel.tsx +212 -117
- package/templates/assistkick-product-system/packages/frontend/src/components/SystemPromptAccordion.tsx +48 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/TaskIcon.tsx +11 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +25 -9
- package/templates/assistkick-product-system/packages/frontend/src/components/ToolDiffView.tsx +114 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ToolResultCard.tsx +87 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ToolUseCard.tsx +149 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +25 -8
- package/templates/assistkick-product-system/packages/frontend/src/components/UnifiedGitWidget.tsx +722 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GroupNode.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/NodePalette.tsx +2 -1
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/ProgrammableNode.tsx +178 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowCanvas.tsx +3 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowMonitorModal.tsx +103 -9
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/monitor_nodes.tsx +26 -2
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.ts +42 -1
- package/templates/assistkick-product-system/packages/frontend/src/hooks/useDocumentTitle.ts +11 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/useProjects.ts +1 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/use_chat_stream.ts +826 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/use_file_tree_cache.ts +69 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/use_mention_autocomplete.ts +284 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/attachment_manager.test.ts +183 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/attachment_manager.ts +150 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.test.ts +305 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.ts +113 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/context_usage_helpers.test.ts +157 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/context_usage_helpers.ts +95 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/mermaid_helpers.test.ts +65 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/mermaid_helpers.ts +110 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/message_queue.ts +66 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/tool_use_summary.test.ts +124 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/tool_use_summary.ts +112 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/AgentsRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/ChatRoute.tsx +8 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/CoherenceRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/DashboardLayout.tsx +0 -4
- package/templates/assistkick-product-system/packages/frontend/src/routes/DesignSystemRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/FilesRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/GraphRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/KanbanRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/TerminalRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/UsersRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/VideographyRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/WorkflowsRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/accept_invitation.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/forgot_password.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/login.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/register.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/reset_password.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useAttachmentStore.ts +66 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useChatSessionStore.ts +107 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useMessageQueueStore.ts +110 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/usePreviewStore.ts +78 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useProjectStore.ts +7 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useSidePanelStore.ts +6 -1
- package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +30 -357
- package/templates/assistkick-product-system/packages/frontend/src/utils/parse_node_markdown.test.ts +115 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/parse_node_markdown.ts +91 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/preview_utils.test.ts +30 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/preview_utils.ts +3 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0015_magenta_jazinda.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0016_giant_xorn.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0017_sloppy_mentor.sql +6 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0018_vengeful_kabuki.sql +9 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0019_careful_sentinels.sql +8 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0020_clever_spot.sql +27 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0021_graceful_hex.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0022_short_kingpin.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0023_ambiguous_sharon_carter.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0024_fat_unus.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0015_snapshot.json +1552 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0016_snapshot.json +1560 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0017_snapshot.json +1598 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0018_snapshot.json +1657 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0019_snapshot.json +1709 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0020_snapshot.json +1733 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0021_snapshot.json +1740 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0022_snapshot.json +1755 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0023_snapshot.json +1762 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0024_snapshot.json +1769 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +70 -0
- package/templates/assistkick-product-system/packages/shared/db/schema.ts +40 -1
- package/templates/assistkick-product-system/packages/shared/lib/claude-service.test.ts +236 -0
- package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +46 -5
- package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +65 -39
- package/templates/assistkick-product-system/packages/shared/lib/programmable_node_executor.test.ts +173 -0
- package/templates/assistkick-product-system/packages/shared/lib/programmable_node_executor.ts +213 -0
- package/templates/assistkick-product-system/packages/shared/lib/validator.test.ts +70 -0
- package/templates/assistkick-product-system/packages/shared/lib/validator.ts +17 -1
- package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.test.ts +803 -27
- package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.ts +502 -68
- package/templates/assistkick-product-system/packages/shared/lib/workflow_orchestrator.ts +4 -4
- package/templates/assistkick-product-system/packages/shared/package.json +2 -1
- package/templates/assistkick-product-system/packages/shared/test_fixtures/hanging_stream.mjs +46 -0
- package/templates/assistkick-product-system/packages/shared/tools/add_node.test.ts +44 -0
- package/templates/assistkick-product-system/packages/shared/tools/add_node.ts +7 -0
- package/templates/assistkick-product-system/packages/shared/tools/remove_node.ts +2 -1
- package/templates/assistkick-product-system/packages/shared/tools/resolve_question.ts +2 -1
- package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -1
- package/templates/assistkick-product-system/tests/message_queue.test.ts +178 -0
- package/templates/assistkick-product-system/tests/message_queue_per_session.test.ts +143 -0
- package/templates/skills/assistkick-bootstrap/SKILL.md +26 -26
- package/templates/skills/assistkick-code-reviewer/SKILL.md +45 -46
- package/templates/skills/assistkick-db-explorer/SKILL.md +13 -13
- package/templates/skills/assistkick-debugger/SKILL.md +23 -23
- package/templates/skills/assistkick-developer/SKILL.md +59 -63
- package/templates/skills/assistkick-interview/SKILL.md +26 -26
- package/templates/skills/assistkick-video-composition-agent/SKILL.md +231 -0
- package/templates/skills/assistkick-video-script-writer/SKILL.md +136 -0
package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.test.ts
ADDED
|
@@ -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
|
+
});
|