Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions sdk/src/__tests__/run-state-git-changes.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { EventEmitter } from 'events'

import { describe, expect, it } from 'bun:test'
import z from 'zod/v4'

import {
applyOverridesToSessionState,
Expand Down Expand Up @@ -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<string, unknown> = {}
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<string, unknown> = { 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', () => {
Expand Down
60 changes: 55 additions & 5 deletions sdk/src/run-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 = <T>(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.
Expand All @@ -1115,10 +1142,33 @@ export async function applyOverridesToSessionState(
maxAgentSteps?: number
},
): Promise<SessionState> {
// 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) {
Expand Down
Loading