kcg-code 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/README.md +230 -2
  2. package/bin/kcgcode.ts +13 -0
  3. package/bun-env.d.ts +19 -0
  4. package/kcg-code.config.example.json +3 -0
  5. package/package.json +84 -4
  6. package/public/logo.svg +1 -0
  7. package/public/manifest.json +18 -0
  8. package/public/sw.js +49 -0
  9. package/src/App.tsx +39 -0
  10. package/src/__tests__/manifest.test.ts +61 -0
  11. package/src/build-config.ts +9 -0
  12. package/src/cli/__tests__/daemon.test.ts +84 -0
  13. package/src/cli/__tests__/dashboard.test.ts +224 -0
  14. package/src/cli/__tests__/keep-awake.test.ts +44 -0
  15. package/src/cli/__tests__/port.test.ts +86 -0
  16. package/src/cli/args.ts +186 -0
  17. package/src/cli/attach.ts +154 -0
  18. package/src/cli/commands.ts +327 -0
  19. package/src/cli/daemon.ts +275 -0
  20. package/src/cli/dashboard.tsx +1060 -0
  21. package/src/cli/index.ts +47 -0
  22. package/src/cli/keep-awake.ts +148 -0
  23. package/src/cli/port.ts +92 -0
  24. package/src/cli/theme.ts +100 -0
  25. package/src/components/auth/AuthGate.tsx +182 -0
  26. package/src/components/auth/AvatarEditor.tsx +236 -0
  27. package/src/components/auth/LockScreen.tsx +714 -0
  28. package/src/components/auth/ProfileAvatar.tsx +60 -0
  29. package/src/components/auth/WelcomeScreen.tsx +672 -0
  30. package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
  31. package/src/components/auth/avatar-icons.tsx +114 -0
  32. package/src/components/layout/AppShell.tsx +147 -0
  33. package/src/components/layout/AppSidebar.tsx +419 -0
  34. package/src/components/layout/SearchDialog.tsx +189 -0
  35. package/src/components/layout/SidebarAccount.tsx +111 -0
  36. package/src/components/layout/SidebarSessionMenu.tsx +163 -0
  37. package/src/components/layout/ThemeToggle.tsx +67 -0
  38. package/src/components/projects/FolderBrowser.tsx +142 -0
  39. package/src/components/projects/HomeChat.tsx +287 -0
  40. package/src/components/projects/HomeRecentSessions.tsx +90 -0
  41. package/src/components/projects/NewProjectDialog.tsx +249 -0
  42. package/src/components/projects/ProjectInstructions.tsx +218 -0
  43. package/src/components/projects/ProjectRow.tsx +58 -0
  44. package/src/components/projects/ProjectSidePanel.tsx +514 -0
  45. package/src/components/sessions/AgentLogo.tsx +42 -0
  46. package/src/components/sessions/AgentModeList.tsx +265 -0
  47. package/src/components/sessions/AgentPicker.tsx +132 -0
  48. package/src/components/sessions/AssistantTurn.tsx +145 -0
  49. package/src/components/sessions/AttachmentTile.tsx +161 -0
  50. package/src/components/sessions/CodeBlock.tsx +107 -0
  51. package/src/components/sessions/ComposerActionSheet.tsx +96 -0
  52. package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
  53. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
  54. package/src/components/sessions/ContextPanel.tsx +661 -0
  55. package/src/components/sessions/DropPlate.tsx +165 -0
  56. package/src/components/sessions/FileDropOverlay.tsx +232 -0
  57. package/src/components/sessions/FileMentionDropdown.tsx +47 -0
  58. package/src/components/sessions/ImageLightbox.tsx +173 -0
  59. package/src/components/sessions/MarkdownContent.tsx +187 -0
  60. package/src/components/sessions/Mascot.tsx +47 -0
  61. package/src/components/sessions/MessageActions.tsx +60 -0
  62. package/src/components/sessions/MessageRail.tsx +121 -0
  63. package/src/components/sessions/ModelPicker.tsx +247 -0
  64. package/src/components/sessions/ModelSearchList.tsx +231 -0
  65. package/src/components/sessions/NewSessionDialog.tsx +172 -0
  66. package/src/components/sessions/PendingAttachments.tsx +127 -0
  67. package/src/components/sessions/PromptCard.tsx +1085 -0
  68. package/src/components/sessions/PromptPanel.tsx +112 -0
  69. package/src/components/sessions/SessionComposer.tsx +641 -0
  70. package/src/components/sessions/SessionHeader.tsx +176 -0
  71. package/src/components/sessions/SessionList.tsx +503 -0
  72. package/src/components/sessions/SessionRow.tsx +237 -0
  73. package/src/components/sessions/SessionTimeline.tsx +275 -0
  74. package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
  75. package/src/components/sessions/SessionView.tsx +240 -0
  76. package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
  77. package/src/components/sessions/UserMessage.tsx +177 -0
  78. package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
  79. package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
  80. package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
  81. package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
  82. package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
  83. package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
  84. package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
  85. package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
  86. package/src/components/settings/AboutGroup.tsx +170 -0
  87. package/src/components/settings/AccountGroup.tsx +122 -0
  88. package/src/components/settings/DevicesGroup.tsx +151 -0
  89. package/src/components/settings/LhrRemoteAccess.tsx +257 -0
  90. package/src/components/settings/PasskeysGroup.tsx +551 -0
  91. package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
  92. package/src/components/settings/SecurityGroup.tsx +566 -0
  93. package/src/components/settings/WorkspaceGroup.tsx +370 -0
  94. package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
  95. package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
  96. package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
  97. package/src/components/settings/prefs.tsx +333 -0
  98. package/src/components/skills/InstallNotifications.tsx +326 -0
  99. package/src/components/skills/InstallSkillDialog.tsx +390 -0
  100. package/src/components/skills/SkillCards.tsx +254 -0
  101. package/src/components/skills/SkillsProjectPicker.tsx +178 -0
  102. package/src/components/ui/adw-dialog.tsx +114 -0
  103. package/src/components/ui/adw-toggle-group.tsx +75 -0
  104. package/src/components/ui/alert-dialog.tsx +179 -0
  105. package/src/components/ui/alert.tsx +60 -0
  106. package/src/components/ui/badge.tsx +46 -0
  107. package/src/components/ui/bubble.tsx +125 -0
  108. package/src/components/ui/button.tsx +62 -0
  109. package/src/components/ui/card.tsx +75 -0
  110. package/src/components/ui/collapsible.tsx +19 -0
  111. package/src/components/ui/dialog.tsx +142 -0
  112. package/src/components/ui/dropdown-menu.tsx +225 -0
  113. package/src/components/ui/empty.tsx +94 -0
  114. package/src/components/ui/field.tsx +234 -0
  115. package/src/components/ui/input-group.tsx +161 -0
  116. package/src/components/ui/input.tsx +21 -0
  117. package/src/components/ui/label.tsx +19 -0
  118. package/src/components/ui/message-scroller.tsx +131 -0
  119. package/src/components/ui/message.tsx +85 -0
  120. package/src/components/ui/popover.tsx +46 -0
  121. package/src/components/ui/search-entry.tsx +70 -0
  122. package/src/components/ui/select.tsx +172 -0
  123. package/src/components/ui/separator.tsx +28 -0
  124. package/src/components/ui/sheet.tsx +138 -0
  125. package/src/components/ui/sidebar.tsx +699 -0
  126. package/src/components/ui/skeleton.tsx +15 -0
  127. package/src/components/ui/sonner.tsx +45 -0
  128. package/src/components/ui/spinner.tsx +16 -0
  129. package/src/components/ui/switch.tsx +32 -0
  130. package/src/components/ui/textarea.tsx +18 -0
  131. package/src/components/ui/tooltip.tsx +51 -0
  132. package/src/config.ts +114 -0
  133. package/src/db.ts +146 -0
  134. package/src/frontend.tsx +51 -0
  135. package/src/hooks/__tests__/useMention.test.ts +79 -0
  136. package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
  137. package/src/hooks/__tests__/useRouter.test.ts +17 -0
  138. package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
  139. package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
  140. package/src/hooks/__tests__/useTheme.test.ts +118 -0
  141. package/src/hooks/use-mobile.ts +16 -0
  142. package/src/hooks/useAgentPicker.ts +101 -0
  143. package/src/hooks/useMediaQuery.ts +49 -0
  144. package/src/hooks/useMention.ts +236 -0
  145. package/src/hooks/useProjects.ts +88 -0
  146. package/src/hooks/useResyncOnReconnect.ts +28 -0
  147. package/src/hooks/useRouter.ts +92 -0
  148. package/src/hooks/useSessionChat.ts +427 -0
  149. package/src/hooks/useSessionContext.ts +106 -0
  150. package/src/hooks/useSessionList.ts +327 -0
  151. package/src/hooks/useSidebarData.ts +108 -0
  152. package/src/hooks/useSmoothText.ts +122 -0
  153. package/src/hooks/useTheme.ts +164 -0
  154. package/src/hooks/useWebSocket.ts +196 -0
  155. package/src/index.css +27 -0
  156. package/src/index.html +14 -0
  157. package/src/index.ts +49 -0
  158. package/src/lib/__tests__/attachments.test.ts +58 -0
  159. package/src/lib/__tests__/avatar-presets.test.ts +25 -0
  160. package/src/lib/__tests__/collapsible.test.ts +101 -0
  161. package/src/lib/__tests__/composer.test.ts +118 -0
  162. package/src/lib/__tests__/highlight.test.ts +42 -0
  163. package/src/lib/__tests__/home-chat.test.ts +64 -0
  164. package/src/lib/__tests__/lock-screen.test.ts +64 -0
  165. package/src/lib/__tests__/model-picker.test.ts +205 -0
  166. package/src/lib/__tests__/onboarding.test.ts +41 -0
  167. package/src/lib/__tests__/permission-info.test.ts +61 -0
  168. package/src/lib/__tests__/project-extensions.test.ts +83 -0
  169. package/src/lib/__tests__/project-overview.test.ts +188 -0
  170. package/src/lib/__tests__/references.test.ts +121 -0
  171. package/src/lib/__tests__/routes.test.ts +134 -0
  172. package/src/lib/__tests__/session-selection.test.ts +41 -0
  173. package/src/lib/__tests__/session-summary.test.ts +115 -0
  174. package/src/lib/__tests__/session-title.test.ts +58 -0
  175. package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
  176. package/src/lib/__tests__/skill-installs.test.ts +135 -0
  177. package/src/lib/__tests__/skills-catalog.test.ts +124 -0
  178. package/src/lib/__tests__/tone.test.ts +37 -0
  179. package/src/lib/__tests__/turns-delta.test.ts +55 -0
  180. package/src/lib/agent-types.ts +49 -0
  181. package/src/lib/api.ts +306 -0
  182. package/src/lib/attachments.ts +203 -0
  183. package/src/lib/auth.ts +92 -0
  184. package/src/lib/avatar-presets.ts +31 -0
  185. package/src/lib/collapsible.ts +75 -0
  186. package/src/lib/composer.ts +89 -0
  187. package/src/lib/data-events.ts +17 -0
  188. package/src/lib/headlines.ts +30 -0
  189. package/src/lib/highlight.ts +186 -0
  190. package/src/lib/layout.ts +20 -0
  191. package/src/lib/lock-screen.ts +54 -0
  192. package/src/lib/model-picker.ts +133 -0
  193. package/src/lib/onboarding.ts +38 -0
  194. package/src/lib/passkeys.ts +113 -0
  195. package/src/lib/pending-prompt.ts +53 -0
  196. package/src/lib/permission-info.ts +101 -0
  197. package/src/lib/project-extensions.ts +136 -0
  198. package/src/lib/project-overview.ts +123 -0
  199. package/src/lib/prompts.ts +87 -0
  200. package/src/lib/references.ts +106 -0
  201. package/src/lib/routes.ts +124 -0
  202. package/src/lib/session-handoff.ts +25 -0
  203. package/src/lib/session-selection.ts +40 -0
  204. package/src/lib/session-status.ts +45 -0
  205. package/src/lib/session-summary.ts +68 -0
  206. package/src/lib/session-title.ts +63 -0
  207. package/src/lib/sidebar-groups.ts +78 -0
  208. package/src/lib/skill-installs.ts +362 -0
  209. package/src/lib/skills-catalog.ts +138 -0
  210. package/src/lib/time.ts +22 -0
  211. package/src/lib/tone.ts +80 -0
  212. package/src/lib/turns.ts +320 -0
  213. package/src/lib/utils.ts +6 -0
  214. package/src/logo.svg +31 -0
  215. package/src/pages/projects/ProjectDetailPage.tsx +131 -0
  216. package/src/pages/projects/ProjectsPage.tsx +97 -0
  217. package/src/pages/sessions/SessionPage.tsx +122 -0
  218. package/src/pages/settings/SettingsPage.tsx +366 -0
  219. package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
  220. package/src/pages/skills/SkillsPage.tsx +745 -0
  221. package/src/paths.ts +121 -0
  222. package/src/runtime.ts +24 -0
  223. package/src/server/__tests__/app.test.ts +1044 -0
  224. package/src/server/__tests__/auth.test.ts +370 -0
  225. package/src/server/__tests__/config.test.ts +101 -0
  226. package/src/server/__tests__/db.test.ts +485 -0
  227. package/src/server/__tests__/host.test.ts +14 -0
  228. package/src/server/__tests__/tunnel-config.test.ts +38 -0
  229. package/src/server/app.ts +427 -0
  230. package/src/server/db/auth.ts +311 -0
  231. package/src/server/db/messages.ts +50 -0
  232. package/src/server/db/projects.ts +106 -0
  233. package/src/server/db/prompts.ts +78 -0
  234. package/src/server/db/rows.ts +150 -0
  235. package/src/server/db/schema.ts +221 -0
  236. package/src/server/db/sessions.ts +239 -0
  237. package/src/server/db/tunnel.ts +124 -0
  238. package/src/server/host.ts +12 -0
  239. package/src/server/result.ts +15 -0
  240. package/src/server/routes/auth-guard.ts +156 -0
  241. package/src/server/routes/auth.routes.ts +287 -0
  242. package/src/server/routes/helpers.ts +165 -0
  243. package/src/server/routes/meta.routes.ts +24 -0
  244. package/src/server/routes/passkeys.routes.ts +161 -0
  245. package/src/server/routes/projects.routes.ts +167 -0
  246. package/src/server/routes/sessions.routes.ts +197 -0
  247. package/src/server/routes/skills.routes.ts +136 -0
  248. package/src/server/routes/tunnel.routes.ts +132 -0
  249. package/src/server/routes/types.ts +35 -0
  250. package/src/server/routes/uploads.routes.ts +54 -0
  251. package/src/server/routes/workspace.routes.ts +127 -0
  252. package/src/server/services/__tests__/app-info.test.ts +18 -0
  253. package/src/server/services/__tests__/attachments.test.ts +159 -0
  254. package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
  255. package/src/server/services/__tests__/auth.test.ts +341 -0
  256. package/src/server/services/__tests__/claude-code.test.ts +609 -0
  257. package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
  258. package/src/server/services/__tests__/claude-skills.test.ts +63 -0
  259. package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
  260. package/src/server/services/__tests__/git-info.test.ts +74 -0
  261. package/src/server/services/__tests__/opencode.test.ts +320 -0
  262. package/src/server/services/__tests__/project-manager.test.ts +190 -0
  263. package/src/server/services/__tests__/sandbox.test.ts +141 -0
  264. package/src/server/services/__tests__/session-manager.test.ts +3509 -0
  265. package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
  266. package/src/server/services/__tests__/skills-registry.test.ts +365 -0
  267. package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
  268. package/src/server/services/__tests__/tunnel.test.ts +367 -0
  269. package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
  270. package/src/server/services/__tests__/workspace.test.ts +249 -0
  271. package/src/server/services/app-info.ts +29 -0
  272. package/src/server/services/attachments.ts +193 -0
  273. package/src/server/services/auth-passkeys.ts +459 -0
  274. package/src/server/services/auth.ts +633 -0
  275. package/src/server/services/claude-code-client.ts +1093 -0
  276. package/src/server/services/claude-code-translate.ts +370 -0
  277. package/src/server/services/claude-mcp.ts +122 -0
  278. package/src/server/services/claude-skills.ts +102 -0
  279. package/src/server/services/frpc-installer.ts +183 -0
  280. package/src/server/services/git-info.ts +87 -0
  281. package/src/server/services/opencode-client.ts +714 -0
  282. package/src/server/services/opencode-server.ts +319 -0
  283. package/src/server/services/opencode-sse.ts +82 -0
  284. package/src/server/services/project-manager.ts +147 -0
  285. package/src/server/services/sandbox.ts +83 -0
  286. package/src/server/services/session-events.ts +256 -0
  287. package/src/server/services/session-manager.ts +1439 -0
  288. package/src/server/services/skill-install-jobs.ts +275 -0
  289. package/src/server/services/skills-registry.ts +480 -0
  290. package/src/server/services/tunnel-api.ts +207 -0
  291. package/src/server/services/tunnel-config.ts +45 -0
  292. package/src/server/services/tunnel-lhr.ts +387 -0
  293. package/src/server/services/tunnel.ts +633 -0
  294. package/src/server/services/turn-stream.ts +339 -0
  295. package/src/server/services/websocket-gateway.ts +374 -0
  296. package/src/server/services/workspace.ts +223 -0
  297. package/src/server/services/ws-transport.ts +121 -0
  298. package/src/types/context.ts +69 -0
  299. package/src/types/index.ts +13 -0
  300. package/src/types/message.ts +58 -0
  301. package/src/types/project.ts +16 -0
  302. package/src/types/prompt.ts +118 -0
  303. package/src/types/session.ts +52 -0
  304. package/src/types/turns.ts +47 -0
  305. package/src/ws-protocol.ts +93 -0
  306. package/styles/globals.css +311 -0
  307. package/tsconfig.json +37 -0
@@ -0,0 +1,1044 @@
1
+ /**
2
+ * Integration test end-to-end alur utama (task 20.2) + wiring otentikasi.
3
+ *
4
+ * Skenario dengan `OpenCodeServerManager` + `OpenCodeClient` mock:
5
+ * buat Project (POST /api/projects) -> buat Session (POST /api/sessions)
6
+ * -> `attach` via WebSocket -> kirim `input` -> terima pesan terstruktur
7
+ * -> event `permission.asked` -> respon prompt -> `stop`.
8
+ *
9
+ * Server nyata (`Bun.serve` via `createKcgServer`) + WebSocket client nyata;
10
+ * hanya komponen opencode headless yang di-mock.
11
+ */
12
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import path from "node:path";
16
+ import { openSessionStore, type SessionStore } from "../../db";
17
+ import type { Project, Session, SessionContext } from "../../types";
18
+ import type { ServerMessage } from "../../ws-protocol";
19
+ import { createKcgServer, type KcgServer } from "../app";
20
+ import type {
21
+ OpenCodeClient,
22
+ OpenCodeEvent,
23
+ OpenCodeFileChange,
24
+ OpenCodeTodo,
25
+ } from "../services/opencode-client";
26
+ import type { OpenCodeServerManager } from "../services/opencode-server";
27
+ import type { SkillsRegistry } from "../services/skills-registry";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Mock OpenCodeClient + OpenCodeServerManager
31
+ // ---------------------------------------------------------------------------
32
+
33
+ interface FakeClient extends OpenCodeClient {
34
+ emit(ev: OpenCodeEvent): void;
35
+ sendMessageCalls: string[];
36
+ replyPermissionCalls: [string, string][];
37
+ abortCalls: string[];
38
+ deleteCalls: string[];
39
+ /** Referensi file yang dikirim ke tiap promptAsync. */
40
+ promptFilesCalls: { filename: string; mime: string; url: string }[][];
41
+ /** Apakah `getSession` melaporkan session remote masih ada (default: ya). */
42
+ getSessionOk: boolean;
43
+ /** Task list & perubahan file untuk panel konteks Session. */
44
+ todos: OpenCodeTodo[];
45
+ fileChanges: OpenCodeFileChange[];
46
+ /**
47
+ * Prompt berikutnya gagal diproses opencode: alih-alih sukses, server
48
+ * memancarkan `session.error` (meniru kegagalan diam-diam di sisi model).
49
+ */
50
+ failNextPrompt: boolean;
51
+ }
52
+
53
+ function makeFakeClient(projectId: string): FakeClient {
54
+ let eventCb: ((ev: OpenCodeEvent) => void) | null = null;
55
+ const client: FakeClient = {
56
+ sendMessageCalls: [],
57
+ replyPermissionCalls: [],
58
+ abortCalls: [],
59
+ deleteCalls: [],
60
+ promptFilesCalls: [],
61
+ getSessionOk: true,
62
+ failNextPrompt: false,
63
+ todos: [],
64
+ fileChanges: [],
65
+ async createSession() {
66
+ return { ok: true, data: { id: `ses_${projectId}`, directory: "/proj" } };
67
+ },
68
+ async getSession(_sessionId) {
69
+ return client.getSessionOk ? { ok: true } : { ok: false, error: "OC_SESSION_NOT_FOUND" };
70
+ },
71
+ async deleteSession(sessionId) {
72
+ client.deleteCalls.push(sessionId);
73
+ return { ok: true };
74
+ },
75
+ async findFiles(query) {
76
+ return { ok: true, data: query ? ["src/App.tsx"] : [] };
77
+ },
78
+ async listTodos() {
79
+ return { ok: true, data: client.todos };
80
+ },
81
+ async fileStatus() {
82
+ return { ok: true, data: client.fileChanges };
83
+ },
84
+ async listModels() {
85
+ return {
86
+ ok: true,
87
+ data: [
88
+ {
89
+ providerID: "kcgcode",
90
+ providerName: "kcgcode",
91
+ modelID: "kiro/claude-opus-5",
92
+ name: "Claude Opus 5",
93
+ },
94
+ ],
95
+ };
96
+ },
97
+ async listAgents() {
98
+ return {
99
+ ok: true,
100
+ data: [
101
+ { name: "build", mode: "primary" as const, description: "Full tool access" },
102
+ { name: "plan", mode: "primary" as const, description: "Planning only" },
103
+ ],
104
+ };
105
+ },
106
+ async listMcp() {
107
+ return { ok: true, data: [{ name: "context7", status: "connected" as const, error: null }] };
108
+ },
109
+ async disposeInstance() {
110
+ return { ok: true };
111
+ },
112
+ async listSkills() {
113
+ return {
114
+ ok: true,
115
+ data: [
116
+ {
117
+ name: "shadcn",
118
+ description: "UI kit",
119
+ location: "/x/SKILL.md",
120
+ source: "project" as const,
121
+ },
122
+ ],
123
+ };
124
+ },
125
+ async sendMessage(_sessionId, text) {
126
+ client.sendMessageCalls.push(text);
127
+ return {
128
+ ok: true,
129
+ data: {
130
+ info: { id: `msg_${projectId}`, role: "assistant" },
131
+ parts: [{ type: "text", text: `balasan: ${text}` }],
132
+ },
133
+ };
134
+ },
135
+ /**
136
+ * Meniru server sungguhan: prompt diterima (204), balasan mengalir lewat
137
+ * SSE, lalu `session.idle` menutup turn. Bila `failNextPrompt`, prompt
138
+ * justru gagal diproses — server memancarkan `session.error`.
139
+ */
140
+ async promptAsync(sessionId, text, _model, files) {
141
+ client.sendMessageCalls.push(text);
142
+ client.promptFilesCalls.push(files ?? []);
143
+ if (client.failNextPrompt) {
144
+ client.failNextPrompt = false;
145
+ queueMicrotask(() => {
146
+ client.emit({
147
+ type: "session.error",
148
+ sessionID: sessionId,
149
+ error: {
150
+ name: "UnknownError",
151
+ data: {
152
+ message:
153
+ 'TypeError: File URL host must be "localhost" or empty on linux\n at SessionPrompt.resolveUserPart',
154
+ },
155
+ },
156
+ });
157
+ // Error turn KCG Code bersifat "ditunda" — pesan error baru ditulis
158
+ // saat turn ditutup tanpa konten baru (TurnStream.finish -> idle).
159
+ // Tanpa idle, banner terkirim tapi history tetap kosong dan test menunggu 3 detik sia-sia.
160
+ client.emit({ type: "session.idle", sessionID: sessionId });
161
+ });
162
+ return { ok: true, data: null };
163
+ }
164
+ const messageID = `msg_${projectId}`;
165
+ queueMicrotask(() => {
166
+ client.emit({
167
+ type: "message.updated",
168
+ sessionID: sessionId,
169
+ info: { id: messageID, role: "assistant" },
170
+ });
171
+ client.emit({
172
+ type: "message.part.updated",
173
+ sessionID: sessionId,
174
+ part: { type: "text", id: "prt_1", text: `balasan: ${text}`, messageID },
175
+ });
176
+ client.emit({ type: "session.idle", sessionID: sessionId });
177
+ });
178
+ return { ok: true, data: null };
179
+ },
180
+ async replyPermission(requestId, reply) {
181
+ client.replyPermissionCalls.push([requestId, reply]);
182
+ return { ok: true, data: null };
183
+ },
184
+ async replyQuestion() {
185
+ return { ok: true, data: null };
186
+ },
187
+ async rejectQuestion() {
188
+ return { ok: true, data: null };
189
+ },
190
+ async abortSession(sessionId) {
191
+ client.abortCalls.push(sessionId);
192
+ return { ok: true, data: null };
193
+ },
194
+ subscribeEvents(cb) {
195
+ eventCb = cb;
196
+ return () => {
197
+ eventCb = null;
198
+ };
199
+ },
200
+ async health() {
201
+ return true;
202
+ },
203
+ emit(ev: OpenCodeEvent) {
204
+ eventCb?.(ev);
205
+ },
206
+ };
207
+ return client;
208
+ }
209
+
210
+ interface FakeServers {
211
+ manager: OpenCodeServerManager;
212
+ clients: Map<string, FakeClient>;
213
+ stopped: string[];
214
+ }
215
+
216
+ function makeFakeServers(): FakeServers {
217
+ const clients = new Map<string, FakeClient>();
218
+ const stopped: string[] = [];
219
+ return {
220
+ stopped,
221
+ clients,
222
+ manager: {
223
+ async ensureServer(projectId, _projectPath) {
224
+ let client = clients.get(projectId);
225
+ if (!client) {
226
+ client = makeFakeClient(projectId);
227
+ clients.set(projectId, client);
228
+ }
229
+ return { ok: true, data: { projectId, baseUrl: "http://x", client } };
230
+ },
231
+ async ensureFreshServer(projectId, projectPath) {
232
+ return this.ensureServer(projectId, projectPath);
233
+ },
234
+ getServer(projectId) {
235
+ const client = clients.get(projectId);
236
+ return client ? { projectId, baseUrl: "http://x", client } : undefined;
237
+ },
238
+ async stopServer(projectId) {
239
+ stopped.push(projectId);
240
+ clients.delete(projectId);
241
+ },
242
+ async stopAll() {
243
+ for (const id of [...clients.keys()]) stopped.push(id);
244
+ clients.clear();
245
+ },
246
+ onServerExit() {},
247
+ },
248
+ };
249
+ }
250
+
251
+ async function waitFor(pred: () => boolean, timeoutMs = 3000, label = "kondisi"): Promise<void> {
252
+ const start = Date.now();
253
+ while (!pred()) {
254
+ if (Date.now() - start > timeoutMs) throw new Error(`timeout menunggu ${label}`);
255
+ await Bun.sleep(10);
256
+ }
257
+ }
258
+
259
+ describe("createKcgServer — alur utama e2e (headless)", () => {
260
+ let store: SessionStore;
261
+ let app: KcgServer;
262
+ let root: string;
263
+ let servers: FakeServers;
264
+ let claudeServers: FakeServers;
265
+ /** Instalasi yang diminta ke Skills_Registry palsu: [cwd, source, skill]. */
266
+ const installCalls: [string, string, string][] = [];
267
+ const skillsRegistry: SkillsRegistry = {
268
+ async search(q) {
269
+ if (q.trim().length < 2) return { ok: false, error: "INVALID_SKILL_QUERY" };
270
+ return {
271
+ ok: true,
272
+ data: [
273
+ {
274
+ id: "a/b/c",
275
+ name: "c",
276
+ source: "a/b",
277
+ skillId: "c",
278
+ installs: 3,
279
+ url: "https://skills.sh/a/b/c",
280
+ },
281
+ ],
282
+ };
283
+ },
284
+ async audit(source) {
285
+ if (source === "bad") return { ok: false, error: "INVALID_SKILL_ID" };
286
+ return { ok: true, data: { audits: null, overall: null } };
287
+ },
288
+ async install(cwd, source, skill, onLine) {
289
+ installCalls.push([cwd, source, skill]);
290
+ onLine?.(`installing ${skill}`);
291
+ if (skill === "boom") return { ok: false, error: "SKILL_INSTALL_FAILED" };
292
+ return { ok: true, data: { name: skill, path: `${cwd}/.agents/skills/${skill}` } };
293
+ },
294
+ };
295
+
296
+ beforeAll(() => {
297
+ store = openSessionStore(":memory:");
298
+ root = mkdtempSync(path.join(tmpdir(), "kcg-e2e2-"));
299
+ mkdirSync(path.join(root, "proj"), { recursive: true });
300
+ servers = makeFakeServers();
301
+ claudeServers = makeFakeServers();
302
+ app = createKcgServer({
303
+ config: { sandboxRoot: root, configPath: "test" },
304
+ store,
305
+ servers: servers.manager,
306
+ claudeServers: claudeServers.manager,
307
+ uploadsRoot: path.join(root, "uploads"),
308
+ port: 0,
309
+ skillsRegistry,
310
+ });
311
+ });
312
+
313
+ afterAll(async () => {
314
+ await app.close();
315
+ rmSync(root, { recursive: true, force: true });
316
+ });
317
+
318
+ function baseUrl(): string {
319
+ return `http://127.0.0.1:${app.server.port}`;
320
+ }
321
+
322
+ test("20.2: project -> session -> attach -> input -> pesan terstruktur -> prompt -> stop", async () => {
323
+ // ---- buat Project (Requirement 10.4) ----
324
+ const projRes = await fetch(`${baseUrl()}/api/projects`, {
325
+ method: "POST",
326
+ headers: { "content-type": "application/json" },
327
+ body: JSON.stringify({ name: "demo", path: "proj" }),
328
+ });
329
+ expect(projRes.status).toBe(201);
330
+ const projBody = (await projRes.json()) as { project: Project };
331
+ expect(projBody.project.path.endsWith("proj")).toBe(true);
332
+
333
+ // Folder_Browser melihat sub-direktori di sandbox (Requirement 10.2)
334
+ const fsRes = await fetch(`${baseUrl()}/api/fs?path=`);
335
+ expect(fsRes.status).toBe(200);
336
+ const fsBody = (await fsRes.json()) as { entries: string[] };
337
+ expect(fsBody.entries).toContain("proj");
338
+
339
+ // ---- buat Session (Requirement 1.1) ----
340
+ const sessRes = await fetch(`${baseUrl()}/api/sessions`, {
341
+ method: "POST",
342
+ headers: { "content-type": "application/json" },
343
+ body: JSON.stringify({ agentType: "opencode", projectId: projBody.project.id }),
344
+ });
345
+ expect(sessRes.status).toBe(201);
346
+ const sessBody = (await sessRes.json()) as { session: Session };
347
+ const sessionId = sessBody.session.id;
348
+ expect(sessBody.session.status).toBe("running");
349
+ expect(sessBody.session.ocSessionId).toBe(`ses_${projBody.project.id}`);
350
+
351
+ const client = servers.clients.get(projBody.project.id);
352
+ expect(client).toBeDefined();
353
+ if (!client) return;
354
+
355
+ // ---- attach via WebSocket (Requirement 4.1) ----
356
+ const msgs: ServerMessage[] = [];
357
+ const ws = new WebSocket(`ws://127.0.0.1:${app.server.port}/ws`);
358
+ ws.onmessage = (event) => {
359
+ msgs.push(JSON.parse(String(event.data)) as ServerMessage);
360
+ };
361
+ await waitFor(() => ws.readyState === WebSocket.OPEN, 3000, "koneksi WS terbuka");
362
+ ws.send(JSON.stringify({ type: "attach", sessionId }));
363
+
364
+ await waitFor(() => msgs.some((m) => m.type === "history"), 3000, "pesan history");
365
+ const hist = msgs.find((m): m is Extract<ServerMessage, { type: "history" }> => {
366
+ return m.type === "history";
367
+ });
368
+ expect(hist?.messages ?? []).toHaveLength(0);
369
+
370
+ // ---- kirim input -> echo user + balasan assistant (Requirement 7.1) ----
371
+ ws.send(JSON.stringify({ type: "input", sessionId, text: "hello" }));
372
+ await waitFor(
373
+ () => msgs.filter((m) => m.type === "message" && m.message.role === "user").length === 1,
374
+ 3000,
375
+ "echo pesan user",
376
+ );
377
+ await waitFor(
378
+ () => msgs.filter((m) => m.type === "message" && m.message.role === "assistant").length === 1,
379
+ 3000,
380
+ "balasan assistant",
381
+ );
382
+ const reply = msgs.find(
383
+ (m): m is Extract<ServerMessage, { type: "message" }> =>
384
+ m.type === "message" && m.message.role === "assistant",
385
+ );
386
+ // Parts dirakit dari SSE, jadi membawa metadata part (id, messageID).
387
+ expect(reply?.message.parts).toHaveLength(1);
388
+ expect(reply?.message.parts[0]).toMatchObject({ type: "text", text: "balasan: hello" });
389
+
390
+ // ---- Interactive_Prompt dari event SSE terstruktur (Requirement 6) ----
391
+ client.emit({
392
+ type: "permission.asked",
393
+ requestID: "per_1",
394
+ sessionID: `ses_${projBody.project.id}`,
395
+ permission: "bash:ls",
396
+ });
397
+ await waitFor(() => msgs.some((m) => m.type === "prompt"), 3000, "pesan prompt");
398
+ const promptMsg = msgs.find((m): m is Extract<ServerMessage, { type: "prompt" }> => {
399
+ return m.type === "prompt";
400
+ });
401
+ expect(promptMsg?.prompt.kind).toBe("permission");
402
+ expect(promptMsg?.prompt.type).toBe("confirmation");
403
+
404
+ // ---- respon prompt -> replyPermission (Requirement 6.3) ----
405
+ ws.send(
406
+ JSON.stringify({
407
+ type: "prompt_response",
408
+ sessionId,
409
+ promptId: promptMsg?.prompt.id ?? "",
410
+ response: "approve",
411
+ }),
412
+ );
413
+ await waitFor(() => msgs.some((m) => m.type === "prompt_resolved"), 3000, "prompt_resolved");
414
+ expect(client.replyPermissionCalls).toContainEqual(["per_1", "once"]);
415
+
416
+ // ---- stopSession (Requirement 1.6) — POST /stop, data tetap ada ----
417
+ const stopRes = await fetch(`${baseUrl()}/api/sessions/${sessionId}/stop`, { method: "POST" });
418
+ expect(stopRes.status).toBe(200);
419
+ expect(client.abortCalls).toContain(`ses_${projBody.project.id}`);
420
+ await waitFor(
421
+ () => msgs.some((m) => m.type === "session_status" && m.status === "stopped"),
422
+ 3000,
423
+ "session_status stopped",
424
+ );
425
+
426
+ const listRes = await fetch(`${baseUrl()}/api/sessions`);
427
+ const listBody = (await listRes.json()) as { sessions: Session[] };
428
+ const listed = listBody.sessions.find((s) => s.id === sessionId);
429
+ expect(listed?.status).toBe("stopped");
430
+
431
+ // ---- resume session stopped (ocSessionId dipertahankan) ----
432
+ const resumeRes = await fetch(`${baseUrl()}/api/sessions/${sessionId}`, { method: "POST" });
433
+ expect(resumeRes.status).toBe(200);
434
+ const resumeBody = (await resumeRes.json()) as { session: Session; ok: boolean };
435
+ expect(resumeBody.ok).toBe(true);
436
+ expect(resumeBody.session.status).toBe("running");
437
+ // ocSessionId lama masih dikenal fake server -> tidak diganti.
438
+ expect(resumeBody.session.ocSessionId).toBe(`ses_${projBody.project.id}`);
439
+ // Input kembali berfungsi setelah resume (via WebSocket).
440
+ ws.send(JSON.stringify({ type: "input", sessionId, text: "setelah resume" }));
441
+ await waitFor(
442
+ () => msgs.filter((m) => m.type === "message" && m.message.role === "user").length === 2,
443
+ 3000,
444
+ "echo user setelah resume",
445
+ );
446
+
447
+ // Resume kedua kali -> konflik 409.
448
+ const resumeRes2 = await fetch(`${baseUrl()}/api/sessions/${sessionId}`, { method: "POST" });
449
+ expect(resumeRes2.status).toBe(409);
450
+
451
+ // ---- deleteSession: hapus permanen (remote + lokal) ----
452
+ const delRes = await fetch(`${baseUrl()}/api/sessions/${sessionId}`, { method: "DELETE" });
453
+ expect(delRes.status).toBe(200);
454
+ expect(client.deleteCalls).toContain(`ses_${projBody.project.id}`);
455
+ // Session hilang dari daftar.
456
+ const afterDel = (await (await fetch(`${baseUrl()}/api/sessions`)).json()) as {
457
+ sessions: Session[];
458
+ };
459
+ expect(afterDel.sessions.find((s) => s.id === sessionId)).toBeUndefined();
460
+ // Session sudah tidak ada -> hapus lagi -> 404.
461
+ const delRes2 = await fetch(`${baseUrl()}/api/sessions/${sessionId}`, { method: "DELETE" });
462
+ expect(delRes2.status).toBe(404);
463
+
464
+ ws.close();
465
+ });
466
+
467
+ test("gambar: upload -> GET serve bytes -> kirim input dgn image -> part file image", async () => {
468
+ // Path unik agar tidak bertabrakan dengan test lain (store dipakai bersama).
469
+ mkdirSync(path.join(root, "proj-gambar"), { recursive: true });
470
+ const projRes = await fetch(`${baseUrl()}/api/projects`, {
471
+ method: "POST",
472
+ headers: { "content-type": "application/json" },
473
+ body: JSON.stringify({ name: "gambar-proj", path: "proj-gambar" }),
474
+ });
475
+ expect(projRes.status).toBe(201);
476
+ const projBody = (await projRes.json()) as { project: Project };
477
+ const sessRes = await fetch(`${baseUrl()}/api/sessions`, {
478
+ method: "POST",
479
+ headers: { "content-type": "application/json" },
480
+ body: JSON.stringify({ agentType: "opencode", projectId: projBody.project.id }),
481
+ });
482
+ expect(sessRes.status).toBe(201);
483
+ const sessBody = (await sessRes.json()) as { session: Session };
484
+ const sessionId = sessBody.session.id;
485
+
486
+ // PNG 1x1 minimal (header). Server tidak memvalidasi isi, hanya mime.
487
+ const png = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1])], {
488
+ type: "image/png",
489
+ });
490
+ const form = new FormData();
491
+ form.append("file", png, "foto.png");
492
+ const upRes = await fetch(`${baseUrl()}/api/sessions/${sessionId}/uploads`, {
493
+ method: "POST",
494
+ body: form,
495
+ });
496
+ expect(upRes.status).toBe(201);
497
+ const upBody = (await upRes.json()) as {
498
+ upload: { id: string; filename: string; mime: string };
499
+ };
500
+ expect(upBody.upload.filename).toBe("foto.png");
501
+ expect(upBody.upload.mime).toBe("image/png");
502
+ const uploadId = upBody.upload.id;
503
+
504
+ // GET mengembalikan bytes dengan content-type gambar.
505
+ const getRes = await fetch(`${baseUrl()}/api/uploads/${sessionId}/${uploadId}`);
506
+ expect(getRes.status).toBe(200);
507
+ expect(getRes.headers.get("content-type")).toContain("image/png");
508
+
509
+ // File non-gambar diterima, tapi disajikan sebagai unduhan (bukan inline)
510
+ // agar HTML/SVG upload tidak bisa dieksekusi di origin aplikasi.
511
+ const htmlForm = new FormData();
512
+ htmlForm.append("file", new Blob(["<script>x</script>"], { type: "text/html" }), "a.html");
513
+ const htmlRes = await fetch(`${baseUrl()}/api/sessions/${sessionId}/uploads`, {
514
+ method: "POST",
515
+ body: htmlForm,
516
+ });
517
+ expect(htmlRes.status).toBe(201);
518
+ const htmlId = ((await htmlRes.json()) as { upload: { id: string } }).upload.id;
519
+ const htmlGet = await fetch(`${baseUrl()}/api/uploads/${sessionId}/${htmlId}`);
520
+ expect(htmlGet.headers.get("content-type")).toBe("application/octet-stream");
521
+ expect(htmlGet.headers.get("content-disposition")).toStartWith("attachment");
522
+ expect(htmlGet.headers.get("x-content-type-options")).toBe("nosniff");
523
+
524
+ // Kirim pesan dengan lampiran -> echo user memuat part file image;
525
+ // promptAsync menerima ref gambar (mime image + url file).
526
+ const msgs: ServerMessage[] = [];
527
+ const ws = new WebSocket(`ws://127.0.0.1:${app.server.port}/ws`);
528
+ ws.onmessage = (event) => {
529
+ msgs.push(JSON.parse(String(event.data)) as ServerMessage);
530
+ };
531
+ await waitFor(() => ws.readyState === WebSocket.OPEN, 3000, "koneksi WS terbuka");
532
+ ws.send(JSON.stringify({ type: "attach", sessionId }));
533
+ await waitFor(() => msgs.some((m) => m.type === "history"), 3000, "pesan history");
534
+ ws.send(
535
+ JSON.stringify({
536
+ type: "input",
537
+ sessionId,
538
+ text: "jelaskan gambar ini",
539
+ images: [uploadId],
540
+ }),
541
+ );
542
+ await waitFor(
543
+ () => msgs.filter((m) => m.type === "message" && m.message.role === "user").length === 1,
544
+ 3000,
545
+ "echo pesan user dgn gambar",
546
+ );
547
+ const userMsg = msgs.find(
548
+ (m): m is Extract<ServerMessage, { type: "message" }> =>
549
+ m.type === "message" && m.message.role === "user",
550
+ );
551
+ const imagePart = userMsg?.message.parts.find(
552
+ (p) => p.type === "file" && p.attachmentId === uploadId,
553
+ );
554
+ expect(imagePart?.mime).toBe("image/png");
555
+ expect(imagePart?.filename).toBe("foto.png");
556
+ expect(typeof imagePart?.url).toBe("string");
557
+
558
+ // Fake server menerima ref file image pada promptAsync.
559
+ const client = servers.clients.get(projBody.project.id);
560
+ expect(client?.promptFilesCalls.at(-1)).toContainEqual(
561
+ expect.objectContaining({ mime: "image/png", filename: "foto.png" }),
562
+ );
563
+ ws.close();
564
+ });
565
+
566
+ test("prompt gagal (session.error) -> error ke client + pesan error di history", async () => {
567
+ // Path unik agar tidak bertabrakan dengan test lain (store dipakai bersama).
568
+ mkdirSync(path.join(root, "proj-gagal"), { recursive: true });
569
+ const projRes = await fetch(`${baseUrl()}/api/projects`, {
570
+ method: "POST",
571
+ headers: { "content-type": "application/json" },
572
+ body: JSON.stringify({ name: "gagal-proj", path: "proj-gagal" }),
573
+ });
574
+ expect(projRes.status).toBe(201);
575
+ const projBody = (await projRes.json()) as { project: Project };
576
+ const sessRes = await fetch(`${baseUrl()}/api/sessions`, {
577
+ method: "POST",
578
+ headers: { "content-type": "application/json" },
579
+ body: JSON.stringify({ agentType: "opencode", projectId: projBody.project.id }),
580
+ });
581
+ expect(sessRes.status).toBe(201);
582
+ const sessBody = (await sessRes.json()) as { session: Session };
583
+ const sessionId = sessBody.session.id;
584
+ const client = servers.clients.get(projBody.project.id);
585
+ if (!client) throw new Error("client p1 tidak ada");
586
+ client.failNextPrompt = true;
587
+
588
+ const msgs: ServerMessage[] = [];
589
+ const ws = new WebSocket(`ws://127.0.0.1:${app.server.port}/ws`);
590
+ ws.onmessage = (event) => {
591
+ msgs.push(JSON.parse(String(event.data)) as ServerMessage);
592
+ };
593
+ await waitFor(() => ws.readyState === WebSocket.OPEN, 3000, "koneksi WS terbuka");
594
+ ws.send(JSON.stringify({ type: "attach", sessionId }));
595
+ await waitFor(() => msgs.some((m) => m.type === "history"), 3000, "pesan history");
596
+
597
+ ws.send(JSON.stringify({ type: "input", sessionId, text: "jelaskan" }));
598
+ await waitFor(
599
+ () => msgs.filter((m) => m.type === "message" && m.message.role === "user").length === 1,
600
+ 3000,
601
+ "echo pesan user",
602
+ );
603
+
604
+ // Client menerima notifikasi error (banner) dengan pesan ramah.
605
+ await waitFor(
606
+ () => msgs.some((m) => m.type === "error" && m.code === "AGENT_ERROR"),
607
+ 3000,
608
+ "pesan error AGENT_ERROR",
609
+ );
610
+ const err = msgs.find((m): m is Extract<ServerMessage, { type: "error" }> => {
611
+ return m.type === "error";
612
+ });
613
+ expect(err?.message).toBe('TypeError: File URL host must be "localhost" or empty on linux');
614
+
615
+ // Kegagalan bertahan di history: pesan assistant part `type: "error"`.
616
+ await waitFor(
617
+ () =>
618
+ msgs.some(
619
+ (m) =>
620
+ m.type === "message" &&
621
+ m.message.role === "assistant" &&
622
+ m.message.parts.some((p) => p.type === "error"),
623
+ ),
624
+ 3000,
625
+ "pesan error di history",
626
+ );
627
+ const errMsg = msgs.find(
628
+ (m): m is Extract<ServerMessage, { type: "message" }> =>
629
+ m.type === "message" && m.message.role === "assistant",
630
+ );
631
+ expect(
632
+ errMsg?.message.parts.some((p) => p.type === "error" && typeof p.text === "string"),
633
+ ).toBe(true);
634
+ ws.close();
635
+
636
+ // Reattach: kegagalan tetap terlihat setelah reload (bukan hanya banner).
637
+ const msgs2: ServerMessage[] = [];
638
+ const ws2 = new WebSocket(`ws://127.0.0.1:${app.server.port}/ws`);
639
+ ws2.onmessage = (event) => {
640
+ msgs2.push(JSON.parse(String(event.data)) as ServerMessage);
641
+ };
642
+ await waitFor(() => ws2.readyState === WebSocket.OPEN, 3000, "koneksi WS kedua terbuka");
643
+ ws2.send(JSON.stringify({ type: "attach", sessionId }));
644
+ await waitFor(() => msgs2.some((m) => m.type === "history"), 3000, "history reattach");
645
+ const hist = msgs2.find((m): m is Extract<ServerMessage, { type: "history" }> => {
646
+ return m.type === "history";
647
+ });
648
+ expect(
649
+ hist?.messages.some((m) => m.role === "assistant" && m.parts.some((p) => p.type === "error")),
650
+ ).toBe(true);
651
+ ws2.close();
652
+ });
653
+
654
+ test("20.2: daftar Project/Session melalui API lengkap", async () => {
655
+ const projRes = await fetch(`${baseUrl()}/api/projects`);
656
+ expect(projRes.status).toBe(200);
657
+ const projBody = (await projRes.json()) as { projects: Project[] };
658
+ expect(projBody.projects.length).toBeGreaterThan(0);
659
+
660
+ const sessRes = await fetch(`${baseUrl()}/api/sessions`);
661
+ expect(sessRes.status).toBe(200);
662
+ });
663
+
664
+ test("agentType claude-code memakai backend Claude Code; tipe tak dikenal ditolak", async () => {
665
+ const projRes = await fetch(`${baseUrl()}/api/projects`, {
666
+ method: "POST",
667
+ headers: { "content-type": "application/json" },
668
+ body: JSON.stringify({ name: "demo2", path: "proj" }),
669
+ });
670
+ expect(projRes.status).toBe(409); // path sudah dipakai
671
+ // Buat path baru untuk project lain.
672
+ mkdirSync(path.join(root, "proj2"), { recursive: true });
673
+ const proj2 = await fetch(`${baseUrl()}/api/projects`, {
674
+ method: "POST",
675
+ headers: { "content-type": "application/json" },
676
+ body: JSON.stringify({ name: "demo2b", path: "proj2" }),
677
+ });
678
+ expect(proj2.status).toBe(201);
679
+ const body2 = (await proj2.json()) as { project: Project };
680
+
681
+ const sessRes = await fetch(`${baseUrl()}/api/sessions`, {
682
+ method: "POST",
683
+ headers: { "content-type": "application/json" },
684
+ body: JSON.stringify({ agentType: "codex", projectId: body2.project.id }),
685
+ });
686
+ expect(sessRes.status).toBe(400);
687
+ expect(((await sessRes.json()) as { error: string }).error).toBe("UNSUPPORTED_AGENT_TYPE");
688
+
689
+ const claudeRes = await fetch(`${baseUrl()}/api/sessions`, {
690
+ method: "POST",
691
+ headers: { "content-type": "application/json" },
692
+ body: JSON.stringify({ agentType: "claude-code", projectId: body2.project.id }),
693
+ });
694
+ expect(claudeRes.status).toBe(201);
695
+ const { session } = (await claudeRes.json()) as { session: Session };
696
+ expect(session.agentType).toBe("claude-code");
697
+ expect(session.ocSessionId).toBe(`ses_${body2.project.id}`);
698
+ });
699
+
700
+ test("body JSON tidak valid -> 400 INVALID_JSON", async () => {
701
+ const projRes = await fetch(`${baseUrl()}/api/projects`, {
702
+ method: "POST",
703
+ headers: { "content-type": "application/json" },
704
+ body: "{invalid json",
705
+ });
706
+ expect(projRes.status).toBe(400);
707
+ expect(((await projRes.json()) as { error: string }).error).toBe("INVALID_JSON");
708
+
709
+ const sessRes = await fetch(`${baseUrl()}/api/sessions`, {
710
+ method: "POST",
711
+ headers: { "content-type": "application/json" },
712
+ body: "bukan-json",
713
+ });
714
+ expect(sessRes.status).toBe(400);
715
+ expect(((await sessRes.json()) as { error: string }).error).toBe("INVALID_JSON");
716
+ });
717
+
718
+ test("GET /api/projects/:id/mcp & /skills -> daftar dari server Project; id tak dikenal -> 404", async () => {
719
+ mkdirSync(path.join(root, "proj-ext"), { recursive: true });
720
+ const proj = (await (
721
+ await fetch(`${baseUrl()}/api/projects`, {
722
+ method: "POST",
723
+ headers: { "content-type": "application/json" },
724
+ body: JSON.stringify({ name: "ext-proj", path: "proj-ext" }),
725
+ })
726
+ ).json()) as { project: Project };
727
+
728
+ const mcpRes = await fetch(`${baseUrl()}/api/projects/${proj.project.id}/mcp`);
729
+ expect(mcpRes.status).toBe(200);
730
+ expect(((await mcpRes.json()) as { mcp: { name: string }[] }).mcp[0]?.name).toBe("context7");
731
+
732
+ const skillsRes = await fetch(`${baseUrl()}/api/projects/${proj.project.id}/skills`);
733
+ expect(skillsRes.status).toBe(200);
734
+ expect(((await skillsRes.json()) as { skills: { source: string }[] }).skills[0]?.source).toBe(
735
+ "project",
736
+ );
737
+
738
+ expect((await fetch(`${baseUrl()}/api/projects/tidak-ada/mcp`)).status).toBe(404);
739
+ expect((await fetch(`${baseUrl()}/api/projects/tidak-ada/skills`)).status).toBe(404);
740
+ });
741
+
742
+ test("model: GET /api/projects/:id/models + create dengan model + PUT ganti model", async () => {
743
+ mkdirSync(path.join(root, "proj-model"), { recursive: true });
744
+ const proj = (await (
745
+ await fetch(`${baseUrl()}/api/projects`, {
746
+ method: "POST",
747
+ headers: { "content-type": "application/json" },
748
+ body: JSON.stringify({ name: "model-proj", path: "proj-model" }),
749
+ })
750
+ ).json()) as { project: Project };
751
+
752
+ // ---- daftar model tersedia ----
753
+ const modelsRes = await fetch(`${baseUrl()}/api/projects/${proj.project.id}/models`);
754
+ expect(modelsRes.status).toBe(200);
755
+ const modelsBody = (await modelsRes.json()) as {
756
+ models: { providerID: string; modelID: string; name: string }[];
757
+ };
758
+ expect(modelsBody.models[0]).toMatchObject({
759
+ providerID: "kcgcode",
760
+ modelID: "kiro/claude-opus-5",
761
+ });
762
+
763
+ // ---- create Session dengan model pilihan ----
764
+ const sess = (await (
765
+ await fetch(`${baseUrl()}/api/sessions`, {
766
+ method: "POST",
767
+ headers: { "content-type": "application/json" },
768
+ body: JSON.stringify({
769
+ agentType: "opencode",
770
+ projectId: proj.project.id,
771
+ model: { providerID: "kcgcode", modelID: "kiro/claude-opus-5" },
772
+ }),
773
+ })
774
+ ).json()) as { session: Session };
775
+ expect(sess.session.model).toEqual({
776
+ providerID: "kcgcode",
777
+ modelID: "kiro/claude-opus-5",
778
+ });
779
+
780
+ // ---- model tidak dikenal -> 400 MODEL_NOT_FOUND ----
781
+ const badRes = await fetch(`${baseUrl()}/api/sessions`, {
782
+ method: "POST",
783
+ headers: { "content-type": "application/json" },
784
+ body: JSON.stringify({
785
+ agentType: "opencode",
786
+ projectId: proj.project.id,
787
+ model: { providerID: "kcgcode", modelID: "tidak-ada" },
788
+ }),
789
+ });
790
+ expect(badRes.status).toBe(400);
791
+ expect(((await badRes.json()) as { error: string }).error).toBe("MODEL_NOT_FOUND");
792
+
793
+ // ---- ganti model via PUT ----
794
+ const putRes = await fetch(`${baseUrl()}/api/sessions/${sess.session.id}`, {
795
+ method: "PUT",
796
+ headers: { "content-type": "application/json" },
797
+ body: JSON.stringify({ model: null }),
798
+ });
799
+ expect(putRes.status).toBe(200);
800
+ const putBody = (await putRes.json()) as { session: Session };
801
+ expect(putBody.session.model).toBeNull();
802
+
803
+ // Session tak dikenal -> 404
804
+ const missing = await fetch(`${baseUrl()}/api/sessions/tidak-ada`, {
805
+ method: "PUT",
806
+ headers: { "content-type": "application/json" },
807
+ body: JSON.stringify({ model: null }),
808
+ });
809
+ expect(missing.status).toBe(404);
810
+ });
811
+
812
+ test("@file: GET /api/sessions/:id/files mencari via server headless", async () => {
813
+ mkdirSync(path.join(root, "proj-files"), { recursive: true });
814
+ const proj = (await (
815
+ await fetch(`${baseUrl()}/api/projects`, {
816
+ method: "POST",
817
+ headers: { "content-type": "application/json" },
818
+ body: JSON.stringify({ name: "files-proj", path: "proj-files" }),
819
+ })
820
+ ).json()) as { project: Project };
821
+
822
+ const sess = (await (
823
+ await fetch(`${baseUrl()}/api/sessions`, {
824
+ method: "POST",
825
+ headers: { "content-type": "application/json" },
826
+ body: JSON.stringify({ agentType: "opencode", projectId: proj.project.id }),
827
+ })
828
+ ).json()) as { session: Session };
829
+
830
+ // Query mencocokkan file dari fake client (availableFiles).
831
+ const found = await fetch(`${baseUrl()}/api/sessions/${sess.session.id}/files?q=App`);
832
+ expect(found.status).toBe(200);
833
+ const foundBody = (await found.json()) as { files: string[] };
834
+ expect(foundBody.files).toContain("src/App.tsx");
835
+
836
+ // Session tak dikenal -> 404.
837
+ const missing = await fetch(`${baseUrl()}/api/sessions/tidak-ada/files?q=x`);
838
+ expect(missing.status).toBe(404);
839
+ });
840
+
841
+ test("panel konteks: GET /api/sessions/:id/context memuat git, task list & perubahan file", async () => {
842
+ mkdirSync(path.join(root, "proj-ctx"), { recursive: true });
843
+ const proj = (await (
844
+ await fetch(`${baseUrl()}/api/projects`, {
845
+ method: "POST",
846
+ headers: { "content-type": "application/json" },
847
+ body: JSON.stringify({ name: "ctx-proj", path: "proj-ctx" }),
848
+ })
849
+ ).json()) as { project: Project };
850
+
851
+ const sess = (await (
852
+ await fetch(`${baseUrl()}/api/sessions`, {
853
+ method: "POST",
854
+ headers: { "content-type": "application/json" },
855
+ body: JSON.stringify({ agentType: "opencode", projectId: proj.project.id }),
856
+ })
857
+ ).json()) as { session: Session };
858
+
859
+ // Data tool berasal dari fake client Project ini (server headless-nya
860
+ // sudah dibuat saat Session dibuat).
861
+ const client = servers.clients.get(proj.project.id);
862
+ if (!client) throw new Error("fake client tidak ada");
863
+ client.todos = [
864
+ { content: "Tulis tes", status: "in_progress", priority: "high" },
865
+ { content: "Rapikan UI", status: "pending", priority: "medium" },
866
+ ];
867
+ client.fileChanges = [{ path: "src/App.tsx", added: 3, removed: 1, status: "modified" }];
868
+
869
+ const res = await fetch(`${baseUrl()}/api/sessions/${sess.session.id}/context`);
870
+ expect(res.status).toBe(200);
871
+ const body = (await res.json()) as { context: SessionContext };
872
+ expect(body.context.todos.map((t) => t.status)).toEqual(["in_progress", "pending"]);
873
+ expect(body.context.changes[0]?.path).toBe("src/App.tsx");
874
+ expect(body.context.changes[0]?.added).toBe(3);
875
+ expect(body.context.live).toBe(true);
876
+ // MCP server Project ikut dimuat (fake client: context7 connected).
877
+ expect(body.context.mcp).toEqual([{ name: "context7", status: "connected", error: null }]);
878
+ // Direktori project di tmpdir bukan repo git -> bagian git kosong, bukan error.
879
+ expect(body.context.git.branch).toBeNull();
880
+ expect(body.context.git.hasRemote).toBe(false);
881
+
882
+ // Session tak dikenal -> 404.
883
+ const missing = await fetch(`${baseUrl()}/api/sessions/tidak-ada/context`);
884
+ expect(missing.status).toBe(404);
885
+ });
886
+
887
+ test("hapus Project: Session ikut dibersihkan, server headless dihentikan, direktori utuh", async () => {
888
+ const dir = path.join(root, "proj-hapus");
889
+ mkdirSync(dir, { recursive: true });
890
+ const proj = (await (
891
+ await fetch(`${baseUrl()}/api/projects`, {
892
+ method: "POST",
893
+ headers: { "content-type": "application/json" },
894
+ body: JSON.stringify({ name: "hapus-proj", path: "proj-hapus" }),
895
+ })
896
+ ).json()) as { project: Project };
897
+
898
+ const sess = (await (
899
+ await fetch(`${baseUrl()}/api/sessions`, {
900
+ method: "POST",
901
+ headers: { "content-type": "application/json" },
902
+ body: JSON.stringify({ agentType: "opencode", projectId: proj.project.id }),
903
+ })
904
+ ).json()) as { session: Session };
905
+ const client = servers.clients.get(proj.project.id);
906
+ expect(client).toBeDefined();
907
+
908
+ const del = await fetch(`${baseUrl()}/api/projects/${proj.project.id}`, { method: "DELETE" });
909
+ expect(del.status).toBe(200);
910
+
911
+ // Session lokal + sesi remote opencode ikut terhapus.
912
+ expect(store.getSession(sess.session.id).ok).toBe(false);
913
+ expect(client?.deleteCalls).toContain(sess.session.ocSessionId ?? "");
914
+ // Server headless Project dihentikan (tidak lagi memakan memori).
915
+ expect(servers.stopped).toContain(proj.project.id);
916
+ // Project hilang dari daftar, tapi direktori kerjanya TIDAK dihapus.
917
+ expect(store.listProjects().some((p) => p.id === proj.project.id)).toBe(false);
918
+ expect(existsSync(dir)).toBe(true);
919
+ });
920
+
921
+ test("hapus Project tanpa Session -> 200; id tak dikenal -> 404", async () => {
922
+ mkdirSync(path.join(root, "proj-kosong"), { recursive: true });
923
+ const proj = (await (
924
+ await fetch(`${baseUrl()}/api/projects`, {
925
+ method: "POST",
926
+ headers: { "content-type": "application/json" },
927
+ body: JSON.stringify({ name: "kosong-proj", path: "proj-kosong" }),
928
+ })
929
+ ).json()) as { project: Project };
930
+
931
+ const del = await fetch(`${baseUrl()}/api/projects/${proj.project.id}`, { method: "DELETE" });
932
+ expect(del.status).toBe(200);
933
+
934
+ const missing = await fetch(`${baseUrl()}/api/projects/tidak-ada`, { method: "DELETE" });
935
+ expect(missing.status).toBe(404);
936
+ expect(((await missing.json()) as { error: string }).error).toBe("PROJECT_NOT_FOUND");
937
+ });
938
+
939
+ test("PATCH instructions Project: simpan, kosongkan, validasi, 404", async () => {
940
+ mkdirSync(path.join(root, "proj-instr"), { recursive: true });
941
+ const proj = (await (
942
+ await fetch(`${baseUrl()}/api/projects`, {
943
+ method: "POST",
944
+ headers: { "content-type": "application/json" },
945
+ body: JSON.stringify({ name: "instr-proj", path: "proj-instr" }),
946
+ })
947
+ ).json()) as { project: Project };
948
+ expect(proj.project.instructions).toBeNull();
949
+
950
+ const patch = (id: string, body: unknown) =>
951
+ fetch(`${baseUrl()}/api/projects/${id}`, {
952
+ method: "PATCH",
953
+ headers: { "content-type": "application/json" },
954
+ body: JSON.stringify(body),
955
+ });
956
+
957
+ const saved = await patch(proj.project.id, { instructions: " Reply in English. " });
958
+ expect(saved.status).toBe(200);
959
+ expect(((await saved.json()) as { project: Project }).project.instructions).toBe(
960
+ "Reply in English.",
961
+ );
962
+
963
+ // Tersimpan & ikut di GET daftar.
964
+ const list = (await (await fetch(`${baseUrl()}/api/projects`)).json()) as {
965
+ projects: Project[];
966
+ };
967
+ expect(list.projects.find((p) => p.id === proj.project.id)?.instructions).toBe(
968
+ "Reply in English.",
969
+ );
970
+
971
+ const cleared = await patch(proj.project.id, { instructions: " " });
972
+ expect(((await cleared.json()) as { project: Project }).project.instructions).toBeNull();
973
+
974
+ expect((await patch(proj.project.id, { instructions: 42 })).status).toBe(400);
975
+ expect((await patch(proj.project.id, { instructions: "x".repeat(20_001) })).status).toBe(400);
976
+ expect((await patch("tidak-ada", { instructions: "x" })).status).toBe(404);
977
+ });
978
+
979
+ test("skills.sh: search, audit, install ke folder Project (+ validasi & 404)", async () => {
980
+ mkdirSync(path.join(root, "proj-skills"), { recursive: true });
981
+ const proj = (await (
982
+ await fetch(`${baseUrl()}/api/projects`, {
983
+ method: "POST",
984
+ headers: { "content-type": "application/json" },
985
+ body: JSON.stringify({ name: "skills-proj", path: "proj-skills" }),
986
+ })
987
+ ).json()) as { project: Project };
988
+
989
+ const search = await fetch(`${baseUrl()}/api/skills/search?q=react`);
990
+ expect(search.status).toBe(200);
991
+ expect(((await search.json()) as { skills: { id: string }[] }).skills[0]?.id).toBe("a/b/c");
992
+ expect((await fetch(`${baseUrl()}/api/skills/search?q=a`)).status).toBe(400);
993
+
994
+ const audit = await fetch(`${baseUrl()}/api/skills/audit?source=a/b&skill=c`);
995
+ expect(audit.status).toBe(200);
996
+ expect((await fetch(`${baseUrl()}/api/skills/audit?source=bad&skill=c`)).status).toBe(400);
997
+
998
+ const install = (id: string, body: unknown) =>
999
+ fetch(`${baseUrl()}/api/projects/${id}/skills/install`, {
1000
+ method: "POST",
1001
+ headers: { "content-type": "application/json" },
1002
+ body: JSON.stringify(body),
1003
+ });
1004
+
1005
+ // Instalasi berjalan sebagai job: 202 + job, lalu status & log via polling.
1006
+ const ok = await install(proj.project.id, { source: "a/b", skill: "c" });
1007
+ expect(ok.status).toBe(202);
1008
+ const { job } = (await ok.json()) as { job: { id: string; status: string } };
1009
+ expect(job.status).toBe("running");
1010
+ await app.skillInstalls.settled(job.id);
1011
+
1012
+ const polled = await fetch(`${baseUrl()}/api/skills/installs/${job.id}?from=0`);
1013
+ expect(polled.status).toBe(200);
1014
+ const log = (await polled.json()) as {
1015
+ job: { status: string; refreshed: boolean };
1016
+ lines: string[];
1017
+ };
1018
+ expect(log.job.status).toBe("succeeded");
1019
+ // Server headless Project belum hidup -> tidak perlu refresh.
1020
+ expect(log.job.refreshed).toBe(true);
1021
+ expect(log.lines).toContain("installing c");
1022
+ expect(installCalls).toContainEqual([proj.project.path, "a/b", "c"]);
1023
+
1024
+ const all = (await (await fetch(`${baseUrl()}/api/skills/installs`)).json()) as {
1025
+ jobs: { id: string }[];
1026
+ };
1027
+ expect(all.jobs.map((j) => j.id)).toContain(job.id);
1028
+ expect((await fetch(`${baseUrl()}/api/skills/installs/tidak-ada`)).status).toBe(404);
1029
+
1030
+ // Kegagalan CLI -> job failed (bukan HTTP error).
1031
+ const boom = (await (
1032
+ await install(proj.project.id, { source: "a/b", skill: "boom" })
1033
+ ).json()) as { job: { id: string } };
1034
+ await app.skillInstalls.settled(boom.job.id);
1035
+ const boomLog = (await (
1036
+ await fetch(`${baseUrl()}/api/skills/installs/${boom.job.id}`)
1037
+ ).json()) as { job: { status: string; error: string } };
1038
+ expect(boomLog.job.status).toBe("failed");
1039
+ expect(boomLog.job.error).toBe("SKILL_INSTALL_FAILED");
1040
+
1041
+ expect((await install(proj.project.id, { source: 1, skill: "c" })).status).toBe(400);
1042
+ expect((await install("tidak-ada", { source: "a/b", skill: "c" })).status).toBe(404);
1043
+ });
1044
+ });