mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
3317 lines
134 KiB
JavaScript
3317 lines
134 KiB
JavaScript
'use strict';
|
|
|
|
const { describe, it } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { spawnSync } = require('node:child_process');
|
|
const crypto = require('node:crypto');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const INDEX_JS = path.join(__dirname, '..', 'index.js');
|
|
|
|
const {
|
|
buildTrajectories,
|
|
buildTrajectoryFromRows,
|
|
buildTrajectoryFromSessionLog,
|
|
readTraceRowsDetailed,
|
|
writeTrajectories,
|
|
} = require('../src/gep/trajectoryExport');
|
|
|
|
function encryptRowWithKey(row, key, extra = {}) {
|
|
const iv = crypto.randomBytes(12);
|
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(row), 'utf8'), cipher.final()]);
|
|
return {
|
|
encrypted: true,
|
|
algorithm: 'aes-256-gcm',
|
|
payload_schema: 'prism_trace_row',
|
|
iv: iv.toString('base64'),
|
|
tag: cipher.getAuthTag().toString('base64'),
|
|
ciphertext: ciphertext.toString('base64'),
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function encryptRowWithNodeSecret(row, nodeSecret, version) {
|
|
const key = crypto.createHash('sha256').update('evomap-proxy-trace-v1:' + nodeSecret, 'utf8').digest();
|
|
return encryptRowWithKey(row, key, version ? { secret_version: version } : {});
|
|
}
|
|
|
|
function encryptRowWithHubKey(row, publicPem) {
|
|
const key = crypto.randomBytes(32);
|
|
const wrapped = crypto.publicEncrypt(
|
|
{
|
|
key: publicPem,
|
|
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
|
|
oaepHash: 'sha256',
|
|
},
|
|
key,
|
|
);
|
|
return encryptRowWithKey(row, key, {
|
|
hub_key_envelope: {
|
|
algorithm: 'rsa-oaep-sha256',
|
|
key_id: 'test-key',
|
|
wrapped_key: wrapped.toString('base64'),
|
|
},
|
|
});
|
|
}
|
|
|
|
describe('trajectoryExport', () => {
|
|
it('groups proxy traces into session-level coding trajectories', () => {
|
|
const rows = [
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_a',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_1',
|
|
path: '/v1/messages',
|
|
upstream: 'anthropic',
|
|
model: 'claude-test',
|
|
status: 200,
|
|
durationMs: 10,
|
|
input_tokens: 11,
|
|
output_tokens: 7,
|
|
requestBody: JSON.stringify({
|
|
model: 'claude-test',
|
|
messages: [
|
|
{ role: 'user', content: 'Fix the TypeScript parser test' },
|
|
{
|
|
role: 'assistant',
|
|
content: [{ type: 'tool_use', id: 'tool_1', name: 'exec_command', input: { cmd: 'pnpm test' } }],
|
|
},
|
|
],
|
|
}),
|
|
responseBody: JSON.stringify({
|
|
id: 'msg_1',
|
|
stop_reason: 'end_turn',
|
|
content: [{ type: 'tool_use', id: 'tool_1', name: 'exec_command', input: { cmd: 'pnpm test' } }],
|
|
}),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_b',
|
|
createdAtIso: '2026-06-23T01:01:00.000Z',
|
|
sessionId: 'sess_2',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
model: 'gpt-test',
|
|
status: 200,
|
|
durationMs: 12,
|
|
input_tokens: 5,
|
|
output_tokens: 6,
|
|
responseId: 'resp_1',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', instructions: 'Review Python code' }),
|
|
responseBody: JSON.stringify({ id: 'resp_1', status: 'completed' }),
|
|
},
|
|
];
|
|
|
|
const trajectories = buildTrajectories(rows);
|
|
assert.equal(trajectories.length, 2);
|
|
const first = trajectories.find((t) => t.session_id === 'sess_1');
|
|
assert.ok(first);
|
|
assert.equal(first.schema, 'evomap.coding_trajectory.v1');
|
|
assert.equal(first.task, 'Fix the TypeScript parser test');
|
|
assert.equal(first.stats.turns, 1);
|
|
assert.equal(first.stats.input_tokens, 11);
|
|
assert.equal(first.stats.output_tokens, 7);
|
|
assert.equal(first.stats.has_tool_calls, true);
|
|
assert.equal(first.stats.tool_types.exec_command, 1);
|
|
assert.ok(first.languages.includes('typescript'));
|
|
|
|
const second = trajectories.find((t) => t.session_id === 'sess_2');
|
|
assert.ok(second);
|
|
assert.deepEqual(second.providers, ['openai']);
|
|
assert.deepEqual(second.endpoints, ['/v1/responses']);
|
|
assert.equal(second.task, 'Review Python code');
|
|
assert.equal(second.turns[0].response_id, 'resp_1');
|
|
assert.ok(second.languages.includes('python'));
|
|
});
|
|
|
|
it('marks failed turns as failure-correction candidates', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_fail', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_fail',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_fail',
|
|
path: '/v1/chat/completions',
|
|
upstream: 'openai',
|
|
model: 'gpt-test',
|
|
status: 429,
|
|
errorMessage: 'rate_limit_exceeded',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }] }),
|
|
responseBody: JSON.stringify({ error: { type: 'rate_limit_exceeded' } }),
|
|
}]);
|
|
|
|
assert.equal(trajectory.stats.has_failure_correction, true);
|
|
assert.equal(trajectory.turns[0].provider, 'openai');
|
|
assert.equal(trajectory.turns[0].endpoint, '/v1/chat/completions');
|
|
});
|
|
|
|
it('normalizes Bedrock provider aliases to the Anthropic Bedrock taxonomy', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_bedrock', [
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_bedrock_upstream',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_bedrock',
|
|
path: '/v1/messages',
|
|
upstream: 'aws-bedrock',
|
|
model: 'claude-test',
|
|
status: 200,
|
|
requestBody: JSON.stringify({ model: 'claude-test', messages: [{ role: 'user', content: 'Summarize Bedrock output' }] }),
|
|
responseBody: JSON.stringify({ content: [{ type: 'text', text: 'done' }] }),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_bedrock_provider',
|
|
createdAtIso: '2026-06-23T01:01:00.000Z',
|
|
sessionId: 'sess_bedrock',
|
|
path: '/v1/messages',
|
|
provider: 'aws-bedrock',
|
|
model: 'claude-test',
|
|
status: 200,
|
|
requestBody: JSON.stringify({ model: 'claude-test', messages: [{ role: 'user', content: 'Continue Bedrock output' }] }),
|
|
responseBody: JSON.stringify({ content: [{ type: 'text', text: 'done' }] }),
|
|
},
|
|
]);
|
|
|
|
assert.deepEqual(trajectory.providers, ['aws-bedrock-anthropic']);
|
|
assert.deepEqual(trajectory.turns.map((turn) => turn.provider), [
|
|
'aws-bedrock-anthropic',
|
|
'aws-bedrock-anthropic',
|
|
]);
|
|
});
|
|
|
|
it('extracts task and tool calls from OpenAI Responses shapes', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_openai', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_openai',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_openai',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
model: 'gpt-test',
|
|
status: 200,
|
|
responseId: 'resp_tool',
|
|
previousResponseId: 'resp_prev',
|
|
requestBody: JSON.stringify({
|
|
model: 'gpt-test',
|
|
input: 'Patch the Go parser',
|
|
previous_response_id: 'resp_prev',
|
|
tools: [{ type: 'function', name: 'exec_command' }],
|
|
}),
|
|
responseBody: JSON.stringify({
|
|
id: 'resp_tool',
|
|
output: [{ type: 'function_call', call_id: 'call_1', name: 'exec_command' }],
|
|
}),
|
|
}]);
|
|
|
|
assert.equal(trajectory.task, 'Patch the Go parser');
|
|
assert.equal(trajectory.stats.has_tool_calls, true);
|
|
assert.equal(trajectory.stats.tool_types.exec_command, 1);
|
|
assert.equal(trajectory.turns[0].tool_calls.length, 2);
|
|
assert.equal(trajectory.turns[0].response_id, 'resp_tool');
|
|
assert.equal(trajectory.turns[0].previous_response_id, 'resp_prev');
|
|
assert.ok(trajectory.languages.includes('go'));
|
|
});
|
|
|
|
it('extracts OpenAI Responses request input tool calls and failed tool outputs', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_openai_input_tools', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_openai_input_tools',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_openai_input_tools',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
model: 'gpt-test',
|
|
status: 200,
|
|
requestBody: JSON.stringify({
|
|
model: 'gpt-test',
|
|
input: [
|
|
{ role: 'user', content: 'Run the pytest suite and fix failures' },
|
|
{
|
|
type: 'function_call',
|
|
call_id: 'call_pytest_fake',
|
|
name: 'exec_command',
|
|
arguments: '{"cmd":"pytest test_fake.py"}',
|
|
},
|
|
{
|
|
type: 'function_call_output',
|
|
call_id: 'call_pytest_fake',
|
|
output: 'pytest failed: 1 failed; command exited with code 1',
|
|
},
|
|
],
|
|
}),
|
|
responseBody: JSON.stringify({ id: 'resp_after_input_tools', status: 'completed' }),
|
|
}]);
|
|
|
|
assert.equal(trajectory.stats.has_tool_calls, true);
|
|
assert.equal(trajectory.stats.tool_call_count, 2);
|
|
assert.equal(trajectory.stats.tool_types.exec_command, 1);
|
|
assert.equal(trajectory.stats.tool_types.tool_result, 1);
|
|
assert.equal(trajectory.stats.has_failure_correction, true);
|
|
assert.equal(trajectory.turns[0].tool_calls.length, 2);
|
|
assert.equal(trajectory.turns[0].tool_calls[0].name, 'exec_command');
|
|
assert.equal(trajectory.turns[0].tool_calls[0].id, 'call_pytest_fake');
|
|
assert.equal(trajectory.turns[0].tool_calls[1].name, 'tool_result');
|
|
assert.equal(trajectory.turns[0].tool_calls[1].id, 'call_pytest_fake');
|
|
assert.equal(trajectory.turns[0].tool_calls[1].arguments, 'pytest failed: 1 failed; command exited with code 1');
|
|
assert.equal(Object.prototype.hasOwnProperty.call(trajectory.turns[0].tool_calls[1], 'content'), false);
|
|
assert.equal(Object.prototype.hasOwnProperty.call(trajectory.turns[0].tool_calls[1], 'output'), false);
|
|
});
|
|
|
|
it('accepts v2 llm_turn rows and normalizes trajectory fields', () => {
|
|
const trajectory = buildTrajectoryFromRows('', [{
|
|
event: 'llm_turn',
|
|
ts: '2026-06-23T01:00:00.000Z',
|
|
request_id: 'req_v2',
|
|
session_id: 'sess_v2',
|
|
response_id: 'resp_v2',
|
|
previous_response_id: 'resp_prev_v2',
|
|
route: 'POST /v1/responses',
|
|
provider: 'openai',
|
|
chosen_model: 'gpt-chosen',
|
|
original_model: 'gpt-original',
|
|
latency_ms: 123,
|
|
stream: true,
|
|
stop_reason: 'completed',
|
|
usage: { input_tokens: 31, output_tokens: 17 },
|
|
request_body: JSON.stringify({
|
|
model: 'gpt-original',
|
|
input: 'Normalize v2 trace rows',
|
|
previous_response_id: 'resp_prev_v2',
|
|
}),
|
|
response_body: JSON.stringify({ id: 'resp_v2', status: 'completed' }),
|
|
}]);
|
|
|
|
assert.equal(trajectory.session_id, 'sess_v2');
|
|
assert.deepEqual(trajectory.providers, ['openai']);
|
|
assert.deepEqual(trajectory.endpoints, ['/v1/responses']);
|
|
assert.equal(trajectory.task, 'Normalize v2 trace rows');
|
|
assert.equal(trajectory.stats.input_tokens, 31);
|
|
assert.equal(trajectory.stats.output_tokens, 17);
|
|
|
|
const turn = trajectory.turns[0];
|
|
assert.equal(turn.request_id, 'req_v2');
|
|
assert.equal(turn.timestamp, '2026-06-23T01:00:00.000Z');
|
|
assert.equal(turn.provider, 'openai');
|
|
assert.equal(turn.endpoint, '/v1/responses');
|
|
assert.equal(turn.model, 'gpt-chosen');
|
|
assert.equal(turn.chosen_model, 'gpt-chosen');
|
|
assert.equal(turn.original_model, 'gpt-original');
|
|
assert.equal(turn.duration_ms, 123);
|
|
assert.equal(turn.is_stream, true);
|
|
assert.equal(turn.finish_reason, 'completed');
|
|
assert.equal(turn.response_id, 'resp_v2');
|
|
assert.equal(turn.previous_response_id, 'resp_prev_v2');
|
|
assert.equal(turn.input_tokens, 31);
|
|
assert.equal(turn.output_tokens, 17);
|
|
assert.equal(Object.prototype.hasOwnProperty.call(turn, 'error'), false);
|
|
});
|
|
|
|
it('preserves row error fields and treats row.error as a failure signal', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_v2_error', [
|
|
{
|
|
event: 'llm_turn',
|
|
ts: '2026-06-23T01:00:00.000Z',
|
|
request_id: 'req_error',
|
|
session_id: 'sess_v2_error',
|
|
route: '/v1/responses',
|
|
provider: 'openai',
|
|
error: 'upstream stream error: synthetic failure',
|
|
errorMessage: 'legacy error should not win',
|
|
stream: true,
|
|
request_body: JSON.stringify({ model: 'gpt-test', input: 'stream failed' }),
|
|
response_body: JSON.stringify({ reconstructed_stream: true, events: [] }),
|
|
},
|
|
{
|
|
event: 'llm_turn',
|
|
ts: '2026-06-23T01:01:00.000Z',
|
|
request_id: 'req_error_message',
|
|
session_id: 'sess_v2_error',
|
|
route: '/v1/responses',
|
|
provider: 'openai',
|
|
errorMessage: 'fallback error message',
|
|
request_body: JSON.stringify({ model: 'gpt-test', input: 'request failed' }),
|
|
response_body: JSON.stringify({ id: 'resp_after_error' }),
|
|
},
|
|
]);
|
|
|
|
assert.equal(trajectory.stats.has_failure_correction, true);
|
|
assert.equal(trajectory.stats.has_incomplete_turns, true);
|
|
assert.equal(trajectory.turns[0].error, 'upstream stream error: synthetic failure');
|
|
assert.deepEqual(trajectory.turns[0].incomplete_reasons, ['stream_error']);
|
|
assert.equal(trajectory.turns[1].error, 'fallback error message');
|
|
});
|
|
|
|
it('preserves row-level trajectory parity fields and row-level tool calls', () => {
|
|
const reasoning = { summary: 'selected edit then validation' };
|
|
const diff = 'diff --git a/file.js b/file.js';
|
|
const validation = { commands: ['pnpm test'] };
|
|
const trajectory = buildTrajectoryFromRows('sess_row_parity', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_row_parity',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_row_parity',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
model: 'gpt-test',
|
|
status: 200,
|
|
body_truncated: true,
|
|
redaction: 'evolver-redact-v2',
|
|
reasoning,
|
|
diff,
|
|
validation,
|
|
tool_calls: [
|
|
{ id: 'call_patch', name: 'apply_patch', arguments: '{"patch":"*** Begin Patch"}', bytes: 50 },
|
|
{ id: 'call_test', name: 'exec_command', input: { cmd: 'pnpm test' }, bytes: 30 },
|
|
],
|
|
requestBody: JSON.stringify({
|
|
model: 'gpt-test',
|
|
input: 'Patch and validate',
|
|
tools: [{ type: 'function', function: { name: 'apply_patch' } }],
|
|
}),
|
|
responseBody: JSON.stringify({
|
|
output: [
|
|
{ type: 'function_call', call_id: 'call_patch', name: 'apply_patch', arguments: '{"patch":"*** Begin Patch"}' },
|
|
],
|
|
}),
|
|
}]);
|
|
|
|
const turn = trajectory.turns[0];
|
|
assert.equal(turn.reasoning, reasoning);
|
|
assert.equal(turn.diff, diff);
|
|
assert.equal(turn.validation, validation);
|
|
assert.equal(turn.redaction, 'evolver-redact-v2');
|
|
assert.equal(turn.body_truncated, true);
|
|
assert.equal(turn.response_events_truncated, true);
|
|
assert.deepEqual(turn.incomplete_reasons, ['body_truncated']);
|
|
assert.equal(turn.tool_calls.filter((call) => call.id === 'call_patch' && !call.declared).length, 1);
|
|
assert.ok(turn.tool_calls.some((call) => (
|
|
call.id === 'call_test'
|
|
&& call.name === 'exec_command'
|
|
&& call.input.cmd === 'pnpm test'
|
|
)));
|
|
assert.equal(trajectory.stats.tool_call_count, 2);
|
|
assert.deepEqual(trajectory.stats.tool_types, { apply_patch: 1, exec_command: 1 });
|
|
assert.equal(trajectory.stats.has_code_edit, true);
|
|
assert.equal(trajectory.stats.has_test_execution, true);
|
|
assert.deepEqual(trajectory.stats.test_commands, ['pnpm test']);
|
|
assert.equal(trajectory.stats.has_truncated_stream, true);
|
|
assert.equal(trajectory.stats.has_incomplete_turns, true);
|
|
});
|
|
|
|
it('extracts OpenAI Chat Completions tool calls', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_chat', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_chat',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_chat',
|
|
path: '/v1/chat/completions',
|
|
upstream: 'openai',
|
|
model: 'gpt-test',
|
|
status: 200,
|
|
requestBody: JSON.stringify({
|
|
model: 'gpt-test',
|
|
messages: [{ role: 'user', content: 'Run the Python test' }],
|
|
tools: [{ type: 'function', function: { name: 'exec_command' } }],
|
|
}),
|
|
responseBody: JSON.stringify({
|
|
choices: [{
|
|
message: {
|
|
tool_calls: [{
|
|
id: 'call_1',
|
|
type: 'function',
|
|
function: { name: 'exec_command', arguments: '{}' },
|
|
}],
|
|
},
|
|
}],
|
|
}),
|
|
}]);
|
|
|
|
assert.equal(trajectory.task, 'Run the Python test');
|
|
assert.equal(trajectory.stats.tool_types.exec_command, 1);
|
|
assert.equal(trajectory.turns[0].endpoint, '/v1/chat/completions');
|
|
assert.ok(trajectory.languages.includes('python'));
|
|
});
|
|
|
|
it('does not count declared tools as actual tool calls', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_declared_only', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_declared_only',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_declared_only',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
model: 'gpt-test',
|
|
status: 200,
|
|
requestBody: JSON.stringify({
|
|
model: 'gpt-test',
|
|
input: 'Say hello',
|
|
tools: [{ type: 'function', name: 'exec_command' }],
|
|
}),
|
|
responseBody: JSON.stringify({
|
|
id: 'resp_plain',
|
|
status: 'completed',
|
|
output: [{ type: 'message', content: [{ type: 'output_text', text: 'hello' }] }],
|
|
}),
|
|
}]);
|
|
|
|
assert.equal(trajectory.stats.has_tool_calls, false);
|
|
assert.equal(trajectory.stats.tool_call_count, 0);
|
|
assert.deepEqual(trajectory.stats.tool_types, {});
|
|
assert.equal(trajectory.turns[0].tool_calls.length, 1);
|
|
assert.equal(trajectory.turns[0].tool_calls[0].declared, true);
|
|
assert.equal(trajectory.turns[0].request_body.model, 'gpt-test');
|
|
assert.equal(trajectory.turns[0].response_body.id, 'resp_plain');
|
|
});
|
|
|
|
it('detects code edits and test execution from tool call arguments', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_edit_test', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_edit_test',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_edit_test',
|
|
path: '/v1/messages',
|
|
upstream: 'anthropic',
|
|
model: 'claude-test',
|
|
status: 200,
|
|
requestBody: JSON.stringify({ model: 'claude-test', messages: [{ role: 'user', content: 'Patch and test' }] }),
|
|
responseBody: JSON.stringify({
|
|
id: 'msg_1',
|
|
stop_reason: 'end_turn',
|
|
content: [
|
|
{ type: 'tool_use', id: 'tool_patch', name: 'apply_patch', input: { patch: '*** Begin Patch\n*** End Patch' } },
|
|
{ type: 'tool_use', id: 'tool_test', name: 'exec_command', input: { cmd: 'pnpm test' } },
|
|
],
|
|
}),
|
|
}]);
|
|
|
|
assert.equal(trajectory.stats.has_code_edit, true);
|
|
assert.equal(trajectory.stats.has_test_execution, true);
|
|
assert.deepEqual(trajectory.stats.test_commands, ['pnpm test']);
|
|
});
|
|
|
|
it('does not mark ordinary tool calls as code edits or tests', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_read_only', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_read_only',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_read_only',
|
|
path: '/v1/messages',
|
|
upstream: 'anthropic',
|
|
model: 'claude-test',
|
|
status: 200,
|
|
requestBody: JSON.stringify({
|
|
model: 'claude-test',
|
|
messages: [{
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'tool_use', id: 'tool_read', name: 'Read', input: { file_path: 'src/index.js' } },
|
|
{ type: 'tool_use', id: 'tool_ls', name: 'List', input: { path: 'src' } },
|
|
{ type: 'tool_use', id: 'tool_shell', name: 'exec_command', input: { cmd: 'node scripts/inspect.js' } },
|
|
],
|
|
}],
|
|
}),
|
|
responseBody: JSON.stringify({ id: 'msg_1', stop_reason: 'end_turn' }),
|
|
}]);
|
|
|
|
assert.equal(trajectory.stats.has_code_edit, false);
|
|
assert.equal(trajectory.stats.has_test_execution, false);
|
|
assert.deepEqual(trajectory.stats.test_commands, []);
|
|
});
|
|
|
|
it('exports native stream events as response bodies and extracts streamed tool calls', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_stream', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_stream',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_stream',
|
|
path: '/v1/messages',
|
|
upstream: 'anthropic',
|
|
model: 'claude-test',
|
|
status: 200,
|
|
isStream: true,
|
|
requestBody: JSON.stringify({ model: 'claude-test', messages: [{ role: 'user', content: 'Patch and test' }] }),
|
|
responseBody: JSON.stringify({
|
|
reconstructed_stream: true,
|
|
events: [
|
|
{ type: 'content_block_delta', delta: { type: 'text_delta', text: 'working' } },
|
|
{ type: 'content_block_start', content_block: { type: 'tool_use', id: 'tool_patch', name: 'apply_patch', input: { patch: '*** Begin Patch' } } },
|
|
{ type: 'response.output_item.done', item: { type: 'function_call', call_id: 'call_test', name: 'exec_command', arguments: JSON.stringify({ cmd: 'node --test test/trajectoryExport.test.js' }) } },
|
|
],
|
|
}),
|
|
}]);
|
|
|
|
assert.equal(trajectory.turns[0].is_stream, true);
|
|
assert.equal(trajectory.turns[0].response_body.reconstructed_stream, true);
|
|
assert.equal(trajectory.stats.has_code_edit, true);
|
|
assert.equal(trajectory.stats.has_test_execution, true);
|
|
assert.deepEqual(trajectory.stats.test_commands, ['node --test test/trajectoryExport.test.js']);
|
|
});
|
|
|
|
it('reconstructs streamed Anthropic tool input_json_delta arguments', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_stream_delta', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_stream_delta',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_stream_delta',
|
|
path: '/v1/messages',
|
|
upstream: 'anthropic',
|
|
model: 'claude-test',
|
|
status: 200,
|
|
isStream: true,
|
|
requestBody: JSON.stringify({ model: 'claude-test', messages: [{ role: 'user', content: 'Patch and test' }] }),
|
|
responseBody: JSON.stringify({
|
|
reconstructed_stream: true,
|
|
events: [
|
|
{ type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'tool_bash', name: 'Bash', input: {} } },
|
|
{ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"command":"apply_patch <<PATCH\\n*** Begin Patch\\nPATCH' } },
|
|
{ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: ' && node --test test/trajectoryExport.test.js"}' } },
|
|
{ type: 'content_block_stop', index: 1 },
|
|
],
|
|
}),
|
|
}]);
|
|
|
|
assert.equal(trajectory.stats.has_code_edit, true);
|
|
assert.equal(trajectory.stats.has_test_execution, true);
|
|
assert.deepEqual(trajectory.stats.test_commands, ['apply_patch <<PATCH\n*** Begin Patch\nPATCH && node --test test/trajectoryExport.test.js']);
|
|
assert.equal(trajectory.turns[0].tool_calls.length, 1);
|
|
assert.equal(trajectory.turns[0].tool_calls[0].name, 'Bash');
|
|
assert.equal(trajectory.turns[0].tool_calls[0].input.command, 'apply_patch <<PATCH\n*** Begin Patch\nPATCH && node --test test/trajectoryExport.test.js');
|
|
});
|
|
|
|
it('reconstructs streamed OpenAI Chat tool call argument deltas', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_chat_stream_delta', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_chat_stream_delta',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_chat_stream_delta',
|
|
path: '/v1/chat/completions',
|
|
upstream: 'openai',
|
|
model: 'gpt-test',
|
|
status: 200,
|
|
isStream: true,
|
|
requestBody: JSON.stringify({ model: 'gpt-test', messages: [{ role: 'user', content: 'Run the test' }] }),
|
|
responseBody: JSON.stringify({
|
|
reconstructed_stream: true,
|
|
events: [
|
|
{
|
|
choices: [{
|
|
index: 0,
|
|
delta: {
|
|
tool_calls: [{
|
|
index: 0,
|
|
id: 'call_test',
|
|
type: 'function',
|
|
function: { name: 'exec_command', arguments: '{"cmd":"pnpm ' },
|
|
}],
|
|
},
|
|
}],
|
|
},
|
|
{
|
|
choices: [{
|
|
index: 0,
|
|
delta: {
|
|
tool_calls: [{
|
|
index: 0,
|
|
function: { arguments: 'test"}' },
|
|
}],
|
|
},
|
|
}],
|
|
},
|
|
],
|
|
}),
|
|
}]);
|
|
|
|
assert.equal(trajectory.stats.has_tool_calls, true);
|
|
assert.equal(trajectory.stats.has_test_execution, true);
|
|
assert.deepEqual(trajectory.stats.test_commands, ['pnpm test']);
|
|
assert.equal(trajectory.turns[0].tool_calls.length, 1);
|
|
assert.equal(trajectory.turns[0].tool_calls[0].function.name, 'exec_command');
|
|
assert.equal(trajectory.turns[0].tool_calls[0].function.arguments, '{"cmd":"pnpm test"}');
|
|
});
|
|
|
|
it('does not append duplicate OpenAI Chat streamed full tool call snapshots', () => {
|
|
const responseBody = {
|
|
reconstructed_stream: true,
|
|
events: [
|
|
{
|
|
choices: [{
|
|
index: 0,
|
|
delta: {
|
|
tool_calls: [{
|
|
index: 0,
|
|
id: 'call_test',
|
|
type: 'function',
|
|
function: { name: 'exec_command', arguments: '{"cmd":"pnpm ' },
|
|
}],
|
|
},
|
|
}],
|
|
},
|
|
{
|
|
choices: [{
|
|
index: 0,
|
|
delta: {
|
|
tool_calls: [{
|
|
index: 0,
|
|
function: { arguments: '{"cmd":"pnpm test"}' },
|
|
}],
|
|
},
|
|
}],
|
|
},
|
|
],
|
|
};
|
|
const trajectory = buildTrajectoryFromRows('sess_chat_stream_snapshot', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_chat_stream_snapshot',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_chat_stream_snapshot',
|
|
path: '/v1/chat/completions',
|
|
upstream: 'openai',
|
|
model: 'gpt-test',
|
|
status: 200,
|
|
isStream: true,
|
|
requestBody: JSON.stringify({ model: 'gpt-test', messages: [{ role: 'user', content: 'Run the test' }] }),
|
|
responseBody: JSON.stringify(responseBody),
|
|
}]);
|
|
|
|
assert.deepEqual(trajectory.turns[0].response_body, responseBody);
|
|
assert.equal(trajectory.stats.has_test_execution, true);
|
|
assert.deepEqual(trajectory.stats.test_commands, ['pnpm test']);
|
|
assert.equal(trajectory.turns[0].tool_calls.length, 1);
|
|
assert.equal(trajectory.turns[0].tool_calls[0].function.arguments, '{"cmd":"pnpm test"}');
|
|
});
|
|
|
|
it('reconstructs streamed OpenAI Responses function call argument deltas', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_responses_stream_delta', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_responses_stream_delta',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_responses_stream_delta',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
model: 'gpt-test',
|
|
status: 200,
|
|
isStream: true,
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Run the test' }),
|
|
responseBody: JSON.stringify({
|
|
reconstructed_stream: true,
|
|
events: [
|
|
{
|
|
type: 'response.output_item.added',
|
|
output_index: 0,
|
|
item: {
|
|
id: 'fc_1',
|
|
type: 'function_call',
|
|
call_id: 'call_test',
|
|
name: 'exec_command',
|
|
arguments: '',
|
|
},
|
|
},
|
|
{
|
|
type: 'response.function_call_arguments.delta',
|
|
output_index: 0,
|
|
item_id: 'fc_1',
|
|
delta: '{"cmd":"pnpm ',
|
|
},
|
|
{
|
|
type: 'response.function_call_arguments.delta',
|
|
output_index: 0,
|
|
item_id: 'fc_1',
|
|
delta: 'test"}',
|
|
},
|
|
{
|
|
type: 'response.function_call_arguments.done',
|
|
output_index: 0,
|
|
item_id: 'fc_1',
|
|
arguments: '{"cmd":"pnpm test"}',
|
|
},
|
|
{
|
|
type: 'response.output_item.done',
|
|
output_index: 0,
|
|
item: {
|
|
id: 'fc_1',
|
|
type: 'function_call',
|
|
call_id: 'call_test',
|
|
name: 'exec_command',
|
|
arguments: '',
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
}]);
|
|
|
|
assert.equal(trajectory.stats.has_tool_calls, true);
|
|
assert.equal(trajectory.stats.has_test_execution, true);
|
|
assert.deepEqual(trajectory.stats.test_commands, ['pnpm test']);
|
|
assert.equal(trajectory.turns[0].tool_calls.length, 1);
|
|
assert.equal(trajectory.turns[0].tool_calls[0].name, 'exec_command');
|
|
assert.equal(trajectory.turns[0].tool_calls[0].arguments, '{"cmd":"pnpm test"}');
|
|
});
|
|
|
|
it('surfaces truncated reconstructed stream markers', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_stream_truncated', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_stream_truncated',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_stream_truncated',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
model: 'gpt-test',
|
|
status: 200,
|
|
isStream: true,
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Long task' }),
|
|
responseBody: JSON.stringify({
|
|
reconstructed_stream: true,
|
|
events_truncated: true,
|
|
events_limit: 1000,
|
|
events: [{ type: 'response.output_text.delta', delta: 'partial' }],
|
|
}),
|
|
}]);
|
|
|
|
assert.equal(trajectory.stats.has_truncated_stream, true);
|
|
assert.equal(trajectory.turns[0].response_events_truncated, true);
|
|
assert.equal(trajectory.turns[0].response_body.events_truncated, true);
|
|
});
|
|
|
|
it('marks raw stream capture truncation as an incomplete streamed turn', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_raw_stream_truncated', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_raw_stream_truncated',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_raw_stream_truncated',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
model: 'gpt-test',
|
|
status: 200,
|
|
isStream: true,
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Long task' }),
|
|
responseBody: JSON.stringify({
|
|
reconstructed: true,
|
|
raw_stream_truncated: true,
|
|
raw_stream_body: 'partial',
|
|
}),
|
|
}]);
|
|
|
|
const turn = trajectory.turns[0];
|
|
assert.equal(turn.complete, false);
|
|
assert.equal(turn.response_events_truncated, true);
|
|
assert.deepEqual(turn.incomplete_reasons, ['response_body_truncated']);
|
|
assert.equal(trajectory.stats.has_truncated_stream, true);
|
|
assert.equal(trajectory.stats.has_incomplete_turns, true);
|
|
});
|
|
|
|
it('exports retry attempts with parsed snake_case bodies without adding turns', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_attempts', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_attempts',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_attempts',
|
|
path: '/v1/messages',
|
|
upstream: 'anthropic',
|
|
model: 'claude-opus-4-1',
|
|
status: 200,
|
|
requestBody: JSON.stringify({ model: 'claude-opus-4-1', messages: [] }),
|
|
responseBody: JSON.stringify({ id: 'msg_retry' }),
|
|
attempts: [
|
|
{
|
|
attempt_index: 0,
|
|
model: 'claude-haiku-4-7',
|
|
status: 503,
|
|
upstreamMode: 'anthropic',
|
|
requestBody: JSON.stringify({ model: 'claude-haiku-4-7', messages: [] }),
|
|
responseBody: JSON.stringify({ error: { type: 'upstream_unavailable' } }),
|
|
},
|
|
{
|
|
attempt_index: 1,
|
|
model: 'claude-opus-4-1',
|
|
status: 200,
|
|
upstreamMode: 'anthropic',
|
|
requestBody: JSON.stringify({ model: 'claude-opus-4-1', messages: [] }),
|
|
responseBody: JSON.stringify({ id: 'msg_retry' }),
|
|
},
|
|
],
|
|
}]);
|
|
|
|
const turn = trajectory.turns[0];
|
|
assert.equal(trajectory.stats.turns, 1);
|
|
assert.equal(turn.attempts.length, 2);
|
|
assert.equal(turn.attempts[0].upstream_mode, 'anthropic');
|
|
assert.deepEqual(turn.attempts[0].request_body, { model: 'claude-haiku-4-7', messages: [] });
|
|
assert.deepEqual(turn.attempts[0].response_body, { error: { type: 'upstream_unavailable' } });
|
|
assert.deepEqual(turn.attempts[1].request_body, { model: 'claude-opus-4-1', messages: [] });
|
|
assert.deepEqual(turn.attempts[1].response_body, { id: 'msg_retry' });
|
|
});
|
|
|
|
it('extracts tool calls from semantic stream tail events after event capture truncates', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_stream_tail', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_stream_tail',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_stream_tail',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
model: 'gpt-test',
|
|
status: 200,
|
|
isStream: true,
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Run the test' }),
|
|
responseBody: JSON.stringify({
|
|
reconstructed_stream: true,
|
|
events: [{ type: 'response.output_text.delta', delta: 'working' }],
|
|
semantic_tail_events: [
|
|
{ type: 'response.output_item.done', item: { type: 'function_call', call_id: 'call_tail', name: 'exec_command', arguments: JSON.stringify({ cmd: 'node --test test/trajectoryExport.test.js' }) } },
|
|
{ type: 'response.completed', response: { id: 'resp_tail', status: 'completed', usage: { input_tokens: 5, output_tokens: 7 } } },
|
|
],
|
|
events_truncated: true,
|
|
events_limit: 1000,
|
|
}),
|
|
}]);
|
|
|
|
assert.equal(trajectory.stats.has_test_execution, true);
|
|
assert.deepEqual(trajectory.stats.test_commands, ['node --test test/trajectoryExport.test.js']);
|
|
assert.equal(trajectory.turns[0].response_events_truncated, true);
|
|
});
|
|
|
|
it('groups OpenAI Responses chains when session and cwd are absent', () => {
|
|
const trajectories = buildTrajectories([
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_1',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
responseId: 'resp_1',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Start task' }),
|
|
responseBody: JSON.stringify({ id: 'resp_1', status: 'completed' }),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_2',
|
|
createdAtIso: '2026-06-23T01:01:00.000Z',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
previousResponseId: 'resp_1',
|
|
responseId: 'resp_2',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Continue task', previous_response_id: 'resp_1' }),
|
|
responseBody: JSON.stringify({ id: 'resp_2', status: 'completed' }),
|
|
},
|
|
]);
|
|
|
|
assert.equal(trajectories.length, 1);
|
|
assert.equal(trajectories[0].turns.length, 2);
|
|
assert.equal(trajectories[0].turns[0].response_id, 'resp_1');
|
|
assert.equal(trajectories[0].turns[1].previous_response_id, 'resp_1');
|
|
});
|
|
|
|
it('does not merge previousResponseId through ambiguous duplicate response owners', () => {
|
|
const trajectories = buildTrajectories([
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_owner_a',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
responseId: 'resp_duplicate',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Task A' }),
|
|
responseBody: JSON.stringify({ id: 'resp_duplicate', status: 'completed' }),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_owner_b',
|
|
createdAtIso: '2026-06-23T01:01:00.000Z',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
responseId: 'resp_duplicate',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Task B' }),
|
|
responseBody: JSON.stringify({ id: 'resp_duplicate', status: 'completed' }),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_child',
|
|
createdAtIso: '2026-06-23T01:02:00.000Z',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
previousResponseId: 'resp_duplicate',
|
|
responseId: 'resp_child',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Continue task', previous_response_id: 'resp_duplicate' }),
|
|
responseBody: JSON.stringify({ id: 'resp_child', status: 'completed' }),
|
|
},
|
|
]);
|
|
|
|
assert.equal(trajectories.length, 3);
|
|
assert.ok(trajectories.every((trajectory) => trajectory.turns.length === 1));
|
|
});
|
|
|
|
it('merges duplicate response owners only when the child has one compatible session owner', () => {
|
|
const trajectories = buildTrajectories([
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_owner_a',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_a',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
responseId: 'resp_duplicate_session_filter',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Task A' }),
|
|
responseBody: JSON.stringify({ id: 'resp_duplicate_session_filter', status: 'completed' }),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_owner_b',
|
|
createdAtIso: '2026-06-23T01:01:00.000Z',
|
|
sessionId: 'sess_b',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
responseId: 'resp_duplicate_session_filter',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Task B' }),
|
|
responseBody: JSON.stringify({ id: 'resp_duplicate_session_filter', status: 'completed' }),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_child_a',
|
|
createdAtIso: '2026-06-23T01:02:00.000Z',
|
|
sessionId: 'sess_a',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
previousResponseId: 'resp_duplicate_session_filter',
|
|
responseId: 'resp_child_a',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Continue task A', previous_response_id: 'resp_duplicate_session_filter' }),
|
|
responseBody: JSON.stringify({ id: 'resp_child_a', status: 'completed' }),
|
|
},
|
|
]);
|
|
|
|
assert.equal(trajectories.length, 2);
|
|
const sessA = trajectories.find((trajectory) => trajectory.session_id === 'sess_a');
|
|
assert.ok(sessA);
|
|
assert.equal(sessA.turns.length, 2);
|
|
assert.deepEqual(trajectories.map((trajectory) => trajectory.session_id).sort(), ['sess_a', 'sess_b']);
|
|
});
|
|
|
|
it('merges duplicate response owners only after they resolve to one explicit session root', () => {
|
|
const trajectories = buildTrajectories([
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_same_session_a',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_duplicate_owner',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
responseId: 'resp_duplicate_session',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Task A' }),
|
|
responseBody: JSON.stringify({ id: 'resp_duplicate_session', status: 'completed' }),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_same_session_b',
|
|
createdAtIso: '2026-06-23T01:01:00.000Z',
|
|
sessionId: 'sess_duplicate_owner',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
responseId: 'resp_duplicate_session',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Task B' }),
|
|
responseBody: JSON.stringify({ id: 'resp_duplicate_session', status: 'completed' }),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_same_session_child',
|
|
createdAtIso: '2026-06-23T01:02:00.000Z',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
previousResponseId: 'resp_duplicate_session',
|
|
responseId: 'resp_child',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Continue task', previous_response_id: 'resp_duplicate_session' }),
|
|
responseBody: JSON.stringify({ id: 'resp_child', status: 'completed' }),
|
|
},
|
|
]);
|
|
|
|
assert.equal(trajectories.length, 1);
|
|
assert.equal(trajectories[0].session_id, 'sess_duplicate_owner');
|
|
assert.equal(trajectories[0].turns.length, 3);
|
|
});
|
|
|
|
it('does not merge response chains across different explicit sessions', () => {
|
|
const trajectories = buildTrajectories([
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_session_a',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_a',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
responseId: 'resp_session_a',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Task A' }),
|
|
responseBody: JSON.stringify({ id: 'resp_session_a', status: 'completed' }),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_session_b',
|
|
createdAtIso: '2026-06-23T01:01:00.000Z',
|
|
sessionId: 'sess_b',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
previousResponseId: 'resp_session_a',
|
|
responseId: 'resp_session_b',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Task B', previous_response_id: 'resp_session_a' }),
|
|
responseBody: JSON.stringify({ id: 'resp_session_b', status: 'completed' }),
|
|
},
|
|
]);
|
|
|
|
assert.equal(trajectories.length, 2);
|
|
assert.deepEqual(trajectories.map((t) => t.session_id).sort(), ['sess_a', 'sess_b']);
|
|
});
|
|
|
|
it('uses explicit child sessionId for response chains rooted at unlabeled turns', () => {
|
|
const trajectories = buildTrajectories([
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_root',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
responseId: 'resp_root',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Start task' }),
|
|
responseBody: JSON.stringify({ id: 'resp_root', status: 'completed' }),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_child',
|
|
createdAtIso: '2026-06-23T01:01:00.000Z',
|
|
sessionId: 'sess_child',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
previousResponseId: 'resp_root',
|
|
responseId: 'resp_child',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Continue task', previous_response_id: 'resp_root' }),
|
|
responseBody: JSON.stringify({ id: 'resp_child', status: 'completed' }),
|
|
},
|
|
]);
|
|
|
|
assert.equal(trajectories.length, 1);
|
|
assert.equal(trajectories[0].session_id, 'sess_child');
|
|
assert.equal(trajectories[0].turns.length, 2);
|
|
});
|
|
|
|
it('does not merge different child sessions through an unlabeled response root', () => {
|
|
const trajectories = buildTrajectories([
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_root',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
responseId: 'resp_root',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Start task' }),
|
|
responseBody: JSON.stringify({ id: 'resp_root', status: 'completed' }),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_child_a',
|
|
createdAtIso: '2026-06-23T01:01:00.000Z',
|
|
sessionId: 'sess_a',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
previousResponseId: 'resp_root',
|
|
responseId: 'resp_child_a',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Continue task A', previous_response_id: 'resp_root' }),
|
|
responseBody: JSON.stringify({ id: 'resp_child_a', status: 'completed' }),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_child_b',
|
|
createdAtIso: '2026-06-23T01:02:00.000Z',
|
|
sessionId: 'sess_b',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
previousResponseId: 'resp_root',
|
|
responseId: 'resp_child_b',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Continue task B', previous_response_id: 'resp_root' }),
|
|
responseBody: JSON.stringify({ id: 'resp_child_b', status: 'completed' }),
|
|
},
|
|
]);
|
|
|
|
assert.equal(trajectories.length, 2);
|
|
assert.deepEqual(trajectories.map((t) => t.session_id).sort(), ['sess_a', 'sess_b']);
|
|
});
|
|
|
|
it('keeps unrelated unlabeled components separate even when fallback ids collide', () => {
|
|
const trajectories = buildTrajectories([
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'shared-request',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
responseId: 'resp_a',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Task A' }),
|
|
responseBody: JSON.stringify({ id: 'resp_a', status: 'completed' }),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'shared-request',
|
|
createdAtIso: '2026-06-23T01:01:00.000Z',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
responseId: 'resp_b',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Task B' }),
|
|
responseBody: JSON.stringify({ id: 'resp_b', status: 'completed' }),
|
|
},
|
|
]);
|
|
|
|
assert.equal(trajectories.length, 2);
|
|
assert.ok(trajectories.every((trajectory) => trajectory.turns.length === 1));
|
|
assert.equal(new Set(trajectories.map((trajectory) => trajectory.session_id)).size, 2);
|
|
});
|
|
|
|
it('keeps unrelated unlabeled components separate when response ids collide', () => {
|
|
const trajectories = buildTrajectories([
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_a',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
responseId: 'shared-response',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Task A' }),
|
|
responseBody: JSON.stringify({ id: 'shared-response', status: 'completed' }),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_b',
|
|
createdAtIso: '2026-06-23T01:01:00.000Z',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
responseId: 'shared-response',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Task B' }),
|
|
responseBody: JSON.stringify({ id: 'shared-response', status: 'completed' }),
|
|
},
|
|
]);
|
|
|
|
assert.equal(trajectories.length, 2);
|
|
assert.ok(trajectories.every((trajectory) => trajectory.turns.length === 1));
|
|
assert.equal(new Set(trajectories.map((trajectory) => trajectory.session_id)).size, 2);
|
|
});
|
|
|
|
it('does not merge independent runs only because they share a cwd', () => {
|
|
const trajectories = buildTrajectories([
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_cwd_a',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
cwd: '/repo/app',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
responseId: 'resp_cwd_a',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Task A' }),
|
|
responseBody: JSON.stringify({ id: 'resp_cwd_a', status: 'completed' }),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_cwd_b',
|
|
createdAtIso: '2026-06-23T01:01:00.000Z',
|
|
cwd: '/repo/app',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
responseId: 'resp_cwd_b',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Task B' }),
|
|
responseBody: JSON.stringify({ id: 'resp_cwd_b', status: 'completed' }),
|
|
},
|
|
]);
|
|
|
|
assert.equal(trajectories.length, 2);
|
|
assert.deepEqual(trajectories.map((t) => t.task).sort(), ['Task A', 'Task B']);
|
|
});
|
|
|
|
it('does not use raw cwd as a fallback session id', () => {
|
|
const trajectories = buildTrajectories([{
|
|
prism_compatible: true,
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
cwd: '/Users/alice/private-repo',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Task without ids' }),
|
|
responseBody: JSON.stringify({ id: 'resp_body_only', status: 'completed' }),
|
|
}]);
|
|
|
|
assert.equal(trajectories.length, 1);
|
|
assert.equal(trajectories[0].session_id, 'unknown#0');
|
|
assert.doesNotMatch(JSON.stringify(trajectories[0]), /\/Users\/alice\/private-repo/);
|
|
});
|
|
|
|
it('reads sorted top-level llm trace files when input is a directory', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-dir-'));
|
|
try {
|
|
fs.mkdirSync(path.join(dir, 'nested'));
|
|
fs.writeFileSync(path.join(dir, 'ignore.jsonl'), JSON.stringify({
|
|
prism_compatible: true,
|
|
requestId: 'ignored',
|
|
}) + '\n', 'utf8');
|
|
fs.writeFileSync(path.join(dir, 'nested', 'llm-trace-000.jsonl'), JSON.stringify({
|
|
prism_compatible: true,
|
|
requestId: 'ignored_nested',
|
|
}) + '\n', 'utf8');
|
|
fs.writeFileSync(path.join(dir, 'llm-trace-002.jsonl'), JSON.stringify({
|
|
prism_compatible: true,
|
|
requestId: 'req_b',
|
|
sessionId: 'sess_b',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'second sorted row' }),
|
|
responseBody: JSON.stringify({ id: 'resp_b' }),
|
|
}) + '\n', 'utf8');
|
|
fs.writeFileSync(path.join(dir, 'llm-trace-001.jsonl'), JSON.stringify({
|
|
prism_compatible: true,
|
|
requestId: 'req_a',
|
|
sessionId: 'sess_a',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'first sorted row' }),
|
|
responseBody: JSON.stringify({ id: 'resp_a' }),
|
|
}) + '\n', 'utf8');
|
|
|
|
const { rows, stats } = readTraceRowsDetailed(dir);
|
|
|
|
assert.deepEqual(rows.map((row) => row.requestId), ['req_a', 'req_b']);
|
|
assert.equal(stats.filesScanned, 2);
|
|
assert.equal(stats.rowsScanned, 2);
|
|
assert.equal(stats.rowsRead, 2);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('reads v2 llm_turn trace rows from jsonl input', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-v2-input-'));
|
|
try {
|
|
const input = path.join(dir, 'traces.jsonl');
|
|
fs.writeFileSync(input, [
|
|
JSON.stringify({
|
|
event: 'llm_turn',
|
|
ts: '2026-06-23T01:00:00.000Z',
|
|
request_id: 'req_v2_file',
|
|
session_id: 'sess_v2_file',
|
|
response_id: 'resp_v2_file',
|
|
route: '/v1/responses',
|
|
provider: 'openai',
|
|
chosen_model: 'gpt-test',
|
|
latency_ms: 25,
|
|
usage: { input_tokens: 3, output_tokens: 4 },
|
|
request_body: JSON.stringify({ model: 'gpt-test', input: 'v2 file row' }),
|
|
response_body: JSON.stringify({ id: 'resp_v2_file' }),
|
|
}),
|
|
JSON.stringify({ event: 'not_llm_turn', request_id: 'skip_v2_file' }),
|
|
].join('\n') + '\n', 'utf8');
|
|
|
|
const { rows, stats } = readTraceRowsDetailed(input);
|
|
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].requestId, 'req_v2_file');
|
|
assert.equal(rows[0].sessionId, 'sess_v2_file');
|
|
assert.equal(rows[0].responseId, 'resp_v2_file');
|
|
assert.equal(rows[0].path, '/v1/responses');
|
|
assert.equal(rows[0].durationMs, 25);
|
|
assert.equal(rows[0].input_tokens, 3);
|
|
assert.equal(rows[0].output_tokens, 4);
|
|
assert.equal(stats.rowsScanned, 2);
|
|
assert.equal(stats.rowsRead, 1);
|
|
assert.equal(stats.nonPrismSkipped, 1);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('CLI accepts split --input/--output args for directory exports', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-cli-dir-'));
|
|
try {
|
|
const traceDir = path.join(dir, 'traces');
|
|
const output = path.join(dir, 'trajectories.jsonl');
|
|
fs.mkdirSync(traceDir);
|
|
fs.writeFileSync(path.join(traceDir, 'llm-trace-001.jsonl'), JSON.stringify({
|
|
prism_compatible: true,
|
|
requestId: 'req_cli_dir',
|
|
sessionId: 'sess_cli_dir',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'split arg directory input' }),
|
|
responseBody: JSON.stringify({ id: 'resp_cli_dir' }),
|
|
}) + '\n', 'utf8');
|
|
|
|
const res = spawnSync(process.execPath, [
|
|
INDEX_JS,
|
|
'trajectory-export',
|
|
'--input',
|
|
traceDir,
|
|
'--output',
|
|
output,
|
|
], {
|
|
cwd: dir,
|
|
env: {
|
|
...process.env,
|
|
A2A_NODE_SECRET: '',
|
|
EVOMAP_NODE_SECRET: '',
|
|
EVOLVER_HOME: dir,
|
|
HOME: dir,
|
|
},
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
assert.equal(res.status, 0, res.stderr);
|
|
const exported = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
assert.equal(exported.length, 1);
|
|
assert.equal(exported[0].session_id, 'sess_cli_dir');
|
|
assert.equal(exported[0].task, 'split arg directory input');
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('CLI exports a Codex rollout session JSONL as a runtime session trajectory', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-codex-session-'));
|
|
try {
|
|
const inputDir = path.join(dir, '.codex', 'sessions', '2026', '06', '24');
|
|
const input = path.join(inputDir, 'rollout-2026-06-24T01-02-03-test.jsonl');
|
|
const output = path.join(dir, 'coding-trajectories.jsonl');
|
|
fs.mkdirSync(inputDir, { recursive: true });
|
|
fs.writeFileSync(input, [
|
|
{
|
|
timestamp: '2026-06-24T01:02:03.000Z',
|
|
type: 'session_meta',
|
|
payload: { id: 'codex-session-1', cwd: '/tmp/work' },
|
|
},
|
|
{
|
|
timestamp: '2026-06-24T01:02:04.000Z',
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'message',
|
|
role: 'user',
|
|
content: [{ type: 'input_text', text: 'Fix the TypeScript test and run pnpm test.' }],
|
|
},
|
|
},
|
|
{
|
|
timestamp: '2026-06-24T01:02:05.000Z',
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'reasoning',
|
|
model: 'gpt-5-codex',
|
|
usage: { input_tokens: 13, output_tokens: 2 },
|
|
summary: [{ text: 'Need inspect then test.' }],
|
|
encrypted_content: 'codex-encrypted-content',
|
|
},
|
|
},
|
|
{
|
|
timestamp: '2026-06-24T01:02:06.000Z',
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'function_call',
|
|
name: 'shell_command',
|
|
call_id: 'call_test',
|
|
arguments: '{"command":"pnpm test"}',
|
|
},
|
|
},
|
|
{
|
|
timestamp: '2026-06-24T01:02:07.000Z',
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'function_call_output',
|
|
call_id: 'call_test',
|
|
output: 'Exit code: 1\nOutput:\n1 failed',
|
|
},
|
|
},
|
|
{
|
|
timestamp: '2026-06-24T01:02:08.000Z',
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'custom_tool_call',
|
|
name: 'apply_patch',
|
|
call_id: 'call_patch',
|
|
input: '*** Begin Patch\n*** End Patch',
|
|
},
|
|
},
|
|
{
|
|
timestamp: '2026-06-24T01:02:09.000Z',
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'tool_search_call',
|
|
id: 'call_search',
|
|
query: 'confirmed high jsonl',
|
|
filters: { repo: 'evolver' },
|
|
},
|
|
},
|
|
{
|
|
timestamp: '2026-06-24T01:02:10.000Z',
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'tool_search_output',
|
|
call_id: 'call_search',
|
|
results: [{ title: 'Keep native tool events' }],
|
|
},
|
|
},
|
|
].map((row) => JSON.stringify(row)).join('\n') + '\n', 'utf8');
|
|
|
|
const res = spawnSync(process.execPath, [
|
|
INDEX_JS,
|
|
'trajectory-export',
|
|
'--input',
|
|
dir,
|
|
'--output',
|
|
output,
|
|
], {
|
|
cwd: dir,
|
|
env: {
|
|
...process.env,
|
|
A2A_NODE_SECRET: '',
|
|
EVOMAP_NODE_SECRET: '',
|
|
EVOLVER_HOME: dir,
|
|
HOME: dir,
|
|
},
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
assert.equal(res.status, 0, res.stderr);
|
|
const [trajectory] = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
assert.equal(trajectory.session_id, 'codex-session-1');
|
|
assert.equal(trajectory.source_kind, 'runtime_session');
|
|
assert.equal(trajectory.source_agent, 'codex');
|
|
assert.equal(trajectory.source_path, input);
|
|
assert.match(trajectory.task, /Fix the TypeScript test/);
|
|
assert.equal(trajectory.stats.has_test_execution, true);
|
|
assert.equal(trajectory.stats.has_code_edit, true);
|
|
assert.equal(trajectory.stats.has_failure_correction, true);
|
|
assert.equal(trajectory.stats.input_tokens, 13);
|
|
assert.equal(trajectory.stats.output_tokens, 2);
|
|
assert.equal(trajectory.stats.tool_types.shell_command, 1);
|
|
assert.equal(trajectory.stats.tool_types.apply_patch, 1);
|
|
assert.equal(trajectory.stats.tool_types.tool_search_call, 1);
|
|
const reasoningTurn = trajectory.turns.find((turn) => turn.reasoning === 'Need inspect then test.');
|
|
assert.ok(reasoningTurn);
|
|
assert.equal(reasoningTurn.model, 'gpt-5-codex');
|
|
assert.equal(reasoningTurn.input_tokens, 13);
|
|
assert.equal(reasoningTurn.output_tokens, 2);
|
|
assert.equal(reasoningTurn.encrypted_content, 'codex-encrypted-content');
|
|
assert.ok(trajectory.turns.some((turn) => String(turn.error || '').includes('1 failed')));
|
|
assert.ok(trajectory.turns.some((turn) => turn.tool_calls.some((call) => (
|
|
call.name === 'shell_command' && call.input === '{"command":"pnpm test"}'
|
|
))));
|
|
assert.ok(trajectory.turns.some((turn) => turn.tool_calls.some((call) => (
|
|
call.name === 'tool_search_call'
|
|
&& call.id === 'call_search'
|
|
&& call.input.query === 'confirmed high jsonl'
|
|
))));
|
|
const toolOutputTurn = trajectory.turns.find((turn) => (
|
|
turn.response_body
|
|
&& turn.response_body.tool_name === 'tool_search_output'
|
|
&& turn.response_body.tool_use_id === 'call_search'
|
|
));
|
|
assert.ok(toolOutputTurn);
|
|
assert.deepEqual(JSON.parse(toolOutputTurn.response_body.tool_result).results, [{ title: 'Keep native tool events' }]);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('writes the export atomically with mode 0o600 and does not follow a pre-placed symlink (PR #294 C4)', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-c4-'));
|
|
try {
|
|
const inputDir = path.join(dir, '.codex', 'sessions');
|
|
const input = path.join(inputDir, 'rollout.jsonl');
|
|
fs.mkdirSync(inputDir, { recursive: true });
|
|
fs.writeFileSync(input, [
|
|
JSON.stringify({ timestamp: '2026-06-24T01:02:03.000Z', type: 'session_meta', payload: { id: 'c4-session' } }),
|
|
JSON.stringify({
|
|
timestamp: '2026-06-24T01:02:04.000Z',
|
|
type: 'response_item',
|
|
payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }] },
|
|
}),
|
|
].join('\n') + '\n', 'utf8');
|
|
|
|
const sensitiveTarget = path.join(dir, 'sensitive-target.txt');
|
|
fs.writeFileSync(sensitiveTarget, 'ORIGINAL', { mode: 0o644 });
|
|
const output = path.join(dir, 'out.jsonl');
|
|
fs.symlinkSync(sensitiveTarget, output);
|
|
|
|
writeTrajectories({ input, output });
|
|
|
|
// The pre-placed symlink must not be followed: the sensitive target stays untouched.
|
|
assert.equal(fs.readFileSync(sensitiveTarget, 'utf8'), 'ORIGINAL');
|
|
// The output is now a real regular file (symlink replaced), owner-only 0o600.
|
|
const st = fs.lstatSync(output);
|
|
assert.ok(!st.isSymbolicLink());
|
|
if (process.platform === 'win32') {
|
|
assert.equal((st.mode & fs.constants.S_IFMT), fs.constants.S_IFREG);
|
|
} else {
|
|
assert.equal(st.mode & 0o777, 0o600);
|
|
}
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('marks Codex rollout session trajectories incomplete when JSONL contains invalid rows', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-codex-invalid-json-'));
|
|
try {
|
|
const inputDir = path.join(dir, '.codex', 'sessions');
|
|
const input = path.join(inputDir, 'rollout-invalid.jsonl');
|
|
const output = path.join(dir, 'coding-trajectories.jsonl');
|
|
fs.mkdirSync(inputDir, { recursive: true });
|
|
fs.writeFileSync(input, [
|
|
JSON.stringify({
|
|
timestamp: '2026-06-24T01:02:03.000Z',
|
|
type: 'session_meta',
|
|
payload: { id: 'codex-invalid-json' },
|
|
}),
|
|
'{bad json',
|
|
JSON.stringify({
|
|
timestamp: '2026-06-24T01:02:04.000Z',
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'message',
|
|
role: 'user',
|
|
content: [{ type: 'input_text', text: 'Fix incomplete export.' }],
|
|
},
|
|
}),
|
|
].join('\n') + '\n', 'utf8');
|
|
|
|
const result = writeTrajectories({ input, output });
|
|
const [trajectory] = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
|
|
assert.equal(result.stats.invalidJson, 1);
|
|
assert.equal(result.stats.sessionInvalidJson, 1);
|
|
assert.equal(trajectory.stats.has_incomplete_turns, true);
|
|
assert.ok(trajectory.turns.every((turn) => turn.complete === false));
|
|
assert.ok(trajectory.turns.every((turn) => turn.incomplete_reasons.includes('runtime_session_invalid_json')));
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('discovers current-workspace Codex and Claude Code session JSONL with runtimeSessions', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-runtime-discovery-'));
|
|
const prevTraceFile = process.env.EVOMAP_PROXY_TRACE_FILE;
|
|
const prevEvolverHome = process.env.EVOLVER_HOME;
|
|
const prevHome = process.env.HOME;
|
|
try {
|
|
const home = path.join(dir, 'home');
|
|
const workspace = path.join(dir, 'workspace');
|
|
const codexDir = path.join(home, '.codex', 'sessions', '2026', '06', '26');
|
|
const claudeDir = path.join(home, '.claude', 'projects', 'workspace');
|
|
const output = path.join(dir, 'coding-trajectories.jsonl');
|
|
fs.mkdirSync(codexDir, { recursive: true });
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
fs.mkdirSync(workspace, { recursive: true });
|
|
fs.writeFileSync(path.join(codexDir, 'rollout-2026-06-26T01-02-03-test.jsonl'), [
|
|
{ timestamp: '2026-06-26T01:02:03.000Z', type: 'session_meta', payload: { id: 'codex-runtime-1', cwd: workspace } },
|
|
{ timestamp: '2026-06-26T01:02:04.000Z', type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Patch the Codex runtime path.' }] } },
|
|
{ timestamp: '2026-06-26T01:02:05.000Z', type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Patched.' }] } },
|
|
].map((row) => JSON.stringify(row)).join('\n') + '\n', 'utf8');
|
|
fs.writeFileSync(path.join(claudeDir, 'claude-runtime-1.jsonl'), [
|
|
{ timestamp: '2026-06-26T02:00:00.000Z', cwd: workspace, type: 'user', message: { content: [{ type: 'text', text: 'Patch the Claude runtime path.' }] } },
|
|
{ timestamp: '2026-06-26T02:00:01.000Z', cwd: workspace, type: 'assistant', message: { content: [{ type: 'text', text: 'Done.' }] } },
|
|
].map((row) => JSON.stringify(row)).join('\n') + '\n', 'utf8');
|
|
process.env.EVOMAP_PROXY_TRACE_FILE = path.join(dir, 'missing-proxy-traces.jsonl');
|
|
process.env.EVOLVER_HOME = path.join(dir, '.evomap');
|
|
process.env.HOME = home;
|
|
|
|
// Strict marking gate (default): both runtime sessions must be in the
|
|
// evolver-marked registry to be collected. The session-start hook writes
|
|
// codex's session_meta.payload.id ('codex-runtime-1') and Claude Code's
|
|
// session uuid (the transcript basename 'claude-runtime-1').
|
|
const markedFile = path.join(dir, 'marked-sessions.jsonl');
|
|
fs.writeFileSync(markedFile, [
|
|
{ session_id: 'codex-runtime-1', marked_at: '2026-06-26T01:02:03.000Z' },
|
|
{ session_id: 'claude-runtime-1', marked_at: '2026-06-26T02:00:00.000Z' },
|
|
].map((row) => JSON.stringify(row)).join('\n') + '\n', 'utf8');
|
|
|
|
const result = writeTrajectories({
|
|
output,
|
|
runtimeSessions: true,
|
|
homedir: home,
|
|
workspaceRoot: workspace,
|
|
markedSessionsFile: markedFile,
|
|
});
|
|
const trajectories = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
|
|
assert.deepEqual(trajectories.map((item) => item.source_agent).sort(), ['claude-code', 'codex']);
|
|
assert.ok(trajectories.every((item) => item.source_kind === 'runtime_session'));
|
|
assert.ok(trajectories.some((item) => String(item.task || '').includes('Codex runtime path')));
|
|
assert.ok(trajectories.some((item) => String(item.task || '').includes('Claude runtime path')));
|
|
assert.equal(result.runtimeSessionDiscovery.enabled, true);
|
|
assert.equal(result.runtimeSessionDiscovery.dirsScanned, 2);
|
|
assert.equal(result.runtimeSessionDiscovery.filesMatched, 2);
|
|
} finally {
|
|
if (prevTraceFile === undefined) delete process.env.EVOMAP_PROXY_TRACE_FILE;
|
|
else process.env.EVOMAP_PROXY_TRACE_FILE = prevTraceFile;
|
|
if (prevEvolverHome === undefined) delete process.env.EVOLVER_HOME;
|
|
else process.env.EVOLVER_HOME = prevEvolverHome;
|
|
if (prevHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = prevHome;
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('CLI exports a Claude Code transcript JSONL as a runtime session trajectory', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-claude-session-'));
|
|
try {
|
|
const inputDir = path.join(dir, '.claude', 'projects', 'demo');
|
|
const input = path.join(inputDir, 'claude-session-1.transcript.jsonl');
|
|
const output = path.join(dir, 'coding-trajectories.jsonl');
|
|
fs.mkdirSync(inputDir, { recursive: true });
|
|
fs.writeFileSync(input, [
|
|
{
|
|
timestamp: '2026-06-24T02:00:00.000Z',
|
|
type: 'user',
|
|
message: { content: [{ type: 'text', text: 'Patch foo.py and run pytest.' }] },
|
|
},
|
|
{
|
|
timestamp: '2026-06-24T02:00:01.000Z',
|
|
type: 'assistant',
|
|
message: {
|
|
model: 'claude-opus-4-20250514',
|
|
usage: { input_tokens: 17, output_tokens: 23 },
|
|
content: [
|
|
{
|
|
type: 'thinking',
|
|
thinking: 'Need inspect first.',
|
|
signature: 'claude-thinking-signature',
|
|
encrypted_signature: 'claude-encrypted-signature',
|
|
},
|
|
{ type: 'tool_use', id: 'tu_1', name: 'Bash', input: { command: 'pytest' } },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
timestamp: '2026-06-24T02:00:02.000Z',
|
|
type: 'user',
|
|
message: {
|
|
content: [{ type: 'tool_result', tool_use_id: 'tu_1', content: 'Exit code: 1\npytest failed', is_error: true }],
|
|
},
|
|
},
|
|
].map((row) => JSON.stringify(row)).join('\n') + '\n', 'utf8');
|
|
|
|
const res = spawnSync(process.execPath, [
|
|
INDEX_JS,
|
|
'trajectory-export',
|
|
'--input',
|
|
input,
|
|
'--output',
|
|
output,
|
|
], {
|
|
cwd: dir,
|
|
env: {
|
|
...process.env,
|
|
A2A_NODE_SECRET: '',
|
|
EVOMAP_NODE_SECRET: '',
|
|
EVOLVER_HOME: dir,
|
|
HOME: dir,
|
|
},
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
assert.equal(res.status, 0, res.stderr);
|
|
const [trajectory] = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
assert.equal(trajectory.session_id, 'claude-session-1.transcript');
|
|
assert.equal(trajectory.source_kind, 'runtime_session');
|
|
assert.equal(trajectory.source_agent, 'claude-code');
|
|
assert.equal(trajectory.source_path, input);
|
|
assert.equal(trajectory.stats.has_test_execution, true);
|
|
assert.equal(trajectory.stats.has_failure_correction, true);
|
|
assert.equal(trajectory.stats.input_tokens, 17);
|
|
assert.equal(trajectory.stats.output_tokens, 23);
|
|
assert.equal(trajectory.stats.total_tokens, 40);
|
|
assert.equal(trajectory.stats.tool_types.Bash, 1);
|
|
const reasoningTurn = trajectory.turns.find((turn) => turn.reasoning === 'Need inspect first.');
|
|
assert.ok(reasoningTurn);
|
|
assert.equal(reasoningTurn.model, 'claude-opus-4-20250514');
|
|
assert.equal(reasoningTurn.input_tokens, 17);
|
|
assert.equal(reasoningTurn.output_tokens, 23);
|
|
assert.equal(reasoningTurn.reasoning_signature, 'claude-thinking-signature');
|
|
assert.equal(reasoningTurn.encrypted_signature, 'claude-encrypted-signature');
|
|
assert.doesNotMatch(reasoningTurn.reasoning, /claude-thinking-signature|claude-encrypted-signature/);
|
|
assert.ok(trajectory.turns.some((turn) => (
|
|
turn.response_body
|
|
&& turn.response_body.tool_name === 'Bash'
|
|
&& String(turn.error || '').includes('pytest failed')
|
|
)));
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('redacts runtime session bodies, tool inputs, errors, and summaries before export', () => {
|
|
const auth = 'Bearer local-redaction-fixture-value';
|
|
const apiKey = 'api_key=local-redaction-fixture-value';
|
|
const trajectory = buildTrajectoryFromSessionLog({
|
|
sourceAgent: 'generic-chat',
|
|
sourcePath: 'sample.messages.jsonl',
|
|
sessionId: 'runtime-redaction',
|
|
turns: [
|
|
{
|
|
role: 'user',
|
|
text: `Fix login with Authorization: ${auth}`,
|
|
isMeta: false,
|
|
},
|
|
{
|
|
role: 'assistant',
|
|
text: `Need shell with ${apiKey}`,
|
|
reasoning: true,
|
|
isMeta: false,
|
|
},
|
|
{
|
|
role: 'assistant',
|
|
text: '',
|
|
toolName: 'shell_command',
|
|
toolUseId: 'call_secret',
|
|
toolInput: { command: `curl -H "Authorization: ${auth}" https://example.invalid` },
|
|
isMeta: false,
|
|
},
|
|
{
|
|
role: 'tool',
|
|
text: '',
|
|
toolUseId: 'call_secret',
|
|
toolResult: `Exit code: 1\nOutput:\n${apiKey}`,
|
|
errorMessage: `failed with ${apiKey}`,
|
|
isMeta: false,
|
|
},
|
|
],
|
|
});
|
|
|
|
const serialized = JSON.stringify(trajectory);
|
|
assert.ok(trajectory);
|
|
assert.doesNotMatch(serialized, new RegExp(auth.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
|
assert.doesNotMatch(serialized, new RegExp(apiKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
|
assert.match(trajectory.task, /\[redacted\]/i);
|
|
assert.match(trajectory.turns[1].reasoning, /\[redacted\]/i);
|
|
assert.equal(trajectory.turns[2].redaction, 'evolver-redact-v2');
|
|
assert.match(JSON.stringify(trajectory.turns[2].tool_calls[0].input), /\[redacted\]/i);
|
|
assert.match(trajectory.turns[3].error, /\[redacted\]/i);
|
|
});
|
|
|
|
it('preserves runtime session system prompts and session envelope metadata', () => {
|
|
const trajectory = buildTrajectoryFromSessionLog({
|
|
sourceAgent: 'generic-chat',
|
|
sourcePath: 'sample.messages.jsonl',
|
|
sessionId: 'runtime-envelope-session',
|
|
provider: 'openai',
|
|
model: 'gpt-5.5',
|
|
tools: [{ type: 'function', function: { name: 'exec_command', parameters: { type: 'object' } } }],
|
|
turns: [
|
|
{
|
|
role: 'system',
|
|
text: 'SYSTEM_PROMPT_MUST_SURVIVE',
|
|
isMeta: true,
|
|
},
|
|
{
|
|
role: 'user',
|
|
text: 'Use the declared tool.',
|
|
isMeta: false,
|
|
},
|
|
{
|
|
role: 'assistant',
|
|
text: '',
|
|
toolName: 'exec_command',
|
|
toolUseId: 'call_test',
|
|
toolInput: { cmd: 'node --test' },
|
|
isMeta: false,
|
|
},
|
|
],
|
|
});
|
|
|
|
assert.match(JSON.stringify(trajectory), /SYSTEM_PROMPT_MUST_SURVIVE/);
|
|
assert.deepEqual(trajectory.providers, ['openai']);
|
|
assert.equal(trajectory.session_model, 'gpt-5.5');
|
|
assert.deepEqual(trajectory.session_tools, [{ type: 'function', function: { name: 'exec_command', parameters: { type: 'object' } } }]);
|
|
assert.deepEqual(trajectory.turns[0].request_body, {
|
|
source: 'runtime_session',
|
|
role: 'system',
|
|
model: 'gpt-5.5',
|
|
tools_ref: 'session_tools',
|
|
text: 'SYSTEM_PROMPT_MUST_SURVIVE',
|
|
});
|
|
assert.ok(trajectory.turns[1].tool_calls.some((call) => call.name === 'exec_command' && call.declared === true));
|
|
assert.equal(trajectory.stats.tool_call_count, 1);
|
|
});
|
|
|
|
it('exports generic OpenAI messages JSONL and preserves tool call arguments', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-generic-chat-'));
|
|
try {
|
|
const input = path.join(dir, 'session.messages.jsonl');
|
|
const output = path.join(dir, 'coding-trajectories.jsonl');
|
|
const toolArguments = '{"cmd":"pnpm test"}';
|
|
fs.writeFileSync(input, [
|
|
{ role: 'system', content: 'You are a careful coding assistant.' },
|
|
{ role: 'user', content: 'Fix the TypeScript test and run pnpm test.' },
|
|
{
|
|
role: 'assistant',
|
|
model: 'gpt-generic-runtime',
|
|
usage: { prompt_tokens: 5, completion_tokens: 7 },
|
|
content: [{ type: 'thinking', thinking: 'Need run tests.', signature: 'generic-thinking-signature' }],
|
|
},
|
|
{
|
|
role: 'assistant',
|
|
content: null,
|
|
tool_calls: [{
|
|
id: 'call_run_tests',
|
|
type: 'function',
|
|
function: {
|
|
name: 'exec_command',
|
|
arguments: toolArguments,
|
|
},
|
|
}],
|
|
},
|
|
{
|
|
role: 'tool',
|
|
tool_call_id: 'call_run_tests',
|
|
content: 'Exit code: 0\nTests passed',
|
|
},
|
|
].map((row) => JSON.stringify(row)).join('\n') + '\n', 'utf8');
|
|
|
|
const result = writeTrajectories({ input, output });
|
|
const [trajectory] = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
|
|
assert.equal(result.trajectories.length, 1);
|
|
assert.equal(result.rowsRead, 0);
|
|
assert.equal(result.sessionFilesRead, 1);
|
|
assert.equal(trajectory.source_kind, 'runtime_session');
|
|
assert.equal(trajectory.source_agent, 'generic-chat');
|
|
assert.equal(trajectory.source_path, input);
|
|
assert.equal(trajectory.task, 'Fix the TypeScript test and run pnpm test.');
|
|
assert.equal(trajectory.stats.has_tool_calls, true);
|
|
assert.equal(trajectory.stats.tool_types.exec_command, 1);
|
|
assert.equal(trajectory.stats.has_test_execution, true);
|
|
assert.equal(trajectory.stats.input_tokens, 5);
|
|
assert.equal(trajectory.stats.output_tokens, 7);
|
|
assert.deepEqual(trajectory.stats.test_commands, ['pnpm test']);
|
|
const reasoningTurn = trajectory.turns.find((turn) => turn.reasoning === 'Need run tests.');
|
|
assert.ok(reasoningTurn);
|
|
assert.equal(reasoningTurn.model, 'gpt-generic-runtime');
|
|
assert.equal(reasoningTurn.reasoning_signature, 'generic-thinking-signature');
|
|
assert.doesNotMatch(reasoningTurn.reasoning, /generic-thinking-signature/);
|
|
|
|
const callTurn = trajectory.turns.find((turn) => (
|
|
Array.isArray(turn.tool_calls)
|
|
&& turn.tool_calls.some((call) => call.id === 'call_run_tests')
|
|
));
|
|
assert.ok(callTurn);
|
|
assert.equal(callTurn.response_body.tool_input, toolArguments);
|
|
assert.equal(callTurn.tool_calls[0].name, 'exec_command');
|
|
assert.equal(callTurn.tool_calls[0].input, toolArguments);
|
|
|
|
const resultTurn = trajectory.turns.find((turn) => (
|
|
turn.response_body
|
|
&& turn.response_body.tool_result === 'Exit code: 0\nTests passed'
|
|
));
|
|
assert.ok(resultTurn);
|
|
assert.equal(resultTurn.response_body.tool_name, 'exec_command');
|
|
assert.equal(resultTurn.response_body.tool_use_id, 'call_run_tests');
|
|
assert.match(JSON.stringify(trajectory), /careful coding assistant/);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('exports generic chat wrapper system prompt and top-level envelope metadata', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-generic-wrapper-'));
|
|
try {
|
|
const input = path.join(dir, 'sample.messages.json');
|
|
const output = path.join(dir, 'coding-trajectories.jsonl');
|
|
fs.writeFileSync(input, JSON.stringify({
|
|
session_id: 'wrapper-session-1',
|
|
provider: 'openai',
|
|
model: 'gpt-5.5',
|
|
tools: [{ type: 'function', function: { name: 'exec_command', parameters: { type: 'object' } } }],
|
|
messages: [
|
|
{ role: 'system', content: 'SYSTEM_PROMPT_MUST_SURVIVE' },
|
|
{ role: 'user', content: 'Run the declared tool.' },
|
|
{ role: 'assistant', tool_calls: [{ id: 'call_test', type: 'function', function: { name: 'exec_command', arguments: '{"cmd":"node --test"}' } }] },
|
|
],
|
|
}), 'utf8');
|
|
|
|
const result = writeTrajectories({ input, output });
|
|
const [trajectory] = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
|
|
assert.equal(result.trajectories.length, 1);
|
|
assert.equal(trajectory.session_id, 'wrapper-session-1');
|
|
assert.deepEqual(trajectory.providers, ['openai']);
|
|
assert.equal(trajectory.session_model, 'gpt-5.5');
|
|
assert.match(JSON.stringify(trajectory.session_tools), /exec_command/);
|
|
assert.match(JSON.stringify(trajectory), /SYSTEM_PROMPT_MUST_SURVIVE/);
|
|
assert.deepEqual(trajectory.turns[0].request_body, {
|
|
source: 'runtime_session',
|
|
role: 'system',
|
|
model: 'gpt-5.5',
|
|
tools_ref: 'session_tools',
|
|
text: 'SYSTEM_PROMPT_MUST_SURVIVE',
|
|
});
|
|
assert.ok(trajectory.turns[1].tool_calls.some((call) => call.name === 'exec_command' && call.declared === true));
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('exports one trajectory per generic JSONL session wrapper row', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-generic-multi-session-'));
|
|
try {
|
|
const input = path.join(dir, 'sample.messages.jsonl');
|
|
const output = path.join(dir, 'coding-trajectories.jsonl');
|
|
fs.writeFileSync(input, [
|
|
{ session_id: 's1', model: 'gpt-test', messages: [{ role: 'user', content: 'Patch parser one.' }, { role: 'assistant', content: 'Done one.' }] },
|
|
{ session_id: 's2', model: 'gpt-test', messages: [{ role: 'user', content: 'Patch parser two.' }, { role: 'assistant', content: 'Done two.' }] },
|
|
].map((row) => JSON.stringify(row)).join('\n') + '\n', 'utf8');
|
|
|
|
const result = writeTrajectories({ input, output });
|
|
const trajectories = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
|
|
assert.equal(result.trajectories.length, 2);
|
|
assert.deepEqual(trajectories.map((trajectory) => trajectory.session_id), ['s1', 's2']);
|
|
assert.deepEqual(trajectories.map((trajectory) => trajectory.task), ['Patch parser one.', 'Patch parser two.']);
|
|
assert.deepEqual(trajectories.map((trajectory) => trajectory.turns.length), [2, 2]);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('exports OpenAI card prompt/candidates/tools/meta with reasoning_content', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-card-envelope-'));
|
|
try {
|
|
const input = path.join(dir, 'baidu-card.jsonl');
|
|
const output = path.join(dir, 'coding-trajectories.jsonl');
|
|
fs.writeFileSync(input, JSON.stringify({
|
|
task_id: 'card-task-1',
|
|
prompt: [
|
|
{ role: 'system', content: [{ type: 'text', text: 'SYSTEM_PROMPT_MUST_SURVIVE' }] },
|
|
{ role: 'user', content: [{ type: 'text', text: 'Run the declared command.' }] },
|
|
],
|
|
candidates: [[{
|
|
role: 'assistant',
|
|
reasoning_content: 'Need inspect before command.',
|
|
content: [{ type: 'text', text: 'I will call the command.' }],
|
|
tool_calls: [{ id: 'call_exec', type: 'function', function: { name: 'exec_command', arguments: '{"cmd":"node --test"}' } }],
|
|
}]],
|
|
tools: [{ type: 'function', function: { name: 'exec_command' } }],
|
|
meta: { model: 'claude-opus-4.5', create_time: '2026-06-23T00:00:00Z' },
|
|
}) + '\n', 'utf8');
|
|
|
|
const result = writeTrajectories({ input, output });
|
|
const [trajectory] = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
|
|
assert.equal(result.trajectories.length, 1);
|
|
assert.equal(trajectory.session_id, 'card-task-1');
|
|
assert.equal(trajectory.session_model, 'claude-opus-4.5');
|
|
assert.match(JSON.stringify(trajectory.session_tools), /exec_command/);
|
|
assert.ok(trajectory.turns.some((turn) => turn.reasoning === 'Need inspect before command.'));
|
|
assert.ok(trajectory.turns.some((turn) => turn.response_body?.reasoning === true && turn.response_body.text === 'Need inspect before command.'));
|
|
assert.ok(trajectory.turns.some((turn) => turn.tool_calls?.some((call) => call.name === 'exec_command' && call.declared !== true)));
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('exports native call-level request plus response.response_data JSON', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-native-call-'));
|
|
try {
|
|
const input = path.join(dir, 'native-call.json');
|
|
const output = path.join(dir, 'coding-trajectories.jsonl');
|
|
fs.writeFileSync(input, JSON.stringify({
|
|
session_id: 'native-call-1',
|
|
timestamp: '2026-06-23T01:02:03Z',
|
|
request: {
|
|
model: 'claude-sonnet-4-6',
|
|
max_tokens: 4096,
|
|
metadata: { trace_label: 'native-user-1' },
|
|
tools: [{ name: 'Bash', input_schema: { type: 'object' } }],
|
|
messages: [{ role: 'user', content: [{ type: 'text', text: 'Run node --test.' }] }],
|
|
},
|
|
response: {
|
|
response_data: {
|
|
id: 'msg_native_1',
|
|
role: 'assistant',
|
|
model: 'claude-sonnet-4-6',
|
|
stop_reason: 'tool_use',
|
|
usage: { input_tokens: 10, output_tokens: 4 },
|
|
content: [
|
|
{ type: 'thinking', thinking: 'Need test result first.', signature: 'thinking-sig-native-1' },
|
|
{ type: 'tool_use', id: 'tu_1', name: 'Bash', input: { command: 'node --test' } },
|
|
],
|
|
},
|
|
},
|
|
}), 'utf8');
|
|
|
|
const result = writeTrajectories({ input, output });
|
|
const [trajectory] = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
|
|
assert.equal(result.trajectories.length, 1);
|
|
assert.equal(trajectory.session_id, 'native-call-1');
|
|
assert.equal(trajectory.session_model, 'claude-sonnet-4-6');
|
|
assert.equal(trajectory.stats.input_tokens, 10);
|
|
assert.equal(trajectory.stats.output_tokens, 4);
|
|
assert.equal(trajectory.stats.tool_types.Bash, 1);
|
|
assert.equal(trajectory.native_calls[0].request_body.max_tokens, 4096);
|
|
assert.deepEqual(trajectory.native_calls[0].request_body.metadata, { trace_label: 'native-user-1' });
|
|
assert.equal(trajectory.native_calls[0].response_body.response_data.id, 'msg_native_1');
|
|
assert.equal(trajectory.native_calls[0].response_body.response_data.stop_reason, 'tool_use');
|
|
assert.deepEqual(trajectory.native_calls[0].response_body.response_data.usage, { input_tokens: 10, output_tokens: 4 });
|
|
assert.equal(trajectory.native_calls[0].response_body.response_data.content[0].signature, 'thinking-sig-native-1');
|
|
assert.ok(trajectory.turns.some((turn) => turn.reasoning === 'Need test result first.' && turn.reasoning_signature === 'thinking-sig-native-1'));
|
|
assert.ok(trajectory.turns.some((turn) => turn.tool_calls?.some((call) => (
|
|
call.name === 'Bash'
|
|
&& call.input !== undefined
|
|
&& JSON.stringify(call.input).includes('node --test')
|
|
))));
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('exports OpenAI native response_body choices and usage without losing normalized text', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-openai-native-call-'));
|
|
try {
|
|
const input = path.join(dir, 'native-openai.messages.json');
|
|
const output = path.join(dir, 'coding-trajectories.jsonl');
|
|
fs.writeFileSync(input, JSON.stringify({
|
|
session_id: 'openai-native-call-1',
|
|
provider: 'openai',
|
|
timestamp: '2026-06-23T02:03:04Z',
|
|
request_body: {
|
|
model: 'gpt-5.5',
|
|
messages: [{ role: 'user', content: 'Summarize the failure.' }],
|
|
},
|
|
response_body: {
|
|
id: 'chatcmpl_native_1',
|
|
model: 'gpt-5.5',
|
|
choices: [{
|
|
index: 0,
|
|
finish_reason: 'stop',
|
|
message: { role: 'assistant', content: 'Tests failed because timeout was too low.' },
|
|
}],
|
|
usage: { prompt_tokens: 7, completion_tokens: 9, total_tokens: 16 },
|
|
},
|
|
}), 'utf8');
|
|
|
|
const result = writeTrajectories({ input, output });
|
|
const [trajectory] = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
|
|
assert.equal(result.trajectories.length, 1);
|
|
assert.equal(trajectory.session_id, 'openai-native-call-1');
|
|
assert.deepEqual(trajectory.providers, ['openai']);
|
|
assert.deepEqual(trajectory.stats, {
|
|
turns: 2,
|
|
input_tokens: 7,
|
|
output_tokens: 9,
|
|
total_tokens: 16,
|
|
tool_call_count: 0,
|
|
tool_types: {},
|
|
has_tool_calls: false,
|
|
has_code_edit: false,
|
|
has_test_execution: false,
|
|
test_commands: [],
|
|
has_failure_correction: false,
|
|
has_truncated_stream: false,
|
|
has_incomplete_turns: false,
|
|
});
|
|
assert.equal(trajectory.native_calls[0].response_body.id, 'chatcmpl_native_1');
|
|
assert.equal(trajectory.native_calls[0].response_body.choices[0].finish_reason, 'stop');
|
|
assert.deepEqual(trajectory.native_calls[0].response_body.usage, { prompt_tokens: 7, completion_tokens: 9, total_tokens: 16 });
|
|
assert.ok(trajectory.turns.some((turn) => turn.response_body?.text === 'Tests failed because timeout was too low.'));
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('accepts generic chat arrays, wrappers, and single pretty JSON messages', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-generic-formats-'));
|
|
try {
|
|
const cases = [
|
|
{
|
|
name: 'array.chat.json',
|
|
body: [
|
|
{ role: 'system', content: 'Hidden setup.' },
|
|
{ role: 'user', content: 'Patch the Python parser.' },
|
|
],
|
|
task: 'Patch the Python parser.',
|
|
},
|
|
{
|
|
name: 'wrapped.messages.json',
|
|
body: { messages: [{ role: 'user', content: 'Run go test.' }] },
|
|
task: 'Run go test.',
|
|
},
|
|
{
|
|
name: 'wrapped.transcript.json',
|
|
body: { turns: [{ role: 'user', content: 'Review TypeScript code.' }] },
|
|
task: 'Review TypeScript code.',
|
|
},
|
|
{
|
|
name: 'single.messages.json',
|
|
body: { role: 'user', content: 'Summarize the Java failure.' },
|
|
task: 'Summarize the Java failure.',
|
|
},
|
|
];
|
|
|
|
for (const item of cases) {
|
|
const input = path.join(dir, item.name);
|
|
const output = path.join(dir, `${item.name}.out.jsonl`);
|
|
fs.writeFileSync(input, JSON.stringify(item.body, null, 2), 'utf8');
|
|
|
|
const result = writeTrajectories({ input, output });
|
|
const [trajectory] = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
|
|
assert.equal(result.trajectories.length, 1);
|
|
assert.equal(result.rowsRead, 0);
|
|
assert.equal(result.sessionFilesRead, 1);
|
|
assert.equal(trajectory.source_agent, 'generic-chat');
|
|
assert.equal(trajectory.task, item.task);
|
|
}
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('classifies an explicitly named generic session JSONL by content', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-content-session-'));
|
|
try {
|
|
const input = path.join(dir, 'session.jsonl');
|
|
const output = path.join(dir, 'coding-trajectories.jsonl');
|
|
fs.writeFileSync(input, [
|
|
{ role: 'user', content: 'Patch the parser and run node --test.' },
|
|
{ role: 'assistant', content: 'I will inspect the parser first.' },
|
|
].map((row) => JSON.stringify(row)).join('\n') + '\n', 'utf8');
|
|
|
|
const result = writeTrajectories({ input, output });
|
|
const [trajectory] = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
|
|
assert.equal(result.trajectories.length, 1);
|
|
assert.equal(result.rowsRead, 0);
|
|
assert.equal(result.sessionFilesRead, 1);
|
|
assert.equal(trajectory.source_kind, 'runtime_session');
|
|
assert.equal(trajectory.source_agent, 'generic-chat');
|
|
assert.equal(trajectory.task, 'Patch the parser and run node --test.');
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('skips unrelated JSON files while content-classifying an explicit directory', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-content-dir-'));
|
|
try {
|
|
const output = path.join(dir, 'coding-trajectories.jsonl');
|
|
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'not-a-session' }), 'utf8');
|
|
fs.writeFileSync(path.join(dir, 'session.jsonl'), [
|
|
{ role: 'user', content: 'Fix the JSONL directory parser.' },
|
|
{ role: 'assistant', content: 'I will keep unrelated JSON out.' },
|
|
].map((row) => JSON.stringify(row)).join('\n') + '\n', 'utf8');
|
|
|
|
const result = writeTrajectories({ input: dir, output });
|
|
const [trajectory] = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
|
|
assert.equal(result.trajectories.length, 1);
|
|
assert.equal(trajectory.task, 'Fix the JSONL directory parser.');
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('fails closed when an explicit JSONL input is not a supported trace or session', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-unknown-jsonl-'));
|
|
try {
|
|
const input = path.join(dir, 'session.jsonl');
|
|
const output = path.join(dir, 'coding-trajectories.jsonl');
|
|
fs.writeFileSync(input, [
|
|
{ payload: { message: 'missing role and trace event' } },
|
|
{ payload: { message: 'still unsupported' } },
|
|
].map((row) => JSON.stringify(row)).join('\n') + '\n', 'utf8');
|
|
|
|
assert.throws(
|
|
() => writeTrajectories({ input, output }),
|
|
/trajectory input format is not recognized/,
|
|
);
|
|
assert.equal(fs.existsSync(output), false);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('fails closed when an explicit hidden .jsonl file is not a supported trace or session', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-hidden-jsonl-'));
|
|
try {
|
|
const input = path.join(dir, '.jsonl');
|
|
const output = path.join(dir, 'coding-trajectories.jsonl');
|
|
fs.writeFileSync(input, JSON.stringify({ payload: { message: 'unsupported hidden jsonl' } }) + '\n', 'utf8');
|
|
|
|
assert.throws(
|
|
() => writeTrajectories({ input, output }),
|
|
/trajectory input format is not recognized/,
|
|
);
|
|
assert.equal(fs.existsSync(output), false);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('fails closed when an explicitly named Codex JSONL file has no runtime turns', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-codex-unknown-jsonl-'));
|
|
try {
|
|
const input = path.join(dir, 'codex-unknown.jsonl');
|
|
const output = path.join(dir, 'coding-trajectories.jsonl');
|
|
fs.writeFileSync(input, JSON.stringify({ task_id: 'unsupported', canonical_solution: 'return 1' }) + '\n', 'utf8');
|
|
|
|
assert.throws(
|
|
() => writeTrajectories({ input, output }),
|
|
/trajectory input format is not recognized/,
|
|
);
|
|
assert.equal(fs.existsSync(output), false);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('fails closed when encrypted trace rows cannot be decrypted', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-encrypted-'));
|
|
try {
|
|
const input = path.join(dir, 'traces.jsonl');
|
|
fs.writeFileSync(input, JSON.stringify({
|
|
encrypted: true,
|
|
algorithm: 'aes-256-gcm',
|
|
payload_schema: 'prism_trace_row',
|
|
iv: 'bad',
|
|
tag: 'bad',
|
|
ciphertext: 'bad',
|
|
}) + '\n', 'utf8');
|
|
|
|
assert.throws(() => readTraceRowsDetailed(input, { nodeSecret: '' }), /encrypted trace row cannot be exported without node secret/);
|
|
assert.throws(() => readTraceRowsDetailed(input, { nodeSecret: '0'.repeat(64) }), /failed to decrypt encrypted trace row/);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('decrypts hub_key_envelope rows with a hub private key and fails closed on bad keys', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-hub-key-'));
|
|
try {
|
|
const input = path.join(dir, 'traces.jsonl');
|
|
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
const { privateKey: wrongPrivateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
|
|
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
|
|
const wrongPrivatePem = wrongPrivateKey.export({ type: 'pkcs8', format: 'pem' });
|
|
fs.writeFileSync(input, JSON.stringify(encryptRowWithHubKey({
|
|
prism_compatible: true,
|
|
requestId: 'req_hub_key',
|
|
sessionId: 'sess_hub_key',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'hub private key decrypts this row' }),
|
|
responseBody: JSON.stringify({ id: 'resp_hub_key' }),
|
|
}, publicPem)) + '\n', 'utf8');
|
|
|
|
const result = readTraceRowsDetailed(input, { hubPrivateKey: privatePem });
|
|
|
|
assert.equal(result.rows.length, 1);
|
|
assert.equal(result.rows[0].requestId, 'req_hub_key');
|
|
assert.equal(result.stats.encryptedRows, 1);
|
|
assert.equal(result.stats.decryptFailures, 0);
|
|
assert.throws(
|
|
() => readTraceRowsDetailed(input, { hubPrivateKey: wrongPrivatePem }),
|
|
/failed to decrypt encrypted trace row with hub private key/,
|
|
);
|
|
const partial = readTraceRowsDetailed(input, { hubPrivateKey: wrongPrivatePem, allowPartial: true });
|
|
assert.equal(partial.rows.length, 0);
|
|
assert.equal(partial.stats.decryptFailures, 1);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('falls back to node secret when hub private key cannot decrypt the row', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-hub-node-fallback-'));
|
|
const nodeSecret = 'c'.repeat(64);
|
|
try {
|
|
const input = path.join(dir, 'traces.jsonl');
|
|
const { publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
const { privateKey: wrongPrivateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
|
|
const wrongPrivatePem = wrongPrivateKey.export({ type: 'pkcs8', format: 'pem' });
|
|
const nodeEncrypted = encryptRowWithNodeSecret({
|
|
prism_compatible: true,
|
|
requestId: 'req_hub_fallback',
|
|
sessionId: 'sess_hub_fallback',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'node secret fallback row' }),
|
|
responseBody: JSON.stringify({ id: 'resp_hub_fallback' }),
|
|
}, nodeSecret, 7);
|
|
const hubEnvelope = encryptRowWithHubKey({ prism_compatible: true }, publicPem).hub_key_envelope;
|
|
fs.writeFileSync(input, JSON.stringify({ ...nodeEncrypted, hub_key_envelope: hubEnvelope }) + '\n', 'utf8');
|
|
|
|
const result = readTraceRowsDetailed(input, {
|
|
hubPrivateKey: wrongPrivatePem,
|
|
nodeSecret,
|
|
nodeSecretKeyring: { 7: nodeSecret },
|
|
});
|
|
|
|
assert.equal(result.rows.length, 1);
|
|
assert.equal(result.rows[0].requestId, 'req_hub_fallback');
|
|
assert.equal(result.stats.decryptFailures, 0);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('CLI accepts split --hub-private-key for hub envelope rows', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-cli-hub-key-'));
|
|
try {
|
|
const input = path.join(dir, 'traces.jsonl');
|
|
const output = path.join(dir, 'trajectories.jsonl');
|
|
const privateKeyFile = path.join(dir, 'hub_private.pem');
|
|
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
|
|
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
|
|
fs.writeFileSync(privateKeyFile, privatePem, { encoding: 'utf8', mode: 0o600 });
|
|
fs.writeFileSync(input, JSON.stringify(encryptRowWithHubKey({
|
|
prism_compatible: true,
|
|
requestId: 'req_cli_hub_key',
|
|
sessionId: 'sess_cli_hub_key',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'split hub private key row' }),
|
|
responseBody: JSON.stringify({ id: 'resp_cli_hub_key' }),
|
|
}, publicPem)) + '\n', 'utf8');
|
|
|
|
const res = spawnSync(process.execPath, [
|
|
INDEX_JS,
|
|
'trajectory-export',
|
|
'--input',
|
|
input,
|
|
'--output',
|
|
output,
|
|
'--hub-private-key',
|
|
privateKeyFile,
|
|
], {
|
|
cwd: dir,
|
|
env: {
|
|
...process.env,
|
|
A2A_NODE_SECRET: '',
|
|
EVOMAP_NODE_SECRET: '',
|
|
EVOLVER_HOME: dir,
|
|
HOME: dir,
|
|
},
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
assert.equal(res.status, 0, res.stderr);
|
|
assert.doesNotMatch(`${res.stdout}\n${res.stderr}`, /BEGIN PRIVATE KEY/);
|
|
const exported = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
assert.equal(exported.length, 1);
|
|
assert.equal(exported[0].session_id, 'sess_cli_hub_key');
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses row secret_version to select a node secret keyring entry before fallback secret', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-keyring-'));
|
|
const fallbackSecret = 'a'.repeat(64);
|
|
const rotatedSecret = 'b'.repeat(64);
|
|
try {
|
|
const input = path.join(dir, 'traces.jsonl');
|
|
fs.writeFileSync(input, [
|
|
JSON.stringify(encryptRowWithNodeSecret({
|
|
prism_compatible: true,
|
|
requestId: 'req_rotated',
|
|
sessionId: 'sess_rotated',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'rotated secret row' }),
|
|
responseBody: JSON.stringify({ id: 'resp_rotated' }),
|
|
}, rotatedSecret, 2)),
|
|
JSON.stringify(encryptRowWithNodeSecret({
|
|
prism_compatible: true,
|
|
requestId: 'req_fallback',
|
|
sessionId: 'sess_fallback',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'fallback secret row' }),
|
|
responseBody: JSON.stringify({ id: 'resp_fallback' }),
|
|
}, fallbackSecret)),
|
|
].join('\n') + '\n', 'utf8');
|
|
|
|
const result = readTraceRowsDetailed(input, {
|
|
nodeSecret: fallbackSecret,
|
|
nodeSecretKeyring: { 2: rotatedSecret },
|
|
});
|
|
|
|
assert.deepEqual(result.rows.map((row) => row.requestId), ['req_rotated', 'req_fallback']);
|
|
assert.equal(result.stats.encryptedRows, 2);
|
|
assert.equal(result.stats.decryptFailures, 0);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('falls back to nodeSecret when a keyring entry cannot decrypt the row', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-keyring-fallback-'));
|
|
const staleSecret = 'a'.repeat(64);
|
|
const currentSecret = 'b'.repeat(64);
|
|
try {
|
|
const input = path.join(dir, 'traces.jsonl');
|
|
fs.writeFileSync(input, JSON.stringify(encryptRowWithNodeSecret({
|
|
prism_compatible: true,
|
|
requestId: 'req_current_fallback',
|
|
sessionId: 'sess_current_fallback',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'current secret row' }),
|
|
responseBody: JSON.stringify({ id: 'resp_current_fallback' }),
|
|
}, currentSecret, 4)) + '\n', 'utf8');
|
|
|
|
const result = readTraceRowsDetailed(input, {
|
|
nodeSecret: currentSecret,
|
|
nodeSecretKeyring: { 4: staleSecret },
|
|
});
|
|
|
|
assert.equal(result.rows.length, 1);
|
|
assert.equal(result.rows[0].requestId, 'req_current_fallback');
|
|
assert.equal(result.stats.decryptFailures, 0);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('CLI accepts split --node-secret-keyring and array keyring JSON', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-cli-keyring-'));
|
|
const rotatedSecret = 'c'.repeat(64);
|
|
try {
|
|
const input = path.join(dir, 'traces.jsonl');
|
|
const output = path.join(dir, 'trajectories.jsonl');
|
|
const keyringFile = path.join(dir, 'keyring.json');
|
|
fs.writeFileSync(keyringFile, JSON.stringify([{ version: 3, nodeSecret: rotatedSecret }]), 'utf8');
|
|
fs.writeFileSync(input, JSON.stringify(encryptRowWithNodeSecret({
|
|
prism_compatible: true,
|
|
requestId: 'req_cli_keyring',
|
|
sessionId: 'sess_cli_keyring',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'array keyring row' }),
|
|
responseBody: JSON.stringify({ id: 'resp_cli_keyring' }),
|
|
}, rotatedSecret, 3)) + '\n', 'utf8');
|
|
|
|
const res = spawnSync(process.execPath, [
|
|
INDEX_JS,
|
|
'trajectory-export',
|
|
'--input',
|
|
input,
|
|
'--output',
|
|
output,
|
|
'--node-secret-keyring',
|
|
keyringFile,
|
|
], {
|
|
cwd: dir,
|
|
env: {
|
|
...process.env,
|
|
A2A_NODE_SECRET: '',
|
|
EVOMAP_NODE_SECRET: '',
|
|
EVOLVER_HOME: dir,
|
|
HOME: dir,
|
|
},
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
assert.equal(res.status, 0, res.stderr);
|
|
assert.doesNotMatch(`${res.stdout}\n${res.stderr}`, new RegExp(rotatedSecret));
|
|
const exported = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
assert.equal(exported.length, 1);
|
|
assert.equal(exported[0].session_id, 'sess_cli_keyring');
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('CLI accepts literal --node-secret for encrypted rows', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-cli-node-secret-literal-'));
|
|
const secret = 'd'.repeat(64);
|
|
try {
|
|
const input = path.join(dir, 'traces.jsonl');
|
|
const output = path.join(dir, 'trajectories.jsonl');
|
|
fs.writeFileSync(input, JSON.stringify(encryptRowWithNodeSecret({
|
|
prism_compatible: true,
|
|
requestId: 'req_cli_node_secret_literal',
|
|
sessionId: 'sess_cli_node_secret_literal',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'literal node secret row' }),
|
|
responseBody: JSON.stringify({ id: 'resp_cli_node_secret_literal' }),
|
|
}, secret)) + '\n', 'utf8');
|
|
|
|
const res = spawnSync(process.execPath, [
|
|
INDEX_JS,
|
|
'trajectory-export',
|
|
`--input=${input}`,
|
|
`--output=${output}`,
|
|
`--node-secret=${secret}`,
|
|
], {
|
|
cwd: dir,
|
|
env: {
|
|
...process.env,
|
|
A2A_NODE_SECRET: '',
|
|
EVOMAP_NODE_SECRET: '',
|
|
EVOLVER_HOME: dir,
|
|
HOME: dir,
|
|
},
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
assert.equal(res.status, 0, res.stderr);
|
|
assert.doesNotMatch(`${res.stdout}\n${res.stderr}`, new RegExp(secret));
|
|
const exported = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
assert.equal(exported.length, 1);
|
|
assert.equal(exported[0].session_id, 'sess_cli_node_secret_literal');
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('CLI reads --node-secret from an existing file path', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-cli-node-secret-file-path-'));
|
|
const secret = 'e'.repeat(64);
|
|
try {
|
|
const input = path.join(dir, 'traces.jsonl');
|
|
const output = path.join(dir, 'trajectories.jsonl');
|
|
const secretFile = path.join(dir, 'node_secret');
|
|
fs.writeFileSync(secretFile, secret + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
fs.writeFileSync(input, JSON.stringify(encryptRowWithNodeSecret({
|
|
prism_compatible: true,
|
|
requestId: 'req_cli_node_secret_path',
|
|
sessionId: 'sess_cli_node_secret_path',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'file path node secret row' }),
|
|
responseBody: JSON.stringify({ id: 'resp_cli_node_secret_path' }),
|
|
}, secret)) + '\n', 'utf8');
|
|
|
|
const res = spawnSync(process.execPath, [
|
|
INDEX_JS,
|
|
'trajectory-export',
|
|
'--input',
|
|
input,
|
|
'--output',
|
|
output,
|
|
'--node-secret',
|
|
secretFile,
|
|
], {
|
|
cwd: dir,
|
|
env: {
|
|
...process.env,
|
|
A2A_NODE_SECRET: '',
|
|
EVOMAP_NODE_SECRET: '',
|
|
EVOLVER_HOME: dir,
|
|
HOME: dir,
|
|
},
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
assert.equal(res.status, 0, res.stderr);
|
|
assert.doesNotMatch(`${res.stdout}\n${res.stderr}`, new RegExp(secret));
|
|
const exported = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
assert.equal(exported.length, 1);
|
|
assert.equal(exported[0].session_id, 'sess_cli_node_secret_path');
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('CLI --allow-partial exports plaintext rows when encrypted rows fail decrypt', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-cli-partial-'));
|
|
try {
|
|
const input = path.join(dir, 'traces.jsonl');
|
|
const output = path.join(dir, 'trajectories.jsonl');
|
|
const secretFile = path.join(dir, 'node_secret');
|
|
fs.writeFileSync(secretFile, 'a'.repeat(64) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
fs.writeFileSync(input, [
|
|
JSON.stringify({
|
|
prism_compatible: true,
|
|
requestId: 'req_plain_cli',
|
|
sessionId: 'sess_plain_cli',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'keep plaintext row' }),
|
|
responseBody: JSON.stringify({ id: 'resp_plain_cli', status: 'completed' }),
|
|
}),
|
|
JSON.stringify({
|
|
encrypted: true,
|
|
algorithm: 'aes-256-gcm',
|
|
payload_schema: 'prism_trace_row',
|
|
iv: 'bad',
|
|
tag: 'bad',
|
|
ciphertext: 'bad',
|
|
}),
|
|
].join('\n') + '\n', 'utf8');
|
|
|
|
const res = spawnSync(process.execPath, [
|
|
INDEX_JS,
|
|
'trajectory-export',
|
|
`--input=${input}`,
|
|
`--output=${output}`,
|
|
`--node-secret-file=${secretFile}`,
|
|
'--allow-partial',
|
|
], {
|
|
cwd: dir,
|
|
env: {
|
|
...process.env,
|
|
A2A_NODE_SECRET: '',
|
|
EVOMAP_NODE_SECRET: '',
|
|
EVOLVER_HOME: dir,
|
|
HOME: dir,
|
|
},
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
assert.equal(res.status, 0, res.stderr);
|
|
assert.match(res.stdout, /decrypt_failures=1/);
|
|
const exported = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
assert.equal(exported.length, 1);
|
|
assert.equal(exported[0].session_id, 'sess_plain_cli');
|
|
assert.equal(exported[0].task, 'keep plaintext row');
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('CLI node secret env input is not echoed when decrypt fails', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-cli-secret-'));
|
|
const secret = 'a'.repeat(64);
|
|
try {
|
|
const input = path.join(dir, 'traces.jsonl');
|
|
const output = path.join(dir, 'trajectories.jsonl');
|
|
fs.writeFileSync(input, JSON.stringify({
|
|
encrypted: true,
|
|
algorithm: 'aes-256-gcm',
|
|
payload_schema: 'prism_trace_row',
|
|
iv: 'bad',
|
|
tag: 'bad',
|
|
ciphertext: 'bad',
|
|
}) + '\n', 'utf8');
|
|
|
|
const res = spawnSync(process.execPath, [
|
|
INDEX_JS,
|
|
'trajectory-export',
|
|
`--input=${input}`,
|
|
`--output=${output}`,
|
|
'--node-secret-env=TRAJECTORY_EXPORT_TEST_SECRET',
|
|
], {
|
|
cwd: dir,
|
|
env: {
|
|
...process.env,
|
|
A2A_NODE_SECRET: '',
|
|
EVOMAP_NODE_SECRET: '',
|
|
EVOLVER_HOME: dir,
|
|
HOME: dir,
|
|
TRAJECTORY_EXPORT_TEST_SECRET: secret,
|
|
},
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
assert.equal(res.status, 1);
|
|
assert.match(res.stderr, /failed to decrypt encrypted trace row/);
|
|
assert.doesNotMatch(`${res.stdout}\n${res.stderr}`, new RegExp(secret));
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('fails closed when an explicit trace input is missing', () => {
|
|
const missing = path.join(os.tmpdir(), `missing-trajectory-${Date.now()}.jsonl`);
|
|
|
|
assert.throws(() => readTraceRowsDetailed(missing), /trace input is not readable/);
|
|
assert.throws(() => writeTrajectories({ input: missing, output: path.join(os.tmpdir(), 'unused-trajectories.jsonl') }), /trace input is not readable/);
|
|
assert.throws(() => writeTrajectories({ input: '', output: path.join(os.tmpdir(), 'unused-trajectories.jsonl') }), /trace input is not readable/);
|
|
});
|
|
|
|
it('fails closed when default trace rows cannot be decrypted', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-default-encrypted-'));
|
|
const prevTraceFile = process.env.EVOMAP_PROXY_TRACE_FILE;
|
|
const prevEvolverHome = process.env.EVOLVER_HOME;
|
|
const prevHome = process.env.HOME;
|
|
const prevEvomapSecret = process.env.EVOMAP_NODE_SECRET;
|
|
const prevA2aSecret = process.env.A2A_NODE_SECRET;
|
|
try {
|
|
const input = path.join(dir, 'proxy-traces.jsonl');
|
|
const output = path.join(dir, 'trajectories.jsonl');
|
|
fs.writeFileSync(input, JSON.stringify({
|
|
encrypted: true,
|
|
algorithm: 'aes-256-gcm',
|
|
payload_schema: 'prism_trace_row',
|
|
iv: 'bad',
|
|
tag: 'bad',
|
|
ciphertext: 'bad',
|
|
}) + '\n', 'utf8');
|
|
process.env.EVOMAP_PROXY_TRACE_FILE = input;
|
|
process.env.EVOLVER_HOME = dir;
|
|
process.env.HOME = dir;
|
|
delete process.env.EVOMAP_NODE_SECRET;
|
|
delete process.env.A2A_NODE_SECRET;
|
|
|
|
assert.throws(() => writeTrajectories({ output }), /encrypted trace row cannot be exported without node secret or hub private key/);
|
|
assert.equal(fs.existsSync(output), false);
|
|
} finally {
|
|
if (prevTraceFile === undefined) delete process.env.EVOMAP_PROXY_TRACE_FILE;
|
|
else process.env.EVOMAP_PROXY_TRACE_FILE = prevTraceFile;
|
|
if (prevEvolverHome === undefined) delete process.env.EVOLVER_HOME;
|
|
else process.env.EVOLVER_HOME = prevEvolverHome;
|
|
if (prevHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = prevHome;
|
|
if (prevEvomapSecret === undefined) delete process.env.EVOMAP_NODE_SECRET;
|
|
else process.env.EVOMAP_NODE_SECRET = prevEvomapSecret;
|
|
if (prevA2aSecret === undefined) delete process.env.A2A_NODE_SECRET;
|
|
else process.env.A2A_NODE_SECRET = prevA2aSecret;
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('treats undefined nodeSecret as default secret resolution', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-secret-'));
|
|
const prevSecret = process.env.EVOMAP_NODE_SECRET;
|
|
try {
|
|
const input = path.join(dir, 'traces.jsonl');
|
|
fs.writeFileSync(input, JSON.stringify({
|
|
encrypted: true,
|
|
algorithm: 'aes-256-gcm',
|
|
payload_schema: 'prism_trace_row',
|
|
iv: 'bad',
|
|
tag: 'bad',
|
|
ciphertext: 'bad',
|
|
}) + '\n', 'utf8');
|
|
process.env.EVOMAP_NODE_SECRET = '0'.repeat(64);
|
|
|
|
assert.throws(() => readTraceRowsDetailed(input, { nodeSecret: undefined }), /failed to decrypt encrypted trace row/);
|
|
} finally {
|
|
if (prevSecret === undefined) delete process.env.EVOMAP_NODE_SECRET;
|
|
else process.env.EVOMAP_NODE_SECRET = prevSecret;
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('deduplicates request history tool calls already counted from prior responses', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_history', [
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_first',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_history',
|
|
path: '/v1/chat/completions',
|
|
upstream: 'openai',
|
|
status: 200,
|
|
requestBody: JSON.stringify({ messages: [{ role: 'user', content: 'Run the JavaScript test' }] }),
|
|
responseBody: JSON.stringify({
|
|
choices: [{ message: { tool_calls: [{ id: 'call_1', function: { name: 'exec_command' } }] } }],
|
|
}),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_second',
|
|
createdAtIso: '2026-06-23T01:01:00.000Z',
|
|
sessionId: 'sess_history',
|
|
path: '/v1/chat/completions',
|
|
upstream: 'openai',
|
|
status: 200,
|
|
requestBody: JSON.stringify({
|
|
messages: [
|
|
{ role: 'user', content: 'Run the JavaScript test' },
|
|
{ role: 'assistant', tool_calls: [{ id: 'call_1', function: { name: 'exec_command' } }] },
|
|
{ role: 'tool', tool_call_id: 'call_1', content: 'ok' },
|
|
{ role: 'user', content: 'continue without tools' },
|
|
],
|
|
}),
|
|
responseBody: JSON.stringify({ choices: [{ message: { content: 'done' } }] }),
|
|
},
|
|
]);
|
|
|
|
assert.equal(trajectory.stats.tool_call_count, 2);
|
|
assert.equal(trajectory.stats.tool_types.exec_command, 1);
|
|
assert.equal(trajectory.stats.tool_types.tool_result, 1);
|
|
assert.equal(trajectory.turns[1].tool_calls.length, 2);
|
|
});
|
|
|
|
it('marks tool output failures as failure-correction candidates', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_tool_fail', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_tool_fail',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_tool_fail',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
status: 200,
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'Run tests and fix failures' }),
|
|
responseBody: JSON.stringify({
|
|
id: 'resp_tool_fail',
|
|
output: [{ type: 'function_call_output', call_id: 'call_1', output: 'pytest failed: 2 failed; command exited with code 1' }],
|
|
}),
|
|
}]);
|
|
|
|
assert.equal(trajectory.stats.has_failure_correction, true);
|
|
});
|
|
|
|
it('does not treat ordinary assistant prose about failed tests as failure correction', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_assistant_prose', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_assistant_prose',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_assistant_prose',
|
|
path: '/v1/chat/completions',
|
|
upstream: 'openai',
|
|
status: 200,
|
|
requestBody: JSON.stringify({ model: 'gpt-test', messages: [{ role: 'user', content: 'Summarize the build' }] }),
|
|
responseBody: JSON.stringify({
|
|
choices: [{ message: { role: 'assistant', content: 'The report says tests failed yesterday, but this response is only prose.' } }],
|
|
}),
|
|
}]);
|
|
|
|
assert.equal(trajectory.stats.has_failure_correction, false);
|
|
});
|
|
|
|
it('marks failed request-side tool history as failure-correction candidates', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_request_tool_fail', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_request_tool_fail',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_request_tool_fail',
|
|
path: '/v1/chat/completions',
|
|
upstream: 'openai',
|
|
status: 200,
|
|
requestBody: JSON.stringify({
|
|
messages: [
|
|
{ role: 'user', content: 'Run tests' },
|
|
{ role: 'assistant', tool_calls: [{ id: 'call_fail', function: { name: 'exec_command', arguments: '{"cmd":"pytest"}' } }] },
|
|
{ role: 'tool', tool_call_id: 'call_fail', content: 'pytest failed: 1 failed; exit code 1' },
|
|
{ role: 'user', content: 'Fix the failure' },
|
|
],
|
|
tools: [{ type: 'function', function: { name: 'exec_command' } }],
|
|
}),
|
|
responseBody: JSON.stringify({ choices: [{ message: { content: 'I will fix it.' } }] }),
|
|
}]);
|
|
|
|
assert.equal(trajectory.stats.has_failure_correction, true);
|
|
assert.equal(trajectory.stats.has_tool_calls, true);
|
|
assert.equal(trajectory.stats.tool_types.exec_command, 1);
|
|
assert.equal(trajectory.stats.tool_types.tool_result, 1);
|
|
assert.equal(trajectory.stats.tool_call_count, 2);
|
|
assert.equal(trajectory.turns[0].tool_calls.filter((call) => call.declared).length, 1);
|
|
});
|
|
|
|
it('marks streamed turns without captured response bodies as incomplete', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_stream_missing', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_stream_missing',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_stream_missing',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
status: 200,
|
|
isStream: true,
|
|
finished: true,
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'stream a response' }),
|
|
responseBody: '',
|
|
responseId: 'resp_stream_missing',
|
|
input_tokens: 10,
|
|
output_tokens: 20,
|
|
}]);
|
|
|
|
assert.equal(trajectory.stats.has_incomplete_turns, true);
|
|
assert.equal(trajectory.turns[0].complete, false);
|
|
assert.deepEqual(trajectory.turns[0].incomplete_reasons, ['stream_response_body_missing']);
|
|
});
|
|
|
|
it('marks streamed turns with partial bodies as incomplete when transport did not complete', () => {
|
|
const partialResponseBody = {
|
|
reconstructed_stream: true,
|
|
events: [{ type: 'response.output_text.delta', delta: 'partial text' }],
|
|
};
|
|
const rows = [
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_stream_unfinished',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_stream_transport',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
status: 200,
|
|
isStream: true,
|
|
finished: false,
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'stream unfinished' }),
|
|
responseBody: JSON.stringify(partialResponseBody),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_stream_unobserved',
|
|
createdAtIso: '2026-06-23T01:01:00.000Z',
|
|
sessionId: 'sess_stream_transport',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
status: 200,
|
|
isStream: true,
|
|
finished: true,
|
|
finishReason: 'stream_forwarded_unobserved',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'stream forwarded' }),
|
|
responseBody: JSON.stringify(partialResponseBody),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_stream_cancelled',
|
|
createdAtIso: '2026-06-23T01:02:00.000Z',
|
|
sessionId: 'sess_stream_transport',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
status: 499,
|
|
isStream: true,
|
|
finished: false,
|
|
errorMessage: 'client cancelled stream',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'stream cancelled' }),
|
|
responseBody: JSON.stringify(partialResponseBody),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_stream_error',
|
|
createdAtIso: '2026-06-23T01:03:00.000Z',
|
|
sessionId: 'sess_stream_transport',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
status: 502,
|
|
isStream: true,
|
|
finished: true,
|
|
errorMessage: 'upstream stream error: ECONNRESET',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'stream error' }),
|
|
responseBody: JSON.stringify(partialResponseBody),
|
|
},
|
|
];
|
|
const trajectory = buildTrajectoryFromRows('sess_stream_transport', rows);
|
|
|
|
assert.equal(trajectory.stats.has_incomplete_turns, true);
|
|
assert.deepEqual(trajectory.turns.map((turn) => turn.complete), [false, false, false, false]);
|
|
assert.deepEqual(trajectory.turns.map((turn) => turn.incomplete_reasons), [
|
|
['stream_unfinished'],
|
|
['stream_forwarded_unobserved'],
|
|
['stream_cancelled'],
|
|
['stream_error'],
|
|
]);
|
|
});
|
|
|
|
it('exports explicit stream error and cancellation details', () => {
|
|
const partialResponseBody = {
|
|
reconstructed_stream: true,
|
|
events: [{ type: 'response.output_text.delta', delta: 'partial text' }],
|
|
};
|
|
const trajectory = buildTrajectoryFromRows('sess_stream_details', [
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_stream_error_detail',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_stream_details',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
status: 502,
|
|
isStream: true,
|
|
finished: false,
|
|
stream_error: 'upstream stream error ECONNRESET',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'stream error' }),
|
|
responseBody: JSON.stringify(partialResponseBody),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_stream_cancel_detail',
|
|
createdAtIso: '2026-06-23T01:01:00.000Z',
|
|
sessionId: 'sess_stream_details',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
status: 499,
|
|
isStream: true,
|
|
finished: false,
|
|
stream_cancelled: 'client cancelled stream',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'stream cancel' }),
|
|
responseBody: JSON.stringify(partialResponseBody),
|
|
},
|
|
]);
|
|
|
|
assert.equal(trajectory.turns[0].stream_error, 'upstream stream error ECONNRESET');
|
|
assert.equal(trajectory.turns[1].stream_cancelled, 'client cancelled stream');
|
|
assert.deepEqual(trajectory.turns.map((turn) => turn.incomplete_reasons), [
|
|
['stream_error'],
|
|
['stream_cancelled'],
|
|
]);
|
|
});
|
|
|
|
it('marks missing, invalid, empty, and truncated native bodies as incomplete', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_incomplete', [
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_metadata_only',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_incomplete',
|
|
path: '/v1/messages',
|
|
upstream: 'anthropic',
|
|
status: 200,
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_invalid_request',
|
|
createdAtIso: '2026-06-23T01:01:00.000Z',
|
|
sessionId: 'sess_incomplete',
|
|
path: '/v1/messages',
|
|
upstream: 'anthropic',
|
|
status: 200,
|
|
requestBody: '{bad json',
|
|
responseBody: JSON.stringify({ id: 'msg_valid' }),
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_empty_response',
|
|
createdAtIso: '2026-06-23T01:02:00.000Z',
|
|
sessionId: 'sess_incomplete',
|
|
path: '/v1/messages',
|
|
upstream: 'anthropic',
|
|
status: 200,
|
|
requestBody: JSON.stringify({ model: 'claude-test' }),
|
|
responseBody: '{}',
|
|
},
|
|
{
|
|
prism_compatible: true,
|
|
requestId: 'req_truncated',
|
|
createdAtIso: '2026-06-23T01:03:00.000Z',
|
|
sessionId: 'sess_incomplete',
|
|
path: '/v1/chat/completions',
|
|
upstream: 'openai',
|
|
status: 200,
|
|
body_truncated: true,
|
|
requestBody: JSON.stringify({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }] }),
|
|
responseBody: JSON.stringify({ content_truncated: true }),
|
|
},
|
|
]);
|
|
|
|
assert.equal(trajectory.stats.has_incomplete_turns, true);
|
|
assert.equal(trajectory.stats.has_truncated_stream, true);
|
|
assert.deepEqual(trajectory.turns.map((turn) => turn.complete), [false, false, false, false]);
|
|
assert.deepEqual(trajectory.turns[0].incomplete_reasons, ['request_body_missing', 'response_body_missing']);
|
|
assert.deepEqual(trajectory.turns[1].incomplete_reasons, ['request_body_invalid_json']);
|
|
assert.deepEqual(trajectory.turns[2].incomplete_reasons, ['response_body_empty']);
|
|
assert.deepEqual(trajectory.turns[3].incomplete_reasons, ['body_truncated', 'response_body_truncated']);
|
|
assert.equal(trajectory.turns[3].response_events_truncated, true);
|
|
});
|
|
|
|
it('exports Trial Pack trajectory_id, string meta JSON, system prompt, thinking, and raw source records', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-trial-pack-'));
|
|
try {
|
|
const input = path.join(dir, 'trial.messages.jsonl');
|
|
const output = path.join(dir, 'coding-trajectories.jsonl');
|
|
fs.writeFileSync(input, JSON.stringify({
|
|
trajectory_id: 'trial-pack-trajectory-1',
|
|
meta: JSON.stringify({
|
|
model_name: 'claude-trial-pack',
|
|
risk: { level: 'low' },
|
|
fidelity: { source: 'trial-pack', complete: true },
|
|
confidentiality: { tier: 'internal' },
|
|
}),
|
|
system_prompt: 'TRIAL_SYSTEM_PROMPT',
|
|
messages: [
|
|
{ role: 'user', content: 'Patch Trial Pack export.' },
|
|
{
|
|
role: 'assistant',
|
|
thinking: 'Need preserve top-level thinking.',
|
|
content: 'Done.',
|
|
usage: { input_tokens: 3, output_tokens: 4 },
|
|
},
|
|
],
|
|
}) + '\n', 'utf8');
|
|
|
|
const result = writeTrajectories({ input, output });
|
|
const [trajectory] = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
|
|
assert.equal(result.trajectories.length, 1);
|
|
assert.equal(trajectory.session_id, 'trial-pack-trajectory-1');
|
|
assert.equal(trajectory.session_model, 'claude-trial-pack');
|
|
assert.deepEqual(trajectory.risk, { level: 'low' });
|
|
assert.deepEqual(trajectory.fidelity, { source: 'trial-pack', complete: true });
|
|
assert.deepEqual(trajectory.confidentiality, { tier: 'internal' });
|
|
assert.match(JSON.stringify(trajectory), /TRIAL_SYSTEM_PROMPT/);
|
|
assert.equal(trajectory.stats.input_tokens, 3);
|
|
assert.equal(trajectory.stats.output_tokens, 4);
|
|
const reasoningTurn = trajectory.turns.find((turn) => turn.reasoning === 'Need preserve top-level thinking.');
|
|
assert.ok(reasoningTurn);
|
|
assert.equal(reasoningTurn.model, 'claude-trial-pack');
|
|
assert.equal(trajectory.metadata.model_name, 'claude-trial-pack');
|
|
assert.equal(reasoningTurn.source_record_index, 0);
|
|
assert.equal(reasoningTurn.raw_row_index, 0);
|
|
assert.equal(trajectory.source_records[0].trajectory_id, 'trial-pack-trajectory-1');
|
|
assert.equal(trajectory.raw_rows[0].trajectory_id, 'trial-pack-trajectory-1');
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('exports normalized showcase metadata, usage, risk, fidelity, confidentiality, headers, ttfb, and raw passthrough', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-showcase-'));
|
|
try {
|
|
const input = path.join(dir, 'normalized.messages.json');
|
|
const output = path.join(dir, 'coding-trajectories.jsonl');
|
|
fs.writeFileSync(input, JSON.stringify({
|
|
metadata: {
|
|
model: 'gpt-showcase',
|
|
system_prompt: 'SHOWCASE_SYSTEM_PROMPT',
|
|
client_source: 'showcase-client',
|
|
authorization: 'placeholder',
|
|
},
|
|
usage: { prompt_tokens: 11, completion_tokens: 12 },
|
|
risk: { score: 0.1 },
|
|
fidelity: { transcript: 'raw-plus-normalized' },
|
|
confidentiality: { pii: false },
|
|
headers: { 'set-cookie': 'placeholder', 'x-trace-id': 'trace-1' },
|
|
ttfb_ms: 42,
|
|
messages: [
|
|
{ role: 'user', content: 'Build normalized showcase.' },
|
|
{ role: 'assistant', content: 'ok' },
|
|
],
|
|
}), 'utf8');
|
|
|
|
const result = writeTrajectories({ input, output });
|
|
const [trajectory] = fs.readFileSync(output, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
|
|
assert.equal(result.trajectories.length, 1);
|
|
assert.equal(trajectory.session_model, 'gpt-showcase');
|
|
assert.equal(trajectory.client_source, 'showcase-client');
|
|
assert.equal(trajectory.metadata.model, 'gpt-showcase');
|
|
assert.equal(trajectory.metadata.authorization, '[redacted]');
|
|
assert.deepEqual(trajectory.usage, { prompt_tokens: 11, completion_tokens: 12 });
|
|
assert.deepEqual(trajectory.risk, { score: 0.1 });
|
|
assert.deepEqual(trajectory.fidelity, { transcript: 'raw-plus-normalized' });
|
|
assert.deepEqual(trajectory.confidentiality, { pii: false });
|
|
assert.equal(trajectory.stats.input_tokens, 11);
|
|
assert.equal(trajectory.stats.output_tokens, 12);
|
|
assert.match(JSON.stringify(trajectory), /SHOWCASE_SYSTEM_PROMPT/);
|
|
const firstTurn = trajectory.turns[0];
|
|
assert.equal(firstTurn.client_source, 'showcase-client');
|
|
assert.equal(firstTurn.headers['set-cookie'], '[redacted]');
|
|
assert.equal(firstTurn.headers['x-trace-id'], 'trace-1');
|
|
assert.equal(firstTurn.ttfb_ms, 42);
|
|
assert.equal(firstTurn.source_record_index, 0);
|
|
assert.equal(firstTurn.raw_row_index, 0);
|
|
assert.equal(trajectory.source_records[0].metadata.authorization, '[redacted]');
|
|
assert.equal(trajectory.raw_rows[0].metadata.authorization, '[redacted]');
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('exports proxy trace raw passthrough, metadata, headers, ttfb, and degraded body-stripped reasons', () => {
|
|
const trajectory = buildTrajectoryFromRows('sess_passthrough', [{
|
|
prism_compatible: true,
|
|
requestId: 'req_passthrough',
|
|
createdAtIso: '2026-06-23T01:00:00.000Z',
|
|
sessionId: 'sess_passthrough',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
status: 200,
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'hello' }),
|
|
responseBody: JSON.stringify({ id: 'resp_passthrough', status: 'completed' }),
|
|
metadata: { source: 'raw-fixture', authorization: 'placeholder' },
|
|
usage: { input_tokens: 5, output_tokens: 6 },
|
|
risk: { level: 'low' },
|
|
fidelity: { normalized: true },
|
|
confidentiality: { pii: false },
|
|
source_record: { authorization: 'placeholder', raw: 'source' },
|
|
raw_row: { cookie: 'placeholder', raw: 'row' },
|
|
headers: { 'set-cookie': 'placeholder', 'x-upstream': 'ok' },
|
|
ttfb_ms: 123,
|
|
trace_degraded: true,
|
|
trace_degraded_reason: 'missing_node_secret',
|
|
body_stripped: true,
|
|
body_stripped_reason: 'missing_node_secret',
|
|
}]);
|
|
|
|
const turn = trajectory.turns[0];
|
|
assert.equal(turn.metadata.authorization, '[redacted]');
|
|
assert.equal(turn.source_record_index, 0);
|
|
assert.equal(turn.raw_row_index, 0);
|
|
assert.equal(trajectory.source_records[0].authorization, '[redacted]');
|
|
assert.equal(trajectory.raw_rows[0].cookie, '[redacted]');
|
|
assert.equal(turn.headers['set-cookie'], '[redacted]');
|
|
assert.equal(turn.headers['x-upstream'], 'ok');
|
|
assert.equal(turn.ttfb_ms, 123);
|
|
assert.equal(turn.trace_degraded, true);
|
|
assert.equal(turn.trace_degraded_reason, 'missing_node_secret');
|
|
assert.equal(turn.body_stripped, true);
|
|
assert.equal(turn.body_stripped_reason, 'missing_node_secret');
|
|
assert.deepEqual(turn.usage, { input_tokens: 5, output_tokens: 6 });
|
|
assert.deepEqual(turn.risk, { level: 'low' });
|
|
assert.equal(turn.complete, false);
|
|
assert.ok(turn.incomplete_reasons.includes('trace_degraded'));
|
|
assert.ok(turn.incomplete_reasons.includes('body_stripped'));
|
|
});
|
|
|
|
it('marks oversized runtime transcript fields as truncated and incomplete with omitted bytes', () => {
|
|
const oversized = 'x'.repeat(1024 * 1024 + 2048);
|
|
const trajectory = buildTrajectoryFromSessionLog({
|
|
sourceAgent: 'generic-chat',
|
|
sourcePath: 'oversized.messages.jsonl',
|
|
sessionId: 'runtime-oversized',
|
|
turns: [{ role: 'user', text: oversized, isMeta: false }],
|
|
});
|
|
|
|
const turn = trajectory.turns[0];
|
|
assert.equal(turn.complete, false);
|
|
assert.equal(turn.body_truncated, true);
|
|
assert.ok(turn.omitted_bytes > 0);
|
|
assert.deepEqual(turn.incomplete_reasons, ['body_truncated']);
|
|
assert.equal(turn.request_body.body_truncated, true);
|
|
assert.equal(turn.request_body.incomplete_reasons.includes('body_truncated'), true);
|
|
assert.ok(turn.request_body.omitted_bytes > 0);
|
|
});
|
|
|
|
it('writes exported trajectories with owner-only permissions', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-'));
|
|
try {
|
|
const input = path.join(dir, 'traces.jsonl');
|
|
const output = path.join(dir, 'trajectories.jsonl');
|
|
fs.writeFileSync(input, JSON.stringify({
|
|
prism_compatible: true,
|
|
requestId: 'req_perm',
|
|
sessionId: 'sess_perm',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'hello' }),
|
|
}) + '\n', 'utf8');
|
|
|
|
const result = writeTrajectories({ input, output });
|
|
assert.equal(result.trajectories.length, 1);
|
|
if (process.platform !== 'win32') {
|
|
assert.equal(fs.statSync(output).mode & 0o777, 0o600);
|
|
}
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('tightens permissions when overwriting an existing export file', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trajectory-export-'));
|
|
try {
|
|
const input = path.join(dir, 'traces.jsonl');
|
|
const output = path.join(dir, 'trajectories.jsonl');
|
|
fs.writeFileSync(input, JSON.stringify({
|
|
prism_compatible: true,
|
|
requestId: 'req_perm_existing',
|
|
sessionId: 'sess_perm_existing',
|
|
path: '/v1/responses',
|
|
upstream: 'openai',
|
|
requestBody: JSON.stringify({ model: 'gpt-test', input: 'hello' }),
|
|
}) + '\n', 'utf8');
|
|
fs.writeFileSync(output, 'old\n', { encoding: 'utf8', mode: 0o644 });
|
|
try {
|
|
fs.chmodSync(output, 0o644);
|
|
} catch {
|
|
// Best effort on filesystems that do not support POSIX modes.
|
|
}
|
|
|
|
writeTrajectories({ input, output });
|
|
if (process.platform !== 'win32') {
|
|
assert.equal(fs.statSync(output).mode & 0o777, 0o600);
|
|
}
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|