d13662d5d8
* fix: mirror migration 28 in SessionStore so pending_messages.tool_use_id and worker_pid columns are created (#2139)
SessionStore's inline migration list jumped from v27 to v29, skipping
rebuildPendingMessagesForSelfHealingClaim. The worker uses SessionStore
directly via worker/DatabaseManager.ts and bypasses the canonical
MigrationRunner, so fresh installs ended up at "max v29" with neither
column present — every queue claim and observation insert failed.
Adds addPendingMessagesToolUseIdAndWorkerPidColumns following the existing
mirror precedent (addObservationSubagentColumns / addObservationsUniqueContentHashIndex).
Uses ALTER TABLE + column-existence guards so already-broken DBs at v29
self-heal on next worker boot.
Verified on fresh DB and on a synthetic v29-without-v28 broken DB:
both columns and indexes (idx_pending_messages_worker_pid,
ux_pending_session_tool) appear after one boot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: wrap v28 mirror dedup+index creation in transaction
Addresses Greptile P2 review on PR #2140: matches the existing pattern in
addObservationsUniqueContentHashIndex (v29 mirror at SessionStore.ts:1127)
and runner.ts rebuildPendingMessagesForSelfHealingClaim. A crash between
the dedup DELETE and the schema_versions INSERT no longer leaves the DB
in a half-applied state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(plan): cynical-deletion plan for 29 open issues
9-phase plan applying delete-first lens to triaged issue corpus.
Headlines: kill defenders (orphan cleanup, EncodedCommand spawn,
restart-port-steal) and tolerators (silent JSON drops, drifted SSE
filters). Each phase closes a named subset of issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: delete process-management theater (Phase 1: DEL-1 + DEL-2)
Delete aggressiveStartupCleanup, the PowerShell -EncodedCommand
spawn branch, and the restart-with-port-steal sequence. Replace
daemon spawning with a single uniform child_process.spawn path
using arg-array form, keeping setsid on Unix when available.
The defenders (orphan cleanup, duplicate-worker probes, port
stealing) bred more bugs than they fixed. PID file with start-time
token already provides correct OS-trust ownership; restart now
requests httpShutdown, waits 5s for the port to free, then exits 1
if it didn't (user resolves). Net -247 lines.
Closes #2090, #2095 (already fixed at session-init.ts:78), #2107,
#2111, #2114, #2117, #2123, #2097, #2135.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: observer-sessions trust boundary via CLAUDE_MEM_INTERNAL env (Phase 2: DEL-9)
Replace the cwd === OBSERVER_SESSIONS_DIR discriminator (which every
consumer must repeat and inevitably drifts) with a single env-var
trust boundary set once at spawn time in buildIsolatedEnv.
- buildIsolatedEnv now sets CLAUDE_MEM_INTERNAL=1, covering all three
spawn sites (SDKAgent, KnowledgeAgent.prime, KnowledgeAgent.executeQuery)
- shouldTrackProject checks the env var first (cwd check stays as
belt-and-braces fallback)
- New shared shouldEmitProjectRow predicate — SSE broadcaster and
pagination filter share the same predicate so they can never drift
apart (#2118)
- ObservationBroadcaster filters observer rows from SSE stream
- PaginationHelper hardcoded 'observer-sessions' replaced with
OBSERVER_SESSIONS_PROJECT const
- project-filter basename match pass — *observer-sessions* now matches
basename, not just full path (globToRegex's [^/]* can't cross /)
(#2126 item 1)
- New `claude-mem cleanup [--dry-run]` subcommand wires CleanupV12_4_3
through to the worker for #2126 item 5
Closes #2118, #2126.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: strip proxy env vars before spawning worker (Phase 4: CON-1)
User's HTTP_PROXY/HTTPS_PROXY config was bleeding into internal AI
calls when claude-mem spawns the claude subprocess, causing
connection failures. Strip unconditionally — no passthrough knob,
which rejects #2099's whitelist proposal.
Closes #2115, #2099.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: fail-fast on silent drops in stdin/file-context/memory-save (Phase 5: FF-1)
Three independent fail-fast fixes:
#2089 — stdin-reader silent drop. Non-empty stdin that fails JSON.parse
now rejects with a clear error instead of resolving undefined. Empty
stdin still resolves undefined.
#2094 — PreToolUse:Read truncation Edit deadlock. file-context handler
no longer returns a fake truncated Read result via updatedInput.
Removes userOffset/userLimit/truncated machinery; injects the timeline
via additionalContext only and lets the real Read pass through. Read
state and Claude's expectation now stay consistent, eliminating the
infinite Edit retry loop.
#2116 — /api/memory/save metadata drop + project bug. Schema accepts
metadata as a documented JSON column (migration 30 adds observations.
metadata TEXT, mirrored in SessionStore). Schema also tightened to
.strict() so unknown top-level fields fail fast instead of being
silently dropped. Project resolution now consults metadata.project as
a fallback before defaultProject.
Closes #2089, #2094, #2116.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: small deletions — Zod externalize / Gemini fallback / session timeout / installCLI alias (Phase 6)
DEL-4 (#2113): Externalize zod from mcp-server.cjs and context-generator.cjs
hook bundles so OpenCode's runtime resolves a single Zod copy. Worker
keeps Zod bundled (it's a daemon subprocess, not in OpenCode's hook
bundle). Added zod to plugin/package.json so externalized requires
resolve at runtime.
DEL-5 (#2087): Delete the never-wired GeminiAgent → Claude fallback.
fallbackAgent was always null in production. On 429 the agent now
throws cleanly (message stays pending for retry). Removed
setFallbackAgent, FallbackAgent interface, and the 429 fallback
branch from both GeminiAgent and OpenRouterAgent. Updated docs
that claimed automatic Claude fallback.
DEL-6 (#2127, #2098): Raise MAX_SESSION_WALL_CLOCK_MS from 4h to
24h. The timeout is a real guard against runaway-cost loops (per
issue #1590), but 4h kills legitimate long Claude Code days. 24h
preserves the guard while never hitting in normal use. No knob —
a session approaching this age is a bug worth investigating, not
a value worth tuning.
DEL-8 (#2054): Delete installCLI() alias function. Saves 4 keystrokes
at the cost of cross-platform shell-config mutation surface — not
worth it. Canonical entry is npx claude-mem (and bunx). Uninstall
now strips legacy alias/function lines from ~/.bashrc, ~/.zshrc,
and the PowerShell profile.
Closes #2087, #2098, #2113, #2127, #2054.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: de-hardcode worker port + multi-account commit (Phase 3: CON-2 + DEL-7)
Replace hardcoded 37777 fallbacks with SettingsDefaultsManager.get(
'CLAUDE_MEM_WORKER_PORT') in npx-cli (runtime/install/uninstall),
opencode-plugin, OpenClaw installer, SearchRoutes example URLs.
Timeline-report SKILL.md now resolves WORKER_PORT from settings.json
at the top and uses ${WORKER_PORT} in all curl invocations.
Remaining 37777 literals are doc comments + viewer build-time form-
field placeholder (which is replaced by /api/settings on mount).
hooks.json: add cygpath POSIX→Windows path translation between _R
resolution and node invocation. No-op on macOS/Linux. Closes the
Windows + Git Bash MODULE_NOT_FOUND in #2109.
CLAUDE.md gains a Multi-account section documenting CLAUDE_MEM_DATA_DIR
+ optional CLAUDE_MEM_WORKER_PORT — every existing path/port code
path now honors them.
Closes #2103, #2109, #2101.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: install/uninstall improvements (Phase 7: #2106)
5 fixes for the install/uninstall flow:
Item 1 — multiselect default. install.ts no longer pre-selects every
detected IDE; user explicitly opts in.
Item 3 — shutdown-before-overwrite. New
src/services/install/shutdown-helper.ts shared by install and
uninstall: POSTs /api/admin/shutdown then polls /api/health until
the worker stops responding. install calls it before
copyPluginToMarketplace so reinstall over a running worker doesn't
conflict; uninstall calls it before deletion.
Item 4 — uninstall path coverage. Removes ~/.npm/_npx/*/node_modules/
claude-mem, ~/.cache/claude-cli-nodejs/*/mcp-logs-plugin-claude-mem-*,
~/.claude/plugins/data/claude-mem-thedotmack/. Best-effort: per-path
try/catch so a single permission failure doesn't abort uninstall.
chroma-mcp shutdown is implicit via the worker's GracefulShutdown
cascade in item 3's helper.
Item 5 — install summary documents "Close all Claude Code sessions
before uninstalling, or ~/.claude-mem will be recreated by active
hooks."
Item 6 — real-port query. After install, fetches /api/health on the
configured port with 3s timeout. Reports actually-bound port if the
response carries it; falls back to requested port. No retry loop.
Closes #2106 (items 1, 3, 4, 5, 6). Items 2, 7 closed separately
as already-fixed and insufficient-detail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: pin chroma-mcp to 0.2.6 (Phase 8: DEL-3 lite)
Replace unpinned 'chroma-mcp' arg with chroma-mcp==0.2.6 in both
local and remote modes. Pinning makes installs deterministic across
machines and across time, eliminating the dependency-drift class
of bugs.
Verified 0.2.6 in a clean uv cache: starts cleanly, no httpcore/
httpx ImportError, no --with flags needed. The --with flags removed
in a0dd516c are not required at this pin (transitive deps resolve
correctly when the top-level version is fixed).
#2102's three protections (transport cleanup on failure, stale onclose
handler guard, 10s reconnect backoff) confirmed intact.
Closes #2046, #2085, #2102.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: update stale assertions for per-UID port + migration 30 (Phase 9)
SettingsDefaultsManager.CLAUDE_MEM_WORKER_PORT default is per-UID
(37700 + uid%100), not literal '37777'. Three assertions in
settings-defaults-manager.test.ts now compute the expected value
the same way the source does.
migration-runner.test.ts: drop expect(versions).toContain(19)
(version 19 was a noop never recorded — pre-existing bug at parent),
add expect(versions).toContain(30) for the new observations.metadata
column added in Phase 5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address Greptile P1/P2 review comments on PR #2141
P1: spawnDaemon return value was unchecked in worker-service.ts restart
case, so a failed spawn silently exited 0 with a misleading "Worker
restart spawned" log. Now error and exit 1 when restartPid is undefined.
P2: shutdown-helper.ts health-poll catch treated AbortError (timeout)
the same as connection-refused, so a slow worker could be reported
confirmedStopped while still holding file locks. Now distinguish:
AbortError continues polling; other errors return confirmedStopped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* build: rebuild plugin artifacts after merging main
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review comments on PR #2141
- hooks.json: quote $HOME in cache lookup so paths with spaces work
- timeline-report SKILL.md: fall back when process.getuid is unavailable (Windows)
- opencode-plugin: validate CLAUDE_MEM_WORKER_PORT before using
- uninstall.ts: only strip alias lines, not function declarations (multi-line bodies left intact)
- MemoryRoutes: trim whitespace-only project before precedence resolution
- SessionStore migration 21: preserve metadata column if observations already has it
- stdin-reader test: restore full property descriptor to avoid cross-test pollution
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
484 lines
18 KiB
TypeScript
484 lines
18 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
|
|
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { GeminiAgent } from '../src/services/worker/GeminiAgent';
|
|
import { DatabaseManager } from '../src/services/worker/DatabaseManager';
|
|
import { SessionManager } from '../src/services/worker/SessionManager';
|
|
import { ModeManager } from '../src/services/domain/ModeManager';
|
|
import { SettingsDefaultsManager } from '../src/shared/SettingsDefaultsManager';
|
|
|
|
// Track rate limiting setting (controls Gemini RPM throttling)
|
|
// Set to 'false' to disable rate limiting for faster tests
|
|
let rateLimitingEnabled = 'false';
|
|
|
|
// Mock mode config
|
|
const mockMode = {
|
|
name: 'code',
|
|
prompts: {
|
|
init: 'init prompt',
|
|
observation: 'obs prompt',
|
|
summary: 'summary prompt'
|
|
},
|
|
observation_types: [{ id: 'discovery' }, { id: 'bugfix' }],
|
|
observation_concepts: []
|
|
};
|
|
|
|
// Use spyOn for all dependencies to avoid affecting other test files
|
|
// spyOn restores automatically, unlike mock.module which persists
|
|
let loadFromFileSpy: ReturnType<typeof spyOn>;
|
|
let getSpy: ReturnType<typeof spyOn>;
|
|
let modeManagerSpy: ReturnType<typeof spyOn>;
|
|
|
|
describe('GeminiAgent', () => {
|
|
let agent: GeminiAgent;
|
|
let originalFetch: typeof global.fetch;
|
|
|
|
// Mocks
|
|
let mockStoreObservation: any;
|
|
let mockStoreObservations: any; // Plural - atomic transaction method used by ResponseProcessor
|
|
let mockStoreSummary: any;
|
|
let mockMarkSessionCompleted: any;
|
|
let mockSyncObservation: any;
|
|
let mockSyncSummary: any;
|
|
let mockMarkProcessed: any;
|
|
let mockCleanupProcessed: any;
|
|
let mockResetStuckMessages: any;
|
|
let mockDbManager: DatabaseManager;
|
|
let mockSessionManager: SessionManager;
|
|
|
|
beforeEach(() => {
|
|
// Reset rate limiting to disabled by default (speeds up tests)
|
|
rateLimitingEnabled = 'false';
|
|
|
|
// Mock ModeManager using spyOn (restores properly)
|
|
modeManagerSpy = spyOn(ModeManager, 'getInstance').mockImplementation(() => ({
|
|
getActiveMode: () => mockMode,
|
|
loadMode: () => {},
|
|
} as any));
|
|
|
|
// Mock SettingsDefaultsManager methods using spyOn (restores properly)
|
|
loadFromFileSpy = spyOn(SettingsDefaultsManager, 'loadFromFile').mockImplementation(() => ({
|
|
...SettingsDefaultsManager.getAllDefaults(),
|
|
CLAUDE_MEM_GEMINI_API_KEY: 'test-api-key',
|
|
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite',
|
|
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: rateLimitingEnabled,
|
|
CLAUDE_MEM_DATA_DIR: '/tmp/claude-mem-test',
|
|
}));
|
|
|
|
getSpy = spyOn(SettingsDefaultsManager, 'get').mockImplementation((key: string) => {
|
|
if (key === 'CLAUDE_MEM_GEMINI_API_KEY') return 'test-api-key';
|
|
if (key === 'CLAUDE_MEM_GEMINI_MODEL') return 'gemini-2.5-flash-lite';
|
|
if (key === 'CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED') return rateLimitingEnabled;
|
|
if (key === 'CLAUDE_MEM_DATA_DIR') return '/tmp/claude-mem-test';
|
|
return SettingsDefaultsManager.getAllDefaults()[key as keyof ReturnType<typeof SettingsDefaultsManager.getAllDefaults>] ?? '';
|
|
});
|
|
|
|
// Initialize mocks
|
|
mockStoreObservation = mock(() => ({ id: 1, createdAtEpoch: Date.now() }));
|
|
mockStoreSummary = mock(() => ({ id: 1, createdAtEpoch: Date.now() }));
|
|
mockMarkSessionCompleted = mock(() => {});
|
|
mockSyncObservation = mock(() => Promise.resolve());
|
|
mockSyncSummary = mock(() => Promise.resolve());
|
|
mockMarkProcessed = mock(() => {});
|
|
mockCleanupProcessed = mock(() => 0);
|
|
mockResetStuckMessages = mock(() => 0);
|
|
|
|
// Mock for storeObservations (plural) - the atomic transaction method called by ResponseProcessor
|
|
mockStoreObservations = mock(() => ({
|
|
observationIds: [1],
|
|
summaryId: 1,
|
|
createdAtEpoch: Date.now()
|
|
}));
|
|
|
|
const mockSessionStore = {
|
|
storeObservation: mockStoreObservation,
|
|
storeObservations: mockStoreObservations, // Required by ResponseProcessor.ts
|
|
storeSummary: mockStoreSummary,
|
|
markSessionCompleted: mockMarkSessionCompleted,
|
|
getSessionById: mock(() => ({ memory_session_id: 'mem-session-123' })), // Required by ResponseProcessor.ts for FK fix
|
|
ensureMemorySessionIdRegistered: mock(() => {}) // Required by ResponseProcessor.ts for FK constraint fix (Issue #846)
|
|
};
|
|
|
|
const mockChromaSync = {
|
|
syncObservation: mockSyncObservation,
|
|
syncSummary: mockSyncSummary
|
|
};
|
|
|
|
mockDbManager = {
|
|
getSessionStore: () => mockSessionStore,
|
|
getChromaSync: () => mockChromaSync
|
|
} as unknown as DatabaseManager;
|
|
|
|
const mockPendingMessageStore = {
|
|
markProcessed: mockMarkProcessed,
|
|
confirmProcessed: mock(() => {}), // CLAIM-CONFIRM pattern: confirm after successful storage
|
|
cleanupProcessed: mockCleanupProcessed,
|
|
resetStuckMessages: mockResetStuckMessages
|
|
};
|
|
|
|
mockSessionManager = {
|
|
getMessageIterator: async function* () { yield* []; },
|
|
getPendingMessageStore: () => mockPendingMessageStore
|
|
} as unknown as SessionManager;
|
|
|
|
agent = new GeminiAgent(mockDbManager, mockSessionManager);
|
|
originalFetch = global.fetch;
|
|
});
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
// Restore spied methods
|
|
if (modeManagerSpy) modeManagerSpy.mockRestore();
|
|
if (loadFromFileSpy) loadFromFileSpy.mockRestore();
|
|
if (getSpy) getSpy.mockRestore();
|
|
mock.restore();
|
|
});
|
|
|
|
it('should initialize with correct config', async () => {
|
|
const session = {
|
|
sessionDbId: 1,
|
|
contentSessionId: 'test-session',
|
|
memorySessionId: 'mem-session-123',
|
|
project: 'test-project',
|
|
userPrompt: 'test prompt',
|
|
conversationHistory: [],
|
|
lastPromptNumber: 1,
|
|
cumulativeInputTokens: 0,
|
|
cumulativeOutputTokens: 0,
|
|
pendingMessages: [],
|
|
abortController: new AbortController(),
|
|
generatorPromise: null,
|
|
earliestPendingTimestamp: null,
|
|
currentProvider: null,
|
|
startTime: Date.now(),
|
|
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
|
} as any;
|
|
|
|
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
|
candidates: [{
|
|
content: {
|
|
parts: [{ text: '<observation><type>discovery</type><title>Test</title></observation>' }]
|
|
}
|
|
}],
|
|
usageMetadata: { totalTokenCount: 100 }
|
|
}))));
|
|
|
|
await agent.startSession(session);
|
|
|
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
const url = (global.fetch as any).mock.calls[0][0];
|
|
expect(url).toContain('https://generativelanguage.googleapis.com/v1/models/gemini-2.5-flash-lite:generateContent');
|
|
expect(url).toContain('key=test-api-key');
|
|
});
|
|
|
|
it('should handle multi-turn conversation', async () => {
|
|
const session = {
|
|
sessionDbId: 1,
|
|
contentSessionId: 'test-session',
|
|
memorySessionId: 'mem-session-123',
|
|
project: 'test-project',
|
|
userPrompt: 'test prompt',
|
|
conversationHistory: [{ role: 'user', content: 'prev context' }, { role: 'assistant', content: 'prev response' }],
|
|
lastPromptNumber: 2,
|
|
cumulativeInputTokens: 0,
|
|
cumulativeOutputTokens: 0,
|
|
pendingMessages: [],
|
|
abortController: new AbortController(),
|
|
generatorPromise: null,
|
|
earliestPendingTimestamp: null,
|
|
currentProvider: null,
|
|
startTime: Date.now(),
|
|
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
|
} as any;
|
|
|
|
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
|
candidates: [{ content: { parts: [{ text: 'response' }] } }]
|
|
}))));
|
|
|
|
await agent.startSession(session);
|
|
|
|
const body = JSON.parse((global.fetch as any).mock.calls[0][1].body);
|
|
expect(body.contents).toHaveLength(3);
|
|
expect(body.contents[0].role).toBe('user');
|
|
expect(body.contents[1].role).toBe('model');
|
|
expect(body.contents[2].role).toBe('user');
|
|
});
|
|
|
|
it('should process observations and store them', async () => {
|
|
const session = {
|
|
sessionDbId: 1,
|
|
contentSessionId: 'test-session',
|
|
memorySessionId: 'mem-session-123',
|
|
project: 'test-project',
|
|
userPrompt: 'test prompt',
|
|
conversationHistory: [],
|
|
lastPromptNumber: 1,
|
|
cumulativeInputTokens: 0,
|
|
cumulativeOutputTokens: 0,
|
|
pendingMessages: [],
|
|
abortController: new AbortController(),
|
|
generatorPromise: null,
|
|
earliestPendingTimestamp: null,
|
|
currentProvider: null,
|
|
startTime: Date.now(),
|
|
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
|
} as any;
|
|
|
|
const observationXml = `
|
|
<observation>
|
|
<type>discovery</type>
|
|
<title>Found bug</title>
|
|
<subtitle>Null pointer</subtitle>
|
|
<narrative>Found a null pointer in the code</narrative>
|
|
<facts><fact>Null check missing</fact></facts>
|
|
<concepts><concept>bug</concept></concepts>
|
|
<files_read><file>src/main.ts</file></files_read>
|
|
<files_modified></files_modified>
|
|
</observation>
|
|
`;
|
|
|
|
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
|
candidates: [{ content: { parts: [{ text: observationXml }] } }],
|
|
usageMetadata: { totalTokenCount: 50 }
|
|
}))));
|
|
|
|
await agent.startSession(session);
|
|
|
|
// ResponseProcessor uses storeObservations (plural) for atomic transactions
|
|
expect(mockStoreObservations).toHaveBeenCalled();
|
|
expect(mockSyncObservation).toHaveBeenCalled();
|
|
expect(session.cumulativeInputTokens).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should throw on rate limit (429) error — no Claude fallback (#2087)', async () => {
|
|
// The Claude-SDK fallback path was removed in #2087: it was never wired in
|
|
// production (`fallbackAgent` was always null) so 429s already threw.
|
|
// This test pins the new explicit behavior.
|
|
const session = {
|
|
sessionDbId: 1,
|
|
contentSessionId: 'test-session',
|
|
memorySessionId: 'mem-session-123',
|
|
project: 'test-project',
|
|
userPrompt: 'test prompt',
|
|
conversationHistory: [],
|
|
lastPromptNumber: 1,
|
|
cumulativeInputTokens: 0,
|
|
cumulativeOutputTokens: 0,
|
|
pendingMessages: [],
|
|
abortController: new AbortController(),
|
|
generatorPromise: null,
|
|
earliestPendingTimestamp: null,
|
|
currentProvider: null,
|
|
startTime: Date.now(),
|
|
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
|
} as any;
|
|
|
|
global.fetch = mock(() => Promise.resolve(new Response('Resource has been exhausted (e.g. check quota).', { status: 429 })));
|
|
|
|
await expect(agent.startSession(session)).rejects.toThrow(/429/);
|
|
});
|
|
|
|
it('should throw on other errors', async () => {
|
|
const session = {
|
|
sessionDbId: 1,
|
|
contentSessionId: 'test-session',
|
|
memorySessionId: 'mem-session-123',
|
|
project: 'test-project',
|
|
userPrompt: 'test prompt',
|
|
conversationHistory: [],
|
|
lastPromptNumber: 1,
|
|
cumulativeInputTokens: 0,
|
|
cumulativeOutputTokens: 0,
|
|
pendingMessages: [],
|
|
abortController: new AbortController(),
|
|
generatorPromise: null,
|
|
earliestPendingTimestamp: null,
|
|
currentProvider: null,
|
|
startTime: Date.now(),
|
|
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
|
} as any;
|
|
|
|
global.fetch = mock(() => Promise.resolve(new Response('Invalid argument', { status: 400 })));
|
|
|
|
await expect(agent.startSession(session)).rejects.toThrow('Gemini API error: 400 - Invalid argument');
|
|
});
|
|
|
|
it('should respect rate limits when rate limiting enabled', async () => {
|
|
// Enable rate limiting - this means requests will be throttled
|
|
// Note: CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED !== 'false' means enabled
|
|
rateLimitingEnabled = 'true';
|
|
|
|
const originalSetTimeout = global.setTimeout;
|
|
const mockSetTimeout = mock((cb: any) => cb());
|
|
global.setTimeout = mockSetTimeout as any;
|
|
|
|
try {
|
|
const session = {
|
|
sessionDbId: 1,
|
|
contentSessionId: 'test-session',
|
|
memorySessionId: 'mem-session-123',
|
|
project: 'test-project',
|
|
userPrompt: 'test prompt',
|
|
conversationHistory: [],
|
|
lastPromptNumber: 1,
|
|
cumulativeInputTokens: 0,
|
|
cumulativeOutputTokens: 0,
|
|
pendingMessages: [],
|
|
abortController: new AbortController(),
|
|
generatorPromise: null,
|
|
earliestPendingTimestamp: null,
|
|
currentProvider: null,
|
|
startTime: Date.now(),
|
|
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
|
} as any;
|
|
|
|
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
|
candidates: [{ content: { parts: [{ text: 'ok' }] } }]
|
|
}))));
|
|
|
|
await agent.startSession(session);
|
|
await agent.startSession(session);
|
|
|
|
expect(mockSetTimeout).toHaveBeenCalled();
|
|
} finally {
|
|
global.setTimeout = originalSetTimeout;
|
|
}
|
|
});
|
|
|
|
describe('conversation history truncation', () => {
|
|
it('should truncate history when message count exceeds limit', async () => {
|
|
// Build a history with 25 small messages (limit is 20)
|
|
const history: any[] = [];
|
|
for (let i = 0; i < 25; i++) {
|
|
history.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `message ${i}` });
|
|
}
|
|
|
|
const session = {
|
|
sessionDbId: 1,
|
|
contentSessionId: 'test-session',
|
|
memorySessionId: 'mem-session-123',
|
|
project: 'test-project',
|
|
userPrompt: 'test prompt',
|
|
conversationHistory: history,
|
|
lastPromptNumber: 2,
|
|
cumulativeInputTokens: 0,
|
|
cumulativeOutputTokens: 0,
|
|
pendingMessages: [],
|
|
abortController: new AbortController(),
|
|
generatorPromise: null,
|
|
earliestPendingTimestamp: null,
|
|
currentProvider: null,
|
|
startTime: Date.now(),
|
|
processingMessageIds: []
|
|
} as any;
|
|
|
|
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
|
candidates: [{ content: { parts: [{ text: 'response' }] } }]
|
|
}))));
|
|
|
|
await agent.startSession(session);
|
|
|
|
// The request body should have truncated contents (init adds 1 more, so 26 total → truncated to 20)
|
|
const body = JSON.parse((global.fetch as any).mock.calls[0][1].body);
|
|
expect(body.contents.length).toBeLessThanOrEqual(20);
|
|
});
|
|
|
|
it('should always keep at least the newest message even if it exceeds token limit', async () => {
|
|
// Override settings to have a very low token limit
|
|
loadFromFileSpy.mockImplementation(() => ({
|
|
...SettingsDefaultsManager.getAllDefaults(),
|
|
CLAUDE_MEM_GEMINI_API_KEY: 'test-api-key',
|
|
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite',
|
|
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'false',
|
|
CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES: '20',
|
|
CLAUDE_MEM_GEMINI_MAX_TOKENS: '1000', // Very low: ~250 chars
|
|
CLAUDE_MEM_DATA_DIR: '/tmp/claude-mem-test',
|
|
}));
|
|
|
|
// Create a single large message that exceeds the token limit
|
|
const largeContent = 'x'.repeat(8000); // ~2000 tokens, well above 1000 limit
|
|
|
|
const session = {
|
|
sessionDbId: 1,
|
|
contentSessionId: 'test-session',
|
|
memorySessionId: 'mem-session-123',
|
|
project: 'test-project',
|
|
userPrompt: largeContent,
|
|
conversationHistory: [],
|
|
lastPromptNumber: 1,
|
|
cumulativeInputTokens: 0,
|
|
cumulativeOutputTokens: 0,
|
|
pendingMessages: [],
|
|
abortController: new AbortController(),
|
|
generatorPromise: null,
|
|
earliestPendingTimestamp: null,
|
|
currentProvider: null,
|
|
startTime: Date.now(),
|
|
processingMessageIds: []
|
|
} as any;
|
|
|
|
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
|
candidates: [{ content: { parts: [{ text: 'response' }] } }]
|
|
}))));
|
|
|
|
await agent.startSession(session);
|
|
|
|
// Should still send at least 1 message (the newest), not empty contents
|
|
const body = JSON.parse((global.fetch as any).mock.calls[0][1].body);
|
|
expect(body.contents.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
describe('gemini-3-flash-preview model support', () => {
|
|
it('should accept gemini-3-flash-preview as a valid model', async () => {
|
|
// The GeminiModel type includes gemini-3-flash-preview - compile-time check
|
|
const validModels = [
|
|
'gemini-2.5-flash-lite',
|
|
'gemini-2.5-flash',
|
|
'gemini-2.5-pro',
|
|
'gemini-2.0-flash',
|
|
'gemini-2.0-flash-lite',
|
|
'gemini-3-flash-preview'
|
|
];
|
|
|
|
// Verify all models are strings (type guard)
|
|
expect(validModels.every(m => typeof m === 'string')).toBe(true);
|
|
expect(validModels).toContain('gemini-3-flash-preview');
|
|
});
|
|
|
|
it('should have rate limit defined for gemini-3-flash-preview', async () => {
|
|
// GEMINI_RPM_LIMITS['gemini-3-flash-preview'] = 5
|
|
// This is enforced at compile time, but we can test the rate limiting behavior
|
|
// by checking that the rate limit is applied when using gemini-3-flash-preview
|
|
const session = {
|
|
sessionDbId: 1,
|
|
contentSessionId: 'test-session',
|
|
memorySessionId: 'mem-session-123',
|
|
project: 'test-project',
|
|
userPrompt: 'test prompt',
|
|
conversationHistory: [],
|
|
lastPromptNumber: 1,
|
|
cumulativeInputTokens: 0,
|
|
cumulativeOutputTokens: 0,
|
|
pendingMessages: [],
|
|
abortController: new AbortController(),
|
|
generatorPromise: null,
|
|
earliestPendingTimestamp: null,
|
|
currentProvider: null,
|
|
startTime: Date.now(),
|
|
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
|
} as any;
|
|
|
|
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
|
candidates: [{ content: { parts: [{ text: 'ok' }] } }],
|
|
usageMetadata: { totalTokenCount: 10 }
|
|
}))));
|
|
|
|
// This validates that gemini-3-flash-preview is a valid model at runtime
|
|
// The agent's validation array includes gemini-3-flash-preview
|
|
await agent.startSession(session);
|
|
expect(global.fetch).toHaveBeenCalled();
|
|
});
|
|
});
|
|
}); |