@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,339 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test";
|
|
2
|
+
import type { ChatEvent } from "../../../src/types/chat.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Helper: create an async iterable from an array of items with optional delay.
|
|
6
|
+
* Supports being "closed" mid-iteration (simulates SDK query.close()).
|
|
7
|
+
*/
|
|
8
|
+
function createMockQueryIterator(
|
|
9
|
+
items: Array<{ type: string; message?: unknown }>,
|
|
10
|
+
delayMs = 10,
|
|
11
|
+
) {
|
|
12
|
+
let closed = false;
|
|
13
|
+
let closeResolve: (() => void) | undefined;
|
|
14
|
+
const closePromise = new Promise<void>((r) => (closeResolve = r));
|
|
15
|
+
|
|
16
|
+
const iterator: AsyncIterableIterator<any> & { close: () => void } = {
|
|
17
|
+
close() {
|
|
18
|
+
closed = true;
|
|
19
|
+
closeResolve?.();
|
|
20
|
+
},
|
|
21
|
+
[Symbol.asyncIterator]() {
|
|
22
|
+
return this;
|
|
23
|
+
},
|
|
24
|
+
async next() {
|
|
25
|
+
if (closed) return { done: true, value: undefined };
|
|
26
|
+
|
|
27
|
+
if (items.length === 0) return { done: true, value: undefined };
|
|
28
|
+
|
|
29
|
+
// Small delay to simulate streaming
|
|
30
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
31
|
+
|
|
32
|
+
if (closed) return { done: true, value: undefined };
|
|
33
|
+
|
|
34
|
+
const item = items.shift()!;
|
|
35
|
+
return { done: false, value: item };
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return iterator;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Mock the SDK module
|
|
43
|
+
let mockQueryFn: ReturnType<typeof mock>;
|
|
44
|
+
|
|
45
|
+
mock.module("@anthropic-ai/claude-agent-sdk", () => {
|
|
46
|
+
mockQueryFn = mock((...args: any[]) => {
|
|
47
|
+
// Default: return empty iterator. Tests override via mockQueryFn.mockImplementation()
|
|
48
|
+
return createMockQueryIterator([]);
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
query: (...args: any[]) => mockQueryFn(...args),
|
|
52
|
+
listSessions: mock(() => Promise.resolve([])),
|
|
53
|
+
getSessionMessages: mock(() => Promise.resolve([])),
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Import AFTER mocking
|
|
58
|
+
const { ClaudeAgentSdkProvider } = await import(
|
|
59
|
+
"../../../src/providers/claude-agent-sdk.ts"
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
describe("ClaudeAgentSdkProvider", () => {
|
|
63
|
+
let provider: InstanceType<typeof ClaudeAgentSdkProvider>;
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
provider = new ClaudeAgentSdkProvider();
|
|
67
|
+
mockQueryFn.mockReset();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("sendMessage", () => {
|
|
71
|
+
it("yields text events from partial messages", async () => {
|
|
72
|
+
const iter = createMockQueryIterator([
|
|
73
|
+
{
|
|
74
|
+
type: "partial",
|
|
75
|
+
message: { content: [{ type: "text", text: "Hello" }] },
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
type: "partial",
|
|
79
|
+
message: { content: [{ type: "text", text: "Hello world" }] },
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
type: "assistant",
|
|
83
|
+
message: { content: [{ type: "text", text: "Hello world" }] },
|
|
84
|
+
},
|
|
85
|
+
{ type: "result" },
|
|
86
|
+
]);
|
|
87
|
+
mockQueryFn.mockReturnValue(iter);
|
|
88
|
+
|
|
89
|
+
const session = await provider.createSession({});
|
|
90
|
+
const events: ChatEvent[] = [];
|
|
91
|
+
for await (const event of provider.sendMessage(session.id, "hi")) {
|
|
92
|
+
events.push(event);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const textEvents = events.filter((e) => e.type === "text");
|
|
96
|
+
expect(textEvents.length).toBeGreaterThan(0);
|
|
97
|
+
|
|
98
|
+
const fullText = textEvents.map((e) => (e as any).content).join("");
|
|
99
|
+
expect(fullText).toContain("Hello");
|
|
100
|
+
|
|
101
|
+
const done = events.find((e) => e.type === "done");
|
|
102
|
+
expect(done).toBeTruthy();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("yields tool_use events from assistant messages", async () => {
|
|
106
|
+
const iter = createMockQueryIterator([
|
|
107
|
+
{
|
|
108
|
+
type: "assistant",
|
|
109
|
+
message: {
|
|
110
|
+
content: [
|
|
111
|
+
{ type: "tool_use", name: "Read", input: { path: "test.ts" } },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{ type: "result" },
|
|
116
|
+
]);
|
|
117
|
+
mockQueryFn.mockReturnValue(iter);
|
|
118
|
+
|
|
119
|
+
const session = await provider.createSession({});
|
|
120
|
+
const events: ChatEvent[] = [];
|
|
121
|
+
for await (const event of provider.sendMessage(session.id, "read file")) {
|
|
122
|
+
events.push(event);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const toolUse = events.find((e) => e.type === "tool_use");
|
|
126
|
+
expect(toolUse).toBeTruthy();
|
|
127
|
+
expect((toolUse as any).tool).toBe("Read");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("always yields done event even on empty response", async () => {
|
|
131
|
+
const iter = createMockQueryIterator([{ type: "result" }]);
|
|
132
|
+
mockQueryFn.mockReturnValue(iter);
|
|
133
|
+
|
|
134
|
+
const session = await provider.createSession({});
|
|
135
|
+
const events: ChatEvent[] = [];
|
|
136
|
+
for await (const event of provider.sendMessage(session.id, "hi")) {
|
|
137
|
+
events.push(event);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
expect(events).toHaveLength(1);
|
|
141
|
+
expect(events[0]!.type).toBe("done");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("yields done event after SDK error (non-abort)", async () => {
|
|
145
|
+
mockQueryFn.mockImplementation(() => {
|
|
146
|
+
const iter = createMockQueryIterator([], 0);
|
|
147
|
+
// Override next to throw
|
|
148
|
+
iter.next = async () => {
|
|
149
|
+
throw new Error("SDK connection failed");
|
|
150
|
+
};
|
|
151
|
+
return iter;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const session = await provider.createSession({});
|
|
155
|
+
const events: ChatEvent[] = [];
|
|
156
|
+
for await (const event of provider.sendMessage(session.id, "hi")) {
|
|
157
|
+
events.push(event);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const error = events.find((e) => e.type === "error");
|
|
161
|
+
expect(error).toBeTruthy();
|
|
162
|
+
expect((error as any).message).toContain("SDK connection failed");
|
|
163
|
+
|
|
164
|
+
const done = events.find((e) => e.type === "done");
|
|
165
|
+
expect(done).toBeTruthy();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("uses sessionId for first message and resume for subsequent", async () => {
|
|
169
|
+
// First call
|
|
170
|
+
mockQueryFn.mockReturnValue(
|
|
171
|
+
createMockQueryIterator([{ type: "result" }]),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const session = await provider.createSession({});
|
|
175
|
+
const events1: ChatEvent[] = [];
|
|
176
|
+
for await (const event of provider.sendMessage(session.id, "first")) {
|
|
177
|
+
events1.push(event);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
expect(mockQueryFn).toHaveBeenCalledTimes(1);
|
|
181
|
+
const firstCall = mockQueryFn.mock.calls[0]![0];
|
|
182
|
+
expect(firstCall.options.sessionId).toBe(session.id);
|
|
183
|
+
expect(firstCall.options.resume).toBeUndefined();
|
|
184
|
+
|
|
185
|
+
// Second call
|
|
186
|
+
mockQueryFn.mockReturnValue(
|
|
187
|
+
createMockQueryIterator([{ type: "result" }]),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const events2: ChatEvent[] = [];
|
|
191
|
+
for await (const event of provider.sendMessage(session.id, "second")) {
|
|
192
|
+
events2.push(event);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const secondCall = mockQueryFn.mock.calls[1]![0];
|
|
196
|
+
expect(secondCall.options.sessionId).toBeUndefined();
|
|
197
|
+
expect(secondCall.options.resume).toBe(session.id);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("abortQuery (cancel)", () => {
|
|
202
|
+
it("calls close() on active SDK query", async () => {
|
|
203
|
+
const iter = createMockQueryIterator(
|
|
204
|
+
[
|
|
205
|
+
{
|
|
206
|
+
type: "partial",
|
|
207
|
+
message: { content: [{ type: "text", text: "Working..." }] },
|
|
208
|
+
},
|
|
209
|
+
// Many more items that won't be reached after close
|
|
210
|
+
{
|
|
211
|
+
type: "partial",
|
|
212
|
+
message: {
|
|
213
|
+
content: [{ type: "text", text: "Working... still going" }],
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
type: "partial",
|
|
218
|
+
message: {
|
|
219
|
+
content: [
|
|
220
|
+
{ type: "text", text: "Working... still going... more" },
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
{ type: "result" },
|
|
225
|
+
],
|
|
226
|
+
100, // slow enough to cancel mid-stream
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const closeSpy = spyOn(iter, "close");
|
|
230
|
+
mockQueryFn.mockReturnValue(iter);
|
|
231
|
+
|
|
232
|
+
const session = await provider.createSession({});
|
|
233
|
+
|
|
234
|
+
// Start streaming in background
|
|
235
|
+
const events: ChatEvent[] = [];
|
|
236
|
+
const streamPromise = (async () => {
|
|
237
|
+
for await (const event of provider.sendMessage(session.id, "hello")) {
|
|
238
|
+
events.push(event);
|
|
239
|
+
}
|
|
240
|
+
})();
|
|
241
|
+
|
|
242
|
+
// Wait for first event
|
|
243
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
244
|
+
|
|
245
|
+
// Cancel
|
|
246
|
+
provider.abortQuery(session.id);
|
|
247
|
+
|
|
248
|
+
expect(closeSpy).toHaveBeenCalledTimes(1);
|
|
249
|
+
|
|
250
|
+
// Wait for stream to finish
|
|
251
|
+
await streamPromise;
|
|
252
|
+
|
|
253
|
+
// Should have done event (always emitted)
|
|
254
|
+
const done = events.find((e) => e.type === "done");
|
|
255
|
+
expect(done).toBeTruthy();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("does not yield error event on abort", async () => {
|
|
259
|
+
// Simulate SDK throwing abort error when query is closed
|
|
260
|
+
let rejectNext: ((err: Error) => void) | undefined;
|
|
261
|
+
|
|
262
|
+
mockQueryFn.mockImplementation(() => {
|
|
263
|
+
let callCount = 0;
|
|
264
|
+
const q = {
|
|
265
|
+
close() {
|
|
266
|
+
rejectNext?.(new Error("aborted"));
|
|
267
|
+
},
|
|
268
|
+
[Symbol.asyncIterator]() {
|
|
269
|
+
return this;
|
|
270
|
+
},
|
|
271
|
+
async next(): Promise<{ done: boolean; value: any }> {
|
|
272
|
+
callCount++;
|
|
273
|
+
if (callCount === 1) {
|
|
274
|
+
return {
|
|
275
|
+
done: false,
|
|
276
|
+
value: {
|
|
277
|
+
type: "partial",
|
|
278
|
+
message: { content: [{ type: "text", text: "Hi" }] },
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
// Second call: wait to be aborted
|
|
283
|
+
return new Promise((resolve, reject) => {
|
|
284
|
+
rejectNext = reject;
|
|
285
|
+
// Also resolve after timeout as fallback
|
|
286
|
+
setTimeout(
|
|
287
|
+
() => resolve({ done: true, value: undefined }),
|
|
288
|
+
5000,
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
return q;
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const session = await provider.createSession({});
|
|
297
|
+
const events: ChatEvent[] = [];
|
|
298
|
+
const streamPromise = (async () => {
|
|
299
|
+
for await (const event of provider.sendMessage(session.id, "test")) {
|
|
300
|
+
events.push(event);
|
|
301
|
+
}
|
|
302
|
+
})();
|
|
303
|
+
|
|
304
|
+
// Wait for first event
|
|
305
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
306
|
+
|
|
307
|
+
// Cancel — should trigger abort error
|
|
308
|
+
provider.abortQuery(session.id);
|
|
309
|
+
|
|
310
|
+
await streamPromise;
|
|
311
|
+
|
|
312
|
+
// Should NOT have an error event (abort is intentional)
|
|
313
|
+
const errors = events.filter((e) => e.type === "error");
|
|
314
|
+
expect(errors).toHaveLength(0);
|
|
315
|
+
|
|
316
|
+
// Should still have done event
|
|
317
|
+
const done = events.find((e) => e.type === "done");
|
|
318
|
+
expect(done).toBeTruthy();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("abortQuery is no-op when no active query", () => {
|
|
322
|
+
// Should not throw
|
|
323
|
+
expect(() => provider.abortQuery("nonexistent-session")).not.toThrow();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("cleans up activeQueries after stream ends", async () => {
|
|
327
|
+
const iter = createMockQueryIterator([{ type: "result" }]);
|
|
328
|
+
mockQueryFn.mockReturnValue(iter);
|
|
329
|
+
|
|
330
|
+
const session = await provider.createSession({});
|
|
331
|
+
for await (const _ of provider.sendMessage(session.id, "hi")) {
|
|
332
|
+
// consume
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// abortQuery should be no-op now (query already cleaned up)
|
|
336
|
+
expect(() => provider.abortQuery(session.id)).not.toThrow();
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { MockProvider } from "../../../src/providers/mock-provider.ts";
|
|
3
|
+
|
|
4
|
+
describe("MockProvider", () => {
|
|
5
|
+
it("creates a session with UUID and metadata", async () => {
|
|
6
|
+
const provider = new MockProvider();
|
|
7
|
+
const session = await provider.createSession({
|
|
8
|
+
projectName: "test-project",
|
|
9
|
+
title: "Test Chat",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
expect(session.id).toBeTruthy();
|
|
13
|
+
expect(session.providerId).toBe("mock");
|
|
14
|
+
expect(session.title).toBe("Test Chat");
|
|
15
|
+
expect(session.projectName).toBe("test-project");
|
|
16
|
+
expect(session.createdAt).toBeTruthy();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("resumes an existing session", async () => {
|
|
20
|
+
const provider = new MockProvider();
|
|
21
|
+
const session = await provider.createSession({ title: "Original" });
|
|
22
|
+
const resumed = await provider.resumeSession(session.id);
|
|
23
|
+
|
|
24
|
+
expect(resumed.id).toBe(session.id);
|
|
25
|
+
expect(resumed.title).toBe("Original");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("throws on resume of non-existent session", async () => {
|
|
29
|
+
const provider = new MockProvider();
|
|
30
|
+
expect(provider.resumeSession("non-existent")).rejects.toThrow();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("lists sessions", async () => {
|
|
34
|
+
const provider = new MockProvider();
|
|
35
|
+
await provider.createSession({ title: "Chat 1" });
|
|
36
|
+
await provider.createSession({ title: "Chat 2" });
|
|
37
|
+
const sessions = await provider.listSessions();
|
|
38
|
+
|
|
39
|
+
expect(sessions).toHaveLength(2);
|
|
40
|
+
expect(sessions.map((s) => s.title)).toContain("Chat 1");
|
|
41
|
+
expect(sessions.map((s) => s.title)).toContain("Chat 2");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("deletes a session", async () => {
|
|
45
|
+
const provider = new MockProvider();
|
|
46
|
+
const session = await provider.createSession({ title: "Doomed" });
|
|
47
|
+
await provider.deleteSession(session.id);
|
|
48
|
+
const sessions = await provider.listSessions();
|
|
49
|
+
|
|
50
|
+
expect(sessions).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("streams text events for a simple message", async () => {
|
|
54
|
+
const provider = new MockProvider();
|
|
55
|
+
const session = await provider.createSession({});
|
|
56
|
+
const events: any[] = [];
|
|
57
|
+
|
|
58
|
+
for await (const event of provider.sendMessage(session.id, "hello")) {
|
|
59
|
+
events.push(event);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const textEvents = events.filter((e) => e.type === "text");
|
|
63
|
+
const doneEvents = events.filter((e) => e.type === "done");
|
|
64
|
+
|
|
65
|
+
expect(textEvents.length).toBeGreaterThan(0);
|
|
66
|
+
expect(doneEvents).toHaveLength(1);
|
|
67
|
+
expect(doneEvents[0].sessionId).toBe(session.id);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("yields tool_use events for messages containing 'file'", async () => {
|
|
71
|
+
const provider = new MockProvider();
|
|
72
|
+
const session = await provider.createSession({});
|
|
73
|
+
const events: any[] = [];
|
|
74
|
+
|
|
75
|
+
for await (const event of provider.sendMessage(session.id, "read the file")) {
|
|
76
|
+
events.push(event);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const toolUse = events.find((e) => e.type === "tool_use");
|
|
80
|
+
const toolResult = events.find((e) => e.type === "tool_result");
|
|
81
|
+
|
|
82
|
+
expect(toolUse).toBeTruthy();
|
|
83
|
+
expect(toolUse.tool).toBe("Read");
|
|
84
|
+
expect(toolResult).toBeTruthy();
|
|
85
|
+
expect(toolResult.output).toContain("Main entry point");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("yields approval_request for messages containing 'delete'", async () => {
|
|
89
|
+
const provider = new MockProvider();
|
|
90
|
+
const session = await provider.createSession({});
|
|
91
|
+
const events: any[] = [];
|
|
92
|
+
|
|
93
|
+
for await (const event of provider.sendMessage(session.id, "delete the temp files")) {
|
|
94
|
+
events.push(event);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const approval = events.find((e) => e.type === "approval_request");
|
|
98
|
+
expect(approval).toBeTruthy();
|
|
99
|
+
expect(approval.tool).toBe("Bash");
|
|
100
|
+
expect(approval.requestId).toBeTruthy();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("updates session title from first message", async () => {
|
|
104
|
+
const provider = new MockProvider();
|
|
105
|
+
const session = await provider.createSession({});
|
|
106
|
+
|
|
107
|
+
for await (const _ of provider.sendMessage(session.id, "My first question about testing")) {
|
|
108
|
+
// consume
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const sessions = await provider.listSessions();
|
|
112
|
+
const updated = sessions.find((s) => s.id === session.id);
|
|
113
|
+
expect(updated?.title).toContain("My first question");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("stores message history", async () => {
|
|
117
|
+
const provider = new MockProvider();
|
|
118
|
+
const session = await provider.createSession({});
|
|
119
|
+
|
|
120
|
+
for await (const _ of provider.sendMessage(session.id, "hello")) {
|
|
121
|
+
// consume
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const messages = provider.getMessages(session.id);
|
|
125
|
+
expect(messages).toHaveLength(2); // user + assistant
|
|
126
|
+
expect(messages[0].role).toBe("user");
|
|
127
|
+
expect(messages[0].content).toBe("hello");
|
|
128
|
+
expect(messages[1].role).toBe("assistant");
|
|
129
|
+
expect(messages[1].content).toBeTruthy();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("yields error for non-existent session", async () => {
|
|
133
|
+
const provider = new MockProvider();
|
|
134
|
+
const events: any[] = [];
|
|
135
|
+
|
|
136
|
+
for await (const event of provider.sendMessage("bad-id", "hello")) {
|
|
137
|
+
events.push(event);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
expect(events[0].type).toBe("error");
|
|
141
|
+
expect(events[0].message).toBe("Session not found");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { chatService } from "../../../src/services/chat.service.ts";
|
|
3
|
+
|
|
4
|
+
describe("ChatService", () => {
|
|
5
|
+
it("creates session with default provider", async () => {
|
|
6
|
+
const session = await chatService.createSession(undefined, {
|
|
7
|
+
title: "Test",
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
expect(session.id).toBeTruthy();
|
|
11
|
+
expect(session.providerId).toBe("claude-sdk"); // default provider
|
|
12
|
+
expect(session.title).toBe("Test");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("creates session with specific provider", async () => {
|
|
16
|
+
const session = await chatService.createSession("mock", {
|
|
17
|
+
title: "Mock Chat",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(session.providerId).toBe("mock");
|
|
21
|
+
expect(session.title).toBe("Mock Chat");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("throws on unknown provider", async () => {
|
|
25
|
+
expect(
|
|
26
|
+
chatService.createSession("nonexistent", {}),
|
|
27
|
+
).rejects.toThrow('Provider "nonexistent" not found');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("lists sessions from all providers", async () => {
|
|
31
|
+
// Create sessions in mock provider
|
|
32
|
+
await chatService.createSession("mock", { title: "A" });
|
|
33
|
+
await chatService.createSession("mock", { title: "B" });
|
|
34
|
+
|
|
35
|
+
const all = await chatService.listSessions();
|
|
36
|
+
// Should include at least the mock sessions
|
|
37
|
+
const mockSessions = all.filter((s) => s.providerId === "mock");
|
|
38
|
+
expect(mockSessions.length).toBeGreaterThanOrEqual(2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("lists sessions filtered by provider", async () => {
|
|
42
|
+
await chatService.createSession("mock", { title: "Filtered" });
|
|
43
|
+
const sessions = await chatService.listSessions("mock");
|
|
44
|
+
|
|
45
|
+
expect(sessions.length).toBeGreaterThanOrEqual(1);
|
|
46
|
+
expect(sessions.every((s) => s.providerId === "mock")).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("deletes session", async () => {
|
|
50
|
+
const session = await chatService.createSession("mock", { title: "Delete me" });
|
|
51
|
+
await chatService.deleteSession("mock", session.id);
|
|
52
|
+
|
|
53
|
+
const sessions = await chatService.listSessions("mock");
|
|
54
|
+
expect(sessions.find((s) => s.id === session.id)).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("streams events from sendMessage", async () => {
|
|
58
|
+
const session = await chatService.createSession("mock", {});
|
|
59
|
+
const events: any[] = [];
|
|
60
|
+
|
|
61
|
+
for await (const event of chatService.sendMessage("mock", session.id, "hello world")) {
|
|
62
|
+
events.push(event);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
expect(events.some((e) => e.type === "text")).toBe(true);
|
|
66
|
+
expect(events.some((e) => e.type === "done")).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns error for unknown provider in sendMessage", async () => {
|
|
70
|
+
const events: any[] = [];
|
|
71
|
+
|
|
72
|
+
for await (const event of chatService.sendMessage("bad-provider", "id", "hello")) {
|
|
73
|
+
events.push(event);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
expect(events[0].type).toBe("error");
|
|
77
|
+
expect(events[0].message).toContain("not found");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("getSession finds session across providers", async () => {
|
|
81
|
+
const session = await chatService.createSession("mock", { title: "Findable" });
|
|
82
|
+
const found = chatService.getSession(session.id);
|
|
83
|
+
|
|
84
|
+
expect(found).not.toBeNull();
|
|
85
|
+
expect(found?.id).toBe(session.id);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("getMessages returns history for mock provider", async () => {
|
|
89
|
+
const session = await chatService.createSession("mock", {});
|
|
90
|
+
|
|
91
|
+
for await (const _ of chatService.sendMessage("mock", session.id, "test msg")) {
|
|
92
|
+
// consume
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const messages = await chatService.getMessages("mock", session.id);
|
|
96
|
+
expect(messages.length).toBeGreaterThanOrEqual(2); // user + assistant
|
|
97
|
+
expect(messages[0].role).toBe("user");
|
|
98
|
+
expect(messages[0].content).toBe("test msg");
|
|
99
|
+
});
|
|
100
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
|
4
|
+
"target": "ESNext",
|
|
5
|
+
"module": "Preserve",
|
|
6
|
+
"moduleDetection": "force",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"allowJs": true,
|
|
9
|
+
|
|
10
|
+
"moduleResolution": "bundler",
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"verbatimModuleSyntax": true,
|
|
13
|
+
"noEmit": true,
|
|
14
|
+
|
|
15
|
+
"strict": true,
|
|
16
|
+
"skipLibCheck": true,
|
|
17
|
+
"noFallthroughCasesInSwitch": true,
|
|
18
|
+
"noUncheckedIndexedAccess": true,
|
|
19
|
+
"noImplicitOverride": true,
|
|
20
|
+
|
|
21
|
+
"noUnusedLocals": false,
|
|
22
|
+
"noUnusedParameters": false,
|
|
23
|
+
"noPropertyAccessFromIndexSignature": false,
|
|
24
|
+
|
|
25
|
+
"baseUrl": ".",
|
|
26
|
+
"paths": {
|
|
27
|
+
"@/*": ["./src/web/*"]
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
|
31
|
+
"exclude": ["node_modules", "dist"]
|
|
32
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import react from "@vitejs/plugin-react";
|
|
3
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
4
|
+
import { VitePWA } from "vite-plugin-pwa";
|
|
5
|
+
import { resolve } from "path";
|
|
6
|
+
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
plugins: [
|
|
9
|
+
react(),
|
|
10
|
+
tailwindcss(),
|
|
11
|
+
VitePWA({
|
|
12
|
+
registerType: "autoUpdate",
|
|
13
|
+
manifest: {
|
|
14
|
+
name: "PPM — Personal Project Manager",
|
|
15
|
+
short_name: "PPM",
|
|
16
|
+
description: "Mobile-first web IDE for managing code projects",
|
|
17
|
+
theme_color: "#0f1419",
|
|
18
|
+
background_color: "#0f1419",
|
|
19
|
+
display: "standalone",
|
|
20
|
+
orientation: "any",
|
|
21
|
+
icons: [
|
|
22
|
+
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
|
|
23
|
+
{ src: "/icon-512.png", sizes: "512x512", type: "image/png" },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
workbox: {
|
|
27
|
+
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
|
28
|
+
runtimeCaching: [
|
|
29
|
+
{
|
|
30
|
+
urlPattern: /^https?:\/\/.*\/api\//,
|
|
31
|
+
handler: "NetworkOnly",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
urlPattern: /^https?:\/\/.*\/ws\//,
|
|
35
|
+
handler: "NetworkOnly",
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
],
|
|
41
|
+
root: "src/web",
|
|
42
|
+
resolve: {
|
|
43
|
+
alias: {
|
|
44
|
+
"@": resolve(__dirname, "src/web"),
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
build: {
|
|
48
|
+
outDir: resolve(__dirname, "dist/web"),
|
|
49
|
+
emptyOutDir: true,
|
|
50
|
+
},
|
|
51
|
+
server: {
|
|
52
|
+
host: true,
|
|
53
|
+
port: 5173,
|
|
54
|
+
proxy: {
|
|
55
|
+
"/api": "http://localhost:8080",
|
|
56
|
+
"/ws": {
|
|
57
|
+
target: "ws://localhost:8080",
|
|
58
|
+
ws: true,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
});
|