maestro/src/bridge/delegate-runs-api.test.ts
oss-sync 63d34d7cf6
All checks were successful
CI / build-and-test (push) Successful in 6m22s
sync: update from private repo (061c701c)
2026-07-09 03:01:06 +00:00

695 lines
25 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 { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { createDelegateRunsRouter } from './delegate-runs-api.js';
import type { Repository } from '../db/repository.js';
// ---------------------------------------------------------------------------
// Subtask-event helper: write events.jsonl to {dir}/logs/events.jsonl
// ---------------------------------------------------------------------------
function writeSubEvents(dir: string, lines: object[]) {
mkdirSync(join(dir, 'logs'), { recursive: true });
writeFileSync(
join(dir, 'logs', 'events.jsonl'),
lines.map((l) => JSON.stringify(l)).join('\n') + '\n',
'utf-8',
);
}
// ---------------------------------------------------------------------------
// Minimal repo mock — mirrors subtask-activity-api.test.ts pattern
// ---------------------------------------------------------------------------
function makeRepo(overrides: Partial<Repository> = {}): Repository {
return {
getLocalTask: vi.fn(),
getLatestJobForIssue: vi.fn(),
getSubJobs: vi.fn(),
getJob: vi.fn(),
userCanViewSpace: vi.fn().mockReturnValue(false),
...overrides,
} as unknown as Repository;
}
// ---------------------------------------------------------------------------
// Seed helpers
// ---------------------------------------------------------------------------
/** Write lines to {workspacePath}/logs/events.jsonl (logRoot fallback path). */
function writeEventsJsonl(workspacePath: string, lines: object[]) {
mkdirSync(join(workspacePath, 'logs'), { recursive: true });
writeFileSync(
join(workspacePath, 'logs', 'events.jsonl'),
lines.map((l) => JSON.stringify(l)).join('\n') + '\n',
'utf-8',
);
}
// Canonical minimal event factories
const START = (id: string) => ({
v: 1,
ts: '2026-06-25T00:00:01.000Z',
seq: 1,
eventId: 's',
runId: 'r',
kind: 'delegate_start',
payload: { delegateRunId: id, parentRunId: null, description: 'd', depth: 1 },
});
const TOOL = (id: string) => ({
v: 1,
ts: '2026-06-25T00:00:02.000Z',
seq: 2,
eventId: 't',
runId: 'r',
kind: 'tool_call',
correlationId: id,
payload: { toolName: 'Read', toolCallId: 'tc1' },
});
const DONE = (id: string) => ({
v: 1,
ts: '2026-06-25T00:00:03.000Z',
seq: 3,
eventId: 'c',
runId: 'r',
kind: 'delegate_complete',
payload: { delegateRunId: id, parentRunId: null, description: 'd', depth: 1, next: 'COMPLETE', status: 'success' },
});
// ---------------------------------------------------------------------------
// seedTaskWithWorkspace: build a test Express app + mocked repo + a task that
// has a non-null workspacePath backed by a real tmp directory.
// Also provides otherUsersTaskId whose getLocalTask mock returns null (viewer
// cannot see it), to test the visibility gate.
// ---------------------------------------------------------------------------
async function seedTaskWithWorkspace() {
const workspacePath = mkdtempSync(join(tmpdir(), 'delegate-runs-test-'));
const TASK = {
id: 1,
title: 'test task',
workspacePath,
runtimeDir: null,
ownerId: 'alice-id',
visibility: 'private' as const,
visibilityScopeOrgId: null,
spaceId: null,
};
const repo = makeRepo();
// By default task 1 is visible (no user set on req, so canViewTask returns true)
vi.mocked(repo.getLocalTask).mockImplementation(async (id: number) => {
if (id === 1) return TASK as never;
return null as never;
});
const app = express();
app.use(express.json());
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
return { app, repo, taskId: 1, workspacePath, otherUsersTaskId: 99 };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('GET /api/local/tasks/:id/delegate-runs', () => {
let tmpDirs: string[] = [];
afterEach(() => {
for (const dir of tmpDirs) {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
tmpDirs = [];
vi.clearAllMocks();
});
it('events.jsonl から run 一覧を返す', async () => {
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
tmpDirs.push(workspacePath);
writeEventsJsonl(workspacePath, [START('R1'), TOOL('R1'), DONE('R1')]);
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`);
expect(res.status).toBe(200);
expect(res.body.runs).toHaveLength(1);
expect(res.body.runs[0]).toMatchObject({ delegateRunId: 'R1', status: 'success', toolCalls: 1 });
});
it('events.jsonl 不在なら空配列(エラーにしない)', async () => {
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
tmpDirs.push(workspacePath);
// No events.jsonl written
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`);
expect(res.status).toBe(200);
expect(res.body.runs).toEqual([]);
});
it('複数 run を delegateRunId で区別して返す', async () => {
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
tmpDirs.push(workspacePath);
writeEventsJsonl(workspacePath, [
START('R1'), TOOL('R1'), DONE('R1'),
START('R2'), DONE('R2'),
]);
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`);
expect(res.status).toBe(200);
expect(res.body.runs).toHaveLength(2);
expect(res.body.runs.map((r: { delegateRunId: string }) => r.delegateRunId)).toEqual(['R1', 'R2']);
});
it('非可視タスクは 404 を返す', async () => {
const { app, otherUsersTaskId } = await seedTaskWithWorkspace();
const res = await request(app).get(`/api/local/tasks/${otherUsersTaskId}/delegate-runs`);
expect([403, 404]).toContain(res.status);
});
it('authenticated non-owner viewer blocked from private task (IDOR protection)', async () => {
const { repo, taskId, workspacePath } = await seedTaskWithWorkspace();
const tmpDirs = [workspacePath];
const mockGetLocalTask = vi.mocked(repo.getLocalTask);
// Configure mock: when viewer is passed (authenticated request),
// return null (simulating DB-level visibility filter)
mockGetLocalTask.mockImplementation(async (id: number, opts?: { viewer?: Express.User }) => {
if (id === taskId) {
if (opts?.viewer) {
// Non-owner viewer: visibility filter returns null
return null as never;
}
// No viewer (unauthenticated): allowed to view
return {
id: taskId,
title: 'test task',
workspacePath,
runtimeDir: null,
ownerId: 'alice-id',
visibility: 'private' as const,
visibilityScopeOrgId: null,
spaceId: null,
} as never;
}
return null as never;
});
// Create app with middleware that injects non-owner viewer
const nonOwnerUser: Express.User = { id: 'bob-id', name: 'Bob' } as Express.User;
const app = express();
app.use((req: any, _res: any, next: any) => {
req.user = nonOwnerUser;
next();
});
app.use(express.json());
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`);
// Verify: request is blocked (404 or 403) and no data is leaked
expect([403, 404]).toContain(res.status);
expect(res.body.runs).toBeUndefined();
expect(res.body.error).toBeDefined();
// Verify: getLocalTask was called WITH the viewer object
expect(mockGetLocalTask).toHaveBeenCalledWith(
taskId,
expect.objectContaining({ viewer: nonOwnerUser })
);
for (const dir of tmpDirs) {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
});
describe('GET /api/local/tasks/:id/delegate-runs/:delegateRunId/timeline', () => {
let tmpDirs: string[] = [];
afterEach(() => {
for (const dir of tmpDirs) {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
tmpDirs = [];
vi.clearAllMocks();
});
it('drill-down: その run の内部イベントのみ返す', async () => {
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
tmpDirs.push(workspacePath);
writeEventsJsonl(workspacePath, [START('R1'), TOOL('R1'), DONE('R1')]);
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs/R1/timeline`);
expect(res.status).toBe(200);
// Events that have correlationId === 'R1' — TOOL has it; START/DONE do not since they
// are keyed by kind not correlationId in the filter
expect(Array.isArray(res.body.events)).toBe(true);
// The TOOL event has correlationId === 'R1', others do not
expect(res.body.events.some((e: { correlationId?: string }) => e.correlationId === 'R1')).toBe(true);
});
it('回帰: tool_call が独自 correlationId でも delegateRunId でタイムラインに含まれる', async () => {
// 本番形状: tool_call は per-tool UUID で correlationId を上書きし、実行帰属は
// delegateRunId タグで持つ。旧実装は correlationId フィルタだったため、展開時の
// タイムラインからツールイベントが丸ごと落ちていた(サマリの「ツール N 回」と矛盾)。
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
tmpDirs.push(workspacePath);
const REAL_TOOL = {
v: 1, ts: '2026-06-25T00:00:02.000Z', seq: 2, eventId: 't-real', runId: 'r',
kind: 'tool_call', correlationId: 'per-tool-uuid-xyz', delegateRunId: 'R1',
movement: 'delegate:d', payload: { tool: 'Read' },
};
writeEventsJsonl(workspacePath, [START('R1'), REAL_TOOL, DONE('R1')]);
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs/R1/timeline`);
expect(res.status).toBe(200);
// ツールイベントがcorrelation 上書きされていても)返る
expect(res.body.events.some((e: { kind: string }) => e.kind === 'tool_call')).toBe(true);
});
it('timeline: events.jsonl 不在なら空配列', async () => {
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
tmpDirs.push(workspacePath);
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs/R1/timeline`);
expect(res.status).toBe(200);
expect(res.body.events).toEqual([]);
});
it('timeline: 非可視タスクは 404', async () => {
const { app, otherUsersTaskId } = await seedTaskWithWorkspace();
const res = await request(app).get(`/api/local/tasks/${otherUsersTaskId}/delegate-runs/R1/timeline`);
expect([403, 404]).toContain(res.status);
});
it('timeline: authenticated non-owner viewer blocked from private task (IDOR protection)', async () => {
const { repo, taskId, workspacePath } = await seedTaskWithWorkspace();
const tmpDirs = [workspacePath];
const mockGetLocalTask = vi.mocked(repo.getLocalTask);
// Configure mock: when viewer is passed (authenticated request),
// return null (simulating DB-level visibility filter)
mockGetLocalTask.mockImplementation(async (id: number, opts?: { viewer?: Express.User }) => {
if (id === taskId) {
if (opts?.viewer) {
// Non-owner viewer: visibility filter returns null
return null as never;
}
// No viewer (unauthenticated): allowed to view
return {
id: taskId,
title: 'test task',
workspacePath,
runtimeDir: null,
ownerId: 'alice-id',
visibility: 'private' as const,
visibilityScopeOrgId: null,
spaceId: null,
} as never;
}
return null as never;
});
// Create app with middleware that injects non-owner viewer
const nonOwnerUser: Express.User = { id: 'bob-id', name: 'Bob' } as Express.User;
const app = express();
app.use((req: any, _res: any, next: any) => {
req.user = nonOwnerUser;
next();
});
app.use(express.json());
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs/R1/timeline`);
// Verify: request is blocked (404 or 403) and no data is leaked
expect([403, 404]).toContain(res.status);
expect(res.body.events).toBeUndefined();
expect(res.body.error).toBeDefined();
// Verify: getLocalTask was called WITH the viewer object
expect(mockGetLocalTask).toHaveBeenCalledWith(
taskId,
expect.objectContaining({ viewer: nonOwnerUser })
);
for (const dir of tmpDirs) {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
});
// ---------------------------------------------------------------------------
// Timeline jobId scope tests (Task 3)
// ---------------------------------------------------------------------------
describe('GET /api/local/tasks/:id/delegate-runs/:delegateRunId/timeline?jobId=', () => {
let tmpDirs: string[] = [];
afterEach(() => {
for (const dir of tmpDirs) {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
tmpDirs = [];
vi.clearAllMocks();
});
it('timeline は jobId 指定でサブジョブの events を読む', async () => {
const parentDir = mkdtempSync(join(tmpdir(), 'parent-'));
const subDir = join(parentDir, 'subtasks', 'sub1');
tmpDirs.push(parentDir);
// Only write events in the sub-job dir (not the parent)
writeSubEvents(subDir, [
{ v: 1, ts: '2026-06-29T00:00:00.000Z', seq: 1, eventId: 's1', runId: 'r', kind: 'tool_call', correlationId: 'subRun', payload: { toolName: 'Read', toolCallId: 'tc1' } },
]);
const repo = makeRepo({
getLocalTask: vi.fn().mockResolvedValue({
id: 1,
workspacePath: parentDir,
runtimeDir: null,
ownerId: 'alice-id',
visibility: 'private' as const,
visibilityScopeOrgId: null,
spaceId: null,
}),
getJob: vi.fn().mockImplementation(async (id: string) => {
if (id === 'sub1') {
return { id: 'sub1', issueNumber: 1, status: 'succeeded', worktreePath: subDir, parentJobId: 'root' };
}
return null;
}),
});
const app = express();
app.use(express.json());
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
const res = await request(app).get('/api/local/tasks/1/delegate-runs/subRun/timeline?jobId=sub1');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.events)).toBe(true);
expect(res.body.events.length).toBeGreaterThan(0);
expect(res.body.events.every((e: { correlationId?: string }) => e.correlationId === 'subRun')).toBe(true);
});
it('timeline jobId: ジョブが見つからない場合は 404', async () => {
const parentDir = mkdtempSync(join(tmpdir(), 'parent-'));
tmpDirs.push(parentDir);
const repo = makeRepo({
getLocalTask: vi.fn().mockResolvedValue({
id: 1,
workspacePath: parentDir,
runtimeDir: null,
ownerId: 'alice-id',
visibility: 'private' as const,
visibilityScopeOrgId: null,
spaceId: null,
}),
getJob: vi.fn().mockResolvedValue(null),
});
const app = express();
app.use(express.json());
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
const res = await request(app).get('/api/local/tasks/1/delegate-runs/subRun/timeline?jobId=nonexistent');
expect(res.status).toBe(404);
});
it('timeline jobId: workspacePath 外の worktree は fail-closed で 404', async () => {
const parentDir = mkdtempSync(join(tmpdir(), 'parent-'));
const outsideDir = mkdtempSync(join(tmpdir(), 'evil-'));
tmpDirs.push(parentDir, outsideDir);
writeSubEvents(outsideDir, [
{ v: 1, ts: '2026-06-29T00:00:00.000Z', seq: 1, eventId: 's1', runId: 'r', kind: 'tool_call', correlationId: 'evilRun', payload: { toolName: 'Read', toolCallId: 'tc1' } },
]);
const repo = makeRepo({
getLocalTask: vi.fn().mockResolvedValue({
id: 1,
workspacePath: parentDir,
runtimeDir: null,
ownerId: 'alice-id',
visibility: 'private' as const,
visibilityScopeOrgId: null,
spaceId: null,
}),
getJob: vi.fn().mockResolvedValue({
id: 'evil', issueNumber: 1, status: 'succeeded', worktreePath: outsideDir, parentJobId: 'root',
}),
});
const app = express();
app.use(express.json());
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
const res = await request(app).get('/api/local/tasks/1/delegate-runs/evilRun/timeline?jobId=evil');
expect(res.status).toBe(404);
});
it('timeline jobId 未指定: 親 events に correlationId 一致なければサブジョブを線形探索', async () => {
const parentDir = mkdtempSync(join(tmpdir(), 'parent-'));
const subDir = join(parentDir, 'subtasks', 'sub1');
tmpDirs.push(parentDir);
// Parent has no matching correlationId events; sub-job does
writeSubEvents(subDir, [
{ v: 1, ts: '2026-06-29T00:00:00.000Z', seq: 1, eventId: 's1', runId: 'r', kind: 'tool_call', correlationId: 'subRun', payload: { toolName: 'Read', toolCallId: 'tc1' } },
]);
const repo = makeRepo({
getLocalTask: vi.fn().mockResolvedValue({
id: 1,
workspacePath: parentDir,
runtimeDir: null,
ownerId: 'alice-id',
visibility: 'private' as const,
visibilityScopeOrgId: null,
spaceId: null,
}),
getLatestJobForIssue: vi.fn().mockResolvedValue({ id: 'root' }),
getSubJobs: vi.fn().mockImplementation(async (pid: string) => {
if (pid === 'root') {
return [{ id: 'sub1', issueNumber: 1, status: 'succeeded', worktreePath: subDir, parentJobId: 'root' }];
}
return [];
}),
});
const app = express();
app.use(express.json());
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
const res = await request(app).get('/api/local/tasks/1/delegate-runs/subRun/timeline');
expect(res.status).toBe(200);
expect(res.body.events.length).toBeGreaterThan(0);
expect(res.body.events.every((e: { correlationId?: string }) => e.correlationId === 'subRun')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Subtask aggregation tests (Task 2)
// ---------------------------------------------------------------------------
describe('GET /api/local/tasks/:id/delegate-runs — subtask aggregation', () => {
let tmpDirs: string[] = [];
afterEach(() => {
for (const dir of tmpDirs) {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
tmpDirs = [];
vi.clearAllMocks();
});
// Event factories reusing the same shape as START/DONE above
const mkStart = (id: string) => ({
v: 1, ts: '2026-06-29T00:00:00.000Z', seq: 1, eventId: `s-${id}`, runId: 'r',
kind: 'delegate_start',
payload: { delegateRunId: id, parentRunId: null, description: 'x', depth: 1 },
});
const mkDone = (id: string) => ({
v: 1, ts: '2026-06-29T00:00:01.000Z', seq: 2, eventId: `c-${id}`, runId: 'r',
kind: 'delegate_complete',
payload: { delegateRunId: id, parentRunId: null, description: 'x', depth: 1, next: 'COMPLETE', status: 'success' },
});
it('親 run とサブタスク run を分けて返す', async () => {
const parentDir = mkdtempSync(join(tmpdir(), 'parent-'));
const subDir = join(parentDir, 'subtasks', 'sub1');
tmpDirs.push(parentDir);
// Write parent events
writeSubEvents(parentDir, [mkStart('parentRun'), mkDone('parentRun')]);
// Write sub-job events
writeSubEvents(subDir, [mkStart('subRun'), mkDone('subRun')]);
const repo = makeRepo({
getLocalTask: vi.fn().mockResolvedValue({
id: 1,
workspacePath: parentDir,
runtimeDir: null,
ownerId: 'alice-id',
visibility: 'private' as const,
visibilityScopeOrgId: null,
spaceId: null,
}),
getLatestJobForIssue: vi.fn().mockResolvedValue({ id: 'root' }),
getSubJobs: vi.fn().mockImplementation(async (pid: string) => {
if (pid === 'root') {
return [{
id: 'sub1',
issueNumber: 1,
status: 'succeeded',
worktreePath: subDir,
parentJobId: 'root',
}];
}
return [];
}),
});
const app = express();
app.use(express.json());
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
const res = await request(app).get('/api/local/tasks/1/delegate-runs');
expect(res.status).toBe(200);
// Parent runs
expect(res.body.runs).toHaveLength(1);
expect(res.body.runs[0].delegateRunId).toBe('parentRun');
// Subtask runs
expect(res.body.subtasks).toHaveLength(1);
expect(res.body.subtasks[0].jobId).toBe('sub1');
expect(res.body.subtasks[0].depth).toBe(1);
expect(res.body.subtasks[0].status).toBe('succeeded');
expect(res.body.subtasks[0].runs).toHaveLength(1);
expect(res.body.subtasks[0].runs[0].delegateRunId).toBe('subRun');
});
it('親ワークスペース外の worktree を持つサブジョブは除外 (auth containment)', async () => {
const parentDir = mkdtempSync(join(tmpdir(), 'parent-'));
const outsideDir = mkdtempSync(join(tmpdir(), 'evil-'));
tmpDirs.push(parentDir, outsideDir);
// Write parent events (no delegate runs → empty)
// Write events to the outside dir to prove it's excluded, not missing
writeSubEvents(outsideDir, [mkStart('evilRun'), mkDone('evilRun')]);
const repo = makeRepo({
getLocalTask: vi.fn().mockResolvedValue({
id: 1,
workspacePath: parentDir,
runtimeDir: null,
ownerId: 'alice-id',
visibility: 'private' as const,
visibilityScopeOrgId: null,
spaceId: null,
}),
getLatestJobForIssue: vi.fn().mockResolvedValue({ id: 'root' }),
getSubJobs: vi.fn().mockImplementation(async (pid: string) => {
if (pid === 'root') {
return [{
id: 'sub1',
issueNumber: 1,
status: 'succeeded',
worktreePath: outsideDir,
parentJobId: 'root',
}];
}
return [];
}),
});
const app = express();
app.use(express.json());
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
const res = await request(app).get('/api/local/tasks/1/delegate-runs');
expect(res.status).toBe(200);
// Outside sub-job must be excluded
expect(res.body.subtasks).toHaveLength(0);
});
it('workspacePath が null のタスクはサブジョブ containment を評価できないため subtasks が空 (fail-closed regression)', async () => {
// Regression: old code used `task.workspacePath && !isJobWithinWorkspace(...)` — when
// workspacePath is null the && short-circuits and the check is BYPASSED, leaking events.
// Fixed to: `!isJobWithinWorkspace(null, path)` which returns false → skips the sub-job.
const subDir = mkdtempSync(join(tmpdir(), 'sub-'));
tmpDirs.push(subDir);
// Write delegate events into the sub-job dir so it would appear if included
writeSubEvents(subDir, [mkStart('leakedRun'), mkDone('leakedRun')]);
const repo = makeRepo({
getLocalTask: vi.fn().mockResolvedValue({
id: 1,
workspacePath: null, // ← null triggers the bypass in old code
runtimeDir: null,
ownerId: 'alice-id',
visibility: 'private' as const,
visibilityScopeOrgId: null,
spaceId: null,
}),
getLatestJobForIssue: vi.fn().mockResolvedValue({ id: 'root' }),
getSubJobs: vi.fn().mockImplementation(async (pid: string) => {
if (pid === 'root') {
return [{
id: 'sub1',
issueNumber: 1,
status: 'succeeded',
worktreePath: subDir,
parentJobId: 'root',
}];
}
return [];
}),
});
const app = express();
app.use(express.json());
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
const res = await request(app).get('/api/local/tasks/1/delegate-runs');
expect(res.status).toBe(200);
// Sub-job must be skipped because workspacePath is null (containment cannot be evaluated)
expect(res.body.subtasks).toHaveLength(0);
});
it('既存テスト互換: サブジョブ無し時は subtasks が空配列', async () => {
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
tmpDirs.push(workspacePath);
writeSubEvents(workspacePath, [mkStart('R1'), mkDone('R1')]);
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`);
expect(res.status).toBe(200);
expect(res.body.runs).toHaveLength(1);
// getLatestJobForIssue is vi.fn() returning undefined → latestJob falsy → empty subtasks
expect(res.body.subtasks).toEqual([]);
});
});