Skip to content

Commit 2032c4d

Browse files
committed
feat(evals): record and replay live model transcripts
Record a live run once, replay it forever through the real tool loop with no key. EVAL_RECORD=1 wraps the live completion and writes each model call's streamed chunks to fixtures/<scenario>.json; agent-tool-use.replay.test.ts feeds them back through createOpenAICompatStreamingToolLoopStream and scores them with the same checks. - replay.ts: recording/replay completions + fixture I/O - replay.test.ts: chunk round-trip and fixture I/O (key-free) - live test records on EVAL_RECORD=1; test:evals:record script - replay suite skips until a fixture exists; README documents the loop
1 parent c93426b commit 2032c4d

6 files changed

Lines changed: 284 additions & 1 deletion

File tree

‎apps/sim/evals/README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ Useful knobs:
6666
| `EVAL_MODEL` | `deepseek-chat` | Model id sent to the provider. |
6767
| `EVAL_TIMEOUT_MS` | `180000` | Per-request timeout. |
6868
| `EVAL_REPORT_PATH` | `test-results/evals/agent-tool-use-live.json` | Report location. |
69+
| `EVAL_RECORD` | `0` | Set to `1` to also write the first trial's transcript to `fixtures/`. |
6970

7071
Live runs relax exact assertions: `toolCallSequence` becomes an ordered
7172
subsequence, `successfulToolCalls` becomes a minimum, and scripted-only cases
@@ -75,6 +76,24 @@ example, an exact retry count). The report is at
7576
`test-results/evals/agent-tool-use-live.{json,md}` with pass rates, average
7677
iterations, latency, and the failed check names.
7778

79+
### Record and replay
80+
81+
A live run is nondeterministic and needs a key; a fixture is neither. Record one
82+
trial, then replay it forever through the real loop with no network:
83+
84+
```sh
85+
cd apps/sim
86+
EVAL_RECORD=1 DEEPSEEK_API_KEY=... bun run test:evals:live # writes fixtures/*.json
87+
bun run test:evals # replays them, no key
88+
```
89+
90+
`fixtures/<scenario>.json` holds the raw streamed chunks per model call, so a
91+
diff shows a behavior change exactly as the model produced it. Fixtures are
92+
committed and reviewed like snapshots. `agent-tool-use.replay.test.ts` replays
93+
each one through `createOpenAICompatStreamingToolLoopStream` and scores it with
94+
the same checks; the suite skips until at least one fixture exists. Re-record a
95+
fixture when the scenario, prompt, or model intentionally changes.
96+
7897
## Add a case
7998

8099
1. Open [`agent-tool-use/scenarios.ts`](./agent-tool-use/scenarios.ts) and add

‎apps/sim/evals/agent-tool-use/agent-tool-use.live.test.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1+
import { fileURLToPath } from 'node:url'
12
import { providersMock } from '@sim/testing/mocks/providers.mock'
23
import { providersConversationHistoryMock } from '@sim/testing/mocks/providers-conversation-history.mock'
34
import { providersUtilsMock } from '@sim/testing/mocks/providers-utils.mock'
45
import { toolsMock } from '@sim/testing/mocks/tools.mock'
6+
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
57
import { afterAll, describe, expect, it, vi } from 'vitest'
68
import { runScenario } from '@/evals/agent-tool-use/harness'
79
import { createDeepSeekLiveCompletion } from '@/evals/agent-tool-use/live'
10+
import {
11+
createRecordingCompletion,
12+
type ReplayFixture,
13+
writeReplayFixture,
14+
} from '@/evals/agent-tool-use/replay'
815
import { writeLiveEvalReport } from '@/evals/agent-tool-use/report'
916
import { AGENT_TOOL_USE_SCENARIOS } from '@/evals/agent-tool-use/scenarios'
1017
import type { AgentToolUseResult, LiveScenarioSummary } from '@/evals/agent-tool-use/types'
@@ -23,18 +30,26 @@ vi.mock('@/providers', () => providersMock)
2330
* Each scenario runs `EVAL_TRIALS` times (default 3) because a real model is
2431
* nondeterministic. The report carries pass rates, not a single boolean. Set
2532
* `EVAL_MIN_PASS_RATE` (0–1) to turn a pass-rate floor into a failing gate.
33+
*
34+
* Add `EVAL_RECORD=1` to also write the first trial's transcript to
35+
* `fixtures/<scenario>.json`, which `agent-tool-use.replay.test.ts` replays
36+
* offline with no key.
2637
*/
2738
const LIVE = process.env.EVAL_LIVE === '1' && Boolean(process.env.DEEPSEEK_API_KEY)
39+
const RECORD = process.env.EVAL_RECORD === '1'
2840
const TRIALS = Number(process.env.EVAL_TRIALS ?? '3')
2941
const MIN_PASS_RATE = Number(process.env.EVAL_MIN_PASS_RATE ?? '0')
3042
const MODEL = process.env.EVAL_MODEL ?? 'deepseek-chat'
3143
const TIMEOUT_MS = Number(process.env.EVAL_TIMEOUT_MS ?? '180000')
44+
const FIXTURES_DIR = fileURLToPath(new URL('./fixtures', import.meta.url))
3245

3346
const liveScenarios = AGENT_TOOL_USE_SCENARIOS.filter((scenario) => !scenario.scriptedOnly)
3447
const summaries: LiveScenarioSummary[] = []
48+
const fixturesToWrite: ReplayFixture[] = []
3549

3650
afterAll(() => {
3751
if (!LIVE) return
52+
for (const fixture of fixturesToWrite) writeReplayFixture(FIXTURES_DIR, fixture)
3853
writeLiveEvalReport(
3954
summaries,
4055
process.env.EVAL_REPORT_PATH ?? 'test-results/evals/agent-tool-use-live.json'
@@ -45,10 +60,16 @@ describe.skipIf(!LIVE)('agent tool-use eval suite (live DeepSeek)', () => {
4560
it.each(liveScenarios)(
4661
'$id: $name',
4762
async (scenario) => {
48-
const completion = createDeepSeekLiveCompletion(MODEL)
63+
const base = createDeepSeekLiveCompletion(MODEL)
4964
const results: AgentToolUseResult[] = []
5065

5166
for (let trial = 0; trial < TRIALS; trial++) {
67+
const recordedTurns: ChatCompletionChunk[][] = []
68+
const completion =
69+
RECORD && trial === 0
70+
? createRecordingCompletion(base, (turn) => recordedTurns.push(turn))
71+
: base
72+
5273
results.push(
5374
await runScenario(scenario, {
5475
completion,
@@ -57,6 +78,15 @@ describe.skipIf(!LIVE)('agent tool-use eval suite (live DeepSeek)', () => {
5778
providerName: 'DeepSeek',
5879
})
5980
)
81+
82+
if (RECORD && trial === 0) {
83+
fixturesToWrite.push({
84+
scenarioId: scenario.id,
85+
model: MODEL,
86+
recordedAt: new Date().toISOString(),
87+
turns: recordedTurns,
88+
})
89+
}
6090
}
6191

6292
const passed = results.filter((result) => result.passed).length
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { fileURLToPath } from 'node:url'
2+
import { providersMock } from '@sim/testing/mocks/providers.mock'
3+
import { providersConversationHistoryMock } from '@sim/testing/mocks/providers-conversation-history.mock'
4+
import { providersUtilsMock } from '@sim/testing/mocks/providers-utils.mock'
5+
import { toolsMock } from '@sim/testing/mocks/tools.mock'
6+
import { describe, expect, it, vi } from 'vitest'
7+
import { runScenario } from '@/evals/agent-tool-use/harness'
8+
import {
9+
createReplayCompletion,
10+
listReplayFixtures,
11+
type ReplayFixture,
12+
} from '@/evals/agent-tool-use/replay'
13+
import { AGENT_TOOL_USE_SCENARIOS } from '@/evals/agent-tool-use/scenarios'
14+
import type { AgentToolUseScenario } from '@/evals/agent-tool-use/types'
15+
16+
vi.mock('@/providers/conversation-history', () => providersConversationHistoryMock)
17+
vi.mock('@/tools', () => toolsMock)
18+
vi.mock('@/providers/utils', () => providersUtilsMock)
19+
vi.mock('@/providers', () => providersMock)
20+
21+
/**
22+
* Replays recorded live transcripts through the real tool loop — no key, no
23+
* network. Fixtures are written by the live suite with `EVAL_RECORD=1`; see the
24+
* README. The suite skips until at least one fixture is committed.
25+
*/
26+
const FIXTURES_DIR = fileURLToPath(new URL('./fixtures', import.meta.url))
27+
28+
const replayCases: Array<{
29+
id: string
30+
fixture: ReplayFixture
31+
scenario: AgentToolUseScenario
32+
}> = []
33+
for (const fixture of listReplayFixtures(FIXTURES_DIR)) {
34+
const scenario = AGENT_TOOL_USE_SCENARIOS.find((candidate) => candidate.id === fixture.scenarioId)
35+
if (scenario) replayCases.push({ id: fixture.scenarioId, fixture, scenario })
36+
}
37+
38+
describe.skipIf(replayCases.length === 0)('agent tool-use replay suite', () => {
39+
it.each(replayCases)('$id replays deterministically', async ({ fixture, scenario }) => {
40+
const result = await runScenario(scenario, {
41+
completion: createReplayCompletion(fixture.turns),
42+
mode: 'live',
43+
model: fixture.model,
44+
providerName: 'Replay',
45+
})
46+
47+
const failed = result.checks.filter((entry) => !entry.passed)
48+
expect(
49+
failed,
50+
failed.map((entry) => `${entry.name}: ${entry.detail}`).join('; ') || undefined
51+
).toEqual([])
52+
})
53+
})
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
5+
import { describe, expect, it } from 'vitest'
6+
import {
7+
createRecordingCompletion,
8+
createReplayCompletion,
9+
listReplayFixtures,
10+
replayFixturePath,
11+
writeReplayFixture,
12+
} from '@/evals/agent-tool-use/replay'
13+
import type { OpenAICompatCreateCompletion } from '@/providers/openai-compat/streaming-tool-loop'
14+
15+
function chunk(text: string): ChatCompletionChunk {
16+
return {
17+
id: 'chunk',
18+
object: 'chat.completion.chunk',
19+
created: 0,
20+
model: 'test',
21+
choices: [{ index: 0, delta: { content: text }, finish_reason: 'stop', logprobs: null }],
22+
}
23+
}
24+
25+
/** A completion that yields each turn's chunks, one turn per call. */
26+
function stagedCompletion(turns: ChatCompletionChunk[][]): OpenAICompatCreateCompletion {
27+
let index = 0
28+
return async () => {
29+
const turn = turns[index]
30+
index += 1
31+
return (async function* () {
32+
for (const item of turn) yield item
33+
})()
34+
}
35+
}
36+
37+
async function drain(iterable: AsyncIterable<ChatCompletionChunk>): Promise<ChatCompletionChunk[]> {
38+
const seen: ChatCompletionChunk[] = []
39+
for await (const item of iterable) seen.push(item)
40+
return seen
41+
}
42+
43+
describe('replay primitives', () => {
44+
it('record then replay preserves every chunk of every turn', async () => {
45+
const original = [[chunk('a'), chunk('b')], [chunk('c')]]
46+
const recorded: ChatCompletionChunk[][] = []
47+
const recording = createRecordingCompletion(stagedCompletion(original), (turn) =>
48+
recorded.push(turn)
49+
)
50+
51+
const first = await drain(await recording({} as never))
52+
const second = await drain(await recording({} as never))
53+
54+
expect(first).toEqual(original[0])
55+
expect(second).toEqual(original[1])
56+
expect(recorded).toEqual(original)
57+
58+
const replay = createReplayCompletion(recorded)
59+
expect(await drain(await replay({} as never))).toEqual(original[0])
60+
expect(await drain(await replay({} as never))).toEqual(original[1])
61+
})
62+
63+
it('rejects when the fixture is exhausted', async () => {
64+
const replay = createReplayCompletion([[chunk('only')]])
65+
await drain(await replay({} as never))
66+
await expect(async () => replay({} as never)).rejects.toThrow(
67+
'Replay fixture exhausted after 1 model turns'
68+
)
69+
})
70+
71+
it('writes and lists fixtures from a directory', () => {
72+
const directory = mkdtempSync(join(tmpdir(), 'eval-replay-'))
73+
try {
74+
writeReplayFixture(directory, {
75+
scenarioId: 'single-tool-lookup',
76+
model: 'deepseek-chat',
77+
recordedAt: '2026-01-01T00:00:00.000Z',
78+
turns: [[chunk('a')]],
79+
})
80+
81+
expect(existsSync(replayFixturePath(directory, 'single-tool-lookup'))).toBe(true)
82+
const fixtures = listReplayFixtures(directory)
83+
expect(fixtures).toHaveLength(1)
84+
expect(fixtures[0]).toMatchObject({
85+
scenarioId: 'single-tool-lookup',
86+
model: 'deepseek-chat',
87+
})
88+
expect(fixtures[0].turns).toEqual([[chunk('a')]])
89+
} finally {
90+
rmSync(directory, { recursive: true, force: true })
91+
}
92+
})
93+
94+
it('returns no fixtures for a missing directory', () => {
95+
expect(listReplayFixtures(join(tmpdir(), 'eval-replay-does-not-exist'))).toEqual([])
96+
})
97+
})
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
2+
import { join } from 'node:path'
3+
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
4+
import type { OpenAICompatCreateCompletion } from '@/providers/openai-compat/streaming-tool-loop'
5+
6+
/**
7+
* Record a live model once, replay it forever.
8+
*
9+
* The model is the only thing captured. Tools stay stubbed and the loop stays
10+
* real, so a recorded run is a deterministic, key-free regression test for the
11+
* behavior a live run measured.
12+
*/
13+
14+
/** One scenario's recorded transcript. */
15+
export interface ReplayFixture {
16+
scenarioId: string
17+
model: string
18+
recordedAt: string
19+
/** One entry per model call; each holds the chunks that call streamed. */
20+
turns: ChatCompletionChunk[][]
21+
}
22+
23+
export function replayFixturePath(directory: string, scenarioId: string): string {
24+
return join(directory, `${scenarioId}.json`)
25+
}
26+
27+
export function writeReplayFixture(directory: string, fixture: ReplayFixture): void {
28+
mkdirSync(directory, { recursive: true })
29+
writeFileSync(
30+
replayFixturePath(directory, fixture.scenarioId),
31+
`${JSON.stringify(fixture, null, 2)}\n`
32+
)
33+
}
34+
35+
export function readReplayFixture(file: string): ReplayFixture {
36+
return JSON.parse(readFileSync(file, 'utf8')) as ReplayFixture
37+
}
38+
39+
/** Every fixture in a directory, or none when the directory does not exist yet. */
40+
export function listReplayFixtures(directory: string): ReplayFixture[] {
41+
if (!existsSync(directory)) return []
42+
return readdirSync(directory)
43+
.filter((name) => name.endsWith('.json'))
44+
.map((name) => readReplayFixture(join(directory, name)))
45+
}
46+
47+
/**
48+
* Wraps a live completion and records the chunks each model call streams.
49+
* `record` runs after a turn completes, so a failed call records nothing.
50+
*/
51+
export function createRecordingCompletion(
52+
inner: OpenAICompatCreateCompletion,
53+
record: (turn: ChatCompletionChunk[]) => void
54+
): OpenAICompatCreateCompletion {
55+
return async (params, options) => {
56+
const iterable = await inner(params, options)
57+
const chunks: ChatCompletionChunk[] = []
58+
return (async function* () {
59+
for await (const chunk of iterable) {
60+
chunks.push(chunk)
61+
yield chunk
62+
}
63+
record(chunks)
64+
})()
65+
}
66+
}
67+
68+
/** Replays a fixture's recorded turns in order, one per model call. */
69+
export function createReplayCompletion(
70+
turns: ChatCompletionChunk[][]
71+
): OpenAICompatCreateCompletion {
72+
let index = 0
73+
return async () => {
74+
const turn = turns[index]
75+
index += 1
76+
if (!turn) {
77+
throw new Error(`Replay fixture exhausted after ${turns.length} model turns`)
78+
}
79+
return (async function* () {
80+
for (const chunk of turn) yield chunk
81+
})()
82+
}
83+
}

‎apps/sim/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"test:coverage": "vitest run --coverage",
2727
"test:evals": "EVAL_REPORT_PATH=test-results/evals/agent-tool-use.json vitest run evals/agent-tool-use",
2828
"test:evals:live": "EVAL_LIVE=1 vitest run --mode live evals/agent-tool-use",
29+
"test:evals:record": "EVAL_LIVE=1 EVAL_RECORD=1 vitest run --mode live evals/agent-tool-use",
2930
"email:dev": "email dev --dir components/emails",
3031
"type-check": "tsc --noEmit",
3132
"lint": "biome check --write --unsafe .",

0 commit comments

Comments
 (0)