596 lines
18 KiB
TypeScript
596 lines
18 KiB
TypeScript
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import * as fs from 'fs/promises';
|
|
|
|
import { TaskChangeComputer } from '../../../../src/main/services/team/TaskChangeComputer';
|
|
|
|
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 {
|
|
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: filePaths.map((filePath) => ({ path: filePath, kind: 'add' })),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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('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(second.files).toEqual(first.files);
|
|
|
|
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']);
|
|
});
|
|
|
|
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]?.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.linesAdded === 0 && file.linesRemoved === 0)).toBe(
|
|
true
|
|
);
|
|
});
|
|
|
|
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']);
|
|
});
|
|
});
|