@cloudcli-ai/cloudcli 1.37.0 → 1.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/dist/assets/index-D3jfzuKN.css +32 -0
  2. package/dist/assets/{index-eGqPeIEu.js → index-DTMWjJj8.js} +324 -305
  3. package/dist/index.html +2 -2
  4. package/dist-server/server/modules/commands/commands.routes.js +3 -3
  5. package/dist-server/server/modules/commands/commands.routes.js.map +1 -1
  6. package/dist-server/server/modules/commands/tests/commands.test.js +2 -7
  7. package/dist-server/server/modules/commands/tests/commands.test.js.map +1 -1
  8. package/dist-server/server/modules/database/index.js +2 -0
  9. package/dist-server/server/modules/database/index.js.map +1 -1
  10. package/dist-server/server/modules/database/migrations.js +18 -1
  11. package/dist-server/server/modules/database/migrations.js.map +1 -1
  12. package/dist-server/server/modules/database/repositories/provider-models.js +112 -0
  13. package/dist-server/server/modules/database/repositories/provider-models.js.map +1 -0
  14. package/dist-server/server/modules/database/repositories/sessions.db.js +63 -7
  15. package/dist-server/server/modules/database/repositories/sessions.db.js.map +1 -1
  16. package/dist-server/server/modules/database/schema.js +28 -3
  17. package/dist-server/server/modules/database/schema.js.map +1 -1
  18. package/dist-server/server/modules/database/tests/provider-models.db.integration.test.js +102 -0
  19. package/dist-server/server/modules/database/tests/provider-models.db.integration.test.js.map +1 -0
  20. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js +2 -1
  21. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js.map +1 -1
  22. package/dist-server/server/modules/database/tests/sessions.db.integration.test.js +22 -0
  23. package/dist-server/server/modules/database/tests/sessions.db.integration.test.js.map +1 -1
  24. package/dist-server/server/modules/file-tree/file-tree.module.js +5 -1
  25. package/dist-server/server/modules/file-tree/file-tree.module.js.map +1 -1
  26. package/dist-server/server/modules/file-tree/file-tree.service.js +50 -18
  27. package/dist-server/server/modules/file-tree/file-tree.service.js.map +1 -1
  28. package/dist-server/server/modules/file-tree/tests/file-tree.service.test.js +83 -7
  29. package/dist-server/server/modules/file-tree/tests/file-tree.service.test.js.map +1 -1
  30. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js +46 -14
  31. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js.map +1 -1
  32. package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js +8 -15
  33. package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js.map +1 -1
  34. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js +58 -71
  35. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js.map +1 -1
  36. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js +1 -54
  37. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js.map +1 -1
  38. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js +7 -7
  39. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js.map +1 -1
  40. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js +32 -669
  41. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js.map +1 -1
  42. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +105 -328
  43. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -1
  44. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +0 -12
  45. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -1
  46. package/dist-server/server/modules/providers/provider.routes.js +139 -31
  47. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  48. package/dist-server/server/modules/providers/services/provider-models.service.js +167 -199
  49. package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -1
  50. package/dist-server/server/modules/providers/services/provider-runtime.service.js +2 -2
  51. package/dist-server/server/modules/providers/services/provider-runtime.service.js.map +1 -1
  52. package/dist-server/server/modules/providers/services/session-conversations-search.service.js +12 -4
  53. package/dist-server/server/modules/providers/services/session-conversations-search.service.js.map +1 -1
  54. package/dist-server/server/modules/providers/services/sessions.service.js +62 -3
  55. package/dist-server/server/modules/providers/services/sessions.service.js.map +1 -1
  56. package/dist-server/server/modules/providers/tests/codex-sessions.test.js +53 -17
  57. package/dist-server/server/modules/providers/tests/codex-sessions.test.js.map +1 -1
  58. package/dist-server/server/modules/providers/tests/opencode-models.test.js +15 -126
  59. package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -1
  60. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +5 -5
  61. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -1
  62. package/dist-server/server/modules/providers/tests/provider-models.service.test.js +175 -292
  63. package/dist-server/server/modules/providers/tests/provider-models.service.test.js.map +1 -1
  64. package/dist-server/server/modules/providers/tests/provider-runtime.service.test.js +2 -6
  65. package/dist-server/server/modules/providers/tests/provider-runtime.service.test.js.map +1 -1
  66. package/dist-server/server/modules/providers/tests/provider-token-usage.service.test.js +1 -0
  67. package/dist-server/server/modules/providers/tests/provider-token-usage.service.test.js.map +1 -1
  68. package/dist-server/server/modules/providers/tests/provider.routes.test.js +126 -0
  69. package/dist-server/server/modules/providers/tests/provider.routes.test.js.map +1 -0
  70. package/dist-server/server/modules/providers/tests/sessions.service.test.js +87 -0
  71. package/dist-server/server/modules/providers/tests/sessions.service.test.js.map +1 -0
  72. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +5 -1
  73. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  74. package/package.json +3 -3
  75. package/server/modules/commands/commands.routes.ts +3 -3
  76. package/server/modules/commands/tests/commands.test.ts +2 -7
  77. package/server/modules/database/index.ts +2 -0
  78. package/server/modules/database/migrations.ts +20 -0
  79. package/server/modules/database/repositories/provider-models.ts +168 -0
  80. package/server/modules/database/repositories/sessions.db.ts +85 -8
  81. package/server/modules/database/schema.ts +29 -3
  82. package/server/modules/database/tests/provider-models.db.integration.test.ts +125 -0
  83. package/server/modules/database/tests/sessions-provider-mapping.test.ts +2 -1
  84. package/server/modules/database/tests/sessions.db.integration.test.ts +32 -0
  85. package/server/modules/file-tree/file-tree.module.ts +5 -1
  86. package/server/modules/file-tree/file-tree.service.ts +68 -20
  87. package/server/modules/file-tree/tests/file-tree.service.test.ts +104 -7
  88. package/server/modules/providers/list/claude/claude-models.provider.ts +46 -14
  89. package/server/modules/providers/list/claude/claude-runtime.provider.js +8 -16
  90. package/server/modules/providers/list/codex/codex-models.provider.ts +58 -99
  91. package/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +1 -60
  92. package/server/modules/providers/list/codex/codex-sessions.provider.ts +7 -7
  93. package/server/modules/providers/list/cursor/cursor-models.provider.ts +32 -710
  94. package/server/modules/providers/list/opencode/opencode-models.provider.ts +105 -413
  95. package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +0 -13
  96. package/server/modules/providers/provider.routes.ts +185 -33
  97. package/server/modules/providers/services/provider-models.service.ts +223 -288
  98. package/server/modules/providers/services/provider-runtime.service.ts +2 -3
  99. package/server/modules/providers/services/session-conversations-search.service.ts +12 -4
  100. package/server/modules/providers/services/sessions.service.ts +87 -3
  101. package/server/modules/providers/tests/codex-sessions.test.ts +57 -18
  102. package/server/modules/providers/tests/opencode-models.test.ts +34 -135
  103. package/server/modules/providers/tests/opencode-sessions.test.ts +5 -5
  104. package/server/modules/providers/tests/provider-models.service.test.ts +353 -450
  105. package/server/modules/providers/tests/provider-runtime.service.test.ts +2 -6
  106. package/server/modules/providers/tests/provider-token-usage.service.test.ts +1 -0
  107. package/server/modules/providers/tests/provider.routes.test.ts +183 -0
  108. package/server/modules/providers/tests/sessions.service.test.ts +127 -0
  109. package/server/modules/websocket/services/chat-websocket.service.ts +5 -1
  110. package/server/shared/interfaces.ts +4 -6
  111. package/server/shared/types.ts +29 -17
  112. package/dist/assets/index-DSlds633.css +0 -32
@@ -787,11 +787,18 @@ async function parseClaudeSessionMatches(
787
787
  ? matchedSessionsForFile
788
788
  : [session];
789
789
 
790
- const targetSessionIds = new Set(targetSessions.map((candidate) => candidate.session_id));
790
+ // Transcript lines are tagged with the provider session id (e.g. Claude's own
791
+ // conversation UUID), which is not necessarily the app-facing `session_id`.
792
+ // Match and attribute by the provider id, but map results back to the
793
+ // internal `session_id` the rest of the app uses to identify sessions.
794
+ const providerToInternalId = new Map<string, string>();
791
795
  const customNameBySessionId = new Map<string, string | null>();
792
796
  for (const candidate of targetSessions) {
793
- customNameBySessionId.set(candidate.session_id, candidate.custom_name ?? null);
797
+ const providerId = candidate.provider_session_id || candidate.session_id;
798
+ providerToInternalId.set(providerId, candidate.session_id);
799
+ customNameBySessionId.set(providerId, candidate.custom_name ?? null);
794
800
  }
801
+ const targetSessionIds = new Set(providerToInternalId.keys());
795
802
 
796
803
  type ClaudeSessionSearchState = {
797
804
  matches: SessionConversationMatch[];
@@ -912,8 +919,9 @@ async function parseClaudeSessionMatches(
912
919
  continue;
913
920
  }
914
921
 
915
- fileResults.set(sessionId, {
916
- sessionId,
922
+ const internalSessionId = providerToInternalId.get(sessionId) ?? sessionId;
923
+ fileResults.set(internalSessionId, {
924
+ sessionId: internalSessionId,
917
925
  provider: 'claude',
918
926
  sessionSummary: toSummaryText(
919
927
  customNameBySessionId.get(sessionId) ?? null,
@@ -17,6 +17,7 @@ type CreateAppSessionResult = {
17
17
  sessionId: string;
18
18
  provider: LLMProvider;
19
19
  projectPath: string;
20
+ sessionName: string;
20
21
  };
21
22
 
22
23
  type ArchivedSessionListItem = {
@@ -32,6 +33,17 @@ type ArchivedSessionListItem = {
32
33
  isProjectArchived: boolean;
33
34
  };
34
35
 
36
+ type RecentSessionListItem = Pick<
37
+ ArchivedSessionListItem,
38
+ 'sessionId' | 'provider' | 'projectId' | 'projectDisplayName' | 'sessionTitle' | 'lastActivity'
39
+ >;
40
+
41
+ type RecentSessionsPage = {
42
+ conversations: RecentSessionListItem[];
43
+ total: number;
44
+ hasMore: boolean;
45
+ };
46
+
35
47
  type SessionDetails = {
36
48
  /** Canonical app-facing session id (may differ from the looked-up id when a provider-native id was given). */
37
49
  sessionId: string;
@@ -51,6 +63,13 @@ type SessionDetails = {
51
63
  } | null;
52
64
  };
53
65
 
66
+ const MAX_CLOUDCLI_SESSION_NAME_WORDS = 4;
67
+
68
+ function buildCloudCliSessionName(initialMessage: string): string {
69
+ const words = initialMessage.trim().split(/\s+/).filter(Boolean);
70
+ return words.slice(0, MAX_CLOUDCLI_SESSION_NAME_WORDS).join(' ') || 'Untitled Session';
71
+ }
72
+
54
73
  /**
55
74
  * Removes one file if it exists.
56
75
  */
@@ -119,6 +138,40 @@ export const sessionsService = {
119
138
  return chatRunRegistry.listRunningRuns();
120
139
  },
121
140
 
141
+ /**
142
+ * Returns the active conversation feed in true global activity order.
143
+ */
144
+ listRecentSessions(limit: number, offset: number): RecentSessionsPage {
145
+ const page = sessionsDb.getRecentSessionsPage(limit, offset);
146
+ const projectCache = new Map<string, ReturnType<typeof projectsDb.getProjectPath>>();
147
+ const conversations = page.sessions.map((session) => {
148
+ const projectPath = session.project_path?.trim() ? session.project_path : null;
149
+ let project = null;
150
+
151
+ if (projectPath) {
152
+ if (!projectCache.has(projectPath)) {
153
+ projectCache.set(projectPath, projectsDb.getProjectPath(projectPath));
154
+ }
155
+ project = projectCache.get(projectPath) ?? null;
156
+ }
157
+
158
+ return {
159
+ sessionId: session.session_id,
160
+ provider: session.provider as LLMProvider,
161
+ projectId: project?.project_id ?? null,
162
+ projectDisplayName: resolveProjectDisplayName(projectPath, project?.custom_project_name),
163
+ sessionTitle: session.custom_name?.trim() || session.session_id,
164
+ lastActivity: session.updated_at ?? session.created_at ?? null,
165
+ };
166
+ });
167
+
168
+ return {
169
+ conversations,
170
+ total: page.total,
171
+ hasMore: offset + conversations.length < page.total,
172
+ };
173
+ },
174
+
122
175
  /**
123
176
  * Resolves the provider-native session id a runtime needs for resume.
124
177
  *
@@ -154,9 +207,15 @@ export const sessionsService = {
154
207
  * (via `POST /api/providers/sessions`) when the user starts a brand-new
155
208
  * chat, navigates to the returned id immediately, and the id never changes
156
209
  * for the lifetime of the conversation. The provider-native id is mapped to
157
- * this row later, when the provider runtime announces it mid-run.
210
+ * this row later, when the provider runtime announces it mid-run. Its title
211
+ * comes directly from the first visible CloudCLI message and is limited to
212
+ * four whole words before any provider-owned storage exists.
158
213
  */
159
- createAppSession(provider: LLMProvider, projectPath: string): CreateAppSessionResult {
214
+ createAppSession(
215
+ provider: LLMProvider,
216
+ projectPath: string,
217
+ initialMessage: string,
218
+ ): CreateAppSessionResult {
160
219
  const normalizedProjectPath = projectPath.trim();
161
220
  if (!normalizedProjectPath) {
162
221
  throw new AppError('projectPath is required.', {
@@ -166,15 +225,40 @@ export const sessionsService = {
166
225
  }
167
226
 
168
227
  const sessionId = randomUUID();
169
- sessionsDb.createAppSession(sessionId, provider, normalizedProjectPath);
228
+ const sessionName = buildCloudCliSessionName(initialMessage);
229
+ sessionsDb.createAppSession(sessionId, provider, normalizedProjectPath, sessionName);
170
230
 
171
231
  return {
172
232
  sessionId,
173
233
  provider,
174
234
  projectPath: normalizedProjectPath,
235
+ sessionName,
175
236
  };
176
237
  },
177
238
 
239
+ /**
240
+ * Resolves the provider-native id only for an explicit user copy action.
241
+ * Normal session payloads continue to expose only the stable app id.
242
+ */
243
+ getProviderSessionId(sessionId: string): string {
244
+ const session = sessionsDb.getSessionById(sessionId);
245
+ if (!session) {
246
+ throw new AppError(`Session "${sessionId}" was not found.`, {
247
+ code: 'SESSION_NOT_FOUND',
248
+ statusCode: 404,
249
+ });
250
+ }
251
+
252
+ if (!session.provider_session_id) {
253
+ throw new AppError('This session ID is not available yet.', {
254
+ code: 'PROVIDER_SESSION_ID_NOT_AVAILABLE',
255
+ statusCode: 409,
256
+ });
257
+ }
258
+
259
+ return session.provider_session_id;
260
+ },
261
+
178
262
  /**
179
263
  * Fetches persisted history by app session id.
180
264
  *
@@ -64,24 +64,24 @@ const writeCodexTranscript = async (
64
64
  return filePath;
65
65
  };
66
66
 
67
- test('Codex synchronizer titles app-created sessions from the first user message', { concurrency: false }, async () => {
67
+ test('Codex synchronizer preserves the title assigned when CloudCLI creates a session', { concurrency: false }, async () => {
68
68
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-session-sync-app-'));
69
69
  const workspacePath = path.join(tempRoot, 'workspace');
70
70
  await mkdir(workspacePath, { recursive: true });
71
71
  const restoreHomeDir = patchHomeDir(tempRoot);
72
72
 
73
73
  try {
74
- await writeCodexTranscript(tempRoot, 'codex-app-1', workspacePath, 'Fix the login redirect bug');
74
+ await writeCodexTranscript(tempRoot, 'codex-app-1', workspacePath, 'Provider transcript title must not win');
75
75
  await withIsolatedDatabase(async () => {
76
76
  // The app allocates its own id and later maps the provider id onto it,
77
77
  // exactly as a message sent from cloudcli does.
78
- sessionsDb.createAppSession('app-1', 'codex', workspacePath);
78
+ sessionsDb.createAppSession('app-1', 'codex', workspacePath, 'Fix the login redirect');
79
79
  sessionsDb.assignProviderSessionId('app-1', 'codex-app-1');
80
80
 
81
81
  const synchronizer = new CodexSessionSynchronizer();
82
82
  await synchronizer.synchronize();
83
83
 
84
- assert.equal(sessionsDb.getSessionById('app-1')?.custom_name, 'Fix the login redirect bug');
84
+ assert.equal(sessionsDb.getSessionById('app-1')?.custom_name, 'Fix the login redirect');
85
85
  });
86
86
  } finally {
87
87
  restoreHomeDir();
@@ -152,7 +152,7 @@ test('Codex synchronizer leaves indexed sessions untitled when no name is availa
152
152
  }
153
153
  });
154
154
 
155
- test('Codex history renders Promise.all shell wrappers as Bash activity', { concurrency: false }, async () => {
155
+ test('Codex history preserves wrapped exec tool calls and results', { concurrency: false }, async () => {
156
156
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-exec-history-'));
157
157
  const workspacePath = path.join(tempRoot, 'workspace');
158
158
  await mkdir(workspacePath, { recursive: true });
@@ -161,15 +161,46 @@ test('Codex history renders Promise.all shell wrappers as Bash activity', { conc
161
161
  try {
162
162
  const providerSessionId = 'codex-exec-1';
163
163
  const transcriptPath = await writeCodexTranscript(tempRoot, providerSessionId, workspacePath);
164
- const execInput = 'const cmds = ["echo one", "echo two"]; await Promise.all(cmds.map(command => tools.shell_command({ command })));';
165
- const planInput = 'await tools.update_plan({ plan: [] });';
166
- await writeFile(transcriptPath, [
164
+ const wrappedCalls = [
165
+ {
166
+ callId: 'shell-command-1',
167
+ input: 'const cmds = ["echo one", "echo two"]; await Promise.all(cmds.map(command => tools.shell_command({ command })));',
168
+ expectedToolName: 'Bash',
169
+ expectedToolInput: JSON.stringify({ command: 'echo one\necho two' }),
170
+ },
171
+ {
172
+ callId: 'json-shell-command-1',
173
+ input: 'const r = await tools.shell_command({"command":"Get-Content -Raw README.md","workdir":"C:\\\\workspace","timeout_ms":10000}); text(r)',
174
+ expectedToolName: 'Bash',
175
+ expectedToolInput: JSON.stringify({ command: 'Get-Content -Raw README.md' }),
176
+ },
177
+ {
178
+ callId: 'exec-command-1',
179
+ input: 'await tools.exec_command({"command":"echo current"});',
180
+ expectedToolName: 'Bash',
181
+ expectedToolInput: JSON.stringify({ command: 'echo current' }),
182
+ },
183
+ { callId: 'apply-patch-1', input: 'await tools.apply_patch("*** Begin Patch\\n*** End Patch");' },
184
+ { callId: 'web-run-1', input: 'await tools.web__run({ search_query: [{ q: "Codex" }] });' },
185
+ { callId: 'update-plan-1', input: 'await tools.update_plan({ plan: [] });' },
186
+ { callId: 'unknown-1', input: 'await tools.unknown_wrapper({ value: true });' },
187
+ ];
188
+ const transcriptLines = [
167
189
  JSON.stringify({ type: 'session_meta', payload: { id: providerSessionId, cwd: workspacePath } }),
168
- JSON.stringify({ type: 'response_item', payload: { type: 'custom_tool_call', name: 'exec', call_id: 'exec-1', input: execInput } }),
169
- JSON.stringify({ type: 'response_item', payload: { type: 'custom_tool_call_output', call_id: 'exec-1', output: 'done' } }),
170
- JSON.stringify({ type: 'response_item', payload: { type: 'custom_tool_call', name: 'exec', call_id: 'plan-1', input: planInput } }),
171
- JSON.stringify({ type: 'response_item', payload: { type: 'custom_tool_call_output', call_id: 'plan-1', output: 'done' } }),
172
- ].join('\n') + '\n', 'utf8');
190
+ ];
191
+ for (const call of wrappedCalls) {
192
+ transcriptLines.push(
193
+ JSON.stringify({
194
+ type: 'response_item',
195
+ payload: { type: 'custom_tool_call', name: 'exec', call_id: call.callId, input: call.input },
196
+ }),
197
+ JSON.stringify({
198
+ type: 'response_item',
199
+ payload: { type: 'custom_tool_call_output', call_id: call.callId, output: `result:${call.callId}` },
200
+ }),
201
+ );
202
+ }
203
+ await writeFile(transcriptPath, `${transcriptLines.join('\n')}\n`, 'utf8');
173
204
 
174
205
  await withIsolatedDatabase(async () => {
175
206
  sessionsDb.createAppSession('app-exec-1', 'codex', workspacePath);
@@ -179,11 +210,19 @@ test('Codex history renders Promise.all shell wrappers as Bash activity', { conc
179
210
  const history = await new CodexSessionsProvider().fetchHistory('app-exec-1');
180
211
  const toolUses = history.messages.filter((message) => message.kind === 'tool_use');
181
212
  const toolResults = history.messages.filter((message) => message.kind === 'tool_result');
182
-
183
- assert.equal(toolUses.length, 1);
184
- assert.equal(toolUses[0].toolName, 'Bash');
185
- assert.equal(toolUses[0].toolInput, JSON.stringify({ command: 'echo one\necho two' }));
186
- assert.equal(toolResults.some((message) => message.toolCallId === 'plan-1'), false);
213
+ const toolUsesById = new Map(toolUses.map((message) => [message.toolId, message]));
214
+ const toolResultsById = new Map(toolResults.map((message) => [message.toolId, message]));
215
+
216
+ assert.equal(toolUses.length, wrappedCalls.length);
217
+ assert.equal(toolResults.length, wrappedCalls.length);
218
+ for (const call of wrappedCalls) {
219
+ const toolUse = toolUsesById.get(call.callId);
220
+ assert.ok(toolUse);
221
+ assert.equal(toolUse.toolName, call.expectedToolName || 'exec');
222
+ assert.equal(toolUse.toolInput, call.expectedToolInput || call.input);
223
+ assert.equal(toolUse.toolResult?.content, `result:${call.callId}`);
224
+ assert.equal(toolResultsById.get(call.callId)?.content, `result:${call.callId}`);
225
+ }
187
226
  });
188
227
  } finally {
189
228
  restoreHomeDir();
@@ -2,141 +2,40 @@ import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
 
4
4
  import {
5
- buildOpenCodeDefinitionFromVerboseModels,
6
- buildOpenCodeDefinitionFromIds,
7
- parseOpenCodeModelsStdout,
8
- parseOpenCodeVerboseModelsStdout,
5
+ OpenCodeProviderModels,
6
+ OPENCODE_PREDEFINED_MODELS,
9
7
  } from '@/modules/providers/list/opencode/opencode-models.provider.js';
10
8
 
11
- test('OpenCode models provider parses plain CLI output and removes duplicates', () => {
12
- const ids = parseOpenCodeModelsStdout(`
13
- opencode/big-pickle
14
- not a model
15
- anthropic/claude-opus-4-7-fast
16
- anthropic/claude-opus-4-7-fast
17
- openai/gpt-5.5-pro
18
- `);
19
-
20
- assert.deepEqual(ids, [
21
- 'opencode/big-pickle',
22
- 'anthropic/claude-opus-4-7-fast',
23
- 'openai/gpt-5.5-pro',
24
- ]);
25
- });
26
-
27
- test('OpenCode models provider formats frontend labels from provider-prefixed ids', () => {
28
- const definition = buildOpenCodeDefinitionFromIds([
29
- 'opencode/deepseek-v4-flash-free',
30
- 'opencode/nemotron-3-super-free',
31
- 'anthropic/claude-3-5-sonnet-20241022',
32
- 'anthropic/claude-opus-4-7-fast',
33
- 'google/model-alpha',
34
- 'openai/gpt-5.4-mini-fast',
35
- 'openai/gpt-5.5-pro',
36
- 'newprovider/alpha-v12-special-20261231',
37
- ]);
38
-
39
- assert.deepEqual(definition.OPTIONS, [
40
- {
41
- value: 'opencode/deepseek-v4-flash-free',
42
- label: 'Deepseek V4 Flash Free',
43
- description: 'opencode - opencode/deepseek-v4-flash-free',
44
- },
45
- {
46
- value: 'opencode/nemotron-3-super-free',
47
- label: 'Nemotron 3 Super Free',
48
- description: 'opencode - opencode/nemotron-3-super-free',
49
- },
50
- {
51
- value: 'anthropic/claude-3-5-sonnet-20241022',
52
- label: 'Claude 3.5 Sonnet (2024-10-22)',
53
- description: 'anthropic - anthropic/claude-3-5-sonnet-20241022',
54
- },
55
- {
56
- value: 'anthropic/claude-opus-4-7-fast',
57
- label: 'Claude Opus 4.7 Fast',
58
- description: 'anthropic - anthropic/claude-opus-4-7-fast',
59
- },
60
- {
61
- value: 'openai/gpt-5.4-mini-fast',
62
- label: 'GPT-5.4 Mini Fast',
63
- description: 'openai - openai/gpt-5.4-mini-fast',
64
- },
65
- {
66
- value: 'openai/gpt-5.5-pro',
67
- label: 'GPT-5.5 Pro',
68
- description: 'openai - openai/gpt-5.5-pro',
69
- },
70
- {
71
- value: 'newprovider/alpha-v12-special-20261231',
72
- label: 'Alpha V12 Special (2026-12-31)',
73
- description: 'newprovider - newprovider/alpha-v12-special-20261231',
74
- },
75
- ]);
76
- });
77
-
78
- test('OpenCode models provider maps verbose model variants to effort options', () => {
79
- const models = parseOpenCodeVerboseModelsStdout(`
80
- opencode/deepseek-v4-flash-free
81
- {
82
- "id": "deepseek-v4-flash-free",
83
- "providerID": "opencode",
84
- "name": "DeepSeek V4 Flash Free",
85
- "variants": {
86
- "low": {
87
- "reasoningEffort": "low"
88
- },
89
- "high": {
90
- "reasoningEffort": "high"
91
- }
92
- }
93
- }
94
- anthropic/claude-sonnet-5
95
- {
96
- "id": "claude-sonnet-5",
97
- "providerID": "anthropic",
98
- "name": "Claude Sonnet 5",
99
- "variants": {
100
- "low": {
101
- "effort": "low"
102
- },
103
- "max": {
104
- "effort": "max"
105
- }
106
- }
107
- }
108
- google/model-alpha
109
- {
110
- "id": "model-alpha",
111
- "providerID": "google",
112
- "name": "Model Alpha"
113
- }
114
- `);
115
-
116
- const definition = buildOpenCodeDefinitionFromVerboseModels(models);
117
-
118
- assert.deepEqual(definition.OPTIONS, [
119
- {
120
- value: 'opencode/deepseek-v4-flash-free',
121
- label: 'DeepSeek V4 Flash Free',
122
- description: 'opencode - opencode/deepseek-v4-flash-free',
123
- effort: {
124
- values: [
125
- { value: 'low' },
126
- { value: 'high' },
127
- ],
128
- },
129
- },
130
- {
131
- value: 'anthropic/claude-sonnet-5',
132
- label: 'Claude Sonnet 5',
133
- description: 'anthropic - anthropic/claude-sonnet-5',
134
- effort: {
135
- values: [
136
- { value: 'low' },
137
- { value: 'max' },
138
- ],
139
- },
140
- },
141
- ]);
9
+ test('OpenCode exposes only the curated predefined catalog', async () => {
10
+ const adapter = new OpenCodeProviderModels();
11
+
12
+ assert.deepEqual(await adapter.getSupportedModels(), OPENCODE_PREDEFINED_MODELS);
13
+ assert.equal(
14
+ (await adapter.getCurrentActiveModel()).model,
15
+ OPENCODE_PREDEFINED_MODELS.DEFAULT,
16
+ );
17
+ // OpenCode routes by `<providerID>/<modelID>`, so every option has to carry a
18
+ // provider prefix that `opencode models --verbose` reports.
19
+ const providerIds = new Set(
20
+ OPENCODE_PREDEFINED_MODELS.OPTIONS.map((option) => option.value.split('/')[0]),
21
+ );
22
+ assert.deepEqual([...providerIds].sort(), ['anthropic', 'opencode', 'openai'].sort());
23
+ assert.equal(
24
+ OPENCODE_PREDEFINED_MODELS.OPTIONS.every((option) => /^[a-z0-9-]+\/.+/.test(option.value)),
25
+ true,
26
+ );
27
+ assert.equal(
28
+ new Set(OPENCODE_PREDEFINED_MODELS.OPTIONS.map((option) => option.value)).size,
29
+ OPENCODE_PREDEFINED_MODELS.OPTIONS.length,
30
+ );
31
+ assert.equal(OPENCODE_PREDEFINED_MODELS.DEFAULT, 'opencode/gpt-5.6-terra');
32
+ assert.ok(
33
+ OPENCODE_PREDEFINED_MODELS.OPTIONS.some((option) => option.value === 'opencode/claude-opus-5'),
34
+ );
35
+ assert.ok(
36
+ OPENCODE_PREDEFINED_MODELS.OPTIONS.some((option) => option.value === 'anthropic/claude-opus-5'),
37
+ );
38
+ assert.ok(
39
+ OPENCODE_PREDEFINED_MODELS.OPTIONS.some((option) => option.value === 'openai/gpt-5.6'),
40
+ );
142
41
  });
@@ -470,22 +470,22 @@ const seedOpenCodeSession = async (
470
470
  }
471
471
  };
472
472
 
473
- test('OpenCode synchronizer titles app-created sessions from the first user message', { concurrency: false }, async () => {
473
+ test('OpenCode synchronizer preserves the title assigned when CloudCLI creates a session', { concurrency: false }, async () => {
474
474
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-app-'));
475
475
  const workspacePath = path.join(tempRoot, 'workspace');
476
476
  await mkdir(workspacePath, { recursive: true });
477
477
  const restoreHomeDir = patchHomeDir(tempRoot);
478
478
 
479
479
  try {
480
- // Stored title differs from the first message so we can prove the first
481
- // message wins for sessions started from cloudcli.
480
+ // Both provider-owned values differ from the CloudCLI title so either one
481
+ // leaking through would change the assertion below.
482
482
  await seedOpenCodeSession(tempRoot, workspacePath, {
483
483
  sessionId: 'oc-app-1',
484
484
  title: 'OpenCode generated title',
485
- firstUserText: 'Fix the checkout crash',
485
+ firstUserText: 'OpenCode first user prompt',
486
486
  });
487
487
  await withIsolatedDatabase(async () => {
488
- sessionsDb.createAppSession('app-1', 'opencode', workspacePath);
488
+ sessionsDb.createAppSession('app-1', 'opencode', workspacePath, 'Fix the checkout crash');
489
489
  sessionsDb.assignProviderSessionId('app-1', 'oc-app-1');
490
490
 
491
491
  await new OpenCodeSessionSynchronizer().synchronize();