converse-mcp-server 2.29.2 → 3.0.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 (42) hide show
  1. package/.env.example +6 -3
  2. package/README.md +96 -94
  3. package/docs/API.md +703 -1562
  4. package/docs/ARCHITECTURE.md +13 -11
  5. package/docs/EXAMPLES.md +241 -667
  6. package/docs/PROVIDERS.md +104 -79
  7. package/package.json +1 -1
  8. package/src/async/asyncJobStore.js +2 -2
  9. package/src/async/jobRunner.js +6 -1
  10. package/src/async/providerStreamNormalizer.js +59 -1
  11. package/src/config.js +4 -3
  12. package/src/prompts/helpPrompt.js +43 -61
  13. package/src/providers/anthropic.js +0 -31
  14. package/src/providers/claude.js +0 -14
  15. package/src/providers/codex.js +15 -21
  16. package/src/providers/copilot.js +36 -202
  17. package/src/providers/deepseek.js +73 -53
  18. package/src/providers/gemini-cli.js +0 -3
  19. package/src/providers/google.js +10 -27
  20. package/src/providers/interface.js +0 -3
  21. package/src/providers/mistral.js +169 -63
  22. package/src/providers/openai-compatible.js +113 -28
  23. package/src/providers/openai.js +14 -66
  24. package/src/providers/openrouter-discovery.js +308 -0
  25. package/src/providers/openrouter.js +452 -282
  26. package/src/providers/xai.js +298 -280
  27. package/src/services/summarizationService.js +4 -14
  28. package/src/systemPrompts.js +19 -2
  29. package/src/tools/cancelJob.js +7 -1
  30. package/src/tools/chat.js +957 -995
  31. package/src/tools/checkStatus.js +1 -0
  32. package/src/tools/index.js +2 -6
  33. package/src/tools/modes/parallel.js +488 -0
  34. package/src/tools/modes/roundtable.js +495 -0
  35. package/src/tools/modes/streamShared.js +31 -0
  36. package/src/utils/contextProcessor.js +1 -38
  37. package/src/utils/conversationExporter.js +6 -26
  38. package/src/utils/formatStatus.js +46 -19
  39. package/src/utils/modelRouting.js +207 -4
  40. package/src/providers/openrouter-endpoints-client.js +0 -220
  41. package/src/tools/consensus.js +0 -1813
  42. package/src/tools/conversation.js +0 -1233
package/src/tools/chat.js CHANGED
@@ -1,95 +1,140 @@
1
1
  /**
2
- * Chat Tool
2
+ * Chat Tool (unified)
3
3
  *
4
- * Single-provider conversational AI with context and continuation support.
5
- * Handles context processing, provider calls, and state management.
4
+ * A single MCP tool with three execution modes:
5
+ * - `chat` : 1..N models invoked in parallel; each responds independently.
6
+ * - `consensus` : ≥2 models in parallel, then a cross-feedback refinement phase.
7
+ * - `roundtable` : models respond sequentially, each seeing the full transcript.
8
+ *
9
+ * This module is the shared shell: it validates arguments, loads/normalizes
10
+ * continuation history, builds file/image context, dispatches to the parallel or
11
+ * roundtable execution engine, and owns persistence, export, token-limiting, and
12
+ * MCP-response construction. The engines (`modes/parallel.js`, `modes/roundtable.js`)
13
+ * only resolve/invoke and return invocation data.
6
14
  */
7
15
 
8
- import { createToolResponse, createToolError } from './index.js';
9
- import {
10
- processUnifiedContext,
11
- createFileContext,
12
- } from '../utils/contextProcessor.js';
16
+ import { createToolResponse, createToolError, formatFailureDetails } from './index.js';
17
+ import { createFileContext } from '../utils/contextProcessor.js';
13
18
  import {
14
19
  generateContinuationId,
15
- addMessageToHistory,
16
20
  isValidContinuationId,
17
21
  } from '../continuationStore.js';
18
22
  import { isSafeIdSegment } from '../utils/idValidation.js';
19
- import { debugLog, debugError } from '../utils/console.js';
23
+ import { debugError } from '../utils/console.js';
20
24
  import { createLogger } from '../utils/logger.js';
21
- import { CHAT_PROMPT } from '../systemPrompts.js';
25
+ import { getSystemPromptForMode } from '../systemPrompts.js';
22
26
  import { applyTokenLimit, getTokenLimit } from '../utils/tokenLimiter.js';
23
27
  import { validateAllPaths } from '../utils/fileValidator.js';
24
28
  import { SummarizationService } from '../services/summarizationService.js';
25
29
  import { exportConversation } from '../utils/conversationExporter.js';
26
- import { isRecoverableError, retryWithBackoff } from '../utils/errorHandler.js';
27
30
  import {
28
- providerSupportsImages,
31
+ getDefaultModelForProvider,
29
32
  getProviderUnavailableMessage,
33
+ getAvailableProviders,
34
+ resolveModelSpec,
30
35
  } from '../utils/modelRouting.js';
36
+ import {
37
+ runChatMode,
38
+ runConsensusMode,
39
+ getProviderRetryOptions,
40
+ } from './modes/parallel.js';
41
+ import {
42
+ runRoundtableLap,
43
+ renderStoredTranscriptToText,
44
+ } from './modes/roundtable.js';
31
45
 
32
46
  const logger = createLogger('chat');
33
47
 
48
+ const VALID_MODES = ['chat', 'consensus', 'roundtable'];
49
+
50
+ // SDK providers that consume ONLY the last user message. On a resumed thread in
51
+ // chat/consensus modes, prior history must be packed into that final message for
52
+ // these providers (codex is exempt in chat mode when it can reuse its own thread).
53
+ const LAST_USER_ONLY = new Set(['codex', 'claude', 'copilot']);
54
+
34
55
  /**
35
- * Chat tool implementation
56
+ * Unified chat tool implementation.
36
57
  * @param {object} args - Tool arguments
37
- * @param {object} dependencies - Injected dependencies (config, providers, continuationStore)
58
+ * @param {object} dependencies - Injected dependencies
38
59
  * @returns {object} MCP tool response
39
60
  */
40
61
  export async function chatTool(args, dependencies) {
41
62
  try {
42
- const {
43
- config,
44
- providers,
45
- continuationStore,
46
- contextProcessor,
47
- jobRunner,
48
- providerStreamNormalizer,
49
- signal,
50
- } = dependencies;
63
+ const { config, providers, continuationStore, jobRunner, providerStreamNormalizer } =
64
+ dependencies;
51
65
 
52
- // Validate required arguments
53
- if (!args.prompt || typeof args.prompt !== 'string') {
66
+ if (!args.prompt || typeof args.prompt !== 'string' || !args.prompt.trim()) {
54
67
  return createToolError('Prompt is required and must be a string');
55
68
  }
56
69
 
57
- // Extract and validate arguments
70
+ // The router applies no JSON-Schema defaults, so apply them in code.
71
+ const mode = args.mode ?? 'chat';
72
+ const models = args.models ?? ['auto'];
73
+ const reasoning_effort = args.reasoning_effort ?? 'medium';
58
74
  const {
59
75
  prompt,
60
- model = 'auto',
61
76
  files = [],
62
- continuation_id,
63
- temperature = 0.5,
64
- use_websearch = false,
65
77
  images = [],
66
- reasoning_effort = 'medium',
67
- verbosity = 'medium',
68
- async = false,
78
+ continuation_id,
79
+ async: isAsync = false,
69
80
  export: shouldExport = false,
70
81
  } = args;
71
82
 
72
- // Handle async execution mode
73
- if (async) {
74
- // Validate async dependencies are available
83
+ if (!VALID_MODES.includes(mode)) {
84
+ return createToolError(
85
+ `Invalid mode "${mode}". Valid modes are: ${VALID_MODES.join(', ')}.`,
86
+ );
87
+ }
88
+
89
+ const modelsError = validateModels(models, mode);
90
+ if (modelsError) {
91
+ return createToolError(modelsError);
92
+ }
93
+
94
+ // Consensus needs ≥2 *resolved* (available) models, checked AFTER auto
95
+ // expansion so the default ["auto"] is valid.
96
+ if (mode === 'consensus') {
97
+ const { resolved } = resolveConsensusCallPlans(
98
+ models,
99
+ providers,
100
+ config,
101
+ images,
102
+ );
103
+ if (resolved.length < 2) {
104
+ return createToolError(
105
+ 'Consensus mode requires at least 2 available models. ' +
106
+ 'Provide 2+ models (or "auto" when 2+ providers are configured), ' +
107
+ 'or use mode "chat" for a single model.',
108
+ );
109
+ }
110
+ }
111
+
112
+ const normalizedArgs = {
113
+ prompt,
114
+ mode,
115
+ models,
116
+ reasoning_effort,
117
+ files,
118
+ images,
119
+ continuation_id,
120
+ export: shouldExport,
121
+ };
122
+
123
+ if (isAsync) {
75
124
  if (!jobRunner || !providerStreamNormalizer) {
76
125
  return createToolError(
77
126
  'Async execution not available - missing async dependencies',
78
127
  );
79
128
  }
80
129
 
81
- // Validate custom continuation ID for async safety (used as filesystem path segment)
82
130
  if (continuation_id && !isSafeIdSegment(continuation_id)) {
83
131
  return createToolError(
84
132
  `Invalid continuation_id for async mode: "${continuation_id}". Async IDs must contain only letters, numbers, hyphens, and underscores (max 128 chars).`,
85
133
  );
86
134
  }
87
135
 
88
- // Generate or use existing continuation ID for the conversation
89
- const conversationContinuationId =
90
- continuation_id || generateContinuationId();
136
+ const jobContinuationId = continuation_id || generateContinuationId();
91
137
 
92
- // Determine if this is a custom ID (non-standard format AND not found in store)
93
138
  let isCustomId = false;
94
139
  if (continuation_id && !isValidContinuationId(continuation_id)) {
95
140
  try {
@@ -100,77 +145,46 @@ export async function chatTool(args, dependencies) {
100
145
  }
101
146
  }
102
147
 
103
- // Get provider and model info for the job
104
- const providerName = mapModelToProvider(args.model || 'auto', providers);
105
- const resolvedModel =
106
- providers[providerName]?.resolveModel?.(args.model) ||
107
- args.model ||
108
- 'auto';
109
-
110
- // Generate title early for initial response
148
+ const modelsList = models.join(', ');
111
149
  const summarizationService = new SummarizationService(providers, config);
112
150
  let title = null;
113
151
  try {
114
152
  title = await summarizationService.generateTitle(prompt);
115
- debugLog(`Chat: Generated title for initial response - "${title}"`);
116
153
  } catch (error) {
117
- debugError(
118
- 'Chat: Failed to generate title for initial response',
119
- error,
120
- );
154
+ debugError('Chat: Failed to generate title for initial response', error);
121
155
  title = prompt.substring(0, 50);
122
156
  }
123
157
 
124
158
  try {
125
- // Submit background job using continuation_id as the job identifier
126
- const jobId = await jobRunner.submit(
159
+ await jobRunner.submit(
127
160
  {
128
161
  tool: 'chat',
129
- sessionId: 'local-user', // Use standard session ID
162
+ mode,
163
+ sessionId: jobContinuationId,
130
164
  options: {
131
- ...args,
132
- jobId: conversationContinuationId, // Use continuation_id as job ID
133
- continuation_id: conversationContinuationId, // Pass the conversation continuation ID
134
- provider: providerName, // Add provider info for status display
135
- model: resolvedModel, // Add resolved model info for status display
136
- title, // Pass the generated title
165
+ ...normalizedArgs,
166
+ jobId: jobContinuationId,
167
+ continuation_id: jobContinuationId,
168
+ mode,
169
+ models_list: modelsList,
170
+ title,
137
171
  },
138
172
  },
139
173
  async (context) => {
140
- // Execute chat in background using stream normalizer
141
- return await executeChatWithStreaming(
142
- args,
143
- {
144
- ...dependencies,
145
- continuationId: conversationContinuationId,
146
- isCustomId,
147
- title, // Pass title to execution context
148
- },
174
+ return await runAsyncJob(
175
+ { ...normalizedArgs, continuation_id: jobContinuationId },
176
+ { ...dependencies, isCustomId, title },
149
177
  context,
150
178
  );
151
179
  },
152
180
  );
153
181
 
154
- // Format initial response like check_status output
155
- const startTime = new Date()
156
- .toLocaleString('en-GB', {
157
- day: '2-digit',
158
- month: '2-digit',
159
- year: 'numeric',
160
- hour: '2-digit',
161
- minute: '2-digit',
162
- second: '2-digit',
163
- hour12: false,
164
- })
165
- .replace(',', '');
166
-
167
- const statusLine = `⏳ SUBMITTED | CHAT | ${conversationContinuationId} | 1/1 | Started: ${startTime} | "${title || 'Processing...'}" | ${providerName}/${resolvedModel}`;
168
-
169
- // Return formatted response with status line and continuation_id
182
+ const statusLine = `⏳ SUBMITTED | ${mode.toUpperCase()} | ${jobContinuationId} | 1/1 | Started: ${formatStartTime()} | "${title || 'Processing...'}" | ${modelsList}`;
183
+
170
184
  return createToolResponse({
171
- content: `${statusLine}\ncontinuation_id: ${conversationContinuationId}`,
185
+ content: `${statusLine}\ncontinuation_id: ${jobContinuationId}`,
172
186
  continuation: {
173
- id: conversationContinuationId, // Use continuation_id as the primary ID
187
+ id: jobContinuationId,
174
188
  status: 'processing',
175
189
  ...(isCustomId && { custom_id: true }),
176
190
  },
@@ -182,1062 +196,1010 @@ export async function chatTool(args, dependencies) {
182
196
  }
183
197
  }
184
198
 
185
- let conversationHistory = [];
186
- let continuationId = continuation_id;
187
- let isCustomId = false;
188
-
189
- // Load existing conversation if continuation_id provided
190
- if (continuationId) {
191
- try {
192
- const existingState = await continuationStore.get(continuationId);
193
- if (existingState) {
194
- conversationHistory = existingState.messages || [];
195
- } else {
196
- // Preserve user-provided ID and start fresh conversation
197
- isCustomId = !isValidContinuationId(continuationId);
198
- }
199
- } catch (error) {
200
- logger.error('Error loading conversation', { error });
201
- // Preserve user-provided ID on error
202
- isCustomId = !isValidContinuationId(continuationId);
203
- }
204
- } else {
205
- // Generate new continuation ID for new conversation
206
- continuationId = generateContinuationId();
207
- }
208
-
209
- // Validate file paths before processing
210
- if (files.length > 0 || images.length > 0) {
211
- const validation = await validateAllPaths(
212
- { files, images },
213
- { clientCwd: config.server?.client_cwd },
214
- );
215
- if (!validation.valid) {
216
- logger.error('File validation failed', { errors: validation.errors });
217
- return validation.errorResponse;
218
- }
199
+ // Synchronous path
200
+ const pipeline = await runUnifiedChat(normalizedArgs, dependencies, null);
201
+ if (pipeline.error) {
202
+ return pipeline.error;
219
203
  }
220
-
221
- // Process context (files, images, web search)
222
- let contextMessage = null;
223
- if (files.length > 0 || images.length > 0 || use_websearch) {
224
- try {
225
- const contextRequest = {
226
- files: Array.isArray(files) ? files : [],
227
- images: Array.isArray(images) ? images : [],
228
- webSearch: use_websearch ? prompt : null,
229
- };
230
-
231
- const contextResult = await contextProcessor.processUnifiedContext(
232
- contextRequest,
233
- {
234
- enforceSecurityCheck: false, // Allow files from any location
235
- skipSecurityCheck: true, // Legacy flag for backward compatibility
236
- clientCwd: config.server?.client_cwd, // Use auto-detected client working directory
237
- },
238
- );
239
-
240
- // Create context message from files and images
241
- const allProcessedFiles = [
242
- ...contextResult.files,
243
- ...contextResult.images,
244
- ];
245
- if (allProcessedFiles.length > 0) {
246
- contextMessage = createFileContext(allProcessedFiles, {
247
- includeMetadata: true,
248
- includeErrors: true,
249
- });
250
- }
251
-
252
- // Add web search results if available (placeholder for now)
253
- if (contextResult.webSearch && !contextResult.webSearch.placeholder) {
254
- // Future implementation: add web search results to context
255
- logger.debug('Web search results available but not yet implemented');
256
- }
257
- } catch (error) {
258
- logger.error('Error processing context', { error });
259
- // Continue without context if processing fails
260
- }
261
- }
262
-
263
- // Build message array for provider
264
- const messages = [];
265
-
266
- // Add system prompt only if not already in conversation history
267
- if (
268
- conversationHistory.length === 0 ||
269
- conversationHistory[0].role !== 'system'
270
- ) {
271
- messages.push({
272
- role: 'system',
273
- content: CHAT_PROMPT,
274
- });
204
+ return buildSyncResponse(pipeline, config);
205
+ } catch (error) {
206
+ if (dependencies?.signal?.aborted || error.name === 'AbortError') {
207
+ const cancelledMode = args?.mode ?? 'chat';
208
+ const label = cancelledMode.charAt(0).toUpperCase() + cancelledMode.slice(1);
209
+ logger.debug('Chat tool cancelled by client');
210
+ return createToolError(`${label} request cancelled`);
275
211
  }
212
+ logger.error('Chat tool error', { error });
213
+ return createToolError('Chat tool failed', error);
214
+ }
215
+ }
276
216
 
277
- // Add conversation history
278
- messages.push(...conversationHistory);
279
-
280
- // Add user prompt with context
281
- const userMessage = {
282
- role: 'user',
283
- content: prompt, // default to simple string content
284
- };
285
-
286
- // If we have context (files/images), create complex content array
287
- if (contextMessage && contextMessage.content) {
288
- // Create complex content array
289
- userMessage.content = [
290
- ...contextMessage.content, // Include all file/image parts
291
- { type: 'text', text: prompt }, // Add the user prompt as text
292
- ];
217
+ /**
218
+ * Validate the models array and (for chat/consensus) reject duplicates.
219
+ * @returns {string|null} Error message or null when valid
220
+ */
221
+ function validateModels(models, mode) {
222
+ if (!Array.isArray(models) || models.length === 0) {
223
+ return 'Models must be a non-empty array of model names';
224
+ }
225
+ for (const entry of models) {
226
+ if (!entry || typeof entry !== 'string' || !entry.trim()) {
227
+ return 'Each model must be a non-empty string';
293
228
  }
294
-
295
- messages.push(userMessage);
296
-
297
- // Select provider(s)
298
- let selectedProvider;
299
- let providerName;
300
-
301
- const providerCandidates = [];
302
-
303
- if (model === 'auto') {
304
- // Auto-select providers in priority order
305
- // Prioritize subscription-based providers (codex, gemini-cli, claude, copilot) over API-key providers
306
- const providerOrder = [
307
- 'codex',
308
- 'gemini-cli',
309
- 'claude',
310
- 'copilot',
311
- 'openai',
312
- 'google',
313
- 'xai',
314
- 'anthropic',
315
- 'mistral',
316
- 'deepseek',
317
- 'openrouter',
318
- ];
319
-
320
- const requestHasImages = Array.isArray(images) && images.length > 0;
321
-
322
- for (const name of providerOrder) {
323
- const provider = providers[name];
324
- if (provider && provider.isAvailable && provider.isAvailable(config)) {
325
- // When the request has images, skip text-only providers (e.g.
326
- // gemini-cli, copilot) so auto routing lands on an image-capable one.
327
- if (requestHasImages && !providerSupportsImages(provider, name)) {
328
- continue;
329
- }
330
- providerCandidates.push({ name, provider });
331
- }
332
- }
333
-
334
- if (providerCandidates.length === 0) {
335
- return createToolError(
336
- 'No providers available. Please configure at least one API key.',
337
- );
338
- }
339
- } else {
340
- // Use specified provider/model
341
- providerName = mapModelToProvider(model, providers);
342
- selectedProvider = providers[providerName];
343
-
344
- if (!selectedProvider) {
345
- return createToolError(`Provider not found for model: ${model}`);
346
- }
347
-
348
- if (!selectedProvider.isAvailable(config)) {
349
- return createToolError(getProviderUnavailableMessage(providerName));
229
+ }
230
+ if (mode !== 'roundtable') {
231
+ const seen = new Set();
232
+ for (const entry of models) {
233
+ const key = entry.trim().toLowerCase();
234
+ if (seen.has(key)) {
235
+ return `Duplicate model "${entry}" is not allowed in mode "${mode}". Duplicate models are only allowed in mode "roundtable".`;
350
236
  }
351
-
352
- providerCandidates.push({
353
- name: providerName,
354
- provider: selectedProvider,
355
- });
237
+ seen.add(key);
356
238
  }
239
+ }
240
+ return null;
241
+ }
357
242
 
358
- // Call provider with recovery (retry and, for auto, failover)
359
- let response;
360
- const startTime = Date.now();
361
- let lastError;
362
- let resolvedModel;
363
-
364
- for (const candidate of providerCandidates) {
365
- providerName = candidate.name;
366
- selectedProvider = candidate.provider;
367
-
368
- // Resolve model name and prepare provider options
369
- resolvedModel = resolveAutoModel(model, providerName);
370
- const providerOptions = {
371
- model: resolvedModel,
372
- temperature,
373
- reasoning_effort,
374
- verbosity,
375
- use_websearch,
376
- signal,
377
- config,
378
- continuation_id, // Pass for thread resumption
379
- continuationStore, // Pass store for state management
380
- };
381
-
382
- try {
383
- response = await retryWithBackoff(
384
- () => selectedProvider.invoke(messages, providerOptions),
385
- getProviderRetryOptions(config, providerName),
386
- );
387
- break;
388
- } catch (error) {
389
- lastError = error;
390
- logger.error('Provider error', {
391
- error,
392
- data: { provider: providerName },
393
- });
394
-
395
- if (
396
- model !== 'auto' ||
397
- !shouldFailoverToNextProvider(error) ||
398
- candidate === providerCandidates[providerCandidates.length - 1]
399
- ) {
400
- return createToolError(`Provider error: ${error.message}`);
401
- }
402
- }
403
- }
243
+ /**
244
+ * The async job runner: executes the pipeline with streaming, then generates a
245
+ * final summary and returns the completion result for the job store.
246
+ */
247
+ async function runAsyncJob(args, dependencies, context) {
248
+ const { providers, config, title: passedTitle } = dependencies;
249
+
250
+ const pipeline = await runUnifiedChat(args, dependencies, context);
251
+ if (pipeline.error) {
252
+ // Validation errors are surfaced eagerly in chatTool; reaching here means a
253
+ // runtime failure throw so the job is marked failed.
254
+ throw new Error(
255
+ pipeline.errorMessage || 'Chat job failed during execution',
256
+ );
257
+ }
404
258
 
405
- if (!response) {
406
- return createToolError(
407
- `Provider error: ${(lastError && lastError.message) || 'Unknown error'}`,
259
+ const summarizationService = new SummarizationService(providers, config);
260
+ let finalSummary = null;
261
+ if (pipeline.combinedForSummary && pipeline.combinedForSummary.length > 100) {
262
+ try {
263
+ finalSummary = await summarizationService.generateFinalSummary(
264
+ pipeline.combinedForSummary,
408
265
  );
409
- }
410
- const executionTime = (Date.now() - startTime) / 1000; // Convert to seconds
411
-
412
- // Validate response
413
- if (!response || !response.content) {
414
- return createToolError('Provider returned invalid response');
415
- }
416
-
417
- // Add assistant response to conversation history
418
- const assistantMessage = {
419
- role: 'assistant',
420
- content: response.content,
421
- };
422
-
423
- const updatedMessages = [...messages, assistantMessage];
424
-
425
- // Save conversation state (skip on abort to avoid persisting incomplete history)
426
- if (!signal?.aborted) {
427
- try {
428
- const conversationState = {
429
- messages: updatedMessages,
430
- provider: providerName,
431
- model,
432
- lastUpdated: Date.now(),
433
- // Store Codex thread ID if available (for thread resumption)
434
- codexThreadId: response.metadata?.threadId,
435
- };
436
-
437
- await continuationStore.set(continuationId, conversationState);
438
- } catch (error) {
439
- logger.error('Error saving conversation', { error });
440
- // Continue even if save fails
266
+ if (finalSummary) {
267
+ await context.updateJob({ final_summary: finalSummary });
441
268
  }
269
+ } catch (error) {
270
+ debugError('Chat: Failed to generate final summary', error);
442
271
  }
272
+ }
443
273
 
444
- // Export conversation if requested
445
- if (shouldExport) {
446
- await exportConversation(
447
- {
448
- messages: updatedMessages,
449
- provider: providerName,
450
- model,
451
- lastUpdated: Date.now(),
452
- codexThreadId: response.metadata?.threadId,
453
- },
454
- {
455
- clientCwd: config.server?.client_cwd,
456
- continuation_id: continuationId,
457
- model,
458
- temperature,
459
- reasoning_effort,
460
- verbosity,
461
- use_websearch,
462
- files,
463
- images,
464
- },
465
- );
466
- }
467
-
468
- // Create unified status line (similar to async status display)
469
- const statusLine =
470
- config.environment?.nodeEnv !== 'test'
471
- ? `✅ COMPLETED | CHAT | ${continuationId} | ${executionTime.toFixed(1)}s elapsed | ${providerName}/${resolvedModel}\n`
472
- : '';
473
-
474
- // Always include continuation_id line for clarity
475
- const continuationIdLine = `continuation_id: ${continuationId}\n\n`;
476
-
477
- const result = {
478
- content: statusLine + continuationIdLine + response.content,
479
- continuation: {
480
- id: continuationId,
481
- provider: providerName,
482
- model,
483
- messageCount: updatedMessages.filter((msg) => msg.role !== 'system')
484
- .length,
485
- ...(isCustomId && { custom_id: true }),
486
- },
487
- };
488
-
489
- // Add metadata if available
490
- if (response.metadata) {
491
- result.metadata = response.metadata;
492
- }
274
+ return buildAsyncResult(pipeline, passedTitle, finalSummary);
275
+ }
493
276
 
494
- // Apply token limiting to the final response
495
- const tokenLimit = getTokenLimit(config);
496
- const resultStr = JSON.stringify(result, null, 2);
497
- const limitedResult = applyTokenLimit(resultStr, tokenLimit);
277
+ /**
278
+ * The shared pipeline: load history, build context, resolve + invoke via the
279
+ * mode's engine, persist, export. Returns a normalized pipeline result (or an
280
+ * `{ error }` for path-validation failures).
281
+ */
282
+ async function runUnifiedChat(args, dependencies, context) {
283
+ const {
284
+ config,
285
+ providers,
286
+ continuationStore,
287
+ contextProcessor,
288
+ providerStreamNormalizer,
289
+ } = dependencies;
290
+ const signal = context ? context.signal : dependencies.signal;
291
+ const { prompt, mode, models, reasoning_effort, files, images, export: shouldExport } = args;
498
292
 
499
- // Parse the limited result back to object format to preserve structure
500
- let finalResult;
293
+ // Continuation load
294
+ let continuationId = args.continuation_id;
295
+ let isCustomId = false;
296
+ let existingState = null;
297
+ if (continuationId) {
501
298
  try {
502
- finalResult = JSON.parse(limitedResult.content);
503
- } catch (e) {
504
- // Fallback if parsing fails - return original result
505
- finalResult = result;
299
+ existingState = await continuationStore.get(continuationId);
300
+ } catch (error) {
301
+ logger.error('Error loading conversation', { error });
506
302
  }
507
-
508
- return createToolResponse(finalResult);
509
- } catch (error) {
510
- if (dependencies?.signal?.aborted || error.name === 'AbortError') {
511
- logger.debug('Chat tool cancelled by client');
512
- return createToolError('Chat request cancelled');
303
+ if (!existingState) {
304
+ isCustomId = !isValidContinuationId(continuationId);
513
305
  }
514
- logger.error('Chat tool error', { error });
515
- return createToolError('Chat tool failed', error);
306
+ } else {
307
+ continuationId = generateContinuationId();
516
308
  }
517
- }
518
309
 
519
- /**
520
- * Map model name to provider name
521
- * @param {string} model - Model name
522
- * @returns {string} Provider name
523
- */
524
- /**
525
- * Resolve "auto" model to default model for the provider
526
- */
527
- function resolveAutoModel(model, providerName) {
528
- if (model.toLowerCase() !== 'auto') {
529
- return model;
530
- }
310
+ const priorHistory = existingState?.messages || [];
311
+ // Strip any stored leading system message so exactly one current-mode system
312
+ // prompt leads the invoked/persisted history (prevents a cross-mode resume
313
+ // running the previous mode's prompt).
314
+ const normalizedHistory =
315
+ priorHistory.length > 0 && priorHistory[0].role === 'system'
316
+ ? priorHistory.slice(1)
317
+ : priorHistory;
531
318
 
532
- const defaults = {
533
- codex: 'codex',
534
- 'gemini-cli': 'gemini',
535
- claude: 'claude',
536
- copilot: 'copilot',
537
- openai: 'gpt-5',
538
- xai: 'grok-4-0709',
539
- google: 'gemini-pro',
540
- anthropic: 'claude-sonnet-4-20250514',
541
- mistral: 'magistral-medium-2506',
542
- deepseek: 'deepseek-reasoner',
543
- openrouter: 'qwen/qwen3-coder',
544
- };
319
+ // Path validation
320
+ if (files.length > 0 || images.length > 0) {
321
+ const validation = await validateAllPaths(
322
+ { files, images },
323
+ { clientCwd: config.server?.client_cwd },
324
+ );
325
+ if (!validation.valid) {
326
+ logger.error('File validation failed', { errors: validation.errors });
327
+ return { error: validation.errorResponse };
328
+ }
329
+ }
545
330
 
546
- return defaults[providerName] || 'gpt-5';
547
- }
331
+ const contextMessage = await buildContextMessage(
332
+ files,
333
+ images,
334
+ contextProcessor,
335
+ config,
336
+ );
548
337
 
549
- function getProviderRetryOptions(config, providerName) {
550
- const nodeEnv = config?.environment?.nodeEnv || process.env.NODE_ENV;
551
- const isTest = nodeEnv === 'test';
338
+ const systemPrompt = getSystemPromptForMode(mode);
339
+ const systemMessage = { role: 'system', content: systemPrompt };
340
+ const hasImages = Array.isArray(images) && images.length > 0;
552
341
 
553
- return {
554
- retries: isTest ? 1 : 3,
555
- delay: isTest ? 0 : 500,
556
- maxDelay: isTest ? 0 : 10000,
557
- operation: `provider-invoke:${providerName}`,
342
+ const common = {
343
+ mode,
344
+ models,
345
+ prompt,
346
+ reasoning_effort,
347
+ files,
348
+ images,
349
+ continuationId,
350
+ isCustomId,
351
+ existingState,
352
+ normalizedHistory,
353
+ systemMessage,
354
+ systemPrompt,
355
+ contextMessage,
356
+ hasImages,
357
+ shouldExport,
358
+ signal,
359
+ context,
360
+ providers,
361
+ config,
362
+ continuationStore,
363
+ providerStreamNormalizer,
558
364
  };
559
- }
560
365
 
561
- function shouldFailoverToNextProvider(error) {
562
- if (isRecoverableError(error)) {
563
- return true;
366
+ if (mode === 'roundtable') {
367
+ return await runRoundtablePipeline(common);
564
368
  }
565
-
566
- const message = (error && error.message) || '';
567
- return /(api key|authentication|unauthorized|forbidden|invalid|not available)/i.test(
568
- message,
569
- );
369
+ return await runParallelPipeline(common);
570
370
  }
571
371
 
572
- export function mapModelToProvider(model, providers) {
573
- const modelLower = model.toLowerCase();
574
-
575
- // Handle "auto" - prioritize: codex > gemini-cli > claude > copilot > openai
576
- if (modelLower === 'auto') {
577
- if (providers['codex']) {
578
- return 'codex';
579
- }
580
- if (providers['gemini-cli']) {
581
- return 'gemini-cli';
372
+ /**
373
+ * Build the per-provider message array for a chat/consensus candidate. API and
374
+ * gemini-cli providers get the full role-separated history; last-user-only SDK
375
+ * providers get the prior transcript packed into a single final user message,
376
+ * except Codex in chat mode when it can resume its own thread.
377
+ */
378
+ function makeMessageBuilder(common, priorTranscriptText, userMessage, packedUserMessage) {
379
+ const fullMessages = [common.systemMessage, ...common.normalizedHistory, userMessage];
380
+ return (candidate, callPlan) => {
381
+ if (!LAST_USER_ONLY.has(candidate.name)) {
382
+ return fullMessages;
582
383
  }
583
- if (providers['claude']) {
584
- return 'claude';
384
+ if (!priorTranscriptText) {
385
+ return [common.systemMessage, userMessage];
585
386
  }
586
- if (providers['copilot']) {
587
- return 'copilot';
387
+ const codexReusesThread =
388
+ common.mode === 'chat' &&
389
+ candidate.name === 'codex' &&
390
+ !!common.existingState?.providerThreads?.[callPlan.threadKey];
391
+ if (codexReusesThread) {
392
+ return fullMessages;
588
393
  }
589
- return 'openai';
590
- }
591
-
592
- // Check Codex (exact match only - don't route "gpt-5-codex" etc to Codex provider)
593
- if (modelLower === 'codex') {
594
- return 'codex';
595
- }
394
+ return [common.systemMessage, packedUserMessage];
395
+ };
396
+ }
596
397
 
597
- // Check Gemini CLI (exact match only - routes to CLI provider instead of Google API)
598
- if (modelLower === 'gemini' || modelLower === 'gemini-cli') {
599
- return 'gemini-cli';
600
- }
398
+ /**
399
+ * Parallel-mode pipeline (chat + consensus).
400
+ */
401
+ async function runParallelPipeline(common) {
402
+ const {
403
+ mode,
404
+ models,
405
+ prompt,
406
+ reasoning_effort,
407
+ continuationId,
408
+ isCustomId,
409
+ existingState,
410
+ normalizedHistory,
411
+ contextMessage,
412
+ signal,
413
+ context,
414
+ providers,
415
+ config,
416
+ continuationStore,
417
+ providerStreamNormalizer,
418
+ } = common;
419
+
420
+ const userContent = contextMessage?.content
421
+ ? [...contextMessage.content, { type: 'text', text: prompt }]
422
+ : prompt;
423
+ const userMessage = { role: 'user', content: userContent };
424
+
425
+ const priorTranscriptText = renderStoredTranscriptToText(normalizedHistory);
426
+ const packedText = priorTranscriptText ? `${priorTranscriptText}\n\n${prompt}` : prompt;
427
+ const packedUserContent = contextMessage?.content
428
+ ? [...contextMessage.content, { type: 'text', text: packedText }]
429
+ : packedText;
430
+ const packedUserMessage = { role: 'user', content: packedUserContent };
431
+
432
+ const buildMessagesForCandidate = makeMessageBuilder(
433
+ common,
434
+ priorTranscriptText,
435
+ userMessage,
436
+ packedUserMessage,
437
+ );
601
438
 
602
- // Check gemini: prefix (e.g., gemini:flash, gemini:pro) - routes to Antigravity
603
- // CLI provider. Must be before the google flash/pro keyword rule so it wins.
604
- if (modelLower.startsWith('gemini:')) {
605
- return 'gemini-cli';
606
- }
439
+ const optionsForCandidate = (candidate, callPlan) => {
440
+ const options = {
441
+ model: candidate.resolvedModel,
442
+ reasoning_effort,
443
+ config,
444
+ };
445
+ // Web search opt-in from an OpenRouter `:online` decoration (parsed by the
446
+ // shared resolver). Only ever set for OpenRouter candidates.
447
+ if (candidate.resolveOptions?.web_search) {
448
+ options.web_search = true;
449
+ }
450
+ // Codex thread reuse is chat-mode-scoped, and only Codex consumes these
451
+ // options — passing them to other providers would leak `threadKey` into
452
+ // their API payloads via generic option passthrough.
453
+ if (mode === 'chat' && candidate.name === 'codex') {
454
+ options.continuation_id = continuationId;
455
+ options.continuationStore = continuationStore;
456
+ options.threadKey = callPlan.threadKey;
457
+ }
458
+ return options;
459
+ };
607
460
 
608
- // Check Claude SDK (exact match only - routes to SDK provider instead of Anthropic API)
609
- if (
610
- modelLower === 'claude' ||
611
- modelLower === 'claude-sdk' ||
612
- modelLower === 'claude-code'
613
- ) {
614
- return 'claude';
615
- }
461
+ const startedAt = Date.now();
616
462
 
617
- // Check claude: prefix (e.g., claude:fable, claude:opus) - routes to SDK provider
618
- // Must be before keyword matching to prevent misrouting to Anthropic API
619
- if (modelLower.startsWith('claude:')) {
620
- return 'claude';
621
- }
463
+ if (mode === 'consensus') {
464
+ const { resolved, preFailed } = resolveConsensusCallPlans(
465
+ models,
466
+ providers,
467
+ config,
468
+ common.images,
469
+ );
622
470
 
623
- // Check Copilot SDK (exact match only - routes to SDK provider)
624
- if (
625
- modelLower === 'copilot' ||
626
- modelLower === 'copilot-sdk' ||
627
- modelLower === 'github-copilot'
628
- ) {
629
- return 'copilot';
630
- }
471
+ if (resolved.length === 0) {
472
+ return {
473
+ error: createToolError(
474
+ 'No providers available. Please configure at least one API key.',
475
+ ),
476
+ errorMessage: 'No providers available',
477
+ };
478
+ }
631
479
 
632
- // Check copilot: prefix (e.g., copilot:gpt-5.2, copilot:claude-sonnet-4.6)
633
- // Must be before slash-format and keyword matching to prevent misrouting
634
- if (modelLower.startsWith('copilot:')) {
635
- return 'copilot';
636
- }
480
+ const { initial, refined } = await runConsensusMode({
481
+ callPlans: resolved,
482
+ buildMessagesForCandidate,
483
+ optionsForCandidate,
484
+ prompt,
485
+ signal,
486
+ context,
487
+ providerStreamNormalizer,
488
+ });
637
489
 
638
- // Check OpenRouter-specific patterns first
639
- if (
640
- modelLower === 'openrouter auto' ||
641
- modelLower === 'auto router' ||
642
- modelLower === 'auto-router' ||
643
- modelLower === 'openrouter-auto'
644
- ) {
645
- return 'openrouter';
646
- }
490
+ const successful = initial.filter((r) => r.status === 'success');
491
+ const failed = [
492
+ ...initial.filter((r) => r.status === 'failed'),
493
+ ...preFailed.map((f) => ({ ...f, status: 'failed' })),
494
+ ];
647
495
 
648
- // If model contains "/", check if native provider supports it
649
- if (modelLower.includes('/')) {
650
- // Check each provider to see if they have this exact model
651
- for (const [providerName, provider] of Object.entries(providers)) {
652
- if (provider && provider.getModelConfig) {
653
- const modelConfig = provider.getModelConfig(model);
654
- if (
655
- modelConfig &&
656
- !modelConfig.isDynamic &&
657
- !modelConfig.needsApiUpdate
658
- ) {
659
- // Model exists in this provider's static list
660
- return providerName;
661
- }
662
- }
663
- }
664
- // No native provider has this model, route to OpenRouter
665
- return 'openrouter';
666
- }
496
+ const formattedContent = formatConsensusContent(successful, refined);
667
497
 
668
- // For non-slash models, use keyword matching as before
498
+ await persistAndExport(common, {
499
+ userMessage,
500
+ assistantContent: formattedContent,
501
+ extraState: {
502
+ consensusData: {
503
+ modelsRequested: models.length,
504
+ providersSuccessful: successful.length,
505
+ providersFailed: failed.length,
506
+ },
507
+ },
508
+ });
669
509
 
670
- // OpenAI models
671
- if (
672
- modelLower.includes('gpt') ||
673
- modelLower.includes('o1') ||
674
- modelLower.includes('o3') ||
675
- modelLower.includes('o4')
676
- ) {
677
- return 'openai';
510
+ const finalCount = refined
511
+ ? refined.filter((r) => r.status === 'success').length
512
+ : successful.length;
513
+ const totalCount = resolved.length;
514
+ const failureDetails = collectConsensusFailures(successful, failed, refined);
515
+ const combinedForSummary = successful
516
+ .map((r) => `${r.model}:\n${refined ? refinedText(refined, r) : r.response}`)
517
+ .join('\n\n---\n\n');
518
+
519
+ return {
520
+ kind: 'consensus',
521
+ continuationId,
522
+ isCustomId,
523
+ executionTime: (Date.now() - startedAt) / 1000,
524
+ structuredResult: {
525
+ status: 'consensus_complete',
526
+ models_consulted: models.length,
527
+ successful_initial_responses: successful.length,
528
+ failed_responses: failed.length,
529
+ refined_responses: refined
530
+ ? refined.filter((r) => r.status === 'success').length
531
+ : 0,
532
+ phases: {
533
+ initial: successful,
534
+ ...(refined !== null && { refined }),
535
+ failed,
536
+ },
537
+ continuation: {
538
+ id: continuationId,
539
+ messageCount: normalizedHistory.length + 3,
540
+ ...(isCustomId && { custom_id: true }),
541
+ },
542
+ settings: { models_requested: models },
543
+ },
544
+ formattedContent,
545
+ finalCount,
546
+ totalCount,
547
+ failureDetails,
548
+ modelsList: resolved.map((c) => c.displayModel).join(', '),
549
+ combinedForSummary,
550
+ };
678
551
  }
679
552
 
680
- // XAI models
681
- if (modelLower.includes('grok')) {
682
- return 'xai';
553
+ // mode === 'chat'
554
+ const { callPlans, preFailed, error } = resolveChatCallPlans(
555
+ models,
556
+ providers,
557
+ config,
558
+ common.hasImages,
559
+ );
560
+ if (error) {
561
+ return { error: createToolError(error), errorMessage: error };
683
562
  }
684
563
 
685
- // Google models
686
- if (
687
- modelLower.includes('flash') ||
688
- modelLower.includes('pro') ||
689
- modelLower === 'google'
690
- ) {
691
- return 'google';
564
+ const { results } = await runChatMode({
565
+ callPlans,
566
+ buildMessagesForCandidate,
567
+ optionsForCandidate,
568
+ signal,
569
+ context,
570
+ providerStreamNormalizer,
571
+ retryOptionsFor: (providerName) => getProviderRetryOptions(config, providerName),
572
+ });
573
+
574
+ const allResults = [
575
+ ...results,
576
+ ...preFailed.map((f) => ({ ...f, status: 'failed' })),
577
+ ];
578
+ const successful = allResults.filter((r) => r.status === 'success');
579
+
580
+ if (successful.length === 0) {
581
+ if (allResults.length > 1) {
582
+ // Multi-model all-failed: surface every model's failure.
583
+ const details = allResults.map((r) => `${r.model} (${r.error})`).join('; ');
584
+ return {
585
+ error: createToolError(`All models failed: ${details}`),
586
+ errorMessage: details,
587
+ };
588
+ }
589
+ const message =
590
+ allResults.find((r) => r.error)?.error || 'Provider returned no response';
591
+ return { error: createToolError(`Provider error: ${message}`), errorMessage: message };
692
592
  }
693
593
 
694
- // Anthropic models
695
- if (
696
- modelLower.includes('claude') ||
697
- modelLower.includes('fable') ||
698
- modelLower.includes('opus') ||
699
- modelLower.includes('sonnet') ||
700
- modelLower.includes('haiku')
701
- ) {
702
- return 'anthropic';
703
- }
594
+ const isMulti = models.length > 1;
595
+ const combinedContent = isMulti
596
+ ? formatChatMultiContent(successful)
597
+ : successful[0].response;
704
598
 
705
- // Mistral models
706
- if (modelLower.includes('mistral') || modelLower.includes('magistral')) {
707
- return 'mistral';
599
+ // Merge new Codex thread IDs into the existing per-spec thread map.
600
+ const providerThreads = { ...(existingState?.providerThreads || {}) };
601
+ for (const r of successful) {
602
+ if (r.metadata?.threadId && r.threadKey) {
603
+ providerThreads[r.threadKey] = r.metadata.threadId;
604
+ }
708
605
  }
709
606
 
710
- // DeepSeek models
711
- if (
712
- modelLower.includes('deepseek') ||
713
- modelLower === 'reasoner' ||
714
- modelLower === 'r1' ||
715
- modelLower === 'chat'
716
- ) {
717
- return 'deepseek';
718
- }
607
+ await persistAndExport(common, {
608
+ userMessage,
609
+ assistantContent: combinedContent,
610
+ extraState: { models, providerThreads },
611
+ });
719
612
 
720
- // OpenRouter models (specific model patterns)
721
- if (
722
- modelLower.includes('qwen') ||
723
- modelLower.includes('kimi') ||
724
- modelLower.includes('moonshot') ||
725
- modelLower === 'k2'
726
- ) {
727
- return 'openrouter';
728
- }
613
+ const failed = allResults.filter((r) => r.status === 'failed');
614
+ const failureDetails = failed.map((f) => `${f.model} (${f.error})`);
615
+ const winner = successful[0];
616
+ const messageCount = normalizedHistory.length + 2; // user + assistant (system excluded)
729
617
 
730
- // Default fallback
731
- return 'openai';
618
+ return {
619
+ kind: 'chat',
620
+ continuationId,
621
+ isCustomId,
622
+ executionTime: (Date.now() - startedAt) / 1000,
623
+ content: combinedContent,
624
+ isMulti,
625
+ successCount: successful.length,
626
+ totalCount: allResults.length,
627
+ failureDetails,
628
+ messageCount,
629
+ modelsList: models.join(', '),
630
+ provider: winner.provider,
631
+ model: winner.resolvedModel,
632
+ combinedForSummary: combinedContent,
633
+ };
732
634
  }
733
635
 
734
636
  /**
735
- * Execute chat with streaming normalization for async execution
736
- * @param {object} args - Original chat arguments
737
- * @param {object} dependencies - Dependencies with continuationId
738
- * @param {object} context - Job execution context
739
- * @returns {Promise<object>} Complete chat result
637
+ * Roundtable-mode pipeline.
740
638
  */
741
- async function executeChatWithStreaming(args, dependencies, context) {
639
+ async function runRoundtablePipeline(common) {
742
640
  const {
641
+ models,
642
+ prompt,
643
+ reasoning_effort,
644
+ continuationId,
645
+ isCustomId,
646
+ normalizedHistory,
647
+ systemPrompt,
648
+ contextMessage,
649
+ hasImages,
650
+ signal,
651
+ context,
652
+ providers,
743
653
  config,
654
+ providerStreamNormalizer,
655
+ } = common;
656
+
657
+ const startedAt = Date.now();
658
+
659
+ const lap = await runRoundtableLap({
660
+ models,
661
+ prompt,
662
+ priorHistory: normalizedHistory,
663
+ contextMessage,
664
+ systemPrompt,
744
665
  providers,
745
- continuationStore,
746
- contextProcessor,
666
+ config,
667
+ signal,
668
+ reasoning_effort,
669
+ hasImages,
670
+ context,
747
671
  providerStreamNormalizer,
672
+ });
673
+
674
+ const { lapTurns, transcript, turnsSuccessful, turnsFailed, lapUserMessage } = lap;
675
+
676
+ const conversationState = await persistAndExport(common, {
677
+ userMessage: lapUserMessage,
678
+ assistantContent: transcript,
679
+ extraState: {
680
+ roundtableData: {
681
+ modelsOrdered: models,
682
+ turnsSuccessful,
683
+ turnsFailed,
684
+ },
685
+ },
686
+ });
687
+
688
+ const failureDetails = lapTurns
689
+ .filter((t) => t.status === 'failed')
690
+ .map((t) => `${t.model} (${t.error})`);
691
+ const messageCount = (conversationState?.messages || []).length;
692
+ const combinedForSummary = lapTurns
693
+ .filter((t) => t.status === 'success' && t.response)
694
+ .map((t) => `${t.model}:\n${t.response}`)
695
+ .join('\n\n---\n\n');
696
+
697
+ return {
698
+ kind: 'roundtable',
748
699
  continuationId,
749
700
  isCustomId,
750
- title: passedTitle, // Title passed from initial submission
751
- } = dependencies;
701
+ executionTime: (Date.now() - startedAt) / 1000,
702
+ transcript,
703
+ structuredResult: {
704
+ status: 'roundtable_complete',
705
+ content: transcript,
706
+ models_consulted: models.length,
707
+ successful_turns: turnsSuccessful,
708
+ failed_turns: turnsFailed,
709
+ turns: lapTurns,
710
+ continuation: {
711
+ id: continuationId,
712
+ messageCount,
713
+ ...(isCustomId && { custom_id: true }),
714
+ },
715
+ settings: { models_requested: models },
716
+ },
717
+ turnsSuccessful,
718
+ totalTurns: lapTurns.length,
719
+ failureDetails,
720
+ modelsList: models.join(', '),
721
+ combinedForSummary,
722
+ };
723
+ }
752
724
 
753
- const {
754
- prompt,
755
- model = 'auto',
756
- files = [],
757
- temperature = 0.5,
758
- use_websearch = false,
759
- images = [],
760
- reasoning_effort = 'medium',
761
- verbosity = 'medium',
762
- export: shouldExport = false,
763
- } = args;
764
-
765
- // Initialize SummarizationService
766
- const summarizationService = new SummarizationService(providers, config);
725
+ /**
726
+ * Build the synchronous MCP response from a pipeline result.
727
+ */
728
+ function buildSyncResponse(pipeline, config) {
729
+ const isTest = config.environment?.nodeEnv === 'test';
730
+ const tokenLimit = getTokenLimit(config);
731
+ const idLine = `continuation_id: ${pipeline.continuationId}\n\n`;
732
+
733
+ if (pipeline.kind === 'chat') {
734
+ const statusLine = isTest
735
+ ? ''
736
+ : pipeline.isMulti
737
+ ? `✅ COMPLETED | CHAT | ${pipeline.continuationId} | ${pipeline.executionTime.toFixed(1)}s elapsed | ${pipeline.successCount}/${pipeline.totalCount} succeeded | ${pipeline.modelsList}\n`
738
+ : `✅ COMPLETED | CHAT | ${pipeline.continuationId} | ${pipeline.executionTime.toFixed(1)}s elapsed | ${pipeline.provider}/${pipeline.model}\n`;
767
739
 
768
- // Use passed title or generate if not provided
769
- let title = passedTitle;
770
- if (!title) {
771
- try {
772
- title = await summarizationService.generateTitle(prompt);
773
- debugLog(`Chat: Generated title - "${title}"`);
774
- } catch (error) {
775
- debugError('Chat: Failed to generate title', error);
776
- // Continue without title if generation fails
740
+ const result = {
741
+ content: statusLine + idLine + pipeline.content,
742
+ continuation: {
743
+ id: pipeline.continuationId,
744
+ messageCount: pipeline.messageCount,
745
+ ...(pipeline.isMulti
746
+ ? { models: pipeline.modelsList }
747
+ : { provider: pipeline.provider, model: pipeline.model }),
748
+ ...(pipeline.isCustomId && { custom_id: true }),
749
+ },
750
+ };
751
+ if (pipeline.failureDetails.length > 0) {
752
+ result.content += formatFailureDetails(pipeline.failureDetails);
777
753
  }
778
- } else {
779
- debugLog(`Chat: Using passed title - "${title}"`);
780
- }
781
754
 
782
- let conversationHistory = [];
783
-
784
- // Load existing conversation if continuation_id provided
785
- if (continuationId) {
755
+ const limited = applyTokenLimit(JSON.stringify(result, null, 2), tokenLimit);
756
+ let finalResult;
786
757
  try {
787
- const existingState = await continuationStore.get(continuationId);
788
- if (existingState) {
789
- conversationHistory = existingState.messages || [];
790
- }
791
- } catch (error) {
792
- logger.error('Error loading conversation', { error });
793
- // Continue with fresh conversation on error
758
+ finalResult = JSON.parse(limited.content);
759
+ } catch {
760
+ finalResult = result;
794
761
  }
762
+ return createToolResponse(finalResult);
795
763
  }
796
764
 
797
- // Validate file paths before processing
798
- if (files.length > 0 || images.length > 0) {
799
- const validation = await validateAllPaths(
800
- { files, images },
801
- { clientCwd: config.server?.client_cwd },
802
- );
803
- if (!validation.valid) {
804
- logger.error('File validation failed', { errors: validation.errors });
805
- throw new Error(
806
- `File validation failed: ${validation.errors.join(', ')}`,
807
- );
808
- }
765
+ // consensus / roundtable structured JSON body
766
+ const modeLabel = pipeline.kind.toUpperCase();
767
+ const countField =
768
+ pipeline.kind === 'consensus'
769
+ ? `${pipeline.finalCount}/${pipeline.totalCount} succeeded`
770
+ : `${pipeline.turnsSuccessful}/${pipeline.totalTurns} turns`;
771
+ const statusLine = isTest
772
+ ? ''
773
+ : `✅ COMPLETED | ${modeLabel} | ${pipeline.continuationId} | ${pipeline.executionTime.toFixed(1)}s elapsed | ${countField} | ${pipeline.modelsList}\n`;
774
+
775
+ const limited = applyTokenLimit(
776
+ JSON.stringify(pipeline.structuredResult, null, 2),
777
+ tokenLimit,
778
+ );
779
+ let finalContent = limited.content;
780
+ if (pipeline.failureDetails.length > 0) {
781
+ finalContent += formatFailureDetails(pipeline.failureDetails);
809
782
  }
783
+ finalContent = statusLine + idLine + finalContent;
810
784
 
811
- // Process context (files, images, web search)
812
- let contextMessage = null;
813
- if (files.length > 0 || images.length > 0 || use_websearch) {
814
- try {
815
- const contextRequest = {
816
- files: Array.isArray(files) ? files : [],
817
- images: Array.isArray(images) ? images : [],
818
- webSearch: use_websearch ? prompt : null,
819
- };
785
+ return createToolResponse({
786
+ content: finalContent,
787
+ continuation: pipeline.structuredResult.continuation,
788
+ });
789
+ }
820
790
 
821
- const contextResult = await contextProcessor.processUnifiedContext(
822
- contextRequest,
823
- {
824
- enforceSecurityCheck: false,
825
- skipSecurityCheck: true,
826
- clientCwd: config.server?.client_cwd,
827
- },
828
- );
791
+ /**
792
+ * Build the async job-completion result object from a pipeline result.
793
+ */
794
+ function buildAsyncResult(pipeline, title, finalSummary) {
795
+ const baseMetadata = {
796
+ execution_time: pipeline.executionTime,
797
+ async_execution: true,
798
+ title,
799
+ final_summary: finalSummary,
800
+ };
829
801
 
830
- // Create context message from files and images
831
- const allProcessedFiles = [
832
- ...contextResult.files,
833
- ...contextResult.images,
834
- ];
835
- if (allProcessedFiles.length > 0) {
836
- contextMessage = createFileContext(allProcessedFiles, {
837
- includeMetadata: true,
838
- includeErrors: true,
839
- });
840
- }
841
- } catch (error) {
842
- logger.error('Error processing context', { error });
843
- // Continue without context if processing fails
844
- }
802
+ if (pipeline.kind === 'chat') {
803
+ return {
804
+ status: 'chat_complete',
805
+ content: pipeline.content,
806
+ continuation: {
807
+ id: pipeline.continuationId,
808
+ messageCount: pipeline.messageCount,
809
+ ...(pipeline.isCustomId && { custom_id: true }),
810
+ },
811
+ metadata: {
812
+ ...baseMetadata,
813
+ provider: pipeline.provider,
814
+ model: pipeline.model,
815
+ successful_models: pipeline.successCount,
816
+ total_models: pipeline.totalCount,
817
+ failure_details: pipeline.failureDetails,
818
+ },
819
+ };
845
820
  }
846
821
 
847
- // Build message array for provider
848
- const messages = [];
849
-
850
- // Add system prompt only if not already in conversation history
851
- if (
852
- conversationHistory.length === 0 ||
853
- conversationHistory[0].role !== 'system'
854
- ) {
855
- messages.push({
856
- role: 'system',
857
- content: CHAT_PROMPT,
858
- });
822
+ if (pipeline.kind === 'consensus') {
823
+ return {
824
+ ...pipeline.structuredResult,
825
+ content: pipeline.formattedContent,
826
+ continuation: {
827
+ id: pipeline.continuationId,
828
+ ...(pipeline.isCustomId && { custom_id: true }),
829
+ },
830
+ metadata: {
831
+ ...baseMetadata,
832
+ successful_models: pipeline.finalCount,
833
+ total_models: pipeline.totalCount,
834
+ failure_details: pipeline.failureDetails,
835
+ },
836
+ };
859
837
  }
860
838
 
861
- // Add conversation history
862
- messages.push(...conversationHistory);
863
-
864
- // Add user prompt with context
865
- const userMessage = {
866
- role: 'user',
867
- content: prompt,
839
+ // roundtable
840
+ return {
841
+ ...pipeline.structuredResult,
842
+ content: pipeline.transcript,
843
+ continuation: {
844
+ id: pipeline.continuationId,
845
+ ...(pipeline.isCustomId && { custom_id: true }),
846
+ },
847
+ metadata: {
848
+ ...baseMetadata,
849
+ successful_models: pipeline.turnsSuccessful,
850
+ total_models: pipeline.totalTurns,
851
+ failure_details: pipeline.failureDetails,
852
+ },
868
853
  };
854
+ }
869
855
 
870
- // If we have context (files/images), create complex content array
871
- if (contextMessage && contextMessage.content) {
872
- userMessage.content = [
873
- ...contextMessage.content,
874
- { type: 'text', text: prompt },
875
- ];
876
- }
877
-
878
- messages.push(userMessage);
879
-
880
- // Select provider
881
- let selectedProvider;
882
- let providerName;
883
-
884
- if (model === 'auto') {
885
- // Auto-select first available provider using same priority as sync path
886
- const providerOrder = [
887
- 'codex',
888
- 'gemini-cli',
889
- 'claude',
890
- 'copilot',
891
- 'openai',
892
- 'google',
893
- 'xai',
894
- 'anthropic',
895
- 'mistral',
896
- 'deepseek',
897
- 'openrouter',
898
- ];
856
+ // --- Model resolution helpers ------------------------------------------------
899
857
 
900
- const requestHasImages = Array.isArray(images) && images.length > 0;
858
+ /**
859
+ * Build the full provider-priority candidate list for an "auto" spec (used for
860
+ * chat-mode failover). Skips text-only providers when the request has images.
861
+ */
862
+ function buildAutoCandidates(providers, config, hasImages) {
863
+ return getAvailableProviders(providers, config, { hasImages }).map((name) => ({
864
+ name,
865
+ providerInstance: providers[name],
866
+ resolvedModel: getDefaultModelForProvider(name),
867
+ displayModel: 'auto',
868
+ }));
869
+ }
901
870
 
902
- for (const name of providerOrder) {
903
- const provider = providers[name];
904
- if (provider && provider.isAvailable && provider.isAvailable(config)) {
905
- // Skip text-only providers when the request includes images.
906
- if (requestHasImages && !providerSupportsImages(provider, name)) {
907
- continue;
871
+ /**
872
+ * Resolve chat-mode call plans. Each "auto" spec (whether the list is exactly
873
+ * ["auto"] or "auto" appears alongside explicit models) yields a plan with the
874
+ * full provider-priority candidate list (failover); explicit models yield one
875
+ * single-candidate plan each. Unavailable/unknown explicit models are returned
876
+ * as pre-failed entries (surfaced as per-model failures).
877
+ */
878
+ function resolveChatCallPlans(models, providers, config, hasImages) {
879
+ const callPlans = [];
880
+ const preFailed = [];
881
+
882
+ for (const spec of models) {
883
+ if (String(spec).toLowerCase() === 'auto') {
884
+ const candidates = buildAutoCandidates(providers, config, hasImages);
885
+ if (candidates.length === 0) {
886
+ // A single ["auto"] with no providers is a hard error; an "auto" entry
887
+ // in a multi-model list becomes a per-model failure instead.
888
+ if (models.length === 1) {
889
+ return {
890
+ callPlans: [],
891
+ preFailed: [],
892
+ error:
893
+ 'No providers available. Please configure at least one API key.',
894
+ };
908
895
  }
909
- providerName = name;
910
- selectedProvider = provider;
911
- break;
896
+ preFailed.push({
897
+ model: 'auto',
898
+ error: 'No providers available for "auto".',
899
+ });
900
+ continue;
912
901
  }
902
+ callPlans.push({
903
+ modelSpec: spec,
904
+ displayModel: 'auto',
905
+ threadKey: spec,
906
+ candidates,
907
+ });
908
+ continue;
913
909
  }
914
910
 
915
- if (!selectedProvider) {
916
- throw new Error(
917
- 'No providers available. Please configure at least one API key.',
918
- );
919
- }
920
- } else {
921
- // Use specified provider/model
922
- providerName = mapModelToProvider(model, providers);
923
- selectedProvider = providers[providerName];
924
-
925
- if (!selectedProvider) {
926
- throw new Error(`Provider not found for model: ${model}`);
927
- }
928
-
929
- if (!selectedProvider.isAvailable(config)) {
930
- throw new Error(getProviderUnavailableMessage(providerName));
911
+ const { providerName, provider, resolvedModel, status, options } = resolveModelSpec(spec, providers, config);
912
+ if (status === 'not_found') {
913
+ preFailed.push({
914
+ model: spec,
915
+ provider: providerName,
916
+ error: `Provider not found for model: ${spec}`,
917
+ });
918
+ } else if (status === 'unavailable') {
919
+ preFailed.push({
920
+ model: spec,
921
+ provider: providerName,
922
+ error: getProviderUnavailableMessage(providerName),
923
+ });
924
+ } else {
925
+ callPlans.push({
926
+ modelSpec: spec,
927
+ displayModel: spec,
928
+ threadKey: spec,
929
+ candidates: [
930
+ { name: providerName, providerInstance: provider, resolvedModel, displayModel: spec, resolveOptions: options },
931
+ ],
932
+ });
931
933
  }
932
934
  }
935
+ return { callPlans, preFailed, error: null };
936
+ }
933
937
 
934
- // Resolve model name and prepare provider options
935
- const resolvedModel = resolveAutoModel(model, providerName);
936
- const providerOptions = {
937
- model: resolvedModel,
938
- temperature,
939
- reasoning_effort,
940
- verbosity,
941
- use_websearch,
942
- config,
943
- continuation_id: continuationId, // Pass for thread resumption
944
- continuationStore, // Pass store for state management
945
- };
946
-
947
- // For streaming, add the stream flag and signal separately
948
- const streamingOptions = {
949
- ...providerOptions,
950
- stream: true,
951
- signal: context?.signal, // Pass AbortSignal for cancellation support
952
- };
953
-
954
- // Check if provider supports streaming (by checking if invoke can return a stream)
955
- let response;
956
- const startTime = Date.now();
957
-
958
- // Always use streaming for async execution in background
959
- if (context?.jobId) {
960
- // Use streaming with normalization
961
- debugLog(`Chat: Using streaming for provider ${providerName}`);
962
-
963
- const stream = await selectedProvider.invoke(messages, streamingOptions);
964
- const normalizedStream = providerStreamNormalizer.normalize(
965
- providerName,
966
- stream,
967
- {
968
- provider: providerName,
969
- model: resolvedModel,
970
- requestId: context.jobId,
971
- },
972
- );
973
-
974
- // Process normalized stream and build final response
975
- let accumulatedContent = '';
976
- let finalUsage = null;
977
- let finalMetadata = {};
978
-
979
- for await (const event of normalizedStream) {
980
- // Check for cancellation
981
- if (context.signal.aborted) {
982
- throw new Error('Chat execution was cancelled');
983
- }
938
+ /**
939
+ * Resolve consensus-mode call plans. Single "auto" expands to the first 3
940
+ * available providers' default models; each spec becomes a single-candidate plan.
941
+ */
942
+ function resolveConsensusCallPlans(models, providers, config, images) {
943
+ const hasImages = Array.isArray(images) && images.length > 0;
984
944
 
985
- switch (event.type) {
986
- case 'start':
987
- // Update job with streaming started status, provider info, and title
988
- await context.updateJob({
989
- status: 'running',
990
- provider: providerName,
991
- model: resolvedModel,
992
- title: title || undefined, // Include title if generated
993
- progress: {
994
- phase: 'streaming_started',
995
- provider: providerName,
996
- model: resolvedModel,
997
- },
998
- });
999
- break;
1000
-
1001
- case 'delta':
1002
- accumulatedContent += event.data.textDelta;
1003
- // Update job with progress and full accumulated content
1004
- await context.updateJob({
1005
- accumulated_content: accumulatedContent, // Store full content
1006
- progress: {
1007
- phase: 'streaming',
1008
- provider: providerName,
1009
- model: resolvedModel,
1010
- content_length: accumulatedContent.length,
1011
- },
1012
- });
1013
- break;
945
+ let modelsToProcess = models;
946
+ if (models.length === 1 && String(models[0]).toLowerCase() === 'auto') {
947
+ const available = getAvailableProviders(providers, config, { hasImages, limit: 3 });
948
+ modelsToProcess = available.map((name) => getDefaultModelForProvider(name));
949
+ }
1014
950
 
1015
- case 'reasoning_summary':
1016
- // Update job with reasoning summary
1017
- debugLog(
1018
- `[Chat] *** UPDATING JOB WITH REASONING: "${event.data.content?.substring(0, 100)}..."`,
1019
- );
1020
- await context.updateJob({
1021
- reasoning_summary: event.data.content,
1022
- });
1023
- break;
951
+ const resolved = [];
952
+ const preFailed = [];
953
+ for (const spec of modelsToProcess) {
954
+ if (!spec || typeof spec !== 'string') {
955
+ preFailed.push({ model: spec || 'unknown', error: 'Invalid model specification' });
956
+ continue;
957
+ }
958
+ const { providerName, provider, resolvedModel, status, options } = resolveModelSpec(spec, providers, config);
959
+ if (status === 'not_found') {
960
+ preFailed.push({ model: spec, provider: providerName, error: `Provider not found: ${providerName}` });
961
+ } else if (status === 'unavailable') {
962
+ preFailed.push({ model: spec, provider: providerName, error: getProviderUnavailableMessage(providerName) });
963
+ } else {
964
+ resolved.push({
965
+ modelSpec: spec,
966
+ displayModel: spec,
967
+ threadKey: spec,
968
+ candidates: [
969
+ { name: providerName, providerInstance: provider, resolvedModel, displayModel: spec, resolveOptions: options },
970
+ ],
971
+ });
972
+ }
973
+ }
974
+ return { resolved, preFailed };
975
+ }
1024
976
 
1025
- case 'usage':
1026
- finalUsage = event.data.usage;
1027
- break;
977
+ // --- Formatting helpers ------------------------------------------------------
1028
978
 
1029
- case 'end':
1030
- accumulatedContent = event.data.content || accumulatedContent;
1031
- finalUsage = event.data.usage || finalUsage;
1032
- finalMetadata = event.data.metadata || finalMetadata;
1033
- break;
979
+ function formatChatMultiContent(successful) {
980
+ let content = '';
981
+ for (const r of successful) {
982
+ content += `### ${r.model}:\n${r.response}\n\n---\n\n`;
983
+ }
984
+ return content.trimEnd();
985
+ }
1034
986
 
1035
- case 'error':
1036
- throw new Error(`Streaming error: ${event.data.error.message}`);
987
+ function formatConsensusContent(successful, refined) {
988
+ let content = '## Initial Responses\n\n';
989
+ for (const r of successful) {
990
+ content += `### ${r.model} (initial response):\n${r.response}\n\n---\n\n`;
991
+ }
992
+ if (refined) {
993
+ content += '## Refined Responses\n\n';
994
+ for (const r of refined) {
995
+ if (r.status === 'success' && r.refined_response) {
996
+ content += `### ${r.model} (refined response):\n${r.refined_response}\n\n---\n\n`;
997
+ } else if (r.status === 'partial') {
998
+ content += `### ${r.model} (refinement failed, showing initial):\n${r.initial_response}\n\n---\n\n`;
1037
999
  }
1038
1000
  }
1039
-
1040
- response = {
1041
- content: accumulatedContent,
1042
- metadata: {
1043
- ...finalMetadata,
1044
- usage: finalUsage,
1045
- streaming: true,
1046
- },
1047
- };
1048
- } else {
1049
- // Fall back to regular invoke
1050
- debugLog(`Chat: Using regular invoke for provider ${providerName}`);
1051
- response = await selectedProvider.invoke(messages, providerOptions);
1052
1001
  }
1053
-
1054
- const executionTime = (Date.now() - startTime) / 1000;
1055
-
1056
- // Validate response
1057
- if (!response || !response.content) {
1058
- throw new Error('Provider returned invalid response');
1002
+ content += `\n**Summary:** Consensus completed with ${successful.length} successful initial responses`;
1003
+ if (refined) {
1004
+ const successfulRefinements = refined.filter((r) => r.status === 'success').length;
1005
+ content += ` and ${successfulRefinements} successful refined responses`;
1059
1006
  }
1007
+ content += '.';
1008
+ return content;
1009
+ }
1060
1010
 
1061
- // Store reasoning summary from OpenAI if available
1062
- if (
1063
- response.metadata?.usage?.reasoning_summary &&
1064
- context &&
1065
- context.updateJob
1066
- ) {
1067
- try {
1068
- await context.updateJob({
1069
- reasoning_summary: response.metadata.usage.reasoning_summary,
1070
- });
1071
- debugLog('Chat: Stored reasoning summary');
1072
- } catch (error) {
1073
- debugError('Chat: Failed to store reasoning summary', error);
1074
- }
1011
+ function refinedText(refined, result) {
1012
+ const match = refined.find((r) => r.model === result.model);
1013
+ if (match && match.status === 'success' && match.refined_response) {
1014
+ return match.refined_response;
1075
1015
  }
1016
+ return result.response;
1017
+ }
1076
1018
 
1077
- // Generate final summary for responses longer than 100 characters (non-blocking)
1078
- let finalSummary = null;
1079
- if (response.content && response.content.length > 100) {
1080
- try {
1081
- finalSummary = await summarizationService.generateFinalSummary(
1082
- response.content,
1083
- );
1084
- debugLog(`Chat: Generated final summary - "${finalSummary}"`);
1085
- // Store final summary in job
1086
- if (finalSummary && context && context.updateJob) {
1087
- await context.updateJob({
1088
- final_summary: finalSummary,
1089
- });
1019
+ function collectConsensusFailures(successful, failed, refined) {
1020
+ const details = [];
1021
+ if (refined) {
1022
+ refined.forEach((r) => {
1023
+ if (r.status === 'partial') {
1024
+ details.push(`${r.model} (refinement failed)`);
1090
1025
  }
1091
- } catch (error) {
1092
- debugError('Chat: Failed to generate final summary', error);
1093
- // Continue without summary if generation fails
1094
- }
1026
+ });
1027
+ failed.forEach((f) => details.push(`${f.model} (initial failed)`));
1028
+ } else {
1029
+ failed.forEach((f) => details.push(`${f.model} (${f.error})`));
1095
1030
  }
1031
+ return details;
1032
+ }
1096
1033
 
1097
- // Add assistant response to conversation history
1098
- const assistantMessage = {
1099
- role: 'assistant',
1100
- content: response.content,
1101
- };
1034
+ // --- Shared side-effect + context helpers ------------------------------------
1102
1035
 
1103
- const updatedMessages = [...messages, assistantMessage];
1036
+ /**
1037
+ * Build the persisted conversation state (aborted-guarded), persist it, export
1038
+ * it when requested, and return it. Returns undefined when the request was
1039
+ * aborted (matching the historical `const persist = !signal?.aborted` guard).
1040
+ * @param {object} common - Shared pipeline context
1041
+ * @param {object} parts - Varying per-mode parts
1042
+ * @param {object} parts.userMessage - The turn's user message
1043
+ * @param {string} parts.assistantContent - The assistant body to persist
1044
+ * @param {object} [parts.extraState] - Extra per-mode state fields to merge
1045
+ * @returns {Promise<object|undefined>} The persisted state, or undefined if skipped
1046
+ */
1047
+ async function persistAndExport(common, { userMessage, assistantContent, extraState = {} }) {
1048
+ if (common.signal?.aborted) {
1049
+ return undefined;
1050
+ }
1051
+ const conversationState = {
1052
+ messages: [
1053
+ common.systemMessage,
1054
+ ...common.normalizedHistory,
1055
+ userMessage,
1056
+ { role: 'assistant', content: assistantContent },
1057
+ ],
1058
+ mode: common.mode,
1059
+ lastUpdated: Date.now(),
1060
+ ...extraState,
1061
+ };
1062
+ await persistState(common.continuationStore, common.continuationId, conversationState);
1063
+ await maybeExport(common.shouldExport, conversationState, {
1064
+ config: common.config,
1065
+ continuationId: common.continuationId,
1066
+ models: common.models,
1067
+ reasoning_effort: common.reasoning_effort,
1068
+ mode: common.mode,
1069
+ common,
1070
+ });
1071
+ return conversationState;
1072
+ }
1104
1073
 
1105
- // Save conversation state
1074
+ async function persistState(continuationStore, continuationId, state) {
1106
1075
  try {
1107
- const conversationState = {
1108
- messages: updatedMessages,
1109
- provider: providerName,
1110
- model,
1111
- lastUpdated: Date.now(),
1112
- // Store Codex thread ID if available (for thread resumption)
1113
- codexThreadId: response.metadata?.threadId,
1114
- };
1115
-
1116
- await continuationStore.set(continuationId, conversationState);
1076
+ await continuationStore.set(continuationId, state);
1117
1077
  } catch (error) {
1118
1078
  logger.error('Error saving conversation', { error });
1119
- // Continue even if save fails
1120
1079
  }
1080
+ }
1081
+
1082
+ async function maybeExport(shouldExport, conversationState, opts) {
1083
+ if (!shouldExport || !conversationState) {
1084
+ return;
1085
+ }
1086
+ const { config, continuationId, models, reasoning_effort, mode, common } = opts;
1087
+ await exportConversation(conversationState, {
1088
+ clientCwd: config.server?.client_cwd,
1089
+ continuation_id: continuationId,
1090
+ mode,
1091
+ models,
1092
+ reasoning_effort,
1093
+ files: common.files,
1094
+ images: common.images,
1095
+ });
1096
+ }
1121
1097
 
1122
- // Export conversation if requested
1123
- if (shouldExport) {
1124
- await exportConversation(
1098
+ /**
1099
+ * Process files/images into a single context message (shared sync + async).
1100
+ * @returns {Promise<object|null>} Context message or null
1101
+ */
1102
+ async function buildContextMessage(files, images, contextProcessor, config) {
1103
+ if ((!files || files.length === 0) && (!images || images.length === 0)) {
1104
+ return null;
1105
+ }
1106
+ try {
1107
+ const contextResult = await contextProcessor.processUnifiedContext(
1125
1108
  {
1126
- messages: updatedMessages,
1127
- provider: providerName,
1128
- model,
1129
- lastUpdated: Date.now(),
1130
- codexThreadId: response.metadata?.threadId,
1109
+ files: Array.isArray(files) ? files : [],
1110
+ images: Array.isArray(images) ? images : [],
1131
1111
  },
1132
1112
  {
1113
+ enforceSecurityCheck: false,
1114
+ skipSecurityCheck: true,
1133
1115
  clientCwd: config.server?.client_cwd,
1134
- continuation_id: continuationId,
1135
- model,
1136
- temperature,
1137
- reasoning_effort,
1138
- verbosity,
1139
- use_websearch,
1140
- files,
1141
- images,
1142
1116
  },
1143
1117
  );
1118
+ const allProcessedFiles = [...contextResult.files, ...contextResult.images];
1119
+ if (allProcessedFiles.length > 0) {
1120
+ return createFileContext(allProcessedFiles, {
1121
+ includeMetadata: true,
1122
+ includeErrors: true,
1123
+ });
1124
+ }
1125
+ } catch (error) {
1126
+ logger.error('Error processing context', { error });
1144
1127
  }
1128
+ return null;
1129
+ }
1145
1130
 
1146
- // Return complete result for job completion
1147
- return {
1148
- content: response.content,
1149
- title: title || undefined, // Include title if generated
1150
- summary: finalSummary || undefined, // Include summary if generated
1151
- continuation: {
1152
- id: continuationId,
1153
- provider: providerName,
1154
- model,
1155
- messageCount: updatedMessages.filter((msg) => msg.role !== 'system')
1156
- .length,
1157
- ...(isCustomId && { custom_id: true }),
1158
- },
1159
- metadata: {
1160
- provider: providerName,
1161
- model: resolvedModel,
1162
- execution_time: executionTime,
1163
- async_execution: true,
1164
- ...response.metadata,
1165
- },
1166
- };
1131
+ function formatStartTime() {
1132
+ return new Date()
1133
+ .toLocaleString('en-GB', {
1134
+ day: '2-digit',
1135
+ month: '2-digit',
1136
+ year: 'numeric',
1137
+ hour: '2-digit',
1138
+ minute: '2-digit',
1139
+ second: '2-digit',
1140
+ hour12: false,
1141
+ })
1142
+ .replace(',', '');
1167
1143
  }
1168
1144
 
1169
- // Tool metadata
1145
+ // --- Tool metadata -----------------------------------------------------------
1146
+
1170
1147
  chatTool.description =
1171
- 'GENERAL CHAT & COLLABORATIVE THINKING - Development assistance, brainstorming, code analysis. Supports files, images, continuation_id for multi-turn conversations. Use model: "auto" for automatic selection. IMPORTANT: Use the "files" parameter to share code/file content instead of pasting into the prompt.';
1148
+ 'UNIFIED CHAT talk to one or more AI models. mode "chat" (default): 1..N models answer independently in parallel. mode "consensus": ≥2 models answer, then refine after seeing each other. mode "roundtable": models answer sequentially, each building on the running transcript. Supports files, images, and continuation_id for multi-turn threads (you may switch modes on resume). Use model "auto" for automatic selection. IMPORTANT: use the "files" parameter to share code/file content instead of pasting into the prompt.';
1149
+
1172
1150
  chatTool.inputSchema = {
1173
1151
  type: 'object',
1174
1152
  properties: {
1175
- model: {
1153
+ prompt: {
1176
1154
  type: 'string',
1177
1155
  description:
1178
- 'AI model to use. Examples: "auto" (recommended), "codex", "gemini", "gemini:flash", "claude", "claude:fable", "claude:opus", "copilot", "copilot:codex". Defaults to auto-selection.',
1156
+ 'Your question, topic, or task with relevant context. More detail enables better responses. Example: "How should I structure the authentication module for this Express.js API?"',
1179
1157
  },
1180
- files: {
1158
+ models: {
1181
1159
  type: 'array',
1182
1160
  items: { type: 'string' },
1161
+ minItems: 1,
1183
1162
  description:
1184
- 'File paths to include as context (absolute or relative paths). Supports line ranges: file.txt{10:50}, file.txt{100:}. Example: ["./src/utils/auth.js{50:100}", "./config.json"]. IMPORTANT: Always use this parameter to share file content instead of copying code into the prompt - it provides better formatting, line numbers, and preserves context.',
1163
+ 'Models to use. Examples: ["auto"] (recommended), ["codex"], ["codex", "gemini", "claude"]. In mode "chat" each model answers independently; in "consensus" they refine after seeing each other; in "roundtable" they speak in the given ORDER, each seeing the transcript. Default: ["auto"].',
1185
1164
  },
1186
- images: {
1187
- type: 'array',
1188
- items: { type: 'string' },
1165
+ mode: {
1166
+ type: 'string',
1167
+ enum: ['chat', 'consensus', 'roundtable'],
1189
1168
  description:
1190
- 'Image paths for visual context (absolute or relative paths, or base64 data). Example: ["C:\\Users\\username\\diagram.png", "./screenshot.jpg", "data:image/jpeg;base64,/9j/4AAQ..."]',
1169
+ 'Execution mode. "chat" (default): independent parallel answers. "consensus": ≥2 models answer then refine via cross-feedback. "roundtable": sequential turn-based dialogue in the given model order. Default: "chat".',
1191
1170
  },
1192
1171
  continuation_id: {
1193
1172
  type: 'string',
1194
1173
  description:
1195
- 'Continuation ID for persistent conversation. Auto-generated in the first response; pass it back to continue the conversation.',
1174
+ 'Continuation ID for a persistent multi-turn thread. Auto-generated in the first response; pass it back to continue. You MAY change the mode or models on a resuming turn.',
1196
1175
  },
1197
- temperature: {
1198
- type: 'number',
1176
+ files: {
1177
+ type: 'array',
1178
+ items: { type: 'string' },
1199
1179
  description:
1200
- 'Response randomness (0.0-1.0). Examples: 0.2 (focused), 0.5 (balanced), 0.8 (creative). Default: 0.5',
1201
- minimum: 0.0,
1202
- maximum: 1.0,
1203
- default: 0.5,
1180
+ 'File paths to include as context (absolute or relative). Supports line ranges: file.txt{10:50}, file.txt{100:}. Example: ["./src/utils/auth.js{50:100}", "./config.json"]. IMPORTANT: Always use this parameter to share file content instead of copying code into the prompt.',
1204
1181
  },
1205
- reasoning_effort: {
1206
- type: 'string',
1207
- enum: ['none', 'minimal', 'low', 'medium', 'high', 'max'],
1182
+ images: {
1183
+ type: 'array',
1184
+ items: { type: 'string' },
1208
1185
  description:
1209
- 'Reasoning depth for thinking models. Examples: "none" (no reasoning, fastest - GPT-5.1+ only), "minimal" (few reasoning tokens), "low" (light analysis), "medium" (balanced), "high" (complex analysis). Default: "medium"',
1210
- default: 'medium',
1186
+ 'Image paths for visual context (absolute or relative paths, or base64 data). Example: ["C:\\Users\\username\\diagram.png", "./screenshot.jpg", "data:image/jpeg;base64,/9j/4AAQ..."]',
1211
1187
  },
1212
- verbosity: {
1188
+ reasoning_effort: {
1213
1189
  type: 'string',
1214
- enum: ['low', 'medium', 'high'],
1215
- description:
1216
- 'Output verbosity for GPT-5 models. Examples: "low" (concise answers), "medium" (balanced), "high" (thorough explanations). Default: "medium"',
1217
- default: 'medium',
1218
- },
1219
- use_websearch: {
1220
- type: 'boolean',
1190
+ enum: ['none', 'minimal', 'low', 'medium', 'high', 'max'],
1221
1191
  description:
1222
- 'Enable web search for current information. Example: true for recent developments or up to date documentation. Default: false',
1223
- default: false,
1192
+ 'Reasoning depth for thinking models. Examples: "none" (no reasoning, fastest - GPT-5.1+ only), "minimal", "low", "medium" (balanced), "high", "max". Default: "medium"',
1224
1193
  },
1225
1194
  async: {
1226
1195
  type: 'boolean',
1227
1196
  description:
1228
- 'Execute chat in background. When true, returns continuation_id immediately and processes request asynchronously. Default: false',
1229
- default: false,
1197
+ 'Execute in the background. When true, returns a continuation_id immediately and processes the request asynchronously; poll with check_status. Default: false',
1230
1198
  },
1231
1199
  export: {
1232
1200
  type: 'boolean',
1233
1201
  description:
1234
- 'Export conversation to disk. Creates folder with continuation_id name containing numbered request/response files and metadata. Default: false',
1235
- default: false,
1236
- },
1237
- prompt: {
1238
- type: 'string',
1239
- description:
1240
- 'Your question or topic with relevant context. More detail enables better responses. Example: "How should I structure the authentication module for this Express.js API?"',
1202
+ 'Export the conversation to disk. Creates a folder named for the continuation_id with numbered request/response files and metadata. Default: false',
1241
1203
  },
1242
1204
  },
1243
1205
  required: ['prompt'],