mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-09 07:25:43 +08:00
Fix/resolve different bugs (#964)
* fix: don't overlap thinking banner over conversation * feat: support message queue and add attention indicator * fix(shell): use c to copy for claude * fix: strip timestamp tags from cursor * fix: select project when loading session from direct URL * feat: support attached images for all providers * feat(opencode): support permission options * fix: resolve source control and chat UX bugs - make staging real in the git panel: new /stage and /unstage endpoints, /status now reports the actual index via porcelain -z (handles renames, conflicts, and paths with spaces), and Stage/Unstage All run real git commands instead of toggling client-side state - add branch search to the header dropdown and Branches tab - persist the chosen permission mode per provider so a brand-new chat keeps it once the session id arrives, instead of reverting to default - replace cmdk's fuzzy model search with strict token matching so "chatgpt" no longer surfaces unrelated models - unit tests for the git status parser * feat(git): commit graph in history view and cross-spawn everywhere - render a VSCode-style commit graph in the source control history: lane-assignment algorithm, SVG strip with colored rails, merge and branch curves, commit dots, and branch/tag badges per commit - /commits returns parents and ref decorations across all branches (--branches --remotes --tags --topo-order) using unit-separator fields so pipes in commit subjects can't break parsing - collect stats via a single --shortstat pass instead of one `git show --stat` call per commit; raise the history limit to 50 - replace child_process spawn with cross-spawn in all runtimes, routes, and services: it resolves .cmd shims and PATHEXT on Windows (fixing taskmaster's npx invocations) and delegates to native spawn elsewhere, removing the per-file win32 ternaries - unit tests for the log parser and lane assignment * fix: remove gemini support since google discontinued it * fix: address code review findings - validate chat image attachments server-side: only files inside the ~/.cloudcli/assets upload store may reach provider file reads - harden asset serving with nosniff and attachment disposition for SVGs to prevent stored XSS - show the timestamp on image-only user messages - serialize git stage/unstage calls and defer the status re-sync so rapid toggles can't interleave or flicker - use a literal hex fallback for commit ref badges (alpha suffix on a var() string produced invalid CSS) - stop binding a URL session to a guening project instead - add the missing attentionRequiredIndicator key to all sidebar locales - clean up dangling conjunctions left the de/ru/tr/ja/zh-CN/zh-TW READMEs * fix: address code scanning findings - enforce a second image trust boundary in the provider builders: buildClaudeUserContent/buildCodexInputItems only accept files inside the upload store or the run's working directory (CodeQL path injection) - drop an always-true selectedProject check in handleSubmit - remove the unused resume option from spawnCursor * fix: use CodeQL-recognized containment check for image reads Replace the path.relative guard in isPathInsideDirectory with the resolve + startsWith(root + sep) idiom so the path-injection barrier is visible to code scanning; behavior is unchanged. * fix(security): canonicalize Claude image attachment reads Resolve image attachment paths with realpath before reading so symlinks inside allowed roots cannot escape to arbitrary files. Preserve support for symlinked project/upload roots and add focused coverage for both cases. * fix: show agent subtask * fix(sessions): title app-created sessions from the first user message App-created sessions (started by sending a message from cloudcli) were titled with placeholder names — "Untitled Codex Session" from the disk indexer and, briefly, "New session" from the empty canonical upsert — before a later sync finally settled on the right text, causing the sidebar title to flicker. - Codex/OpenCode synchronizers now title app-created sessions (distinct app id mapped to a provider id) from the first user message, while sessions found purely by indexing keep their existing setup. Claude keeps its AI-generated titles; Cursor already used the first message. - Decode OpenCode's JSON-string-literal prompts so titles no longer surface wrapped in quotes; hoist unwrapJsonStringLiteral into shared/utils since it's now used by both the reader and synchronizer. - Guard the sidebar upsert merge so an empty summary can never blank out a title that is already set. - Add codex/opencode synchronizer tests for the app-created vs indexed naming paths. * fix(chat): make message queuing reliable across sessions and turn boundaries Queued messages had four related defects: - A queued message flashed and then vanished at flush time: concurrent transcript refreshes (the `complete` handler racing the watcher-triggered update) could resolve out of order, letting a stale response overwrite newer server messages after the optimistic row was pruned. Session slots now carry a monotonic fetch ticket and discard stale fetch/refresh/ fetchMore responses. - Switching sessions flushed the previous session's queued draft into the newly viewed one (sending it with the wrong provider's settings, e.g. a Claude model into a Codex session). The composer flush is now scoped to its session, and a draft restored into an idle session sends after a short grace period so a live-run ack can cancel it. - The thinking banner never appeared for a queued turn: the chat handler's session-keyed completeRun safety net could fire after a queued message had already started the session's next run, emitting a spurious `complete` that killed it. The safety net is now scoped to its own run via completeRunIfCurrent (with a regression test). - Queued messages only sent while their session was being viewed. Drafts now persist their send options (model, effort, permissions) at queue time, and a new app-level useQueuedMessageAutoSend hook dispatches a non-viewed session's queued message as soon as its run completes, using the storage key as the claim ticket to prevent double sends.
This commit is contained in:
103
server/modules/assets/assets.routes.ts
Normal file
103
server/modules/assets/assets.routes.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import fsSync, { promises as fs } from 'node:fs';
|
||||
|
||||
import express from 'express';
|
||||
import mime from 'mime-types';
|
||||
import multer from 'multer';
|
||||
|
||||
import {
|
||||
buildStoredImageRecords,
|
||||
ensureImageAssetsDir,
|
||||
isAllowedImageMimeType,
|
||||
resolveImageAssetFile,
|
||||
} from '@/modules/assets/services/image-assets.service.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Multer writes uploads straight into the global assets folder; the service
|
||||
// owns the folder location and the response record shape.
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
ensureImageAssetsDir()
|
||||
.then((assetsDir) => cb(null, assetsDir))
|
||||
.catch((error) => cb(error as Error, ''));
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||
cb(null, `${uniqueSuffix}-${sanitizedName}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (isAllowedImageMimeType(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.'));
|
||||
}
|
||||
},
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024, // 5MB
|
||||
files: 5,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Stores chat image attachments in the global `~/.cloudcli/assets` folder and
|
||||
* returns their absolute paths for use in provider prompts and chat history.
|
||||
*/
|
||||
router.post('/images', (req, res) => {
|
||||
upload.array('images', 5)(req, res, (err: unknown) => {
|
||||
if (err) {
|
||||
const message = err instanceof Error ? err.message : 'Upload failed';
|
||||
return res.status(400).json({ error: message });
|
||||
}
|
||||
|
||||
const files = Array.isArray(req.files) ? req.files : [];
|
||||
if (files.length === 0) {
|
||||
return res.status(400).json({ error: 'No image files provided' });
|
||||
}
|
||||
|
||||
res.json({ images: buildStoredImageRecords(files) });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Serves one stored image asset by filename. Only files directly inside the
|
||||
* global assets folder are reachable; traversal attempts resolve to null.
|
||||
*/
|
||||
router.get('/images/:filename', async (req, res) => {
|
||||
const resolved = resolveImageAssetFile(req.params.filename);
|
||||
if (!resolved) {
|
||||
return res.status(400).json({ error: 'Invalid asset filename' });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(resolved);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'Asset not found' });
|
||||
}
|
||||
|
||||
const contentType = mime.lookup(resolved) || 'application/octet-stream';
|
||||
res.setHeader('Content-Type', contentType);
|
||||
// Stored-XSS hardening: never let the browser sniff a different type, and
|
||||
// force SVGs (which can carry scripts when rendered as a document) to
|
||||
// download instead of rendering inline. The chat UI is unaffected — it
|
||||
// fetches assets as blobs and shows them through <img>, where SVG scripts
|
||||
// never execute.
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
if (contentType === 'image/svg+xml') {
|
||||
res.setHeader('Content-Disposition', 'attachment');
|
||||
}
|
||||
const fileStream = fsSync.createReadStream(resolved);
|
||||
fileStream.pipe(res);
|
||||
fileStream.on('error', (error) => {
|
||||
console.error('Error streaming image asset:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Error reading asset' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
3
server/modules/assets/index.ts
Normal file
3
server/modules/assets/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Express router mounted at /api/assets by server/index.js (upload + serving
|
||||
// of chat image attachments stored in the global ~/.cloudcli/assets folder).
|
||||
export { default as assetsRoutes } from './assets.routes.js';
|
||||
82
server/modules/assets/services/image-assets.service.ts
Normal file
82
server/modules/assets/services/image-assets.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { getGlobalImageAssetsDir, toPosixPath } from '@/shared/image-attachments.js';
|
||||
|
||||
/**
|
||||
* Image mime types accepted for chat attachment uploads. SVG is allowed for
|
||||
* storage/preview even though some providers (Claude API) skip it at send time.
|
||||
*/
|
||||
const ALLOWED_IMAGE_MIME_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
]);
|
||||
|
||||
// Used only by this service and the assets routes via the barrel file.
|
||||
type StoredImageAsset = {
|
||||
/** Original upload filename, for display. */
|
||||
name: string;
|
||||
/** Absolute posix-normalized path inside the global assets folder. */
|
||||
path: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
// Shape of one multer-stored file; kept local because only this module reads it.
|
||||
type UploadedImageFile = {
|
||||
originalname: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
mimetype: string;
|
||||
};
|
||||
|
||||
/** Returns whether one uploaded mime type may be stored as a chat image asset. */
|
||||
export function isAllowedImageMimeType(mimeType: string): boolean {
|
||||
return ALLOWED_IMAGE_MIME_TYPES.has(mimeType);
|
||||
}
|
||||
|
||||
/** Creates the global `~/.cloudcli/assets` folder if needed and returns it. */
|
||||
export async function ensureImageAssetsDir(): Promise<string> {
|
||||
const assetsDir = getGlobalImageAssetsDir();
|
||||
await fs.mkdir(assetsDir, { recursive: true });
|
||||
return assetsDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps multer-stored upload files to the attachment records returned to the
|
||||
* chat composer. The absolute path is what providers receive and what session
|
||||
* history carries back to the UI.
|
||||
*/
|
||||
export function buildStoredImageRecords(files: UploadedImageFile[]): StoredImageAsset[] {
|
||||
const assetsDir = getGlobalImageAssetsDir();
|
||||
return files.map((file) => ({
|
||||
name: file.originalname,
|
||||
path: toPosixPath(path.join(assetsDir, file.filename)),
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves one asset filename to its absolute path inside the global assets
|
||||
* folder, or null when the name is empty, contains path separators/traversal,
|
||||
* or would escape the folder. This is the only lookup the serving route uses,
|
||||
* so nothing outside `~/.cloudcli/assets` can ever be read through it.
|
||||
*/
|
||||
export function resolveImageAssetFile(filename: string): string | null {
|
||||
const trimmed = typeof filename === 'string' ? filename.trim() : '';
|
||||
if (!trimmed || trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes('..')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assetsDir = path.resolve(getGlobalImageAssetsDir());
|
||||
const resolved = path.resolve(assetsDir, trimmed);
|
||||
if (!resolved.startsWith(assetsDir + path.sep)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
46
server/modules/assets/tests/image-assets.service.test.ts
Normal file
46
server/modules/assets/tests/image-assets.service.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildStoredImageRecords,
|
||||
isAllowedImageMimeType,
|
||||
resolveImageAssetFile,
|
||||
} from '@/modules/assets/services/image-assets.service.js';
|
||||
|
||||
const ASSETS_DIR = path.join(os.homedir(), '.cloudcli', 'assets');
|
||||
|
||||
test('isAllowedImageMimeType accepts image formats and rejects the rest', () => {
|
||||
assert.equal(isAllowedImageMimeType('image/png'), true);
|
||||
assert.equal(isAllowedImageMimeType('image/svg+xml'), true);
|
||||
assert.equal(isAllowedImageMimeType('application/pdf'), false);
|
||||
assert.equal(isAllowedImageMimeType('text/html'), false);
|
||||
});
|
||||
|
||||
test('buildStoredImageRecords returns absolute posix paths in the assets dir', () => {
|
||||
const records = buildStoredImageRecords([
|
||||
{ originalname: 'shot.png', filename: '123-456-shot.png', size: 42, mimetype: 'image/png' },
|
||||
]);
|
||||
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(records[0].name, 'shot.png');
|
||||
assert.equal(records[0].size, 42);
|
||||
assert.equal(records[0].mimeType, 'image/png');
|
||||
assert.equal(records[0].path, `${ASSETS_DIR.replace(/\\/g, '/')}/123-456-shot.png`);
|
||||
});
|
||||
|
||||
test('resolveImageAssetFile resolves plain filenames inside the assets dir', () => {
|
||||
const resolved = resolveImageAssetFile('123-shot.png');
|
||||
assert.equal(resolved, path.join(path.resolve(ASSETS_DIR), '123-shot.png'));
|
||||
});
|
||||
|
||||
test('resolveImageAssetFile rejects traversal and separator attempts', () => {
|
||||
assert.equal(resolveImageAssetFile(''), null);
|
||||
assert.equal(resolveImageAssetFile(' '), null);
|
||||
assert.equal(resolveImageAssetFile('../auth.db'), null);
|
||||
assert.equal(resolveImageAssetFile('..'), null);
|
||||
assert.equal(resolveImageAssetFile('sub/dir.png'), null);
|
||||
assert.equal(resolveImageAssetFile('sub\\dir.png'), null);
|
||||
assert.equal(resolveImageAssetFile('a..b/../c.png'), null);
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution.
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import { appConfigDb } from '@/modules/database/index.js';
|
||||
import { providerMcpService } from '@/modules/providers/index.js';
|
||||
import { getModuleDir } from '@/utils/runtime-paths.js';
|
||||
@@ -270,8 +272,10 @@ function runCommand(command: string, args: string[]): Promise<void> {
|
||||
}, INSTALL_COMMAND_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
|
||||
child.stdout.on('data', (chunk) => output.push(String(chunk)));
|
||||
child.stderr.on('data', (chunk) => output.push(String(chunk)));
|
||||
// stdio config above guarantees the pipes exist; cross-spawn's types
|
||||
// just don't narrow them the way node's spawn overloads do.
|
||||
child.stdout?.on('data', (chunk) => output.push(String(chunk)));
|
||||
child.stderr?.on('data', (chunk) => output.push(String(chunk)));
|
||||
child.on('error', (error) => finish(() => reject(error)));
|
||||
child.on('close', (code) => finish(() => {
|
||||
if (code === 0) {
|
||||
|
||||
@@ -100,9 +100,9 @@ test('assignProviderSessionId merges a watcher-created duplicate into the app ro
|
||||
|
||||
test('legacy provider-keyed rows stay resolvable through both lookups', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createSession('legacy-1', 'gemini', '/workspace/demo');
|
||||
sessionsDb.createSession('legacy-1', 'opencode', '/workspace/demo');
|
||||
|
||||
assert.equal(sessionsDb.getSessionById('legacy-1')?.provider, 'gemini');
|
||||
assert.equal(sessionsDb.getSessionById('legacy-1')?.provider, 'opencode');
|
||||
assert.equal(sessionsDb.getSessionByProviderSessionId('legacy-1')?.session_id, 'legacy-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ const PROVIDER_LABELS = {
|
||||
claude: 'Claude',
|
||||
cursor: 'Cursor',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
system: 'System'
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { access, mkdir, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution.
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import { githubTokensDb } from '@/modules/database/index.js';
|
||||
import { createProject } from '@/modules/projects/services/project-management.service.js';
|
||||
import type { WorkspacePathValidationResult } from '@/shared/types.js';
|
||||
|
||||
@@ -36,7 +36,6 @@ Current provider ids in this repo are:
|
||||
- `claude`
|
||||
- `codex`
|
||||
- `cursor`
|
||||
- `gemini`
|
||||
- `opencode`
|
||||
|
||||
Those ids are mirrored in backend unions and frontend provider constants. If
|
||||
@@ -56,8 +55,7 @@ server/modules/providers/list/<provider>/
|
||||
<provider>-session-synchronizer.provider.ts
|
||||
```
|
||||
|
||||
The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and
|
||||
`opencode`.
|
||||
The existing provider folders are `claude`, `codex`, `cursor`, and `opencode`.
|
||||
|
||||
## What Each Facet Does
|
||||
|
||||
@@ -123,7 +121,6 @@ Current MCP formats in this repo are:
|
||||
| Claude | `.mcp.json` in user / local / project locations | `user`, `local`, `project` | `stdio`, `http`, `sse` |
|
||||
| Codex | `.codex/config.toml` | `user`, `project` | `stdio`, `http` |
|
||||
| Cursor | `.cursor/mcp.json` | `user`, `project` | `stdio`, `http` |
|
||||
| Gemini | `.gemini/settings.json` | `user`, `project` | `stdio`, `http` |
|
||||
| OpenCode | `~/.config/opencode/opencode.json` or `<workspace>/opencode.json` (`.jsonc` is read when present) | `user`, `project` | `stdio`, `http` |
|
||||
|
||||
5. Implement skills.
|
||||
@@ -144,7 +141,6 @@ Current skill discovery roots are:
|
||||
| Claude | `~/.claude/skills` | `<workspace>/.claude/skills` | `/` | Also discovers Claude plugin skills from enabled plugin installs. Command skills live under `commands/`; markdown skills live under `skills/` and are scanned recursively. |
|
||||
| Codex | `~/.agents/skills`, `~/.codex/skills/.system`, `/etc/codex/skills` | `<workspace>/.agents/skills`, `path.dirname(workspacePath)/.agents/skills`, topmost git root `.agents/skills` | `$` | Overlapping roots are deduplicated before scanning. |
|
||||
| Cursor | `~/.cursor/skills` | `<workspace>/.cursor/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. |
|
||||
| Gemini | `~/.gemini/skills`, `~/.agents/skills` | `<workspace>/.gemini/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. |
|
||||
| OpenCode | `~/.config/opencode/skills`, `~/.claude/skills`, `~/.agents/skills` | Cwd-to-topmost-git-root `.opencode/skills`, `.claude/skills`, and `.agents/skills` | `/` | Reuses OpenCode, Claude, and Agents skill locations. Overlapping roots are deduplicated before scanning. |
|
||||
|
||||
Command forms currently used by the providers are:
|
||||
@@ -153,7 +149,6 @@ Command forms currently used by the providers are:
|
||||
- Claude plugin skills: `/plugin-name:skill-name`
|
||||
- Codex skills: `$skill-name`
|
||||
- Cursor skills: `/skill-name`
|
||||
- Gemini skills: `/skill-name`
|
||||
- OpenCode skills: `/skill-name`
|
||||
|
||||
6. Implement sessions.
|
||||
@@ -191,7 +186,6 @@ Current session sync roots are:
|
||||
| Claude | `~/.claude/projects/**/*.jsonl` | Uses `~/.claude/history.jsonl` for name lookup and the trailing `ai-title`, `last-prompt`, or `custom-title` entries for title recovery. |
|
||||
| Codex | `~/.codex/sessions/**/*.jsonl` | Uses `~/.codex/session_index.jsonl` for title lookup and the last `task_complete` message for a fallback title. |
|
||||
| Cursor | `~/.cursor/projects/**/*.jsonl` | Uses sibling `worker.log` to recover `workspacePath`, then derives the session title from the first user prompt. |
|
||||
| Gemini | `~/.gemini/tmp/**/*.jsonl` | Current full scans only index temp JSONL chat artifacts. Single-file sync also accepts legacy `.json` files. |
|
||||
| OpenCode | `~/.local/share/opencode/opencode.db` | Reads active sessions/messages/parts from OpenCode's shared SQLite database and stores `jsonl_path` as `null` so deleting one app session cannot remove the shared DB. |
|
||||
|
||||
8. Register the provider.
|
||||
|
||||
@@ -313,6 +313,18 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
|
||||
if (raw.message?.role === 'user' && raw.message?.content && raw.isMeta !== true) {
|
||||
if (Array.isArray(raw.message.content)) {
|
||||
// Image attachments sent through the SDK are persisted as base64
|
||||
// `image` blocks next to the prompt text. Collect them so the UI can
|
||||
// render them on the user bubble.
|
||||
const imageAttachments: Array<{ data: string }> = [];
|
||||
for (const part of raw.message.content) {
|
||||
if (part?.type === 'image' && part.source?.type === 'base64' && typeof part.source.data === 'string') {
|
||||
const mediaType = typeof part.source.media_type === 'string' ? part.source.media_type : 'image/png';
|
||||
imageAttachments.push({ data: `data:${mediaType};base64,${part.source.data}` });
|
||||
}
|
||||
}
|
||||
let imagesAttached = false;
|
||||
|
||||
for (let partIndex = 0; partIndex < raw.message.content.length; partIndex++) {
|
||||
const part = raw.message.content[partIndex];
|
||||
if (part.type === 'tool_result') {
|
||||
@@ -339,7 +351,9 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: text,
|
||||
images: !imagesAttached && imageAttachments.length > 0 ? imageAttachments : undefined,
|
||||
}));
|
||||
imagesAttached = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,9 +373,25 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: textParts,
|
||||
images: imageAttachments.length > 0 ? imageAttachments : undefined,
|
||||
}));
|
||||
imagesAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Image-only turns still deserve a user bubble even without text.
|
||||
if (!imagesAttached && imageAttachments.length > 0) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_images`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: '',
|
||||
images: imageAttachments,
|
||||
}));
|
||||
}
|
||||
} else if (typeof raw.message.content === 'string') {
|
||||
const text = raw.message.content;
|
||||
|
||||
|
||||
@@ -133,7 +133,23 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
};
|
||||
}
|
||||
|
||||
let sessionName = nameMap.get(parsed.sessionId);
|
||||
// Sessions started by sending a message from cloudcli carry a distinct
|
||||
// app-allocated session_id mapped to the provider id. For these we title the
|
||||
// conversation from the first user message the user typed, instead of the
|
||||
// generic "Untitled Codex Session" placeholder. Sessions discovered purely
|
||||
// by indexing (session_id === provider_session_id) keep the existing
|
||||
// thread_name/last-agent-message setup below.
|
||||
const isAppCreated =
|
||||
existingSession != null &&
|
||||
existingSession.provider_session_id != null &&
|
||||
existingSession.session_id !== existingSession.provider_session_id;
|
||||
|
||||
let sessionName = isAppCreated
|
||||
? await this.extractFirstUserMessageFromStart(filePath)
|
||||
: undefined;
|
||||
if (!sessionName) {
|
||||
sessionName = nameMap.get(parsed.sessionId);
|
||||
}
|
||||
if (!sessionName) {
|
||||
sessionName = await this.extractLastAgentMessageFromEnd(filePath);
|
||||
}
|
||||
@@ -144,6 +160,49 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first user message text in a Codex transcript, used to title
|
||||
* app-created sessions from the prompt the user sent from cloudcli.
|
||||
*
|
||||
* Reads the `event_msg`/`user_message` payload rather than the raw
|
||||
* `response_item` user turn so injected `<environment_context>` boilerplate is
|
||||
* never mistaken for the user's prompt.
|
||||
*/
|
||||
private async extractFirstUserMessageFromStart(filePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = parsed as Record<string, unknown>;
|
||||
const eventType = typeof data.type === 'string' ? data.type : undefined;
|
||||
const payload = data.payload as Record<string, unknown> | undefined;
|
||||
const payloadType = typeof payload?.type === 'string' ? payload.type : undefined;
|
||||
const message = typeof payload?.message === 'string' ? payload.message : undefined;
|
||||
|
||||
if (eventType === 'event_msg' && payloadType === 'user_message' && message?.trim()) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing/unreadable files so sync can continue.
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async extractLastAgentMessageFromEnd(filePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
|
||||
@@ -2,6 +2,7 @@ import fsSync from 'node:fs';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import { toImageAttachments } from '@/shared/image-attachments.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
|
||||
@@ -31,6 +32,42 @@ function isVisibleCodexUserMessage(payload: AnyRecord | null | undefined): boole
|
||||
return typeof payload.message === 'string' && payload.message.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the image attachments Codex records on `user_message` events.
|
||||
* Turns sent with `local_image` input items land in `local_images` as file
|
||||
* paths (verified against real rollout JSONL); the `images` array can carry
|
||||
* base64 data URLs, which are passed through as inline `data` attachments so
|
||||
* the UI can preview them without a file lookup.
|
||||
*
|
||||
* Exported for tests.
|
||||
*/
|
||||
export function extractCodexUserImages(
|
||||
payload: AnyRecord | null | undefined,
|
||||
): Array<{ path?: string; data?: string }> | undefined {
|
||||
if (!payload) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
...(Array.isArray(payload.local_images) ? payload.local_images : []),
|
||||
...(Array.isArray(payload.images) ? payload.images : []),
|
||||
];
|
||||
|
||||
const attachments: Array<{ path?: string; data?: string }> = [];
|
||||
for (const entry of candidates) {
|
||||
if (typeof entry !== 'string' || !entry.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (entry.startsWith('data:')) {
|
||||
attachments.push({ data: entry });
|
||||
} else {
|
||||
attachments.push(...toImageAttachments([entry]));
|
||||
}
|
||||
}
|
||||
|
||||
return attachments.length > 0 ? attachments : undefined;
|
||||
}
|
||||
|
||||
function extractCodexTextContent(content: unknown): string {
|
||||
if (!Array.isArray(content)) {
|
||||
return typeof content === 'string' ? content : '';
|
||||
@@ -104,6 +141,7 @@ async function getCodexSessionMessages(
|
||||
role: 'user',
|
||||
content: entry.payload.message,
|
||||
},
|
||||
images: extractCodexUserImages(entry.payload as AnyRecord),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -296,7 +334,8 @@ export class CodexSessionsProvider implements IProviderSessions {
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: String(raw.message.content || '');
|
||||
if (!content.trim()) {
|
||||
const rawImages = Array.isArray(raw.images) && raw.images.length > 0 ? raw.images : undefined;
|
||||
if (!content.trim() && !rawImages) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
@@ -307,6 +346,7 @@ export class CodexSessionsProvider implements IProviderSessions {
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content,
|
||||
images: rawImages,
|
||||
})];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { access, readdir } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import crossSpawn from 'cross-spawn';
|
||||
|
||||
@@ -446,11 +445,6 @@ export const CURSOR_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
label: "gpt-5.2-xhigh-fast",
|
||||
description: "GPT-5.2 Extra High Fast",
|
||||
},
|
||||
{
|
||||
value: "gemini-3.1-pro",
|
||||
label: "gemini-3.1-pro",
|
||||
description: "Gemini 3.1 Pro",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-mini-none",
|
||||
label: "gpt-5.4-mini-none",
|
||||
@@ -531,16 +525,6 @@ export const CURSOR_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
label: "gpt-5.1-high",
|
||||
description: "GPT-5.1 High",
|
||||
},
|
||||
{
|
||||
value: "gemini-3-flash",
|
||||
label: "gemini-3-flash",
|
||||
description: "Gemini 3 Flash",
|
||||
},
|
||||
{
|
||||
value: "gemini-3.5-flash",
|
||||
label: "gemini-3.5-flash",
|
||||
description: "Gemini 3.5 Flash",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-mini-low",
|
||||
label: "gpt-5.1-codex-mini-low",
|
||||
@@ -589,7 +573,9 @@ type CursorModelRow = {
|
||||
|
||||
const CURSOR_MODELS_TIMEOUT_MS = 10_000;
|
||||
const CURSOR_CHATS_ROOT = path.join(os.homedir(), '.cursor', 'chats');
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to
|
||||
// child_process.spawn everywhere else.
|
||||
const spawnFunction = crossSpawn;
|
||||
const ANSI_PATTERN = new RegExp(
|
||||
// eslint-disable-next-line no-control-regex
|
||||
'[\\u001B\\u009B][[\\]()#;?]*(?:'
|
||||
|
||||
@@ -141,7 +141,13 @@ export class CursorSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
}
|
||||
|
||||
const text = typeof data.message?.content?.[0]?.text === 'string' ? data.message.content[0].text : '';
|
||||
const firstLine = text.replace(/<\/?user_query>/g, '').trim().split('\n')[0];
|
||||
// Drop Cursor's `<timestamp>…</timestamp>` prefix and `<user_query>` tags
|
||||
// so the session name comes from the actual first line the user typed.
|
||||
const firstLine = text
|
||||
.replace(/<timestamp>[\s\S]*?<\/timestamp>/g, '')
|
||||
.replace(/<\/?user_query>/g, '')
|
||||
.trim()
|
||||
.split('\n')[0];
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
|
||||
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { parseImagesInputTag } from '@/shared/image-attachments.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import {
|
||||
@@ -24,7 +25,7 @@ type CursorJsonBlob = CursorDbBlob & {
|
||||
parsed: AnyRecord;
|
||||
};
|
||||
|
||||
type CursorMessageBlob = {
|
||||
export type CursorMessageBlob = {
|
||||
id: string;
|
||||
sequence: number;
|
||||
rowid: number;
|
||||
@@ -59,17 +60,42 @@ function unwrapUserQueryText(value: string, role: 'user' | 'assistant'): string
|
||||
return value;
|
||||
}
|
||||
|
||||
const normalized = value.trimStart();
|
||||
// Cursor wraps user turns as `<timestamp>…</timestamp>\n<user_query>…</user_query>`.
|
||||
// Show only the `<user_query>` content, trimmed so there are no blank lines
|
||||
// at the top/bottom and the `<timestamp>` prefix is dropped entirely.
|
||||
const openTag = '<user_query>';
|
||||
const closeTag = '</user_query>';
|
||||
if (!normalized.startsWith(openTag)) {
|
||||
return value;
|
||||
const openIndex = value.indexOf(openTag);
|
||||
if (openIndex >= 0) {
|
||||
const afterOpen = value.slice(openIndex + openTag.length);
|
||||
const closeIndex = afterOpen.lastIndexOf(closeTag);
|
||||
const inner = closeIndex >= 0 ? afterOpen.slice(0, closeIndex) : afterOpen;
|
||||
return inner.trim();
|
||||
}
|
||||
|
||||
const afterOpen = normalized.slice(openTag.length);
|
||||
const closeIndex = afterOpen.lastIndexOf(closeTag);
|
||||
const inner = closeIndex >= 0 ? afterOpen.slice(0, closeIndex) : afterOpen;
|
||||
return inner.trim();
|
||||
// No `<user_query>` wrapper: still strip a leading `<timestamp>…</timestamp>`.
|
||||
return value.replace(/^\s*<timestamp>[\s\S]*?<\/timestamp>\s*/, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps one user-authored text payload and splits off the `<images_input>`
|
||||
* attachment block appended by the chat composer. Assistant text passes
|
||||
* through untouched.
|
||||
*/
|
||||
function extractUserTextAndImages(
|
||||
value: string,
|
||||
role: 'user' | 'assistant',
|
||||
): { text: string; images?: Array<{ path: string; name?: string }> } {
|
||||
const unwrapped = unwrapUserQueryText(value, role);
|
||||
if (role !== 'user') {
|
||||
return { text: unwrapped };
|
||||
}
|
||||
|
||||
const { text, attachments } = parseImagesInputTag(unwrapped);
|
||||
return {
|
||||
text,
|
||||
images: attachments.length > 0 ? attachments : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeToolId(value: unknown): string | null {
|
||||
@@ -401,8 +427,11 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
/**
|
||||
* Converts Cursor SQLite message blobs into normalized messages and attaches
|
||||
* matching tool results to their tool_use entries.
|
||||
*
|
||||
* Public so tests can drive history normalization with synthetic blobs
|
||||
* without needing a real Cursor store.db.
|
||||
*/
|
||||
private normalizeCursorBlobs(blobs: CursorMessageBlob[], sessionId: string | null): NormalizedMessage[] {
|
||||
normalizeCursorBlobs(blobs: CursorMessageBlob[], sessionId: string | null): NormalizedMessage[] {
|
||||
const messages: NormalizedMessage[] = [];
|
||||
const toolUseMap = new Map<string, NormalizedMessage>();
|
||||
const baseTime = Date.now();
|
||||
@@ -442,7 +471,16 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
text = unwrapUserQueryText(content.message.content, role);
|
||||
}
|
||||
}
|
||||
if (text?.trim()) {
|
||||
const { text: cleanText, images } = role === 'user'
|
||||
? (() => {
|
||||
const parsed = parseImagesInputTag(text);
|
||||
return {
|
||||
text: parsed.text,
|
||||
images: parsed.attachments.length > 0 ? parsed.attachments : undefined,
|
||||
};
|
||||
})()
|
||||
: { text, images: undefined };
|
||||
if (cleanText?.trim() || images) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
@@ -450,7 +488,8 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: text,
|
||||
content: cleanText,
|
||||
images,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
@@ -502,8 +541,8 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
|
||||
if (part?.type === 'text' && part?.text) {
|
||||
const normalizedPartText = unwrapUserQueryText(part.text, role);
|
||||
if (!normalizedPartText) {
|
||||
const { text: normalizedPartText, images } = extractUserTextAndImages(part.text, role);
|
||||
if (!normalizedPartText && !images) {
|
||||
continue;
|
||||
}
|
||||
messages.push(createNormalizedMessage({
|
||||
@@ -514,6 +553,7 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
kind: 'text',
|
||||
role,
|
||||
content: normalizedPartText,
|
||||
images,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
@@ -553,8 +593,8 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
&& content.content.trim()
|
||||
&& !isInternalCursorText(content.content)
|
||||
) {
|
||||
const normalizedText = unwrapUserQueryText(content.content, role);
|
||||
if (!normalizedText) {
|
||||
const { text: normalizedText, images } = extractUserTextAndImages(content.content, role);
|
||||
if (!normalizedText && !images) {
|
||||
continue;
|
||||
}
|
||||
messages.push(createNormalizedMessage({
|
||||
@@ -565,6 +605,7 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
kind: 'text',
|
||||
role,
|
||||
content: normalizedText,
|
||||
images,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||
|
||||
type GeminiCredentialsStatus = {
|
||||
authenticated: boolean;
|
||||
email: string | null;
|
||||
method: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type GeminiAuthType =
|
||||
| 'oauth-personal'
|
||||
| 'gemini-api-key'
|
||||
| 'vertex-ai'
|
||||
| 'compute-default-credentials'
|
||||
| 'gateway'
|
||||
| 'cloud-shell'
|
||||
| null;
|
||||
|
||||
export class GeminiProviderAuth implements IProviderAuth {
|
||||
/**
|
||||
* Gemini CLI can override its home root via GEMINI_CLI_HOME.
|
||||
* Use the same resolution so status checks match runtime behavior.
|
||||
*/
|
||||
private getGeminiCliHome(): string {
|
||||
return process.env.GEMINI_CLI_HOME?.trim() || os.homedir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the Gemini CLI is available on this host.
|
||||
*/
|
||||
private checkInstalled(): boolean {
|
||||
const cliPath = process.env.GEMINI_PATH || 'gemini';
|
||||
try {
|
||||
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Gemini CLI installation and credential status.
|
||||
*/
|
||||
async getStatus(): Promise<ProviderAuthStatus> {
|
||||
const installed = this.checkInstalled();
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
installed,
|
||||
provider: 'gemini',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Gemini CLI is not installed',
|
||||
};
|
||||
}
|
||||
|
||||
const credentials = await this.checkCredentials();
|
||||
|
||||
return {
|
||||
installed,
|
||||
provider: 'gemini',
|
||||
authenticated: credentials.authenticated,
|
||||
email: credentials.email,
|
||||
method: credentials.method,
|
||||
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses dotenv-style key/value pairs.
|
||||
*/
|
||||
private parseEnvFile(content: string): Record<string, string> {
|
||||
const parsed: Record<string, string> = {};
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedLine = line.startsWith('export ')
|
||||
? line.slice('export '.length).trim()
|
||||
: line;
|
||||
const separatorIndex = normalizedLine.indexOf('=');
|
||||
if (separatorIndex <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = normalizedLine.slice(0, separatorIndex).trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = normalizedLine.slice(separatorIndex + 1).trim();
|
||||
const quoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''));
|
||||
if (quoted) {
|
||||
value = value.slice(1, -1);
|
||||
} else {
|
||||
value = value.replace(/\s+#.*$/, '').trim();
|
||||
}
|
||||
|
||||
parsed[key] = value;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads user-level auth env in Gemini's "first file found" order.
|
||||
*/
|
||||
private async loadUserLevelAuthEnv(): Promise<Record<string, string>> {
|
||||
const geminiCliHome = this.getGeminiCliHome();
|
||||
const envCandidates = [
|
||||
path.join(geminiCliHome, '.gemini', '.env'),
|
||||
path.join(geminiCliHome, '.env'),
|
||||
];
|
||||
|
||||
for (const envPath of envCandidates) {
|
||||
try {
|
||||
const content = await readFile(envPath, 'utf8');
|
||||
return this.parseEnvFile(content);
|
||||
} catch {
|
||||
// Continue to the next fallback.
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Gemini's selected auth type from settings.json when available.
|
||||
*/
|
||||
private async readSelectedAuthType(): Promise<GeminiAuthType> {
|
||||
try {
|
||||
const settingsPath = path.join(this.getGeminiCliHome(), '.gemini', 'settings.json');
|
||||
const content = await readFile(settingsPath, 'utf8');
|
||||
const settings = readObjectRecord(JSON.parse(content));
|
||||
const security = readObjectRecord(settings?.security);
|
||||
const auth = readObjectRecord(security?.auth);
|
||||
const selectedType = readOptionalString(auth?.selectedType);
|
||||
if (!selectedType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return selectedType as GeminiAuthType;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Gemini credentials from API key env vars or local OAuth credential files.
|
||||
*/
|
||||
private async checkCredentials(): Promise<GeminiCredentialsStatus> {
|
||||
if (process.env.GEMINI_API_KEY?.trim()) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
const userEnv = await this.loadUserLevelAuthEnv();
|
||||
if (readOptionalString(userEnv.GEMINI_API_KEY)) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
const selectedType = await this.readSelectedAuthType();
|
||||
if (selectedType === 'vertex-ai') {
|
||||
const hasGoogleApiKey = Boolean(
|
||||
process.env.GOOGLE_API_KEY?.trim()
|
||||
|| readOptionalString(userEnv.GOOGLE_API_KEY)
|
||||
);
|
||||
const hasProject = Boolean(
|
||||
process.env.GOOGLE_CLOUD_PROJECT?.trim()
|
||||
|| process.env.GOOGLE_CLOUD_PROJECT_ID?.trim()
|
||||
|| readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT)
|
||||
|| readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT_ID)
|
||||
);
|
||||
const hasLocation = Boolean(
|
||||
process.env.GOOGLE_CLOUD_LOCATION?.trim()
|
||||
|| readOptionalString(userEnv.GOOGLE_CLOUD_LOCATION)
|
||||
);
|
||||
const hasServiceAccount = Boolean(
|
||||
process.env.GOOGLE_APPLICATION_CREDENTIALS?.trim()
|
||||
|| readOptionalString(userEnv.GOOGLE_APPLICATION_CREDENTIALS)
|
||||
);
|
||||
|
||||
if (hasGoogleApiKey || hasServiceAccount || (hasProject && hasLocation)) {
|
||||
return { authenticated: true, email: 'Vertex AI Auth', method: 'vertex_ai' };
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: 'vertex_ai',
|
||||
error: 'Gemini is set to Vertex AI, but required env vars are missing',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const credsPath = path.join(this.getGeminiCliHome(), '.gemini', 'oauth_creds.json');
|
||||
const content = await readFile(credsPath, 'utf8');
|
||||
const creds = readObjectRecord(JSON.parse(content)) ?? {};
|
||||
const accessToken = readOptionalString(creds.access_token);
|
||||
|
||||
if (!accessToken) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'No valid tokens found in oauth_creds',
|
||||
};
|
||||
}
|
||||
|
||||
const refreshToken = readOptionalString(creds.refresh_token);
|
||||
const tokenInfo = await this.getTokenInfoEmail(accessToken);
|
||||
if (tokenInfo.valid) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: tokenInfo.email || 'OAuth Session',
|
||||
method: 'credentials_file',
|
||||
};
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: 'credentials_file',
|
||||
error: 'Access token invalid and no refresh token found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
email: await this.getActiveAccountEmail() || 'OAuth Session',
|
||||
method: 'credentials_file',
|
||||
};
|
||||
} catch {
|
||||
if (selectedType === 'gemini-api-key') {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: 'api_key',
|
||||
error: 'Gemini is set to "Use Gemini API key", but GEMINI_API_KEY is unavailable',
|
||||
};
|
||||
}
|
||||
|
||||
if (selectedType === 'oauth-personal') {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: 'credentials_file',
|
||||
error: 'Gemini is set to Google sign-in, but no cached OAuth credentials were found',
|
||||
};
|
||||
}
|
||||
|
||||
// If no explicit auth type was selected, surface the generic "not configured" error.
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Gemini CLI not configured',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Gemini OAuth access token and returns an email when Google reports one.
|
||||
*/
|
||||
private async getTokenInfoEmail(accessToken: string): Promise<{ valid: boolean; email: string | null }> {
|
||||
try {
|
||||
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${accessToken}`);
|
||||
if (!tokenRes.ok) {
|
||||
return { valid: false, email: null };
|
||||
}
|
||||
|
||||
const tokenInfo = readObjectRecord(await tokenRes.json());
|
||||
return {
|
||||
valid: true,
|
||||
email: readOptionalString(tokenInfo?.email) ?? null,
|
||||
};
|
||||
} catch {
|
||||
return { valid: false, email: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Gemini's active local Google account as an offline fallback for display.
|
||||
*/
|
||||
private async getActiveAccountEmail(): Promise<string | null> {
|
||||
try {
|
||||
const accPath = path.join(this.getGeminiCliHome(), '.gemini', 'google_accounts.json');
|
||||
const accContent = await readFile(accPath, 'utf8');
|
||||
const accounts = readObjectRecord(JSON.parse(accContent));
|
||||
return readOptionalString(accounts?.active) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
|
||||
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import {
|
||||
AppError,
|
||||
readJsonConfig,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
readStringArray,
|
||||
readStringRecord,
|
||||
writeJsonConfig,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export class GeminiMcpProvider extends McpProvider {
|
||||
constructor() {
|
||||
super('gemini', ['user', 'project'], ['stdio', 'http', 'sse']);
|
||||
}
|
||||
|
||||
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.gemini', 'settings.json')
|
||||
: path.join(workspacePath, '.gemini', 'settings.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
return readObjectRecord(config.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
protected async writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.gemini', 'settings.json')
|
||||
: path.join(workspacePath, '.gemini', 'settings.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
config.mcpServers = servers;
|
||||
await writeJsonConfig(filePath, config);
|
||||
}
|
||||
|
||||
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||
if (input.transport === 'stdio') {
|
||||
if (!input.command?.trim()) {
|
||||
throw new AppError('command is required for stdio MCP servers.', {
|
||||
code: 'MCP_COMMAND_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
command: input.command,
|
||||
args: input.args ?? [],
|
||||
env: input.env ?? {},
|
||||
cwd: input.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.url?.trim()) {
|
||||
throw new AppError('url is required for http/sse MCP servers.', {
|
||||
code: 'MCP_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: input.transport,
|
||||
url: input.url,
|
||||
headers: input.headers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
protected normalizeServerConfig(
|
||||
scope: McpScope,
|
||||
name: string,
|
||||
rawConfig: unknown,
|
||||
): ProviderMcpServer | null {
|
||||
if (!rawConfig || typeof rawConfig !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = rawConfig as Record<string, unknown>;
|
||||
if (typeof config.command === 'string') {
|
||||
return {
|
||||
provider: 'gemini',
|
||||
name,
|
||||
scope,
|
||||
transport: 'stdio',
|
||||
command: config.command,
|
||||
args: readStringArray(config.args),
|
||||
env: readStringRecord(config.env),
|
||||
cwd: readOptionalString(config.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof config.url === 'string') {
|
||||
const transport = readOptionalString(config.type) === 'sse' ? 'sse' : 'http';
|
||||
return {
|
||||
provider: 'gemini',
|
||||
name,
|
||||
scope,
|
||||
transport,
|
||||
url: config.url,
|
||||
headers: readStringRecord(config.headers),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import {
|
||||
buildDefaultProviderCurrentActiveModel,
|
||||
writeProviderSessionActiveModelChange,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
|
||||
{ value: 'gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash Lite Preview' },
|
||||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||
{ value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
|
||||
{ value: 'gemma-4-31b-it', label: 'Gemma 4 31B IT' },
|
||||
{ value: 'gemma-4-26b-a4b-it', label: 'Gemma 4 26B A4B IT' },
|
||||
],
|
||||
DEFAULT: 'gemini-3-flash-preview',
|
||||
};
|
||||
|
||||
export class GeminiProviderModels implements IProviderModels {
|
||||
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||
return GEMINI_FALLBACK_MODELS;
|
||||
}
|
||||
|
||||
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
|
||||
return buildDefaultProviderCurrentActiveModel(GEMINI_FALLBACK_MODELS);
|
||||
}
|
||||
|
||||
async changeActiveModel(
|
||||
input: ProviderChangeActiveModelInput,
|
||||
): Promise<ProviderSessionActiveModelChange> {
|
||||
return writeProviderSessionActiveModelChange('gemini', input);
|
||||
}
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||
import {
|
||||
findFilesRecursivelyCreatedAfter,
|
||||
normalizeProjectPath,
|
||||
normalizeSessionName,
|
||||
readFileTimestamps,
|
||||
} from '@/shared/utils.js';
|
||||
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord } from '@/shared/types.js';
|
||||
|
||||
type ParsedSession = {
|
||||
sessionId: string;
|
||||
projectPath: string;
|
||||
sessionName?: string;
|
||||
};
|
||||
|
||||
type GeminiJsonlMetadata = {
|
||||
sessionId: string;
|
||||
projectPath?: string;
|
||||
projectHash?: string;
|
||||
firstUserMessage?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Session indexer for Gemini transcript artifacts.
|
||||
*/
|
||||
export class GeminiSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
private readonly provider = 'gemini' as const;
|
||||
private readonly geminiHome = path.join(os.homedir(), '.gemini');
|
||||
|
||||
/**
|
||||
* Scans Gemini legacy JSON and new JSONL artifacts and upserts sessions into DB.
|
||||
*/
|
||||
async synchronize(since?: Date): Promise<number> {
|
||||
const projectHashLookup = this.buildProjectHashLookup();
|
||||
|
||||
// const legacySessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||
// path.join(this.geminiHome, 'sessions'),
|
||||
// '.json',
|
||||
// since ?? null
|
||||
// );
|
||||
// Gemini creates overlapping artifacts across `sessions/` and `tmp/`.
|
||||
// We currently index only `tmp/*/chats/*.jsonl` because those files are the
|
||||
// live transcript source and avoid duplicate session rows from mirrored files.
|
||||
// const legacyTempFiles = await findFilesRecursivelyCreatedAfter(
|
||||
// path.join(this.geminiHome, 'tmp'),
|
||||
// '.json',
|
||||
// since ?? null
|
||||
// );
|
||||
// const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||
// path.join(this.geminiHome, 'sessions'),
|
||||
// '.jsonl',
|
||||
// since ?? null
|
||||
// );
|
||||
const jsonlTempFiles = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(this.geminiHome, 'tmp'),
|
||||
'.jsonl',
|
||||
since ?? null
|
||||
);
|
||||
|
||||
// Current strategy: index only temp chat JSONL artifacts.
|
||||
const files = [
|
||||
// ...legacySessionFiles,
|
||||
// Intentionally disabled to avoid duplicate indexing from mirrored
|
||||
// `sessions/*.json` and `sessions/*.jsonl` artifacts.
|
||||
// ...legacyTempFiles,
|
||||
// ...jsonlSessionFiles,
|
||||
...jsonlTempFiles,
|
||||
];
|
||||
|
||||
let processed = 0;
|
||||
for (const filePath of files) {
|
||||
if (this.shouldSkipTempArtifact(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = filePath.endsWith('.jsonl')
|
||||
? await this.processJsonlSessionFile(filePath, projectHashLookup)
|
||||
: await this.processLegacySessionFile(filePath);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamps = await readFileTimestamps(filePath);
|
||||
sessionsDb.createSession(
|
||||
parsed.sessionId,
|
||||
this.provider,
|
||||
parsed.projectPath,
|
||||
parsed.sessionName,
|
||||
timestamps.createdAt,
|
||||
timestamps.updatedAt,
|
||||
filePath
|
||||
);
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and upserts one Gemini legacy JSON or JSONL artifact.
|
||||
*/
|
||||
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||
if (!filePath.endsWith('.json') && !filePath.endsWith('.jsonl')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.shouldSkipTempArtifact(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = filePath.endsWith('.jsonl')
|
||||
? await this.processJsonlSessionFile(filePath, this.buildProjectHashLookup())
|
||||
: await this.processLegacySessionFile(filePath);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamps = await readFileTimestamps(filePath);
|
||||
return sessionsDb.createSession(
|
||||
parsed.sessionId,
|
||||
this.provider,
|
||||
parsed.projectPath,
|
||||
parsed.sessionName,
|
||||
timestamps.createdAt,
|
||||
timestamps.updatedAt,
|
||||
filePath
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts session metadata from one Gemini legacy JSON artifact.
|
||||
*/
|
||||
private async processLegacySessionFile(filePath: string): Promise<ParsedSession | null> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const data = JSON.parse(content) as AnyRecord;
|
||||
|
||||
const sessionId =
|
||||
typeof data.sessionId === 'string'
|
||||
? data.sessionId
|
||||
: typeof data.id === 'string'
|
||||
? data.id
|
||||
: undefined;
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceProjectPath = await this.resolveProjectPathFromChatWorkspace(filePath);
|
||||
const projectPath = typeof data.projectPath === 'string' && data.projectPath.trim().length > 0
|
||||
? data.projectPath
|
||||
: workspaceProjectPath;
|
||||
if (!projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messages = Array.isArray(data.messages) ? data.messages : [];
|
||||
const firstMessage = messages[0] as AnyRecord | undefined;
|
||||
let rawName: string | undefined;
|
||||
|
||||
if (Array.isArray(firstMessage?.content) && typeof firstMessage.content[0]?.text === 'string') {
|
||||
rawName = firstMessage.content[0].text;
|
||||
} else if (typeof firstMessage?.content === 'string') {
|
||||
rawName = firstMessage.content;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
projectPath,
|
||||
sessionName: normalizeSessionName(rawName, 'New Gemini Chat'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts session metadata from one Gemini JSONL artifact.
|
||||
*/
|
||||
private async processJsonlSessionFile(
|
||||
filePath: string,
|
||||
projectHashLookup: Map<string, string>
|
||||
): Promise<ParsedSession | null> {
|
||||
const metadata = await this.extractJsonlMetadata(filePath);
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let projectPath = typeof metadata.projectPath === 'string' ? metadata.projectPath.trim() : '';
|
||||
if (!projectPath) {
|
||||
const workspaceProjectPath = await this.resolveProjectPathFromChatWorkspace(filePath);
|
||||
if (workspaceProjectPath) {
|
||||
projectPath = workspaceProjectPath;
|
||||
}
|
||||
}
|
||||
if (!projectPath && typeof metadata.projectHash === 'string') {
|
||||
projectPath = projectHashLookup.get(metadata.projectHash.trim().toLowerCase()) ?? '';
|
||||
}
|
||||
if (!projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Once we resolve a project hash/path pair, keep it in-memory for this sync run.
|
||||
if (typeof metadata.projectHash === 'string' && metadata.projectHash.trim()) {
|
||||
projectHashLookup.set(metadata.projectHash.trim().toLowerCase(), projectPath);
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: metadata.sessionId,
|
||||
projectPath,
|
||||
sessionName: normalizeSessionName(metadata.firstUserMessage, 'New Gemini Chat'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads first useful metadata from Gemini JSONL files.
|
||||
*/
|
||||
private async extractJsonlMetadata(filePath: string): Promise<GeminiJsonlMetadata | null> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
let sessionId: string | undefined;
|
||||
let projectPath: string | undefined;
|
||||
let projectHash: string | undefined;
|
||||
let firstUserMessage: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: AnyRecord;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as AnyRecord;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sessionId && typeof parsed.sessionId === 'string') {
|
||||
sessionId = parsed.sessionId;
|
||||
}
|
||||
if (!projectPath && typeof parsed.projectPath === 'string') {
|
||||
projectPath = parsed.projectPath;
|
||||
}
|
||||
if (!projectHash && typeof parsed.projectHash === 'string') {
|
||||
projectHash = parsed.projectHash;
|
||||
}
|
||||
|
||||
if (!firstUserMessage && parsed.type === 'user') {
|
||||
firstUserMessage = this.extractGeminiTextContent(parsed.content);
|
||||
}
|
||||
|
||||
if (sessionId && (projectPath || projectHash) && firstUserMessage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
projectPath,
|
||||
projectHash,
|
||||
firstUserMessage,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to resolve project root from Gemini tmp chat workspaces.
|
||||
*/
|
||||
private async resolveProjectPathFromChatWorkspace(filePath: string): Promise<string> {
|
||||
if (!filePath.includes(`${path.sep}chats${path.sep}`)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const chatsDir = path.dirname(filePath);
|
||||
const workspaceDir = path.dirname(chatsDir);
|
||||
const projectRootPath = path.join(workspaceDir, '.project_root');
|
||||
|
||||
try {
|
||||
const rootContent = await readFile(projectRootPath, 'utf8');
|
||||
return rootContent.trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a hash->path lookup for Gemini JSONL metadata that stores projectHash.
|
||||
*/
|
||||
private buildProjectHashLookup(): Map<string, string> {
|
||||
const lookup = new Map<string, string>();
|
||||
const knownPaths = new Set<string>();
|
||||
|
||||
for (const project of projectsDb.getProjectPaths()) {
|
||||
if (typeof project.project_path === 'string' && project.project_path.trim()) {
|
||||
knownPaths.add(project.project_path.trim());
|
||||
}
|
||||
}
|
||||
|
||||
for (const session of sessionsDb.getAllSessions()) {
|
||||
if (session.provider === this.provider && typeof session.project_path === 'string' && session.project_path.trim()) {
|
||||
knownPaths.add(session.project_path.trim());
|
||||
}
|
||||
}
|
||||
|
||||
for (const knownPath of knownPaths) {
|
||||
this.addProjectHashCandidates(lookup, knownPath);
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds likely Gemini hash variants for one project path.
|
||||
*/
|
||||
private addProjectHashCandidates(lookup: Map<string, string>, projectPath: string): void {
|
||||
const trimmed = projectPath.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeProjectPath(trimmed);
|
||||
const resolved = path.resolve(trimmed);
|
||||
const resolvedNormalized = normalizeProjectPath(resolved);
|
||||
|
||||
const candidates = new Set<string>([
|
||||
trimmed,
|
||||
normalized,
|
||||
resolved,
|
||||
resolvedNormalized,
|
||||
]);
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
for (const candidate of [...candidates]) {
|
||||
candidates.add(candidate.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hash = this.sha256(candidate);
|
||||
if (!lookup.has(hash)) {
|
||||
lookup.set(hash, trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns first user text from Gemini content payload shapes.
|
||||
*/
|
||||
private extractGeminiTextContent(content: unknown): string | undefined {
|
||||
if (typeof content === 'string' && content.trim().length > 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const part of content) {
|
||||
if (typeof part === 'string' && part.trim().length > 0) {
|
||||
return part;
|
||||
}
|
||||
|
||||
if (part && typeof part === 'object' && typeof (part as AnyRecord).text === 'string') {
|
||||
const text = (part as AnyRecord).text;
|
||||
if (text.trim().length > 0) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps tmp scanning scoped to chat artifacts only.
|
||||
*/
|
||||
private shouldSkipTempArtifact(filePath: string): boolean {
|
||||
return (
|
||||
filePath.startsWith(path.join(this.geminiHome, 'tmp'))
|
||||
&& !filePath.includes(`${path.sep}chats${path.sep}`)
|
||||
);
|
||||
}
|
||||
|
||||
private sha256(value: string): string {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -1,540 +0,0 @@
|
||||
import fsSync from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'gemini';
|
||||
|
||||
type GeminiHistoryResult = {
|
||||
messages: AnyRecord[];
|
||||
tokenUsage?: unknown;
|
||||
};
|
||||
|
||||
function mapGeminiRole(value: unknown): 'user' | 'assistant' | null {
|
||||
if (value === 'user') {
|
||||
return 'user';
|
||||
}
|
||||
|
||||
if (value === 'gemini' || value === 'assistant') {
|
||||
return 'assistant';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractGeminiTextContent(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return content
|
||||
.map((part) => {
|
||||
if (typeof part === 'string') {
|
||||
return part;
|
||||
}
|
||||
if (!part || typeof part !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const record = part as AnyRecord;
|
||||
if (typeof record.text === 'string') {
|
||||
return record.text;
|
||||
}
|
||||
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function extractGeminiThoughts(thoughts: unknown): string {
|
||||
if (!Array.isArray(thoughts)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return thoughts
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const record = item as AnyRecord;
|
||||
const subject = typeof record.subject === 'string' ? record.subject.trim() : '';
|
||||
const description = typeof record.description === 'string' ? record.description.trim() : '';
|
||||
|
||||
if (subject && description) {
|
||||
return `${subject}: ${description}`;
|
||||
}
|
||||
|
||||
return description || subject;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildGeminiTokenUsage(tokens: unknown): AnyRecord | undefined {
|
||||
if (!tokens || typeof tokens !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = tokens as AnyRecord;
|
||||
const input = Number(record.input || 0);
|
||||
const output = Number(record.output || 0);
|
||||
const total = Number(record.total || input + output || 0);
|
||||
|
||||
return {
|
||||
used: total,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
breakdown: {
|
||||
input,
|
||||
output,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getGeminiLegacySessionMessages(sessionFilePath: string): Promise<GeminiHistoryResult> {
|
||||
try {
|
||||
const data = await fs.readFile(sessionFilePath, 'utf8');
|
||||
const session = JSON.parse(data) as AnyRecord;
|
||||
const sourceMessages = Array.isArray(session.messages) ? session.messages : [];
|
||||
|
||||
const messages: AnyRecord[] = [];
|
||||
for (const msg of sourceMessages) {
|
||||
const role = mapGeminiRole(msg.type ?? msg.role);
|
||||
if (!role) {
|
||||
continue;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
type: 'message',
|
||||
uuid: typeof msg.id === 'string' ? msg.id : undefined,
|
||||
message: { role, content: msg.content },
|
||||
timestamp: msg.timestamp || null,
|
||||
});
|
||||
}
|
||||
|
||||
return { messages };
|
||||
} catch {
|
||||
return { messages: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function getGeminiJsonlSessionMessages(sessionFilePath: string): Promise<GeminiHistoryResult> {
|
||||
const messages: AnyRecord[] = [];
|
||||
let tokenUsage: AnyRecord | undefined;
|
||||
|
||||
try {
|
||||
const fileStream = fsSync.createReadStream(sessionFilePath);
|
||||
const lineReader = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
for await (const line of lineReader) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry: AnyRecord;
|
||||
try {
|
||||
entry = JSON.parse(trimmed) as AnyRecord;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Metadata/update lines (e.g. {$set:{lastUpdated:...}}) do not represent chat messages.
|
||||
if (entry.$set) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = mapGeminiRole(entry.type);
|
||||
if (role) {
|
||||
const textContent = extractGeminiTextContent(entry.content);
|
||||
if (textContent.trim()) {
|
||||
messages.push({
|
||||
type: 'message',
|
||||
uuid: typeof entry.id === 'string' ? entry.id : undefined,
|
||||
message: { role, content: textContent },
|
||||
timestamp: entry.timestamp || null,
|
||||
});
|
||||
}
|
||||
|
||||
const thinkingContent = extractGeminiThoughts(entry.thoughts);
|
||||
if (thinkingContent.trim()) {
|
||||
messages.push({
|
||||
type: 'thinking',
|
||||
uuid: typeof entry.id === 'string' ? `${entry.id}_thinking` : undefined,
|
||||
message: { role: 'assistant', content: thinkingContent },
|
||||
timestamp: entry.timestamp || null,
|
||||
isReasoning: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
const usage = buildGeminiTokenUsage(entry.tokens);
|
||||
if (usage) {
|
||||
tokenUsage = usage;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'tool_use') {
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
uuid: typeof entry.id === 'string' ? entry.id : undefined,
|
||||
timestamp: entry.timestamp || null,
|
||||
toolName: entry.tool_name || entry.name || 'Tool',
|
||||
toolInput: entry.parameters ?? entry.input ?? entry.arguments ?? '',
|
||||
toolCallId: entry.tool_id || entry.toolCallId || entry.id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'tool_result') {
|
||||
messages.push({
|
||||
type: 'tool_result',
|
||||
uuid: typeof entry.id === 'string' ? entry.id : undefined,
|
||||
timestamp: entry.timestamp || null,
|
||||
toolCallId: entry.tool_id || entry.toolCallId || entry.id || '',
|
||||
output: entry.output ?? entry.result ?? '',
|
||||
isError: Boolean(entry.error) || entry.status === 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return { messages: [] };
|
||||
}
|
||||
|
||||
messages.sort(
|
||||
(a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(),
|
||||
);
|
||||
|
||||
return { messages, tokenUsage };
|
||||
}
|
||||
|
||||
async function getGeminiCliSessionMessages(sessionId: string): Promise<GeminiHistoryResult> {
|
||||
const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
||||
if (!sessionFilePath) {
|
||||
return { messages: [] };
|
||||
}
|
||||
|
||||
if (sessionFilePath.endsWith('.jsonl')) {
|
||||
return getGeminiJsonlSessionMessages(sessionFilePath);
|
||||
}
|
||||
|
||||
return getGeminiLegacySessionMessages(sessionFilePath);
|
||||
}
|
||||
|
||||
export class GeminiSessionsProvider implements IProviderSessions {
|
||||
/**
|
||||
* Normalizes live Gemini stream-json events into the shared message shape.
|
||||
*
|
||||
* Gemini history uses a different session file shape, so fetchHistory handles
|
||||
* that separately after loading raw persisted messages.
|
||||
*/
|
||||
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
||||
const raw = readObjectRecord(rawMessage);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('gemini');
|
||||
|
||||
if (raw.type === 'message' && raw.role === 'assistant') {
|
||||
const content = raw.content || '';
|
||||
const messages: NormalizedMessage[] = [];
|
||||
if (content) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'stream_delta',
|
||||
content,
|
||||
}));
|
||||
}
|
||||
if (raw.delta !== true) {
|
||||
messages.push(createNormalizedMessage({
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'stream_end',
|
||||
}));
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_use') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: raw.tool_name,
|
||||
toolInput: raw.parameters || {},
|
||||
toolId: raw.tool_id || baseId,
|
||||
})];
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_result') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: raw.tool_id || '',
|
||||
content: raw.output === undefined ? '' : String(raw.output),
|
||||
isError: raw.status === 'error',
|
||||
})];
|
||||
}
|
||||
|
||||
if (raw.type === 'result') {
|
||||
const messages = [createNormalizedMessage({
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'stream_end',
|
||||
})];
|
||||
if (raw.stats?.total_tokens) {
|
||||
messages.push(createNormalizedMessage({
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'status',
|
||||
text: 'Complete',
|
||||
tokens: raw.stats.total_tokens,
|
||||
canInterrupt: false,
|
||||
}));
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (raw.type === 'error') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'error',
|
||||
content: raw.error || raw.message || 'Unknown Gemini streaming error',
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads Gemini history from Gemini CLI session files on disk.
|
||||
*/
|
||||
async fetchHistory(
|
||||
sessionId: string,
|
||||
options: FetchHistoryOptions = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
const { limit = null, offset = 0 } = options;
|
||||
|
||||
let result: GeminiHistoryResult;
|
||||
try {
|
||||
result = await getGeminiCliSessionMessages(sessionId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[GeminiProvider] Failed to load session ${sessionId}:`, message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
const rawMessages = result.messages;
|
||||
const normalized: NormalizedMessage[] = [];
|
||||
|
||||
for (let i = 0; i < rawMessages.length; i++) {
|
||||
const raw = rawMessages[i];
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('gemini');
|
||||
|
||||
if (raw.type === 'thinking' || raw.isReasoning) {
|
||||
const thinkingContent = typeof raw.message?.content === 'string'
|
||||
? raw.message.content
|
||||
: typeof raw.content === 'string'
|
||||
? raw.content
|
||||
: '';
|
||||
|
||||
if (thinkingContent.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: thinkingContent,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_use' || raw.toolName) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: raw.toolName || 'Tool',
|
||||
toolInput: raw.toolInput,
|
||||
toolId: raw.toolCallId || baseId,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_result') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: raw.toolCallId || '',
|
||||
content: raw.output === undefined ? '' : String(raw.output),
|
||||
isError: Boolean(raw.isError),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = raw.message?.role || raw.role;
|
||||
const content = raw.message?.content || raw.content;
|
||||
if (!role || !content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedRole = role === 'user' ? 'user' : 'assistant';
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
for (let partIdx = 0; partIdx < content.length; partIdx++) {
|
||||
const part = content[partIdx] as AnyRecord | string;
|
||||
|
||||
if (typeof part === 'string' && part.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content: part,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!part || typeof part !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((part.type === 'text' || !part.type) && typeof part.text === 'string' && part.text.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content: part.text,
|
||||
}));
|
||||
} else if (part.type === 'tool_use') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: part.name,
|
||||
toolInput: part.input,
|
||||
toolId: part.id || generateMessageId('gemini_tool'),
|
||||
}));
|
||||
} else if (part.type === 'tool_result') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: part.tool_use_id || '',
|
||||
content: part.content === undefined ? '' : String(part.content),
|
||||
isError: Boolean(part.is_error),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else if (typeof content === 'string' && content.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content,
|
||||
}));
|
||||
} else {
|
||||
const textContent = extractGeminiTextContent(content);
|
||||
if (textContent.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content: textContent,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toolResultMap = new Map<string, NormalizedMessage>();
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||
toolResultMap.set(msg.toolId, msg);
|
||||
}
|
||||
}
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||
const toolResult = toolResultMap.get(msg.toolId);
|
||||
if (toolResult) {
|
||||
msg.toolResult = { content: toolResult.content, isError: toolResult.isError };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const start = Math.max(0, offset);
|
||||
const pageLimit = limit === null ? null : Math.max(0, limit);
|
||||
// Tail pagination via the shared contract: offset 0 returns the most
|
||||
// recent page, matching every other provider.
|
||||
const { page, hasMore } = sliceTailPage(normalized, pageLimit, start);
|
||||
let total = 0;
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind !== 'tool_result') {
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: page,
|
||||
total,
|
||||
hasMore,
|
||||
offset: start,
|
||||
limit: pageLimit,
|
||||
tokenUsage: result.tokenUsage,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
||||
import type { ProviderSkillSource } from '@/shared/types.js';
|
||||
|
||||
export class GeminiSkillsProvider extends SkillsProvider {
|
||||
constructor() {
|
||||
super('gemini');
|
||||
}
|
||||
|
||||
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||
return [
|
||||
{
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.gemini', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
{
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.agents', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
{
|
||||
scope: 'project',
|
||||
rootDir: path.join(workspacePath, '.gemini', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
{
|
||||
scope: 'project',
|
||||
rootDir: path.join(workspacePath, '.agents', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
|
||||
return {
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.gemini', 'skills'),
|
||||
commandPrefix: '/',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth.provider.js';
|
||||
import { GeminiProviderModels } from '@/modules/providers/list/gemini/gemini-models.provider.js';
|
||||
import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js';
|
||||
import { GeminiSessionSynchronizer } from '@/modules/providers/list/gemini/gemini-session-synchronizer.provider.js';
|
||||
import { GeminiSessionsProvider } from '@/modules/providers/list/gemini/gemini-sessions.provider.js';
|
||||
import { GeminiSkillsProvider } from '@/modules/providers/list/gemini/gemini-skills.provider.js';
|
||||
import type {
|
||||
IProviderAuth,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
|
||||
export class GeminiProvider extends AbstractProvider {
|
||||
readonly models: IProviderModels = new GeminiProviderModels();
|
||||
readonly mcp = new GeminiMcpProvider();
|
||||
readonly auth: IProviderAuth = new GeminiProviderAuth();
|
||||
readonly skills: IProviderSkills = new GeminiSkillsProvider();
|
||||
readonly sessions: IProviderSessions = new GeminiSessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer = new GeminiSessionSynchronizer();
|
||||
|
||||
constructor() {
|
||||
super('gemini');
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ const OPENCODE_ENV_CREDENTIAL_KEYS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'GOOGLE_GENERATIVE_AI_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'GROQ_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
];
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import crossSpawn from 'cross-spawn';
|
||||
|
||||
@@ -51,23 +49,15 @@ export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
label: 'GPT-5.4 Mini',
|
||||
description: 'openai - openai/gpt-5.4-mini',
|
||||
},
|
||||
{
|
||||
value: 'google/gemini-2.5-pro',
|
||||
label: 'Gemini 2.5 Pro',
|
||||
description: 'google - google/gemini-2.5-pro',
|
||||
},
|
||||
{
|
||||
value: 'google/gemini-2.5-flash',
|
||||
label: 'Gemini 2.5 Flash',
|
||||
description: 'google - google/gemini-2.5-flash',
|
||||
},
|
||||
],
|
||||
DEFAULT: 'anthropic/claude-sonnet-4-5',
|
||||
};
|
||||
|
||||
const OPEN_CODE_MODELS_TIMEOUT_MS = 20_000;
|
||||
const MODEL_ID_LINE = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to
|
||||
// child_process.spawn everywhere else.
|
||||
const spawnFunction = crossSpawn;
|
||||
const DATE_TOKEN = /^\d{8}$/;
|
||||
const SIMPLE_NUMBER_TOKEN = /^\d$/;
|
||||
const VERSION_TOKEN = /^[a-z]\d+$/i;
|
||||
@@ -239,6 +229,10 @@ const readOpenCodeModelParts = (id: string): { upstreamProvider: string; slug: s
|
||||
};
|
||||
};
|
||||
|
||||
const isSupportedOpenCodeModelId = (id: string): boolean => (
|
||||
readOpenCodeModelParts(id).upstreamProvider.toLowerCase() !== 'google'
|
||||
);
|
||||
|
||||
const readOpenCodeVerboseModelId = (model: OpenCodeVerboseModel): string | null => {
|
||||
const id = readOptionalString(model.id);
|
||||
if (!id) {
|
||||
@@ -296,7 +290,7 @@ const readOpenCodeEffortValues = (
|
||||
|
||||
const mapOpenCodeVerboseModel = (model: OpenCodeVerboseModel): ProviderModelOption | null => {
|
||||
const value = readOpenCodeVerboseModelId(model);
|
||||
if (!value) {
|
||||
if (!value || !isSupportedOpenCodeModelId(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -315,11 +309,13 @@ const mapOpenCodeVerboseModel = (model: OpenCodeVerboseModel): ProviderModelOpti
|
||||
};
|
||||
|
||||
export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => {
|
||||
const options: ProviderModelOption[] = ids.map((value) => ({
|
||||
value,
|
||||
label: labelForOpenCodeModelId(value),
|
||||
description: descriptionForOpenCodeModelId(value),
|
||||
}));
|
||||
const options: ProviderModelOption[] = ids
|
||||
.filter(isSupportedOpenCodeModelId)
|
||||
.map((value) => ({
|
||||
value,
|
||||
label: labelForOpenCodeModelId(value),
|
||||
description: descriptionForOpenCodeModelId(value),
|
||||
}));
|
||||
|
||||
const defaultValue = options.find((option) => option.value === OPENCODE_FALLBACK_MODELS.DEFAULT)?.value
|
||||
?? options[0]?.value
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
normalizeSessionName,
|
||||
readJsonRecord,
|
||||
readOptionalString,
|
||||
unwrapJsonStringLiteral,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
type OpenCodeSessionRow = {
|
||||
@@ -128,9 +129,26 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
|
||||
const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId)
|
||||
?? sessionsDb.getSessionById(sessionId);
|
||||
const existingName = existingSession?.custom_name;
|
||||
const nextName = existingName && existingName !== fallbackTitle
|
||||
? existingName
|
||||
: readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId);
|
||||
|
||||
// Sessions started by sending a message from cloudcli carry a distinct
|
||||
// app-allocated session_id mapped to the provider id. For these we title the
|
||||
// conversation from the first user message the user typed, matching how the
|
||||
// app titles a brand-new conversation. Sessions discovered purely by
|
||||
// indexing (session_id === provider_session_id) keep OpenCode's own stored
|
||||
// title.
|
||||
const isAppCreated =
|
||||
existingSession != null &&
|
||||
existingSession.provider_session_id != null &&
|
||||
existingSession.session_id !== existingSession.provider_session_id;
|
||||
|
||||
let nextName: string | undefined;
|
||||
if (existingName && existingName !== fallbackTitle) {
|
||||
nextName = existingName;
|
||||
} else if (isAppCreated) {
|
||||
nextName = this.readFirstUserText(db, sessionId) ?? readOptionalString(row.title);
|
||||
} else {
|
||||
nextName = readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId);
|
||||
}
|
||||
|
||||
// OpenCode stores every session in one shared sqlite database, so jsonl_path
|
||||
// must stay null to avoid deleting opencode.db when one app session is removed.
|
||||
@@ -163,7 +181,10 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
|
||||
`).get(sessionId) as { data: string | null } | undefined;
|
||||
|
||||
const data = readJsonRecord(row?.data);
|
||||
return readOptionalString(data?.text);
|
||||
const text = readOptionalString(data?.text);
|
||||
// OpenCode persists the first prompt as a JSON string literal (e.g.
|
||||
// `"hello"`), so decode it to avoid titling the session with quotes.
|
||||
return text === undefined ? undefined : unwrapJsonStringLiteral(text);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import fsSync from 'node:fs';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { parseImagesInputTag } from '@/shared/image-attachments.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
readJsonRecord,
|
||||
readOptionalString,
|
||||
sliceTailPage,
|
||||
unwrapJsonStringLiteral,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'opencode';
|
||||
@@ -59,25 +61,6 @@ const formatToolContent = (value: unknown): string => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenCode can persist the first prompt as a JSON string literal inside a text
|
||||
* part, for example `"hello"` instead of `hello`. Decode only complete JSON
|
||||
* string literals so normal assistant/user prose remains untouched.
|
||||
*/
|
||||
const unwrapJsonStringLiteral = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return typeof parsed === 'string' ? parsed : value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const extractText = (value: unknown): string => {
|
||||
if (typeof value === 'string') {
|
||||
return unwrapJsonStringLiteral(value);
|
||||
@@ -418,8 +401,13 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
|
||||
if (partType === 'text') {
|
||||
const content = extractText(partData);
|
||||
if (content.trim()) {
|
||||
const rawContent = extractText(partData);
|
||||
// User prompts sent with attachments carry an <images_input> path
|
||||
// list; strip it for display and surface the paths as images.
|
||||
const { text: content, attachments } = messageRole === 'user'
|
||||
? parseImagesInputTag(rawContent)
|
||||
: { text: rawContent, attachments: [] };
|
||||
if (content.trim() || attachments.length > 0) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
@@ -428,6 +416,7 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
|
||||
kind: 'text',
|
||||
role: messageRole === 'user' ? 'user' : 'assistant',
|
||||
content,
|
||||
images: attachments.length > 0 ? attachments : undefined,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.js';
|
||||
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
|
||||
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
|
||||
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
|
||||
import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js';
|
||||
import type { IProvider } from '@/shared/interfaces.js';
|
||||
import type { LLMProvider } from '@/shared/types.js';
|
||||
@@ -11,7 +10,6 @@ const providers: Record<LLMProvider, IProvider> = {
|
||||
claude: new ClaudeProvider(),
|
||||
codex: new CodexProvider(),
|
||||
cursor: new CursorProvider(),
|
||||
gemini: new GeminiProvider(),
|
||||
opencode: new OpenCodeProvider(),
|
||||
};
|
||||
|
||||
|
||||
@@ -285,7 +285,6 @@ const parseProvider = (value: unknown): LLMProvider => {
|
||||
normalized === 'claude'
|
||||
|| normalized === 'codex'
|
||||
|| normalized === 'cursor'
|
||||
|| normalized === 'gemini'
|
||||
|| normalized === 'opencode'
|
||||
) {
|
||||
return normalized;
|
||||
|
||||
@@ -46,7 +46,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
||||
provider: 'cursor',
|
||||
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: false,
|
||||
supportsImages: true,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: false,
|
||||
@@ -56,27 +56,20 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
||||
provider: 'codex',
|
||||
permissionModes: ['default', 'acceptEdits', 'bypassPermissions'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: false,
|
||||
supportsImages: true,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
supportsEffort: true,
|
||||
},
|
||||
gemini: {
|
||||
provider: 'gemini',
|
||||
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
supportsEffort: false,
|
||||
},
|
||||
opencode: {
|
||||
provider: 'opencode',
|
||||
permissionModes: ['default'],
|
||||
// Mapped by the runtime onto OpenCode's controls: `--agent plan` (plan),
|
||||
// `--auto` (bypassPermissions) and the OPENCODE_PERMISSION env var
|
||||
// (acceptEdits). See resolveOpenCodePermissionOptions in opencode-cli.js.
|
||||
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: false,
|
||||
supportsImages: true,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
|
||||
@@ -17,7 +17,7 @@ import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
|
||||
|
||||
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
const PROVIDER_MODELS_CACHE_VERSION = 2;
|
||||
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude', 'gemini']);
|
||||
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude']);
|
||||
|
||||
type ProviderModelsServiceDependencies = {
|
||||
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { rgPath } from '@vscode/ripgrep';
|
||||
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||
|
||||
type AnyRecord = Record<string, any>;
|
||||
type SearchableProvider = 'claude' | 'codex' | 'gemini';
|
||||
type SearchableProvider = 'claude' | 'codex';
|
||||
|
||||
type SearchSnippetHighlight = {
|
||||
start: number;
|
||||
@@ -82,7 +82,7 @@ type ProjectBucket = {
|
||||
sessions: SearchableSessionRow[];
|
||||
};
|
||||
|
||||
const SUPPORTED_PROVIDERS = new Set<SearchableProvider>(['claude', 'codex', 'gemini']);
|
||||
const SUPPORTED_PROVIDERS = new Set<SearchableProvider>(['claude', 'codex']);
|
||||
const MAX_MATCHES_PER_SESSION = 2;
|
||||
const RIPGREP_FILE_CHUNK_SIZE = 40;
|
||||
const RIPGREP_CHUNK_CONCURRENCY = 6;
|
||||
@@ -455,21 +455,6 @@ function extractCodexText(content: unknown): string {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function extractGeminiText(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return content
|
||||
.filter((part: AnyRecord) => typeof part?.text === 'string')
|
||||
.map((part: AnyRecord) => String(part.text))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function normalizeSearchableSessions(rows: SessionRepositoryRow[]): SearchableSessionRow[] {
|
||||
const normalizedRows: SearchableSessionRow[] = [];
|
||||
const projectArchiveStateByPath = new Map<string, boolean>();
|
||||
@@ -1065,81 +1050,6 @@ async function parseCodexSessionMatches(
|
||||
};
|
||||
}
|
||||
|
||||
async function parseGeminiSessionMatches(
|
||||
session: SearchableSessionRow,
|
||||
runtime: SearchRuntime,
|
||||
): Promise<SessionConversationResult | null> {
|
||||
let data: string;
|
||||
try {
|
||||
data = await fs.readFile(session.jsonl_path, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: AnyRecord;
|
||||
try {
|
||||
parsed = JSON.parse(data) as AnyRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceMessages = Array.isArray(parsed.messages) ? parsed.messages as AnyRecord[] : [];
|
||||
if (sourceMessages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matches: SessionConversationMatch[] = [];
|
||||
let firstUserText: string | null = null;
|
||||
|
||||
for (const msg of sourceMessages) {
|
||||
if (runtime.totalMatches >= runtime.limit || runtime.isAborted()) {
|
||||
break;
|
||||
}
|
||||
|
||||
const role = msg.type === 'user'
|
||||
? 'user'
|
||||
: (msg.type === 'gemini' || msg.type === 'assistant')
|
||||
? 'assistant'
|
||||
: null;
|
||||
if (!role) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = extractGeminiText(msg.content);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'user' && !firstUserText) {
|
||||
firstUserText = text;
|
||||
}
|
||||
|
||||
if (!runtime.matchesQuery(text)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { snippet, highlights } = runtime.buildSnippet(text);
|
||||
addSessionMatch(runtime, matches, {
|
||||
role,
|
||||
snippet,
|
||||
highlights,
|
||||
timestamp: msg.timestamp ? String(msg.timestamp) : null,
|
||||
provider: 'gemini',
|
||||
});
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: session.session_id,
|
||||
provider: 'gemini',
|
||||
sessionSummary: toSummaryText(session.custom_name, firstUserText, 'Gemini Session'),
|
||||
matches,
|
||||
};
|
||||
}
|
||||
|
||||
async function parseSessionMatches(
|
||||
session: SearchableSessionRow,
|
||||
runtime: SearchRuntime,
|
||||
@@ -1150,7 +1060,7 @@ async function parseSessionMatches(
|
||||
if (session.provider === 'codex') {
|
||||
return parseCodexSessionMatches(session, runtime);
|
||||
}
|
||||
return parseGeminiSessionMatches(session, runtime);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function searchConversations(
|
||||
|
||||
@@ -21,7 +21,6 @@ export const sessionSynchronizerService = {
|
||||
claude: 0,
|
||||
codex: 0,
|
||||
cursor: 0,
|
||||
gemini: 0,
|
||||
opencode: 0,
|
||||
};
|
||||
const failures: string[] = [];
|
||||
|
||||
@@ -25,16 +25,6 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
|
||||
provider: 'codex',
|
||||
rootPath: path.join(os.homedir(), '.codex', 'sessions'),
|
||||
},
|
||||
// {
|
||||
// provider: 'gemini',
|
||||
// rootPath: path.join(os.homedir(), '.gemini', 'sessions'),
|
||||
// },
|
||||
// Keep `sessions/` watcher disabled: Gemini also mirrors artifacts there,
|
||||
// which causes duplicate synchronization events.
|
||||
{
|
||||
provider: 'gemini',
|
||||
rootPath: path.join(os.homedir(), '.gemini', 'tmp'),
|
||||
},
|
||||
{
|
||||
provider: 'opencode',
|
||||
rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
|
||||
@@ -81,10 +71,6 @@ function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
|
||||
return path.basename(filePath) === 'opencode.db';
|
||||
}
|
||||
|
||||
if (provider === 'gemini') {
|
||||
return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
|
||||
}
|
||||
|
||||
return filePath.endsWith('.jsonl');
|
||||
}
|
||||
|
||||
|
||||
111
server/modules/providers/tests/codex-sessions.test.ts
Normal file
111
server/modules/providers/tests/codex-sessions.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
|
||||
import { CodexSessionSynchronizer } from '@/modules/providers/list/codex/codex-session-synchronizer.provider.js';
|
||||
|
||||
const patchHomeDir = (nextHomeDir: string) => {
|
||||
const original = os.homedir;
|
||||
(os as any).homedir = () => nextHomeDir;
|
||||
return () => {
|
||||
(os as any).homedir = original;
|
||||
};
|
||||
};
|
||||
|
||||
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
|
||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||
const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'codex-provider-db-'));
|
||||
const databasePath = path.join(tempDirectory, 'auth.db');
|
||||
|
||||
closeConnection();
|
||||
process.env.DATABASE_PATH = databasePath;
|
||||
await initializeDatabase();
|
||||
|
||||
try {
|
||||
await runTest();
|
||||
} finally {
|
||||
closeConnection();
|
||||
if (previousDatabasePath === undefined) {
|
||||
delete process.env.DATABASE_PATH;
|
||||
} else {
|
||||
process.env.DATABASE_PATH = previousDatabasePath;
|
||||
}
|
||||
await rm(tempDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one Codex rollout transcript. `firstUserMessage` mirrors the
|
||||
* `event_msg`/`user_message` payload the runtime records for the prompt the
|
||||
* user typed; omitting it produces a transcript with no user turn.
|
||||
*/
|
||||
const writeCodexTranscript = async (
|
||||
homeDir: string,
|
||||
codexSessionId: string,
|
||||
workspacePath: string,
|
||||
firstUserMessage?: string,
|
||||
): Promise<string> => {
|
||||
const sessionsDir = path.join(homeDir, '.codex', 'sessions', '2026', '07', '07');
|
||||
await mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
const lines: string[] = [
|
||||
JSON.stringify({ type: 'session_meta', payload: { id: codexSessionId, cwd: workspacePath } }),
|
||||
];
|
||||
if (firstUserMessage !== undefined) {
|
||||
lines.push(JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: firstUserMessage } }));
|
||||
}
|
||||
|
||||
const filePath = path.join(sessionsDir, `rollout-${codexSessionId}.jsonl`);
|
||||
await writeFile(filePath, `${lines.join('\n')}\n`, 'utf8');
|
||||
return filePath;
|
||||
};
|
||||
|
||||
test('Codex synchronizer titles app-created sessions from the first user message', { concurrency: false }, async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-session-sync-app-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
|
||||
try {
|
||||
await writeCodexTranscript(tempRoot, 'codex-app-1', workspacePath, 'Fix the login redirect bug');
|
||||
await withIsolatedDatabase(async () => {
|
||||
// The app allocates its own id and later maps the provider id onto it,
|
||||
// exactly as a message sent from cloudcli does.
|
||||
sessionsDb.createAppSession('app-1', 'codex', workspacePath);
|
||||
sessionsDb.assignProviderSessionId('app-1', 'codex-app-1');
|
||||
|
||||
const synchronizer = new CodexSessionSynchronizer();
|
||||
await synchronizer.synchronize();
|
||||
|
||||
assert.equal(sessionsDb.getSessionById('app-1')?.custom_name, 'Fix the login redirect bug');
|
||||
});
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Codex synchronizer leaves indexed sessions untitled when no name is available', { concurrency: false }, async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-session-sync-indexed-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
|
||||
try {
|
||||
// A CLI-created session has no app row; its first user message must NOT be
|
||||
// used as the title, preserving the existing indexing behavior.
|
||||
await writeCodexTranscript(tempRoot, 'codex-indexed-1', workspacePath, 'This prompt should be ignored');
|
||||
await withIsolatedDatabase(async () => {
|
||||
const synchronizer = new CodexSessionSynchronizer();
|
||||
await synchronizer.synchronize();
|
||||
|
||||
assert.equal(sessionsDb.getSessionById('codex-indexed-1')?.custom_name, 'Untitled Codex Session');
|
||||
});
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -257,34 +257,15 @@ test('providerMcpService handles opencode MCP config and capability validation',
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence.
|
||||
* This test covers Cursor MCP JSON format and user/project scope persistence.
|
||||
*/
|
||||
test('providerMcpService handles gemini and cursor MCP JSON config formats', { concurrency: false }, async () => {
|
||||
test('providerMcpService handles cursor MCP JSON config formats', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-gc-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
await providerMcpService.upsertProviderMcpServer('gemini', {
|
||||
name: 'gemini-stdio',
|
||||
scope: 'user',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: { TOKEN: '$TOKEN' },
|
||||
cwd: './server',
|
||||
});
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('gemini', {
|
||||
name: 'gemini-http',
|
||||
scope: 'project',
|
||||
transport: 'http',
|
||||
url: 'https://gemini.example.com/mcp',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('cursor', {
|
||||
name: 'cursor-stdio',
|
||||
scope: 'project',
|
||||
@@ -303,15 +284,6 @@ test('providerMcpService handles gemini and cursor MCP JSON config formats', { c
|
||||
headers: { API_KEY: 'value' },
|
||||
});
|
||||
|
||||
const geminiUserConfig = await readJson(path.join(tempRoot, '.gemini', 'settings.json'));
|
||||
const geminiUserServer = (geminiUserConfig.mcpServers as Record<string, unknown>)['gemini-stdio'] as Record<string, unknown>;
|
||||
assert.equal(geminiUserServer.command, 'node');
|
||||
assert.equal(geminiUserServer.type, undefined);
|
||||
|
||||
const geminiProjectConfig = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
|
||||
const geminiProjectServer = (geminiProjectConfig.mcpServers as Record<string, unknown>)['gemini-http'] as Record<string, unknown>;
|
||||
assert.equal(geminiProjectServer.type, 'http');
|
||||
|
||||
const cursorUserConfig = await readJson(path.join(tempRoot, '.cursor', 'mcp.json'));
|
||||
const cursorHttpServer = (cursorUserConfig.mcpServers as Record<string, unknown>)['cursor-http'] as Record<string, unknown>;
|
||||
assert.equal(cursorHttpServer.url, 'http://localhost:3333/mcp');
|
||||
@@ -341,7 +313,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
assert.equal(globalResult.length, 5);
|
||||
assert.equal(globalResult.length, 4);
|
||||
assert.ok(globalResult.every((entry) => entry.created === true));
|
||||
|
||||
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
|
||||
@@ -350,9 +322,6 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
||||
const codexProject = TOML.parse(await fs.readFile(path.join(workspacePath, '.codex', 'config.toml'), 'utf8')) as Record<string, unknown>;
|
||||
assert.ok((codexProject.mcp_servers as Record<string, unknown>)['global-http']);
|
||||
|
||||
const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
|
||||
assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||
|
||||
const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json'));
|
||||
assert.ok((opencodeProject.mcp as Record<string, unknown>)['global-http']);
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ test('OpenCode models provider formats frontend labels from provider-prefixed id
|
||||
'opencode/nemotron-3-super-free',
|
||||
'anthropic/claude-3-5-sonnet-20241022',
|
||||
'anthropic/claude-opus-4-7-fast',
|
||||
'google/model-alpha',
|
||||
'openai/gpt-5.4-mini-fast',
|
||||
'openai/gpt-5.5-pro',
|
||||
'newprovider/alpha-v12-special-20261231',
|
||||
@@ -104,6 +105,12 @@ anthropic/claude-sonnet-5
|
||||
}
|
||||
}
|
||||
}
|
||||
google/model-alpha
|
||||
{
|
||||
"id": "model-alpha",
|
||||
"providerID": "google",
|
||||
"name": "Model Alpha"
|
||||
}
|
||||
`);
|
||||
|
||||
const definition = buildOpenCodeDefinitionFromVerboseModels(models);
|
||||
|
||||
@@ -9,6 +9,7 @@ import Database from 'better-sqlite3';
|
||||
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
|
||||
import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js';
|
||||
import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js';
|
||||
import { appendImagesInputTag } from '@/shared/image-attachments.js';
|
||||
|
||||
const patchHomeDir = (nextHomeDir: string) => {
|
||||
const original = os.homedir;
|
||||
@@ -321,6 +322,41 @@ test('OpenCode session synchronizer adopts the pending app session before watche
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenCode sessions provider strips <images_input> from user turns and exposes attachments', { concurrency: false }, async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-images-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
|
||||
try {
|
||||
await createOpenCodeDatabase(tempRoot, workspacePath);
|
||||
|
||||
// Rewrite the user text part with the tagged prompt the runtime sends.
|
||||
const taggedPrompt = appendImagesInputTag('Look at this screenshot.', [
|
||||
{ path: 'C:/Users/x/.cloudcli/assets/shot.png' },
|
||||
]);
|
||||
const db = new Database(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db'));
|
||||
try {
|
||||
db.prepare('UPDATE part SET data = ? WHERE id = ?').run(
|
||||
JSON.stringify({ type: 'text', text: taggedPrompt }),
|
||||
'part-user-text',
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
const provider = new OpenCodeSessionsProvider();
|
||||
const history = await provider.fetchHistory('open-session-1');
|
||||
const userMessage = history.messages.find((message) => message.kind === 'text' && message.role === 'user');
|
||||
|
||||
assert.equal(userMessage?.content, 'Look at this screenshot.');
|
||||
assert.deepEqual(userMessage?.images, [{ path: 'C:/Users/x/.cloudcli/assets/shot.png' }]);
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => {
|
||||
const provider = new OpenCodeSessionsProvider();
|
||||
const normalized = provider.normalizeMessage({
|
||||
@@ -381,3 +417,106 @@ test('OpenCode sessions provider reads sqlite history and token usage', { concur
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Seeds a single OpenCode session with a controllable stored title and first
|
||||
* user message. Uses a minimal schema (only the columns the synchronizer reads)
|
||||
* with a plain-text user part so the derived name is unambiguous.
|
||||
*/
|
||||
const seedOpenCodeSession = async (
|
||||
homeDir: string,
|
||||
workspacePath: string,
|
||||
options: { sessionId: string; title: string | null; firstUserText: string },
|
||||
): Promise<void> => {
|
||||
const dataDir = path.join(homeDir, '.local', 'share', 'opencode');
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
|
||||
const db = new Database(path.join(dataDir, 'opencode.db'));
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT);
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
directory TEXT,
|
||||
title TEXT,
|
||||
time_created INTEGER,
|
||||
time_updated INTEGER,
|
||||
time_archived INTEGER
|
||||
);
|
||||
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT);
|
||||
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT);
|
||||
`);
|
||||
|
||||
db.prepare('INSERT INTO project (id, worktree) VALUES (?, ?)').run('project-1', workspacePath);
|
||||
db.prepare(`
|
||||
INSERT INTO session (id, project_id, directory, title, time_created, time_updated, time_archived)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NULL)
|
||||
`).run(options.sessionId, 'project-1', workspacePath, options.title, 1_700_000_000_000, 1_700_000_001_000);
|
||||
db.prepare('INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)')
|
||||
.run('message-user', options.sessionId, 1_700_000_001_000, JSON.stringify({ role: 'user' }));
|
||||
db.prepare('INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(
|
||||
'part-user',
|
||||
'message-user',
|
||||
options.sessionId,
|
||||
1_700_000_001_000,
|
||||
// OpenCode persists the prompt as a JSON string literal inside the text
|
||||
// field, so double-encode it here to exercise the unwrap on read.
|
||||
JSON.stringify({ type: 'text', text: JSON.stringify(options.firstUserText) }),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
|
||||
test('OpenCode synchronizer titles app-created sessions from the first user message', { concurrency: false }, async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-app-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
|
||||
try {
|
||||
// Stored title differs from the first message so we can prove the first
|
||||
// message wins for sessions started from cloudcli.
|
||||
await seedOpenCodeSession(tempRoot, workspacePath, {
|
||||
sessionId: 'oc-app-1',
|
||||
title: 'OpenCode generated title',
|
||||
firstUserText: 'Fix the checkout crash',
|
||||
});
|
||||
await withIsolatedDatabase(async () => {
|
||||
sessionsDb.createAppSession('app-1', 'opencode', workspacePath);
|
||||
sessionsDb.assignProviderSessionId('app-1', 'oc-app-1');
|
||||
|
||||
await new OpenCodeSessionSynchronizer().synchronize();
|
||||
|
||||
assert.equal(sessionsDb.getSessionById('app-1')?.custom_name, 'Fix the checkout crash');
|
||||
});
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenCode synchronizer keeps the stored title for indexed sessions', { concurrency: false }, async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-indexed-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
|
||||
try {
|
||||
await seedOpenCodeSession(tempRoot, workspacePath, {
|
||||
sessionId: 'oc-indexed-1',
|
||||
title: 'OpenCode generated title',
|
||||
firstUserText: 'This prompt should be ignored',
|
||||
});
|
||||
await withIsolatedDatabase(async () => {
|
||||
await new OpenCodeSessionSynchronizer().synchronize();
|
||||
|
||||
assert.equal(sessionsDb.getSessionById('oc-indexed-1')?.custom_name, 'OpenCode generated title');
|
||||
});
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
180
server/modules/providers/tests/provider-image-history.test.ts
Normal file
180
server/modules/providers/tests/provider-image-history.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js';
|
||||
import { CodexSessionsProvider, extractCodexUserImages } from '@/modules/providers/list/codex/codex-sessions.provider.js';
|
||||
import { CursorSessionsProvider } from '@/modules/providers/list/cursor/cursor-sessions.provider.js';
|
||||
import { appendImagesInputTag } from '@/shared/image-attachments.js';
|
||||
|
||||
const SESSION_ID = 'session-1';
|
||||
|
||||
// ---------------------------------------------------------------- Claude
|
||||
|
||||
test('claude history: base64 image blocks surface as user message images', () => {
|
||||
const provider = new ClaudeSessionsProvider();
|
||||
const entry = {
|
||||
uuid: 'u1',
|
||||
timestamp: '2026-07-03T10:00:00.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'What is in this screenshot?' },
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'QUJD' } },
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: 'REVG' } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const messages = provider.normalizeMessage(entry, SESSION_ID);
|
||||
|
||||
assert.equal(messages.length, 1);
|
||||
assert.equal(messages[0].kind, 'text');
|
||||
assert.equal(messages[0].role, 'user');
|
||||
assert.equal(messages[0].content, 'What is in this screenshot?');
|
||||
assert.deepEqual(messages[0].images, [
|
||||
{ data: 'data:image/png;base64,QUJD' },
|
||||
{ data: 'data:image/jpeg;base64,REVG' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('claude history: image-only user turns still produce a bubble', () => {
|
||||
const provider = new ClaudeSessionsProvider();
|
||||
const entry = {
|
||||
uuid: 'u2',
|
||||
timestamp: '2026-07-03T10:00:00.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'QUJD' } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const messages = provider.normalizeMessage(entry, SESSION_ID);
|
||||
|
||||
assert.equal(messages.length, 1);
|
||||
assert.equal(messages[0].role, 'user');
|
||||
assert.equal(messages[0].content, '');
|
||||
assert.deepEqual(messages[0].images, [{ data: 'data:image/png;base64,QUJD' }]);
|
||||
});
|
||||
|
||||
test('claude history: plain text user turns carry no images field', () => {
|
||||
const provider = new ClaudeSessionsProvider();
|
||||
const entry = {
|
||||
uuid: 'u3',
|
||||
timestamp: '2026-07-03T10:00:00.000Z',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
|
||||
};
|
||||
|
||||
const messages = provider.normalizeMessage(entry, SESSION_ID);
|
||||
assert.equal(messages.length, 1);
|
||||
assert.equal(messages[0].images, undefined);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Codex
|
||||
|
||||
test('codex history: user_message payload images become path attachments', () => {
|
||||
// Real rollout shape: local_image input items land in `local_images`,
|
||||
// while `images` stays an empty array.
|
||||
assert.deepEqual(
|
||||
extractCodexUserImages({
|
||||
type: 'user_message',
|
||||
message: 'can u see attached image?',
|
||||
images: [],
|
||||
local_images: ['C:\\proj\\.cloudcli\\assets\\a.png'],
|
||||
}),
|
||||
[{ path: 'C:/proj/.cloudcli/assets/a.png' }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
extractCodexUserImages({ type: 'user_message', message: 'hi', images: ['/proj/b.jpg'] }),
|
||||
[{ path: '/proj/b.jpg' }],
|
||||
);
|
||||
assert.equal(extractCodexUserImages({ type: 'user_message', message: 'hi' }), undefined);
|
||||
assert.equal(extractCodexUserImages({ type: 'user_message', message: 'hi', images: [], local_images: [] }), undefined);
|
||||
});
|
||||
|
||||
test('codex history: base64 data URLs pass through as inline data attachments', () => {
|
||||
const dataUrl = 'data:image/png;base64,QUJD';
|
||||
assert.deepEqual(
|
||||
extractCodexUserImages({
|
||||
type: 'user_message',
|
||||
message: 'look',
|
||||
images: [dataUrl],
|
||||
local_images: ['C:\\proj\\a.png'],
|
||||
}),
|
||||
[{ path: 'C:/proj/a.png' }, { data: dataUrl }],
|
||||
);
|
||||
});
|
||||
|
||||
test('codex history: normalized user entries keep their images', () => {
|
||||
const provider = new CodexSessionsProvider();
|
||||
const messages = provider.normalizeMessage(
|
||||
{
|
||||
timestamp: '2026-07-03T10:00:00.000Z',
|
||||
message: { role: 'user', content: 'Look at this' },
|
||||
images: [{ path: '.cloudcli/assets/a.png' }],
|
||||
},
|
||||
SESSION_ID,
|
||||
);
|
||||
|
||||
assert.equal(messages.length, 1);
|
||||
assert.equal(messages[0].role, 'user');
|
||||
assert.equal(messages[0].content, 'Look at this');
|
||||
assert.deepEqual(messages[0].images, [{ path: '.cloudcli/assets/a.png' }]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Cursor
|
||||
|
||||
test('cursor history: <images_input> inside user_query is stripped and attached', () => {
|
||||
const provider = new CursorSessionsProvider();
|
||||
const taggedPrompt = appendImagesInputTag('Fix the layout bug', [{ path: '.cloudcli/assets/shot.png' }]);
|
||||
const blobs = [
|
||||
{
|
||||
id: 'blob1',
|
||||
sequence: 1,
|
||||
rowid: 1,
|
||||
content: {
|
||||
role: 'user',
|
||||
content: `<timestamp>2026-07-03</timestamp>\n<user_query>${taggedPrompt}</user_query>`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'blob2',
|
||||
sequence: 2,
|
||||
rowid: 2,
|
||||
content: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Done — the flex container was wrong.' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const messages = provider.normalizeCursorBlobs(blobs, SESSION_ID);
|
||||
|
||||
assert.equal(messages.length, 2);
|
||||
assert.equal(messages[0].role, 'user');
|
||||
assert.equal(messages[0].content, 'Fix the layout bug');
|
||||
assert.deepEqual(messages[0].images, [{ path: '.cloudcli/assets/shot.png' }]);
|
||||
assert.equal(messages[1].role, 'assistant');
|
||||
assert.equal(messages[1].images, undefined);
|
||||
});
|
||||
|
||||
test('cursor history: user text without a tag keeps existing behavior', () => {
|
||||
const provider = new CursorSessionsProvider();
|
||||
const blobs = [
|
||||
{
|
||||
id: 'blob1',
|
||||
sequence: 1,
|
||||
rowid: 1,
|
||||
content: {
|
||||
role: 'user',
|
||||
content: '<timestamp>2026-07-03</timestamp>\n<user_query>plain question</user_query>',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const messages = provider.normalizeCursorBlobs(blobs, SESSION_ID);
|
||||
assert.equal(messages.length, 1);
|
||||
assert.equal(messages[0].content, 'plain question');
|
||||
assert.equal(messages[0].images, undefined);
|
||||
});
|
||||
@@ -170,13 +170,13 @@ test('provider model cache is persisted across service instances', async () => {
|
||||
cachePath,
|
||||
resolveProvider: () => ({
|
||||
models: {
|
||||
getSupportedModels: async () => createModels('gemini-cached'),
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input),
|
||||
getSupportedModels: async () => createModels('cursor-cached'),
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange('cursor', input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
await writer.getProviderModels('gemini');
|
||||
await writer.getProviderModels('cursor');
|
||||
|
||||
const reader = createProviderModelsService({
|
||||
cachePath,
|
||||
@@ -185,13 +185,13 @@ test('provider model cache is persisted across service instances', async () => {
|
||||
getSupportedModels: async () => {
|
||||
throw new Error('loader should not be called for persisted cache hits');
|
||||
},
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input),
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange('cursor', input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const models = await reader.getProviderModels('gemini');
|
||||
assert.equal(models.models.DEFAULT, 'gemini-cached');
|
||||
const models = await reader.getProviderModels('cursor');
|
||||
assert.equal(models.models.DEFAULT, 'cursor-cached');
|
||||
assert.equal(models.cache.source, 'disk');
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
|
||||
@@ -444,34 +444,22 @@ test('providerSkillsService lists opencode project and user compatibility skills
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers Gemini and Cursor skill directory rules, including shared
|
||||
* This test covers Cursor skill directory rules, including shared
|
||||
* `.agents/skills` project support.
|
||||
*/
|
||||
test('providerSkillsService lists gemini and cursor skills from their configured directories', { concurrency: false }, async () => {
|
||||
test('providerSkillsService lists cursor skills from its configured directories', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-gc-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.gemini', 'skills'),
|
||||
'gemini-user-dir',
|
||||
'gemini-user',
|
||||
'Gemini user skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.agents', 'skills'),
|
||||
'agents-user-dir',
|
||||
'agents-user',
|
||||
'Agents user skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(workspacePath, '.gemini', 'skills'),
|
||||
'gemini-project-dir',
|
||||
'gemini-project',
|
||||
'Gemini project skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(workspacePath, '.agents', 'skills'),
|
||||
'agents-project-dir',
|
||||
@@ -491,14 +479,6 @@ test('providerSkillsService lists gemini and cursor skills from their configured
|
||||
'Cursor user skill',
|
||||
);
|
||||
|
||||
const geminiSkills = await providerSkillsService.listProviderSkills('gemini', { workspacePath });
|
||||
const geminiByName = new Map(geminiSkills.map((skill) => [skill.name, skill]));
|
||||
assert.equal(geminiByName.get('gemini-user')?.scope, 'user');
|
||||
assert.equal(geminiByName.get('agents-user')?.scope, 'user');
|
||||
assert.equal(geminiByName.get('gemini-project')?.scope, 'project');
|
||||
assert.equal(geminiByName.get('agents-project')?.scope, 'project');
|
||||
assert.equal(geminiByName.get('gemini-project')?.command, '/gemini-project');
|
||||
|
||||
const cursorSkills = await providerSkillsService.listProviderSkills('cursor', { workspacePath });
|
||||
const cursorByName = new Map(cursorSkills.map((skill) => [skill.name, skill]));
|
||||
assert.equal(cursorByName.get('agents-project')?.scope, 'project');
|
||||
@@ -515,7 +495,7 @@ test('providerSkillsService lists gemini and cursor skills from their configured
|
||||
* This test covers managed global skill creation for providers that own a
|
||||
* writable user skill directory.
|
||||
*/
|
||||
test('providerSkillsService adds global skills for claude, codex, gemini, and cursor', { concurrency: false }, async () => {
|
||||
test('providerSkillsService adds global skills for claude, codex, and cursor', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-create-'));
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
|
||||
@@ -618,22 +598,6 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu
|
||||
);
|
||||
await assert.rejects(fs.stat(pendingBatchSkillPath), { code: 'ENOENT' });
|
||||
|
||||
const createdGeminiSkills = await providerSkillsService.addProviderSkills('gemini', {
|
||||
entries: [
|
||||
{
|
||||
directoryName: 'gemini-global-dir',
|
||||
content: '---\nname: gemini-global\ndescription: Gemini global skill\n---\n\nGemini body.\n',
|
||||
},
|
||||
],
|
||||
});
|
||||
const createdGeminiSkill = createdGeminiSkills[0];
|
||||
assert.ok(createdGeminiSkill);
|
||||
assert.equal(createdGeminiSkill.command, '/gemini-global');
|
||||
assert.equal(
|
||||
createdGeminiSkill.sourcePath.endsWith(path.join('.gemini', 'skills', 'gemini-global-dir', 'SKILL.md')),
|
||||
true,
|
||||
);
|
||||
|
||||
const createdCursorSkills = await providerSkillsService.addProviderSkills('cursor', {
|
||||
entries: [
|
||||
{
|
||||
@@ -656,9 +620,6 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu
|
||||
const listedCodexSkills = await providerSkillsService.listProviderSkills('codex');
|
||||
assert.equal(listedCodexSkills.some((skill) => skill.name === 'replacement'), true);
|
||||
|
||||
const listedGeminiSkills = await providerSkillsService.listProviderSkills('gemini');
|
||||
assert.equal(listedGeminiSkills.some((skill) => skill.name === 'gemini-global'), true);
|
||||
|
||||
const listedCursorSkills = await providerSkillsService.listProviderSkills('cursor');
|
||||
assert.equal(listedCursorSkills.some((skill) => skill.name === 'cursor-global'), true);
|
||||
|
||||
|
||||
@@ -318,6 +318,22 @@ export const chatRunRegistry = {
|
||||
run.writer.sendComplete(opts);
|
||||
},
|
||||
|
||||
/**
|
||||
* Safety-net variant of `completeRun` scoped to one specific run: a no-op
|
||||
* unless `run` is still the session's current, running run. A runtime
|
||||
* promise can resolve after its own `complete` already streamed AND a new
|
||||
* run has replaced it in the registry (a queued message sends within
|
||||
* milliseconds of the previous turn ending) — the session-keyed
|
||||
* `completeRun` would terminate that newer run.
|
||||
*/
|
||||
completeRunIfCurrent(run: ChatRun, opts: { exitCode: number; aborted?: boolean }): void {
|
||||
if (runs.get(run.appSessionId) !== run || run.status !== 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
run.writer.sendComplete(opts);
|
||||
},
|
||||
|
||||
/**
|
||||
* Test-only escape hatch: clears every tracked run.
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js';
|
||||
import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
|
||||
import { getGlobalImageAssetsDir, normalizeImageDescriptors } from '@/shared/image-attachments.js';
|
||||
import type {
|
||||
AnyRecord,
|
||||
AuthenticatedWebSocketRequest,
|
||||
@@ -10,6 +13,37 @@ import type {
|
||||
} from '@/shared/types.js';
|
||||
import { parseIncomingJsonObject } from '@/shared/utils.js';
|
||||
|
||||
/**
|
||||
* Trust boundary for client-supplied image attachments: chat.send options come
|
||||
* straight from the browser, and the provider runtimes read the referenced
|
||||
* files off disk (Claude base64-encodes them into the prompt). Only images
|
||||
* that live directly inside the global upload store (`~/.cloudcli/assets`,
|
||||
* where POST /api/assets/images puts them) are allowed through — anything
|
||||
* else (absolute paths elsewhere, traversal, subdirectories) is dropped.
|
||||
*
|
||||
* Exported for tests; `assetsRootOverride` exists only for them.
|
||||
*/
|
||||
export function filterImagesToUploadStore(images: unknown, assetsRootOverride?: string): AnyRecord[] {
|
||||
const assetsRoot = path.resolve(assetsRootOverride ?? getGlobalImageAssetsDir());
|
||||
|
||||
return normalizeImageDescriptors(images).filter((descriptor) => {
|
||||
// Relative paths are anchored in the store; absolute ones must already be in it.
|
||||
const resolved = path.resolve(assetsRoot, descriptor.path);
|
||||
const relative = path.relative(assetsRoot, resolved);
|
||||
const isDirectChild =
|
||||
relative.length > 0 &&
|
||||
!relative.startsWith('..') &&
|
||||
!path.isAbsolute(relative) &&
|
||||
!relative.includes(path.sep) &&
|
||||
!relative.includes('/');
|
||||
|
||||
if (!isDirectChild) {
|
||||
console.warn(`[Chat] Dropping image outside the upload store: ${descriptor.path}`);
|
||||
}
|
||||
return isDirectChild;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One provider runtime entry point. All five runtimes share this signature,
|
||||
* which lets the chat handler dispatch through a provider-keyed map instead
|
||||
@@ -161,6 +195,9 @@ async function handleChatSend(
|
||||
// gateway writer captures and maps back to the app session id.
|
||||
const runtimeOptions: AnyRecord = {
|
||||
...clientOptions,
|
||||
// Image attachments are re-validated server-side: only files inside the
|
||||
// global upload store may reach the provider runtimes' file reads.
|
||||
images: filterImagesToUploadStore(clientOptions.images),
|
||||
sessionId: session.provider_session_id ?? undefined,
|
||||
resume: Boolean(session.provider_session_id),
|
||||
cwd: clientOptions.cwd ?? session.project_path ?? undefined,
|
||||
@@ -175,8 +212,10 @@ async function handleChatSend(
|
||||
} finally {
|
||||
// Safety net: a runtime that crashed (or resolved) without emitting its
|
||||
// terminal `complete` would otherwise leave the session stuck in
|
||||
// "processing" forever on every connected client.
|
||||
chatRunRegistry.completeRun(sessionId, { exitCode: 1 });
|
||||
// "processing" forever on every connected client. Scoped to THIS run —
|
||||
// a queued message can start the session's next run before this promise
|
||||
// settles, and the session-keyed completeRun would kill that new run.
|
||||
chatRunRegistry.completeRunIfCurrent(run, { exitCode: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,14 +146,6 @@ function buildShellCommand(
|
||||
return 'codex';
|
||||
}
|
||||
|
||||
if (provider === 'gemini') {
|
||||
const command = initialCommand || 'gemini';
|
||||
if (resumeSessionId) {
|
||||
return `${command} --resume "${resumeSessionId}"`;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
if (provider === 'opencode') {
|
||||
if (resumeSessionId) {
|
||||
return `opencode --session "${resumeSessionId}"`;
|
||||
@@ -477,9 +469,7 @@ export function handleShellConnection(
|
||||
? 'Cursor'
|
||||
: provider === 'codex'
|
||||
? 'Codex'
|
||||
: provider === 'gemini'
|
||||
? 'Gemini'
|
||||
: provider === 'opencode'
|
||||
: provider === 'opencode'
|
||||
? 'OpenCode'
|
||||
: 'Claude';
|
||||
welcomeMsg = hasSession && resumeSessionId
|
||||
|
||||
44
server/modules/websocket/tests/chat-image-filter.test.ts
Normal file
44
server/modules/websocket/tests/chat-image-filter.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { filterImagesToUploadStore } from '@/modules/websocket/services/chat-websocket.service.js';
|
||||
|
||||
const STORE = path.join(os.tmpdir(), 'cloudcli-assets-store');
|
||||
|
||||
test('images inside the upload store pass through', () => {
|
||||
const inside = path.join(STORE, 'shot.png');
|
||||
const result = filterImagesToUploadStore(
|
||||
[{ path: inside, name: 'shot.png', mimeType: 'image/png' }],
|
||||
STORE,
|
||||
);
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].path, inside);
|
||||
});
|
||||
|
||||
test('bare filenames are anchored inside the store', () => {
|
||||
const result = filterImagesToUploadStore(['shot.png'], STORE);
|
||||
assert.equal(result.length, 1);
|
||||
});
|
||||
|
||||
test('paths outside the store, traversal, and subdirs are dropped', () => {
|
||||
const result = filterImagesToUploadStore(
|
||||
[
|
||||
{ path: 'C:/Users/victim/.ssh/id_rsa' },
|
||||
{ path: '/etc/passwd' },
|
||||
{ path: '../outside.png' },
|
||||
{ path: path.join(STORE, '..', 'escaped.png') },
|
||||
{ path: path.join(STORE, 'nested', 'deep.png') },
|
||||
{ path: STORE }, // the store folder itself is not a file
|
||||
],
|
||||
STORE,
|
||||
);
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
test('malformed payloads yield no images', () => {
|
||||
assert.deepEqual(filterImagesToUploadStore(undefined, STORE), []);
|
||||
assert.deepEqual(filterImagesToUploadStore('nope', STORE), []);
|
||||
assert.deepEqual(filterImagesToUploadStore([{ name: 'no-path' }, 42], STORE), []);
|
||||
});
|
||||
@@ -129,6 +129,44 @@ test('complete marks the run finished and duplicate completes are dropped', asyn
|
||||
});
|
||||
});
|
||||
|
||||
test('a finished run\'s safety net cannot complete the session\'s next run', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-run-9', 'codex', '/workspace/demo');
|
||||
const connection = new FakeConnection();
|
||||
|
||||
const firstRun = chatRunRegistry.startRun({
|
||||
appSessionId: 'app-run-9',
|
||||
provider: 'codex',
|
||||
providerSessionId: null,
|
||||
connection,
|
||||
userId: null,
|
||||
});
|
||||
assert.ok(firstRun);
|
||||
firstRun.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-9', exitCode: 0 });
|
||||
|
||||
// A queued message starts the next run before the first run's runtime
|
||||
// promise settles (the chat handler's `finally` hasn't executed yet).
|
||||
const secondRun = chatRunRegistry.startRun({
|
||||
appSessionId: 'app-run-9',
|
||||
provider: 'codex',
|
||||
providerSessionId: null,
|
||||
connection,
|
||||
userId: null,
|
||||
});
|
||||
assert.ok(secondRun);
|
||||
|
||||
// First run's safety net fires late: it must not touch the new run.
|
||||
chatRunRegistry.completeRunIfCurrent(firstRun, { exitCode: 1 });
|
||||
assert.equal(chatRunRegistry.isProcessing('app-run-9'), true);
|
||||
assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 1);
|
||||
|
||||
// The second run's own safety net still works while it is current.
|
||||
chatRunRegistry.completeRunIfCurrent(secondRun, { exitCode: 1 });
|
||||
assert.equal(chatRunRegistry.isProcessing('app-run-9'), false);
|
||||
assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('listRunningRuns returns only currently running app sessions', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-run-7', 'claude', '/workspace/demo');
|
||||
@@ -186,22 +224,22 @@ test('replayEvents returns only events after the requested seq', async () => {
|
||||
|
||||
test('attachConnection reroutes the live stream to a new socket', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-run-5', 'gemini', '/workspace/demo');
|
||||
sessionsDb.createAppSession('app-run-5', 'opencode', '/workspace/demo');
|
||||
const firstConnection = new FakeConnection();
|
||||
const run = chatRunRegistry.startRun({
|
||||
appSessionId: 'app-run-5',
|
||||
provider: 'gemini',
|
||||
provider: 'opencode',
|
||||
providerSessionId: null,
|
||||
connection: firstConnection,
|
||||
userId: null,
|
||||
});
|
||||
assert.ok(run);
|
||||
|
||||
run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'before' });
|
||||
run.writer.send({ kind: 'stream_delta', provider: 'opencode', sessionId: 'o', content: 'before' });
|
||||
|
||||
const secondConnection = new FakeConnection();
|
||||
assert.equal(chatRunRegistry.attachConnection('app-run-5', secondConnection), true);
|
||||
run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'after' });
|
||||
run.writer.send({ kind: 'stream_delta', provider: 'opencode', sessionId: 'o', content: 'after' });
|
||||
|
||||
assert.deepEqual(firstConnection.frames.map((frame) => frame.content), ['before']);
|
||||
assert.deepEqual(secondConnection.frames.map((frame) => frame.content), ['after']);
|
||||
|
||||
Reference in New Issue
Block a user