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,3509 @@
1
+ /**
2
+ * Unit & property test `session-manager.ts` (versi headless).
3
+ *
4
+ * Session_Manager kini mengorkestrasi server headless opencode (bukan PTY):
5
+ * - `createSession` async: validasi -> ensure server -> POST /session.
6
+ * - `sendFreeTextInput` async: echo pesan user -> POST message -> balasan.
7
+ * - Interactive_Prompt dari event SSE `permission.asked` / `question.asked`.
8
+ * - `resolvePrompt` -> reply permission/question, bukan menulis `y\n` ke PTY.
9
+ *
10
+ * Mocking boundary: `OpenCodeServerManager` + `OpenCodeClient` di-mock
11
+ * seluruhnya (sesuai batasan mocking `design.md`); `bun:sqlite` asli.
12
+ */
13
+ import { expect, test } from "bun:test";
14
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import path from "node:path";
17
+ import fc from "fast-check";
18
+ import type { SessionStore } from "../../../db";
19
+ import { openSessionStore } from "../../../db";
20
+ import type {
21
+ AgentType,
22
+ InteractivePrompt,
23
+ MessagePart,
24
+ Project,
25
+ SessionMessage,
26
+ SessionStatus,
27
+ } from "../../../types";
28
+ import { type AttachmentManager, createAttachmentManager } from "../attachments";
29
+ import {
30
+ type AgentOption,
31
+ type ModelOption,
32
+ OC_REQUEST_GONE,
33
+ type OpenCodeClient,
34
+ type OpenCodeEvent,
35
+ type OpenCodeFileChange,
36
+ type OpenCodePermissionReply,
37
+ type OpenCodeTodo,
38
+ } from "../opencode-client";
39
+ import type { OpenCodeServerHandle, OpenCodeServerManager } from "../opencode-server";
40
+ import {
41
+ type CreateSessionRequest,
42
+ createSessionManager,
43
+ type SessionManager,
44
+ } from "../session-manager";
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Mock OpenCodeClient
48
+ // ---------------------------------------------------------------------------
49
+
50
+ interface FakeClient extends OpenCodeClient {
51
+ /** Calls tercatat: createSession, sendMessage, replyPermission, dst. */
52
+ calls: string[];
53
+ sendMessageResult: { ok: boolean; error?: string };
54
+ promptAsyncResult: { ok: boolean; error?: string };
55
+ /**
56
+ * Request id yang sudah "dilupakan" opencode — reply/reject untuk id ini
57
+ * membalas `OC_REQUEST_GONE` (404 asli dari server).
58
+ */
59
+ goneRequests: Set<string>;
60
+ createSessionResult: { ok: boolean; error?: string; id?: string };
61
+ /** Hasil getSession — false mensimulasikan oc session tidak dikenal server. */
62
+ getSessionResult: { ok: boolean; error?: string };
63
+ /** Hasil listModels — dipakai validasi model saat createSession. */
64
+ listModelsResult: { ok: boolean; error?: string };
65
+ /** Hasil deleteSession — false mensimulasikan server headless menolak. */
66
+ deleteSessionResult: { ok: boolean; error?: string };
67
+ /** Hasil findFiles — dipakai autocomplete @file. */
68
+ findFilesResult: { ok: boolean; error?: string };
69
+ /** Task list & perubahan file untuk panel konteks Session. */
70
+ todos: OpenCodeTodo[];
71
+ fileChanges: OpenCodeFileChange[];
72
+ /** Daftar file yang "tersedia" untuk findFiles. */
73
+ availableFiles: string[];
74
+ /** Referensi file yang dikirim ke tiap promptAsync (kosong = tanpa file). */
75
+ promptFilesCalls: { filename: string; mime: string; url: string }[][];
76
+ /** Model yang tersedia (default: satu provider dengan satu model). */
77
+ availableModels: ModelOption[];
78
+ /** Model yang diterima tiap panggilan promptAsync (null = default). */
79
+ promptModels: (string | null)[];
80
+ /** Agent yang tersedia untuk listAgents (default: build + plan). */
81
+ availableAgents: AgentOption[];
82
+ /** Agent yang diterima tiap panggilan promptAsync (null = default). */
83
+ promptAgents: (string | null)[];
84
+ /** `system` (custom instruction Project) tiap promptAsync (null = tanpa). */
85
+ promptSystems: (string | null)[];
86
+ /** Antrean jeda simulasi sebelum tiap sendMessage selesai (ms) — uji race. */
87
+ sendDelays?: number[];
88
+ emit(ev: OpenCodeEvent): void;
89
+ eventCb: ((ev: OpenCodeEvent) => void) | null;
90
+ }
91
+
92
+ function makeFakeClient(overrides: Partial<FakeClient> = {}): FakeClient {
93
+ const calls: string[] = [];
94
+ const client: FakeClient = {
95
+ calls,
96
+ eventCb: null,
97
+ promptFilesCalls: [],
98
+ promptSystems: [],
99
+ sendMessageResult: { ok: true },
100
+ promptAsyncResult: { ok: true },
101
+ goneRequests: new Set<string>(),
102
+ createSessionResult: { ok: true, id: "ses_remote1" },
103
+ getSessionResult: { ok: true },
104
+ listModelsResult: { ok: true },
105
+ deleteSessionResult: { ok: true },
106
+ findFilesResult: { ok: true },
107
+ todos: [],
108
+ fileChanges: [],
109
+ availableFiles: [
110
+ "src/App.tsx",
111
+ "src/server/app.ts",
112
+ "src/lib/api.ts",
113
+ "src/components/sessions/SessionList.tsx",
114
+ ],
115
+ availableModels: [
116
+ {
117
+ providerID: "kcgcode",
118
+ providerName: "kcgcode",
119
+ modelID: "kiro/claude-opus-5",
120
+ name: "Claude Opus 5",
121
+ },
122
+ ],
123
+ promptModels: [],
124
+ availableAgents: [
125
+ { name: "build", mode: "primary", description: "Full tool access" },
126
+ { name: "plan", mode: "primary", description: "Planning only" },
127
+ ],
128
+ promptAgents: [],
129
+ async createSession(opts) {
130
+ calls.push(`createSession:${opts?.title ?? ""}`);
131
+ if (!client.createSessionResult.ok) {
132
+ return { ok: false, error: client.createSessionResult.error ?? "OC_CREATE_SESSION_FAILED" };
133
+ }
134
+ return {
135
+ ok: true,
136
+ data: { id: client.createSessionResult.id ?? "ses_remote1", directory: "/proj" },
137
+ };
138
+ },
139
+ async getSession(sessionId) {
140
+ calls.push(`getSession:${sessionId}`);
141
+ if (!client.getSessionResult.ok) {
142
+ return { ok: false, error: client.getSessionResult.error ?? "OC_SESSION_NOT_FOUND" };
143
+ }
144
+ return { ok: true };
145
+ },
146
+ async listModels() {
147
+ calls.push("listModels");
148
+ if (!client.listModelsResult.ok) {
149
+ return { ok: false, error: client.listModelsResult.error ?? "OC_LIST_MODELS_FAILED" };
150
+ }
151
+ return { ok: true, data: client.availableModels };
152
+ },
153
+ async listAgents() {
154
+ calls.push("listAgents");
155
+ return { ok: true, data: client.availableAgents };
156
+ },
157
+ async listMcp() {
158
+ calls.push("listMcp");
159
+ return {
160
+ ok: true,
161
+ data: [
162
+ { name: "context7", status: "connected", error: null },
163
+ { name: "broken", status: "failed", error: "spawn ENOENT" },
164
+ ],
165
+ };
166
+ },
167
+ async disposeInstance() {
168
+ calls.push("disposeInstance");
169
+ return { ok: true };
170
+ },
171
+ async listSkills(projectPath) {
172
+ calls.push(`listSkills:${projectPath ?? ""}`);
173
+ return {
174
+ ok: true,
175
+ data: [{ name: "shadcn", description: "UI", location: null, source: "project" }],
176
+ };
177
+ },
178
+ async listTodos(sessionId) {
179
+ calls.push(`listTodos:${sessionId}`);
180
+ return { ok: true, data: client.todos };
181
+ },
182
+ async fileStatus() {
183
+ calls.push("fileStatus");
184
+ return { ok: true, data: client.fileChanges };
185
+ },
186
+ async findFiles(query) {
187
+ calls.push(`findFiles:${query}`);
188
+ if (!client.findFilesResult.ok) {
189
+ return { ok: false, error: client.findFilesResult.error ?? "OC_FIND_FILES_FAILED" };
190
+ }
191
+ return { ok: true, data: client.availableFiles.filter((f) => f.includes(query)) };
192
+ },
193
+ async sendMessage(_sessionId, text) {
194
+ calls.push(`sendMessage:${text}`);
195
+ const delay = client.sendDelays?.shift();
196
+ if (delay) await Bun.sleep(delay);
197
+ if (!client.sendMessageResult.ok) {
198
+ return { ok: false, error: client.sendMessageResult.error ?? "OC_SEND_MESSAGE_FAILED" };
199
+ }
200
+ return {
201
+ ok: true,
202
+ data: {
203
+ info: { id: "msg_assistant", role: "assistant" },
204
+ parts: [{ type: "text", text: `balasan: ${text}` }],
205
+ },
206
+ };
207
+ },
208
+ async promptAsync(_sessionId, text, model, files, agent, system) {
209
+ calls.push(`promptAsync:${text}`);
210
+ client.promptSystems.push(system ?? null);
211
+ client.promptFilesCalls.push(files ?? []);
212
+ client.promptModels.push(model ? `${model.providerID}/${model.modelID}` : null);
213
+ client.promptAgents.push(agent ?? null);
214
+ const delay = client.sendDelays?.shift();
215
+ if (delay) await Bun.sleep(delay);
216
+ if (!client.promptAsyncResult.ok) {
217
+ return { ok: false, error: client.promptAsyncResult.error ?? "OC_PROMPT_ASYNC_FAILED" };
218
+ }
219
+ return { ok: true, data: null };
220
+ },
221
+ async replyPermission(requestId, reply: OpenCodePermissionReply) {
222
+ calls.push(`replyPermission:${requestId}:${reply}`);
223
+ if (client.goneRequests.has(requestId)) return { ok: false, error: OC_REQUEST_GONE };
224
+ return { ok: true, data: null };
225
+ },
226
+ async replyQuestion(requestId, answers) {
227
+ // Render nested: SATU grup per pertanyaan dipisah "|", label dalam
228
+ // grup dipisah "," — membedakan jawaban per pertanyaan.
229
+ calls.push(`replyQuestion:${requestId}:${answers.map((a) => a.join(",")).join("|")}`);
230
+ if (client.goneRequests.has(requestId)) return { ok: false, error: OC_REQUEST_GONE };
231
+ return { ok: true, data: null };
232
+ },
233
+ async rejectQuestion(requestId) {
234
+ calls.push(`rejectQuestion:${requestId}`);
235
+ if (client.goneRequests.has(requestId)) return { ok: false, error: OC_REQUEST_GONE };
236
+ return { ok: true, data: null };
237
+ },
238
+ async abortSession(sessionId) {
239
+ calls.push(`abortSession:${sessionId}`);
240
+ return { ok: true, data: null };
241
+ },
242
+ async deleteSession(sessionId) {
243
+ calls.push(`deleteSession:${sessionId}`);
244
+ if (!client.deleteSessionResult.ok) {
245
+ return { ok: false, error: client.deleteSessionResult.error ?? "OC_DELETE_SESSION_FAILED" };
246
+ }
247
+ return { ok: true };
248
+ },
249
+ subscribeEvents(cb) {
250
+ client.eventCb = cb;
251
+ return () => {
252
+ client.eventCb = null;
253
+ };
254
+ },
255
+ async health() {
256
+ return true;
257
+ },
258
+ emit(ev: OpenCodeEvent) {
259
+ client.eventCb?.(ev);
260
+ },
261
+ ...overrides,
262
+ };
263
+ return client;
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Mock OpenCodeServerManager
268
+ // ---------------------------------------------------------------------------
269
+
270
+ interface FakeServers {
271
+ manager: OpenCodeServerManager;
272
+ clients: Map<string, FakeClient>;
273
+ ensureCalls: { projectId: string; projectPath: string }[];
274
+ ensureResult: { ok: boolean; error?: string };
275
+ stopped: string[];
276
+ exitCbs: Set<(projectId: string) => void>;
277
+ }
278
+
279
+ function makeFakeServers(): FakeServers {
280
+ const clients = new Map<string, FakeClient>();
281
+ const ensureCalls: { projectId: string; projectPath: string }[] = [];
282
+ const stopped: string[] = [];
283
+ const exitCbs = new Set<(projectId: string) => void>();
284
+ const state: FakeServers = {
285
+ ensureResult: { ok: true },
286
+ ensureCalls,
287
+ clients,
288
+ stopped,
289
+ exitCbs,
290
+ manager: {
291
+ async ensureServer(projectId, projectPath) {
292
+ ensureCalls.push({ projectId, projectPath });
293
+ if (!state.ensureResult.ok) {
294
+ return { ok: false, error: state.ensureResult.error ?? "SERVER_START_FAILED" };
295
+ }
296
+ let client = clients.get(projectId);
297
+ if (!client) {
298
+ client = makeFakeClient();
299
+ clients.set(projectId, client);
300
+ }
301
+ const handle: OpenCodeServerHandle = {
302
+ projectId,
303
+ baseUrl: "http://127.0.0.1:0",
304
+ client,
305
+ };
306
+ return { ok: true, data: handle };
307
+ },
308
+ async ensureFreshServer(projectId, projectPath) {
309
+ return this.ensureServer(projectId, projectPath);
310
+ },
311
+ getServer(projectId) {
312
+ const client = clients.get(projectId);
313
+ return client ? { projectId, baseUrl: "http://127.0.0.1:0", client } : undefined;
314
+ },
315
+ async stopServer(projectId) {
316
+ stopped.push(projectId);
317
+ clients.delete(projectId);
318
+ },
319
+ async stopAll() {
320
+ for (const id of [...clients.keys()]) stopped.push(id);
321
+ clients.clear();
322
+ },
323
+ onServerExit(cb) {
324
+ exitCbs.add(cb);
325
+ },
326
+ },
327
+ };
328
+ return state;
329
+ }
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Harness
333
+ // ---------------------------------------------------------------------------
334
+
335
+ interface Harness {
336
+ store: SessionStore;
337
+ sm: SessionManager;
338
+ fake: FakeServers;
339
+ attachments?: AttachmentManager;
340
+ messages: SessionMessage[];
341
+ messageParts: [string, string, MessagePart][];
342
+ partDeltas: [string, string, string, string, string][];
343
+ prompts: InteractivePrompt[];
344
+ statuses: [string, SessionStatus][];
345
+ errors: [string, string][];
346
+ /** Perubahan status turn (onTurnChange): [sessionId, active]. */
347
+ turns: [string, boolean][];
348
+ /** Prompt kembar yang ikut resolved lewat fan-out (onPromptResolved). */
349
+ promptResolved: [string, string][];
350
+ /** Judul Session baru (onTitleChange): [sessionId, title]. */
351
+ titles: [string, string][];
352
+ project: Project;
353
+ root: string;
354
+ close(): void;
355
+ }
356
+
357
+ function freshHarness(): Harness {
358
+ return freshHarnessWithHooks({});
359
+ }
360
+
361
+ function freshHarnessWithHooks(
362
+ extraHooks: Pick<Parameters<typeof createSessionManager>[0], "onDeleted" | "onTitleChange">,
363
+ extra: { withAttachments?: boolean } = {},
364
+ managerOverrides: Partial<Parameters<typeof createSessionManager>[0]> = {},
365
+ ): Harness {
366
+ const store = openSessionStore(":memory:");
367
+ const root = mkdtempSync(path.join(tmpdir(), "kcg-sm2-"));
368
+ const cwd = path.join(root, "proj");
369
+ mkdirSync(cwd, { recursive: true });
370
+ const project: Project = { id: "p1", name: "proj", path: cwd, createdAt: 1 };
371
+ store.insertProject(project);
372
+
373
+ const fake = makeFakeServers();
374
+ const messages: SessionMessage[] = [];
375
+ const messageParts: [string, string, MessagePart][] = [];
376
+ /** Delta streaming (onMessagePartDelta): [sessionId, messageId, partId, field, delta]. */
377
+ const partDeltas: [string, string, string, string, string][] = [];
378
+ const prompts: InteractivePrompt[] = [];
379
+ const statuses: [string, SessionStatus][] = [];
380
+ const errors: [string, string][] = [];
381
+ const turns: [string, boolean][] = [];
382
+ const promptResolved: [string, string][] = [];
383
+ /** Judul Session baru (onTitleChange): [sessionId, title]. */
384
+ const titles: [string, string][] = [];
385
+
386
+ const attachments = extra.withAttachments
387
+ ? createAttachmentManager(path.join(root, "uploads"))
388
+ : undefined;
389
+ const sm = createSessionManager({
390
+ store,
391
+ servers: fake.manager,
392
+ attachments,
393
+ now: () => 1000,
394
+ ...extraHooks,
395
+ ...managerOverrides,
396
+ onMessage: (m) => messages.push(m),
397
+ onMessagePart: (sid, mid, part) => messageParts.push([sid, mid, part]),
398
+ onMessagePartDelta: (sid, mid, pid, field, delta) =>
399
+ partDeltas.push([sid, mid, pid, field, delta]),
400
+ onPrompt: (p) => prompts.push(p),
401
+ onPromptResolved: (sid, pid) => promptResolved.push([sid, pid]),
402
+ onStatusChange: (id, st) => statuses.push([id, st]),
403
+ onError: (id, m) => errors.push([id, m]),
404
+ onTurnChange: (id, active) => turns.push([id, active]),
405
+ onTitleChange: (id, t) => titles.push([id, t]),
406
+ });
407
+
408
+ return {
409
+ store,
410
+ sm,
411
+ fake,
412
+ attachments,
413
+ messages,
414
+ messageParts,
415
+ partDeltas,
416
+ prompts,
417
+ statuses,
418
+ errors,
419
+ turns,
420
+ promptResolved,
421
+ titles,
422
+ project,
423
+ root,
424
+ close() {
425
+ store.close();
426
+ rmSync(root, { recursive: true, force: true });
427
+ },
428
+ };
429
+ }
430
+
431
+ /** Tunggu antrean asinkron (POST message) selesai. */
432
+ async function flush(): Promise<void> {
433
+ await new Promise((r) => setTimeout(r, 0));
434
+ await new Promise((r) => setTimeout(r, 0));
435
+ }
436
+
437
+ async function createSession(h: Harness, agentType: AgentType = "opencode"): Promise<string> {
438
+ const res = await h.sm.createSession({ agentType, projectId: h.project.id });
439
+ expect(res.ok).toBe(true);
440
+ if (!res.ok) throw new Error(`harness createSession gagal: ${res.error}`);
441
+ return res.session.id;
442
+ }
443
+
444
+ function statusOf(store: SessionStore, sid: string): SessionStatus | undefined {
445
+ const r = store.getSession(sid);
446
+ return r.ok ? r.data.status : undefined;
447
+ }
448
+
449
+ /** Ambil FakeClient project "p1"; melempar bila belum ada (harusnya sudah dibuat). */
450
+ function clientOf(h: Harness): FakeClient {
451
+ const client = h.fake.clients.get("p1");
452
+ if (!client) throw new Error("fake client p1 tidak ada");
453
+ return client;
454
+ }
455
+
456
+ /** Prompt pertama yang tercatat; melempar bila kosong (harusnya sudah ada). */
457
+ function promptOf(h: Harness): InteractivePrompt {
458
+ const p = h.prompts[0];
459
+ if (!p) throw new Error("belum ada prompt tercatat");
460
+ return p;
461
+ }
462
+
463
+ // ---------------------------------------------------------------------------
464
+ // Pembuatan Session
465
+ // ---------------------------------------------------------------------------
466
+
467
+ test("createSession valid -> running, ocSessionId, server per project", async () => {
468
+ const h = freshHarness();
469
+ try {
470
+ const sid = await createSession(h);
471
+ const sess = h.store.getSession(sid);
472
+ expect(sess.ok).toBe(true);
473
+ if (sess.ok) {
474
+ expect(sess.data.status).toBe("running");
475
+ expect(sess.data.cwd).toBe(h.project.path);
476
+ expect(sess.data.ocSessionId).toBe("ses_remote1");
477
+ expect(sess.data.createdAt).toBe(1000);
478
+ }
479
+ // Server di-ensure dengan path Project (Requirement 10.9).
480
+ expect(h.fake.ensureCalls).toHaveLength(1);
481
+ expect(h.fake.ensureCalls[0]).toEqual({ projectId: "p1", projectPath: h.project.path });
482
+ // Subscribe event SSE terpasang pada client.
483
+ expect(h.fake.clients.get("p1")?.eventCb).not.toBeNull();
484
+ } finally {
485
+ h.close();
486
+ }
487
+ });
488
+
489
+ test("createSession: ensure server hanya sekali untuk project yang sama", async () => {
490
+ const h = freshHarness();
491
+ try {
492
+ await createSession(h);
493
+ await createSession(h);
494
+ await createSession(h);
495
+ expect(h.fake.ensureCalls).toHaveLength(3); // dipanggil tiap createSession
496
+ const client = h.fake.clients.get("p1");
497
+ expect(client?.calls.filter((c) => c.startsWith("createSession"))).toHaveLength(3);
498
+ } finally {
499
+ h.close();
500
+ }
501
+ });
502
+
503
+ // Feature: kcg-code, Property 2: Pembuatan Session dengan kondisi tidak valid selalu ditolak
504
+ test("Property 2: agentType tak didukung / project tak ada / dir hilang -> ditolak", async () => {
505
+ await fc.assert(
506
+ fc.asyncProperty(
507
+ fc.constantFrom("claude-code", "vibe", "codex"),
508
+ fc.string({ maxLength: 20 }).filter((s) => s !== "p1"),
509
+ fc.constantFrom(0, 1, 2),
510
+ async (badType, unknownId, kase) => {
511
+ const h = freshHarness();
512
+ try {
513
+ let req: CreateSessionRequest;
514
+ if (kase === 0) {
515
+ req = { agentType: badType as AgentType, projectId: h.project.id };
516
+ } else if (kase === 1) {
517
+ req = { agentType: "opencode", projectId: unknownId };
518
+ } else {
519
+ h.store.insertProject({
520
+ id: "pm",
521
+ name: "proj-missing",
522
+ path: path.join(h.root, "tidak-ada"),
523
+ createdAt: 1,
524
+ });
525
+ req = { agentType: "opencode", projectId: "pm" };
526
+ }
527
+ const res = await h.sm.createSession(req);
528
+ expect(res.ok).toBe(false);
529
+ if (!res.ok) expect(res.error.length).toBeGreaterThan(0);
530
+ expect(h.store.listSessions().every((s) => s.status !== "running")).toBe(true);
531
+ } finally {
532
+ h.close();
533
+ }
534
+ },
535
+ ),
536
+ { numRuns: 100 },
537
+ );
538
+ });
539
+
540
+ test("createSession: ensure server gagal -> error, tanpa session", async () => {
541
+ const h = freshHarness();
542
+ try {
543
+ h.fake.ensureResult = { ok: false, error: "SERVER_START_FAILED" };
544
+ const res = await h.sm.createSession({ agentType: "opencode", projectId: "p1" });
545
+ expect(res.ok).toBe(false);
546
+ if (!res.ok) expect(res.error).toContain("SERVER_START_FAILED");
547
+ expect(h.store.listSessions()).toHaveLength(0);
548
+ } finally {
549
+ h.close();
550
+ }
551
+ });
552
+
553
+ test("createSession: create session remote gagal -> error, tanpa session", async () => {
554
+ const h = freshHarness();
555
+ try {
556
+ const client = makeFakeClient({
557
+ createSessionResult: { ok: false, error: "OC_CREATE_SESSION_FAILED" },
558
+ });
559
+ h.fake.clients.set("p1", client);
560
+ const res = await h.sm.createSession({ agentType: "opencode", projectId: "p1" });
561
+ expect(res.ok).toBe(false);
562
+ expect(h.store.listSessions()).toHaveLength(0);
563
+ } finally {
564
+ h.close();
565
+ }
566
+ });
567
+
568
+ // ---------------------------------------------------------------------------
569
+ // Free-text input
570
+ // ---------------------------------------------------------------------------
571
+
572
+ test("sendFreeTextInput: validasi text -> TEXT_EMPTY / TEXT_TOO_LONG / not found / inactive", async () => {
573
+ const h = freshHarness();
574
+ try {
575
+ const sid = await createSession(h);
576
+ expect((await h.sm.sendFreeTextInput(sid, " ")).error).toBe("TEXT_EMPTY");
577
+ expect((await h.sm.sendFreeTextInput(sid, "")).error).toBe("TEXT_EMPTY");
578
+ expect((await h.sm.sendFreeTextInput(sid, "x".repeat(10001))).error).toBe("TEXT_TOO_LONG");
579
+ expect((await h.sm.sendFreeTextInput("unknown", "halo")).error).toBe("SESSION_NOT_FOUND");
580
+
581
+ // Session dihentikan -> tidak aktif.
582
+ const r = await h.sm.stopSession(sid);
583
+ expect(r.ok).toBe(true);
584
+ expect((await h.sm.sendFreeTextInput(sid, "halo")).error).toBe("SESSION_NOT_ACTIVE");
585
+ } finally {
586
+ h.close();
587
+ }
588
+ });
589
+
590
+ test("sendFreeTextInput sukses -> echo user; assistant dirakit saat session.idle", async () => {
591
+ const h = freshHarness();
592
+ try {
593
+ const sid = await createSession(h);
594
+ const res = await h.sm.sendFreeTextInput(sid, "hello");
595
+ expect(res.ok).toBe(true);
596
+
597
+ // Echo user tersimpan & terkirim segera.
598
+ expect(h.messages).toHaveLength(1);
599
+ expect(h.messages[0]?.role).toBe("user");
600
+ expect(h.messages[0]?.parts).toEqual([{ type: "text", text: "hello" }]);
601
+
602
+ await flush();
603
+
604
+ // Prompt dikirim via prompt_async (204) — belum ada pesan assistant.
605
+ const client = clientOf(h);
606
+ expect(client.calls).toContain("promptAsync:hello");
607
+ expect(h.messages).toHaveLength(1);
608
+
609
+ // Balasan tiba sebagai parts SSE.
610
+ client.emit({
611
+ type: "message.updated",
612
+ sessionID: "ses_remote1",
613
+ info: { id: "msg_a1", role: "assistant" },
614
+ });
615
+ client.emit({
616
+ type: "message.part.updated",
617
+ sessionID: "ses_remote1",
618
+ part: { type: "text", id: "prt_t1", text: "balasan: hello", messageID: "msg_a1" },
619
+ });
620
+ // Turn belum ditutup sebelum idle.
621
+ expect(h.messages).toHaveLength(1);
622
+
623
+ // session.idle Session akar menutup turn -> pesan assistant dirakit.
624
+ client.emit({ type: "session.idle", sessionID: "ses_remote1" });
625
+
626
+ expect(h.messages).toHaveLength(2);
627
+ expect(h.messages[1]?.role).toBe("assistant");
628
+ expect(h.messages[1]?.id).toBe("msg_a1");
629
+ expect(h.messages[1]?.parts).toEqual([
630
+ { type: "text", id: "prt_t1", text: "balasan: hello", messageID: "msg_a1" },
631
+ ]);
632
+
633
+ // Keduanya tersimpan di store (round-trip history).
634
+ const stored = h.store.getMessages(sid);
635
+ expect(stored.ok).toBe(true);
636
+ if (stored.ok) expect(stored.data).toHaveLength(2);
637
+ } finally {
638
+ h.close();
639
+ }
640
+ });
641
+
642
+ test("sendFreeTextInput: prompt_async gagal -> onError + pesan error tersimpan", async () => {
643
+ const h = freshHarness();
644
+ try {
645
+ const sid = await createSession(h);
646
+ const client = clientOf(h);
647
+ client.promptAsyncResult = { ok: false, error: "OC_PROMPT_ASYNC_FAILED(500)" };
648
+ await h.sm.sendFreeTextInput(sid, "hello");
649
+ await flush();
650
+ expect(h.errors).toHaveLength(1);
651
+ expect(h.errors[0]?.[0]).toBe(sid);
652
+ // Pesan error ramah (bukan kode mentah) dan ditulis ke history (part error).
653
+ expect(h.errors[0]?.[1]).toBe("Failed to send the prompt to opencode (status 500). Try again.");
654
+ const assistants = h.messages.filter((m) => m.role === "assistant");
655
+ expect(assistants).toHaveLength(1);
656
+ expect(assistants[0]?.parts).toEqual([{ type: "error", text: h.errors[0]?.[1] }]);
657
+ const stored = h.store.getMessages(sid);
658
+ expect(stored.ok && stored.data.filter((m) => m.role === "assistant")).toHaveLength(1);
659
+ } finally {
660
+ h.close();
661
+ }
662
+ });
663
+
664
+ test("session.error terminal (parts tersimpan + pesan error ditulis saat idle)", async () => {
665
+ const h = freshHarness();
666
+ try {
667
+ const sid = await createSession(h);
668
+ const client = clientOf(h);
669
+ await h.sm.sendFreeTextInput(sid, "hello");
670
+ await flush();
671
+
672
+ // Parts sudah ter-stream sebelum error datang — harus tetap tersimpan.
673
+ client.emit({
674
+ type: "message.updated",
675
+ sessionID: "ses_remote1",
676
+ info: { id: "msg_a1", role: "assistant" },
677
+ });
678
+ client.emit({
679
+ type: "message.part.updated",
680
+ sessionID: "ses_remote1",
681
+ part: { type: "text", id: "prt_t1", text: "sebagian", messageID: "msg_a1" },
682
+ });
683
+
684
+ // opencode melaporkan kegagalan memproses prompt (mis. URL file gambar).
685
+ client.emit({
686
+ type: "session.error",
687
+ sessionID: "ses_remote1",
688
+ error: {
689
+ name: "UnknownError",
690
+ data: {
691
+ message:
692
+ 'TypeError: File URL host must be "localhost" or empty on linux\n at SessionPrompt.resolveUserPart',
693
+ },
694
+ },
695
+ });
696
+
697
+ // Banner seketika; turn BELUM ditutup (error bisa saja tidak terminal).
698
+ expect(h.errors).toHaveLength(1);
699
+ expect(h.errors[0]?.[0]).toBe(sid);
700
+ expect(h.errors[0]?.[1]).toBe('TypeError: File URL host must be "localhost" or empty on linux');
701
+ expect(h.messages.filter((m) => m.role === "assistant")).toHaveLength(0);
702
+
703
+ // Idle menutup turn: parts tersimpan + pesan error ditulis (tidak ada
704
+ // konten baru setelah error — memang kegagalan terminal).
705
+ client.emit({ type: "session.idle", sessionID: "ses_remote1" });
706
+ const assistants = h.messages.filter((m) => m.role === "assistant");
707
+ expect(assistants).toHaveLength(2);
708
+ expect(assistants[0]?.parts).toEqual([
709
+ { type: "text", id: "prt_t1", text: "sebagian", messageID: "msg_a1" },
710
+ ]);
711
+ expect(assistants[1]?.parts[0]?.type).toBe("error");
712
+ expect(assistants[1]?.parts[0]?.text).toBe(h.errors[0]?.[1]);
713
+ const stored = h.store.getMessages(sid);
714
+ expect(stored.ok && stored.data.filter((m) => m.role === "assistant")).toHaveLength(2);
715
+
716
+ // Idle kedua tidak menambah apa-apa — turn sudah ditutup.
717
+ client.emit({ type: "session.idle", sessionID: "ses_remote1" });
718
+ expect(h.messages.filter((m) => m.role === "assistant")).toHaveLength(2);
719
+ } finally {
720
+ h.close();
721
+ }
722
+ });
723
+
724
+ test("session.error tanpa turn aktif -> hanya onError, tidak menulis pesan", async () => {
725
+ const h = freshHarness();
726
+ try {
727
+ const sid = await createSession(h);
728
+ const client = clientOf(h);
729
+ client.emit({
730
+ type: "session.error",
731
+ sessionID: "ses_remote1",
732
+ error: { name: "ProviderAuthError", data: { message: "auth required" } },
733
+ });
734
+ expect(h.errors).toHaveLength(1);
735
+ expect(h.errors[0]?.[1]).toContain("Model provider authentication failed");
736
+ expect(h.messages.filter((m) => m.role === "assistant")).toHaveLength(0);
737
+ const stored = h.store.getMessages(sid);
738
+ expect(stored.ok && stored.data).toHaveLength(0);
739
+ } finally {
740
+ h.close();
741
+ }
742
+ });
743
+
744
+ test("TURN_TIMEOUT (idle tanpa tanda hidup) -> pesan error tersimpan + onError", async () => {
745
+ const timers: (() => void)[] = [];
746
+ const cleared: unknown[] = [];
747
+ const h = freshHarnessWithHooks(
748
+ {},
749
+ {},
750
+ {
751
+ turnIdleTimeoutMs: 5000,
752
+ setTimeoutFn: (cb) => {
753
+ timers.push(cb);
754
+ return timers.length;
755
+ },
756
+ clearTimeoutFn: (handle) => cleared.push(handle),
757
+ },
758
+ );
759
+ try {
760
+ const sid = await createSession(h);
761
+ await h.sm.sendFreeTextInput(sid, "hello");
762
+ await flush();
763
+ // Turn aktif, belum ada balasan — belum ada error.
764
+ expect(timers).toHaveLength(1);
765
+ expect(h.errors).toHaveLength(0);
766
+
767
+ timers[0]?.();
768
+ expect(h.errors).toHaveLength(1);
769
+ expect(h.errors[0]?.[0]).toBe(sid);
770
+ expect(h.errors[0]?.[1]).toBe(
771
+ "The model stopped responding — no activity for 5 minutes. Try sending the message again.",
772
+ );
773
+ const assistants = h.messages.filter((m) => m.role === "assistant");
774
+ expect(assistants).toHaveLength(1);
775
+ expect(assistants[0]?.parts).toEqual([{ type: "error", text: h.errors[0]?.[1] }]);
776
+ const stored = h.store.getMessages(sid);
777
+ expect(stored.ok && stored.data.filter((m) => m.role === "assistant")).toHaveLength(1);
778
+
779
+ // Timer lama sudah di-clear saat reset/fire — tidak ada timer yatim.
780
+ expect(cleared.length).toBeGreaterThanOrEqual(1);
781
+ } finally {
782
+ h.close();
783
+ }
784
+ });
785
+
786
+ test("turn idle timer di-reset oleh tanda hidup (part streaming, status busy)", async () => {
787
+ const timers: (() => void)[] = [];
788
+ const h = freshHarnessWithHooks(
789
+ {},
790
+ {},
791
+ {
792
+ turnIdleTimeoutMs: 5000,
793
+ setTimeoutFn: (cb) => {
794
+ timers.push(cb);
795
+ return timers.length;
796
+ },
797
+ clearTimeoutFn: () => {},
798
+ },
799
+ );
800
+ try {
801
+ const sid = await createSession(h);
802
+ const client = clientOf(h);
803
+ await h.sm.sendFreeTextInput(sid, "hello");
804
+ await flush();
805
+ expect(timers).toHaveLength(1); // timer awal
806
+
807
+ const fireFirst = timers[0];
808
+
809
+ // Tanda hidup #1: pesan assistant + part streaming — timer di-reset 2x.
810
+ client.emit({
811
+ type: "message.updated",
812
+ sessionID: "ses_remote1",
813
+ info: { id: "msg_a1", role: "assistant" },
814
+ });
815
+ client.emit({
816
+ type: "message.part.updated",
817
+ sessionID: "ses_remote1",
818
+ part: { type: "text", id: "prt_t1", text: "menulis lama...", messageID: "msg_a1" },
819
+ });
820
+ expect(timers).toHaveLength(3); // timer awal + 2 reset
821
+
822
+ // Timer LAMA terpicu (race): tidak boleh menggagalkan turn yang hidup.
823
+ fireFirst?.();
824
+ expect(h.errors).toHaveLength(0);
825
+ expect(h.turns).toEqual([[sid, true]]);
826
+
827
+ // Tanda hidup #2: status busy/retry juga me-reset timer.
828
+ client.emit({ type: "session.status", sessionID: "ses_remote1", status: { type: "busy" } });
829
+ expect(timers).toHaveLength(4);
830
+
831
+ // Timer lama (hasil reset) tidak menggagalkan turn — turn tetap hidup.
832
+ timers[1]?.();
833
+ timers[2]?.();
834
+ expect(h.errors).toHaveLength(0);
835
+
836
+ // Timer yang terpasang sekarang (terakhir) terpicu -> turn gagal idle.
837
+ timers[3]?.();
838
+ expect(h.errors).toHaveLength(1);
839
+ expect(h.errors[0]?.[1]).toBe(
840
+ "The model stopped responding — no activity for 5 minutes. Try sending the message again.",
841
+ );
842
+ expect(h.turns).toContainEqual([sid, false]);
843
+ } finally {
844
+ h.close();
845
+ }
846
+ });
847
+
848
+ test("turn idle timer DITUNDA selama kartu permission pending (user sedang memutus)", async () => {
849
+ const timers: (() => void)[] = [];
850
+ const h = freshHarnessWithHooks(
851
+ {},
852
+ {},
853
+ {
854
+ turnIdleTimeoutMs: 5000,
855
+ setTimeoutFn: (cb) => {
856
+ timers.push(cb);
857
+ return timers.length;
858
+ },
859
+ clearTimeoutFn: () => {},
860
+ },
861
+ );
862
+ try {
863
+ const sid = await createSession(h);
864
+ const client = clientOf(h);
865
+ await h.sm.sendFreeTextInput(sid, "perbaiki deps");
866
+ await flush();
867
+ expect(timers).toHaveLength(1);
868
+
869
+ // Turn meminta izin — opencode hidup MENUNGGU keputusan user.
870
+ client.emit({
871
+ type: "permission.asked",
872
+ requestID: "per_idle1",
873
+ sessionID: "ses_remote1",
874
+ permission: "bash",
875
+ patterns: ["bun install"],
876
+ });
877
+ expect(h.prompts).toHaveLength(1);
878
+
879
+ // Timer terpicu saat kartu masih pending -> DITUNDA (re-arm, bukan fail).
880
+ timers[0]?.();
881
+ expect(h.errors).toHaveLength(0);
882
+ expect(h.turns).toEqual([[sid, true]]);
883
+ expect(timers).toHaveLength(2); // timer dipasang ulang
884
+
885
+ // User menjawab kartu -> tidak ada pending lagi.
886
+ const resolved = await h.sm.resolvePrompt(sid, "per_idle1", "approve");
887
+ expect(resolved.ok).toBe(true);
888
+
889
+ // Timer yang dipasang ulang kini boleh memicu fail idle.
890
+ timers[1]?.();
891
+ expect(h.errors).toHaveLength(1);
892
+ expect(h.turns).toContainEqual([sid, false]);
893
+ } finally {
894
+ h.close();
895
+ }
896
+ });
897
+
898
+ test("session.idle: turn panjang tersimpan walau POST message tidak dipakai", async () => {
899
+ const h = freshHarness();
900
+ try {
901
+ const sid = await createSession(h);
902
+ const client = clientOf(h);
903
+ await h.sm.sendFreeTextInput(sid, "delegasikan");
904
+ await flush();
905
+
906
+ // Sub-agent: child session ikut menyumbang pesan pada turn yang sama.
907
+ client.emit({
908
+ type: "session.created",
909
+ sessionID: "ses_child1",
910
+ info: { id: "ses_child1", parentID: "ses_remote1" },
911
+ });
912
+ client.emit({
913
+ type: "message.updated",
914
+ sessionID: "ses_remote1",
915
+ info: { id: "msg_induk", role: "assistant" },
916
+ });
917
+ client.emit({
918
+ type: "message.part.updated",
919
+ sessionID: "ses_remote1",
920
+ part: { type: "text", id: "prt_1", text: "memanggil sub-agent", messageID: "msg_induk" },
921
+ });
922
+ client.emit({
923
+ type: "message.updated",
924
+ sessionID: "ses_child1",
925
+ info: { id: "msg_child", role: "assistant" },
926
+ });
927
+ client.emit({
928
+ type: "message.part.updated",
929
+ sessionID: "ses_child1",
930
+ part: { type: "text", id: "prt_2", text: "hasil sub-agent", messageID: "msg_child" },
931
+ });
932
+
933
+ // Idle child TIDAK menutup turn (sub-agent selesai lebih dulu dari induk).
934
+ client.emit({ type: "session.idle", sessionID: "ses_child1" });
935
+ expect(h.messages.filter((m) => m.role === "assistant")).toHaveLength(0);
936
+
937
+ // Idle Session akar baru menutup turn.
938
+ client.emit({ type: "session.idle", sessionID: "ses_remote1" });
939
+ const assistants = h.messages.filter((m) => m.role === "assistant");
940
+ expect(assistants).toHaveLength(2);
941
+ expect(assistants.map((m) => m.id)).toEqual(["msg_induk", "msg_child"]);
942
+
943
+ const stored = h.store.getMessages(sid);
944
+ expect(stored.ok && stored.data.filter((m) => m.role === "assistant")).toHaveLength(2);
945
+ } finally {
946
+ h.close();
947
+ }
948
+ });
949
+
950
+ test("session.idle ganda -> turn hanya difinalisasi sekali", async () => {
951
+ const h = freshHarness();
952
+ try {
953
+ const sid = await createSession(h);
954
+ const client = clientOf(h);
955
+ await h.sm.sendFreeTextInput(sid, "hello");
956
+ await flush();
957
+
958
+ client.emit({
959
+ type: "message.updated",
960
+ sessionID: "ses_remote1",
961
+ info: { id: "msg_a1", role: "assistant" },
962
+ });
963
+ client.emit({
964
+ type: "message.part.updated",
965
+ sessionID: "ses_remote1",
966
+ part: { type: "text", id: "prt_t1", text: "sekali", messageID: "msg_a1" },
967
+ });
968
+
969
+ client.emit({ type: "session.idle", sessionID: "ses_remote1" });
970
+ client.emit({ type: "session.idle", sessionID: "ses_remote1" });
971
+
972
+ expect(h.messages.filter((m) => m.role === "assistant")).toHaveLength(1);
973
+ } finally {
974
+ h.close();
975
+ }
976
+ });
977
+
978
+ test("session.idle tanpa parts assistant -> tidak menyimpan pesan kosong", async () => {
979
+ const h = freshHarness();
980
+ try {
981
+ const sid = await createSession(h);
982
+ const client = clientOf(h);
983
+ await h.sm.sendFreeTextInput(sid, "hello");
984
+ await flush();
985
+
986
+ client.emit({ type: "session.idle", sessionID: "ses_remote1" });
987
+
988
+ expect(h.messages.filter((m) => m.role === "assistant")).toHaveLength(0);
989
+ } finally {
990
+ h.close();
991
+ }
992
+ });
993
+
994
+ test("stopSession di tengah turn menyimpan parts yang sudah ter-stream", async () => {
995
+ const h = freshHarness();
996
+ try {
997
+ const sid = await createSession(h);
998
+ const client = clientOf(h);
999
+ await h.sm.sendFreeTextInput(sid, "hello");
1000
+ await flush();
1001
+
1002
+ client.emit({
1003
+ type: "message.updated",
1004
+ sessionID: "ses_remote1",
1005
+ info: { id: "msg_a1", role: "assistant" },
1006
+ });
1007
+ client.emit({
1008
+ type: "message.part.updated",
1009
+ sessionID: "ses_remote1",
1010
+ part: { type: "text", id: "prt_t1", text: "separuh jalan", messageID: "msg_a1" },
1011
+ });
1012
+
1013
+ expect(h.sm.stopSession(sid).ok).toBe(true);
1014
+
1015
+ const assistants = h.messages.filter((m) => m.role === "assistant");
1016
+ expect(assistants).toHaveLength(1);
1017
+ expect(assistants[0]?.parts[0]?.text).toBe("separuh jalan");
1018
+ } finally {
1019
+ h.close();
1020
+ }
1021
+ });
1022
+
1023
+ // ---------------------------------------------------------------------------
1024
+ // interruptSession (hentikan balasan saja — Session tetap running)
1025
+ // ---------------------------------------------------------------------------
1026
+
1027
+ test("interruptSession: turn dimulai -> turn_active true; selesai -> false", async () => {
1028
+ const h = freshHarness();
1029
+ try {
1030
+ const sid = await createSession(h);
1031
+ const client = clientOf(h);
1032
+
1033
+ // Turn belum ada -> tanpa notifikasi.
1034
+ expect(h.turns).toHaveLength(0);
1035
+
1036
+ await h.sm.sendFreeTextInput(sid, "hello");
1037
+ expect(h.turns).toEqual([[sid, true]]);
1038
+
1039
+ // session.idle menutup turn -> turn_active false.
1040
+ client.emit({ type: "session.idle", sessionID: "ses_remote1" });
1041
+ expect(h.turns).toEqual([
1042
+ [sid, true],
1043
+ [sid, false],
1044
+ ]);
1045
+ } finally {
1046
+ h.close();
1047
+ }
1048
+ });
1049
+
1050
+ test("interruptSession: abort remote + BUANG parts parsial (tidak disimpan); session tetap running", async () => {
1051
+ const h = freshHarness();
1052
+ try {
1053
+ const sid = await createSession(h);
1054
+ const client = clientOf(h);
1055
+ await h.sm.sendFreeTextInput(sid, "hello");
1056
+ await flush();
1057
+
1058
+ client.emit({
1059
+ type: "message.updated",
1060
+ sessionID: "ses_remote1",
1061
+ info: { id: "msg_a1", role: "assistant" },
1062
+ });
1063
+ client.emit({
1064
+ type: "message.part.updated",
1065
+ sessionID: "ses_remote1",
1066
+ part: { type: "text", id: "prt_t1", text: "separuh jalan", messageID: "msg_a1" },
1067
+ });
1068
+
1069
+ const res = h.sm.interruptSession(sid);
1070
+ expect(res.ok).toBe(true);
1071
+
1072
+ // Turn remote di-abort.
1073
+ expect(client.calls).toContain("abortSession:ses_remote1");
1074
+ // User membatalkan balasan -> parts parsial TIDAK disimpan (tidak muncul
1075
+ // di riwayat / tidak di-broadcast sebagai pesan assistant).
1076
+ const assistants = h.messages.filter((m) => m.role === "assistant");
1077
+ expect(assistants).toHaveLength(0);
1078
+ // Turn ditutup (turn_active false).
1079
+ expect(h.turns).toContainEqual([sid, false]);
1080
+
1081
+ // Beda dari stopSession: status TETAP running (tanpa resume).
1082
+ expect(statusOf(h.store, sid)).toBe("running");
1083
+ expect(h.statuses).toHaveLength(0);
1084
+
1085
+ // Bisa langsung kirim pesan baru tanpa Start ulang.
1086
+ const next = await h.sm.sendFreeTextInput(sid, "lanjut");
1087
+ expect(next.ok).toBe(true);
1088
+ expect(h.turns).toContainEqual([sid, true]);
1089
+ } finally {
1090
+ h.close();
1091
+ }
1092
+ });
1093
+
1094
+ test("interruptSession tanpa turn aktif -> no-op, session tetap running", async () => {
1095
+ const h = freshHarness();
1096
+ try {
1097
+ const sid = await createSession(h);
1098
+ const res = h.sm.interruptSession(sid);
1099
+ expect(res.ok).toBe(true);
1100
+ expect(statusOf(h.store, sid)).toBe("running");
1101
+ // Tanpa turn tidak ada broadcast apapun.
1102
+ expect(h.turns).toHaveLength(0);
1103
+ expect(h.messages).toHaveLength(0);
1104
+ // Idempoten: boleh dipanggil lagi.
1105
+ expect(h.sm.interruptSession(sid).ok).toBe(true);
1106
+ } finally {
1107
+ h.close();
1108
+ }
1109
+ });
1110
+
1111
+ test("interruptSession: session tak dikenal -> SESSION_NOT_FOUND", () => {
1112
+ const h = freshHarness();
1113
+ try {
1114
+ expect(h.sm.interruptSession("tidak-ada").error).toBe("SESSION_NOT_FOUND");
1115
+ } finally {
1116
+ h.close();
1117
+ }
1118
+ });
1119
+
1120
+ test("session.error MessageAbortedError setelah interrupt -> tanpa banner error", async () => {
1121
+ const h = freshHarness();
1122
+ try {
1123
+ const sid = await createSession(h);
1124
+ const client = clientOf(h);
1125
+ await h.sm.sendFreeTextInput(sid, "hello");
1126
+ await flush();
1127
+ h.sm.interruptSession(sid);
1128
+
1129
+ // Server mengonfirmasi turn dibatalkan (bukan kegagalan nyata).
1130
+ client.emit({
1131
+ type: "session.error",
1132
+ sessionID: "ses_remote1",
1133
+ error: { name: "MessageAbortedError", data: { message: "Pemrosesan prompt dibatalkan." } },
1134
+ });
1135
+ expect(h.errors).toHaveLength(0);
1136
+ const stored = h.store.getMessages(sid);
1137
+ expect(stored.ok && stored.data.filter((m) => m.role === "assistant")).toHaveLength(0);
1138
+ } finally {
1139
+ h.close();
1140
+ }
1141
+ });
1142
+
1143
+ test("session.error MessageAbortedError dengan turn aktif -> turn ditutup tanpa pesan error", async () => {
1144
+ const h = freshHarness();
1145
+ try {
1146
+ const sid = await createSession(h);
1147
+ const client = clientOf(h);
1148
+ await h.sm.sendFreeTextInput(sid, "hello");
1149
+ await flush();
1150
+
1151
+ // Parts sudah ter-stream sebelum opencode mengabort turn.
1152
+ client.emit({
1153
+ type: "message.updated",
1154
+ sessionID: "ses_remote1",
1155
+ info: { id: "msg_a1", role: "assistant" },
1156
+ });
1157
+ client.emit({
1158
+ type: "message.part.updated",
1159
+ sessionID: "ses_remote1",
1160
+ part: { type: "text", id: "prt_t1", text: "sebagian", messageID: "msg_a1" },
1161
+ });
1162
+ client.emit({
1163
+ type: "session.error",
1164
+ sessionID: "ses_remote1",
1165
+ error: { name: "MessageAbortedError", data: { message: "Pemrosesan prompt dibatalkan." } },
1166
+ });
1167
+
1168
+ // Parts tersimpan, TAPI tanpa pesan error & tanpa banner onError.
1169
+ expect(h.errors).toHaveLength(0);
1170
+ const assistants = h.messages.filter((m) => m.role === "assistant");
1171
+ expect(assistants).toHaveLength(1);
1172
+ expect(assistants[0]?.parts).toEqual([
1173
+ { type: "text", id: "prt_t1", text: "sebagian", messageID: "msg_a1" },
1174
+ ]);
1175
+ expect(h.turns).toContainEqual([sid, false]);
1176
+ } finally {
1177
+ h.close();
1178
+ }
1179
+ });
1180
+
1181
+ test("session.status retry -> onError banner ramah, turn tetap hidup sampai idle", async () => {
1182
+ const h = freshHarness();
1183
+ try {
1184
+ const sid = await createSession(h);
1185
+ const client = clientOf(h);
1186
+ await h.sm.sendFreeTextInput(sid, "hello");
1187
+ await flush();
1188
+
1189
+ // Provider error yang bisa di-retry: opencode memancarkan session.status
1190
+ // retry (bukan session.error) — turn BELUM ditutup, tapi Client diberi
1191
+ // tahu kenapa balasan lambat (bukan menunggu diam-diam).
1192
+ client.emit({
1193
+ type: "session.status",
1194
+ sessionID: "ses_remote1",
1195
+ status: {
1196
+ type: "retry",
1197
+ attempt: 1,
1198
+ message: 'Command Code API error 400: {"success":false}',
1199
+ },
1200
+ });
1201
+ expect(h.errors).toHaveLength(1);
1202
+ expect(h.errors[0]?.[0]).toBe(sid);
1203
+ expect(h.errors[0]?.[1]).toBe(
1204
+ 'The model provider is retrying (attempt 1): Command Code API error 400: {"success":false}',
1205
+ );
1206
+ // Turn masih aktif — retry adalah backoff, bukan kegagalan final.
1207
+ expect(h.turns).toEqual([[sid, true]]);
1208
+ expect(h.messages.filter((m) => m.role === "assistant")).toHaveLength(0);
1209
+
1210
+ // Retry berikutnya memakai `action.title` (upsell) bila ada.
1211
+ client.emit({
1212
+ type: "session.status",
1213
+ sessionID: "ses_remote1",
1214
+ status: {
1215
+ type: "retry",
1216
+ attempt: 2,
1217
+ message: "raw message panjang",
1218
+ action: { title: "Free limit reached", message: "Subscribe to continue" },
1219
+ },
1220
+ });
1221
+ expect(h.errors[1]?.[1]).toBe("The model provider is retrying (attempt 2): Free limit reached");
1222
+
1223
+ // Retry sukses -> turn ditutup normal oleh session.idle (tanpa pesan error).
1224
+ client.emit({
1225
+ type: "message.updated",
1226
+ sessionID: "ses_remote1",
1227
+ info: { id: "msg_a1", role: "assistant" },
1228
+ });
1229
+ client.emit({
1230
+ type: "message.part.updated",
1231
+ sessionID: "ses_remote1",
1232
+ part: { type: "text", id: "prt_t1", text: "akhirnya jawaban", messageID: "msg_a1" },
1233
+ });
1234
+ client.emit({ type: "session.idle", sessionID: "ses_remote1" });
1235
+
1236
+ const assistants = h.messages.filter((m) => m.role === "assistant");
1237
+ expect(assistants).toHaveLength(1);
1238
+ expect(assistants[0]?.parts[0]?.text).toBe("akhirnya jawaban");
1239
+ expect(h.turns).toContainEqual([sid, false]);
1240
+ } finally {
1241
+ h.close();
1242
+ }
1243
+ });
1244
+
1245
+ test("session.status non-retry / busy / idle -> tanpa banner, tanpa efek", async () => {
1246
+ const h = freshHarness();
1247
+ try {
1248
+ const sid = await createSession(h);
1249
+ const client = clientOf(h);
1250
+ await h.sm.sendFreeTextInput(sid, "hello");
1251
+ await flush();
1252
+
1253
+ client.emit({ type: "session.status", sessionID: "ses_remote1", status: { type: "busy" } });
1254
+ client.emit({ type: "session.status", sessionID: "ses_remote1", status: { type: "idle" } });
1255
+ client.emit({ type: "session.status", sessionID: "ses_remote1", status: { type: "retry" } }); // tanpa message
1256
+ expect(h.errors).toHaveLength(0);
1257
+
1258
+ // Turn tetap ditutup oleh session.idle seperti biasa.
1259
+ client.emit({ type: "session.idle", sessionID: "ses_remote1" });
1260
+ expect(h.turns).toContainEqual([sid, false]);
1261
+ } finally {
1262
+ h.close();
1263
+ }
1264
+ });
1265
+
1266
+ // ---------------------------------------------------------------------------
1267
+ // Streaming (SSE message.updated / message.part.updated)
1268
+ // ---------------------------------------------------------------------------
1269
+
1270
+ test("streaming: part assistant di-forward via onMessagePart setelah turn aktif", async () => {
1271
+ const h = freshHarness();
1272
+ try {
1273
+ const sid = await createSession(h);
1274
+ const client = clientOf(h);
1275
+
1276
+ // Turn dimulai (sendFreeTextInput membuat streamingTurns entry).
1277
+ await h.sm.sendFreeTextInput(sid, "hitung 2+2");
1278
+
1279
+ // SSE: pesan assistant mulai (message.updated role=assistant).
1280
+ client.emit({
1281
+ type: "message.updated",
1282
+ sessionID: "ses_remote1",
1283
+ info: { id: "msg_a1", role: "assistant" },
1284
+ });
1285
+ // SSE: part reasoning di-stream.
1286
+ client.emit({
1287
+ type: "message.part.updated",
1288
+ sessionID: "ses_remote1",
1289
+ part: { type: "reasoning", id: "prt_r1", text: "pikir...", messageID: "msg_a1" },
1290
+ });
1291
+ // SSE: part text di-stream (berkembang).
1292
+ client.emit({
1293
+ type: "message.part.updated",
1294
+ sessionID: "ses_remote1",
1295
+ part: { type: "text", id: "prt_t1", text: "jawaban", messageID: "msg_a1" },
1296
+ });
1297
+
1298
+ expect(h.messageParts).toHaveLength(2);
1299
+ expect(h.messageParts[0]).toEqual([
1300
+ sid,
1301
+ "msg_a1",
1302
+ { type: "reasoning", id: "prt_r1", text: "pikir...", messageID: "msg_a1" },
1303
+ ]);
1304
+ expect(h.messageParts[1]).toEqual([
1305
+ sid,
1306
+ "msg_a1",
1307
+ { type: "text", id: "prt_t1", text: "jawaban", messageID: "msg_a1" },
1308
+ ]);
1309
+ } finally {
1310
+ h.close();
1311
+ }
1312
+ });
1313
+
1314
+ test("streaming: message.part.delta di-forward & terakumulasi (alur opencode 1.x)", async () => {
1315
+ const h = freshHarness();
1316
+ try {
1317
+ const sid = await createSession(h);
1318
+ const client = clientOf(h);
1319
+ await h.sm.sendFreeTextInput(sid, "halo");
1320
+
1321
+ client.emit({
1322
+ type: "message.updated",
1323
+ sessionID: "ses_remote1",
1324
+ info: { id: "msg_a1", role: "assistant" },
1325
+ });
1326
+ // opencode 1.x: part dibuat dengan teks KOSONG, isi datang lewat delta.
1327
+ client.emit({
1328
+ type: "message.part.updated",
1329
+ sessionID: "ses_remote1",
1330
+ part: { type: "text", id: "prt_t1", text: "", messageID: "msg_a1" },
1331
+ });
1332
+ for (const delta of ["Ku", "cing ", "lucu."]) {
1333
+ client.emit({
1334
+ type: "message.part.delta",
1335
+ sessionID: "ses_remote1",
1336
+ messageID: "msg_a1",
1337
+ partID: "prt_t1",
1338
+ field: "text",
1339
+ delta,
1340
+ });
1341
+ }
1342
+
1343
+ // Tiap delta diteruskan ke Client apa adanya (bukan teks penuh).
1344
+ expect(h.partDeltas).toEqual([
1345
+ [sid, "msg_a1", "prt_t1", "text", "Ku"],
1346
+ [sid, "msg_a1", "prt_t1", "text", "cing "],
1347
+ [sid, "msg_a1", "prt_t1", "text", "lucu."],
1348
+ ]);
1349
+
1350
+ // Tanpa `updated` final pun, teks yang tersimpan saat idle tetap utuh.
1351
+ client.emit({ type: "session.idle", sessionID: "ses_remote1" });
1352
+ const assistant = h.messages.find((m) => m.role === "assistant");
1353
+ expect(assistant?.parts).toEqual([
1354
+ { type: "text", id: "prt_t1", text: "Kucing lucu.", messageID: "msg_a1" },
1355
+ ]);
1356
+ } finally {
1357
+ h.close();
1358
+ }
1359
+ });
1360
+
1361
+ test("streaming: delta untuk part/pesan tak dikenal diabaikan", async () => {
1362
+ const h = freshHarness();
1363
+ try {
1364
+ const sid = await createSession(h);
1365
+ const client = clientOf(h);
1366
+ await h.sm.sendFreeTextInput(sid, "halo");
1367
+ client.emit({
1368
+ type: "message.updated",
1369
+ sessionID: "ses_remote1",
1370
+ info: { id: "msg_a1", role: "assistant" },
1371
+ });
1372
+ // Part belum pernah di-`updated` -> delta diabaikan.
1373
+ client.emit({
1374
+ type: "message.part.delta",
1375
+ sessionID: "ses_remote1",
1376
+ messageID: "msg_a1",
1377
+ partID: "prt_unknown",
1378
+ field: "text",
1379
+ delta: "x",
1380
+ });
1381
+ // Pesan bukan milik turn (mis. pesan user) -> diabaikan.
1382
+ client.emit({
1383
+ type: "message.part.delta",
1384
+ sessionID: "ses_remote1",
1385
+ messageID: "msg_user",
1386
+ partID: "prt_u",
1387
+ field: "text",
1388
+ delta: "y",
1389
+ });
1390
+ expect(h.partDeltas).toHaveLength(0);
1391
+ expect(sid).toBeTruthy();
1392
+ } finally {
1393
+ h.close();
1394
+ }
1395
+ });
1396
+
1397
+ test("streaming: part user / sebelum assistant dikenal tidak di-forward", async () => {
1398
+ const h = freshHarness();
1399
+ try {
1400
+ const sid = await createSession(h);
1401
+ const client = clientOf(h);
1402
+
1403
+ await h.sm.sendFreeTextInput(sid, "hitung 2+2");
1404
+
1405
+ // Part datang SEBELUM assistant dikenal -> diabaikan.
1406
+ client.emit({
1407
+ type: "message.part.updated",
1408
+ sessionID: "ses_remote1",
1409
+ part: { type: "text", id: "prt_u1", text: "user echo", messageID: "msg_user1" },
1410
+ });
1411
+ expect(h.messageParts).toHaveLength(0);
1412
+
1413
+ // Assistant dikenal -> hanya part dengan messageID yang sama diteruskan.
1414
+ client.emit({
1415
+ type: "message.updated",
1416
+ sessionID: "ses_remote1",
1417
+ info: { id: "msg_a1", role: "assistant" },
1418
+ });
1419
+ client.emit({
1420
+ type: "message.part.updated",
1421
+ sessionID: "ses_remote1",
1422
+ part: { type: "text", id: "prt_x", text: "salah", messageID: "msg_lain" },
1423
+ });
1424
+ expect(h.messageParts).toHaveLength(0);
1425
+
1426
+ client.emit({
1427
+ type: "message.part.updated",
1428
+ sessionID: "ses_remote1",
1429
+ part: { type: "text", id: "prt_t1", text: "benar", messageID: "msg_a1" },
1430
+ });
1431
+ expect(h.messageParts).toHaveLength(1);
1432
+ expect(h.messageParts[0]?.[1]).toBe("msg_a1");
1433
+ } finally {
1434
+ h.close();
1435
+ }
1436
+ });
1437
+
1438
+ test("streaming: satu turn dengan beberapa pesan assistant (sub-agent) di-stream semua", async () => {
1439
+ const h = freshHarness();
1440
+ try {
1441
+ const sid = await createSession(h);
1442
+ const client = clientOf(h);
1443
+
1444
+ await h.sm.sendFreeTextInput(sid, "hitung 2+2");
1445
+
1446
+ // Dua pesan assistant dalam satu turn (mis. sub-agent memancarkan pesan sendiri).
1447
+ client.emit({
1448
+ type: "message.updated",
1449
+ sessionID: "ses_remote1",
1450
+ info: { id: "msg_a1", role: "assistant" },
1451
+ });
1452
+ client.emit({
1453
+ type: "message.updated",
1454
+ sessionID: "ses_remote1",
1455
+ info: { id: "msg_a2", role: "assistant" },
1456
+ });
1457
+ client.emit({
1458
+ type: "message.part.updated",
1459
+ sessionID: "ses_remote1",
1460
+ part: { type: "text", id: "prt_1", text: "satu", messageID: "msg_a1" },
1461
+ });
1462
+ client.emit({
1463
+ type: "message.part.updated",
1464
+ sessionID: "ses_remote1",
1465
+ part: { type: "text", id: "prt_2", text: "dua", messageID: "msg_a2" },
1466
+ });
1467
+
1468
+ expect(h.messageParts).toHaveLength(2);
1469
+ expect(h.messageParts.map(([, mid]) => mid).sort()).toEqual(["msg_a1", "msg_a2"]);
1470
+ } finally {
1471
+ h.close();
1472
+ }
1473
+ });
1474
+
1475
+ test("streaming: turn lama selesai tidak menghapus turn baru (race guard)", async () => {
1476
+ const h = freshHarness();
1477
+ try {
1478
+ const sid = await createSession(h);
1479
+ // Dua input berurutan dengan jeda prompt_async berbeda (40ms lalu 500ms).
1480
+ // Input kedua men-finalisasi turn pertama dan memasang entry turn 2;
1481
+ // penyelesaian prompt_async turn 1 yang menyusul tidak boleh menghapus
1482
+ // entry milik turn 2.
1483
+ const client = clientOf(h);
1484
+ client.sendDelays = [40, 500];
1485
+
1486
+ await h.sm.sendFreeTextInput(sid, "input pertama");
1487
+ await h.sm.sendFreeTextInput(sid, "input kedua");
1488
+ client.emit({
1489
+ type: "message.updated",
1490
+ sessionID: "ses_remote1",
1491
+ info: { id: "msg_b1", role: "assistant" },
1492
+ });
1493
+ client.emit({
1494
+ type: "message.part.updated",
1495
+ sessionID: "ses_remote1",
1496
+ part: { type: "text", id: "prt_b", text: "lanjutan", messageID: "msg_b1" },
1497
+ });
1498
+ expect(h.messageParts).toHaveLength(1);
1499
+
1500
+ // Tunggu prompt_async pertama selesai (40ms) — yang kedua masih berjalan.
1501
+ await Bun.sleep(100);
1502
+
1503
+ // Turn kedua harus tetap hidup walau turn pertama sudah selesai lebih dulu.
1504
+ client.emit({
1505
+ type: "message.part.updated",
1506
+ sessionID: "ses_remote1",
1507
+ part: { type: "text", id: "prt_b", text: "lanjutan lagi", messageID: "msg_b1" },
1508
+ });
1509
+ expect(h.messageParts).toHaveLength(2);
1510
+ expect(h.messageParts[1]?.[1]).toBe("msg_b1");
1511
+
1512
+ // Biarkan prompt_async kedua selesai agar tidak ada timer menggantung.
1513
+ await Bun.sleep(500);
1514
+ } finally {
1515
+ h.close();
1516
+ }
1517
+ });
1518
+
1519
+ test("streaming: turn tetap hidup setelah prompt_async, berakhir saat session.idle", async () => {
1520
+ const h = freshHarness();
1521
+ try {
1522
+ const sid = await createSession(h);
1523
+ const client = clientOf(h);
1524
+
1525
+ await h.sm.sendFreeTextInput(sid, "hitung 2+2");
1526
+ client.emit({
1527
+ type: "message.updated",
1528
+ sessionID: "ses_remote1",
1529
+ info: { id: "msg_a1", role: "assistant" },
1530
+ });
1531
+ client.emit({
1532
+ type: "message.part.updated",
1533
+ sessionID: "ses_remote1",
1534
+ part: { type: "text", id: "prt_t1", text: "sebagian", messageID: "msg_a1" },
1535
+ });
1536
+ expect(h.messageParts).toHaveLength(1);
1537
+
1538
+ // prompt_async selesai (204) TIDAK mengakhiri turn — streaming berlanjut.
1539
+ await flush();
1540
+ client.emit({
1541
+ type: "message.part.updated",
1542
+ sessionID: "ses_remote1",
1543
+ part: { type: "text", id: "prt_t1", text: "sebagian lanjutan", messageID: "msg_a1" },
1544
+ });
1545
+ expect(h.messageParts).toHaveLength(2);
1546
+
1547
+ // session.idle menutup turn -> part berikutnya tidak lagi diteruskan.
1548
+ client.emit({ type: "session.idle", sessionID: "ses_remote1" });
1549
+ client.emit({
1550
+ type: "message.part.updated",
1551
+ sessionID: "ses_remote1",
1552
+ part: { type: "text", id: "prt_t1", text: "setelah idle", messageID: "msg_a1" },
1553
+ });
1554
+ expect(h.messageParts).toHaveLength(2);
1555
+ } finally {
1556
+ h.close();
1557
+ }
1558
+ });
1559
+
1560
+ // ---------------------------------------------------------------------------
1561
+ // Interactive_Prompt (permission & question dari event SSE)
1562
+ // ---------------------------------------------------------------------------
1563
+
1564
+ test("event permission.asked -> prompt kind=permission + onPrompt", async () => {
1565
+ const h = freshHarness();
1566
+ try {
1567
+ const sid = await createSession(h);
1568
+ const client = clientOf(h);
1569
+ client.emit({
1570
+ type: "permission.asked",
1571
+ requestID: "per_1",
1572
+ sessionID: "ses_remote1",
1573
+ permission: "bash:ls",
1574
+ patterns: ["ls"],
1575
+ });
1576
+
1577
+ expect(h.prompts).toHaveLength(1);
1578
+ const p = promptOf(h);
1579
+ expect(p.kind).toBe("permission");
1580
+ expect(p.type).toBe("confirmation");
1581
+ expect(p.sessionId).toBe(sid);
1582
+ expect(p.id).toBe("per_1");
1583
+ expect(p.title).toContain("bash:ls");
1584
+ expect(p.status).toBe("pending");
1585
+ } finally {
1586
+ h.close();
1587
+ }
1588
+ });
1589
+
1590
+ test("event question.asked -> prompt kind=question menu + options", async () => {
1591
+ const h = freshHarness();
1592
+ try {
1593
+ const sid = await createSession(h);
1594
+ const client = clientOf(h);
1595
+ client.emit({
1596
+ type: "question.asked",
1597
+ requestID: "que_1",
1598
+ sessionID: "ses_remote1",
1599
+ questions: [
1600
+ {
1601
+ question: "Gunakan mode apa?",
1602
+ header: "mode",
1603
+ options: [
1604
+ { label: "Build", description: "menulis kode" },
1605
+ { label: "Plan", description: "hanya rencana" },
1606
+ ],
1607
+ },
1608
+ ],
1609
+ });
1610
+
1611
+ expect(h.prompts).toHaveLength(1);
1612
+ const p = promptOf(h);
1613
+ expect(p.kind).toBe("question");
1614
+ expect(p.type).toBe("menu");
1615
+ expect(p.sessionId).toBe(sid);
1616
+ expect(p.title).toBe("Gunakan mode apa?");
1617
+ // Opsi dinormalisasi ke PromptOption — description ikut tersimpan.
1618
+ expect(p.options).toEqual([
1619
+ { label: "Build", description: "menulis kode" },
1620
+ { label: "Plan", description: "hanya rencana" },
1621
+ ]);
1622
+ // Flag `custom` default true di skema question opencode.
1623
+ expect(p.custom).toBe(true);
1624
+ } finally {
1625
+ h.close();
1626
+ }
1627
+ });
1628
+
1629
+ test("event question.asked custom=false -> prompt.custom false (tanpa input bebas)", async () => {
1630
+ const h = freshHarness();
1631
+ try {
1632
+ const sid = await createSession(h);
1633
+ const client = clientOf(h);
1634
+ client.emit({
1635
+ type: "question.asked",
1636
+ requestID: "que_c0",
1637
+ sessionID: "ses_remote1",
1638
+ questions: [
1639
+ {
1640
+ question: "Pilih salah satu",
1641
+ header: "pilih",
1642
+ custom: false,
1643
+ options: [{ label: "A", description: "a" }],
1644
+ },
1645
+ ],
1646
+ });
1647
+
1648
+ expect(h.prompts).toHaveLength(1);
1649
+ expect(promptOf(h).custom).toBe(false);
1650
+ expect(promptOf(h).sessionId).toBe(sid);
1651
+ } finally {
1652
+ h.close();
1653
+ }
1654
+ });
1655
+
1656
+ test("sub-agent: permission.asked pada child session -> prompt untuk Session induk", async () => {
1657
+ const h = freshHarness();
1658
+ try {
1659
+ const sid = await createSession(h);
1660
+ const client = clientOf(h);
1661
+
1662
+ // opencode membuat child session saat model memanggil tool `task`.
1663
+ client.emit({
1664
+ type: "session.created",
1665
+ sessionID: "ses_child1",
1666
+ info: { id: "ses_child1", parentID: "ses_remote1", agent: "explore" },
1667
+ });
1668
+
1669
+ // Izin yang diminta sub-agent memakai sessionID child.
1670
+ client.emit({
1671
+ type: "permission.asked",
1672
+ requestID: "per_sub1",
1673
+ sessionID: "ses_child1",
1674
+ permission: "bash",
1675
+ patterns: ["echo hi"],
1676
+ });
1677
+
1678
+ expect(h.prompts).toHaveLength(1);
1679
+ const p = promptOf(h);
1680
+ expect(p.id).toBe("per_sub1");
1681
+ // Prompt diatribusikan ke Session lokal induk agar muncul di UI.
1682
+ expect(p.sessionId).toBe(sid);
1683
+ } finally {
1684
+ h.close();
1685
+ }
1686
+ });
1687
+
1688
+ test("sub-agent: part child session di-stream ke Session induk", async () => {
1689
+ const h = freshHarness();
1690
+ try {
1691
+ const sid = await createSession(h);
1692
+ const client = clientOf(h);
1693
+
1694
+ await h.sm.sendFreeTextInput(sid, "delegasikan ke sub-agent");
1695
+ client.emit({
1696
+ type: "session.created",
1697
+ sessionID: "ses_child1",
1698
+ info: { id: "ses_child1", parentID: "ses_remote1", agent: "explore" },
1699
+ });
1700
+ client.emit({
1701
+ type: "message.updated",
1702
+ sessionID: "ses_child1",
1703
+ info: { id: "msg_sub1", role: "assistant" },
1704
+ });
1705
+ client.emit({
1706
+ type: "message.part.updated",
1707
+ sessionID: "ses_child1",
1708
+ part: { type: "text", id: "prt_s1", text: "hasil sub-agent", messageID: "msg_sub1" },
1709
+ });
1710
+
1711
+ expect(h.messageParts).toHaveLength(1);
1712
+ expect(h.messageParts[0]?.[0]).toBe(sid);
1713
+ expect(h.messageParts[0]?.[1]).toBe("msg_sub1");
1714
+ } finally {
1715
+ h.close();
1716
+ }
1717
+ });
1718
+
1719
+ test("sub-agent: child bersarang (cucu) tetap terpetakan ke Session induk", async () => {
1720
+ const h = freshHarness();
1721
+ try {
1722
+ const sid = await createSession(h);
1723
+ const client = clientOf(h);
1724
+
1725
+ client.emit({
1726
+ type: "session.created",
1727
+ sessionID: "ses_child1",
1728
+ info: { id: "ses_child1", parentID: "ses_remote1" },
1729
+ });
1730
+ // Sub-agent memanggil sub-agent lagi.
1731
+ client.emit({
1732
+ type: "session.created",
1733
+ sessionID: "ses_child2",
1734
+ info: { id: "ses_child2", parentID: "ses_child1" },
1735
+ });
1736
+ client.emit({
1737
+ type: "permission.asked",
1738
+ requestID: "per_deep",
1739
+ sessionID: "ses_child2",
1740
+ permission: "edit",
1741
+ patterns: ["src/a.ts"],
1742
+ });
1743
+
1744
+ expect(h.prompts).toHaveLength(1);
1745
+ expect(h.prompts[0]?.sessionId).toBe(sid);
1746
+ } finally {
1747
+ h.close();
1748
+ }
1749
+ });
1750
+
1751
+ test("session.created tanpa parentID dikenal -> tidak dipetakan", async () => {
1752
+ const h = freshHarness();
1753
+ try {
1754
+ await createSession(h);
1755
+ const client = clientOf(h);
1756
+
1757
+ // Session lain di server yang sama (mis. dibuat TUI) — bukan milik KCG Code.
1758
+ client.emit({
1759
+ type: "session.created",
1760
+ sessionID: "ses_asing",
1761
+ info: { id: "ses_asing", parentID: "ses_bukan_milik_kcgcode" },
1762
+ });
1763
+ client.emit({
1764
+ type: "permission.asked",
1765
+ requestID: "per_asing",
1766
+ sessionID: "ses_asing",
1767
+ permission: "bash",
1768
+ });
1769
+
1770
+ expect(h.prompts).toHaveLength(0);
1771
+ } finally {
1772
+ h.close();
1773
+ }
1774
+ });
1775
+
1776
+ test("session.updated -> judul akar disimpan + onTitleChange; judul sama diabaikan", async () => {
1777
+ const h = freshHarness();
1778
+ try {
1779
+ const sid = await createSession(h);
1780
+ const client = clientOf(h);
1781
+
1782
+ client.emit({
1783
+ type: "session.updated",
1784
+ sessionID: "ses_remote1",
1785
+ info: { id: "ses_remote1", title: "Refactor auth module" },
1786
+ });
1787
+ const sess = h.store.getSession(sid);
1788
+ expect(sess.ok).toBe(true);
1789
+ if (sess.ok) expect(sess.data.title).toBe("Refactor auth module");
1790
+ expect(h.titles).toEqual([[sid, "Refactor auth module"]]);
1791
+
1792
+ // Judul identik -> tanpa update DB, tanpa broadcast ulang.
1793
+ client.emit({
1794
+ type: "session.updated",
1795
+ sessionID: "ses_remote1",
1796
+ info: { id: "ses_remote1", title: "Refactor auth module" },
1797
+ });
1798
+ expect(h.titles).toHaveLength(1);
1799
+ } finally {
1800
+ h.close();
1801
+ }
1802
+ });
1803
+
1804
+ test("createSession tidak mengirim judul -> opencode yang meng-generate judul", async () => {
1805
+ const h = freshHarness();
1806
+ try {
1807
+ await createSession(h);
1808
+ const calls = clientOf(h).calls.filter((c) => c.startsWith("createSession:"));
1809
+ expect(calls).toEqual(["createSession:"]);
1810
+ const sess = h.store.listSessions()[0];
1811
+ expect(sess?.title).toBeNull();
1812
+ } finally {
1813
+ h.close();
1814
+ }
1815
+ });
1816
+
1817
+ test("judul sementara dari prompt pertama, lalu diganti judul hasil opencode", async () => {
1818
+ const h = freshHarness();
1819
+ try {
1820
+ const sid = await createSession(h);
1821
+ const client = clientOf(h);
1822
+
1823
+ await h.sm.sendFreeTextInput(sid, "tolong jelaskan cara kerja rate limiting di express");
1824
+ const temp = h.store.getSession(sid);
1825
+ expect(temp.ok && temp.data.title).toBe("tolong jelaskan cara kerja rate limiting di…");
1826
+
1827
+ // Placeholder opencode (dikirim saat Session dibuat) tidak menimpa judul.
1828
+ client.emit({
1829
+ type: "session.updated",
1830
+ sessionID: "ses_remote1",
1831
+ info: { id: "ses_remote1", title: "New session - 2026-09-29T10:59:18.048Z" },
1832
+ });
1833
+ const still = h.store.getSession(sid);
1834
+ expect(still.ok && still.data.title).toBe("tolong jelaskan cara kerja rate limiting di…");
1835
+
1836
+ // Judul hasil generate opencode menggantikan judul sementara.
1837
+ client.emit({
1838
+ type: "session.updated",
1839
+ sessionID: "ses_remote1",
1840
+ info: { id: "ses_remote1", title: "Cara Kerja Rate Limiting di Express" },
1841
+ });
1842
+ const final = h.store.getSession(sid);
1843
+ expect(final.ok && final.data.title).toBe("Cara Kerja Rate Limiting di Express");
1844
+ expect(h.titles.map(([, t]) => t)).toEqual([
1845
+ "tolong jelaskan cara kerja rate limiting di…",
1846
+ "Cara Kerja Rate Limiting di Express",
1847
+ ]);
1848
+
1849
+ // Prompt kedua tidak mengubah judul lagi.
1850
+ await h.sm.sendFreeTextInput(sid, "lanjut ke nestjs");
1851
+ const after = h.store.getSession(sid);
1852
+ expect(after.ok && after.data.title).toBe("Cara Kerja Rate Limiting di Express");
1853
+ } finally {
1854
+ h.close();
1855
+ }
1856
+ });
1857
+
1858
+ test("session.updated child sub-agent -> judul Session akar tidak tertimpa", async () => {
1859
+ const h = freshHarness();
1860
+ try {
1861
+ const sid = await createSession(h);
1862
+ const client = clientOf(h);
1863
+ client.emit({
1864
+ type: "session.created",
1865
+ sessionID: "ses_child1",
1866
+ info: { id: "ses_child1", parentID: "ses_remote1" },
1867
+ });
1868
+
1869
+ // Sub-agent meng-generate judulnya sendiri — bukan judul percakapan user.
1870
+ client.emit({
1871
+ type: "session.updated",
1872
+ sessionID: "ses_child1",
1873
+ info: { id: "ses_child1", title: "Judul sub-agent" },
1874
+ });
1875
+ const sess = h.store.getSession(sid);
1876
+ expect(sess.ok).toBe(true);
1877
+ if (sess.ok) expect(sess.data.title).toBeNull();
1878
+ expect(h.titles).toHaveLength(0);
1879
+
1880
+ // Judul akar tetap sampai setelah event child.
1881
+ client.emit({
1882
+ type: "session.updated",
1883
+ sessionID: "ses_remote1",
1884
+ info: { id: "ses_remote1", title: "Judul asli" },
1885
+ });
1886
+ expect(h.titles).toEqual([[sid, "Judul asli"]]);
1887
+ } finally {
1888
+ h.close();
1889
+ }
1890
+ });
1891
+
1892
+ test("session.updated session tak dikenal / info rusak -> diabaikan", async () => {
1893
+ const h = freshHarness();
1894
+ try {
1895
+ await createSession(h);
1896
+ const client = clientOf(h);
1897
+
1898
+ client.emit({
1899
+ type: "session.updated",
1900
+ sessionID: "ses_asing",
1901
+ info: { id: "ses_asing", title: "bukan milik KCG Code" },
1902
+ });
1903
+ client.emit({
1904
+ type: "session.updated",
1905
+ sessionID: "ses_remote1",
1906
+ info: { id: "ses_remote1", title: " " },
1907
+ });
1908
+ client.emit({
1909
+ type: "session.updated",
1910
+ sessionID: "ses_remote1",
1911
+ info: { id: "ses_remote1", title: 123 },
1912
+ });
1913
+ client.emit({ type: "session.updated", sessionID: "ses_remote1" });
1914
+
1915
+ expect(h.titles).toHaveLength(0);
1916
+ expect(h.store.listSessions().every((s) => s.title === null)).toBe(true);
1917
+ } finally {
1918
+ h.close();
1919
+ }
1920
+ });
1921
+
1922
+ test("stopSession melepas pemetaan child sub-agent", async () => {
1923
+ const h = freshHarness();
1924
+ try {
1925
+ const sid = await createSession(h);
1926
+ const client = clientOf(h);
1927
+ client.emit({
1928
+ type: "session.created",
1929
+ sessionID: "ses_child1",
1930
+ info: { id: "ses_child1", parentID: "ses_remote1" },
1931
+ });
1932
+
1933
+ expect(h.sm.stopSession(sid).ok).toBe(true);
1934
+
1935
+ // Event child setelah stop tidak lagi menghasilkan prompt.
1936
+ client.emit({
1937
+ type: "permission.asked",
1938
+ requestID: "per_setelah_stop",
1939
+ sessionID: "ses_child1",
1940
+ permission: "bash",
1941
+ });
1942
+ expect(h.prompts).toHaveLength(0);
1943
+ } finally {
1944
+ h.close();
1945
+ }
1946
+ });
1947
+
1948
+ test("event permission.v2.asked (action/resources) -> prompt confirmation", async () => {
1949
+ const h = freshHarness();
1950
+ try {
1951
+ const sid = await createSession(h);
1952
+ const client = clientOf(h);
1953
+ client.emit({
1954
+ type: "permission.v2.asked",
1955
+ id: "per_v2",
1956
+ sessionID: "ses_remote1",
1957
+ action: "bash",
1958
+ resources: ["echo hi"],
1959
+ });
1960
+
1961
+ expect(h.prompts).toHaveLength(1);
1962
+ const p = promptOf(h);
1963
+ expect(p.id).toBe("per_v2");
1964
+ expect(p.kind).toBe("permission");
1965
+ expect(p.type).toBe("confirmation");
1966
+ expect(p.sessionId).toBe(sid);
1967
+ // Judul dirakit dari `action` + `resources` (penamaan v2).
1968
+ expect(p.title).toContain("bash");
1969
+ expect(p.title).toContain("echo hi");
1970
+ } finally {
1971
+ h.close();
1972
+ }
1973
+ });
1974
+
1975
+ test("event question.v2.asked -> prompt menu + options", async () => {
1976
+ const h = freshHarness();
1977
+ try {
1978
+ const sid = await createSession(h);
1979
+ const client = clientOf(h);
1980
+ client.emit({
1981
+ type: "question.v2.asked",
1982
+ id: "que_v2",
1983
+ sessionID: "ses_remote1",
1984
+ questions: [
1985
+ {
1986
+ question: "Lanjut deploy?",
1987
+ header: "deploy",
1988
+ options: [{ label: "Ya" }, { label: "Tidak" }],
1989
+ },
1990
+ ],
1991
+ });
1992
+
1993
+ expect(h.prompts).toHaveLength(1);
1994
+ const p = promptOf(h);
1995
+ expect(p.id).toBe("que_v2");
1996
+ expect(p.kind).toBe("question");
1997
+ expect(p.type).toBe("menu");
1998
+ expect(p.sessionId).toBe(sid);
1999
+ expect(p.title).toBe("Lanjut deploy?");
2000
+ expect(p.options).toEqual([
2001
+ { label: "Ya", description: null },
2002
+ { label: "Tidak", description: null },
2003
+ ]);
2004
+ } finally {
2005
+ h.close();
2006
+ }
2007
+ });
2008
+
2009
+ test("v1 + v2 untuk request yang sama -> kartu tidak terduplikasi", async () => {
2010
+ const h = freshHarness();
2011
+ try {
2012
+ await createSession(h);
2013
+ const client = clientOf(h);
2014
+
2015
+ // Server memancarkan kedua varian untuk satu request (id sama).
2016
+ client.emit({
2017
+ type: "permission.asked",
2018
+ id: "per_sama",
2019
+ sessionID: "ses_remote1",
2020
+ permission: "bash",
2021
+ patterns: ["ls"],
2022
+ });
2023
+ client.emit({
2024
+ type: "permission.v2.asked",
2025
+ id: "per_sama",
2026
+ sessionID: "ses_remote1",
2027
+ action: "bash",
2028
+ resources: ["ls"],
2029
+ });
2030
+
2031
+ // `prompts.id` PRIMARY KEY menjadikan insert kedua gagal -> satu kartu.
2032
+ expect(h.prompts).toHaveLength(1);
2033
+ expect(h.prompts[0]?.id).toBe("per_sama");
2034
+ } finally {
2035
+ h.close();
2036
+ }
2037
+ });
2038
+
2039
+ test("prompt v2 dapat diselesaikan lewat resolvePrompt seperti v1", async () => {
2040
+ const h = freshHarness();
2041
+ try {
2042
+ const sid = await createSession(h);
2043
+ const client = clientOf(h);
2044
+ client.emit({
2045
+ type: "permission.v2.asked",
2046
+ id: "per_v2_resolve",
2047
+ sessionID: "ses_remote1",
2048
+ action: "edit",
2049
+ resources: ["src/a.ts"],
2050
+ });
2051
+
2052
+ // Endpoint reply dipakai bersama v1/v2 (tidak ada rute v2 terpisah).
2053
+ const r = await h.sm.resolvePrompt(sid, "per_v2_resolve", "approve");
2054
+ expect(r.ok).toBe(true);
2055
+ expect(client.calls).toContain("replyPermission:per_v2_resolve:once");
2056
+
2057
+ const stored = h.store.getPrompt("per_v2_resolve");
2058
+ expect(stored.ok && stored.data.status).toBe("resolved");
2059
+ } finally {
2060
+ h.close();
2061
+ }
2062
+ });
2063
+
2064
+ test("event permission.v2.asked pada child sub-agent -> prompt untuk Session induk", async () => {
2065
+ const h = freshHarness();
2066
+ try {
2067
+ const sid = await createSession(h);
2068
+ const client = clientOf(h);
2069
+ client.emit({
2070
+ type: "session.created",
2071
+ sessionID: "ses_child1",
2072
+ info: { id: "ses_child1", parentID: "ses_remote1" },
2073
+ });
2074
+ client.emit({
2075
+ type: "permission.v2.asked",
2076
+ id: "per_v2_sub",
2077
+ sessionID: "ses_child1",
2078
+ action: "bash",
2079
+ resources: ["echo sub"],
2080
+ });
2081
+
2082
+ expect(h.prompts).toHaveLength(1);
2083
+ expect(h.prompts[0]?.sessionId).toBe(sid);
2084
+ } finally {
2085
+ h.close();
2086
+ }
2087
+ });
2088
+
2089
+ test("createSession + sendFreeTextInput meneruskan agent tersimpan ke promptAsync", async () => {
2090
+ const h = freshHarness();
2091
+ try {
2092
+ const sid = await createSession(h, "opencode");
2093
+ expect(h.sm.setSessionAgent(sid, "plan").ok).toBe(true);
2094
+ // Status Session tetap terbaca dan agent tersimpan.
2095
+ const cur = h.store.getSession(sid);
2096
+ expect(cur.ok && cur.data.agent).toBe("plan");
2097
+
2098
+ const r = await h.sm.sendFreeTextInput(sid, "buatkan rencana");
2099
+ expect(r.ok).toBe(true);
2100
+ await flush();
2101
+ expect(clientOf(h).promptAgents).toEqual(["plan"]);
2102
+
2103
+ // Kembali ke default -> tanpa field agent di prompt berikutnya.
2104
+ expect(h.sm.setSessionAgent(sid, null).ok).toBe(true);
2105
+ await h.sm.sendFreeTextInput(sid, "lanjut eksekusi");
2106
+ await flush();
2107
+ expect(clientOf(h).promptAgents).toEqual(["plan", null]);
2108
+ } finally {
2109
+ h.close();
2110
+ }
2111
+ });
2112
+
2113
+ test("sendFreeTextInput meneruskan custom instruction Project sebagai system", async () => {
2114
+ const h = freshHarness();
2115
+ try {
2116
+ const sid = await createSession(h, "opencode");
2117
+
2118
+ // Tanpa instruksi -> system null.
2119
+ await h.sm.sendFreeTextInput(sid, "halo");
2120
+ await flush();
2121
+
2122
+ // Instruksi diubah setelah Session dibuat -> langsung berlaku.
2123
+ expect(
2124
+ h.store.updateProjectInstructions(h.project.id, "Selalu jawab dalam Bahasa Indonesia.").ok,
2125
+ ).toBe(true);
2126
+ await h.sm.sendFreeTextInput(sid, "lanjut");
2127
+ await flush();
2128
+
2129
+ expect(clientOf(h).promptSystems).toEqual([null, "Selalu jawab dalam Bahasa Indonesia."]);
2130
+ } finally {
2131
+ h.close();
2132
+ }
2133
+ });
2134
+
2135
+ test("setSessionAgent: session tidak ada / nama kosong -> ditolak", async () => {
2136
+ const h = freshHarness();
2137
+ try {
2138
+ const sid = await createSession(h);
2139
+ expect(h.sm.setSessionAgent("unknown", "plan").error).toBe("SESSION_NOT_FOUND");
2140
+ expect(h.sm.setSessionAgent(sid, " ").error).toBe("INVALID_AGENT");
2141
+ } finally {
2142
+ h.close();
2143
+ }
2144
+ });
2145
+
2146
+ test("listAgents -> daftar agent server headless", async () => {
2147
+ const h = freshHarness();
2148
+ try {
2149
+ await createSession(h); // pastikan server sudah di-ensure
2150
+ const res = await h.sm.listAgents("p1");
2151
+ expect(res.ok).toBe(true);
2152
+ if (res.ok) expect(res.data.map((a) => a.name)).toEqual(["build", "plan"]);
2153
+ } finally {
2154
+ h.close();
2155
+ }
2156
+ });
2157
+
2158
+ test("listMcp / listSkills -> diteruskan ke server Project (skills menerima path Project)", async () => {
2159
+ const h = freshHarness();
2160
+ try {
2161
+ const mcp = await h.sm.listMcp("p1");
2162
+ expect(mcp.ok && mcp.data.map((m) => m.status)).toEqual(["connected", "failed"]);
2163
+
2164
+ const skills = await h.sm.listSkills("p1");
2165
+ expect(skills.ok && skills.data[0]?.name).toBe("shadcn");
2166
+ expect(clientOf(h).calls).toContain(`listSkills:${h.project.path}`);
2167
+
2168
+ expect((await h.sm.listMcp("nope")).ok).toBe(false);
2169
+ expect((await h.sm.listSkills("nope")).ok).toBe(false);
2170
+ } finally {
2171
+ h.close();
2172
+ }
2173
+ });
2174
+
2175
+ test("refreshSkills: server belum hidup -> tanpa dispose; Project tak dikenal -> error", async () => {
2176
+ const h = freshHarness();
2177
+ try {
2178
+ const res = await h.sm.refreshSkills("p1");
2179
+ expect(res.ok && res.data.refreshed).toBe(true);
2180
+ expect(h.fake.clients.get("p1")).toBeUndefined();
2181
+ expect((await h.sm.refreshSkills("nope")).ok).toBe(false);
2182
+ } finally {
2183
+ h.close();
2184
+ }
2185
+ });
2186
+
2187
+ test("refreshSkills: server idle -> dispose; ada turn aktif -> dilewati", async () => {
2188
+ const h = freshHarness();
2189
+ try {
2190
+ const sid = await createSession(h);
2191
+ const idle = await h.sm.refreshSkills("p1");
2192
+ expect(idle.ok && idle.data.refreshed).toBe(true);
2193
+ expect(clientOf(h).calls).toContain("disposeInstance");
2194
+
2195
+ // Mulai turn (belum ada session.idle) -> refresh ditunda.
2196
+ await h.sm.sendFreeTextInput(sid, "halo");
2197
+ const before = clientOf(h).calls.filter((c) => c === "disposeInstance").length;
2198
+ const busy = await h.sm.refreshSkills("p1");
2199
+ expect(busy.ok && busy.data.refreshed).toBe(false);
2200
+ expect(clientOf(h).calls.filter((c) => c === "disposeInstance")).toHaveLength(before);
2201
+ } finally {
2202
+ h.close();
2203
+ }
2204
+ });
2205
+
2206
+ /**
2207
+ * Harness dengan timer manual: refresh skill tertunda dijadwalkan lewat
2208
+ * `setTimeoutFn`, jadi test dapat memicu jedanya secara deterministik.
2209
+ */
2210
+ function refreshHarness() {
2211
+ const timers: { cb: () => void; ms: number; cleared: boolean }[] = [];
2212
+ const refreshed: string[] = [];
2213
+ const h = freshHarnessWithHooks(
2214
+ {},
2215
+ {},
2216
+ {
2217
+ setTimeoutFn: (cb, ms) => {
2218
+ const t = { cb, ms, cleared: false };
2219
+ timers.push(t);
2220
+ return t;
2221
+ },
2222
+ clearTimeoutFn: (t) => {
2223
+ (t as { cleared: boolean }).cleared = true;
2224
+ },
2225
+ skillRefreshDelayMs: 1500,
2226
+ onSkillsRefreshed: (projectId) => refreshed.push(projectId),
2227
+ },
2228
+ );
2229
+ /** Jalankan timer refresh (jeda 1500 ms) yang masih aktif. */
2230
+ const fireRefreshTimers = async () => {
2231
+ for (const t of timers.filter((x) => x.ms === 1500 && !x.cleared)) {
2232
+ t.cleared = true;
2233
+ t.cb();
2234
+ }
2235
+ await flush();
2236
+ };
2237
+ const disposeCount = () => clientOf(h).calls.filter((c) => c === "disposeInstance").length;
2238
+ return { h, timers, refreshed, fireRefreshTimers, disposeCount };
2239
+ }
2240
+
2241
+ test("refresh skill tertunda: dijalankan otomatis setelah turn selesai", async () => {
2242
+ const { h, refreshed, fireRefreshTimers, disposeCount } = refreshHarness();
2243
+ try {
2244
+ const sid = await createSession(h);
2245
+ await h.sm.sendFreeTextInput(sid, "halo");
2246
+ await flush();
2247
+
2248
+ const res = await h.sm.refreshSkills("p1");
2249
+ expect(res.ok && res.data.refreshed).toBe(false);
2250
+ expect(h.sm.hasPendingSkillRefresh("p1")).toBe(true);
2251
+ expect(disposeCount()).toBe(0);
2252
+
2253
+ // Turn selesai -> timer dijadwalkan, belum dispose sebelum jeda lewat.
2254
+ clientOf(h).emit({ type: "session.idle", sessionID: "ses_remote1" });
2255
+ await flush();
2256
+ expect(disposeCount()).toBe(0);
2257
+
2258
+ await fireRefreshTimers();
2259
+ expect(disposeCount()).toBe(1);
2260
+ expect(h.sm.hasPendingSkillRefresh("p1")).toBe(false);
2261
+ expect(refreshed).toEqual(["p1"]);
2262
+
2263
+ // Turn berikutnya tidak memicu dispose lagi.
2264
+ await h.sm.sendFreeTextInput(sid, "lagi");
2265
+ await flush();
2266
+ clientOf(h).emit({ type: "session.idle", sessionID: "ses_remote1" });
2267
+ await flush();
2268
+ await fireRefreshTimers();
2269
+ expect(disposeCount()).toBe(1);
2270
+ } finally {
2271
+ h.close();
2272
+ }
2273
+ });
2274
+
2275
+ test("refresh skill tertunda: turn baru dalam jeda membatalkan timer, ditunda lagi", async () => {
2276
+ const { h, refreshed, fireRefreshTimers, disposeCount, timers } = refreshHarness();
2277
+ try {
2278
+ const sid = await createSession(h);
2279
+ await h.sm.sendFreeTextInput(sid, "satu");
2280
+ await flush();
2281
+ await h.sm.refreshSkills("p1");
2282
+
2283
+ clientOf(h).emit({ type: "session.idle", sessionID: "ses_remote1" });
2284
+ await flush();
2285
+ const scheduled = timers.filter((t) => t.ms === 1500);
2286
+ expect(scheduled).toHaveLength(1);
2287
+
2288
+ // User langsung kirim pesan lagi sebelum jeda habis -> timer dibatalkan.
2289
+ await h.sm.sendFreeTextInput(sid, "dua");
2290
+ await flush();
2291
+ expect(scheduled[0]?.cleared).toBe(true);
2292
+ await fireRefreshTimers();
2293
+ expect(disposeCount()).toBe(0);
2294
+ expect(h.sm.hasPendingSkillRefresh("p1")).toBe(true);
2295
+
2296
+ // Turn kedua selesai -> baru dispose.
2297
+ clientOf(h).emit({ type: "session.idle", sessionID: "ses_remote1" });
2298
+ await flush();
2299
+ await fireRefreshTimers();
2300
+ expect(disposeCount()).toBe(1);
2301
+ expect(refreshed).toEqual(["p1"]);
2302
+ } finally {
2303
+ h.close();
2304
+ }
2305
+ });
2306
+
2307
+ test("refresh skill tertunda: menunggu SEMUA Session di Project selesai", async () => {
2308
+ const { h, fireRefreshTimers, disposeCount } = refreshHarness();
2309
+ try {
2310
+ const a = await createSession(h);
2311
+ clientOf(h).createSessionResult = { ok: true, id: "ses_remote2" };
2312
+ const b = await createSession(h);
2313
+ await h.sm.sendFreeTextInput(a, "a");
2314
+ await h.sm.sendFreeTextInput(b, "b");
2315
+ await flush();
2316
+ await h.sm.refreshSkills("p1");
2317
+
2318
+ // Session A selesai, B masih berjalan -> tidak ada dispose.
2319
+ clientOf(h).emit({ type: "session.idle", sessionID: "ses_remote1" });
2320
+ await flush();
2321
+ await fireRefreshTimers();
2322
+ expect(disposeCount()).toBe(0);
2323
+
2324
+ // B selesai -> dispose.
2325
+ clientOf(h).emit({ type: "session.idle", sessionID: "ses_remote2" });
2326
+ await flush();
2327
+ await fireRefreshTimers();
2328
+ expect(disposeCount()).toBe(1);
2329
+ } finally {
2330
+ h.close();
2331
+ }
2332
+ });
2333
+
2334
+ test("refresh skill tertunda: dispose gagal -> tetap tertunda, dicoba lagi turn berikutnya", async () => {
2335
+ const { h, refreshed, fireRefreshTimers, disposeCount } = refreshHarness();
2336
+ try {
2337
+ const sid = await createSession(h);
2338
+ await h.sm.sendFreeTextInput(sid, "x");
2339
+ await flush();
2340
+ await h.sm.refreshSkills("p1");
2341
+
2342
+ const client = clientOf(h);
2343
+ const original = client.disposeInstance.bind(client);
2344
+ let fail = true;
2345
+ client.disposeInstance = async () => {
2346
+ client.calls.push("disposeInstance");
2347
+ return fail
2348
+ ? { ok: false, error: "OC_DISPOSE_FAILED(500)" }
2349
+ : original().then(() => ({ ok: true }));
2350
+ };
2351
+
2352
+ client.emit({ type: "session.idle", sessionID: "ses_remote1" });
2353
+ await flush();
2354
+ await fireRefreshTimers();
2355
+ expect(disposeCount()).toBe(1);
2356
+ expect(h.sm.hasPendingSkillRefresh("p1")).toBe(true);
2357
+ expect(refreshed).toEqual([]);
2358
+
2359
+ fail = false;
2360
+ await h.sm.sendFreeTextInput(sid, "y");
2361
+ await flush();
2362
+ client.emit({ type: "session.idle", sessionID: "ses_remote1" });
2363
+ await flush();
2364
+ await fireRefreshTimers();
2365
+ expect(h.sm.hasPendingSkillRefresh("p1")).toBe(false);
2366
+ expect(refreshed).toEqual(["p1"]);
2367
+ } finally {
2368
+ h.close();
2369
+ }
2370
+ });
2371
+
2372
+ test("refresh skill tertunda: shutdown membersihkan timer & status tertunda", async () => {
2373
+ const { h, fireRefreshTimers } = refreshHarness();
2374
+ try {
2375
+ const sid = await createSession(h);
2376
+ await h.sm.sendFreeTextInput(sid, "x");
2377
+ await flush();
2378
+ await h.sm.refreshSkills("p1");
2379
+ // Referensi client disimpan: shutdown menghentikan server (client dilepas).
2380
+ const client = clientOf(h);
2381
+ client.emit({ type: "session.idle", sessionID: "ses_remote1" });
2382
+ await flush();
2383
+
2384
+ await h.sm.shutdown();
2385
+ expect(h.sm.hasPendingSkillRefresh("p1")).toBe(false);
2386
+ await fireRefreshTimers();
2387
+ expect(client.calls.filter((c) => c === "disposeInstance")).toHaveLength(0);
2388
+ } finally {
2389
+ h.close();
2390
+ }
2391
+ });
2392
+
2393
+ test("event permission.asked dengan sessionID tak dikenal -> diabaikan", async () => {
2394
+ const h = freshHarness();
2395
+ try {
2396
+ await createSession(h);
2397
+ clientOf(h).emit({
2398
+ type: "permission.asked",
2399
+ requestID: "per_x",
2400
+ sessionID: "ses_tak_dikenal",
2401
+ });
2402
+ expect(h.prompts).toHaveLength(0);
2403
+ } finally {
2404
+ h.close();
2405
+ }
2406
+ });
2407
+
2408
+ // ---------------------------------------------------------------------------
2409
+ // Resolusi prompt
2410
+ // ---------------------------------------------------------------------------
2411
+
2412
+ async function seedPrompt(
2413
+ h: Harness,
2414
+ sid: string,
2415
+ kind: "permission" | "question",
2416
+ id: string,
2417
+ ): Promise<string> {
2418
+ const prompt: InteractivePrompt = {
2419
+ id,
2420
+ sessionId: sid,
2421
+ kind,
2422
+ type: kind === "permission" ? "confirmation" : "menu",
2423
+ title: kind === "permission" ? "bash:ls" : "Pilih opsi",
2424
+ options:
2425
+ kind === "permission"
2426
+ ? null
2427
+ : [
2428
+ { label: "A", description: null },
2429
+ { label: "B", description: null },
2430
+ ],
2431
+ status: "pending",
2432
+ createdAt: 1,
2433
+ resolvedAt: null,
2434
+ };
2435
+ const ins = h.store.insertPrompt(prompt);
2436
+ expect(ins.ok).toBe(true);
2437
+ return prompt.id;
2438
+ }
2439
+
2440
+ test("resolvePrompt permission: approve -> once; deny/cancel -> reject; lalu resolved", async () => {
2441
+ const h = freshHarness();
2442
+ try {
2443
+ const sid = await createSession(h);
2444
+ const client = clientOf(h);
2445
+
2446
+ const pid1 = await seedPrompt(h, sid, "permission", "per_1");
2447
+ const r1 = await h.sm.resolvePrompt(sid, pid1, "approve");
2448
+ expect(r1.ok).toBe(true);
2449
+ expect(client.calls).toContain("replyPermission:per_1:once");
2450
+
2451
+ const pid2 = await seedPrompt(h, sid, "permission", "per_2");
2452
+ await h.sm.resolvePrompt(sid, pid2, "deny");
2453
+ expect(client.calls).toContain("replyPermission:per_2:reject");
2454
+
2455
+ const pid3 = await seedPrompt(h, sid, "permission", "per_3");
2456
+ await h.sm.resolvePrompt(sid, pid3, "cancel");
2457
+ expect(client.calls).toContain("replyPermission:per_3:reject");
2458
+
2459
+ // Semua resolved.
2460
+ for (const id of [pid1, pid2, pid3]) {
2461
+ const p = h.store.getPrompt(id);
2462
+ expect(p.ok && p.data.status).toBe("resolved");
2463
+ }
2464
+ } finally {
2465
+ h.close();
2466
+ }
2467
+ });
2468
+
2469
+ test("resolvePrompt question: option -> replyQuestion; cancel -> rejectQuestion", async () => {
2470
+ const h = freshHarness();
2471
+ try {
2472
+ const sid = await createSession(h);
2473
+ const client = clientOf(h);
2474
+
2475
+ const pid1 = await seedPrompt(h, sid, "question", "que_1");
2476
+ const r1 = await h.sm.resolvePrompt(sid, pid1, { option: "A" });
2477
+ expect(r1.ok).toBe(true);
2478
+ expect(client.calls).toContain("replyQuestion:que_1:A");
2479
+
2480
+ // Jawaban question TIDAK di-echo ke riwayat chat (transcript bersih).
2481
+ expect(h.messages.filter((m) => m.role === "user")).toHaveLength(0);
2482
+
2483
+ const pid2 = await seedPrompt(h, sid, "question", "que_2");
2484
+ const r2 = await h.sm.resolvePrompt(sid, pid2, "cancel");
2485
+ expect(r2.ok).toBe(true);
2486
+ expect(client.calls).toContain("rejectQuestion:que_2");
2487
+ expect(h.messages.filter((m) => m.role === "user")).toHaveLength(0);
2488
+ } finally {
2489
+ h.close();
2490
+ }
2491
+ });
2492
+
2493
+ test("resolvePrompt question multi-select -> replyQuestion dengan seluruh label terpilih", async () => {
2494
+ const h = freshHarness();
2495
+ try {
2496
+ const sid = await createSession(h);
2497
+ const client = clientOf(h);
2498
+
2499
+ // Event question dengan multiple=true (skema tool `question` opencode).
2500
+ client.emit({
2501
+ type: "question.asked",
2502
+ requestID: "que_multi",
2503
+ sessionID: "ses_remote1",
2504
+ questions: [
2505
+ {
2506
+ question: "Mau eksplor bagian mana?",
2507
+ multiple: true,
2508
+ options: [
2509
+ { label: "Debug", description: "investigasi bug" },
2510
+ { label: "Config", description: "cek settings" },
2511
+ { label: "Monitor", description: "logs & performa" },
2512
+ ],
2513
+ },
2514
+ ],
2515
+ });
2516
+
2517
+ expect(h.prompts).toHaveLength(1);
2518
+ const p = h.prompts[0] as InteractivePrompt;
2519
+ expect(p.multiple).toBe(true);
2520
+ // Description per opsi ikut tersimpan, tidak dibuang lagi.
2521
+ expect(p.options).toEqual([
2522
+ { label: "Debug", description: "investigasi bug" },
2523
+ { label: "Config", description: "cek settings" },
2524
+ { label: "Monitor", description: "logs & performa" },
2525
+ ]);
2526
+
2527
+ // Jawaban multi-select: seluruh label dalam SATU panggilan reply.
2528
+ const r = await h.sm.resolvePrompt(sid, "que_multi", { options: ["Debug", "Monitor"] });
2529
+ expect(r.ok).toBe(true);
2530
+ expect(client.calls).toContain("replyQuestion:que_multi:Debug,Monitor");
2531
+ } finally {
2532
+ h.close();
2533
+ }
2534
+ });
2535
+
2536
+ test("event question multi-question -> prompt.questions lengkap; jawaban per pertanyaan", async () => {
2537
+ const h = freshHarness();
2538
+ try {
2539
+ const sid = await createSession(h);
2540
+ const client = clientOf(h);
2541
+
2542
+ // Satu request berisi DUA pertanyaan (skema tool `question` opencode).
2543
+ client.emit({
2544
+ type: "question.asked",
2545
+ requestID: "que_batch",
2546
+ sessionID: "ses_remote1",
2547
+ questions: [
2548
+ {
2549
+ question: "Mau eksplor bagian mana?",
2550
+ multiple: true,
2551
+ options: [
2552
+ { label: "Debug", description: "investigasi bug" },
2553
+ { label: "Config", description: "cek settings" },
2554
+ ],
2555
+ },
2556
+ {
2557
+ question: "Kapan mulai?",
2558
+ options: [{ label: "Sekarang" }, { label: "Nanti" }],
2559
+ },
2560
+ ],
2561
+ });
2562
+
2563
+ expect(h.prompts).toHaveLength(1);
2564
+ const p = h.prompts[0] as InteractivePrompt;
2565
+ // Seluruh pertanyaan tersimpan; pertanyaan pertama juga di field legacy.
2566
+ expect(p.questions).toHaveLength(2);
2567
+ expect(p.questions?.[0]?.question).toBe("Mau eksplor bagian mana?");
2568
+ expect(p.questions?.[0]?.multiple).toBe(true);
2569
+ expect(p.questions?.[1]?.question).toBe("Kapan mulai?");
2570
+ expect(p.title).toBe("Mau eksplor bagian mana?");
2571
+ expect(p.options?.map((o) => o.label)).toEqual(["Debug", "Config"]);
2572
+
2573
+ // Jawaban batch: SATU array label per pertanyaan, urut sesuai questions.
2574
+ const r = await h.sm.resolvePrompt(sid, "que_batch", {
2575
+ answers: [["Debug", "Config"], ["Nanti"]],
2576
+ });
2577
+ expect(r.ok).toBe(true);
2578
+ // Reply API menerima array-of-array (skema Question.Reply).
2579
+ expect(client.calls).toContain("replyQuestion:que_batch:Debug,Config|Nanti");
2580
+ } finally {
2581
+ h.close();
2582
+ }
2583
+ });
2584
+
2585
+ // Multi-question hanya disimpan bila ada >1 pertanyaan — request tunggal
2586
+ // tidak menambah data duplikat di DB.
2587
+ test("event question tunggal -> prompt.questions undefined", async () => {
2588
+ const h = freshHarness();
2589
+ try {
2590
+ await createSession(h);
2591
+ clientOf(h).emit({
2592
+ type: "question.asked",
2593
+ requestID: "que_satu",
2594
+ sessionID: "ses_remote1",
2595
+ questions: [{ question: "Lanjut?", options: [{ label: "Ya" }] }],
2596
+ });
2597
+ expect(h.prompts).toHaveLength(1);
2598
+ expect((h.prompts[0] as InteractivePrompt).questions).toBeUndefined();
2599
+ } finally {
2600
+ h.close();
2601
+ }
2602
+ });
2603
+
2604
+ test("resolvePrompt permission approve -> TIDAK di-echo ke chat", async () => {
2605
+ const h = freshHarness();
2606
+ try {
2607
+ const sid = await createSession(h);
2608
+ const pid = await seedPrompt(h, sid, "permission", "per_echo");
2609
+ await h.sm.resolvePrompt(sid, pid, "approve");
2610
+ expect(h.messages.filter((m) => m.role === "user")).toHaveLength(0);
2611
+ } finally {
2612
+ h.close();
2613
+ }
2614
+ });
2615
+
2616
+ test("resolvePrompt permission identik bertumpuk -> satu jawaban di-fan-out ke semuanya", async () => {
2617
+ const h = freshHarness();
2618
+ try {
2619
+ const sid = await createSession(h);
2620
+ const client = clientOf(h);
2621
+
2622
+ // Tiga request identik (opencode memancarkan satu per tool call).
2623
+ for (const id of ["per_a", "per_b", "per_c"]) {
2624
+ client.emit({
2625
+ type: "permission.asked",
2626
+ requestID: id,
2627
+ sessionID: "ses_remote1",
2628
+ permission: "external_directory",
2629
+ patterns: ["D:\\Proj\\**"],
2630
+ });
2631
+ }
2632
+ expect(h.prompts).toHaveLength(3);
2633
+
2634
+ // Satu klik Approve pada kartu grup -> ketiganya di-reply & resolved.
2635
+ const r = await h.sm.resolvePrompt(sid, "per_b", "approve");
2636
+ expect(r.ok).toBe(true);
2637
+ for (const id of ["per_a", "per_b", "per_c"]) {
2638
+ expect(client.calls).toContain(`replyPermission:${id}:once`);
2639
+ const p = h.store.getPrompt(id);
2640
+ expect(p.ok && p.data.status).toBe("resolved");
2641
+ }
2642
+
2643
+ // Deny juga berlaku ke grup.
2644
+ for (const id of ["per_d", "per_e"]) {
2645
+ client.emit({
2646
+ type: "permission.asked",
2647
+ requestID: id,
2648
+ sessionID: "ses_remote1",
2649
+ permission: "external_directory",
2650
+ patterns: ["D:\\Proj\\**"],
2651
+ });
2652
+ }
2653
+ await h.sm.resolvePrompt(sid, "per_d", "deny");
2654
+ for (const id of ["per_d", "per_e"]) {
2655
+ expect(client.calls).toContain(`replyPermission:${id}:reject`);
2656
+ const p = h.store.getPrompt(id);
2657
+ expect(p.ok && p.data.status).toBe("resolved");
2658
+ }
2659
+ // Twin yang ikut resolved dilaporkan via hook (notif WS prompt_resolved).
2660
+ expect(h.promptResolved).toContainEqual([sid, "per_e"]);
2661
+ } finally {
2662
+ h.close();
2663
+ }
2664
+ });
2665
+
2666
+ test("resolvePrompt always -> replyPermission always; opsi tak dikenal tetap diteruskan", async () => {
2667
+ const h = freshHarness();
2668
+ try {
2669
+ const sid = await createSession(h);
2670
+ const client = clientOf(h);
2671
+
2672
+ // Always allow: opencode mengingat pola -> request identik berikutnya
2673
+ // tidak lagi menampilkan kartu.
2674
+ const pid = await seedPrompt(h, sid, "permission", "per_always");
2675
+ const r = await h.sm.resolvePrompt(sid, pid, "always");
2676
+ expect(r.ok).toBe(true);
2677
+ expect(client.calls).toContain("replyPermission:per_always:always");
2678
+
2679
+ // Opsi menu tidak ada di daftar tersimpan TETAP diteruskan ke opencode
2680
+ // (validasi sebenarnya di server opencode) — menolak keras di sini
2681
+ // membuat tombol opsi terasa mati tanpa feedback.
2682
+ const qid = await seedPrompt(h, sid, "question", "que_opt");
2683
+ const r2 = await h.sm.resolvePrompt(sid, qid, { option: "OpsiLive" });
2684
+ expect(r2.ok).toBe(true);
2685
+ expect(client.calls).toContain("replyQuestion:que_opt:OpsiLive");
2686
+ } finally {
2687
+ h.close();
2688
+ }
2689
+ });
2690
+
2691
+ test("resolvePrompt permission beda judul TIDAK ikut ter-fan-out", async () => {
2692
+ const h = freshHarness();
2693
+ try {
2694
+ const sid = await createSession(h);
2695
+ const client = clientOf(h);
2696
+
2697
+ client.emit({
2698
+ type: "permission.asked",
2699
+ requestID: "per_bash",
2700
+ sessionID: "ses_remote1",
2701
+ permission: "bash",
2702
+ patterns: ["ls"],
2703
+ });
2704
+ client.emit({
2705
+ type: "permission.asked",
2706
+ requestID: "per_edit",
2707
+ sessionID: "ses_remote1",
2708
+ permission: "edit",
2709
+ patterns: ["src/a.ts"],
2710
+ });
2711
+
2712
+ await h.sm.resolvePrompt(sid, "per_bash", "approve");
2713
+ expect(client.calls).toContain("replyPermission:per_bash:once");
2714
+ expect(client.calls).not.toContain("replyPermission:per_edit:once");
2715
+ const other = h.store.getPrompt("per_edit");
2716
+ expect(other.ok && other.data.status).toBe("pending");
2717
+ } finally {
2718
+ h.close();
2719
+ }
2720
+ });
2721
+
2722
+ test("resolvePrompt: request dilupakan opencode (404) -> prompt ditutup lokal, PROMPT_EXPIRED", async () => {
2723
+ const h = freshHarness();
2724
+ try {
2725
+ const sid = await createSession(h);
2726
+ const client = clientOf(h);
2727
+
2728
+ // Jawab question yang sudah tidak ditunggu agent.
2729
+ const pid = await seedPrompt(h, sid, "question", "que_gone");
2730
+ client.goneRequests.add("que_gone");
2731
+ const r = await h.sm.resolvePrompt(sid, pid, { option: "A" });
2732
+ expect(r).toEqual({ ok: false, error: "PROMPT_EXPIRED" });
2733
+ // Baris tidak lagi pending -> kartu hilang & session tidak terkunci.
2734
+ const row = h.store.getPrompt(pid);
2735
+ expect(row.ok && row.data.status).toBe("resolved");
2736
+ expect(h.store.listPendingPrompts(sid)).toHaveLength(0);
2737
+
2738
+ // Skip (reject) pada request yang hilang juga menutup prompt.
2739
+ const pid2 = await seedPrompt(h, sid, "question", "que_gone2");
2740
+ client.goneRequests.add("que_gone2");
2741
+ expect((await h.sm.resolvePrompt(sid, pid2, "cancel")).error).toBe("PROMPT_EXPIRED");
2742
+ expect(h.store.listPendingPrompts(sid)).toHaveLength(0);
2743
+
2744
+ // Kegagalan lain (bukan 404) tetap pending agar bisa dicoba lagi.
2745
+ const pid3 = await seedPrompt(h, sid, "question", "que_ok");
2746
+ client.replyQuestion = async () => ({ ok: false, error: "OC_QUESTION_REPLY_FAILED(500)" });
2747
+ expect((await h.sm.resolvePrompt(sid, pid3, { option: "A" })).error).toBe(
2748
+ "OC_QUESTION_REPLY_FAILED(500)",
2749
+ );
2750
+ const row3 = h.store.getPrompt(pid3);
2751
+ expect(row3.ok && row3.data.status).toBe("pending");
2752
+ } finally {
2753
+ h.close();
2754
+ }
2755
+ });
2756
+
2757
+ test("resolvePrompt invalid: tidak ditemukan / sudah resolved / respon salah", async () => {
2758
+ const h = freshHarness();
2759
+ try {
2760
+ const sid = await createSession(h);
2761
+ const client = clientOf(h);
2762
+
2763
+ // promptId tidak dikenal
2764
+ expect((await h.sm.resolvePrompt(sid, "nope", "approve")).error).toBe("PROMPT_NOT_FOUND");
2765
+
2766
+ // sudah resolved
2767
+ const pid = await seedPrompt(h, sid, "permission", "per_1");
2768
+ await h.sm.resolvePrompt(sid, pid, "approve");
2769
+ expect((await h.sm.resolvePrompt(sid, pid, "approve")).error).toBe("PROMPT_ALREADY_RESOLVED");
2770
+
2771
+ // opsi menu pada permission -> invalid
2772
+ const pid2 = await seedPrompt(h, sid, "permission", "per_2");
2773
+ expect((await h.sm.resolvePrompt(sid, pid2, { option: "x" })).error).toBe(
2774
+ "INVALID_PROMPT_RESPONSE",
2775
+ );
2776
+
2777
+ // approve/deny pada question -> invalid
2778
+ const pid3 = await seedPrompt(h, sid, "question", "que_1");
2779
+ expect((await h.sm.resolvePrompt(sid, pid3, "approve")).error).toBe("INVALID_PROMPT_RESPONSE");
2780
+ // Hanya respon valid yang memicu reply; dua kasus invalid tidak.
2781
+ expect(
2782
+ client.calls.filter((c) => c.startsWith("replyPermission") || c.startsWith("replyQuestion")),
2783
+ ).toHaveLength(1);
2784
+ } finally {
2785
+ h.close();
2786
+ }
2787
+ });
2788
+
2789
+ // ---------------------------------------------------------------------------
2790
+ // Lifecycle
2791
+ // ---------------------------------------------------------------------------
2792
+
2793
+ test("stopSession: valid -> stopped + abort best-effort; invalid -> ditolak", async () => {
2794
+ const h = freshHarness();
2795
+ try {
2796
+ const sid = await createSession(h);
2797
+ const client = clientOf(h);
2798
+
2799
+ const ok = h.sm.stopSession(sid);
2800
+ expect(ok.ok).toBe(true);
2801
+ expect(statusOf(h.store, sid)).toBe("stopped");
2802
+ expect(client.calls).toContain("abortSession:ses_remote1");
2803
+ expect(h.statuses.some(([id, st]) => id === sid && st === "stopped")).toBe(true);
2804
+
2805
+ // stop lagi (sudah stopped) -> ditolak
2806
+ expect(h.sm.stopSession(sid).error).toBe("SESSION_NOT_RUNNING");
2807
+ expect(h.sm.stopSession("unknown").error).toBe("SESSION_NOT_FOUND");
2808
+ } finally {
2809
+ h.close();
2810
+ }
2811
+ });
2812
+
2813
+ test("reconcileOnStartup: session running -> crashed", () => {
2814
+ const h = freshHarness();
2815
+ try {
2816
+ // Seed session running langsung di store (simulasi sisa restart).
2817
+ h.store.insertSession({
2818
+ id: "s1",
2819
+ projectId: "p1",
2820
+ agentType: "opencode",
2821
+ cwd: h.project.path,
2822
+ status: "running",
2823
+ ocSessionId: "ses_x",
2824
+ model: null,
2825
+ agent: null,
2826
+ title: null,
2827
+ createdAt: 1,
2828
+ updatedAt: 1,
2829
+ });
2830
+ h.store.insertSession({
2831
+ id: "s2",
2832
+ projectId: "p1",
2833
+ agentType: "opencode",
2834
+ cwd: h.project.path,
2835
+ status: "stopped",
2836
+ agent: null,
2837
+ ocSessionId: null,
2838
+ model: null,
2839
+ title: null,
2840
+ createdAt: 1,
2841
+ updatedAt: 1,
2842
+ });
2843
+
2844
+ h.sm.reconcileOnStartup();
2845
+ expect(statusOf(h.store, "s1")).toBe("crashed");
2846
+ expect(statusOf(h.store, "s2")).toBe("stopped");
2847
+ } finally {
2848
+ h.close();
2849
+ }
2850
+ });
2851
+
2852
+ test("server exit tak terduga -> session project ditandai crashed", async () => {
2853
+ const h = freshHarness();
2854
+ try {
2855
+ const sid = await createSession(h);
2856
+ // Panggil hook exit yang diregistrasikan Session_Manager.
2857
+ for (const cb of h.fake.exitCbs) cb("p1");
2858
+ expect(statusOf(h.store, sid)).toBe("crashed");
2859
+ } finally {
2860
+ h.close();
2861
+ }
2862
+ });
2863
+
2864
+ test("exit -> recreate -> exit lagi: session baru juga ditandai crashed", async () => {
2865
+ const h = freshHarness();
2866
+ try {
2867
+ const sid1 = await createSession(h);
2868
+ for (const cb of h.fake.exitCbs) cb("p1");
2869
+ expect(statusOf(h.store, sid1)).toBe("crashed");
2870
+
2871
+ // Session baru -> server di-ensure ulang; exitNotified harus di-reset.
2872
+ const sid2 = await createSession(h);
2873
+ expect(statusOf(h.store, sid2)).toBe("running");
2874
+
2875
+ for (const cb of h.fake.exitCbs) cb("p1");
2876
+ expect(statusOf(h.store, sid2)).toBe("crashed");
2877
+ } finally {
2878
+ h.close();
2879
+ }
2880
+ });
2881
+
2882
+ test("shutdown: simpan status running lalu stop seluruh server", async () => {
2883
+ const h = freshHarness();
2884
+ try {
2885
+ await createSession(h);
2886
+ await h.sm.shutdown();
2887
+ expect(h.fake.stopped).toContain("p1");
2888
+ } finally {
2889
+ h.close();
2890
+ }
2891
+ });
2892
+
2893
+ // ---------------------------------------------------------------------------
2894
+ // resumeSession (start ulang Session stopped/crashed, ocSessionId dipertahankan)
2895
+ // ---------------------------------------------------------------------------
2896
+
2897
+ test("resumeSession: stopped + ocSessionId masih dikenal -> running tanpa createSession", async () => {
2898
+ const h = freshHarness();
2899
+ try {
2900
+ const sid = await createSession(h);
2901
+ const before = h.store.getSession(sid);
2902
+ const ocIdBefore = before.ok ? before.data.ocSessionId : null;
2903
+ expect(h.sm.stopSession(sid).ok).toBe(true);
2904
+ const callsBefore =
2905
+ h.fake.clients.get("p1")?.calls.filter((c) => c.startsWith("createSession")).length ?? 0;
2906
+
2907
+ const res = await h.sm.resumeSession(sid);
2908
+ expect(res.ok).toBe(true);
2909
+ expect(statusOf(h.store, sid)).toBe("running");
2910
+ // ocSessionId lama dipertahankan — riwayat opencode tetap nyambung.
2911
+ const after = h.store.getSession(sid);
2912
+ expect(after.ok && after.data.ocSessionId).toBe(ocIdBefore);
2913
+ // Tidak ada createSession tambahan.
2914
+ const callsAfter =
2915
+ h.fake.clients.get("p1")?.calls.filter((c) => c.startsWith("createSession")).length ?? 0;
2916
+ expect(callsAfter).toBe(callsBefore);
2917
+ // Event SSE tetap terpasang.
2918
+ expect(h.fake.clients.get("p1")?.eventCb).not.toBeNull();
2919
+ } finally {
2920
+ h.close();
2921
+ }
2922
+ });
2923
+
2924
+ test("resumeSession: ocSessionId tidak dikenal server -> sesi remote baru, oc_session_id diperbarui", async () => {
2925
+ const h = freshHarness();
2926
+ try {
2927
+ const sid = await createSession(h);
2928
+ expect(h.sm.stopSession(sid).ok).toBe(true);
2929
+ // Server melaporkan oc session lama sudah hilang.
2930
+ const client = h.fake.clients.get("p1");
2931
+ if (client) client.getSessionResult = { ok: false, error: "OC_SESSION_NOT_FOUND" };
2932
+
2933
+ const res = await h.sm.resumeSession(sid);
2934
+ expect(res.ok).toBe(true);
2935
+ expect(statusOf(h.store, sid)).toBe("running");
2936
+ const after = h.store.getSession(sid);
2937
+ expect(after.ok).toBe(true);
2938
+ if (after.ok) {
2939
+ // ocSessionId baru dari createSessionResult (masih ses_remote1, tapi
2940
+ // createSession terpanggil — riwayat lokal utuh, pemetaan SSE baru).
2941
+ expect(after.data.ocSessionId).toBe("ses_remote1");
2942
+ }
2943
+ expect(client?.calls).toContain("getSession:ses_remote1");
2944
+ expect(client?.calls.filter((c) => c.startsWith("createSession"))).toHaveLength(2);
2945
+ } finally {
2946
+ h.close();
2947
+ }
2948
+ });
2949
+
2950
+ test("resumeSession: ditolak bila masih running / session tidak ada", async () => {
2951
+ const h = freshHarness();
2952
+ try {
2953
+ const sid = await createSession(h);
2954
+ const running = await h.sm.resumeSession(sid);
2955
+ expect(running.ok).toBe(false);
2956
+ expect(running.error).toBe("SESSION_ALREADY_RUNNING");
2957
+
2958
+ const missing = await h.sm.resumeSession("tidak-ada");
2959
+ expect(missing.ok).toBe(false);
2960
+ expect(missing.error).toBe("SESSION_NOT_FOUND");
2961
+ } finally {
2962
+ h.close();
2963
+ }
2964
+ });
2965
+
2966
+ test("resumeSession crashed: reconcileOnStartup lalu resume -> running kembali", async () => {
2967
+ const h = freshHarness();
2968
+ try {
2969
+ const sid = await createSession(h);
2970
+ h.sm.reconcileOnStartup();
2971
+ expect(statusOf(h.store, sid)).toBe("crashed");
2972
+
2973
+ const res = await h.sm.resumeSession(sid);
2974
+ expect(res.ok).toBe(true);
2975
+ expect(statusOf(h.store, sid)).toBe("running");
2976
+ } finally {
2977
+ h.close();
2978
+ }
2979
+ });
2980
+
2981
+ test("resumeSession: input bebas kembali berfungsi setelah resume", async () => {
2982
+ const h = freshHarness();
2983
+ try {
2984
+ const sid = await createSession(h);
2985
+ expect(h.sm.stopSession(sid).ok).toBe(true);
2986
+ await h.sm.resumeSession(sid);
2987
+
2988
+ const res = await h.sm.sendFreeTextInput(sid, "halo lagi");
2989
+ expect(res.ok).toBe(true);
2990
+ await flush();
2991
+ const client = h.fake.clients.get("p1");
2992
+ expect(client?.calls).toContain("promptAsync:halo lagi");
2993
+ } finally {
2994
+ h.close();
2995
+ }
2996
+ });
2997
+
2998
+ // ---------------------------------------------------------------------------
2999
+ // Pemilihan model (sesuai pilihan model di opencode)
3000
+ // ---------------------------------------------------------------------------
3001
+
3002
+ const MODEL = { providerID: "kcgcode", modelID: "kiro/claude-opus-5" };
3003
+
3004
+ test("createSession dengan model valid -> tersimpan di Session & tervalidasi", async () => {
3005
+ const h = freshHarness();
3006
+ try {
3007
+ const res = await h.sm.createSession({ agentType: "opencode", projectId: "p1", model: MODEL });
3008
+ expect(res.ok).toBe(true);
3009
+ if (!res.ok) throw new Error("createSession gagal");
3010
+ expect(res.session.model).toEqual(MODEL);
3011
+ const stored = h.store.getSession(res.session.id);
3012
+ expect(stored.ok && stored.data.model).toEqual(MODEL);
3013
+ // Validasi memakai daftar provider server.
3014
+ const client = h.fake.clients.get("p1");
3015
+ expect(client?.calls).toContain("listModels");
3016
+ } finally {
3017
+ h.close();
3018
+ }
3019
+ });
3020
+
3021
+ test("createSession dengan model tidak dikenal -> MODEL_NOT_FOUND, tanpa sesi remote", async () => {
3022
+ const h = freshHarness();
3023
+ try {
3024
+ const res = await h.sm.createSession({
3025
+ agentType: "opencode",
3026
+ projectId: "p1",
3027
+ model: { providerID: "kcgcode", modelID: "model-tidak-ada" },
3028
+ });
3029
+ expect(res.ok).toBe(false);
3030
+ if (res.ok) throw new Error("harusnya ditolak");
3031
+ expect(res.error).toBe("MODEL_NOT_FOUND");
3032
+ const client = h.fake.clients.get("p1");
3033
+ // createSession remote tidak boleh terpanggil.
3034
+ expect(client?.calls.filter((c) => c.startsWith("createSession"))).toHaveLength(0);
3035
+ expect(h.store.listSessions()).toHaveLength(0);
3036
+ } finally {
3037
+ h.close();
3038
+ }
3039
+ });
3040
+
3041
+ test("createSession tanpa model -> null (default opencode), listModels tak dipanggil", async () => {
3042
+ const h = freshHarness();
3043
+ try {
3044
+ const sid = await createSession(h);
3045
+ const stored = h.store.getSession(sid);
3046
+ expect(stored.ok && stored.data.model).toBeNull();
3047
+ const client = h.fake.clients.get("p1");
3048
+ expect(client?.calls).not.toContain("listModels");
3049
+ // promptAsync dikirim tanpa model.
3050
+ await h.sm.sendFreeTextInput(sid, "hai");
3051
+ await flush();
3052
+ expect(client?.promptModels).toEqual([null]);
3053
+ } finally {
3054
+ h.close();
3055
+ }
3056
+ });
3057
+
3058
+ test("createSession: listModels gagal -> error diteruskan, tanpa sesi remote", async () => {
3059
+ const h = freshHarness();
3060
+ try {
3061
+ const client = h.fake.clients.get("p1") ?? makeFakeClient();
3062
+ client.listModelsResult = { ok: false, error: "OC_LIST_MODELS_FAILED(500)" };
3063
+ h.fake.clients.set("p1", client);
3064
+ const res = await h.sm.createSession({ agentType: "opencode", projectId: "p1", model: MODEL });
3065
+ expect(res.ok).toBe(false);
3066
+ if (res.ok) throw new Error("harusnya gagal");
3067
+ expect(res.error).toContain("OC_LIST_MODELS_FAILED");
3068
+ } finally {
3069
+ h.close();
3070
+ }
3071
+ });
3072
+
3073
+ test("promptAsync selalu membawa model Session yang tersimpan", async () => {
3074
+ const h = freshHarness();
3075
+ try {
3076
+ const sid = await h.sm
3077
+ .createSession({ agentType: "opencode", projectId: "p1", model: MODEL })
3078
+ .then((r) => (r.ok ? r.session.id : ""));
3079
+ expect(sid).not.toBe("");
3080
+
3081
+ await h.sm.sendFreeTextInput(sid, "pesan pertama");
3082
+ await flush();
3083
+ await h.sm.sendFreeTextInput(sid, "pesan kedua");
3084
+ await flush();
3085
+ const client = h.fake.clients.get("p1");
3086
+ expect(client?.promptModels).toEqual([
3087
+ "kcgcode/kiro/claude-opus-5",
3088
+ "kcgcode/kiro/claude-opus-5",
3089
+ ]);
3090
+ } finally {
3091
+ h.close();
3092
+ }
3093
+ });
3094
+
3095
+ test("setSessionModel: ganti model -> prompt berikutnya memakai model baru", async () => {
3096
+ const h = freshHarness();
3097
+ try {
3098
+ const sid = await createSession(h);
3099
+ const baru = { providerID: "kcgcode", modelID: "mimo/mimo-v2.5" };
3100
+ const client = h.fake.clients.get("p1");
3101
+ if (client) {
3102
+ client.availableModels = [
3103
+ ...client.availableModels,
3104
+ { providerID: "kcgcode", providerName: "kcgcode", modelID: baru.modelID, name: "Mimo" },
3105
+ ];
3106
+ }
3107
+
3108
+ const res = h.sm.setSessionModel(sid, baru);
3109
+ expect(res.ok).toBe(true);
3110
+ const stored = h.store.getSession(sid);
3111
+ expect(stored.ok && stored.data.model).toEqual(baru);
3112
+ // Status tidak berubah akibat penggantian model.
3113
+ expect(stored.ok && stored.data.status).toBe("running");
3114
+
3115
+ await h.sm.sendFreeTextInput(sid, "setelah ganti model");
3116
+ await flush();
3117
+ expect(client?.promptModels).toEqual(["kcgcode/mimo/mimo-v2.5"]);
3118
+ } finally {
3119
+ h.close();
3120
+ }
3121
+ });
3122
+
3123
+ test("setSessionModel(null) -> kembali default; session tak dikenal -> SESSION_NOT_FOUND", async () => {
3124
+ const h = freshHarness();
3125
+ try {
3126
+ const sid = await h.sm
3127
+ .createSession({ agentType: "opencode", projectId: "p1", model: MODEL })
3128
+ .then((r) => (r.ok ? r.session.id : ""));
3129
+
3130
+ expect(h.sm.setSessionModel(sid, null).ok).toBe(true);
3131
+ const stored = h.store.getSession(sid);
3132
+ expect(stored.ok && stored.data.model).toBeNull();
3133
+
3134
+ const missing = h.sm.setSessionModel("tidak-ada", MODEL);
3135
+ expect(missing.ok).toBe(false);
3136
+ expect(missing.error).toBe("SESSION_NOT_FOUND");
3137
+ } finally {
3138
+ h.close();
3139
+ }
3140
+ });
3141
+
3142
+ test("listModels(projectId) -> daftar model dari server headless Project", async () => {
3143
+ const h = freshHarness();
3144
+ try {
3145
+ const res = await h.sm.listModels("p1");
3146
+ expect(res.ok).toBe(true);
3147
+ if (!res.ok) throw new Error("listModels gagal");
3148
+ expect(res.data).toEqual([
3149
+ {
3150
+ providerID: "kcgcode",
3151
+ providerName: "kcgcode",
3152
+ modelID: "kiro/claude-opus-5",
3153
+ name: "Claude Opus 5",
3154
+ },
3155
+ ]);
3156
+ // Server di-ensure dengan path Project.
3157
+ expect(h.fake.ensureCalls).toContainEqual({ projectId: "p1", projectPath: h.project.path });
3158
+
3159
+ const missing = await h.sm.listModels("project-tidak-ada");
3160
+ expect(missing.ok).toBe(false);
3161
+ if (missing.ok) throw new Error("harusnya gagal");
3162
+ expect(missing.error).toBe("PROJECT_NOT_FOUND");
3163
+ } finally {
3164
+ h.close();
3165
+ }
3166
+ });
3167
+
3168
+ test("model bertahan setelah stop + resume (riwayat & pilihan utuh)", async () => {
3169
+ const h = freshHarness();
3170
+ try {
3171
+ const sid = await h.sm
3172
+ .createSession({ agentType: "opencode", projectId: "p1", model: MODEL })
3173
+ .then((r) => (r.ok ? r.session.id : ""));
3174
+ expect(h.sm.stopSession(sid).ok).toBe(true);
3175
+ expect(await h.sm.resumeSession(sid)).toEqual({ ok: true });
3176
+
3177
+ const stored = h.store.getSession(sid);
3178
+ expect(stored.ok && stored.data.model).toEqual(MODEL);
3179
+ await h.sm.sendFreeTextInput(sid, "lanjut");
3180
+ await flush();
3181
+ expect(h.fake.clients.get("p1")?.promptModels).toEqual(["kcgcode/kiro/claude-opus-5"]);
3182
+ } finally {
3183
+ h.close();
3184
+ }
3185
+ });
3186
+
3187
+ // ---------------------------------------------------------------------------
3188
+ // deleteSession (hapus permanen: remote opencode + lokal)
3189
+ // ---------------------------------------------------------------------------
3190
+
3191
+ test("deleteSession: remote dihapus + lokal bersih (messages, prompts, history)", async () => {
3192
+ const h = freshHarness();
3193
+ try {
3194
+ const sid = await createSession(h);
3195
+ const ocId = h.store.getSession(sid).ok ? "ses_remote1" : null;
3196
+ await h.sm.sendFreeTextInput(sid, "pesan sebelum hapus");
3197
+ await flush();
3198
+
3199
+ const res = await h.sm.deleteSession(sid);
3200
+ expect(res.ok).toBe(true);
3201
+ const client = h.fake.clients.get("p1");
3202
+ expect(client?.calls).toContain(`deleteSession:${ocId}`);
3203
+ // Semua jejak lokal hilang.
3204
+ expect(h.store.getSession(sid).ok).toBe(false);
3205
+ expect(h.store.getMessages(sid).ok).toBe(false);
3206
+ expect(h.store.listSessions()).toHaveLength(0);
3207
+ } finally {
3208
+ h.close();
3209
+ }
3210
+ });
3211
+
3212
+ test("deleteSession: server headless menolak -> error diteruskan, data lokal utuh", async () => {
3213
+ const h = freshHarness();
3214
+ try {
3215
+ const sid = await createSession(h);
3216
+ const client = h.fake.clients.get("p1");
3217
+ if (client) client.deleteSessionResult = { ok: false, error: "OC_DELETE_SESSION_FAILED(500)" };
3218
+
3219
+ const res = await h.sm.deleteSession(sid);
3220
+ expect(res.ok).toBe(false);
3221
+ if (res.ok) throw new Error("harusnya gagal");
3222
+ expect(res.error).toContain("OC_DELETE_SESSION_FAILED");
3223
+ // Data tidak setengah terhapus.
3224
+ expect(h.store.getSession(sid).ok).toBe(true);
3225
+ } finally {
3226
+ h.close();
3227
+ }
3228
+ });
3229
+
3230
+ test("deleteSession: server headless mati -> di-spawn ulang, remote ikut dihapus", async () => {
3231
+ const h = freshHarness();
3232
+ try {
3233
+ const sid = await createSession(h);
3234
+ // Simulasi server project sudah tidak hidup (mis. KCG Code di-restart).
3235
+ h.fake.clients.delete("p1");
3236
+ expect(h.fake.ensureCalls).toHaveLength(1);
3237
+
3238
+ const res = await h.sm.deleteSession(sid);
3239
+ expect(res.ok).toBe(true);
3240
+ // Server di-spawn ulang demi menghapus session remote (bukan dilewati).
3241
+ expect(h.fake.ensureCalls).toHaveLength(2);
3242
+ expect(h.fake.ensureCalls[1]).toEqual({ projectId: "p1", projectPath: h.project.path });
3243
+ expect(h.fake.clients.get("p1")?.calls).toContain("deleteSession:ses_remote1");
3244
+ expect(h.store.getSession(sid).ok).toBe(false);
3245
+ } finally {
3246
+ h.close();
3247
+ }
3248
+ });
3249
+
3250
+ test("deleteSession: server mati & spawn ulang gagal -> error, data lokal utuh", async () => {
3251
+ const h = freshHarness();
3252
+ try {
3253
+ const sid = await createSession(h);
3254
+ // Server mati dan tidak bisa dihidupkan lagi (mis. binary opencode hilang).
3255
+ h.fake.clients.delete("p1");
3256
+ h.fake.ensureResult = { ok: false, error: "SERVER_START_FAILED" };
3257
+
3258
+ const res = await h.sm.deleteSession(sid);
3259
+ expect(res.ok).toBe(false);
3260
+ if (res.ok) throw new Error("harusnya gagal");
3261
+ expect(res.error).toContain("SERVER_START_FAILED");
3262
+ // Penghapusan dibatalkan — sesi tetap tampil agar user bisa coba lagi.
3263
+ expect(h.store.getSession(sid).ok).toBe(true);
3264
+ } finally {
3265
+ h.close();
3266
+ }
3267
+ });
3268
+
3269
+ test("deleteSession: session masih running -> di-stop dulu lalu dihapus", async () => {
3270
+ const h = freshHarness();
3271
+ try {
3272
+ const sid = await createSession(h);
3273
+ const res = await h.sm.deleteSession(sid);
3274
+ expect(res.ok).toBe(true);
3275
+ const client = h.fake.clients.get("p1");
3276
+ expect(client?.calls).toContain("abortSession:ses_remote1");
3277
+ expect(h.store.getSession(sid).ok).toBe(false);
3278
+ } finally {
3279
+ h.close();
3280
+ }
3281
+ });
3282
+
3283
+ test("deleteSession: session tidak ada -> SESSION_NOT_FOUND", async () => {
3284
+ const h = freshHarness();
3285
+ try {
3286
+ const res = await h.sm.deleteSession("tidak-ada");
3287
+ expect(res.ok).toBe(false);
3288
+ if (res.ok) throw new Error("harusnya gagal");
3289
+ expect(res.error).toBe("SESSION_NOT_FOUND");
3290
+ } finally {
3291
+ h.close();
3292
+ }
3293
+ });
3294
+
3295
+ test("deleteSession: onDeleted terpanggil (broadcast ke subscriber WS)", async () => {
3296
+ let deletedId: string | undefined;
3297
+ // Manager kedua dengan hook onDeleted — store & fake manager dipakai bersama.
3298
+ const h = freshHarnessWithHooks({ onDeleted: (id) => (deletedId = id) });
3299
+ try {
3300
+ const created = await h.sm.createSession({ agentType: "opencode", projectId: h.project.id });
3301
+ if (!created.ok) throw new Error("create gagal");
3302
+ const res = await h.sm.deleteSession(created.session.id);
3303
+ expect(res.ok).toBe(true);
3304
+ expect(deletedId).toBe(created.session.id);
3305
+ } finally {
3306
+ h.close();
3307
+ }
3308
+ });
3309
+
3310
+ // ---------------------------------------------------------------------------
3311
+ // Lampiran gambar (upload dari perangkat)
3312
+ // ---------------------------------------------------------------------------
3313
+
3314
+ test("sendFreeTextInput dengan gambar -> echo user berisi part file image + promptAsync menerima ref gambar", async () => {
3315
+ const h = freshHarnessWithHooks({}, { withAttachments: true });
3316
+ try {
3317
+ const sid = await createSession(h);
3318
+ const client = clientOf(h);
3319
+ // 1x1 PNG valid agar `attachment` tersimpan.
3320
+ const png = new Uint8Array([
3321
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d, 0x49, 0x48, 0x44, 0x52,
3322
+ ]);
3323
+ const saved = h.attachments?.save(sid, "foto.png", "image/png", png);
3324
+ expect(saved?.ok).toBe(true);
3325
+ if (!saved?.ok) throw new Error("save gagal");
3326
+
3327
+ const res = await h.sm.sendFreeTextInput(sid, "apa isi gambar ini?", [], [saved.data.id]);
3328
+ expect(res.ok).toBe(true);
3329
+ await flush();
3330
+
3331
+ // Echo user disimpan + dikirim, memuat part file image + attachmentId.
3332
+ const user = h.messages.find((m) => m.role === "user");
3333
+ expect(user).toBeDefined();
3334
+ const imgPart = user?.parts.find((p) => p.type === "file" && p.attachmentId === saved.data.id);
3335
+ expect(imgPart).toBeDefined();
3336
+ expect(imgPart?.mime).toBe("image/png");
3337
+ expect(imgPart?.filename).toBe("foto.png");
3338
+ expect(typeof imgPart?.url).toBe("string");
3339
+
3340
+ // promptAsync dipanggil dengan referensi file image (mime + url absolut).
3341
+ const files = client.promptFilesCalls.at(-1) ?? [];
3342
+ const ref = files.find((f) => f.filename === "foto.png");
3343
+ expect(ref?.mime).toBe("image/png");
3344
+ // URL wajib absolut (`file:///…`): `file://rel/path` ditolak opencode.
3345
+ expect(ref?.url.startsWith("file:///")).toBe(true);
3346
+ expect(ref?.url).toContain(saved.data.id);
3347
+ // Part echo pun membawa URL absolut yang sama.
3348
+ expect(typeof imgPart?.url).toBe("string");
3349
+ expect((imgPart?.url ?? "").startsWith("file:///")).toBe(true);
3350
+ } finally {
3351
+ h.close();
3352
+ }
3353
+ });
3354
+
3355
+ test("sendFreeTextInput file non-gambar -> teks dikirim text/plain, biner hanya dirujuk lewat path", async () => {
3356
+ const h = freshHarnessWithHooks({}, { withAttachments: true });
3357
+ try {
3358
+ const sid = await createSession(h);
3359
+ const client = clientOf(h);
3360
+ const csv = h.attachments?.save(sid, "data.csv", "text/csv", new TextEncoder().encode("a,b"));
3361
+ const zip = h.attachments?.save(sid, "arsip.zip", "application/zip", new Uint8Array([1, 2]));
3362
+ if (!csv?.ok || !zip?.ok) throw new Error("save gagal");
3363
+
3364
+ const res = await h.sm.sendFreeTextInput(sid, "cek ini", [], [csv.data.id, zip.data.id]);
3365
+ expect(res.ok).toBe(true);
3366
+ await flush();
3367
+
3368
+ // Echo: kedua file tampil dengan mime aslinya (renderer memilih kartu).
3369
+ const user = h.messages.find((m) => m.role === "user");
3370
+ const echoed = (user?.parts ?? []).filter((p) => p.type === "file" && p.attachmentId);
3371
+ expect(echoed.map((p) => p.mime)).toEqual(["text/csv", "application/zip"]);
3372
+ // Teks tampilan tetap teks asli (catatan path tidak ikut di bubble).
3373
+ expect(user?.parts.find((p) => p.type === "text")?.text).toBe("cek ini");
3374
+
3375
+ // Ke model: CSV jadi text/plain; ZIP tidak dikirim sebagai part.
3376
+ const files = client.promptFilesCalls.at(-1) ?? [];
3377
+ expect(files.map((f) => [f.filename, f.mime])).toEqual([["data.csv", "text/plain"]]);
3378
+ // ...tapi path ZIP disebut di teks prompt agar model bisa memakai tool.
3379
+ const prompt = client.calls.filter((c) => c.startsWith("promptAsync:")).at(-1) ?? "";
3380
+ expect(prompt).toContain("cek ini");
3381
+ expect(prompt).toContain("arsip.zip");
3382
+ expect(prompt).toContain(zip.data.id);
3383
+ } finally {
3384
+ h.close();
3385
+ }
3386
+ });
3387
+
3388
+ test("sendFreeTextInput gambar + @file -> part text + file image + file teks di echo", async () => {
3389
+ const h = freshHarnessWithHooks({}, { withAttachments: true });
3390
+ try {
3391
+ const sid = await createSession(h);
3392
+ const saved = h.attachments?.save(sid, "ss.png", "image/png", new Uint8Array([1, 2, 3]));
3393
+ expect(saved?.ok).toBe(true);
3394
+ if (!saved?.ok) throw new Error("save gagal");
3395
+
3396
+ const res = await h.sm.sendFreeTextInput(
3397
+ sid,
3398
+ "lihat @src/App.tsx",
3399
+ ["src/App.tsx"],
3400
+ [saved.data.id],
3401
+ );
3402
+ expect(res.ok).toBe(true);
3403
+ const user = h.messages.find((m) => m.role === "user");
3404
+ const parts = user?.parts ?? [];
3405
+ expect(parts.filter((p) => p.type === "text")).toHaveLength(1);
3406
+ expect(parts.filter((p) => p.type === "file" && p.attachmentId)).toHaveLength(1);
3407
+ expect(parts.filter((p) => p.type === "file" && !p.attachmentId)).toHaveLength(1);
3408
+ await flush();
3409
+ } finally {
3410
+ h.close();
3411
+ }
3412
+ });
3413
+
3414
+ test("sendFreeTextInput: id gambar tak dikenal -> ATTACHMENT_NOT_FOUND tanpa echo", async () => {
3415
+ const h = freshHarnessWithHooks({}, { withAttachments: true });
3416
+ try {
3417
+ const sid = await createSession(h);
3418
+ const before = h.messages.length;
3419
+ const res = await h.sm.sendFreeTextInput(
3420
+ sid,
3421
+ "halo",
3422
+ [],
3423
+ ["00000000-0000-0000-0000-000000000000"],
3424
+ );
3425
+ expect(res.ok).toBe(false);
3426
+ expect(res.error).toBe("ATTACHMENT_NOT_FOUND");
3427
+ expect(h.messages.length).toBe(before); // tidak ada echo
3428
+ } finally {
3429
+ h.close();
3430
+ }
3431
+ });
3432
+
3433
+ test("sendFreeTextInput tanpa teks tapi ada gambar -> teks kosong diizinkan", async () => {
3434
+ const h = freshHarnessWithHooks({}, { withAttachments: true });
3435
+ try {
3436
+ const sid = await createSession(h);
3437
+ const saved = h.attachments?.save(sid, "x.webp", "image/webp", new Uint8Array([9]));
3438
+ expect(saved?.ok).toBe(true);
3439
+ if (!saved?.ok) throw new Error("save gagal");
3440
+
3441
+ const res = await h.sm.sendFreeTextInput(sid, " ", [], [saved.data.id]);
3442
+ expect(res.ok).toBe(true);
3443
+ const user = h.messages.find((m) => m.role === "user");
3444
+ expect(user?.parts.some((p) => p.type === "file" && p.attachmentId === saved.data.id)).toBe(
3445
+ true,
3446
+ );
3447
+ await flush();
3448
+ } finally {
3449
+ h.close();
3450
+ }
3451
+ });
3452
+
3453
+ test("deleteSession membersihkan lampiran gambar Session", async () => {
3454
+ const h = freshHarnessWithHooks({}, { withAttachments: true });
3455
+ try {
3456
+ const sid = await createSession(h);
3457
+ const saved = h.attachments?.save(sid, "a.png", "image/png", new Uint8Array([1]));
3458
+ expect(saved?.ok).toBe(true);
3459
+ if (!saved?.ok) throw new Error("save gagal");
3460
+ expect(h.attachments?.info(sid, saved.data.id).ok).toBe(true);
3461
+
3462
+ await h.sm.sendFreeTextInput(sid, "hapus nanti", [], [saved.data.id]);
3463
+ const res = await h.sm.deleteSession(sid);
3464
+ expect(res.ok).toBe(true);
3465
+ expect(h.attachments?.info(sid, saved.data.id).ok).toBe(false);
3466
+ } finally {
3467
+ h.close();
3468
+ }
3469
+ });
3470
+
3471
+ test("Session claude-code memakai backend Claude Code; opencode tidak tersentuh", async () => {
3472
+ const claude = makeFakeServers();
3473
+ const h = freshHarnessWithHooks({}, {}, { claudeServers: claude.manager });
3474
+ try {
3475
+ const sid = await createSession(h, "claude-code");
3476
+ expect(claude.ensureCalls).toHaveLength(1);
3477
+ expect(h.fake.ensureCalls).toHaveLength(0);
3478
+ const cc = claude.clients.get("p1");
3479
+ if (!cc) throw new Error("claude client tidak ada");
3480
+
3481
+ expect((await h.sm.sendFreeTextInput(sid, "halo")).ok).toBe(true);
3482
+ await flush();
3483
+ expect(cc.calls).toContain("promptAsync:halo");
3484
+
3485
+ // Event dari backend Claude Code memetakan ke Session lokal yang sama.
3486
+ cc.emit({
3487
+ type: "permission.asked",
3488
+ sessionID: "ses_remote1",
3489
+ requestID: "per_cc",
3490
+ permission: "bash",
3491
+ patterns: ["ls"],
3492
+ });
3493
+ expect(h.prompts.map((p) => p.id)).toEqual(["per_cc"]);
3494
+ expect((await h.sm.resolvePrompt(sid, "per_cc", "approve")).ok).toBe(true);
3495
+ expect(cc.calls).toContain("replyPermission:per_cc:once");
3496
+
3497
+ // Daftar model per backend + task list dibaca dari session remote.
3498
+ expect((await h.sm.listModels("p1", "claude-code")).ok).toBe(true);
3499
+ expect(cc.calls).toContain("listModels");
3500
+ expect((await h.sm.sessionContext(sid)).ok).toBe(true);
3501
+ expect(cc.calls).toContain("listTodos:ses_remote1");
3502
+ expect(h.fake.ensureCalls).toHaveLength(0);
3503
+
3504
+ expect((await h.sm.releaseProject("p1")).ok).toBe(true);
3505
+ expect(claude.stopped).toContain("p1");
3506
+ } finally {
3507
+ h.close();
3508
+ }
3509
+ });