diff --git a/sdk/src/__tests__/run-state-git-changes.test.ts b/sdk/src/__tests__/run-state-git-changes.test.ts index f26a098c76..c683cf01df 100644 --- a/sdk/src/__tests__/run-state-git-changes.test.ts +++ b/sdk/src/__tests__/run-state-git-changes.test.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'events' import { describe, expect, it } from 'bun:test' +import z from 'zod/v4' import { applyOverridesToSessionState, @@ -194,6 +195,78 @@ describe('repository snapshot persistence', () => { 'AGENTS.md': 'Updated instructions', }) }) + + it('clones a resumed state whose run state carries a cycle', async () => { + // Resuming used to die on the second interaction with "JSON.stringify + // cannot serialize cyclic structures". The state carries the previous + // turn's tool blocks, and a recursive zod `lazy` schema in one of them is + // self-referential, so the clone has to survive a cycle. + const baseSessionState = getInitialSessionState(getStubProjectFileContext()) + const circular: Record = {} + circular.self = circular + ;(baseSessionState.mainAgentState as any).output = circular + + const continuedSessionState = await applyOverridesToSessionState( + '/repo', + baseSessionState, + { maxAgentSteps: 7 }, + ) + + // The overrides still apply... + expect(continuedSessionState.mainAgentState.stepsRemaining).toBe(7) + // ...and the result is an independent copy whose cycle came across intact. + expect(continuedSessionState.mainAgentState).not.toBe( + baseSessionState.mainAgentState, + ) + const clonedOutput = (continuedSessionState.mainAgentState as any).output + expect(clonedOutput.self).toBe(clonedOutput) + }) + + it('keeps a live zod schema intact when the fallback clone runs', async () => { + // The fallback runs exactly when the state is cyclic, and what makes it + // cyclic is a zod schema: mcp.ts stores MCP tools as live zod schemas, and + // a zod schema is self-referential. lodash copies own *enumerable* + // properties only, while zod keeps its internals on a non-enumerable + // `_zod`, so a plain cloneDeep hands back something that still looks like a + // schema -- `safeParse` comes from the prototype -- but throws on the first + // zod call with "undefined is not an object (evaluating '_zod.parent')". + const schema = z.object({ path: z.string() }) + const baseSessionState = getInitialSessionState(getStubProjectFileContext()) + const circular: Record = { schema } + circular.self = circular + ;(baseSessionState.mainAgentState as any).output = circular + + const continuedSessionState = await applyOverridesToSessionState( + '/repo', + baseSessionState, + {}, + ) + + const clonedSchema = (continuedSessionState.mainAgentState as any).output + .schema + expect(clonedSchema._zod).toBeDefined() + expect(z.toJSONSchema(clonedSchema, { io: 'input' })).toMatchObject({ + type: 'object', + }) + }) + + it('falls back to a deep copy for values JSON.stringify rejects (BigInt)', async () => { + // The fallback is broader than "cycles only": BigInt makes JSON.stringify + // throw too, and cloneDeep preserves it rather than dropping the key. + const baseSessionState = getInitialSessionState(getStubProjectFileContext()) + ;(baseSessionState.mainAgentState as any).output = { token: 10n } + + const continuedSessionState = await applyOverridesToSessionState( + '/repo', + baseSessionState, + {}, + ) + + expect((continuedSessionState.mainAgentState as any).output.token).toBe(10n) + expect(continuedSessionState.mainAgentState).not.toBe( + baseSessionState.mainAgentState, + ) + }) }) describe('isTestFilePath', () => { diff --git a/sdk/src/run-state.ts b/sdk/src/run-state.ts index c812115ba1..d9b026e7ac 100644 --- a/sdk/src/run-state.ts +++ b/sdk/src/run-state.ts @@ -13,7 +13,7 @@ import { } from '@codebuff/common/project-file-tree' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { getErrorObject } from '@codebuff/common/util/error' -import { cloneDeep } from 'lodash' +import { cloneDeep, cloneDeepWith } from 'lodash' import z from 'zod/v4' import { loadLocalAgents } from './agents/load-agents' @@ -1097,6 +1097,33 @@ export function withMessageHistory({ return newRunState } +/** + * True for a zod schema, which is the only kind of value a clone has to be + * careful with here. + */ +const isZodSchema = (value: unknown): boolean => { + if (typeof value !== 'object' || value === null) return false + const candidate = value as { safeParse?: unknown; _zod?: unknown } + return ( + typeof candidate.safeParse === 'function' && candidate._zod !== undefined + ) +} + +/** + * Deep-clones a value without destroying the zod schemas inside it. + * + * zod installs its internals on a non-enumerable `_zod` property, and lodash + * copies own *enumerable* properties only. A plain `cloneDeep` therefore + * returns something that still looks like a schema -- `safeParse` lives on the + * prototype, so it survives -- but has lost `_zod`, and the next zod call on it + * throws "undefined is not an object (evaluating 'schema._zod.parent')". + * Schemas are immutable, so the clone keeps them as they are. + */ +export const cloneDeepPreservingZodSchemas = (value: T): T => + cloneDeepWith(value, (candidate) => + isZodSchema(candidate) ? candidate : undefined, + ) + /** * Applies overrides to an existing session state, allowing specific fields to be updated * even when continuing from a previous run. @@ -1115,10 +1142,33 @@ export async function applyOverridesToSessionState( maxAgentSteps?: number }, ): Promise { - // Deep clone to avoid mutating the original session state - const sessionState = JSON.parse( - JSON.stringify(baseSessionState), - ) as SessionState + // Deep clone to avoid mutating the original session state. The JSON + // round-trip is the fast path - it matches what a persisted snapshot looks + // like - but this state can hold values JSON.stringify rejects. A resumed + // session carries the tool blocks of the turn before it, and a recursive zod + // `lazy` schema is cyclic: stringifying it throws "cannot serialize cyclic + // structures" and the resume dies. lodash cloneDeep handles cycles, so fall + // back to it and keep the clone working instead of losing the session. + // + // The catch is deliberately not narrowed, but it is narrower than "any JSON + // failure": in practice the throw comes from a cycle or a BigInt. Values JSON + // merely drops or coerces - `undefined`, functions, symbols, Date, URL - do + // not throw, so they keep taking the JSON path exactly as they did before and + // never reach cloneDeep. The fallback therefore only runs where this function + // used to throw: it repairs the resume that used to fail rather than changing + // one that used to work. On that path cloneDeep keeps the whole graph, which + // is the safe side to err on, since the clone is this turn's working copy. + // It goes through cloneDeepPreservingZodSchemas rather than plain cloneDeep: + // the values that make this state cyclic are the live zod schemas of the + // previous turn's MCP tools, and lodash drops a schema's non-enumerable + // internals, leaving a schema-shaped object that throws on first use. + // Mirrors cloneSessionState, which falls back the same way in run.ts. + let sessionState: SessionState + try { + sessionState = JSON.parse(JSON.stringify(baseSessionState)) as SessionState + } catch { + sessionState = cloneDeepPreservingZodSchemas(baseSessionState) + } // Apply maxAgentSteps override if (overrides.maxAgentSteps !== undefined) {