kcg-code 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +2 -0
  2. package/package.json +2 -1
  3. package/src/cli/__tests__/daemon.test.ts +84 -0
  4. package/src/cli/__tests__/dashboard.test.ts +170 -8
  5. package/src/cli/__tests__/keep-awake.test.ts +44 -0
  6. package/src/cli/__tests__/port.test.ts +86 -0
  7. package/src/cli/args.ts +69 -6
  8. package/src/cli/attach.ts +154 -0
  9. package/src/cli/commands.ts +174 -21
  10. package/src/cli/daemon.ts +275 -0
  11. package/src/cli/dashboard.tsx +880 -159
  12. package/src/cli/index.ts +10 -0
  13. package/src/cli/ink-react.ts +26 -0
  14. package/src/cli/keep-awake.ts +148 -0
  15. package/src/cli/port.ts +92 -0
  16. package/src/cli/theme.ts +6 -6
  17. package/src/components/layout/AppShell.tsx +8 -3
  18. package/src/components/projects/HomeChat.tsx +85 -48
  19. package/src/components/projects/HomeRecentSessions.tsx +90 -0
  20. package/src/components/projects/ProjectInstructions.tsx +27 -38
  21. package/src/components/projects/ProjectSidePanel.tsx +382 -316
  22. package/src/components/sessions/AgentLogo.tsx +42 -0
  23. package/src/components/sessions/AgentPicker.tsx +13 -4
  24. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +27 -7
  25. package/src/components/sessions/ContextPanel.tsx +159 -59
  26. package/src/components/sessions/ModelPicker.tsx +65 -27
  27. package/src/components/sessions/ModelSearchList.tsx +137 -57
  28. package/src/components/sessions/NewSessionDialog.tsx +100 -117
  29. package/src/components/sessions/SessionComposer.tsx +2 -1
  30. package/src/components/sessions/SessionHeader.tsx +1 -0
  31. package/src/components/sessions/SessionList.tsx +173 -66
  32. package/src/components/sessions/SessionRow.tsx +176 -76
  33. package/src/components/sessions/SessionView.tsx +17 -13
  34. package/src/components/sessions/__tests__/ContextPanel.test.tsx +36 -0
  35. package/src/components/settings/LhrRemoteAccess.tsx +20 -25
  36. package/src/components/settings/RemoteAccessGroup.tsx +242 -267
  37. package/src/components/settings/prefs.tsx +69 -1
  38. package/src/components/skills/InstallNotifications.tsx +4 -3
  39. package/src/components/skills/InstallSkillDialog.tsx +256 -99
  40. package/src/components/skills/SkillCards.tsx +52 -8
  41. package/src/components/ui/adw-dialog.tsx +114 -0
  42. package/src/components/ui/adw-toggle-group.tsx +75 -0
  43. package/src/components/ui/search-entry.tsx +70 -0
  44. package/src/config.ts +32 -3
  45. package/src/hooks/useAgentPicker.ts +6 -2
  46. package/src/hooks/useSessionList.ts +95 -2
  47. package/src/lib/__tests__/project-extensions.test.ts +32 -1
  48. package/src/lib/__tests__/project-overview.test.ts +15 -0
  49. package/src/lib/__tests__/routes.test.ts +2 -2
  50. package/src/lib/__tests__/session-selection.test.ts +41 -0
  51. package/src/lib/__tests__/skill-installs.test.ts +1 -0
  52. package/src/lib/__tests__/skills-catalog.test.ts +36 -8
  53. package/src/lib/agent-types.ts +49 -0
  54. package/src/lib/project-extensions.ts +66 -4
  55. package/src/lib/project-overview.ts +14 -8
  56. package/src/lib/routes.ts +13 -4
  57. package/src/lib/session-selection.ts +40 -0
  58. package/src/lib/skill-installs.ts +3 -1
  59. package/src/lib/skills-catalog.ts +56 -17
  60. package/src/pages/projects/ProjectDetailPage.tsx +13 -12
  61. package/src/pages/settings/SettingsPage.tsx +212 -145
  62. package/src/pages/settings/__tests__/SettingsPage.test.ts +11 -4
  63. package/src/pages/skills/SkillsPage.tsx +89 -45
  64. package/src/server/__tests__/app.test.ts +15 -2
  65. package/src/server/app.ts +13 -1
  66. package/src/server/routes/projects.routes.ts +14 -4
  67. package/src/server/routes/sessions.routes.ts +1 -1
  68. package/src/server/routes/skills.routes.ts +24 -5
  69. package/src/server/services/__tests__/claude-code.test.ts +609 -0
  70. package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
  71. package/src/server/services/__tests__/claude-skills.test.ts +63 -0
  72. package/src/server/services/__tests__/session-manager.test.ts +40 -0
  73. package/src/server/services/__tests__/skill-install-jobs.test.ts +22 -3
  74. package/src/server/services/__tests__/skills-registry.test.ts +6 -0
  75. package/src/server/services/claude-code-client.ts +1093 -0
  76. package/src/server/services/claude-code-translate.ts +370 -0
  77. package/src/server/services/claude-mcp.ts +122 -0
  78. package/src/server/services/claude-skills.ts +102 -0
  79. package/src/server/services/session-manager.ts +117 -52
  80. package/src/server/services/skill-install-jobs.ts +21 -8
  81. package/src/server/services/skills-registry.ts +18 -6
  82. package/src/types/message.ts +1 -1
  83. package/src/components/projects/PanelSection.tsx +0 -102
@@ -0,0 +1,609 @@
1
+ /**
2
+ * Backend Claude Code: penerjemah pesan SDK -> event gaya opencode, dan
3
+ * klien in-process (spawn malas, permission/question lewat `canUseTool`,
4
+ * interrupt, resume) dengan `query()` palsu.
5
+ */
6
+ import { describe, expect, test } from "bun:test";
7
+ import type { Options, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
8
+ import {
9
+ CLAUDE_PROVIDER_ID,
10
+ type ClaudeQueryLike,
11
+ createClaudeCodeClient,
12
+ } from "../claude-code-client";
13
+ import {
14
+ ClaudeTurnTranslator,
15
+ normalizeToolInput,
16
+ permissionName,
17
+ todosFromInput,
18
+ toolName,
19
+ } from "../claude-code-translate";
20
+ import type { OpenCodeEvent } from "../opencode-client";
21
+ import { OC_REQUEST_GONE } from "../opencode-client";
22
+
23
+ const SID = "11111111-1111-4111-8111-111111111111";
24
+
25
+ function translator() {
26
+ const events: OpenCodeEvent[] = [];
27
+ const t = new ClaudeTurnTranslator(
28
+ SID,
29
+ (ev) => events.push(ev),
30
+ () => 1000,
31
+ );
32
+ return { t, events };
33
+ }
34
+
35
+ const streamEvent = (event: Record<string, unknown>, parent: string | null = null) => ({
36
+ type: "stream_event",
37
+ event,
38
+ parent_tool_use_id: parent,
39
+ uuid: "u",
40
+ session_id: SID,
41
+ });
42
+
43
+ describe("claude-code-translate helpers", () => {
44
+ test("nama tool & permission dinormalisasi ke gaya opencode", () => {
45
+ expect(toolName("Read")).toBe("read");
46
+ expect(toolName("MultiEdit")).toBe("edit");
47
+ expect(toolName("mcp__github__search")).toBe("mcp__github__search");
48
+ expect(toolName("SomethingNew")).toBe("somethingnew");
49
+ expect(permissionName("Write")).toBe("edit");
50
+ expect(permissionName("Bash")).toBe("bash");
51
+ });
52
+
53
+ test("file_path diberi alias filePath (renderer & Reference)", () => {
54
+ expect(normalizeToolInput({ file_path: "/p/a.ts" })).toEqual({
55
+ file_path: "/p/a.ts",
56
+ filePath: "/p/a.ts",
57
+ });
58
+ expect(normalizeToolInput(null)).toEqual({});
59
+ });
60
+
61
+ test("TodoWrite -> task list dengan prioritas default", () => {
62
+ expect(
63
+ todosFromInput({
64
+ todos: [{ content: "a", status: "in_progress", activeForm: "A" }, { x: 1 }],
65
+ }),
66
+ ).toEqual([{ content: "a", status: "in_progress", priority: "medium" }]);
67
+ expect(todosFromInput({})).toBeNull();
68
+ });
69
+ });
70
+
71
+ describe("ClaudeTurnTranslator", () => {
72
+ test("stream text -> part + delta; blok yang sama di pesan assistant dilewati", () => {
73
+ const { t, events } = translator();
74
+ t.beginTurn();
75
+ const msgId = (events[0]?.info as { id: string } | undefined)?.id;
76
+ t.handle(streamEvent({ type: "message_start", message: { id: "api_1" } }));
77
+ t.handle(
78
+ streamEvent({
79
+ type: "content_block_start",
80
+ index: 0,
81
+ content_block: { type: "text", text: "" },
82
+ }),
83
+ );
84
+ t.handle(
85
+ streamEvent({
86
+ type: "content_block_delta",
87
+ index: 0,
88
+ delta: { type: "text_delta", text: "Hi" },
89
+ }),
90
+ );
91
+ t.handle({
92
+ type: "assistant",
93
+ message: { id: "api_1", content: [{ type: "text", text: "Hi" }] },
94
+ parent_tool_use_id: null,
95
+ uuid: "a1",
96
+ });
97
+ t.handle(streamEvent({ type: "content_block_stop", index: 0 }));
98
+
99
+ expect(events.map((e) => e.type)).toEqual([
100
+ "message.updated",
101
+ "message.part.updated",
102
+ "message.part.delta",
103
+ "message.part.updated",
104
+ ]);
105
+ expect(events[2]).toMatchObject({ messageID: msgId, partID: "prt_api_1_0", delta: "Hi" });
106
+ expect(events[3]).toMatchObject({
107
+ part: { id: "prt_api_1_0", type: "text", text: "Hi", messageID: msgId },
108
+ });
109
+ });
110
+
111
+ test("thinking -> part reasoning", () => {
112
+ const { t, events } = translator();
113
+ t.beginTurn();
114
+ t.handle(streamEvent({ type: "message_start", message: { id: "api_2" } }));
115
+ t.handle(
116
+ streamEvent({ type: "content_block_start", index: 0, content_block: { type: "thinking" } }),
117
+ );
118
+ t.handle(
119
+ streamEvent({
120
+ type: "content_block_delta",
121
+ index: 0,
122
+ delta: { type: "thinking_delta", thinking: "hmm" },
123
+ }),
124
+ );
125
+ t.handle(streamEvent({ type: "content_block_stop", index: 0 }));
126
+ expect(events.at(-1)).toMatchObject({ part: { type: "reasoning", text: "hmm" } });
127
+ });
128
+
129
+ test("thinking tanpa token (disembunyikan provider) tidak menjadi part kosong", () => {
130
+ const { t, events } = translator();
131
+ t.beginTurn();
132
+ t.handle(streamEvent({ type: "message_start", message: { id: "api_h" } }));
133
+ t.handle(
134
+ streamEvent({ type: "content_block_start", index: 0, content_block: { type: "thinking" } }),
135
+ );
136
+ t.handle(streamEvent({ type: "content_block_stop", index: 0 }));
137
+ expect(events.map((e) => e.type)).toEqual(["message.updated"]);
138
+ });
139
+
140
+ test("pesan assistant tanpa stream (fallback) tetap menghasilkan teks", () => {
141
+ const { t, events } = translator();
142
+ t.beginTurn();
143
+ t.handle({
144
+ type: "assistant",
145
+ message: { id: "api_x", content: [{ type: "text", text: "done" }] },
146
+ parent_tool_use_id: null,
147
+ uuid: "a9",
148
+ });
149
+ expect(events.at(-1)).toMatchObject({ part: { type: "text", text: "done" } });
150
+ });
151
+
152
+ test("tool_use -> running, tool_result -> completed + output; TodoWrite -> todos", () => {
153
+ const { t, events } = translator();
154
+ t.beginTurn();
155
+ const effects = t.handle({
156
+ type: "assistant",
157
+ message: {
158
+ id: "api_3",
159
+ content: [
160
+ { type: "tool_use", id: "toolu_1", name: "Read", input: { file_path: "/p/a.ts" } },
161
+ {
162
+ type: "tool_use",
163
+ id: "toolu_2",
164
+ name: "TodoWrite",
165
+ input: { todos: [{ content: "x", status: "pending" }] },
166
+ },
167
+ ],
168
+ },
169
+ parent_tool_use_id: null,
170
+ uuid: "a2",
171
+ });
172
+ expect(effects.todos).toEqual([{ content: "x", status: "pending", priority: "medium" }]);
173
+ expect(events[1]).toMatchObject({
174
+ part: {
175
+ id: "prt_toolu_1",
176
+ type: "tool",
177
+ tool: "read",
178
+ state: { status: "running", input: { filePath: "/p/a.ts" } },
179
+ },
180
+ });
181
+
182
+ t.handle({
183
+ type: "user",
184
+ message: {
185
+ role: "user",
186
+ content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "file body" }],
187
+ },
188
+ parent_tool_use_id: null,
189
+ });
190
+ expect(events.at(-1)).toMatchObject({
191
+ part: {
192
+ id: "prt_toolu_1",
193
+ state: { status: "completed", output: "file body", input: { filePath: "/p/a.ts" } },
194
+ },
195
+ });
196
+
197
+ t.handle({
198
+ type: "user",
199
+ message: {
200
+ role: "user",
201
+ content: [{ type: "tool_result", tool_use_id: "toolu_2", content: "no", is_error: true }],
202
+ },
203
+ parent_tool_use_id: null,
204
+ });
205
+ expect(events.at(-1)).toMatchObject({ part: { state: { status: "error" } } });
206
+ });
207
+
208
+ test("api_retry -> session.status retry (banner)", () => {
209
+ const { t, events } = translator();
210
+ t.handle({
211
+ type: "system",
212
+ subtype: "api_retry",
213
+ attempt: 2,
214
+ error: "rate_limit",
215
+ error_status: 429,
216
+ });
217
+ expect(events[0]).toMatchObject({
218
+ type: "session.status",
219
+ status: { type: "retry", attempt: 2, message: "rate limit (HTTP 429)" },
220
+ });
221
+ });
222
+ });
223
+
224
+ /** `query()` palsu: mencatat input & opsi, pesan SDK didorong dari test. */
225
+ function fakeQuery() {
226
+ const calls: { options: Options; inputs: SDKUserMessage[]; query: FakeQ }[] = [];
227
+ type FakeQ = ClaudeQueryLike & {
228
+ push(m: Record<string, unknown>): void;
229
+ end(): void;
230
+ interrupted: number;
231
+ models: string[];
232
+ modes: string[];
233
+ closed: boolean;
234
+ };
235
+ const fn = (params: { prompt: AsyncIterable<SDKUserMessage>; options: Options }) => {
236
+ const inputs: SDKUserMessage[] = [];
237
+ void (async () => {
238
+ for await (const m of params.prompt) inputs.push(m);
239
+ })();
240
+ const queue: SDKMessage[] = [];
241
+ let wake: (() => void) | null = null;
242
+ let done = false;
243
+ const q: FakeQ = {
244
+ interrupted: 0,
245
+ models: [],
246
+ modes: [],
247
+ closed: false,
248
+ push(m) {
249
+ queue.push(m as unknown as SDKMessage);
250
+ wake?.();
251
+ },
252
+ end() {
253
+ done = true;
254
+ wake?.();
255
+ },
256
+ async interrupt() {
257
+ q.interrupted += 1;
258
+ },
259
+ async setModel(model) {
260
+ q.models.push(model ?? "");
261
+ },
262
+ async setPermissionMode(mode) {
263
+ q.modes.push(mode);
264
+ },
265
+ async supportedModels() {
266
+ return [{ value: "sonnet", displayName: "Sonnet", description: "" }];
267
+ },
268
+ async mcpServerStatus() {
269
+ return [{ name: "fs", status: "connected" }];
270
+ },
271
+ close() {
272
+ q.closed = true;
273
+ q.end();
274
+ },
275
+ async *[Symbol.asyncIterator]() {
276
+ for (;;) {
277
+ const next = queue.shift();
278
+ if (next) {
279
+ yield next;
280
+ continue;
281
+ }
282
+ if (done) return;
283
+ await new Promise<void>((r) => {
284
+ wake = r;
285
+ });
286
+ wake = null;
287
+ }
288
+ },
289
+ };
290
+ calls.push({ options: params.options, inputs, query: q });
291
+ return q;
292
+ };
293
+ return { fn, calls };
294
+ }
295
+
296
+ const tick = () => new Promise((r) => setTimeout(r, 0));
297
+
298
+ function setup(existing: string[] = []) {
299
+ const { fn, calls } = fakeQuery();
300
+ const removed: string[] = [];
301
+ const client = createClaudeCodeClient("/proj", {
302
+ query: fn,
303
+ deleteSettleMs: 0,
304
+ env: { HOME: "/home/u" },
305
+ transcripts: {
306
+ async exists(id) {
307
+ return existing.includes(id);
308
+ },
309
+ async remove(id) {
310
+ removed.push(id);
311
+ existing.splice(existing.indexOf(id), 1);
312
+ },
313
+ },
314
+ });
315
+ const events: OpenCodeEvent[] = [];
316
+ client.subscribeEvents((ev) => events.push(ev));
317
+ return { client, calls, events, removed };
318
+ }
319
+
320
+ const state = (s: string) => ({ type: "system", subtype: "session_state_changed", state: s });
321
+
322
+ describe("ClaudeCodeClient", () => {
323
+ test("prompt pertama spawn proses dengan settings user & sessionId baru", async () => {
324
+ const { client, calls, events } = setup();
325
+ const created = await client.createSession();
326
+ if (!created.ok) throw new Error("create");
327
+ expect(calls.length).toBe(0); // malas: belum ada proses
328
+
329
+ const res = await client.promptAsync(created.data.id, "hello", {
330
+ providerID: CLAUDE_PROVIDER_ID,
331
+ modelID: "sonnet",
332
+ });
333
+ expect(res.ok).toBe(true);
334
+ await tick();
335
+ expect(calls.length).toBe(1);
336
+ const opts = calls[0]?.options as Options;
337
+ expect(opts.cwd).toBe("/proj");
338
+ expect(opts.settingSources).toEqual(["user", "project", "local"]);
339
+ expect(opts.sessionId).toBe(created.data.id);
340
+ expect(opts.resume).toBeUndefined();
341
+ expect(opts.model).toBe("sonnet");
342
+ expect(opts.permissionMode).toBeUndefined(); // ikut defaultMode user
343
+ expect(opts.env?.CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS).toBe("1");
344
+ expect(opts.env?.HOME).toBe("/home/u");
345
+ expect(calls[0]?.inputs[0]?.message).toEqual({ role: "user", content: "hello" });
346
+ expect(events[0]).toMatchObject({ type: "message.updated", sessionID: created.data.id });
347
+ });
348
+
349
+ test("transcript sudah ada -> resume; model/mode diganti lewat kontrol", async () => {
350
+ const { client, calls } = setup([SID]);
351
+ expect((await client.getSession(SID)).ok).toBe(true);
352
+ await client.promptAsync(SID, "a", null, [], "plan");
353
+ await tick();
354
+ expect(calls[0]?.options.resume).toBe(SID);
355
+ expect(calls[0]?.options.sessionId).toBeUndefined();
356
+ expect(calls[0]?.options.permissionMode).toBe("plan");
357
+
358
+ await client.promptAsync(
359
+ SID,
360
+ "b",
361
+ { providerID: CLAUDE_PROVIDER_ID, modelID: "opus" },
362
+ [],
363
+ null,
364
+ );
365
+ await tick();
366
+ expect(calls.length).toBe(1); // proses dipakai ulang
367
+ expect(calls[0]?.query.models).toEqual(["opus"]);
368
+ expect(calls[0]?.query.modes).toEqual(["default"]);
369
+ expect(calls[0]?.inputs.length).toBe(2);
370
+ });
371
+
372
+ test("session_state idle -> session.idle; result error -> session.error", async () => {
373
+ const { client, calls, events } = setup();
374
+ await client.promptAsync(SID, "x");
375
+ await tick();
376
+ const q = calls[0]?.query;
377
+ q?.push(state("running"));
378
+ q?.push({
379
+ type: "result",
380
+ subtype: "error_during_execution",
381
+ is_error: true,
382
+ errors: ["boom"],
383
+ });
384
+ q?.push(state("idle"));
385
+ await tick();
386
+ const types = events.map((e) => e.type);
387
+ expect(types).toContain("session.error");
388
+ expect(types.at(-1)).toBe("session.idle");
389
+ expect(events.find((e) => e.type === "session.error")).toMatchObject({
390
+ error: { data: { message: "boom" } },
391
+ });
392
+ });
393
+
394
+ test("permission: canUseTool -> permission.asked; reply once/always/reject", async () => {
395
+ const { client, calls, events } = setup();
396
+ await client.promptAsync(SID, "x");
397
+ await tick();
398
+ const canUseTool = calls[0]?.options.canUseTool;
399
+ if (!canUseTool) throw new Error("canUseTool");
400
+ const signal = new AbortController().signal;
401
+ const rule = {
402
+ type: "addRules" as const,
403
+ rules: [{ toolName: "Bash", ruleContent: "git *" }],
404
+ behavior: "allow" as const,
405
+ };
406
+ const suggestions = [{ ...rule, destination: "localSettings" as const }];
407
+
408
+ const p1 = canUseTool(
409
+ "Bash",
410
+ { command: "git status" },
411
+ {
412
+ signal,
413
+ suggestions,
414
+ toolUseID: "t1",
415
+ requestId: "r1",
416
+ },
417
+ );
418
+ const asked = events.find((e) => e.type === "permission.asked");
419
+ expect(asked).toMatchObject({ sessionID: SID, permission: "bash", patterns: ["git status"] });
420
+ const id = String(asked?.requestID);
421
+ expect((await client.replyPermission(id, "always")).ok).toBe(true);
422
+ expect(await p1).toEqual({
423
+ behavior: "allow",
424
+ updatedInput: { command: "git status" },
425
+ // "Always" berlaku per Session, tidak menulis file settings.
426
+ updatedPermissions: [{ ...rule, destination: "session" }],
427
+ });
428
+ // Request sudah dijawab -> kedaluwarsa.
429
+ expect(await client.replyPermission(id, "once")).toEqual({ ok: false, error: OC_REQUEST_GONE });
430
+
431
+ const p2 = canUseTool(
432
+ "Write",
433
+ { file_path: "/x/y" },
434
+ {
435
+ signal,
436
+ blockedPath: "/x",
437
+ toolUseID: "t2",
438
+ requestId: "r2",
439
+ },
440
+ );
441
+ const asked2 = events.filter((e) => e.type === "permission.asked").at(-1);
442
+ expect(asked2).toMatchObject({ permission: "external_directory", patterns: ["/x"] });
443
+ await client.replyPermission(String(asked2?.requestID), "reject");
444
+ expect(await p2).toMatchObject({ behavior: "deny" });
445
+ });
446
+
447
+ test("AskUserQuestion -> question.asked; jawaban dipetakan per teks pertanyaan", async () => {
448
+ const { client, calls, events } = setup();
449
+ await client.promptAsync(SID, "x");
450
+ await tick();
451
+ const canUseTool = calls[0]?.options.canUseTool;
452
+ if (!canUseTool) throw new Error("canUseTool");
453
+ const input = {
454
+ questions: [
455
+ {
456
+ question: "Which DB?",
457
+ header: "DB",
458
+ options: [
459
+ { label: "Postgres", description: "" },
460
+ { label: "SQLite", description: "" },
461
+ ],
462
+ multiSelect: false,
463
+ },
464
+ {
465
+ question: "Extras?",
466
+ header: "Extras",
467
+ options: [
468
+ { label: "Auth", description: "" },
469
+ { label: "Cache", description: "" },
470
+ ],
471
+ multiSelect: true,
472
+ },
473
+ ],
474
+ };
475
+ const p = canUseTool("AskUserQuestion", input, {
476
+ signal: new AbortController().signal,
477
+ toolUseID: "t",
478
+ requestId: "r",
479
+ });
480
+ const asked = events.find((e) => e.type === "question.asked");
481
+ expect(asked).toMatchObject({
482
+ questions: [
483
+ { question: "Which DB?", multiple: false, custom: true },
484
+ { question: "Extras?", multiple: true },
485
+ ],
486
+ });
487
+ await client.replyQuestion(String(asked?.requestID), [["SQLite"], ["Auth", "Cache"]]);
488
+ expect(await p).toEqual({
489
+ behavior: "allow",
490
+ updatedInput: { ...input, answers: { "Which DB?": "SQLite", "Extras?": "Auth, Cache" } },
491
+ });
492
+ });
493
+
494
+ test("interrupt: idle turn lama ditelan, prompt baru menunggu sampai turn lama berhenti", async () => {
495
+ const { client, calls, events } = setup();
496
+ await client.promptAsync(SID, "first");
497
+ await tick();
498
+ const q = calls[0]?.query;
499
+ q?.push(state("running"));
500
+ await tick();
501
+ expect((await client.abortSession(SID)).ok).toBe(true);
502
+ expect(q?.interrupted).toBe(1);
503
+
504
+ let sent = false;
505
+ const second = client.promptAsync(SID, "second").then(() => {
506
+ sent = true;
507
+ });
508
+ await tick();
509
+ expect(sent).toBe(false); // menunggu turn yang di-interrupt berhenti
510
+
511
+ q?.push({ type: "result", subtype: "error_during_execution", is_error: true, errors: ["x"] });
512
+ q?.push(state("idle"));
513
+ await second;
514
+ expect(events.some((e) => e.type === "session.idle")).toBe(false);
515
+ expect(events.some((e) => e.type === "session.error")).toBe(false);
516
+ expect(calls[0]?.inputs.length).toBe(2);
517
+
518
+ q?.push(state("running"));
519
+ q?.push(state("idle"));
520
+ await tick();
521
+ expect(events.filter((e) => e.type === "session.idle").length).toBe(1);
522
+ });
523
+
524
+ test("proses mati di tengah turn -> session.error + session.idle", async () => {
525
+ const { client, calls, events } = setup();
526
+ await client.promptAsync(SID, "x");
527
+ await tick();
528
+ const q = calls[0]?.query;
529
+ q?.push(state("running"));
530
+ await tick();
531
+ q?.end();
532
+ await tick();
533
+ expect(events.at(-1)).toMatchObject({ type: "session.idle", sessionID: SID });
534
+ });
535
+
536
+ test("gambar dikirim inline sebagai base64", async () => {
537
+ const { client, calls } = setup();
538
+ const file = `${import.meta.dir}/__fixture_claude.png`;
539
+ await Bun.write(file, new Uint8Array([137, 80, 78, 71]));
540
+ try {
541
+ await client.promptAsync(SID, "look", null, [
542
+ { filename: "a.png", mime: "image/png", url: `file://${file}` },
543
+ { filename: "src/x.ts", mime: "text/plain", url: "file:///proj/src/x.ts" },
544
+ ]);
545
+ await tick();
546
+ const content = calls[0]?.inputs[0]?.message.content as unknown as Record<string, unknown>[];
547
+ expect(content[0]).toEqual({
548
+ type: "text",
549
+ text: "look\nReferenced files:\n- src/x.ts: /proj/src/x.ts",
550
+ });
551
+ expect(content[1]).toMatchObject({
552
+ type: "image",
553
+ source: { type: "base64", media_type: "image/png", data: "iVBORw==" },
554
+ });
555
+ } finally {
556
+ await Bun.file(file).delete();
557
+ }
558
+ });
559
+
560
+ test("model, agent, MCP, todos, delete", async () => {
561
+ const { client, calls, removed } = setup([SID]);
562
+ const models = await client.listModels();
563
+ expect(models).toEqual({
564
+ ok: true,
565
+ data: [
566
+ {
567
+ providerID: CLAUDE_PROVIDER_ID,
568
+ providerName: "Claude Code",
569
+ modelID: "sonnet",
570
+ name: "Sonnet",
571
+ },
572
+ ],
573
+ });
574
+ // Query sementara untuk daftar model langsung ditutup & tidak dipersist.
575
+ expect(calls[0]?.query.closed).toBe(true);
576
+ expect(calls[0]?.options.persistSession).toBe(false);
577
+
578
+ const agents = await client.listAgents();
579
+ expect(agents.ok && agents.data.map((a) => a.name)).toEqual(["default", "acceptEdits", "plan"]);
580
+
581
+ await client.promptAsync(SID, "x");
582
+ await tick();
583
+ const q = calls[1]?.query;
584
+ q?.push({
585
+ type: "assistant",
586
+ message: {
587
+ id: "m",
588
+ content: [
589
+ { type: "tool_use", id: "t", name: "TodoWrite", input: { todos: [{ content: "a" }] } },
590
+ ],
591
+ },
592
+ parent_tool_use_id: null,
593
+ uuid: "u",
594
+ });
595
+ await tick();
596
+ expect(await client.listTodos(SID)).toEqual({
597
+ ok: true,
598
+ data: [{ content: "a", status: "pending", priority: "medium" }],
599
+ });
600
+ expect(await client.listMcp()).toEqual({
601
+ ok: true,
602
+ data: [{ name: "fs", status: "connected", error: null }],
603
+ });
604
+
605
+ expect((await client.deleteSession(SID)).ok).toBe(true);
606
+ expect(q?.closed).toBe(true);
607
+ expect(removed).toEqual([SID]);
608
+ });
609
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Unit test daftar MCP Claude Code (src/server/services/claude-mcp.ts).
3
+ */
4
+ import { describe, expect, test } from "bun:test";
5
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import {
9
+ claudeJsonPath,
10
+ mergeClaudeMcp,
11
+ parseClaudeMcpConfig,
12
+ readClaudeMcpConfig,
13
+ } from "../claude-mcp";
14
+
15
+ describe("parseClaudeMcpConfig", () => {
16
+ test("gabung local > project > user; .mcp.json yang ditolak -> disabled", () => {
17
+ const entries = parseClaudeMcpConfig(
18
+ "/p/",
19
+ { mcpServers: { shared: {}, proj: {}, rejected: {} } },
20
+ {
21
+ mcpServers: { shared: {}, mine: {} },
22
+ projects: {
23
+ "/p": { mcpServers: { shared: {} }, disabledMcpjsonServers: ["rejected"] },
24
+ "/other": { mcpServers: { elsewhere: {} } },
25
+ },
26
+ },
27
+ );
28
+ expect(entries).toEqual([
29
+ { name: "mine", scope: "user", disabled: false },
30
+ { name: "proj", scope: "project", disabled: false },
31
+ { name: "rejected", scope: "project", disabled: true },
32
+ { name: "shared", scope: "local", disabled: false },
33
+ ]);
34
+ });
35
+
36
+ test("input tak valid -> kosong", () => {
37
+ expect(parseClaudeMcpConfig("/p", null, "x")).toEqual([]);
38
+ expect(parseClaudeMcpConfig("/p", { mcpServers: [] }, { mcpServers: 1 })).toEqual([]);
39
+ });
40
+ });
41
+
42
+ describe("mergeClaudeMcp", () => {
43
+ test("status live menang; server live tanpa config tetap ikut", () => {
44
+ const merged = mergeClaudeMcp(
45
+ [
46
+ { name: "a", scope: "user", disabled: false },
47
+ { name: "b", scope: "project", disabled: true },
48
+ { name: "c", scope: "local", disabled: false },
49
+ ],
50
+ [
51
+ { name: "a", status: "failed", error: "boom" },
52
+ { name: "plugin:x", status: "connected", error: null },
53
+ ],
54
+ );
55
+ expect(merged).toEqual([
56
+ { name: "a", status: "failed", error: "boom" },
57
+ { name: "b", status: "disabled", error: null },
58
+ { name: "c", status: "unknown", error: null },
59
+ { name: "plugin:x", status: "connected", error: null },
60
+ ]);
61
+ });
62
+ });
63
+
64
+ describe("readClaudeMcpConfig", () => {
65
+ test("membaca .mcp.json Project + .claude.json di CLAUDE_CONFIG_DIR", async () => {
66
+ const root = mkdtempSync(join(tmpdir(), "kcg-cc-mcp-"));
67
+ const project = join(root, "proj");
68
+ const config = join(root, "cfg");
69
+ mkdirSync(project);
70
+ mkdirSync(config);
71
+ writeFileSync(join(project, ".mcp.json"), JSON.stringify({ mcpServers: { db: {} } }));
72
+ writeFileSync(join(config, ".claude.json"), JSON.stringify({ mcpServers: { web: {} } }));
73
+ const entries = await readClaudeMcpConfig(project, { CLAUDE_CONFIG_DIR: config });
74
+ expect(entries.map((e) => [e.name, e.scope])).toEqual([
75
+ ["db", "project"],
76
+ ["web", "user"],
77
+ ]);
78
+ });
79
+
80
+ test("file tidak ada / rusak -> kosong", async () => {
81
+ const root = mkdtempSync(join(tmpdir(), "kcg-cc-mcp-"));
82
+ writeFileSync(join(root, ".mcp.json"), "{not json");
83
+ expect(await readClaudeMcpConfig(root, { HOME: join(root, "nohome") })).toEqual([]);
84
+ });
85
+
86
+ test("claudeJsonPath: CLAUDE_CONFIG_DIR, lalu HOME", () => {
87
+ expect(claudeJsonPath({ CLAUDE_CONFIG_DIR: "/cfg" })).toBe("/cfg/.claude.json");
88
+ expect(claudeJsonPath({ HOME: "/home/u" })).toBe("/home/u/.claude.json");
89
+ });
90
+ });