Files
maestro/test/transcript.test.ts
T
wangjia 0957a9049a feat(transcript): 归档详情内回放执行/复审转录 (tsk_O9q0_u78_NPo)
- API:新增 GET /api/runs/:id/transcript,读 run.transcriptRef 指向的
  jsonl,逐行解析成脱敏后的结构化消息数组。校验 runId 合法且关联任务/
  项目存在;路径只允许在 transcriptDir 下(防目录穿越)。
- 脱敏:只透传 assistant 文本 / tool_use(名+摘要参数) / tool_result(截断)
  / 结论 / 模型回退;命中 secret/token/env 等敏感键名的值剔除为 [已脱敏]。
- 看板 archiveModal:执行历史从"文件路径"换成可展开的消息流,按 type
  分类渲染(文本/工具调用/工具结果/结论/回退),长内容折叠,复用 mdToHtml。
  executor 与 reviewer/security run 并列可回放。
- Store:新增 getRun(runId)。
- 测试:transcript 解析(样例 jsonl/脱敏/截断/坏行容错/路径穿越)+ 端点
  inject(合法/未知 runId/无转录/越界路径)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:42:57 +08:00

184 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { readTranscript, assertWithinTranscriptDir, TranscriptError } from '../src/executor/transcript.js';
import { Store } from '../src/store/index.js';
import { buildServer } from '../src/api/server.js';
/** 临时数据目录 + transcripts 子目录;返回 {dir, transcriptsDir} */
function tmpData(): { dir: string; transcripts: string } {
const dir = mkdtempSync(join(tmpdir(), 'maestro-transcript-'));
const transcripts = join(dir, 'transcripts');
mkdirSync(transcripts, { recursive: true });
return { dir, transcripts };
}
/** 写一行原始 SDK 风格消息到 jsonl */
function jsonl(lines: unknown[]): string {
return lines.map((l) => JSON.stringify(l)).join('\n') + '\n';
}
const SAMPLE = [
{ type: 'system', subtype: 'init', session_id: 's1' },
{ type: 'assistant', session_id: 's1', message: { role: 'assistant', content: [
{ type: 'text', text: '我先看一下文件结构。' },
{ type: 'tool_use', id: 'tu1', name: 'Bash', input: { command: 'ls -la', description: '列目录' } },
] } },
{ type: 'user', session_id: 's1', message: { role: 'user', content: [
{ type: 'tool_result', tool_use_id: 'tu1', content: 'total 4\nfile.txt', is_error: false },
] } },
{ type: 'maestro.model_fallback', from: 'opusX', to: 'sonnetY', reason: 'not_found' },
{ type: 'result', subtype: 'success', is_error: false, result: '完成:改了一行。' },
];
test('readTranscript:样例 jsonl → 结构化消息数组', () => {
const { transcripts } = tmpData();
const ref = join(transcripts, 'run_abc.jsonl');
writeFileSync(ref, jsonl(SAMPLE));
const msgs = readTranscript(ref, transcripts);
const types = msgs.map((m) => m.type);
// system/init 不外传;其余按序结构化
assert.deepEqual(types, ['text', 'tool_use', 'tool_result', 'fallback', 'result']);
const text = msgs[0];
assert.equal(text.role, 'assistant');
assert.equal(text.text, '我先看一下文件结构。');
const toolUse = msgs[1];
assert.equal(toolUse.name, 'Bash');
assert.match(toolUse.input!, /ls -la/);
const toolResult = msgs[2];
assert.match(toolResult.text!, /file\.txt/);
assert.equal(toolResult.isError, false);
const fb = msgs[3];
assert.equal(fb.from, 'opusX');
assert.equal(fb.to, 'sonnetY');
assert.equal(msgs[4].text, '完成:改了一行。');
});
test('脱敏:含 secret/token/env 的工具参数被剔除', () => {
const { transcripts } = tmpData();
const ref = join(transcripts, 'run_secret.jsonl');
writeFileSync(ref, jsonl([
{ type: 'assistant', message: { content: [
{ type: 'tool_use', name: 'Deploy', input: {
host: 'example.com',
apiKey: 'sk-LIVE-should-not-leak',
token: 'ghp_secrettoken',
env: { ANTHROPIC_API_KEY: 'sk-ant-xxx' },
password: 'hunter2',
note: 'safe-to-show',
} },
] } },
]));
const msgs = readTranscript(ref, transcripts);
const input = msgs[0].input!;
assert.doesNotMatch(input, /should-not-leak/);
assert.doesNotMatch(input, /ghp_secrettoken/);
assert.doesNotMatch(input, /sk-ant-xxx/);
assert.doesNotMatch(input, /hunter2/);
assert.match(input, /\[已脱敏\]/);
// 非敏感字段保留
assert.match(input, /example\.com/);
assert.match(input, /safe-to-show/);
});
test('长内容被截断并标记 truncated', () => {
const { transcripts } = tmpData();
const ref = join(transcripts, 'run_long.jsonl');
const big = 'x'.repeat(10_000);
writeFileSync(ref, jsonl([
{ type: 'user', message: { content: [{ type: 'tool_result', content: big }] } },
]));
const msgs = readTranscript(ref, transcripts);
assert.equal(msgs[0].truncated, true);
assert.ok(msgs[0].text!.length < big.length);
});
test('路径穿越被拒绝(invalid_path', () => {
const { transcripts } = tmpData();
assert.throws(
() => assertWithinTranscriptDir(join(transcripts, '..', '..', 'etc', 'passwd'), transcripts),
(e: unknown) => e instanceof TranscriptError && e.code === 'invalid_path',
);
// 合法路径放行
assert.doesNotThrow(() => assertWithinTranscriptDir(join(transcripts, 'run_ok.jsonl'), transcripts));
});
test('坏行容错:非法 JSON 行被跳过,不影响其余解析', () => {
const { transcripts } = tmpData();
const ref = join(transcripts, 'run_bad.jsonl');
writeFileSync(ref, 'not-json\n' + JSON.stringify({ type: 'result', result: 'ok' }) + '\n\n');
const msgs = readTranscript(ref, transcripts);
assert.equal(msgs.length, 1);
assert.equal(msgs[0].text, 'ok');
});
// ── 端点测试(Fastify inject)──
test('GET /api/runs/:id/transcript:合法 run → 结构化消息', async () => {
const { dir, transcripts } = tmpData();
process.env.MAESTRO_DATA_DIR = dir; // transcriptDir() 在调用时读取
const store = new Store(':memory:');
const p = store.createProject({ name: 't', repoPath: '/tmp/t-' + Math.random() });
const task = store.createTask({ projectId: p.id, title: 'demo', complexity: 'easy' });
const run = store.startRun(task.id, 'executor');
const ref = join(transcripts, run.id + '.jsonl');
writeFileSync(ref, jsonl(SAMPLE));
store.finishRun(run.id, 'succeeded', { transcriptRef: ref });
const app = buildServer({ store });
const res = await app.inject({ method: 'GET', url: `/api/runs/${run.id}/transcript` });
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.runId, run.id);
assert.equal(body.kind, 'executor');
assert.ok(Array.isArray(body.messages));
assert.equal(body.messages.length, 5);
await app.close();
store.close();
});
test('GET /api/runs/:id/transcript:未知 runId → 404', async () => {
const store = new Store(':memory:');
const app = buildServer({ store });
const res = await app.inject({ method: 'GET', url: '/api/runs/run_nope/transcript' });
assert.equal(res.statusCode, 404);
await app.close();
store.close();
});
test('GET /api/runs/:id/transcript:无转录记录 → 404', async () => {
const store = new Store(':memory:');
const p = store.createProject({ name: 't', repoPath: '/tmp/t-' + Math.random() });
const task = store.createTask({ projectId: p.id, title: 'demo', complexity: 'easy' });
const run = store.startRun(task.id, 'executor'); // 未 finishRuntranscriptRef = null
const app = buildServer({ store });
const res = await app.inject({ method: 'GET', url: `/api/runs/${run.id}/transcript` });
assert.equal(res.statusCode, 404);
await app.close();
store.close();
});
test('GET /api/runs/:id/transcript:越界路径 → 400', async () => {
const { dir } = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const store = new Store(':memory:');
const p = store.createProject({ name: 't', repoPath: '/tmp/t-' + Math.random() });
const task = store.createTask({ projectId: p.id, title: 'demo', complexity: 'easy' });
const run = store.startRun(task.id, 'executor');
// 指向 transcriptDir 之外(穿越)
store.finishRun(run.id, 'failed', { transcriptRef: '/etc/passwd' });
const app = buildServer({ store });
const res = await app.inject({ method: 'GET', url: `/api/runs/${run.id}/transcript` });
assert.equal(res.statusCode, 400);
await app.close();
store.close();
});