Skip to content

Commit 1d882db

Browse files
authored
feat(core): replay normalized transcripts (#1658)
1 parent 6b4d014 commit 1d882db

53 files changed

Lines changed: 663 additions & 432 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/cli/src/commands/eval/run-eval.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2274,9 +2274,29 @@ export async function runEvalCommand(
22742274
(sum, meta) => sum + meta.testCases.length,
22752275
0,
22762276
);
2277+
const evalTestIds = [...fileMetadata.values()].flatMap((meta) =>
2278+
meta.testCases.map((testCase) => testCase.id),
2279+
);
22772280
if (transcriptProvider.lineCount !== totalTests) {
22782281
throw new Error(
2279-
`Transcript has ${transcriptProvider.lineCount} entr${transcriptProvider.lineCount === 1 ? 'y' : 'ies'} but eval defines ${totalTests} test(s). Each transcript entry maps positionally to one test case.`,
2282+
`Transcript has ${transcriptProvider.lineCount} entr${transcriptProvider.lineCount === 1 ? 'y' : 'ies'} but eval defines ${totalTests} test(s). Each transcript entry must map to one test case by test_id.`,
2283+
);
2284+
}
2285+
const transcriptTestIds = new Set(transcriptProvider.testIds);
2286+
const evalTestIdSet = new Set(evalTestIds);
2287+
const missing = evalTestIds.filter((testId) => !transcriptTestIds.has(testId));
2288+
const extra = transcriptProvider.testIds.filter((testId) => !evalTestIdSet.has(testId));
2289+
if (missing.length > 0 || extra.length > 0) {
2290+
throw new Error(
2291+
[
2292+
'Transcript test_id values must match eval test ids for replay.',
2293+
missing.length > 0 ? `Missing transcript entries: ${missing.join(', ')}` : undefined,
2294+
extra.length > 0
2295+
? `Transcript entries without eval tests: ${extra.join(', ')}`
2296+
: undefined,
2297+
]
2298+
.filter((line): line is string => line !== undefined)
2299+
.join(' '),
22802300
);
22812301
}
22822302

apps/cli/src/commands/import/claude.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ export const importClaudeCommand = command({
2929
description:
3030
'Output file path (default: .agentv/transcripts/claude-<session-id-short>.jsonl)',
3131
}),
32+
testId: option({
33+
type: optional(string),
34+
long: 'test-id',
35+
description: 'Set the transcript test_id to match an eval test id',
36+
}),
37+
target: option({
38+
type: optional(string),
39+
long: 'target',
40+
description: 'Set the transcript target/source_target value (default: claude)',
41+
}),
3242
projectsDir: option({
3343
type: optional(string),
3444
long: 'projects-dir',
@@ -39,7 +49,7 @@ export const importClaudeCommand = command({
3949
description: 'List available sessions instead of importing',
4050
}),
4151
},
42-
handler: async ({ sessionId, projectPath, output, projectsDir, list }) => {
52+
handler: async ({ sessionId, projectPath, output, testId, target, projectsDir, list }) => {
4353
if (list) {
4454
const sessions = await discoverClaudeSessions({
4555
projectPath,
@@ -95,7 +105,7 @@ export const importClaudeCommand = command({
95105
await mkdir(path.dirname(outputPath), { recursive: true });
96106

97107
// Write transcript as JSONL (one message per line, grouped by test_id)
98-
const jsonLines = toTranscriptJsonLines(transcript);
108+
const jsonLines = toTranscriptJsonLines(transcript, transcriptWriteOptions(testId, target));
99109
await writeFile(
100110
outputPath,
101111
`${jsonLines.map((line) => JSON.stringify(line)).join('\n')}\n`,
@@ -131,6 +141,16 @@ function formatAge(date: Date): string {
131141
return `${diffDays}d ago`;
132142
}
133143

144+
function transcriptWriteOptions(
145+
testId: string | undefined,
146+
target: string | undefined,
147+
): { testId?: string; target?: string } {
148+
return {
149+
...(testId ? { testId } : {}),
150+
...(target ? { target } : {}),
151+
};
152+
}
153+
134154
function formatDurationMs(ms: number): string {
135155
if (ms < 1000) return `${ms}ms`;
136156
const seconds = Math.floor(ms / 1000);

apps/cli/src/commands/import/codex.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ export const importCodexCommand = command({
2828
short: 'o',
2929
description: 'Output file path (default: .agentv/transcripts/codex-<timestamp>.jsonl)',
3030
}),
31+
testId: option({
32+
type: optional(string),
33+
long: 'test-id',
34+
description: 'Set the transcript test_id to match an eval test id',
35+
}),
36+
target: option({
37+
type: optional(string),
38+
long: 'target',
39+
description: 'Set the transcript target/source_target value (default: codex)',
40+
}),
3141
sessionsDir: option({
3242
type: optional(string),
3343
long: 'sessions-dir',
@@ -38,7 +48,7 @@ export const importCodexCommand = command({
3848
description: 'List available sessions instead of importing',
3949
}),
4050
},
41-
handler: async ({ sessionId, date, output, sessionsDir, list }) => {
51+
handler: async ({ sessionId, date, output, testId, target, sessionsDir, list }) => {
4252
if (list) {
4353
const sessions = await discoverCodexSessions({
4454
date,
@@ -92,7 +102,7 @@ export const importCodexCommand = command({
92102
await mkdir(path.dirname(outputPath), { recursive: true });
93103

94104
// Write transcript as JSONL (one message per line, grouped by test_id)
95-
const jsonLines = toTranscriptJsonLines(transcript);
105+
const jsonLines = toTranscriptJsonLines(transcript, transcriptWriteOptions(testId, target));
96106
await writeFile(
97107
outputPath,
98108
`${jsonLines.map((line) => JSON.stringify(line)).join('\n')}\n`,
@@ -123,6 +133,16 @@ function formatAge(date: Date): string {
123133
return `${diffDays}d ago`;
124134
}
125135

136+
function transcriptWriteOptions(
137+
testId: string | undefined,
138+
target: string | undefined,
139+
): { testId?: string; target?: string } {
140+
return {
141+
...(testId ? { testId } : {}),
142+
...(target ? { target } : {}),
143+
};
144+
}
145+
126146
function formatDurationMs(ms: number): string {
127147
if (ms < 1000) return `${ms}ms`;
128148
const seconds = Math.floor(ms / 1000);

apps/cli/src/commands/import/copilot.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ export const importCopilotCommand = command({
1919
description:
2020
'Output file path (default: .agentv/transcripts/copilot-<session-id-short>.jsonl)',
2121
}),
22+
testId: option({
23+
type: optional(string),
24+
long: 'test-id',
25+
description: 'Set the transcript test_id to match an eval test id',
26+
}),
27+
target: option({
28+
type: optional(string),
29+
long: 'target',
30+
description: 'Set the transcript target/source_target value (default: copilot)',
31+
}),
2232
sessionStateDir: option({
2333
type: optional(string),
2434
long: 'session-state-dir',
@@ -29,7 +39,7 @@ export const importCopilotCommand = command({
2939
description: 'List available sessions instead of importing',
3040
}),
3141
},
32-
handler: async ({ sessionId, output, sessionStateDir, list }) => {
42+
handler: async ({ sessionId, output, testId, target, sessionStateDir, list }) => {
3343
if (list) {
3444
const sessions = await discoverCopilotSessions({
3545
sessionStateDir,
@@ -100,7 +110,7 @@ export const importCopilotCommand = command({
100110
await mkdir(path.dirname(outputPath), { recursive: true });
101111

102112
// Write transcript as JSONL (one message per line, grouped by test_id)
103-
const jsonLines = toTranscriptJsonLines(transcript);
113+
const jsonLines = toTranscriptJsonLines(transcript, transcriptWriteOptions(testId, target));
104114
await writeFile(
105115
outputPath,
106116
`${jsonLines.map((line) => JSON.stringify(line)).join('\n')}\n`,
@@ -136,6 +146,16 @@ function formatAge(date: Date): string {
136146
return `${diffDays}d ago`;
137147
}
138148

149+
function transcriptWriteOptions(
150+
testId: string | undefined,
151+
target: string | undefined,
152+
): { testId?: string; target?: string } {
153+
return {
154+
...(testId ? { testId } : {}),
155+
...(target ? { target } : {}),
156+
};
157+
}
158+
139159
function formatDurationMs(ms: number): string {
140160
if (ms < 1000) return `${ms}ms`;
141161
const seconds = Math.floor(ms / 1000);

apps/web/src/content/docs/docs/next/guides/skill-improvement-workflow.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ agentv import claude --list
132132
agentv import claude --session-id <uuid>
133133

134134
# Run deterministic graders against the imported transcript
135-
agentv eval EVAL.yaml --target copilot-log
135+
agentv eval EVAL.yaml --transcript .agentv/transcripts/claude-<session-id-short>.jsonl
136136
```
137137

138138
Offline grading is useful when you want to evaluate skills with agents that don't have a direct API integration — import the session transcript and run deterministic graders.

apps/web/src/content/docs/docs/next/integrations/agent-skills-evals.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ Grade existing agent sessions offline by importing transcripts and running the a
122122
agentv import claude --list
123123
agentv import claude --session-id <uuid>
124124
125-
agentv eval evals.json --target copilot-log
125+
agentv eval evals.json --transcript .agentv/transcripts/claude-<session-id-short>.jsonl
126126
```
127127

128128
If another tool owns the original `evals.json`, keep that file as the source and run it through the read adapter. Convert only when you need to edit the AgentV-native form.

apps/web/src/content/docs/docs/next/targets/coding-agents.mdx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,6 @@ targets:
110110
| `claude-cli` | Claude CLI subprocess. | Default Claude path; captures structured stream output when available. |
111111
| `claude-sdk` | Claude Agent SDK in an AgentV child runner. | Explicit SDK path; useful when SDK-native events matter more than matching a local CLI invocation. |
112112
| `copilot-cli` | Copilot CLI subprocess/protocol path. | Active Copilot eval run through the installed process. |
113-
| `copilot-log` | Passive Copilot session-log reader. | Zero-cost transcript grading for existing sessions; it does not run a new agent. |
114113
| `copilot-sdk` | Copilot SDK in an AgentV child runner. | Explicit SDK path with child-process isolation. |
115114

116115
Every coding-agent provider returns a structured target execution envelope.
@@ -271,15 +270,20 @@ targets:
271270
api_format: responses
272271
```
273272

274-
Read an existing Copilot session log without running a new agent:
273+
Replay an existing Copilot session without running a new agent by importing the
274+
native log into AgentV transcript rows, then using the generic replay target:
275+
276+
```bash
277+
agentv import copilot --session-id <uuid> -o .agentv/transcripts/copilot-case.jsonl
278+
```
275279

276280
```yaml
277281
targets:
278-
- id: copilot-session-log
279-
provider: copilot-log
280-
runtime: host
282+
- id: copilot-session-replay
283+
provider: replay
281284
config:
282-
discover: latest
285+
transcripts: .agentv/transcripts/copilot-case.jsonl
286+
source_target: copilot-cli
283287
```
284288

285289
Use `copilot-sdk` only when you intentionally want the SDK path:
@@ -295,8 +299,9 @@ targets:
295299

296300
Copilot config fields include `command`, `model`, `cwd`, `timeout_seconds`,
297301
`subprovider`, `base_url`, `api_key`, `bearer_token`, `api_version`,
298-
`api_format`, `log_dir`, `stream_log`, `system_prompt`, and session-log fields
299-
such as `discover`, `session_id`, and `session_dir` for `copilot-log`.
302+
`api_format`, `log_dir`, `stream_log`, and `system_prompt`. Copilot
303+
`events.jsonl` parsing is available through `agentv import copilot`; graders
304+
and Dashboard views consume normalized AgentV transcript/replay artifacts.
300305

301306
## File inputs
302307

apps/web/src/content/docs/docs/next/targets/configuration.mdx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,6 @@ already-exported secrets into `.env`.
206206
| `codex-app-server` | Agent | Codex app-server subprocess |
207207
| `codex-sdk` | Agent | Codex SDK in an isolated child runner |
208208
| `copilot-cli` | Agent | Copilot CLI subprocess |
209-
| `copilot-log` | Agent | Passive Copilot CLI session log reader |
210209
| `copilot-sdk` | Agent | Copilot SDK in an isolated child runner |
211210
| `pi-sdk` | Agent | Pi SDK in an isolated child runner |
212211
| `pi-cli` | Agent | Pi CLI subprocess |

apps/web/src/content/docs/docs/next/tools/import.mdx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ The transcript providers share the same core flags:
108108
| `--session-id <uuid>` | Import a specific session by UUID |
109109
| `--list` | List available sessions instead of importing |
110110
| `--output, -o <path>` | Custom output file path |
111+
| `--test-id <id>` | Set the transcript `test_id` to match an eval test id |
112+
| `--target <id>` | Set the transcript `target` / replay `source_target` value |
111113

112114
Provider-specific flags:
113115

@@ -189,19 +191,36 @@ Token usage is aggregated from the final cumulative value per LLM request. Durat
189191

190192
## Workflow
191193

192-
Import a session, then run graders against it:
194+
Import a session, then run graders against it. The transcript `test_id` values
195+
must match the eval test ids so replay cannot silently grade the wrong
196+
trajectory.
193197

194198
```bash
195199
# 1. List sessions and pick one
196200
agentv import claude --list
197201

198202
# 2. Import a session by ID
199-
agentv import claude --session-id 4c4f9e4e-e6f1-490b-a1b1-9aef543ebf22
203+
agentv import claude --session-id 4c4f9e4e-e6f1-490b-a1b1-9aef543ebf22 --test-id my-case
200204

201205
# 3. Run graders against the imported transcript
202206
agentv eval evals/my-eval.yaml --transcript .agentv/transcripts/claude-4c4f9e4e.jsonl
203207
```
204208

209+
You can also use the same normalized transcript rows through a replay target:
210+
211+
```yaml
212+
targets:
213+
- id: recorded-agent
214+
provider: replay
215+
transcripts: .agentv/transcripts/claude-4c4f9e4e.jsonl
216+
source_target: claude-cli
217+
```
218+
219+
Replay targets match by `test_id` and `source_target`, return the recorded
220+
AgentV `Message[]` trajectory, and run graders fresh. Raw provider logs remain
221+
provenance/debug input for importers; graders and Dashboard views consume the
222+
normalized transcript/replay artifacts.
223+
205224
See `examples/features/import-claude/` for a complete working example.
206225

207226
## HuggingFace Datasets (SWE-bench)

apps/web/src/content/docs/docs/v4.42.4/guides/skill-improvement-workflow.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ agentv import claude --list
130130
agentv import claude --session-id <uuid>
131131

132132
# Run deterministic graders against the imported transcript
133-
agentv eval evals.json --target copilot-log
133+
agentv eval evals.json --transcript .agentv/transcripts/claude-<session-id-short>.jsonl
134134
```
135135

136136
Offline grading is useful when you want to evaluate skills with agents that don't have a direct API integration — import the session transcript and run deterministic graders.

0 commit comments

Comments
 (0)