repoburg 1.3.188 → 1.3.196

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 (265) hide show
  1. package/agent-pane/.prettierignore +5 -0
  2. package/agent-pane/.prettierrc +8 -0
  3. package/agent-pane/backend/coverage/base.css +224 -0
  4. package/agent-pane/backend/coverage/block-navigation.js +87 -0
  5. package/agent-pane/backend/coverage/clover.xml +887 -0
  6. package/agent-pane/backend/coverage/coverage-final.json +10 -0
  7. package/agent-pane/backend/coverage/favicon.png +0 -0
  8. package/agent-pane/backend/coverage/index.html +176 -0
  9. package/agent-pane/backend/coverage/prettify.css +1 -0
  10. package/agent-pane/backend/coverage/prettify.js +2 -0
  11. package/agent-pane/backend/coverage/sort-arrow-sprite.png +0 -0
  12. package/agent-pane/backend/coverage/sorter.js +210 -0
  13. package/agent-pane/backend/coverage/src/codex/codex-provider.ts.html +1402 -0
  14. package/agent-pane/backend/coverage/src/codex/index.html +131 -0
  15. package/agent-pane/backend/coverage/src/codex/item-mapper.ts.html +331 -0
  16. package/agent-pane/backend/coverage/src/db/index.html +131 -0
  17. package/agent-pane/backend/coverage/src/db/index.ts.html +436 -0
  18. package/agent-pane/backend/coverage/src/db/schema.ts.html +175 -0
  19. package/agent-pane/backend/coverage/src/index.html +116 -0
  20. package/agent-pane/backend/coverage/src/index.ts.html +262 -0
  21. package/agent-pane/backend/coverage/src/routes/index.html +146 -0
  22. package/agent-pane/backend/coverage/src/routes/sessions.ts.html +991 -0
  23. package/agent-pane/backend/coverage/src/routes/turns.ts.html +256 -0
  24. package/agent-pane/backend/coverage/src/routes/workspaces.ts.html +406 -0
  25. package/agent-pane/backend/coverage/src/ws/index.html +116 -0
  26. package/agent-pane/backend/coverage/src/ws/index.ts.html +238 -0
  27. package/agent-pane/backend/data.db +0 -0
  28. package/agent-pane/backend/data.db-shm +0 -0
  29. package/agent-pane/backend/data.db-wal +0 -0
  30. package/agent-pane/backend/dist/index.js +70869 -0
  31. package/agent-pane/backend/package-lock.json +4529 -1013
  32. package/agent-pane/backend/package.json +10 -6
  33. package/agent-pane/backend/src/claude/claude-message-mapper.test.ts +163 -0
  34. package/agent-pane/backend/src/claude/claude-message-mapper.ts +154 -0
  35. package/agent-pane/backend/src/claude/claude-provider.test.ts +65 -0
  36. package/agent-pane/backend/src/claude/claude-provider.ts +482 -0
  37. package/agent-pane/backend/src/claude/claude-types.ts +33 -0
  38. package/agent-pane/backend/src/codex/app-server-client.test.ts +183 -0
  39. package/agent-pane/backend/src/codex/app-server-client.ts +225 -0
  40. package/agent-pane/backend/src/codex/app-server-types.ts +259 -0
  41. package/agent-pane/backend/src/codex/codex-provider-runtime-config.test.ts +56 -0
  42. package/agent-pane/backend/src/codex/codex-provider.test.ts +60 -0
  43. package/agent-pane/backend/src/codex/codex-provider.ts +521 -265
  44. package/agent-pane/backend/src/codex/item-mapper.test.ts +221 -0
  45. package/agent-pane/backend/src/codex/item-mapper.ts +35 -8
  46. package/agent-pane/backend/src/codex/shared-app-server.ts +25 -0
  47. package/agent-pane/backend/src/db/index.test.ts +125 -0
  48. package/agent-pane/backend/src/db/index.ts +39 -8
  49. package/agent-pane/backend/src/db/schema.ts +28 -74
  50. package/agent-pane/backend/src/index.ts +29 -2
  51. package/agent-pane/backend/src/integration/__fixtures__/cancel-turn.json +29 -0
  52. package/agent-pane/backend/src/integration/__fixtures__/delta-streaming.json +40 -0
  53. package/agent-pane/backend/src/integration/__fixtures__/failed-turn.json +16 -0
  54. package/agent-pane/backend/src/integration/__fixtures__/mcp-tool-call.json +60 -0
  55. package/agent-pane/backend/src/integration/__fixtures__/missing-item-started.json +34 -0
  56. package/agent-pane/backend/src/integration/__fixtures__/multi-item-turn.json +85 -0
  57. package/agent-pane/backend/src/integration/__fixtures__/multi-turn.json +48 -0
  58. package/agent-pane/backend/src/integration/__fixtures__/simple-turn.json +35 -0
  59. package/agent-pane/backend/src/integration/delta-streaming.test.ts +130 -0
  60. package/agent-pane/backend/src/integration/helpers/app-server-mock.ts +272 -0
  61. package/agent-pane/backend/src/integration/helpers/test-app.ts +108 -0
  62. package/agent-pane/backend/src/integration/helpers/ws-capture.ts +64 -0
  63. package/agent-pane/backend/src/integration/item-lifecycle.test.ts +133 -0
  64. package/agent-pane/backend/src/integration/mcp-tool-call.test.ts +102 -0
  65. package/agent-pane/backend/src/integration/missing-item-started.test.ts +91 -0
  66. package/agent-pane/backend/src/integration/multi-turn.test.ts +121 -0
  67. package/agent-pane/backend/src/integration/session-operations.test.ts +168 -0
  68. package/agent-pane/backend/src/integration/title-generation.test.ts +109 -0
  69. package/agent-pane/backend/src/integration/turn-cancel.test.ts +111 -0
  70. package/agent-pane/backend/src/integration/turn-failure.test.ts +97 -0
  71. package/agent-pane/backend/src/integration/turn-lifecycle.test.ts +141 -0
  72. package/agent-pane/backend/src/providers/index.ts +14 -0
  73. package/agent-pane/backend/src/providers/registry.test.ts +92 -0
  74. package/agent-pane/backend/src/providers/registry.ts +43 -0
  75. package/agent-pane/backend/src/providers/runtime-provider.ts +25 -0
  76. package/agent-pane/backend/src/routes/files.ts +31 -0
  77. package/agent-pane/backend/src/routes/mcp.ts +33 -0
  78. package/agent-pane/backend/src/routes/models.test.ts +92 -0
  79. package/agent-pane/backend/src/routes/models.ts +16 -0
  80. package/agent-pane/backend/src/routes/plugins.ts +40 -0
  81. package/agent-pane/backend/src/routes/providers.test.ts +69 -0
  82. package/agent-pane/backend/src/routes/providers.ts +57 -0
  83. package/agent-pane/backend/src/routes/sessions.test.ts +424 -0
  84. package/agent-pane/backend/src/routes/sessions.ts +295 -42
  85. package/agent-pane/backend/src/routes/skills.test.ts +114 -0
  86. package/agent-pane/backend/src/routes/skills.ts +70 -0
  87. package/agent-pane/backend/src/routes/turns.test.ts +152 -0
  88. package/agent-pane/backend/src/routes/turns.ts +36 -16
  89. package/agent-pane/backend/src/routes/workspaces.test.ts +197 -0
  90. package/agent-pane/backend/src/routes/workspaces.ts +73 -55
  91. package/agent-pane/backend/src/ws/index.test.ts +107 -0
  92. package/agent-pane/backend/src/ws/index.ts +19 -11
  93. package/agent-pane/backend/tsconfig.json +4 -2
  94. package/agent-pane/backend/vitest.config.ts +20 -0
  95. package/agent-pane/eslint.config.js +33 -0
  96. package/agent-pane/frontend/coverage/base.css +224 -0
  97. package/agent-pane/frontend/coverage/block-navigation.js +87 -0
  98. package/agent-pane/frontend/coverage/clover.xml +2962 -0
  99. package/agent-pane/frontend/coverage/coverage-final.json +37 -0
  100. package/agent-pane/frontend/coverage/favicon.png +0 -0
  101. package/agent-pane/frontend/coverage/index.html +251 -0
  102. package/agent-pane/frontend/coverage/prettify.css +1 -0
  103. package/agent-pane/frontend/coverage/prettify.js +2 -0
  104. package/agent-pane/frontend/coverage/sort-arrow-sprite.png +0 -0
  105. package/agent-pane/frontend/coverage/sorter.js +210 -0
  106. package/agent-pane/frontend/coverage/src/App.tsx.html +217 -0
  107. package/agent-pane/frontend/coverage/src/AppInitializer.tsx.html +223 -0
  108. package/agent-pane/frontend/coverage/src/api/client.ts.html +187 -0
  109. package/agent-pane/frontend/coverage/src/api/index.html +161 -0
  110. package/agent-pane/frontend/coverage/src/api/sessions.ts.html +316 -0
  111. package/agent-pane/frontend/coverage/src/api/websocket.ts.html +544 -0
  112. package/agent-pane/frontend/coverage/src/api/workspaces.ts.html +199 -0
  113. package/agent-pane/frontend/coverage/src/components/CollapsibleToolCalls.tsx.html +226 -0
  114. package/agent-pane/frontend/coverage/src/components/CommandInputBar.tsx.html +382 -0
  115. package/agent-pane/frontend/coverage/src/components/CommandPalette.tsx.html +715 -0
  116. package/agent-pane/frontend/coverage/src/components/IdeWorkspace.tsx.html +160 -0
  117. package/agent-pane/frontend/coverage/src/components/ModelSelector.tsx.html +478 -0
  118. package/agent-pane/frontend/coverage/src/components/NewSessionDialog.tsx.html +472 -0
  119. package/agent-pane/frontend/coverage/src/components/RenderArea.tsx.html +574 -0
  120. package/agent-pane/frontend/coverage/src/components/RightPanel.tsx.html +205 -0
  121. package/agent-pane/frontend/coverage/src/components/RuntimeIcon.tsx.html +133 -0
  122. package/agent-pane/frontend/coverage/src/components/SessionTabs.tsx.html +217 -0
  123. package/agent-pane/frontend/coverage/src/components/StatusBar.tsx.html +271 -0
  124. package/agent-pane/frontend/coverage/src/components/StatusDot.tsx.html +109 -0
  125. package/agent-pane/frontend/coverage/src/components/Toast.tsx.html +313 -0
  126. package/agent-pane/frontend/coverage/src/components/TurnContent.tsx.html +1216 -0
  127. package/agent-pane/frontend/coverage/src/components/WorkspaceSidebar.tsx.html +1138 -0
  128. package/agent-pane/frontend/coverage/src/components/index.html +326 -0
  129. package/agent-pane/frontend/coverage/src/components/panels/ChangesPanel.tsx.html +247 -0
  130. package/agent-pane/frontend/coverage/src/components/panels/LogsPanel.tsx.html +160 -0
  131. package/agent-pane/frontend/coverage/src/components/panels/MetricsPanel.tsx.html +427 -0
  132. package/agent-pane/frontend/coverage/src/components/panels/TurnsPanel.tsx.html +397 -0
  133. package/agent-pane/frontend/coverage/src/components/panels/index.html +161 -0
  134. package/agent-pane/frontend/coverage/src/constants/index.html +116 -0
  135. package/agent-pane/frontend/coverage/src/constants/models.ts.html +406 -0
  136. package/agent-pane/frontend/coverage/src/hooks/index.html +146 -0
  137. package/agent-pane/frontend/coverage/src/hooks/useEscapeKey.ts.html +124 -0
  138. package/agent-pane/frontend/coverage/src/hooks/useOutsideClick.ts.html +142 -0
  139. package/agent-pane/frontend/coverage/src/hooks/useSessionStatus.ts.html +187 -0
  140. package/agent-pane/frontend/coverage/src/index.html +146 -0
  141. package/agent-pane/frontend/coverage/src/main.tsx.html +115 -0
  142. package/agent-pane/frontend/coverage/src/store/index.html +116 -0
  143. package/agent-pane/frontend/coverage/src/store/store.ts.html +2404 -0
  144. package/agent-pane/frontend/coverage/src/test/component-helpers.ts.html +190 -0
  145. package/agent-pane/frontend/coverage/src/test/fixtures.ts.html +367 -0
  146. package/agent-pane/frontend/coverage/src/test/index.html +131 -0
  147. package/agent-pane/frontend/coverage/src/types/index.html +116 -0
  148. package/agent-pane/frontend/coverage/src/types/index.ts.html +466 -0
  149. package/agent-pane/frontend/coverage/src/utils/changes.ts.html +202 -0
  150. package/agent-pane/frontend/coverage/src/utils/duration.ts.html +127 -0
  151. package/agent-pane/frontend/coverage/src/utils/index.html +131 -0
  152. package/agent-pane/frontend/dist/assets/index-Bg0PF2aV.css +1 -0
  153. package/agent-pane/frontend/dist/assets/index-BlybeX5e.js +281 -0
  154. package/agent-pane/frontend/dist/index.html +19 -0
  155. package/agent-pane/frontend/package-lock.json +2434 -213
  156. package/agent-pane/frontend/package.json +10 -3
  157. package/agent-pane/frontend/src/App.test.tsx +74 -0
  158. package/agent-pane/frontend/src/App.tsx +1 -1
  159. package/agent-pane/frontend/src/AppInitializer.test.tsx +142 -0
  160. package/agent-pane/frontend/src/AppInitializer.tsx +1 -1
  161. package/agent-pane/frontend/src/api/client.test.ts +126 -0
  162. package/agent-pane/frontend/src/api/client.ts +22 -29
  163. package/agent-pane/frontend/src/api/models.ts +7 -0
  164. package/agent-pane/frontend/src/api/providers.ts +12 -0
  165. package/agent-pane/frontend/src/api/sessions.test.ts +241 -0
  166. package/agent-pane/frontend/src/api/sessions.ts +43 -1
  167. package/agent-pane/frontend/src/api/skills.ts +12 -0
  168. package/agent-pane/frontend/src/api/websocket.test.ts +288 -0
  169. package/agent-pane/frontend/src/api/websocket.ts +27 -2
  170. package/agent-pane/frontend/src/api/workspaces.test.ts +112 -0
  171. package/agent-pane/frontend/src/api/workspaces.ts +17 -3
  172. package/agent-pane/frontend/src/components/CollapsibleToolCalls.test.tsx +49 -0
  173. package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +2 -5
  174. package/agent-pane/frontend/src/components/CommandInputBar.test.tsx +133 -0
  175. package/agent-pane/frontend/src/components/CommandInputBar.tsx +296 -17
  176. package/agent-pane/frontend/src/components/CommandPalette.test.tsx +230 -0
  177. package/agent-pane/frontend/src/components/CommandPalette.tsx +26 -23
  178. package/agent-pane/frontend/src/components/IdeWorkspace.tsx +1 -1
  179. package/agent-pane/frontend/src/components/ModelSelector.test.tsx +129 -0
  180. package/agent-pane/frontend/src/components/ModelSelector.tsx +191 -0
  181. package/agent-pane/frontend/src/components/NewSessionDialog.test.tsx +95 -0
  182. package/agent-pane/frontend/src/components/NewSessionDialog.tsx +58 -13
  183. package/agent-pane/frontend/src/components/RenderArea.test.tsx +160 -0
  184. package/agent-pane/frontend/src/components/RenderArea.tsx +18 -25
  185. package/agent-pane/frontend/src/components/RightPanel.test.tsx +43 -0
  186. package/agent-pane/frontend/src/components/RightPanel.tsx +1 -1
  187. package/agent-pane/frontend/src/components/RuntimeIcon.test.tsx +32 -0
  188. package/agent-pane/frontend/src/components/RuntimeIcon.tsx +1 -1
  189. package/agent-pane/frontend/src/components/SessionTabs.test.tsx +72 -0
  190. package/agent-pane/frontend/src/components/SessionTabs.tsx +12 -12
  191. package/agent-pane/frontend/src/components/StatusBar.test.tsx +56 -0
  192. package/agent-pane/frontend/src/components/StatusBar.tsx +25 -31
  193. package/agent-pane/frontend/src/components/StatusDot.test.tsx +25 -0
  194. package/agent-pane/frontend/src/components/StatusDot.tsx +2 -7
  195. package/agent-pane/frontend/src/components/Toast.test.tsx +79 -0
  196. package/agent-pane/frontend/src/components/Toast.tsx +2 -6
  197. package/agent-pane/frontend/src/components/TurnContent.test.tsx +287 -0
  198. package/agent-pane/frontend/src/components/TurnContent.tsx +107 -24
  199. package/agent-pane/frontend/src/components/WorkspaceSidebar.test.tsx +297 -0
  200. package/agent-pane/frontend/src/components/WorkspaceSidebar.tsx +33 -32
  201. package/agent-pane/frontend/src/components/input/OptionSubPopup.tsx +32 -0
  202. package/agent-pane/frontend/src/components/input/SkillMentionPopup.test.tsx +63 -0
  203. package/agent-pane/frontend/src/components/input/SkillMentionPopup.tsx +78 -0
  204. package/agent-pane/frontend/src/components/input/SlashPopup.test.tsx +96 -0
  205. package/agent-pane/frontend/src/components/input/SlashPopup.tsx +63 -0
  206. package/agent-pane/frontend/src/components/panels/ChangesPanel.test.tsx +61 -0
  207. package/agent-pane/frontend/src/components/panels/ChangesPanel.tsx +12 -39
  208. package/agent-pane/frontend/src/components/panels/LogsPanel.test.tsx +58 -0
  209. package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +1 -1
  210. package/agent-pane/frontend/src/components/panels/MetricsPanel.test.tsx +71 -0
  211. package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +63 -31
  212. package/agent-pane/frontend/src/components/panels/TurnsPanel.test.tsx +83 -0
  213. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +66 -7
  214. package/agent-pane/frontend/src/constants/models.test.ts +97 -0
  215. package/agent-pane/frontend/src/constants/models.ts +129 -0
  216. package/agent-pane/frontend/src/hooks/useEscapeKey.test.ts +42 -0
  217. package/agent-pane/frontend/src/hooks/useEscapeKey.ts +13 -0
  218. package/agent-pane/frontend/src/hooks/useOutsideClick.test.ts +92 -0
  219. package/agent-pane/frontend/src/hooks/useOutsideClick.ts +19 -0
  220. package/agent-pane/frontend/src/hooks/useProvider.ts +44 -0
  221. package/agent-pane/frontend/src/hooks/useSessionStatus.test.ts +114 -0
  222. package/agent-pane/frontend/src/hooks/useSessionStatus.ts +34 -0
  223. package/agent-pane/frontend/src/main.test.tsx +37 -0
  224. package/agent-pane/frontend/src/main.tsx +1 -1
  225. package/agent-pane/frontend/src/store/sessionSlice.ts +417 -0
  226. package/agent-pane/frontend/src/store/store.test.ts +1284 -0
  227. package/agent-pane/frontend/src/store/store.ts +16 -617
  228. package/agent-pane/frontend/src/store/uiSlice.ts +83 -0
  229. package/agent-pane/frontend/src/store/workspaceSlice.ts +77 -0
  230. package/agent-pane/frontend/src/store/wsSlice.ts +292 -0
  231. package/agent-pane/frontend/src/styles/app.css +113 -7
  232. package/agent-pane/frontend/src/test/component-helpers.ts +36 -0
  233. package/agent-pane/frontend/src/test/fixtures.ts +96 -0
  234. package/agent-pane/frontend/src/test-setup.ts +5 -0
  235. package/agent-pane/frontend/src/types/index.test.ts +128 -0
  236. package/agent-pane/frontend/src/types/index.ts +80 -104
  237. package/agent-pane/frontend/src/utils/changes.test.ts +81 -0
  238. package/agent-pane/frontend/src/utils/changes.ts +39 -0
  239. package/agent-pane/frontend/src/utils/duration.test.ts +71 -0
  240. package/agent-pane/frontend/src/utils/duration.ts +14 -0
  241. package/agent-pane/frontend/tsconfig.json +4 -1
  242. package/agent-pane/frontend/vite.config.ts +6 -0
  243. package/agent-pane/frontend/vitest.config.ts +29 -0
  244. package/agent-pane/package-lock.json +1527 -0
  245. package/agent-pane/package.json +19 -0
  246. package/agent-pane/shared/provider-types.ts +53 -0
  247. package/agent-pane/shared/types.ts +107 -0
  248. package/agentpane-branch-session.png +0 -0
  249. package/agentpane-expanded-all.png +0 -0
  250. package/agentpane-fixed-rendering.png +0 -0
  251. package/agentpane-focus-check.png +0 -0
  252. package/agentpane-initial.png +0 -0
  253. package/agentpane-mcp-detail.png +0 -0
  254. package/agentpane-no-focus-ring.png +0 -0
  255. package/agentpane-soft-focus.png +0 -0
  256. package/backend/dist/src/llm-provider/ollama-llm.provider.js +1 -0
  257. package/backend/dist/src/llm-provider/ollama-llm.provider.js.map +1 -1
  258. package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
  259. package/data.db +0 -0
  260. package/empty-state.png +0 -0
  261. package/frontend-home.png +0 -0
  262. package/model-selector-open.png +0 -0
  263. package/package.json +1 -1
  264. package/slash-popup-working.png +0 -0
  265. package/agent-pane/frontend/src/components/DiffViewer.tsx +0 -27
@@ -0,0 +1,152 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import Fastify, { type FastifyInstance } from 'fastify';
3
+ import { initDb, getDb } from '../db/index.ts';
4
+ import { registerTurnRoutes } from './turns.ts';
5
+
6
+ // Mock registry to avoid spawning real provider processes
7
+ const mocks = vi.hoisted(() => ({
8
+ startTurn: vi.fn().mockResolvedValue(undefined),
9
+ interruptTurn: vi.fn().mockResolvedValue(true),
10
+ clearThread: vi.fn(),
11
+ clearAllThreads: vi.fn(),
12
+ getModels: vi.fn().mockResolvedValue([]),
13
+ shutdown: vi.fn().mockResolvedValue(undefined),
14
+ }));
15
+
16
+ vi.mock('../providers/registry.ts', () => ({
17
+ getProvider: vi.fn(() => ({
18
+ startTurn: mocks.startTurn,
19
+ interruptTurn: mocks.interruptTurn,
20
+ clearThread: mocks.clearThread,
21
+ clearAllThreads: mocks.clearAllThreads,
22
+ getModels: mocks.getModels,
23
+ shutdown: mocks.shutdown,
24
+ })),
25
+ hasProvider: vi.fn(() => true),
26
+ }));
27
+
28
+ const NOW = '2025-01-01T00:00:00Z';
29
+
30
+ function insertSession(db: ReturnType<typeof getDb>, id: string, overrides: Record<string, unknown> = {}): void {
31
+ db.prepare(
32
+ `INSERT INTO sessions (id, title, runtime_type, model, model_reasoning_effort, cwd, external_thread_id, status, is_running, lifecycle, has_unreviewed, created_at, updated_at)
33
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
34
+ ).run(
35
+ id,
36
+ null,
37
+ 'codex',
38
+ null,
39
+ null,
40
+ null,
41
+ (overrides['external_thread_id'] as string | null) ?? null,
42
+ (overrides['status'] as string) ?? 'active',
43
+ (overrides['is_running'] as number) ?? 0,
44
+ 'working',
45
+ 0,
46
+ NOW,
47
+ NOW,
48
+ );
49
+ }
50
+
51
+ describe('Turn routes', () => {
52
+ let app: FastifyInstance;
53
+
54
+ beforeEach(async () => {
55
+ vi.clearAllMocks();
56
+ initDb(':memory:');
57
+ app = Fastify();
58
+ await registerTurnRoutes(app);
59
+ });
60
+
61
+ describe('POST /api/sessions/:id/turns', () => {
62
+ it('returns 400 when user_prompt is missing', async () => {
63
+ const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/turns', payload: {} });
64
+ expect(res.statusCode).toBe(400);
65
+ });
66
+
67
+ it('returns 400 when user_prompt has wrong type', async () => {
68
+ const res = await app.inject({
69
+ method: 'POST',
70
+ url: '/api/sessions/s1/turns',
71
+ payload: { user_prompt: { nested: true } },
72
+ });
73
+ expect(res.statusCode).toBe(400);
74
+ });
75
+
76
+ it('returns 400 when user_prompt is empty', async () => {
77
+ const res = await app.inject({
78
+ method: 'POST',
79
+ url: '/api/sessions/s1/turns',
80
+ payload: { user_prompt: ' ' },
81
+ });
82
+ expect(res.statusCode).toBe(400);
83
+ });
84
+
85
+ it('returns 404 for missing session', async () => {
86
+ const res = await app.inject({
87
+ method: 'POST',
88
+ url: '/api/sessions/nonexistent/turns',
89
+ payload: { user_prompt: 'hello' },
90
+ });
91
+ expect(res.statusCode).toBe(404);
92
+ });
93
+
94
+ it('returns 409 when session is already running', async () => {
95
+ const db = getDb();
96
+ insertSession(db, 's1', { is_running: 1 });
97
+ const res = await app.inject({
98
+ method: 'POST',
99
+ url: '/api/sessions/s1/turns',
100
+ payload: { user_prompt: 'hello' },
101
+ });
102
+ expect(res.statusCode).toBe(409);
103
+ });
104
+
105
+ it('creates a turn and calls startTurn', async () => {
106
+ const db = getDb();
107
+ insertSession(db, 's1');
108
+ mocks.startTurn.mockResolvedValue(undefined);
109
+ const res = await app.inject({
110
+ method: 'POST',
111
+ url: '/api/sessions/s1/turns',
112
+ payload: { user_prompt: 'hello' },
113
+ });
114
+ expect(res.statusCode).toBe(200);
115
+ const turn = res.json();
116
+ expect(turn.id).toBeDefined();
117
+ expect(turn.user_prompt).toBe('hello');
118
+ expect(turn.status).toBe('pending');
119
+
120
+ expect(mocks.startTurn).toHaveBeenCalledTimes(1);
121
+ const [sessionArg, turnArg] = mocks.startTurn.mock.calls[0];
122
+ expect(sessionArg.id).toBe('s1');
123
+ expect(turnArg.user_prompt).toBe('hello');
124
+ });
125
+ });
126
+
127
+ describe('POST /api/sessions/:id/cancel', () => {
128
+ it('returns 404 for missing session', async () => {
129
+ const res = await app.inject({ method: 'POST', url: '/api/sessions/nonexistent/cancel' });
130
+ expect(res.statusCode).toBe(404);
131
+ });
132
+
133
+ it('returns 409 when no running turn', async () => {
134
+ const db = getDb();
135
+ insertSession(db, 's1');
136
+ mocks.interruptTurn.mockResolvedValue(false);
137
+ const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/cancel' });
138
+ expect(res.statusCode).toBe(409);
139
+ expect(res.json().error).toBe('No running turn to cancel');
140
+ });
141
+
142
+ it('returns success when turn is cancelled', async () => {
143
+ const db = getDb();
144
+ insertSession(db, 's1');
145
+ mocks.interruptTurn.mockResolvedValue(true);
146
+ const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/cancel' });
147
+ expect(res.statusCode).toBe(200);
148
+ expect(res.json()).toEqual({ success: true });
149
+ expect(mocks.interruptTurn).toHaveBeenCalledWith('s1');
150
+ });
151
+ });
152
+ });
@@ -1,13 +1,24 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
2
  import { v4 as uuidv4 } from 'uuid';
3
3
  import { getDb } from '../db/index.ts';
4
- import { startTurn, interruptTurn } from '../codex/codex-provider.ts';
4
+ import { getProvider } from '../providers/registry.ts';
5
5
  import type { Session, Turn } from '../db/schema.ts';
6
6
 
7
7
  export async function registerTurnRoutes(fastify: FastifyInstance): Promise<void> {
8
- // POST /api/sessions/:id/turns — submit prompt, starts Codex turn
8
+ // POST /api/sessions/:id/turns — submit prompt, starts a turn
9
9
  fastify.post<{ Params: { id: string }; Body: { user_prompt: string } }>(
10
10
  '/api/sessions/:id/turns',
11
+ {
12
+ schema: {
13
+ body: {
14
+ type: 'object',
15
+ required: ['user_prompt'],
16
+ properties: {
17
+ user_prompt: { type: 'string', minLength: 1 },
18
+ },
19
+ },
20
+ },
21
+ },
11
22
  async (request, reply) => {
12
23
  const { id } = request.params;
13
24
  const { user_prompt } = request.body || {};
@@ -37,24 +48,33 @@ export async function registerTurnRoutes(fastify: FastifyInstance): Promise<void
37
48
  const turn = db.prepare('SELECT * FROM turns WHERE id = ?').get(turnId) as Turn;
38
49
 
39
50
  // Fire and forget — events stream via WebSocket
40
- startTurn(session, turn).catch((err) => {
41
- console.error(`[codex-provider] Unhandled error for session ${id}:`, err);
42
- });
51
+ getProvider(session.runtime_type)
52
+ .startTurn(session, turn)
53
+ .catch((err) => {
54
+ console.error(`[provider] Unhandled error for session ${id}:`, err);
55
+ });
43
56
 
44
57
  return turn;
45
58
  },
46
59
  );
47
60
 
48
61
  // POST /api/sessions/:id/cancel — cancel running turn
49
- fastify.post<{ Params: { id: string } }>(
50
- '/api/sessions/:id/cancel',
51
- async (request, reply) => {
52
- const { id } = request.params;
53
- const cancelled = interruptTurn(id);
54
- if (!cancelled) {
55
- return reply.code(409).send({ error: 'No running turn to cancel' });
56
- }
57
- return { success: true };
58
- },
59
- );
62
+ fastify.post<{ Params: { id: string } }>('/api/sessions/:id/cancel', async (request, reply) => {
63
+ const { id } = request.params;
64
+ const db = getDb();
65
+
66
+ // Look up session to get runtime_type for provider dispatch
67
+ const session = db.prepare('SELECT runtime_type FROM sessions WHERE id = ?').get(id) as
68
+ | { runtime_type: string }
69
+ | undefined;
70
+ if (!session) {
71
+ return reply.code(404).send({ error: 'Session not found' });
72
+ }
73
+
74
+ const cancelled = await getProvider(session.runtime_type).interruptTurn(id);
75
+ if (!cancelled) {
76
+ return reply.code(409).send({ error: 'No running turn to cancel' });
77
+ }
78
+ return { success: true };
79
+ });
60
80
  }
@@ -0,0 +1,197 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import Fastify, { type FastifyInstance } from 'fastify';
3
+ import { initDb, getDb } from '../db/index.ts';
4
+ import { registerWorkspaceRoutes } from './workspaces.ts';
5
+ import { execFile } from 'node:child_process';
6
+
7
+ // Mock child_process to avoid spawning osascript in tests
8
+ vi.mock('node:child_process', () => ({
9
+ execFile: vi.fn(),
10
+ }));
11
+
12
+ const NOW = '2025-01-01T00:00:00Z';
13
+
14
+ function insertWorkspace(
15
+ db: ReturnType<typeof getDb>,
16
+ id: string,
17
+ path: string,
18
+ overrides: Record<string, unknown> = {},
19
+ ): void {
20
+ db.prepare('INSERT INTO workspaces (id, path, label, deleted, created_at) VALUES (?, ?, ?, ?, ?)').run(
21
+ id,
22
+ path,
23
+ (overrides['label'] as string | null) ?? null,
24
+ (overrides['deleted'] as number) ?? 0,
25
+ (overrides['created_at'] as string) ?? NOW,
26
+ );
27
+ }
28
+
29
+ /** Configure execFile mock to call callback with success.
30
+ * Passes an object as the first result arg so promisify resolves with { stdout, stderr }.
31
+ */
32
+ function mockExecFileSuccess(stdout: string): void {
33
+ const mock = execFile as unknown as ReturnType<typeof vi.fn>;
34
+ mock.mockImplementation((...args: unknown[]) => {
35
+ const cb = args[args.length - 1] as ((err: Error | null, ...results: unknown[]) => void) | undefined;
36
+ if (typeof cb === 'function') cb(null, { stdout, stderr: '' });
37
+ });
38
+ }
39
+
40
+ /** Configure execFile mock to call callback with error (simulates user cancel). */
41
+ function mockExecFileError(error: Error): void {
42
+ const mock = execFile as unknown as ReturnType<typeof vi.fn>;
43
+ mock.mockImplementation((...args: unknown[]) => {
44
+ const cb = args[args.length - 1] as ((err: Error | null, ...results: unknown[]) => void) | undefined;
45
+ if (typeof cb === 'function') cb(error);
46
+ });
47
+ }
48
+
49
+ describe('Workspace routes', () => {
50
+ let app: FastifyInstance;
51
+
52
+ beforeEach(async () => {
53
+ vi.clearAllMocks();
54
+ initDb(':memory:');
55
+ app = Fastify();
56
+ await registerWorkspaceRoutes(app);
57
+ });
58
+
59
+ describe('GET /api/workspaces', () => {
60
+ it('returns empty array when no workspaces', async () => {
61
+ const res = await app.inject({ method: 'GET', url: '/api/workspaces' });
62
+ expect(res.statusCode).toBe(200);
63
+ expect(res.json()).toEqual([]);
64
+ });
65
+
66
+ it('returns all workspaces ordered by created_at ASC', async () => {
67
+ const db = getDb();
68
+ insertWorkspace(db, 'w1', '/path/a', { created_at: '2025-01-02T00:00:00Z' });
69
+ insertWorkspace(db, 'w2', '/path/b', { created_at: '2025-01-01T00:00:00Z' });
70
+ const res = await app.inject({ method: 'GET', url: '/api/workspaces' });
71
+ const workspaces = res.json();
72
+ expect(workspaces).toHaveLength(2);
73
+ expect(workspaces[0].id).toBe('w2');
74
+ expect(workspaces[1].id).toBe('w1');
75
+ });
76
+ });
77
+
78
+ describe('POST /api/workspaces', () => {
79
+ it('returns 400 when path is missing', async () => {
80
+ const res = await app.inject({ method: 'POST', url: '/api/workspaces', payload: {} });
81
+ expect(res.statusCode).toBe(400);
82
+ });
83
+
84
+ it('returns 400 when path is empty', async () => {
85
+ const res = await app.inject({ method: 'POST', url: '/api/workspaces', payload: { path: ' ' } });
86
+ expect(res.statusCode).toBe(400);
87
+ });
88
+
89
+ it('returns 400 when path has wrong type', async () => {
90
+ const res = await app.inject({ method: 'POST', url: '/api/workspaces', payload: { path: { nested: true } } });
91
+ expect(res.statusCode).toBe(400);
92
+ });
93
+
94
+ it('returns 409 for duplicate active workspace', async () => {
95
+ const db = getDb();
96
+ insertWorkspace(db, 'w1', '/existing/path');
97
+ const res = await app.inject({ method: 'POST', url: '/api/workspaces', payload: { path: '/existing/path' } });
98
+ expect(res.statusCode).toBe(409);
99
+ });
100
+
101
+ it('creates a new workspace', async () => {
102
+ const res = await app.inject({
103
+ method: 'POST',
104
+ url: '/api/workspaces',
105
+ payload: { path: '/new/path', label: 'Test' },
106
+ });
107
+ expect(res.statusCode).toBe(200);
108
+ const ws = res.json();
109
+ expect(ws.id).toBeDefined();
110
+ expect(ws.path).toBe('/new/path');
111
+ expect(ws.label).toBe('Test');
112
+ expect(ws.deleted).toBe(0);
113
+ });
114
+
115
+ it('restores a soft-deleted workspace', async () => {
116
+ const db = getDb();
117
+ insertWorkspace(db, 'w1', '/deleted/path', { deleted: 1, label: 'Old' });
118
+ const res = await app.inject({
119
+ method: 'POST',
120
+ url: '/api/workspaces',
121
+ payload: { path: '/deleted/path', label: 'Restored' },
122
+ });
123
+ expect(res.statusCode).toBe(200);
124
+ const ws = res.json();
125
+ expect(ws.id).toBe('w1');
126
+ expect(ws.deleted).toBe(0);
127
+ expect(ws.label).toBe('Restored');
128
+ });
129
+ });
130
+
131
+ describe('POST /api/workspaces/hide', () => {
132
+ it('returns 400 when path is missing', async () => {
133
+ const res = await app.inject({ method: 'POST', url: '/api/workspaces/hide', payload: {} });
134
+ expect(res.statusCode).toBe(400);
135
+ });
136
+
137
+ it('soft-deletes existing workspace', async () => {
138
+ const db = getDb();
139
+ insertWorkspace(db, 'w1', '/path/to/hide');
140
+ const res = await app.inject({ method: 'POST', url: '/api/workspaces/hide', payload: { path: '/path/to/hide' } });
141
+ expect(res.statusCode).toBe(200);
142
+ expect(res.json()).toEqual({ success: true });
143
+ const ws = db.prepare('SELECT deleted FROM workspaces WHERE id = ?').get('w1') as { deleted: number };
144
+ expect(ws.deleted).toBe(1);
145
+ });
146
+
147
+ it('creates and soft-deletes new workspace when path does not exist', async () => {
148
+ const res = await app.inject({ method: 'POST', url: '/api/workspaces/hide', payload: { path: '/new/path' } });
149
+ expect(res.statusCode).toBe(200);
150
+ const db = getDb();
151
+ const ws = db.prepare('SELECT * FROM workspaces WHERE path = ?').get('/new/path') as {
152
+ deleted: number;
153
+ id: string;
154
+ };
155
+ expect(ws.deleted).toBe(1);
156
+ });
157
+ });
158
+
159
+ describe('DELETE /api/workspaces/:id', () => {
160
+ it('returns 404 for missing workspace', async () => {
161
+ const res = await app.inject({ method: 'DELETE', url: '/api/workspaces/nonexistent' });
162
+ expect(res.statusCode).toBe(404);
163
+ });
164
+
165
+ it('returns 404 for already-deleted workspace', async () => {
166
+ const db = getDb();
167
+ insertWorkspace(db, 'w1', '/path', { deleted: 1 });
168
+ const res = await app.inject({ method: 'DELETE', url: '/api/workspaces/w1' });
169
+ expect(res.statusCode).toBe(404);
170
+ });
171
+
172
+ it('soft-deletes an active workspace', async () => {
173
+ const db = getDb();
174
+ insertWorkspace(db, 'w1', '/path');
175
+ const res = await app.inject({ method: 'DELETE', url: '/api/workspaces/w1' });
176
+ expect(res.statusCode).toBe(200);
177
+ expect(res.json()).toEqual({ success: true });
178
+ const ws = db.prepare('SELECT deleted FROM workspaces WHERE id = ?').get('w1') as { deleted: number };
179
+ expect(ws.deleted).toBe(1);
180
+ });
181
+ });
182
+
183
+ describe('GET /api/pick-directory', () => {
184
+ it('returns path on success', async () => {
185
+ mockExecFileSuccess('/selected/path/\n');
186
+ const res = await app.inject({ method: 'GET', url: '/api/pick-directory' });
187
+ expect(res.statusCode).toBe(200);
188
+ expect(res.json()).toEqual({ path: '/selected/path' });
189
+ });
190
+
191
+ it('returns 204 when user cancels', async () => {
192
+ mockExecFileError(new Error('User cancelled'));
193
+ const res = await app.inject({ method: 'GET', url: '/api/pick-directory' });
194
+ expect(res.statusCode).toBe(204);
195
+ });
196
+ });
197
+ });
@@ -10,48 +10,57 @@ const execFileAsync = promisify(execFile);
10
10
  export async function registerWorkspaceRoutes(fastify: FastifyInstance): Promise<void> {
11
11
  // GET /api/workspaces — list ALL workspaces (active + soft-deleted)
12
12
  fastify.get('/api/workspaces', async () => {
13
- const rows = getDb()
14
- .prepare('SELECT * FROM workspaces ORDER BY created_at ASC')
15
- .all() as Workspace[];
13
+ const rows = getDb().prepare('SELECT * FROM workspaces ORDER BY created_at ASC').all() as Workspace[];
16
14
  return rows;
17
15
  });
18
16
 
19
17
  // POST /api/workspaces — create workspace (or restore if soft-deleted)
20
- fastify.post<{ Body: { path: string; label?: string } }>(
21
- '/api/workspaces',
22
- async (request, reply) => {
23
- const { path, label = null } = request.body || {};
24
- if (!path || !path.trim()) {
25
- return reply.code(400).send({ error: 'Path is required' });
26
- }
18
+ fastify.post<{ Body: { path: string; label?: string } }>('/api/workspaces', {
19
+ schema: {
20
+ body: {
21
+ type: 'object',
22
+ required: ['path'],
23
+ properties: {
24
+ path: { type: 'string' },
25
+ label: { type: 'string' },
26
+ },
27
+ },
28
+ },
29
+ }, async (request, reply) => {
30
+ const { path, label = null } = request.body || {};
31
+ if (!path || !path.trim()) {
32
+ return reply.code(400).send({ error: 'Path is required' });
33
+ }
27
34
 
28
- const trimmedPath = path.trim();
29
- const db = getDb();
35
+ const trimmedPath = path.trim();
36
+ const db = getDb();
30
37
 
31
- // Check if workspace with this path already exists
32
- const existing = db.prepare('SELECT * FROM workspaces WHERE path = ?').get(trimmedPath) as Workspace | undefined;
38
+ // Check if workspace with this path already exists
39
+ const existing = db.prepare('SELECT * FROM workspaces WHERE path = ?').get(trimmedPath) as Workspace | undefined;
33
40
 
34
- if (existing) {
35
- if (existing.deleted === 0) {
36
- return reply.code(409).send({ error: 'Workspace with this path already exists' });
37
- }
38
- // Restore soft-deleted workspace
39
- db.prepare('UPDATE workspaces SET deleted = 0, label = ? WHERE id = ?').run(label, existing.id);
40
- const restored = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(existing.id) as Workspace;
41
- return restored;
41
+ if (existing) {
42
+ if (existing.deleted === 0) {
43
+ return reply.code(409).send({ error: 'Workspace with this path already exists' });
42
44
  }
45
+ // Restore soft-deleted workspace
46
+ db.prepare('UPDATE workspaces SET deleted = 0, label = ? WHERE id = ?').run(label, existing.id);
47
+ const restored = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(existing.id) as Workspace;
48
+ return restored;
49
+ }
43
50
 
44
- // Create new workspace
45
- const id = uuidv4();
46
- const now = new Date().toISOString();
47
- db.prepare(
48
- 'INSERT INTO workspaces (id, path, label, deleted, created_at) VALUES (?, ?, ?, 0, ?)',
49
- ).run(id, trimmedPath, label, now);
51
+ // Create new workspace
52
+ const id = uuidv4();
53
+ const now = new Date().toISOString();
54
+ db.prepare('INSERT INTO workspaces (id, path, label, deleted, created_at) VALUES (?, ?, ?, 0, ?)').run(
55
+ id,
56
+ trimmedPath,
57
+ label,
58
+ now,
59
+ );
50
60
 
51
- const workspace = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(id) as Workspace;
52
- return workspace;
53
- },
54
- );
61
+ const workspace = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(id) as Workspace;
62
+ return workspace;
63
+ });
55
64
 
56
65
  // GET /api/pick-directory — open native OS folder picker (macOS only)
57
66
  fastify.get('/api/pick-directory', async (_request, reply) => {
@@ -69,30 +78,39 @@ export async function registerWorkspaceRoutes(fastify: FastifyInstance): Promise
69
78
  });
70
79
 
71
80
  // POST /api/workspaces/hide — soft-delete workspace by path (creates record if not exists)
72
- fastify.post<{ Body: { path: string } }>(
73
- '/api/workspaces/hide',
74
- async (request, reply) => {
75
- const { path } = request.body || {};
76
- if (!path || !path.trim()) {
77
- return reply.code(400).send({ error: 'Path is required' });
78
- }
79
- const trimmedPath = path.trim();
80
- const db = getDb();
81
+ fastify.post<{ Body: { path: string } }>('/api/workspaces/hide', {
82
+ schema: {
83
+ body: {
84
+ type: 'object',
85
+ required: ['path'],
86
+ properties: {
87
+ path: { type: 'string' },
88
+ },
89
+ },
90
+ },
91
+ }, async (request, reply) => {
92
+ const { path } = request.body || {};
93
+ if (!path || !path.trim()) {
94
+ return reply.code(400).send({ error: 'Path is required' });
95
+ }
96
+ const trimmedPath = path.trim();
97
+ const db = getDb();
81
98
 
82
- const existing = db.prepare('SELECT * FROM workspaces WHERE path = ?').get(trimmedPath) as Workspace | undefined;
83
- if (existing) {
84
- db.prepare('UPDATE workspaces SET deleted = 1 WHERE id = ?').run(existing.id);
85
- } else {
86
- const id = uuidv4();
87
- const now = new Date().toISOString();
88
- db.prepare(
89
- 'INSERT INTO workspaces (id, path, label, deleted, created_at) VALUES (?, ?, NULL, 1, ?)',
90
- ).run(id, trimmedPath, now);
91
- }
99
+ const existing = db.prepare('SELECT * FROM workspaces WHERE path = ?').get(trimmedPath) as Workspace | undefined;
100
+ if (existing) {
101
+ db.prepare('UPDATE workspaces SET deleted = 1 WHERE id = ?').run(existing.id);
102
+ } else {
103
+ const id = uuidv4();
104
+ const now = new Date().toISOString();
105
+ db.prepare('INSERT INTO workspaces (id, path, label, deleted, created_at) VALUES (?, ?, NULL, 1, ?)').run(
106
+ id,
107
+ trimmedPath,
108
+ now,
109
+ );
110
+ }
92
111
 
93
- return { success: true };
94
- },
95
- );
112
+ return { success: true };
113
+ });
96
114
 
97
115
  // DELETE /api/workspaces/:id — soft-delete workspace by ID (does NOT delete sessions)
98
116
  fastify.delete<{ Params: { id: string } }>('/api/workspaces/:id', async (request, reply) => {
@@ -107,4 +125,4 @@ export async function registerWorkspaceRoutes(fastify: FastifyInstance): Promise
107
125
  db.prepare('UPDATE workspaces SET deleted = 1 WHERE id = ?').run(id);
108
126
  return { success: true };
109
127
  });
110
- }
128
+ }