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,677 @@
1
+ /**
2
+ * Unit test `websocket-gateway.ts` (versi headless).
3
+ *
4
+ * - attach: history berisi `messages` + `prompts` pending (4.1, 4.4);
5
+ * Session tak dikenal -> error + close (4.3).
6
+ * - input / prompt_response: diteruskan ke Session_Manager (async); error
7
+ * domain -> pesan `error` ke Client pengirim.
8
+ * - notify*: broadcast ke seluruh subscriber Session (5.1, 5.2); kegagalan
9
+ * `send` satu Client tidak menghentikan Client lain (5.3).
10
+ *
11
+ * `Subscriber` (ws.send) dan `SessionManager` di-mock; store `bun:sqlite`
12
+ * asli sesuai batasan mocking `design.md`.
13
+ */
14
+ import { expect, test } from "bun:test";
15
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import path from "node:path";
18
+ import fc from "fast-check";
19
+ import { openSessionStore, type SessionStore } from "../../../db";
20
+ import type { InteractivePrompt, PromptResponse, SessionMessage } from "../../../types";
21
+ import { ErrorCodes, type ServerMessage } from "../../../ws-protocol";
22
+ import type { SessionManager } from "../session-manager";
23
+ import {
24
+ createWebSocketGateway,
25
+ type Subscriber,
26
+ type WebSocketGateway,
27
+ } from "../websocket-gateway";
28
+ import { dispatchClientMessage } from "../ws-transport";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Mock Subscriber (ws.send)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ interface MockSub extends Subscriber {
35
+ id: string;
36
+ sent: ServerMessage[];
37
+ closed: boolean;
38
+ failOnSend: boolean;
39
+ }
40
+
41
+ function makeSub(id: string, failOnSend = false): MockSub {
42
+ const sent: ServerMessage[] = [];
43
+ const sub: MockSub = {
44
+ id,
45
+ sent,
46
+ closed: false,
47
+ failOnSend,
48
+ send(msg: ServerMessage) {
49
+ if (sub.failOnSend) throw new Error(`mock send failure (${id})`);
50
+ sent.push(msg);
51
+ },
52
+ close() {
53
+ sub.closed = true;
54
+ },
55
+ };
56
+ return sub;
57
+ }
58
+
59
+ type HistoryMsg = Extract<ServerMessage, { type: "history" }>;
60
+
61
+ function makeMessage(sessionId: string, id: string, role: "user" | "assistant"): SessionMessage {
62
+ return {
63
+ id,
64
+ sessionId,
65
+ role,
66
+ parts: [{ type: "text", text: `isi-${id}` }],
67
+ createdAt: 0,
68
+ };
69
+ }
70
+
71
+ function makePrompt(
72
+ sessionId: string,
73
+ id: string,
74
+ kind: "permission" | "question",
75
+ ): InteractivePrompt {
76
+ return {
77
+ id,
78
+ sessionId,
79
+ kind,
80
+ type: kind === "permission" ? "confirmation" : "menu",
81
+ title: kind === "permission" ? "bash:ls" : "Pilih",
82
+ options:
83
+ kind === "permission"
84
+ ? null
85
+ : [
86
+ { label: "A", description: null },
87
+ { label: "B", description: null },
88
+ ],
89
+ status: "pending",
90
+ createdAt: 0,
91
+ resolvedAt: null,
92
+ };
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Mock SessionManager
97
+ // ---------------------------------------------------------------------------
98
+
99
+ interface FakeSessionManager extends SessionManager {
100
+ inputs: [string, string][];
101
+ promptReplies: [string, string, PromptResponse][];
102
+ stops: string[];
103
+ interrupts: string[];
104
+ inputResult: { ok: boolean; error?: string };
105
+ resolveResult: { ok: boolean; error?: string };
106
+ stopResult: { ok: boolean; error?: string };
107
+ interruptResult: { ok: boolean; error?: string };
108
+ }
109
+ function makeFakeSessionManager(): FakeSessionManager {
110
+ const sm: FakeSessionManager = {
111
+ inputs: [],
112
+ promptReplies: [],
113
+ stops: [],
114
+ interrupts: [],
115
+ inputResult: { ok: true },
116
+ resolveResult: { ok: true },
117
+ stopResult: { ok: true },
118
+ interruptResult: { ok: true },
119
+ async createSession() {
120
+ return { ok: false, error: "not-used" };
121
+ },
122
+ listSessions: () => [],
123
+ getSession: () => ({ ok: false, error: "SESSION_NOT_FOUND" }),
124
+ stopSession(sessionId) {
125
+ sm.stops.push(sessionId);
126
+ return sm.stopResult;
127
+ },
128
+ interruptSession(sessionId) {
129
+ sm.interrupts.push(sessionId);
130
+ return sm.interruptResult;
131
+ },
132
+ async resumeSession() {
133
+ return { ok: false, error: "not-used" };
134
+ },
135
+ async deleteSession() {
136
+ return { ok: false, error: "not-used" };
137
+ },
138
+ async releaseProject() {
139
+ return { ok: false, error: "not-used" };
140
+ },
141
+ async findFiles() {
142
+ return { ok: false, error: "not-used" };
143
+ },
144
+ async sessionContext() {
145
+ return { ok: false, error: "not-used" };
146
+ },
147
+ async listModels() {
148
+ return { ok: false, error: "not-used" };
149
+ },
150
+ async listAgents() {
151
+ return { ok: false, error: "not-used" };
152
+ },
153
+ async listMcp() {
154
+ return { ok: false, error: "not-used" };
155
+ },
156
+ async listSkills() {
157
+ return { ok: false, error: "not-used" };
158
+ },
159
+ async refreshSkills() {
160
+ return { ok: false, error: "not-used" };
161
+ },
162
+ hasPendingSkillRefresh() {
163
+ return false;
164
+ },
165
+ setSessionModel() {
166
+ return { ok: false, error: "not-used" };
167
+ },
168
+ setSessionAgent() {
169
+ return { ok: false, error: "not-used" };
170
+ },
171
+ async sendFreeTextInput(sessionId, text) {
172
+ sm.inputs.push([sessionId, text]);
173
+ return sm.inputResult;
174
+ },
175
+ async resolvePrompt(sessionId, promptId, response) {
176
+ sm.promptReplies.push([sessionId, promptId, response]);
177
+ return sm.resolveResult;
178
+ },
179
+ reconcileOnStartup: () => {},
180
+ async shutdown() {},
181
+ };
182
+ return sm;
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Harness
187
+ // ---------------------------------------------------------------------------
188
+
189
+ interface Harness {
190
+ store: SessionStore;
191
+ sm: FakeSessionManager;
192
+ gw: WebSocketGateway;
193
+ close(): void;
194
+ }
195
+
196
+ function freshHarness(): Harness {
197
+ const store = openSessionStore(":memory:");
198
+ const root = mkdtempSync(path.join(tmpdir(), "kcg-wsg2-"));
199
+ const cwd = path.join(root, "proj");
200
+ mkdirSync(cwd, { recursive: true });
201
+ store.insertProject({ id: "p1", name: "proj", path: cwd, createdAt: 1 });
202
+ store.insertSession({
203
+ id: "s1",
204
+ projectId: "p1",
205
+ agentType: "opencode",
206
+ cwd,
207
+ status: "running",
208
+ ocSessionId: "ses_1",
209
+ model: null,
210
+ agent: null,
211
+ title: null,
212
+ createdAt: 0,
213
+ updatedAt: 0,
214
+ });
215
+
216
+ const sm = makeFakeSessionManager();
217
+ const gw = createWebSocketGateway({ store, sessionManager: sm });
218
+
219
+ return {
220
+ store,
221
+ sm,
222
+ gw,
223
+ close() {
224
+ store.close();
225
+ rmSync(root, { recursive: true, force: true });
226
+ },
227
+ };
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Attach
232
+ // ---------------------------------------------------------------------------
233
+
234
+ test("attach: history berisi messages terurut + prompts pending (4.1, 4.4)", () => {
235
+ const h = freshHarness();
236
+ try {
237
+ h.store.insertMessage(makeMessage("s1", "m1", "user"));
238
+ h.store.insertMessage(makeMessage("s1", "m2", "assistant"));
239
+ h.store.insertPrompt(makePrompt("s1", "pr1", "permission"));
240
+ h.store.insertPrompt(makePrompt("s1", "pr2", "question"));
241
+ h.store.updatePromptStatus("pr2", "resolved", 5);
242
+
243
+ const sub = makeSub("c1");
244
+ h.gw.attach(sub, "s1");
245
+
246
+ const hist = sub.sent.find((m): m is HistoryMsg => m.type === "history");
247
+ expect(hist).toBeDefined();
248
+ if (hist) {
249
+ expect(hist.messages.map((m) => m.id)).toEqual(["m1", "m2"]);
250
+ expect(hist.prompts.map((p) => p.id)).toEqual(["pr1"]); // hanya pending
251
+ }
252
+ expect(h.gw.subscriberCount("s1")).toBe(1);
253
+ } finally {
254
+ h.close();
255
+ }
256
+ });
257
+
258
+ test("attach session tak dikenal -> error SESSION_NOT_FOUND + close, tanpa history (4.3)", () => {
259
+ fc.assert(
260
+ // minLength 1: `""` adalah mode daftar (tanpa error), bukan id tak dikenal.
261
+ fc.property(fc.string({ minLength: 1, maxLength: 20 }), (unknownId) => {
262
+ const h = freshHarness();
263
+ try {
264
+ const sub = makeSub("c1");
265
+ h.gw.attach(sub, unknownId);
266
+ const err = sub.sent.find((m): m is Extract<ServerMessage, { type: "error" }> => {
267
+ return m.type === "error";
268
+ });
269
+ expect(err).toBeDefined();
270
+ if (err) expect(err.code).toBe(ErrorCodes.SESSION_NOT_FOUND);
271
+ expect(sub.closed).toBe(true);
272
+ expect(sub.sent.some((m) => m.type === "history")).toBe(false);
273
+ expect(h.gw.subscriberCount(unknownId)).toBe(0);
274
+ } finally {
275
+ h.close();
276
+ }
277
+ }),
278
+ { numRuns: 100 },
279
+ );
280
+ });
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Wire pesan Client -> Session_Manager
284
+ // ---------------------------------------------------------------------------
285
+
286
+ test("input diteruskan; input invalid -> error INVALID_TEXT", async () => {
287
+ const h = freshHarness();
288
+ try {
289
+ const sub = makeSub("c1");
290
+ h.gw.attach(sub, "s1");
291
+
292
+ h.gw.input(sub, "s1", "hello");
293
+ await Bun.sleep(0);
294
+ expect(h.sm.inputs).toEqual([["s1", "hello"]]);
295
+
296
+ h.sm.inputResult = { ok: false, error: "TEXT_EMPTY" };
297
+ h.gw.input(sub, "s1", " ");
298
+ await Bun.sleep(0);
299
+ const err = sub.sent.find((m): m is Extract<ServerMessage, { type: "error" }> => {
300
+ return m.type === "error";
301
+ });
302
+ expect(err).toBeDefined();
303
+ if (err) expect(err.code).toBe(ErrorCodes.INVALID_TEXT);
304
+ } finally {
305
+ h.close();
306
+ }
307
+ });
308
+
309
+ test("prompt_response sukses -> notify prompt_resolved; gagal -> error", async () => {
310
+ const h = freshHarness();
311
+ try {
312
+ h.store.insertPrompt(makePrompt("s1", "pr1", "permission"));
313
+ const sub = makeSub("c1");
314
+ h.gw.attach(sub, "s1");
315
+
316
+ h.gw.promptResponse(sub, "s1", "pr1", "approve");
317
+ await Bun.sleep(0);
318
+ expect(h.sm.promptReplies).toEqual([["s1", "pr1", "approve"]]);
319
+ const resolved = sub.sent.filter(
320
+ (m): m is Extract<ServerMessage, { type: "prompt_resolved" }> => m.type === "prompt_resolved",
321
+ );
322
+ expect(resolved).toHaveLength(1);
323
+ expect(resolved[0]?.promptId).toBe("pr1");
324
+
325
+ // Gagal -> error berkode PROMPT_FAILED (ditampilkan DI kartu oleh client)
326
+ // dengan pesan ramah, tanpa notif resolved.
327
+ h.sm.resolveResult = { ok: false, error: "PROMPT_NOT_FOUND" };
328
+ h.gw.promptResponse(sub, "s1", "pr2", "approve");
329
+ await Bun.sleep(0);
330
+ const err = sub.sent.find((m): m is Extract<ServerMessage, { type: "error" }> => {
331
+ return m.type === "error";
332
+ });
333
+ expect(err).toBeDefined();
334
+ if (err) {
335
+ expect(err.code).toBe(ErrorCodes.PROMPT_FAILED);
336
+ expect(err.message).toContain("no longer waiting");
337
+ }
338
+ expect(sub.sent.filter((m) => m.type === "prompt_resolved")).toHaveLength(1);
339
+ } finally {
340
+ h.close();
341
+ }
342
+ });
343
+
344
+ test("prompt_response kedaluwarsa (PROMPT_EXPIRED) -> kartu dihapus + error PROMPT_EXPIRED", async () => {
345
+ const h = freshHarness();
346
+ try {
347
+ h.store.insertPrompt(makePrompt("s1", "pr_gone", "question"));
348
+ const sub = makeSub("c1");
349
+ h.gw.attach(sub, "s1");
350
+ sub.sent.length = 0;
351
+
352
+ h.sm.resolveResult = { ok: false, error: "PROMPT_EXPIRED" };
353
+ h.gw.promptResponse(sub, "s1", "pr_gone", { option: "A" });
354
+ await Bun.sleep(0);
355
+
356
+ // Kartu dihapus di klien (prompt_resolved) supaya session tidak terkunci.
357
+ const resolved = sub.sent.filter(
358
+ (m): m is Extract<ServerMessage, { type: "prompt_resolved" }> => m.type === "prompt_resolved",
359
+ );
360
+ expect(resolved.map((m) => m.promptId)).toEqual(["pr_gone"]);
361
+ const err = sub.sent.find(
362
+ (m): m is Extract<ServerMessage, { type: "error" }> => m.type === "error",
363
+ );
364
+ expect(err?.code).toBe(ErrorCodes.PROMPT_EXPIRED);
365
+ expect(err?.message).toContain("stopped waiting");
366
+ } finally {
367
+ h.close();
368
+ }
369
+ });
370
+
371
+ test("prompt_response multi-select { options } -> diteruskan utuh ke manager", async () => {
372
+ const h = freshHarness();
373
+ try {
374
+ h.store.insertPrompt(makePrompt("s1", "que_m", "question"));
375
+ const sub = makeSub("c1");
376
+ h.gw.attach(sub, "s1");
377
+
378
+ h.gw.promptResponse(sub, "s1", "que_m", { options: ["A", "B"] });
379
+ await Bun.sleep(0);
380
+ expect(h.sm.promptReplies).toEqual([["s1", "que_m", { options: ["A", "B"] }]]);
381
+ } finally {
382
+ h.close();
383
+ }
384
+ });
385
+
386
+ test("stop diteruskan; session tidak running -> error SESSION_NOT_RUNNING", () => {
387
+ const h = freshHarness();
388
+ try {
389
+ const sub = makeSub("c1");
390
+ h.gw.attach(sub, "s1");
391
+
392
+ h.gw.stop(sub, "s1");
393
+ expect(h.sm.stops).toEqual(["s1"]);
394
+
395
+ h.sm.stopResult = { ok: false, error: "SESSION_NOT_RUNNING" };
396
+ h.gw.stop(sub, "s1");
397
+ const err = sub.sent.find((m): m is Extract<ServerMessage, { type: "error" }> => {
398
+ return m.type === "error";
399
+ });
400
+ expect(err).toBeDefined();
401
+ if (err) expect(err.code).toBe(ErrorCodes.SESSION_NOT_RUNNING);
402
+ } finally {
403
+ h.close();
404
+ }
405
+ });
406
+
407
+ test("interrupt diteruskan ke interruptSession; gagal -> error dikirim", () => {
408
+ const h = freshHarness();
409
+ try {
410
+ const sub = makeSub("c1");
411
+ h.gw.attach(sub, "s1");
412
+
413
+ h.gw.interrupt(sub, "s1");
414
+ expect(h.sm.interrupts).toEqual(["s1"]);
415
+
416
+ h.sm.interruptResult = { ok: false, error: "SESSION_NOT_FOUND" };
417
+ h.gw.interrupt(sub, "s1");
418
+ const err = sub.sent.find((m): m is Extract<ServerMessage, { type: "error" }> => {
419
+ return m.type === "error";
420
+ });
421
+ expect(err).toBeDefined();
422
+ if (err) expect(err.code).toBe(ErrorCodes.SESSION_NOT_FOUND);
423
+ } finally {
424
+ h.close();
425
+ }
426
+ });
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // Broadcast & isolasi kegagalan
430
+ // ---------------------------------------------------------------------------
431
+
432
+ test("notify*: broadcast ke seluruh subscriber Session; bukan ke Session lain", () => {
433
+ const h = freshHarness();
434
+ try {
435
+ // Session kedua untuk membuktikan isolasi per-Session.
436
+ h.store.insertSession({
437
+ id: "s2",
438
+ projectId: "p1",
439
+ agentType: "opencode",
440
+ cwd: "/x",
441
+ status: "running",
442
+ ocSessionId: "ses_2",
443
+ model: null,
444
+ agent: null,
445
+ title: null,
446
+ createdAt: 0,
447
+ updatedAt: 0,
448
+ });
449
+
450
+ const subs = [makeSub("c1"), makeSub("c2"), makeSub("other")] as const;
451
+ h.gw.attach(subs[0], "s1");
452
+ h.gw.attach(subs[1], "s1");
453
+ h.gw.attach(subs[2], "s2");
454
+ h.gw.notifyMessage("s1", makeMessage("s1", "m1", "assistant"));
455
+ h.gw.notifyMessagePart("s1", "m1", { type: "text", id: "prt_1", text: "stream" });
456
+ h.gw.notifyPrompt("s1", makePrompt("s1", "pr1", "permission"));
457
+ h.gw.notifySessionStatus("s1", "stopped");
458
+ h.gw.notifyTurnActive("s1", true);
459
+ h.gw.notifyPromptResolved("s1", "pr1");
460
+ h.gw.notifyError("s1", "AGENT_ERROR", "gagal");
461
+
462
+ for (const s of [subs[0], subs[1]]) {
463
+ expect(s.sent.filter((m) => m.type === "message")).toHaveLength(1);
464
+ expect(s.sent.filter((m) => m.type === "message_part")).toHaveLength(1);
465
+ const part = s.sent.find((m): m is Extract<ServerMessage, { type: "message_part" }> => {
466
+ return m.type === "message_part";
467
+ });
468
+ if (part) {
469
+ expect(part.messageId).toBe("m1");
470
+ expect(part.part).toEqual({ type: "text", id: "prt_1", text: "stream" });
471
+ }
472
+ const turn = s.sent.find(
473
+ (m): m is Extract<ServerMessage, { type: "turn_active" }> => m.type === "turn_active",
474
+ );
475
+ expect(turn).toBeDefined();
476
+ if (turn) expect(turn.active).toBe(true);
477
+ expect(s.sent.filter((m) => m.type === "prompt")).toHaveLength(1);
478
+ expect(s.sent.filter((m) => m.type === "session_status")).toHaveLength(1);
479
+ expect(s.sent.filter((m) => m.type === "prompt_resolved")).toHaveLength(1);
480
+ expect(s.sent.filter((m) => m.type === "error")).toHaveLength(1);
481
+ }
482
+ // Subscriber Session lain tidak menerima apa pun.
483
+ expect(subs[2].sent.filter((m) => m.type === "message" || m.type === "prompt")).toHaveLength(0);
484
+ } finally {
485
+ h.close();
486
+ }
487
+ });
488
+
489
+ test("Property 15/16: broadcast konsisten ke banyak client; client gagal diisolasi (5.3)", () => {
490
+ fc.assert(
491
+ fc.property(
492
+ fc.integer({ min: 2, max: 6 }),
493
+ fc.array(fc.boolean(), { minLength: 2, maxLength: 6 }),
494
+ fc.array(fc.string({ maxLength: 20 }), { maxLength: 10 }),
495
+ (clientCount, failFlags, msgIds) => {
496
+ const h = freshHarness();
497
+ try {
498
+ const flags = Array.from(
499
+ { length: clientCount },
500
+ (_, i) => failFlags[i % failFlags.length],
501
+ );
502
+ const healthy = flags.filter((f) => !f).length;
503
+ const subs = flags.map((f, i) => makeSub(`c${i}`, f));
504
+ for (const s of subs) h.gw.attach(s, "s1");
505
+ // Client yang send history gagal tidak terdaftar.
506
+ expect(h.gw.subscriberCount("s1")).toBe(healthy);
507
+
508
+ msgIds.forEach((id, i) => {
509
+ h.gw.notifyMessage("s1", makeMessage("s1", `${id}-${i}`, "assistant"));
510
+ });
511
+
512
+ flags.forEach((f, i) => {
513
+ const s = subs[i];
514
+ if (!s) return;
515
+ if (f) {
516
+ expect(s.sent.filter((m) => m.type === "message")).toHaveLength(0);
517
+ } else {
518
+ const msgs = s.sent.filter(
519
+ (m): m is Extract<ServerMessage, { type: "message" }> => m.type === "message",
520
+ );
521
+ expect(msgs).toHaveLength(msgIds.length);
522
+ expect(msgs.map((m) => m.message.id)).toEqual(msgIds.map((id, j) => `${id}-${j}`));
523
+ }
524
+ });
525
+ expect(h.gw.subscriberCount("s1")).toBe(healthy);
526
+ } finally {
527
+ h.close();
528
+ }
529
+ },
530
+ ),
531
+ { numRuns: 100 },
532
+ );
533
+ });
534
+
535
+ // ---------------------------------------------------------------------------
536
+ // Mode daftar (attach "") + broadcast global
537
+ // ---------------------------------------------------------------------------
538
+
539
+ test('attach "" -> tanpa history; session_title di-broadcast ke semua koneksi', () => {
540
+ const h = freshHarness();
541
+ try {
542
+ const listSub = makeSub("list");
543
+ const listSub2 = makeSub("list2");
544
+ const chatSub = makeSub("chat");
545
+ h.gw.attach(listSub, "");
546
+ h.gw.attach(listSub2, "");
547
+ h.gw.attach(chatSub, "s1");
548
+
549
+ // Mode daftar: terdaftar (terbukti dari broadcast di bawah), tanpa
550
+ // history dan tanpa error.
551
+ expect(listSub.closed).toBe(false);
552
+ expect(listSub.sent).toHaveLength(0);
553
+
554
+ h.gw.notifySessionTitle("s1", "Judul baru");
555
+ expect(listSub.sent).toEqual([{ type: "session_title", sessionId: "s1", title: "Judul baru" }]);
556
+ expect(listSub2.sent).toEqual([
557
+ { type: "session_title", sessionId: "s1", title: "Judul baru" },
558
+ ]);
559
+ // Koneksi chat (attach satu Session) juga menerima — judul berguna di mana pun.
560
+ expect(chatSub.sent.filter((m) => m.type === "session_title")).toHaveLength(1);
561
+ } finally {
562
+ h.close();
563
+ }
564
+ });
565
+
566
+ test("isi percakapan (message) tidak sampai ke koneksi mode daftar", () => {
567
+ const h = freshHarness();
568
+ try {
569
+ const listSub = makeSub("list");
570
+ h.gw.attach(listSub, "");
571
+ h.gw.notifyMessage("s1", makeMessage("s1", "m1", "assistant"));
572
+ expect(listSub.sent).toHaveLength(0);
573
+ } finally {
574
+ h.close();
575
+ }
576
+ });
577
+
578
+ test("data_changed sampai ke semua koneksi (buat/hapus di perangkat lain)", () => {
579
+ const h = freshHarness();
580
+ try {
581
+ const listSub = makeSub("list");
582
+ h.gw.attach(listSub, "");
583
+ const other = makeSub("other");
584
+ h.gw.attach(other, "s1");
585
+ other.sent.length = 0;
586
+
587
+ h.gw.notifyDataChanged();
588
+
589
+ expect(listSub.sent).toEqual([{ type: "data_changed" }]);
590
+ expect(other.sent).toEqual([{ type: "data_changed" }]);
591
+ } finally {
592
+ h.close();
593
+ }
594
+ });
595
+
596
+ test("session_status & session_deleted sampai ke semua koneksi (sidebar realtime)", () => {
597
+ const h = freshHarness();
598
+ try {
599
+ const listSub = makeSub("list");
600
+ h.gw.attach(listSub, "");
601
+ const other = makeSub("other");
602
+ h.gw.attach(other, "s1");
603
+ other.sent.length = 0;
604
+
605
+ h.gw.notifySessionStatus("s1", "stopped");
606
+ h.gw.notifySessionDeleted("s1");
607
+
608
+ // Koneksi mode daftar (sidebar / daftar Session) ikut menerima.
609
+ expect(listSub.sent).toEqual([
610
+ { type: "session_status", sessionId: "s1", status: "stopped" },
611
+ { type: "session_deleted", sessionId: "s1" },
612
+ ]);
613
+ // Koneksi yang attach Session itu tetap menerima seperti biasa.
614
+ expect(other.sent.map((m) => m.type)).toEqual(["session_status", "session_deleted"]);
615
+ } finally {
616
+ h.close();
617
+ }
618
+ });
619
+
620
+ // ---------------------------------------------------------------------------
621
+ // Dispatch pesan Client
622
+ // ---------------------------------------------------------------------------
623
+
624
+ test("dispatchClientMessage mengarahkan attach/input/prompt_response/stop/error", async () => {
625
+ const h = freshHarness();
626
+ try {
627
+ const sub = makeSub("d1");
628
+ dispatchClientMessage(h.gw, sub, JSON.stringify({ type: "attach", sessionId: "s1" }));
629
+ expect(sub.sent.some((m) => m.type === "history")).toBe(true);
630
+
631
+ dispatchClientMessage(
632
+ h.gw,
633
+ sub,
634
+ JSON.stringify({ type: "input", sessionId: "s1", text: "halo" }),
635
+ );
636
+ await Bun.sleep(0);
637
+ expect(h.sm.inputs).toEqual([["s1", "halo"]]);
638
+
639
+ dispatchClientMessage(
640
+ h.gw,
641
+ sub,
642
+ JSON.stringify({
643
+ type: "prompt_response",
644
+ sessionId: "s1",
645
+ promptId: "p",
646
+ response: "approve",
647
+ }),
648
+ );
649
+ await Bun.sleep(0);
650
+ expect(h.sm.promptReplies).toEqual([["s1", "p", "approve"]]);
651
+
652
+ dispatchClientMessage(h.gw, sub, JSON.stringify({ type: "stop", sessionId: "s1" }));
653
+ expect(h.sm.stops).toEqual(["s1"]);
654
+
655
+ dispatchClientMessage(h.gw, sub, JSON.stringify({ type: "interrupt", sessionId: "s1" }));
656
+ expect(h.sm.interrupts).toEqual(["s1"]);
657
+
658
+ // interrupt tanpa sessionId -> INVALID_MESSAGE.
659
+ dispatchClientMessage(h.gw, makeSub("d4"), JSON.stringify({ type: "interrupt" }));
660
+ expect(h.sm.interrupts).toEqual(["s1"]);
661
+
662
+ // JSON rusak / tipe tak dikenal
663
+ const sub2 = makeSub("d2");
664
+ dispatchClientMessage(h.gw, sub2, "bukan-json");
665
+ expect(sub2.sent.some((m) => m.type === "error")).toBe(true);
666
+
667
+ const sub3 = makeSub("d3");
668
+ dispatchClientMessage(h.gw, sub3, JSON.stringify({ type: "bogus" }));
669
+ const err = sub3.sent.find((m): m is Extract<ServerMessage, { type: "error" }> => {
670
+ return m.type === "error";
671
+ });
672
+ expect(err).toBeDefined();
673
+ if (err) expect(err.code).toBe("UNKNOWN_MESSAGE_TYPE");
674
+ } finally {
675
+ h.close();
676
+ }
677
+ });