merge: maestro/tsk_O9q0_u78_NPo [tsk_O9q0_u78_NPo]
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
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'); // 未 finishRun,transcriptRef = 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();
|
||||
});
|
||||
Reference in New Issue
Block a user