@hienlh/ppm 0.1.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/.claude/agent-memory/tester/MEMORY.md +3 -0
- package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/.env.example +1 -0
- package/.github/workflows/release.yml +46 -0
- package/README.md +349 -0
- package/bun.lock +1217 -0
- package/components.json +21 -0
- package/docs/code-standards.md +574 -0
- package/docs/codebase-summary.md +294 -0
- package/docs/deployment-guide.md +631 -0
- package/docs/design-guidelines.md +661 -0
- package/docs/project-overview-pdr.md +142 -0
- package/docs/project-roadmap.md +400 -0
- package/docs/system-architecture.md +459 -0
- package/package.json +68 -0
- package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
- package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
- package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
- package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
- package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
- package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
- package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
- package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
- package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
- package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
- package/plans/260314-2009-ppm-implementation/plan.md +202 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
- package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
- package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
- package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
- package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
- package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
- package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
- package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
- package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
- package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
- package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
- package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
- package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
- package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
- package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
- package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
- package/ppm.example.yaml +14 -0
- package/repomix-output.xml +23745 -0
- package/scripts/build.ts +13 -0
- package/src/cli/commands/chat-cmd.ts +259 -0
- package/src/cli/commands/config-cmd.ts +121 -0
- package/src/cli/commands/git-cmd.ts +315 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/open.ts +19 -0
- package/src/cli/commands/projects.ts +100 -0
- package/src/cli/commands/start.ts +3 -0
- package/src/cli/commands/stop.ts +33 -0
- package/src/cli/utils/project-resolver.ts +27 -0
- package/src/index.ts +59 -0
- package/src/providers/claude-agent-sdk.ts +499 -0
- package/src/providers/claude-binary-finder.ts +256 -0
- package/src/providers/claude-code-cli.ts +413 -0
- package/src/providers/claude-process-registry.ts +106 -0
- package/src/providers/mock-provider.ts +171 -0
- package/src/providers/provider.interface.ts +10 -0
- package/src/providers/registry.ts +45 -0
- package/src/server/helpers/resolve-project.ts +22 -0
- package/src/server/index.ts +181 -0
- package/src/server/middleware/auth.ts +30 -0
- package/src/server/routes/chat.ts +153 -0
- package/src/server/routes/files.ts +168 -0
- package/src/server/routes/git.ts +261 -0
- package/src/server/routes/project-scoped.ts +27 -0
- package/src/server/routes/projects.ts +57 -0
- package/src/server/routes/static.ts +26 -0
- package/src/server/ws/chat.ts +130 -0
- package/src/server/ws/terminal.ts +89 -0
- package/src/services/chat.service.ts +110 -0
- package/src/services/claude-usage.service.ts +113 -0
- package/src/services/config.service.ts +90 -0
- package/src/services/file.service.ts +261 -0
- package/src/services/git-dirs.service.ts +112 -0
- package/src/services/git.service.ts +372 -0
- package/src/services/project.service.ts +107 -0
- package/src/services/slash-items.service.ts +184 -0
- package/src/services/terminal.service.ts +212 -0
- package/src/types/api.ts +37 -0
- package/src/types/chat.ts +92 -0
- package/src/types/config.ts +41 -0
- package/src/types/git.ts +50 -0
- package/src/types/project.ts +18 -0
- package/src/types/terminal.ts +20 -0
- package/src/web/app.tsx +168 -0
- package/src/web/components/auth/login-screen.tsx +88 -0
- package/src/web/components/chat/attachment-chips.tsx +55 -0
- package/src/web/components/chat/chat-placeholder.tsx +10 -0
- package/src/web/components/chat/chat-tab.tsx +301 -0
- package/src/web/components/chat/file-picker.tsx +126 -0
- package/src/web/components/chat/message-input.tsx +420 -0
- package/src/web/components/chat/message-list.tsx +838 -0
- package/src/web/components/chat/session-picker.tsx +139 -0
- package/src/web/components/chat/slash-command-picker.tsx +135 -0
- package/src/web/components/chat/usage-badge.tsx +186 -0
- package/src/web/components/editor/code-editor.tsx +329 -0
- package/src/web/components/editor/diff-viewer.tsx +276 -0
- package/src/web/components/editor/editor-placeholder.tsx +10 -0
- package/src/web/components/explorer/file-actions.tsx +191 -0
- package/src/web/components/explorer/file-tree.tsx +298 -0
- package/src/web/components/git/git-graph.tsx +727 -0
- package/src/web/components/git/git-placeholder.tsx +55 -0
- package/src/web/components/git/git-status-panel.tsx +850 -0
- package/src/web/components/layout/mobile-drawer.tsx +137 -0
- package/src/web/components/layout/mobile-nav.tsx +103 -0
- package/src/web/components/layout/sidebar.tsx +90 -0
- package/src/web/components/layout/tab-bar.tsx +152 -0
- package/src/web/components/layout/tab-content.tsx +85 -0
- package/src/web/components/projects/dir-suggest.tsx +152 -0
- package/src/web/components/projects/project-list.tsx +187 -0
- package/src/web/components/settings/settings-tab.tsx +57 -0
- package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
- package/src/web/components/terminal/terminal-tab.tsx +133 -0
- package/src/web/components/ui/button.tsx +64 -0
- package/src/web/components/ui/context-menu.tsx +250 -0
- package/src/web/components/ui/dialog.tsx +156 -0
- package/src/web/components/ui/dropdown-menu.tsx +257 -0
- package/src/web/components/ui/input.tsx +21 -0
- package/src/web/components/ui/scroll-area.tsx +56 -0
- package/src/web/components/ui/separator.tsx +26 -0
- package/src/web/components/ui/sonner.tsx +40 -0
- package/src/web/components/ui/tabs.tsx +91 -0
- package/src/web/components/ui/tooltip.tsx +57 -0
- package/src/web/hooks/use-chat.ts +420 -0
- package/src/web/hooks/use-terminal.ts +182 -0
- package/src/web/hooks/use-url-sync.ts +66 -0
- package/src/web/hooks/use-websocket.ts +48 -0
- package/src/web/index.html +16 -0
- package/src/web/lib/api-client.ts +90 -0
- package/src/web/lib/file-support.ts +68 -0
- package/src/web/lib/utils.ts +6 -0
- package/src/web/lib/ws-client.ts +100 -0
- package/src/web/main.tsx +10 -0
- package/src/web/public/icon-192.svg +5 -0
- package/src/web/public/icon-512.svg +5 -0
- package/src/web/stores/file-store.ts +81 -0
- package/src/web/stores/project-store.ts +50 -0
- package/src/web/stores/settings-store.ts +65 -0
- package/src/web/stores/tab-store.ts +187 -0
- package/src/web/styles/globals.css +227 -0
- package/src/web/vite-env.d.ts +1 -0
- package/tests/integration/api/chat-routes.test.ts +95 -0
- package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
- package/tests/integration/ws/chat-websocket.test.ts +312 -0
- package/tests/test-setup.ts +5 -0
- package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
- package/tests/unit/providers/mock-provider.test.ts +143 -0
- package/tests/unit/services/chat-service.test.ts +100 -0
- package/tsconfig.json +32 -0
- package/vite.config.ts +62 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { query, listSessions, getSessionMessages } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
+
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
4
|
+
import { ClaudeAgentSdkProvider } from "../../src/providers/claude-agent-sdk.ts";
|
|
5
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
|
|
9
|
+
// Remove CLAUDECODE env to avoid nested session error
|
|
10
|
+
delete process.env.CLAUDECODE;
|
|
11
|
+
|
|
12
|
+
// Use a temp directory so SDK sessions don't pollute the real project
|
|
13
|
+
let tempDir: string;
|
|
14
|
+
let originalCwd: string;
|
|
15
|
+
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
originalCwd = process.cwd();
|
|
18
|
+
tempDir = mkdtempSync(join(tmpdir(), "ppm-sdk-test-"));
|
|
19
|
+
process.chdir(tempDir);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterAll(() => {
|
|
23
|
+
process.chdir(originalCwd);
|
|
24
|
+
try {
|
|
25
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
26
|
+
} catch {
|
|
27
|
+
// best effort cleanup
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** Collect all messages from a query */
|
|
32
|
+
async function collectMessages(q: AsyncIterable<SDKMessage>): Promise<SDKMessage[]> {
|
|
33
|
+
const msgs: SDKMessage[] = [];
|
|
34
|
+
for await (const msg of q) {
|
|
35
|
+
msgs.push(msg);
|
|
36
|
+
}
|
|
37
|
+
return msgs;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("Claude Agent SDK — raw SDK", () => {
|
|
41
|
+
it("query() returns init + assistant + result messages", async () => {
|
|
42
|
+
const sessionId = crypto.randomUUID();
|
|
43
|
+
const msgs = await collectMessages(
|
|
44
|
+
query({
|
|
45
|
+
prompt: "Reply with exactly: PONG",
|
|
46
|
+
options: {
|
|
47
|
+
sessionId,
|
|
48
|
+
maxTurns: 1,
|
|
49
|
+
permissionMode: "bypassPermissions",
|
|
50
|
+
allowDangerouslySkipPermissions: true,
|
|
51
|
+
} as any,
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const types = msgs.map((m) => m.type);
|
|
56
|
+
expect(types).toContain("system"); // init
|
|
57
|
+
expect(types).toContain("assistant");
|
|
58
|
+
expect(types).toContain("result");
|
|
59
|
+
|
|
60
|
+
// Verify init has session_id
|
|
61
|
+
const init = msgs.find((m) => m.type === "system" && (m as any).subtype === "init");
|
|
62
|
+
expect(init).toBeTruthy();
|
|
63
|
+
expect(init!.session_id).toBeTruthy();
|
|
64
|
+
|
|
65
|
+
// Verify assistant message has text content
|
|
66
|
+
const assistant = msgs.find((m) => m.type === "assistant");
|
|
67
|
+
const content = (assistant as any)?.message?.content;
|
|
68
|
+
expect(Array.isArray(content)).toBe(true);
|
|
69
|
+
const textBlock = content.find((b: any) => b.type === "text");
|
|
70
|
+
expect(textBlock).toBeTruthy();
|
|
71
|
+
expect(textBlock.text.toUpperCase()).toContain("PONG");
|
|
72
|
+
|
|
73
|
+
// Verify result
|
|
74
|
+
const result = msgs.find((m) => m.type === "result") as any;
|
|
75
|
+
expect(result.subtype).toBe("success");
|
|
76
|
+
expect(typeof result.result).toBe("string");
|
|
77
|
+
expect(result.result.toUpperCase()).toContain("PONG");
|
|
78
|
+
}, 30000);
|
|
79
|
+
|
|
80
|
+
it("resume continues conversation context", async () => {
|
|
81
|
+
const sessionId = crypto.randomUUID();
|
|
82
|
+
|
|
83
|
+
// Turn 1: tell it a secret
|
|
84
|
+
const turn1 = await collectMessages(
|
|
85
|
+
query({
|
|
86
|
+
prompt: "Remember this secret code: ALPHA-7742. Reply with just 'Noted.'",
|
|
87
|
+
options: {
|
|
88
|
+
sessionId,
|
|
89
|
+
maxTurns: 1,
|
|
90
|
+
permissionMode: "bypassPermissions",
|
|
91
|
+
allowDangerouslySkipPermissions: true,
|
|
92
|
+
} as any,
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
const result1 = turn1.find((m) => m.type === "result") as any;
|
|
96
|
+
expect(result1).toBeTruthy();
|
|
97
|
+
|
|
98
|
+
// Turn 2: ask it back — uses resume
|
|
99
|
+
const turn2 = await collectMessages(
|
|
100
|
+
query({
|
|
101
|
+
prompt: "What was the secret code I told you? Reply with just the code.",
|
|
102
|
+
options: {
|
|
103
|
+
resume: sessionId,
|
|
104
|
+
maxTurns: 1,
|
|
105
|
+
permissionMode: "bypassPermissions",
|
|
106
|
+
allowDangerouslySkipPermissions: true,
|
|
107
|
+
} as any,
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
const result2 = turn2.find((m) => m.type === "result") as any;
|
|
111
|
+
expect(result2).toBeTruthy();
|
|
112
|
+
expect(result2.result).toContain("ALPHA-7742");
|
|
113
|
+
}, 60000);
|
|
114
|
+
|
|
115
|
+
it("listSessions() returns session metadata", async () => {
|
|
116
|
+
const sessions = await listSessions({ limit: 5 });
|
|
117
|
+
|
|
118
|
+
expect(Array.isArray(sessions)).toBe(true);
|
|
119
|
+
if (sessions.length > 0) {
|
|
120
|
+
const s = sessions[0]!;
|
|
121
|
+
expect(s.sessionId).toBeTruthy();
|
|
122
|
+
expect(typeof s.summary).toBe("string");
|
|
123
|
+
expect(typeof s.lastModified).toBe("number");
|
|
124
|
+
}
|
|
125
|
+
}, 15000);
|
|
126
|
+
|
|
127
|
+
it("getSessionMessages() returns transcript", async () => {
|
|
128
|
+
// Create a session first
|
|
129
|
+
const sessionId = crypto.randomUUID();
|
|
130
|
+
await collectMessages(
|
|
131
|
+
query({
|
|
132
|
+
prompt: "Say hello",
|
|
133
|
+
options: {
|
|
134
|
+
sessionId,
|
|
135
|
+
maxTurns: 1,
|
|
136
|
+
permissionMode: "bypassPermissions",
|
|
137
|
+
allowDangerouslySkipPermissions: true,
|
|
138
|
+
} as any,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const messages = await getSessionMessages(sessionId);
|
|
143
|
+
expect(Array.isArray(messages)).toBe(true);
|
|
144
|
+
expect(messages.length).toBeGreaterThanOrEqual(1);
|
|
145
|
+
|
|
146
|
+
// Should have at least assistant message
|
|
147
|
+
const hasAssistant = messages.some((m) => m.type === "assistant");
|
|
148
|
+
expect(hasAssistant).toBe(true);
|
|
149
|
+
}, 30000);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("ClaudeAgentSdkProvider — PPM integration", () => {
|
|
153
|
+
const provider = new ClaudeAgentSdkProvider();
|
|
154
|
+
|
|
155
|
+
it("createSession returns valid session with UUID", async () => {
|
|
156
|
+
const session = await provider.createSession({
|
|
157
|
+
projectName: "test-project",
|
|
158
|
+
title: "Integration Test",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(session.id).toBeTruthy();
|
|
162
|
+
expect(session.id).not.toContain("pending");
|
|
163
|
+
expect(session.providerId).toBe("claude-sdk");
|
|
164
|
+
expect(session.title).toBe("Integration Test");
|
|
165
|
+
expect(session.projectName).toBe("test-project");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("sendMessage streams text events and done", async () => {
|
|
169
|
+
const session = await provider.createSession({ title: "Stream Test" });
|
|
170
|
+
const events: any[] = [];
|
|
171
|
+
|
|
172
|
+
for await (const event of provider.sendMessage(session.id, "Reply with exactly: OK")) {
|
|
173
|
+
events.push(event);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const textEvents = events.filter((e) => e.type === "text");
|
|
177
|
+
const doneEvent = events.find((e) => e.type === "done");
|
|
178
|
+
|
|
179
|
+
expect(textEvents.length).toBeGreaterThan(0);
|
|
180
|
+
const fullText = textEvents.map((e) => e.content).join("");
|
|
181
|
+
expect(fullText.toUpperCase()).toContain("OK");
|
|
182
|
+
|
|
183
|
+
expect(doneEvent).toBeTruthy();
|
|
184
|
+
expect(doneEvent.sessionId).toBe(session.id);
|
|
185
|
+
}, 30000);
|
|
186
|
+
|
|
187
|
+
it("multi-turn: resume maintains context", async () => {
|
|
188
|
+
const session = await provider.createSession({ title: "Multi-turn" });
|
|
189
|
+
|
|
190
|
+
// Turn 1
|
|
191
|
+
for await (const _ of provider.sendMessage(session.id, "Remember: my favorite color is purple. Reply 'Got it.'")) {
|
|
192
|
+
// consume
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Turn 2 — should resume same session
|
|
196
|
+
const events: any[] = [];
|
|
197
|
+
for await (const event of provider.sendMessage(session.id, "What is my favorite color? Reply with just the color.")) {
|
|
198
|
+
events.push(event);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const fullText = events
|
|
202
|
+
.filter((e) => e.type === "text")
|
|
203
|
+
.map((e) => e.content)
|
|
204
|
+
.join("");
|
|
205
|
+
expect(fullText.toLowerCase()).toContain("purple");
|
|
206
|
+
}, 60000);
|
|
207
|
+
|
|
208
|
+
it("returns error for non-existent session", async () => {
|
|
209
|
+
const events: any[] = [];
|
|
210
|
+
for await (const event of provider.sendMessage("nonexistent-id", "hello")) {
|
|
211
|
+
events.push(event);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
expect(events[0].type).toBe("error");
|
|
215
|
+
expect(events[0].message).toContain("Session not found");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("deleteSession removes session", async () => {
|
|
219
|
+
const session = await provider.createSession({ title: "Delete me" });
|
|
220
|
+
await provider.deleteSession(session.id);
|
|
221
|
+
|
|
222
|
+
const events: any[] = [];
|
|
223
|
+
for await (const event of provider.sendMessage(session.id, "hello")) {
|
|
224
|
+
events.push(event);
|
|
225
|
+
}
|
|
226
|
+
expect(events[0].type).toBe("error");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import "../../test-setup.ts"; // disable auth
|
|
3
|
+
import { chatService } from "../../../src/services/chat.service.ts";
|
|
4
|
+
|
|
5
|
+
const PORT = 19876;
|
|
6
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
7
|
+
|
|
8
|
+
beforeAll(async () => {
|
|
9
|
+
const { app } = await import("../../../src/server/index.ts");
|
|
10
|
+
const { chatWebSocket } = await import("../../../src/server/ws/chat.ts");
|
|
11
|
+
|
|
12
|
+
server = Bun.serve({
|
|
13
|
+
port: PORT,
|
|
14
|
+
fetch(req, srv) {
|
|
15
|
+
const url = new URL(req.url);
|
|
16
|
+
|
|
17
|
+
// WebSocket upgrade for chat
|
|
18
|
+
if (url.pathname.startsWith("/ws/chat/")) {
|
|
19
|
+
const sessionId = url.pathname.split("/ws/chat/")[1] ?? "";
|
|
20
|
+
const upgraded = srv.upgrade(req, {
|
|
21
|
+
data: { type: "chat", sessionId },
|
|
22
|
+
});
|
|
23
|
+
if (upgraded) return undefined;
|
|
24
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return app.fetch(req, srv as any);
|
|
28
|
+
},
|
|
29
|
+
websocket: {
|
|
30
|
+
open: chatWebSocket.open as any,
|
|
31
|
+
message: chatWebSocket.message as any,
|
|
32
|
+
close: chatWebSocket.close as any,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterAll(() => {
|
|
38
|
+
server?.stop(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function connectWs(sessionId: string): Promise<{
|
|
42
|
+
ws: WebSocket;
|
|
43
|
+
messages: any[];
|
|
44
|
+
waitForType: (type: string, timeout?: number) => Promise<any>;
|
|
45
|
+
close: () => void;
|
|
46
|
+
}> {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const ws = new WebSocket(`ws://localhost:${PORT}/ws/chat/${sessionId}`, {
|
|
49
|
+
} as any);
|
|
50
|
+
const messages: any[] = [];
|
|
51
|
+
|
|
52
|
+
ws.onmessage = (event) => {
|
|
53
|
+
try {
|
|
54
|
+
messages.push(JSON.parse(event.data as string));
|
|
55
|
+
} catch {
|
|
56
|
+
// ignore
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
ws.onopen = () => {
|
|
61
|
+
const waitForType = (type: string, timeout = 10000): Promise<any> => {
|
|
62
|
+
return new Promise((res, rej) => {
|
|
63
|
+
const existing = messages.find((m) => m.type === type);
|
|
64
|
+
if (existing) return res(existing);
|
|
65
|
+
|
|
66
|
+
const timer = setTimeout(() => rej(new Error(`Timeout waiting for ${type}`)), timeout);
|
|
67
|
+
const handler = (event: MessageEvent) => {
|
|
68
|
+
try {
|
|
69
|
+
const msg = JSON.parse(event.data as string);
|
|
70
|
+
if (msg.type === type) {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
ws.removeEventListener("message", handler);
|
|
73
|
+
res(msg);
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// ignore
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
ws.addEventListener("message", handler);
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
resolve({ ws, messages, waitForType, close: () => ws.close() });
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
ws.onerror = () => reject(new Error("WS connection failed"));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("Chat WebSocket", () => {
|
|
91
|
+
it("sends connected event on open", async () => {
|
|
92
|
+
const session = await chatService.createSession("mock", {});
|
|
93
|
+
const { waitForType, close } = await connectWs(session.id);
|
|
94
|
+
|
|
95
|
+
const connected = await waitForType("connected");
|
|
96
|
+
expect(connected.sessionId).toBe(session.id);
|
|
97
|
+
|
|
98
|
+
close();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("streams text events for a message", async () => {
|
|
102
|
+
const session = await chatService.createSession("mock", {});
|
|
103
|
+
const { ws, messages, waitForType, close } = await connectWs(session.id);
|
|
104
|
+
|
|
105
|
+
await waitForType("connected");
|
|
106
|
+
ws.send(JSON.stringify({ type: "message", content: "hello" }));
|
|
107
|
+
|
|
108
|
+
const done = await waitForType("done");
|
|
109
|
+
expect(done.sessionId).toBe(session.id);
|
|
110
|
+
|
|
111
|
+
const textEvents = messages.filter((m) => m.type === "text");
|
|
112
|
+
expect(textEvents.length).toBeGreaterThan(0);
|
|
113
|
+
|
|
114
|
+
const fullText = textEvents.map((e) => e.content).join("");
|
|
115
|
+
expect(fullText.length).toBeGreaterThan(0);
|
|
116
|
+
|
|
117
|
+
close();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("streams tool_use events for file-related messages", async () => {
|
|
121
|
+
const session = await chatService.createSession("mock", {});
|
|
122
|
+
const { ws, messages, waitForType, close } = await connectWs(session.id);
|
|
123
|
+
|
|
124
|
+
await waitForType("connected");
|
|
125
|
+
ws.send(JSON.stringify({ type: "message", content: "read the file" }));
|
|
126
|
+
|
|
127
|
+
await waitForType("done");
|
|
128
|
+
|
|
129
|
+
const toolUse = messages.find((m) => m.type === "tool_use");
|
|
130
|
+
const toolResult = messages.find((m) => m.type === "tool_result");
|
|
131
|
+
expect(toolUse).toBeTruthy();
|
|
132
|
+
expect(toolUse.tool).toBe("Read");
|
|
133
|
+
expect(toolResult).toBeTruthy();
|
|
134
|
+
|
|
135
|
+
close();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("streams approval_request for delete messages", async () => {
|
|
139
|
+
const session = await chatService.createSession("mock", {});
|
|
140
|
+
const { ws, messages, waitForType, close } = await connectWs(session.id);
|
|
141
|
+
|
|
142
|
+
await waitForType("connected");
|
|
143
|
+
ws.send(JSON.stringify({ type: "message", content: "delete temp" }));
|
|
144
|
+
|
|
145
|
+
await waitForType("done");
|
|
146
|
+
|
|
147
|
+
const approval = messages.find((m) => m.type === "approval_request");
|
|
148
|
+
expect(approval).toBeTruthy();
|
|
149
|
+
expect(approval.tool).toBe("Bash");
|
|
150
|
+
|
|
151
|
+
close();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("handles invalid JSON gracefully", async () => {
|
|
155
|
+
const session = await chatService.createSession("mock", {});
|
|
156
|
+
const { ws, waitForType, close } = await connectWs(session.id);
|
|
157
|
+
|
|
158
|
+
await waitForType("connected");
|
|
159
|
+
ws.send("not json at all");
|
|
160
|
+
|
|
161
|
+
const errMsg = await waitForType("error");
|
|
162
|
+
expect(errMsg.message).toContain("Invalid JSON");
|
|
163
|
+
|
|
164
|
+
close();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("supports multi-turn conversation in same session", async () => {
|
|
168
|
+
const session = await chatService.createSession("mock", {});
|
|
169
|
+
const { ws, messages, waitForType, close } = await connectWs(session.id);
|
|
170
|
+
|
|
171
|
+
await waitForType("connected");
|
|
172
|
+
|
|
173
|
+
// Turn 1
|
|
174
|
+
ws.send(JSON.stringify({ type: "message", content: "hello" }));
|
|
175
|
+
await waitForType("done");
|
|
176
|
+
const turn1Texts = messages.filter((m) => m.type === "text").length;
|
|
177
|
+
|
|
178
|
+
// Turn 2
|
|
179
|
+
const doneCountBefore = messages.filter((m) => m.type === "done").length;
|
|
180
|
+
ws.send(JSON.stringify({ type: "message", content: "follow up" }));
|
|
181
|
+
|
|
182
|
+
// Wait until we see a new "done" event
|
|
183
|
+
await new Promise<void>((resolve, reject) => {
|
|
184
|
+
const timer = setTimeout(() => reject(new Error("Timeout waiting for turn 2 done")), 10000);
|
|
185
|
+
const handler = (event: MessageEvent) => {
|
|
186
|
+
try {
|
|
187
|
+
const msg = JSON.parse(event.data as string);
|
|
188
|
+
if (msg.type === "done" && messages.filter((m: any) => m.type === "done").length > doneCountBefore) {
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
ws.removeEventListener("message", handler);
|
|
191
|
+
resolve();
|
|
192
|
+
}
|
|
193
|
+
} catch { /* ignore */ }
|
|
194
|
+
};
|
|
195
|
+
ws.addEventListener("message", handler);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const turn2Texts = messages.filter((m) => m.type === "text").length;
|
|
199
|
+
expect(turn2Texts).toBeGreaterThan(turn1Texts);
|
|
200
|
+
|
|
201
|
+
// Verify history has both turns
|
|
202
|
+
const history = await chatService.getMessages("mock", session.id);
|
|
203
|
+
const userMsgs = history.filter((m: any) => m.role === "user");
|
|
204
|
+
expect(userMsgs).toHaveLength(2);
|
|
205
|
+
|
|
206
|
+
close();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("cancels streaming mid-response", async () => {
|
|
210
|
+
const session = await chatService.createSession("mock", {});
|
|
211
|
+
const { ws, messages, waitForType, close } = await connectWs(session.id);
|
|
212
|
+
|
|
213
|
+
await waitForType("connected");
|
|
214
|
+
|
|
215
|
+
// Send a message that will trigger slow streaming
|
|
216
|
+
ws.send(JSON.stringify({ type: "message", content: "hello world" }));
|
|
217
|
+
|
|
218
|
+
// Wait for at least one text event to arrive (streaming started)
|
|
219
|
+
await waitForType("text");
|
|
220
|
+
|
|
221
|
+
// Count text events so far
|
|
222
|
+
const textsBefore = messages.filter((m) => m.type === "text").length;
|
|
223
|
+
expect(textsBefore).toBeGreaterThan(0);
|
|
224
|
+
|
|
225
|
+
// Send cancel
|
|
226
|
+
ws.send(JSON.stringify({ type: "cancel" }));
|
|
227
|
+
|
|
228
|
+
// Wait a bit for cancel to take effect
|
|
229
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
230
|
+
|
|
231
|
+
// The stream should have stopped — no "done" event with full text
|
|
232
|
+
// Text events should be fewer than a full response (~10 words = ~10 text events)
|
|
233
|
+
const textsAfter = messages.filter((m) => m.type === "text").length;
|
|
234
|
+
// Cancel should have stopped streaming before all words were sent
|
|
235
|
+
// (Mock sends ~7 words at 50ms each = 350ms, we cancel after first text ~350ms in)
|
|
236
|
+
// Just verify we got some but the stream was interrupted (no new done event)
|
|
237
|
+
expect(textsAfter).toBeGreaterThanOrEqual(textsBefore);
|
|
238
|
+
|
|
239
|
+
close();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("cancel does not affect subsequent messages", async () => {
|
|
243
|
+
const session = await chatService.createSession("mock", {});
|
|
244
|
+
const { ws, messages, waitForType, close } = await connectWs(session.id);
|
|
245
|
+
|
|
246
|
+
await waitForType("connected");
|
|
247
|
+
|
|
248
|
+
// Send message and cancel quickly
|
|
249
|
+
ws.send(JSON.stringify({ type: "message", content: "hello" }));
|
|
250
|
+
await waitForType("text");
|
|
251
|
+
ws.send(JSON.stringify({ type: "cancel" }));
|
|
252
|
+
|
|
253
|
+
// Wait for cancel to settle
|
|
254
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
255
|
+
|
|
256
|
+
// Clear messages array for clean tracking of turn 2
|
|
257
|
+
const msgCountBefore = messages.length;
|
|
258
|
+
|
|
259
|
+
// Send another message — should work normally
|
|
260
|
+
ws.send(JSON.stringify({ type: "message", content: "second message" }));
|
|
261
|
+
|
|
262
|
+
// Wait for done from the second message
|
|
263
|
+
await new Promise<void>((resolve, reject) => {
|
|
264
|
+
const timer = setTimeout(() => reject(new Error("Timeout waiting for second done")), 10000);
|
|
265
|
+
const donesBefore = messages.filter((m) => m.type === "done").length;
|
|
266
|
+
const handler = (event: MessageEvent) => {
|
|
267
|
+
try {
|
|
268
|
+
const msg = JSON.parse(event.data as string);
|
|
269
|
+
if (msg.type === "done" && messages.filter((m: any) => m.type === "done").length > donesBefore) {
|
|
270
|
+
clearTimeout(timer);
|
|
271
|
+
ws.removeEventListener("message", handler);
|
|
272
|
+
resolve();
|
|
273
|
+
}
|
|
274
|
+
} catch { /* ignore */ }
|
|
275
|
+
};
|
|
276
|
+
ws.addEventListener("message", handler);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Should have new text events from second message
|
|
280
|
+
const newMessages = messages.slice(msgCountBefore);
|
|
281
|
+
const newTexts = newMessages.filter((m) => m.type === "text");
|
|
282
|
+
expect(newTexts.length).toBeGreaterThan(0);
|
|
283
|
+
|
|
284
|
+
// Should have a done event from second message
|
|
285
|
+
const newDone = newMessages.find((m) => m.type === "done");
|
|
286
|
+
expect(newDone).toBeTruthy();
|
|
287
|
+
|
|
288
|
+
close();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("cancel with no active stream is a no-op", async () => {
|
|
292
|
+
const session = await chatService.createSession("mock", {});
|
|
293
|
+
const { ws, messages, waitForType, close } = await connectWs(session.id);
|
|
294
|
+
|
|
295
|
+
await waitForType("connected");
|
|
296
|
+
|
|
297
|
+
// Send cancel before any message — should not crash
|
|
298
|
+
ws.send(JSON.stringify({ type: "cancel" }));
|
|
299
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
300
|
+
|
|
301
|
+
// No error events should be emitted
|
|
302
|
+
const errors = messages.filter((m) => m.type === "error");
|
|
303
|
+
expect(errors).toHaveLength(0);
|
|
304
|
+
|
|
305
|
+
// Can still send a normal message after
|
|
306
|
+
ws.send(JSON.stringify({ type: "message", content: "hello after cancel" }));
|
|
307
|
+
const done = await waitForType("done", 10000);
|
|
308
|
+
expect(done.sessionId).toBe(session.id);
|
|
309
|
+
|
|
310
|
+
close();
|
|
311
|
+
});
|
|
312
|
+
});
|