From c32193c85ef45efdc36b4057d0f566e9b3de074c Mon Sep 17 00:00:00 2001 From: Abhishek gahlot Date: Thu, 11 Jun 2026 03:45:39 +0530 Subject: [PATCH 1/3] test(codex-local): cover EEXIST race rejection with mismatched symlink (#5269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - The `codex-local` adapter sets up a per-company Codex home with an auth symlink. Between `lstat` and `symlink` there is a race where two concurrent setups can both try to create the same symlink, surfacing `EEXIST`. > - Master already handles this at runtime via `createExpectedSymlink`, which accepts `EEXIST` only when the raced-in entry resolves to the expected source, and ships a regression test for the tolerated-race path (symlink already points at the right place). > - The symmetric path — `EEXIST` raised by a symlink pointing somewhere else — must stay strictly rejected so a future refactor cannot silently weaken the guard. > - This PR locks that in with a single additive test. No production code change. ## What Changed - Added one regression test in `packages/adapters/codex-local/src/server/codex-home.test.ts` that injects an `EEXIST` whose raced-in symlink target points at a different file, and asserts: - `prepareManagedCodexHome` rejects with `code: "EEXIST"`. - The mismatched symlink is left on disk (we do not blindly overwrite the raced-in entry). Complements the existing "treats a concurrently-created expected auth symlink as success" test already on master. Refs #5240 (Stack B — codex-home adapter session/auth handling). ## Verification - `pnpm --filter @paperclipai/adapter-codex-local exec vitest run src/server/codex-home.test.ts` — passes. - `pnpm --filter @paperclipai/adapter-codex-local typecheck` — clean. ## Risks - Test-only change. No production code is modified. ## Model Used - Provider: Anthropic - Model: Claude (Opus 4.7) - Mode/capabilities: tool-using coding agent with shell execution and test verification ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge - [x] I searched the GitHub PR list for similar PRs and confirmed this is not a duplicate Co-authored-by: Devin Foley Co-authored-by: Paperclip --- .../codex-local/src/server/codex-home.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/adapters/codex-local/src/server/codex-home.test.ts b/packages/adapters/codex-local/src/server/codex-home.test.ts index 840db456916..a6e6aa86b4c 100644 --- a/packages/adapters/codex-local/src/server/codex-home.test.ts +++ b/packages/adapters/codex-local/src/server/codex-home.test.ts @@ -55,6 +55,54 @@ describe("codex managed home", () => { } }); + it("still throws on EEXIST when a raced-in auth symlink points elsewhere", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-home-")); + const sharedCodexHome = path.join(root, "shared-codex-home"); + const paperclipHome = path.join(root, "paperclip-home"); + const managedCodexHome = path.join( + paperclipHome, + "instances", + "default", + "companies", + "company-1", + "codex-home", + ); + const sharedAuth = path.join(sharedCodexHome, "auth.json"); + const wrongAuth = path.join(sharedCodexHome, "other-auth.json"); + const managedAuth = path.join(managedCodexHome, "auth.json"); + + await fs.mkdir(sharedCodexHome, { recursive: true }); + await fs.writeFile(sharedAuth, '{"token":"shared"}\n', "utf8"); + await fs.writeFile(wrongAuth, '{"token":"other"}\n', "utf8"); + + const originalSymlink = fs.symlink.bind(fs); + vi.spyOn(fs, "symlink").mockImplementationOnce(async (_source, target, type) => { + await originalSymlink(wrongAuth, target, type); + const error = new Error("file already exists") as NodeJS.ErrnoException; + error.code = "EEXIST"; + throw error; + }); + + try { + await expect( + prepareManagedCodexHome( + { + CODEX_HOME: sharedCodexHome, + PAPERCLIP_HOME: paperclipHome, + PAPERCLIP_INSTANCE_ID: "default", + }, + async () => {}, + "company-1", + ), + ).rejects.toMatchObject({ code: "EEXIST" }); + + expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true); + expect(await fs.readlink(managedAuth)).toBe(wrongAuth); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + // Regression for #5028: older Paperclip versions copied auth.json into the // managed home instead of symlinking. After upgrading to the symlink-based // logic, the stale regular file at the target stayed in place and every From b853ce518382080f59f88f358a1ac0832cb786d5 Mon Sep 17 00:00:00 2001 From: Nir Arazi Date: Thu, 11 Jun 2026 01:40:39 +0300 Subject: [PATCH 2/3] Fix heartbeat task-session reuse when agent model changes (#4195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Heartbeats wake agents and resume prior adapter task sessions so work is continuous. > - A persisted task session can contain adapter-specific state (for Codex, a resumable thread/session) created under the agent's then-current model. > - When an operator changes an agent's configured model, the next run should not blindly reuse a session created under a different model — context window, capabilities, and prompt assumptions may differ. > - The existing wake reset logic handles wake reasons (forceFreshSession, comment wakes, etc.) but not model drift between current agent config and persisted task-session metadata. > - This pull request adds model-aware task-session reset and persists the configured model into task-session metadata. > - The benefit is that heartbeat runs reliably honor the current agent model configuration and avoid stale session/model mismatches. ## Linked Issues or Issue Description **What happened?** After an operator changes an agent's configured model (for example, swapping a Codex agent from one model variant to another), the heartbeat reuses the persisted adapter task session that was created under the previous model. The new model never takes effect on resume — the run continues on the prior session and prior model assumptions. **Expected behavior** A model change in agent configuration should invalidate the persisted task session for that agent and force a fresh session start on the next run, so the configured model is the one actually used. **Steps to reproduce** 1. Run an agent with model `A` so it persists an adapter task session under model `A`. 2. Change the agent's configured model to `B`. 3. Trigger a heartbeat for the same issue/agent. 4. Observe: the run resumes the prior task session (still under model `A`) instead of starting fresh under model `B`. ## What Changed - Added task-session model metadata support in heartbeat session handling via `__paperclipConfiguredModel`. - Persisted the current configured adapter model into `agent_task_sessions.sessionParamsJson` whenever heartbeat upserts task-session state. - Added `shouldResetTaskSessionForModelChange(...)` to explicitly detect model drift between current config and persisted session metadata. - Updated run startup logic to force a fresh session when model drift is detected, with a clear reason message in runtime warnings. - Strips the internal `__paperclipConfiguredModel` key from `sessionParamsJson` before it is forwarded to adapters so the metadata stays internal. - Added focused tests in `server/src/__tests__/heartbeat-workspace-session.test.ts` covering model-drift reset behavior, non-reset cases, and the strip helper. ## Verification - `pnpm --filter @paperclipai/server test src/__tests__/heartbeat-workspace-session.test.ts` - `pnpm --filter @paperclipai/server typecheck` ## Risks Low. Sessions without persisted model metadata are not reset (backward compatible). The model key is namespaced (`__paperclip...`) to avoid colliding with adapter-forwarded params. Drift detection only fires when both current config and persisted metadata are present and differ. ## Model Used Claude (Opus 4.6) — used to design the metadata persistence, add the drift detection helper, and write unit coverage. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots (N/A — no UI changes) - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (in progress) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip Co-authored-by: Devin Foley --- .../heartbeat-workspace-session.test.ts | 110 ++++++++++++++++++ server/src/services/heartbeat.ts | 78 +++++++++++-- 2 files changed, 180 insertions(+), 8 deletions(-) diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 309212833ea..874edb83352 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -21,6 +21,9 @@ import { shouldDeferFollowupWakeForSameIssue, stripHostWorkspaceProvisionForLowTrustSandbox, stripWorkspaceRuntimeFromExecutionRunConfig, + shouldResetTaskSessionForModelChange, + stripConfiguredModelFromSessionParams, + normalizeSessionParams, shouldResetTaskSessionForWake, type ResolvedWorkspaceForRun, } from "../services/heartbeat.ts"; @@ -926,6 +929,113 @@ describe("shouldDeferFollowupWakeForSameIssue", () => { }); }); +describe("shouldResetTaskSessionForModelChange", () => { + it("resets when configured model differs from persisted session model", () => { + expect( + shouldResetTaskSessionForModelChange({ + configuredModel: "gpt-5.4-mini", + taskSessionParams: { + sessionId: "thread-1", + __paperclipConfiguredModel: "opencode/mimo-v2-pro-free", + }, + }), + ).toBe(true); + }); + + it("does not reset when models match", () => { + expect( + shouldResetTaskSessionForModelChange({ + configuredModel: "gpt-5.4-mini", + taskSessionParams: { + sessionId: "thread-1", + __paperclipConfiguredModel: "gpt-5.4-mini", + }, + }), + ).toBe(false); + }); + + it("does not reset when persisted session model is missing", () => { + expect( + shouldResetTaskSessionForModelChange({ + configuredModel: "gpt-5.4-mini", + taskSessionParams: { + sessionId: "thread-1", + }, + }), + ).toBe(false); + }); + + it("does not reset when configured model is missing", () => { + expect( + shouldResetTaskSessionForModelChange({ + configuredModel: null, + taskSessionParams: { + sessionId: "thread-1", + __paperclipConfiguredModel: "gpt-5.4-mini", + }, + }), + ).toBe(false); + }); + + it("does not reset when task session params are missing", () => { + expect( + shouldResetTaskSessionForModelChange({ + configuredModel: "gpt-5.4-mini", + taskSessionParams: null, + }), + ).toBe(false); + }); +}); + +describe("stripConfiguredModelFromSessionParams", () => { + it("removes the internal model key from persisted session params", () => { + expect( + stripConfiguredModelFromSessionParams({ + sessionId: "thread-1", + __paperclipConfiguredModel: "gpt-5.4-mini", + }), + ).toEqual({ sessionId: "thread-1" }); + }); + + it("returns null when session params are missing", () => { + expect(stripConfiguredModelFromSessionParams(null)).toBeNull(); + expect(stripConfiguredModelFromSessionParams(undefined)).toBeNull(); + }); + + it("returns a copy without mutating the input", () => { + const input = { sessionId: "thread-1", __paperclipConfiguredModel: "gpt-5.4-mini" }; + const result = stripConfiguredModelFromSessionParams(input); + expect(result).not.toBe(input); + expect(input.__paperclipConfiguredModel).toBe("gpt-5.4-mini"); + }); + + it("returns an empty object when only the internal model key is present (caller must normalize)", () => { + const stripped = stripConfiguredModelFromSessionParams({ + __paperclipConfiguredModel: "gpt-5.4-mini", + }); + expect(stripped).toEqual({}); + // Callers that forward params to adapters must normalize {} back to null so + // the pre-PR null contract is preserved (adapters distinguishing {} from null). + expect(normalizeSessionParams(stripped)).toBeNull(); + }); +}); + +describe("normalizeSessionParams", () => { + it("collapses an empty object to null", () => { + expect(normalizeSessionParams({})).toBeNull(); + }); + + it("returns null for null or undefined inputs", () => { + expect(normalizeSessionParams(null)).toBeNull(); + expect(normalizeSessionParams(undefined)).toBeNull(); + }); + + it("preserves a non-empty object", () => { + const params = { sessionId: "thread-1" }; + expect(normalizeSessionParams(params)).toBe(params); + }); +}); + describe("deriveTaskKeyWithHeartbeatFallback", () => { it("returns explicit taskKey when present", () => { expect(deriveTaskKeyWithHeartbeatFallback({ taskKey: "issue-123" }, null)).toBe("issue-123"); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index b988dcb32ec..b88d6995a96 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2131,6 +2131,49 @@ export function shouldDeferFollowupWakeForSameIssue(input: { return false; } +const SESSION_CONFIGURED_MODEL_KEY = "__paperclipConfiguredModel"; + +function readConfiguredModelFromAdapterConfig( + adapterConfig: Record | null | undefined, +) { + return readNonEmptyString(adapterConfig?.model); +} + +function attachConfiguredModelToSessionParams( + sessionParams: Record | null | undefined, + configuredModel: string | null, +) { + if (!configuredModel) return sessionParams ?? null; + const next = { ...(sessionParams ?? {}) }; + next[SESSION_CONFIGURED_MODEL_KEY] = configuredModel; + return next; +} + +function readConfiguredModelFromSessionParams( + sessionParams: Record | null | undefined, +) { + return readNonEmptyString(sessionParams?.[SESSION_CONFIGURED_MODEL_KEY]); +} + +export function shouldResetTaskSessionForModelChange(input: { + configuredModel: string | null; + taskSessionParams: Record | null | undefined; +}) { + const { configuredModel, taskSessionParams } = input; + if (!configuredModel || !taskSessionParams) return false; + const sessionModel = readConfiguredModelFromSessionParams(taskSessionParams); + return !!sessionModel && sessionModel !== configuredModel; +} + +export function stripConfiguredModelFromSessionParams( + sessionParams: Record | null | undefined, +) { + if (!sessionParams) return null; + const next = { ...sessionParams }; + delete next[SESSION_CONFIGURED_MODEL_KEY]; + return next; +} + function shouldAutoCheckoutIssueForWake(input: { contextSnapshot: Record | null | undefined; issueStatus: string | null; @@ -2846,7 +2889,7 @@ function getAdapterSessionCodec(adapterType: string) { return adapter.sessionCodec ?? defaultSessionCodec; } -function normalizeSessionParams(params: Record | null | undefined) { +export function normalizeSessionParams(params: Record | null | undefined) { if (!params) return null; return Object.keys(params).length > 0 ? params : null; } @@ -7833,11 +7876,27 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } : null, }); + const config = parseObject(agent.adapterConfig); + const configuredModel = readConfiguredModelFromAdapterConfig(config); const taskSession = taskKey ? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey) : null; - const resetTaskSession = shouldResetTaskSessionForWake(context); - const sessionResetReason = describeSessionResetReason(context); + const taskSessionDecodedParams = normalizeSessionParams( + sessionCodec.deserialize(taskSession?.sessionParamsJson ?? null), + ); + const modelChangedSinceTaskSession = shouldResetTaskSessionForModelChange({ + configuredModel, + taskSessionParams: taskSessionDecodedParams, + }); + const resetTaskSession = shouldResetTaskSessionForWake(context) || modelChangedSinceTaskSession; + const wakeSessionResetReason = describeSessionResetReason(context); + const taskSessionConfiguredModel = readConfiguredModelFromSessionParams(taskSessionDecodedParams); + const modelSessionResetReason = modelChangedSinceTaskSession && taskSessionConfiguredModel + ? `configured model changed from "${taskSessionConfiguredModel}" to "${configuredModel}"` + : null; + const sessionResetReason = [modelSessionResetReason, wakeSessionResetReason] + .filter((value): value is string => Boolean(value)) + .join("; ") || null; const taskSessionForRun = resetTaskSession ? null : taskSession; const explicitResumeSessionParams = normalizeResumeParamsForAdapter( agent.adapterType, @@ -7855,9 +7914,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) : null) ?? normalizeResumeParamsForAdapter( agent.adapterType, - sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null), + stripConfiguredModelFromSessionParams( + sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null), + ), ); - const config = parseObject(agent.adapterConfig); const resolvedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({ projectPolicy: projectExecutionWorkspacePolicy, issueSettings: issueExecutionWorkspaceSettings, @@ -8490,7 +8550,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) : runtimeSessionDisplayId; let runtimeSessionIdForAdapter = readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback; - let runtimeSessionParamsForAdapter = runtimeSessionParams; + let runtimeSessionParamsForAdapter = normalizeSessionParams( + stripConfiguredModelFromSessionParams(runtimeSessionParams), + ); const sessionCompaction = await evaluateSessionCompaction({ agent, @@ -9159,7 +9221,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) agentId: agent.id, adapterType: agent.adapterType, taskKey, - sessionParamsJson: nextSessionState.params, + sessionParamsJson: attachConfiguredModelToSessionParams(nextSessionState.params, configuredModel), sessionDisplayId: nextSessionState.displayId, lastRunId: finalizedRun.id, lastError: outcome === "succeeded" ? null : (adapterResult.errorMessage ?? "run_failed"), @@ -9242,7 +9304,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) agentId: agent.id, adapterType: agent.adapterType, taskKey, - sessionParamsJson: previousSessionParams, + sessionParamsJson: attachConfiguredModelToSessionParams(previousSessionParams, configuredModel), sessionDisplayId: previousSessionDisplayId, lastRunId: failedRun.id, lastError: message, From b8fb81dee929f94b7590f33055f55ad472521376 Mon Sep 17 00:00:00 2001 From: Sherif Kozman <666788+sherifkozman@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:10:29 -0700 Subject: [PATCH 3/3] fix(gemini-local): treat token-overflow as a fresh-session signal (#4932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path The same 2026-04-30 audit that produced PR #4118 (`Invalid session` regex extension) and the ENOTFOUND classifier (#4931) identified a third stuck-session pattern: **13 failures in 7 days, all on a single agent (Ernest)**, with stderr matching: ``` _ApiError: {"error":{"code":400,"message":"The input token count exceeds the maximum number of tokens allowed 1048576","status":"INVALID_ARGUMENT"}} at ChatCompressionService.compress ``` The root cause is that gemini-cli's `ChatCompressionService` blew the 1M token context limit **during its compression step itself**. Resuming the same session ID will hit the same wall on the next attempt — the session is effectively dead the same way it is when "Invalid session identifier" fires (PR #4118). ## What Changed Extends the `isGeminiUnknownSessionError` regex in `parse.ts` with two phrases: - `exceeds\s+the\s+maximum\s+number\s+of\s+tokens` - `input\s+token\s+count\s+exceeds` Both trigger the **existing** fresh-session retry path in `execute.ts:596` — no new code path. Same extension pattern as PR #4118. ## Verification - `npx vitest run --project @paperclipai/adapter-gemini-local` → 14/14 pass (11 in `parse.test.ts` + 3 existing in `execute.remote.test.ts`) - 2 new tests cover the token-overflow patterns - `pnpm --filter @paperclipai/adapter-gemini-local typecheck` → clean - Audit query against `heartbeat_runs.stderr_excerpt` confirms regex matches all 13 occurrences ## Stacking This PR is stacked on top of #4931 (the ENOTFOUND classifier) which adds the `parse.test.ts` file. If #4931 merges first, this PR's diff is just the regex + 2 tests. If this PR is reviewed first, please merge #4931 first to avoid touching the same test scaffolding twice. ## Risks - **Low.** Single-line regex extension. No new code paths. - The session-reset path is well-trodden (PR #4118 in flight). - If a non-Gemini caller produces a stderr containing "exceeds the maximum number of tokens" by coincidence, they would trigger one unnecessary fresh-session retry. Not plausible in the gemini-cli output context where this stderr is sourced. ## Model Used Claude Opus 4.7 (1M context), Anthropic SDK via Claude Code CLI. ## Checklist - [x] Thinking path traces from audit data to single-line regex change - [x] Model specified - [x] No duplicate of planned core work - [x] Tests pass locally - [x] Tests added (2 new) - [x] N/A — server-side regex - [x] Internal pattern; no docs change - [x] Risks documented - [x] Will address Greptile + reviewer comments before merge - [x] I searched the GitHub PR list for similar PRs and confirmed this is not a duplicate (related: #4118 covers the "Invalid session identifier" regex; this PR extends the same regex with token-overflow phrases) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Devin Foley Co-authored-by: Paperclip --- .../gemini-local/src/server/execute.ts | 14 +++- .../adapters/gemini-local/src/server/index.ts | 3 +- .../gemini-local/src/server/parse.test.ts | 72 ++++++++++++++++++- .../adapters/gemini-local/src/server/parse.ts | 16 ++++- .../adapters/gemini-local/vitest.config.ts | 7 ++ .../__tests__/adapter-session-codecs.test.ts | 8 +-- .../__tests__/gemini-local-adapter.test.ts | 6 +- 7 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 packages/adapters/gemini-local/vitest.config.ts diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index df20937e3ca..12ec7ad0337 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -51,8 +51,9 @@ import { DEFAULT_GEMINI_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js import { describeGeminiFailure, detectGeminiAuthRequired, + isGeminiTransientNetworkError, isGeminiTurnLimitResult, - isGeminiUnknownSessionError, + isGeminiSessionUnrecoverableError, parseGeminiJsonl, } from "./parse.js"; import { firstNonEmptyLine } from "./utils.js"; @@ -569,6 +570,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { it("collects assistant text from message events with string content", () => { @@ -131,3 +135,69 @@ describe("parseGeminiJsonl", () => { expect(result.errorMessage).toBe("boom"); }); }); + +describe("isGeminiSessionUnrecoverableError", () => { + it("matches 'unknown session'", () => { + expect(isGeminiSessionUnrecoverableError("", "Error: unknown session 'abc-123'")).toBe(true); + }); + + it("matches 'session ... not found'", () => { + expect(isGeminiSessionUnrecoverableError("", "Resumed session abc-123 not found on disk")).toBe(true); + }); + + it("matches 'exceeds the maximum number of tokens' (compression overflow)", () => { + const stderr = + '_ApiError: {"error":{"code":400,"message":"The input token count exceeds the maximum number of tokens allowed 1048576","status":"INVALID_ARGUMENT"}} at ChatCompressionService.compress'; + expect(isGeminiSessionUnrecoverableError("", stderr)).toBe(true); + }); + + it("matches 'input token count exceeds'", () => { + expect( + isGeminiSessionUnrecoverableError("", "input token count exceeds maximum"), + ).toBe(true); + }); + + it("does not match unrelated stderr", () => { + expect(isGeminiSessionUnrecoverableError("", "Some other error")).toBe(false); + }); + + it("does not match transient network errors (those go to isGeminiTransientNetworkError)", () => { + expect( + isGeminiSessionUnrecoverableError( + "", + "_GaxiosError: getaddrinfo ENOTFOUND oauth2.googleapis.com", + ), + ).toBe(false); + }); +}); + +describe("isGeminiTransientNetworkError", () => { + it("matches DNS failure on oauth2.googleapis.com", () => { + const stderr = + "_GaxiosError: request to https://oauth2.googleapis.com/token failed, reason: getaddrinfo ENOTFOUND oauth2.googleapis.com"; + expect(isGeminiTransientNetworkError("", stderr)).toBe(true); + }); + + it("matches EAI_AGAIN", () => { + expect( + isGeminiTransientNetworkError("", "Error: getaddrinfo EAI_AGAIN sts.googleapis.com"), + ).toBe(true); + }); + + it("matches _UserRefreshClient ENOTFOUND", () => { + const stderr = + "at _UserRefreshClient.refreshTokenNoCache (.../google-auth-library/...)\n" + + " caused by: ENOTFOUND oauth2.googleapis.com"; + expect(isGeminiTransientNetworkError("", stderr)).toBe(true); + }); + + it("does not match unrelated stderr", () => { + expect(isGeminiTransientNetworkError("", "Some other error")).toBe(false); + }); + + it("does not match unknown-session errors (those go to isGeminiSessionUnrecoverableError)", () => { + expect( + isGeminiTransientNetworkError("", "Error: unknown session 'abc-123'"), + ).toBe(false); + }); +}); diff --git a/packages/adapters/gemini-local/src/server/parse.ts b/packages/adapters/gemini-local/src/server/parse.ts index d309c8c4a43..93686b619f8 100644 --- a/packages/adapters/gemini-local/src/server/parse.ts +++ b/packages/adapters/gemini-local/src/server/parse.ts @@ -199,14 +199,26 @@ export function parseGeminiJsonl(stdout: string) { }; } -export function isGeminiUnknownSessionError(stdout: string, stderr: string): boolean { +export function isGeminiSessionUnrecoverableError(stdout: string, stderr: string): boolean { const haystack = `${stdout}\n${stderr}` .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .join("\n"); - return /unknown\s+session|session\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|checkpoint\s+.*\s+not\s+found|cannot\s+resume|failed\s+to\s+resume/i.test( + return /unknown\s+session|session\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|checkpoint\s+.*\s+not\s+found|cannot\s+resume|failed\s+to\s+resume|exceeds\s+the\s+maximum\s+number\s+of\s+tokens|input\s+token\s+count\s+exceeds/i.test( + haystack, + ); +} + +export function isGeminiTransientNetworkError(stdout: string, stderr: string): boolean { + const haystack = `${stdout}\n${stderr}` + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n"); + + return /ENOTFOUND\s+oauth2\.googleapis\.com|ENOTFOUND\s+sts\.googleapis\.com|EAI_AGAIN|_GaxiosError.*ENOTFOUND|_UserRefreshClient.*ENOTFOUND/i.test( haystack, ); } diff --git a/packages/adapters/gemini-local/vitest.config.ts b/packages/adapters/gemini-local/vitest.config.ts new file mode 100644 index 00000000000..f624398e8de --- /dev/null +++ b/packages/adapters/gemini-local/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/server/src/__tests__/adapter-session-codecs.test.ts b/server/src/__tests__/adapter-session-codecs.test.ts index 10905e5193f..fd5fa60e7ae 100644 --- a/server/src/__tests__/adapter-session-codecs.test.ts +++ b/server/src/__tests__/adapter-session-codecs.test.ts @@ -7,7 +7,7 @@ import { } from "@paperclipai/adapter-cursor-local/server"; import { sessionCodec as geminiSessionCodec, - isGeminiUnknownSessionError, + isGeminiSessionUnrecoverableError, } from "@paperclipai/adapter-gemini-local/server"; import { sessionCodec as opencodeSessionCodec, @@ -220,19 +220,19 @@ describe("cursor resume recovery detection", () => { describe("gemini resume recovery detection", () => { it("detects unknown session errors from gemini output", () => { expect( - isGeminiUnknownSessionError( + isGeminiSessionUnrecoverableError( "", "unknown session id abc", ), ).toBe(true); expect( - isGeminiUnknownSessionError( + isGeminiSessionUnrecoverableError( "", "checkpoint latest not found", ), ).toBe(true); expect( - isGeminiUnknownSessionError( + isGeminiSessionUnrecoverableError( "{\"type\":\"result\",\"subtype\":\"success\"}", "", ), diff --git a/server/src/__tests__/gemini-local-adapter.test.ts b/server/src/__tests__/gemini-local-adapter.test.ts index 96e2e8ff1bc..bc2d4643d8b 100644 --- a/server/src/__tests__/gemini-local-adapter.test.ts +++ b/server/src/__tests__/gemini-local-adapter.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { isGeminiTurnLimitResult, - isGeminiUnknownSessionError, + isGeminiSessionUnrecoverableError, parseGeminiJsonl, } from "@paperclipai/adapter-gemini-local/server"; import { parseGeminiStdoutLine } from "@paperclipai/adapter-gemini-local/ui"; @@ -76,8 +76,8 @@ describe("gemini_local parser", () => { describe("gemini_local stale session detection", () => { it("treats missing session messages as an unknown session error", () => { - expect(isGeminiUnknownSessionError("", "unknown session id abc")).toBe(true); - expect(isGeminiUnknownSessionError("", "checkpoint latest not found")).toBe(true); + expect(isGeminiSessionUnrecoverableError("", "unknown session id abc")).toBe(true); + expect(isGeminiSessionUnrecoverableError("", "checkpoint latest not found")).toBe(true); }); });