agent-ecosystem/test/main/services/team/TaskChangeComputer.test.ts

816 lines
26 KiB
TypeScript

import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { TaskChangeComputer } from '../../../../src/main/services/team/TaskChangeComputer';
import type { TaskChangeTaskMeta } from '../../../../src/main/services/team/taskChangeWorkerTypes';
const NO_TASK_BOUNDARIES_WARNING =
'No task boundaries found - showing all changes from related sessions.';
const noBoundaryTerminalCases: Array<{
name: string;
taskMeta: TaskChangeTaskMeta;
}> = [
{
name: 'completed',
taskMeta: { status: 'completed', reviewState: 'none' },
},
{
name: 'review',
taskMeta: { status: 'completed', reviewState: 'review' },
},
{
name: 'approved',
taskMeta: { status: 'completed', reviewState: 'approved' },
},
];
async function writeJsonl(filePath: string, entries: object[]): Promise<void> {
await fs.writeFile(
filePath,
entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n',
'utf8'
);
}
function writeToolUse(
toolUseId: string,
filePath: string,
content: string,
timestamp = '2026-03-01T10:00:00.000Z'
): object {
return {
timestamp,
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: toolUseId,
name: 'Write',
input: { file_path: filePath, content },
},
],
},
};
}
function metadataOnlyEditToolUse(toolUseId: string, filePath: string): object {
return {
timestamp: '2026-03-01T10:00:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: toolUseId,
name: 'Edit',
input: {
file_path: filePath,
changes: [{ path: filePath, kind: 'update' }],
},
},
],
},
};
}
function metadataOnlyMultiFileEditToolUse(
toolUseId: string,
filePaths: string[],
primaryPath = filePaths[0] ?? ''
): object {
return metadataOnlyMultiFileEditChangesToolUse(
toolUseId,
filePaths.map((filePath) => ({ filePath, kind: 'add' })),
primaryPath
);
}
function metadataOnlyMultiFileEditChangesToolUse(
toolUseId: string,
changes: Array<{ filePath: string; kind?: string }>,
primaryPath = changes[0]?.filePath ?? ''
): object {
return {
timestamp: '2026-03-01T10:00:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: toolUseId,
name: 'Edit',
input: {
file_path: primaryPath,
changes: changes.map((change) => ({
path: change.filePath,
...(change.kind ? { kind: change.kind } : {}),
})),
},
},
],
},
};
}
function createNoLogTaskChangeComputer(): TaskChangeComputer {
const logsFinder = {
findLogFileRefsForTask: () => Promise.resolve([]),
};
const boundaryParser = {
parseBoundaries: () =>
Promise.resolve({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
}),
};
return new TaskChangeComputer(logsFinder as never, boundaryParser as never);
}
function createNoBoundaryTaskChangeComputer(logPath: string): TaskChangeComputer {
const logsFinder = {
findLogFileRefsForTask: () => Promise.resolve([{ filePath: logPath, memberName: 'team-lead' }]),
};
const boundaryParser = {
parseBoundaries: () =>
Promise.resolve({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
}),
};
return new TaskChangeComputer(logsFinder as never, boundaryParser as never);
}
async function writeNoBoundaryTaskMentionLog(tmpDir: string, content: string): Promise<string> {
const logPath = path.join(tmpDir, 'lead.jsonl');
await writeJsonl(logPath, [
{
timestamp: '2026-03-01T10:00:00.000Z',
type: 'user',
message: {
role: 'user',
content,
},
},
]);
return logPath;
}
describe('TaskChangeComputer', () => {
let tmpDir: string | null = null;
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = null;
}
});
it('keeps active tasks without logs quiet even when request status is stale', async () => {
const computer = createNoLogTaskChangeComputer();
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta: {
status: 'in_progress',
reviewState: 'none',
},
effectiveOptions: { status: 'completed' },
projectPath: '/repo',
includeDetails: false,
});
expect(result.files).toEqual([]);
expect(result.confidence).toBe('fallback');
expect(result.warnings).toEqual([]);
});
it('keeps newly created pending tasks without logs quiet', async () => {
const computer = createNoLogTaskChangeComputer();
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta: {
status: 'pending',
reviewState: 'none',
},
effectiveOptions: { status: 'completed' },
projectPath: '/repo',
includeDetails: false,
});
expect(result.files).toEqual([]);
expect(result.confidence).toBe('fallback');
expect(result.warnings).toEqual([]);
});
it('keeps pending tasks quiet when related logs exist but no file edits were captured yet', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
const logPath = await writeNoBoundaryTaskMentionLog(
tmpDir,
'Task task-1 was created and is waiting to start.'
);
const computer = createNoBoundaryTaskChangeComputer(logPath);
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta: {
status: 'pending',
reviewState: 'none',
},
effectiveOptions: { status: 'completed' },
projectPath: '/repo',
includeDetails: false,
});
expect(result.files).toEqual([]);
expect(result.confidence).toBe('fallback');
expect(result.warnings).toEqual([]);
});
it('keeps in-progress related logs quiet when no boundaries or file edits were captured yet', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
const logPath = await writeNoBoundaryTaskMentionLog(
tmpDir,
'Task task-1 is in progress and has not edited files yet.'
);
const computer = createNoBoundaryTaskChangeComputer(logPath);
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta: {
status: 'in_progress',
reviewState: 'none',
},
effectiveOptions: { status: 'completed' },
projectPath: '/repo',
includeDetails: false,
});
expect(result.files).toEqual([]);
expect(result.confidence).toBe('fallback');
expect(result.warnings).toEqual([]);
});
it('keeps reopened needs-fix related logs quiet when no boundaries or file edits were captured', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
const logPath = await writeNoBoundaryTaskMentionLog(
tmpDir,
'Task task-1 was reopened for fixes and is waiting for edits.'
);
const computer = createNoBoundaryTaskChangeComputer(logPath);
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta: {
status: 'completed',
reviewState: 'needsFix',
},
effectiveOptions: { status: 'completed' },
projectPath: '/repo',
includeDetails: false,
});
expect(result.files).toEqual([]);
expect(result.confidence).toBe('fallback');
expect(result.warnings).toEqual([]);
});
it.each(noBoundaryTerminalCases)(
'warns when $name related logs have no task boundaries or file edits',
async ({ taskMeta }) => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
const logPath = await writeNoBoundaryTaskMentionLog(
tmpDir,
'Task task-1 completed but no edit data was captured.'
);
const computer = createNoBoundaryTaskChangeComputer(logPath);
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta,
effectiveOptions: { status: 'in_progress' },
projectPath: '/repo',
includeDetails: false,
});
expect(result.files).toEqual([]);
expect(result.confidence).toBe('fallback');
expect(result.warnings).toEqual([NO_TASK_BOUNDARIES_WARNING]);
}
);
it('warns when completed tasks have no logs even when request status is stale', async () => {
const computer = createNoLogTaskChangeComputer();
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta: {
status: 'completed',
reviewState: 'none',
},
effectiveOptions: { status: 'in_progress' },
projectPath: '/repo',
includeDetails: false,
});
expect(result.files).toEqual([]);
expect(result.confidence).toBe('fallback');
expect(result.warnings).toEqual(['No log files found for this task.']);
});
it('keeps reopened needs-fix tasks quiet even when their base status is completed', async () => {
const computer = createNoLogTaskChangeComputer();
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta: {
status: 'completed',
reviewState: 'needsFix',
},
effectiveOptions: { status: 'completed' },
projectPath: '/repo',
includeDetails: false,
});
expect(result.files).toEqual([]);
expect(result.confidence).toBe('fallback');
expect(result.warnings).toEqual([]);
});
it('shares concurrent JSONL parsing and invalidates when the file changes', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
const logPath = path.join(tmpDir, 'agent.jsonl');
await writeJsonl(logPath, [writeToolUse('tool-1', '/repo/src/a.ts', 'export const a = 1;\n')]);
const logsFinder = {
findLogFileRefsForTask: () => Promise.resolve([{ filePath: logPath, memberName: 'alice' }]),
};
const boundaryParser = {
parseBoundaries: () =>
Promise.resolve({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
}),
};
const computer = new TaskChangeComputer(logsFinder as never, boundaryParser as never);
const input = {
teamName: 'team-a',
taskId: 'task-1',
taskMeta: null,
effectiveOptions: {},
projectPath: '/repo',
includeDetails: false,
};
const [first, second] = await Promise.all([
computer.computeTaskChanges(input),
computer.computeTaskChanges(input),
]);
expect(first.files.map((file) => file.relativePath)).toEqual(['src/a.ts']);
expect(first.warnings).toEqual([NO_TASK_BOUNDARIES_WARNING]);
expect(second.files).toEqual(first.files);
expect(second.warnings).toEqual([NO_TASK_BOUNDARIES_WARNING]);
await new Promise((resolve) => setTimeout(resolve, 20));
await writeJsonl(logPath, [
writeToolUse('tool-1', '/repo/src/a.ts', 'export const a = 1;\n'),
writeToolUse('tool-2', '/repo/src/b.ts', 'export const b = 2;\n'),
]);
const afterChange = await computer.computeTaskChanges(input);
expect(
afterChange.files
.map((file) => file.relativePath)
.sort((left, right) => left.localeCompare(right))
).toEqual(['src/a.ts', 'src/b.ts']);
expect(afterChange.warnings).toEqual([NO_TASK_BOUNDARIES_WARNING]);
});
it('does not pull unrelated log changes into a precise task scope with no file edits', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
const leadLogPath = path.join(tmpDir, 'lead.jsonl');
const memberLogPath = path.join(tmpDir, 'alice.jsonl');
await writeJsonl(leadLogPath, [
writeToolUse('lead-write', '/repo/src/unrelated.ts', 'export const unrelated = true;\n'),
]);
await writeJsonl(memberLogPath, []);
const logsFinder = {
findLogFileRefsForTask: () =>
Promise.resolve([
{ filePath: leadLogPath, memberName: 'team-lead' },
{ filePath: memberLogPath, memberName: 'alice' },
]),
};
const boundaryParser = {
parseBoundaries: (filePath: string) =>
Promise.resolve(
filePath === memberLogPath
? {
boundaries: [],
scopes: [
{
taskId: 'task-1',
memberName: '',
startLine: 1,
endLine: 1,
startTimestamp: '2026-03-01T10:00:00.000Z',
endTimestamp: '2026-03-01T10:01:00.000Z',
toolUseIds: [],
filePaths: [],
confidence: { tier: 1, label: 'high', reason: 'Both markers found' },
},
],
isSingleTaskSession: true,
detectedMechanism: 'mcp' as const,
}
: {
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
}
),
};
const computer = new TaskChangeComputer(logsFinder as never, boundaryParser as never);
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta: null,
effectiveOptions: {},
projectPath: '/repo',
includeDetails: true,
});
expect(result.files).toEqual([]);
expect(result.totalFiles).toBe(0);
expect(result.confidence).toBe('high');
});
it('prefers persisted workIntervals over low-confidence complete-only scopes', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
const logPath = path.join(tmpDir, 'alice.jsonl');
await writeJsonl(logPath, [
writeToolUse(
'outside-tool',
'/repo/src/outside.ts',
'export const outside = true;\n',
'2026-03-01T09:55:00.000Z'
),
writeToolUse(
'inside-tool',
'/repo/src/inside.ts',
'export const inside = true;\n',
'2026-03-01T10:05:00.000Z'
),
]);
const logsFinder = {
findLogFileRefsForTask: () => Promise.resolve([{ filePath: logPath, memberName: 'alice' }]),
};
const boundaryParser = {
parseBoundaries: () =>
Promise.resolve({
boundaries: [],
scopes: [
{
taskId: 'task-1',
memberName: '',
startLine: 1,
endLine: 2,
startTimestamp: '',
endTimestamp: '2026-03-01T10:06:00.000Z',
toolUseIds: ['outside-tool', 'inside-tool'],
filePaths: ['/repo/src/outside.ts', '/repo/src/inside.ts'],
confidence: {
tier: 3,
label: 'low',
reason: 'Only complete marker found, start assumed at file beginning',
},
},
],
isSingleTaskSession: true,
detectedMechanism: 'mcp' as const,
}),
};
const computer = new TaskChangeComputer(logsFinder as never, boundaryParser as never);
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta: { owner: 'alice', status: 'completed' },
effectiveOptions: {
intervals: [
{
startedAt: '2026-03-01T10:00:00.000Z',
completedAt: '2026-03-01T10:10:00.000Z',
},
],
},
projectPath: '/repo',
includeDetails: true,
});
expect(result.confidence).toBe('medium');
expect(result.warnings).toEqual([
'Task start boundary missing - scoped by persisted workIntervals timestamps.',
]);
expect(result.files.map((file) => file.relativePath)).toEqual(['src/inside.ts']);
expect(result.scope.toolUseIds).toEqual(['inside-tool']);
});
it('does not pull lead-session interval edits into a member complete-only scope', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
const leadLogPath = path.join(tmpDir, 'lead.jsonl');
const memberLogPath = path.join(tmpDir, 'alice.jsonl');
await writeJsonl(leadLogPath, [
writeToolUse(
'lead-inside-tool',
'/repo/src/lead.ts',
'export const lead = true;\n',
'2026-03-01T10:05:00.000Z'
),
]);
await writeJsonl(memberLogPath, [
writeToolUse(
'member-inside-tool',
'/repo/src/member.ts',
'export const member = true;\n',
'2026-03-01T10:06:00.000Z'
),
]);
const logsFinder = {
findLogFileRefsForTask: () =>
Promise.resolve([
{ filePath: leadLogPath, memberName: 'team-lead' },
{ filePath: memberLogPath, memberName: 'alice' },
]),
};
const boundaryParser = {
parseBoundaries: (filePath: string) =>
Promise.resolve(
filePath === memberLogPath
? {
boundaries: [],
scopes: [
{
taskId: 'task-1',
memberName: '',
startLine: 1,
endLine: 1,
startTimestamp: '',
endTimestamp: '2026-03-01T10:07:00.000Z',
toolUseIds: ['member-inside-tool'],
filePaths: ['/repo/src/member.ts'],
confidence: {
tier: 3,
label: 'low',
reason: 'Only complete marker found, start assumed at file beginning',
},
},
],
isSingleTaskSession: true,
detectedMechanism: 'mcp' as const,
}
: {
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
}
),
};
const computer = new TaskChangeComputer(logsFinder as never, boundaryParser as never);
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta: { owner: 'alice', status: 'completed' },
effectiveOptions: {
intervals: [
{
startedAt: '2026-03-01T10:00:00.000Z',
completedAt: '2026-03-01T10:10:00.000Z',
},
],
},
projectPath: '/repo',
includeDetails: true,
});
expect(result.files.map((file) => file.relativePath)).toEqual(['src/member.ts']);
expect(result.scope.toolUseIds).toEqual(['member-inside-tool']);
});
it('keeps metadata-only synthetic Edit entries as file-change hints', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
const logPath = path.join(tmpDir, 'agent.jsonl');
await writeJsonl(logPath, [metadataOnlyEditToolUse('tool-1', '/repo/src/a.ts')]);
const logsFinder = {
findLogFileRefsForTask: () => Promise.resolve([{ filePath: logPath, memberName: 'alice' }]),
};
const boundaryParser = {
parseBoundaries: () =>
Promise.resolve({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
}),
};
const computer = new TaskChangeComputer(logsFinder as never, boundaryParser as never);
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta: null,
effectiveOptions: {},
projectPath: '/repo',
includeDetails: true,
});
expect(result.files.map((file) => file.relativePath)).toEqual(['src/a.ts']);
expect(result.files[0]?.snippets).toHaveLength(1);
expect(result.files[0]?.isNewFile).toBe(false);
expect(result.files[0]?.snippets[0]?.type).toBe('edit');
expect(result.files[0]?.snippets[0]?.oldString).toBe('');
expect(result.files[0]?.snippets[0]?.newString).toBe('');
expect(result.totalFiles).toBe(1);
});
it('expands metadata-only Edit changes arrays into all changed file hints', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
const logPath = path.join(tmpDir, 'agent.jsonl');
await writeJsonl(logPath, [
metadataOnlyMultiFileEditToolUse('tool-1', ['/repo/dfdf/calc.js', '/repo/dfdf/style.css']),
]);
const logsFinder = {
findLogFileRefsForTask: () => Promise.resolve([{ filePath: logPath, memberName: 'tom' }]),
};
const boundaryParser = {
parseBoundaries: () =>
Promise.resolve({
boundaries: [],
scopes: [
{
taskId: 'task-1',
memberName: '',
startLine: 1,
endLine: 1,
startTimestamp: '2026-03-01T10:00:00.000Z',
endTimestamp: '2026-03-01T10:01:00.000Z',
toolUseIds: ['tool-1'],
filePaths: ['/repo/dfdf/calc.js', '/repo/dfdf/style.css'],
confidence: { tier: 1, label: 'high', reason: 'Both markers found' },
},
],
isSingleTaskSession: true,
detectedMechanism: 'mcp' as const,
}),
};
const computer = new TaskChangeComputer(logsFinder as never, boundaryParser as never);
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta: null,
effectiveOptions: {},
projectPath: '/repo',
includeDetails: true,
});
expect(result.files.map((file) => file.relativePath)).toEqual([
'dfdf/calc.js',
'dfdf/style.css',
]);
expect(result.files.every((file) => file.snippets[0]?.toolUseId === 'tool-1')).toBe(true);
expect(result.files.every((file) => file.isNewFile)).toBe(true);
expect(result.files.every((file) => file.snippets[0]?.type === 'write-new')).toBe(true);
expect(result.files.every((file) => file.linesAdded === 0 && file.linesRemoved === 0)).toBe(
true
);
});
it('preserves metadata-only Edit change kinds without upgrading updates to new files', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
const logPath = path.join(tmpDir, 'agent.jsonl');
await writeJsonl(logPath, [
metadataOnlyMultiFileEditChangesToolUse('tool-1', [
{ filePath: '/repo/src/new.ts', kind: 'add' },
{ filePath: '/repo/src/existing.ts', kind: 'update' },
]),
]);
const logsFinder = {
findLogFileRefsForTask: () => Promise.resolve([{ filePath: logPath, memberName: 'tom' }]),
};
const boundaryParser = {
parseBoundaries: () =>
Promise.resolve({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
}),
};
const computer = new TaskChangeComputer(logsFinder as never, boundaryParser as never);
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta: null,
effectiveOptions: {},
projectPath: '/repo',
includeDetails: true,
});
const filesByPath = new Map(result.files.map((file) => [file.relativePath, file]));
const newFile = filesByPath.get('src/new.ts');
const existingFile = filesByPath.get('src/existing.ts');
expect(newFile?.isNewFile).toBe(true);
expect(newFile?.snippets[0]?.type).toBe('write-new');
expect(existingFile?.isNewFile).toBe(false);
expect(existingFile?.snippets[0]?.type).toBe('edit');
});
it('does not include repeated tool ids from outside the scoped source lines', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
const logPath = path.join(tmpDir, 'agent.jsonl');
await writeJsonl(logPath, [
metadataOnlyMultiFileEditToolUse('tool-1', ['/repo/index.html', '/repo/style.css']),
metadataOnlyMultiFileEditToolUse(
'tool-1',
['/repo/177/landing.css'],
'/repo/177/landing.css'
),
]);
const logsFinder = {
findLogFileRefsForTask: () => Promise.resolve([{ filePath: logPath, memberName: 'tom' }]),
};
const boundaryParser = {
parseBoundaries: () =>
Promise.resolve({
boundaries: [],
scopes: [
{
taskId: 'task-1',
memberName: '',
startLine: 2,
endLine: 2,
startTimestamp: '2026-03-01T09:59:00.000Z',
endTimestamp: '2026-03-01T10:01:00.000Z',
toolUseIds: ['tool-1'],
filePaths: ['/repo/177/landing.css'],
confidence: { tier: 1, label: 'high', reason: 'Both markers found' },
},
],
isSingleTaskSession: true,
detectedMechanism: 'mcp' as const,
}),
};
const computer = new TaskChangeComputer(logsFinder as never, boundaryParser as never);
const result = await computer.computeTaskChanges({
teamName: 'team-a',
taskId: 'task-1',
taskMeta: null,
effectiveOptions: {},
projectPath: '/repo',
includeDetails: true,
});
expect(result.files.map((file) => file.relativePath)).toEqual(['177/landing.css']);
expect(result.scope.filePaths).toEqual(['/repo/177/landing.css']);
});
});