mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(tracker): return TodoList display for tracker tools (#22060)
This commit is contained in:
@@ -18,7 +18,7 @@ export const TodoTray: React.FC = () => {
|
|||||||
const uiState = useUIState();
|
const uiState = useUIState();
|
||||||
|
|
||||||
const todos: TodoList | null = useMemo(() => {
|
const todos: TodoList | null = useMemo(() => {
|
||||||
// Find the most recent todo list written by the WriteTodosTool
|
// Find the most recent todo list written by tools that output a TodoList (e.g., WriteTodosTool or Tracker tools)
|
||||||
for (let i = uiState.history.length - 1; i >= 0; i--) {
|
for (let i = uiState.history.length - 1; i >= 0; i--) {
|
||||||
const entry = uiState.history[i];
|
const entry = uiState.history[i];
|
||||||
if (entry.type !== 'tool_group') {
|
if (entry.type !== 'tool_group') {
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ export enum TaskType {
|
|||||||
}
|
}
|
||||||
export const TaskTypeSchema = z.nativeEnum(TaskType);
|
export const TaskTypeSchema = z.nativeEnum(TaskType);
|
||||||
|
|
||||||
|
export const TASK_TYPE_LABELS: Record<TaskType, string> = {
|
||||||
|
[TaskType.EPIC]: '[EPIC]',
|
||||||
|
[TaskType.TASK]: '[TASK]',
|
||||||
|
[TaskType.BUG]: '[BUG]',
|
||||||
|
};
|
||||||
|
|
||||||
export enum TaskStatus {
|
export enum TaskStatus {
|
||||||
OPEN = 'open',
|
OPEN = 'open',
|
||||||
IN_PROGRESS = 'in_progress',
|
IN_PROGRESS = 'in_progress',
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ import {
|
|||||||
TrackerUpdateTaskTool,
|
TrackerUpdateTaskTool,
|
||||||
TrackerVisualizeTool,
|
TrackerVisualizeTool,
|
||||||
TrackerAddDependencyTool,
|
TrackerAddDependencyTool,
|
||||||
|
buildTodosReturnDisplay,
|
||||||
} from './trackerTools.js';
|
} from './trackerTools.js';
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
|
|
||||||
import { TaskStatus, TaskType } from '../services/trackerTypes.js';
|
import { TaskStatus, TaskType } from '../services/trackerTypes.js';
|
||||||
|
import type { TrackerService } from '../services/trackerService.js';
|
||||||
|
|
||||||
describe('Tracker Tools Integration', () => {
|
describe('Tracker Tools Integration', () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
@@ -142,4 +144,90 @@ describe('Tracker Tools Integration', () => {
|
|||||||
expect(vizResult.llmContent).toContain('Child Task');
|
expect(vizResult.llmContent).toContain('Child Task');
|
||||||
expect(vizResult.llmContent).toContain(childId);
|
expect(vizResult.llmContent).toContain(childId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildTodosReturnDisplay', () => {
|
||||||
|
it('returns empty list for no tasks', async () => {
|
||||||
|
const mockService = {
|
||||||
|
listTasks: async () => [],
|
||||||
|
} as unknown as TrackerService;
|
||||||
|
const result = await buildTodosReturnDisplay(mockService);
|
||||||
|
expect(result.todos).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns formatted todos', async () => {
|
||||||
|
const parent = {
|
||||||
|
id: 'p1',
|
||||||
|
title: 'Parent',
|
||||||
|
type: TaskType.TASK,
|
||||||
|
status: TaskStatus.IN_PROGRESS,
|
||||||
|
dependencies: [],
|
||||||
|
};
|
||||||
|
const child = {
|
||||||
|
id: 'c1',
|
||||||
|
title: 'Child',
|
||||||
|
type: TaskType.EPIC,
|
||||||
|
status: TaskStatus.OPEN,
|
||||||
|
parentId: 'p1',
|
||||||
|
dependencies: [],
|
||||||
|
};
|
||||||
|
const closedLeaf = {
|
||||||
|
id: 'leaf',
|
||||||
|
title: 'Closed Leaf',
|
||||||
|
type: TaskType.BUG,
|
||||||
|
status: TaskStatus.CLOSED,
|
||||||
|
parentId: 'c1',
|
||||||
|
dependencies: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockService = {
|
||||||
|
listTasks: async () => [parent, child, closedLeaf],
|
||||||
|
} as unknown as TrackerService;
|
||||||
|
const display = await buildTodosReturnDisplay(mockService);
|
||||||
|
|
||||||
|
expect(display.todos).toEqual([
|
||||||
|
{
|
||||||
|
description: `[p1] [TASK] Parent`,
|
||||||
|
status: 'in_progress',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: ` [c1] [EPIC] Child`,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: ` [leaf] [BUG] Closed Leaf`,
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects cycles', async () => {
|
||||||
|
// Since TrackerTask only has a single parentId, a true cycle is unreachable from roots.
|
||||||
|
// We simulate a database corruption (two tasks with same ID, one root, one child)
|
||||||
|
// just to exercise the protective cycle detection branch.
|
||||||
|
const rootP1 = {
|
||||||
|
id: 'p1',
|
||||||
|
title: 'Parent',
|
||||||
|
type: TaskType.TASK,
|
||||||
|
status: TaskStatus.OPEN,
|
||||||
|
dependencies: [],
|
||||||
|
};
|
||||||
|
const childP1 = { ...rootP1, parentId: 'p1' };
|
||||||
|
|
||||||
|
const mockService = {
|
||||||
|
listTasks: async () => [rootP1, childP1],
|
||||||
|
} as unknown as TrackerService;
|
||||||
|
const display = await buildTodosReturnDisplay(mockService);
|
||||||
|
|
||||||
|
expect(display.todos).toEqual([
|
||||||
|
{
|
||||||
|
description: `[p1] [TASK] Parent`,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: ` [CYCLE DETECTED: p1]`,
|
||||||
|
status: 'cancelled',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,11 +23,69 @@ import {
|
|||||||
TRACKER_UPDATE_TASK_TOOL_NAME,
|
TRACKER_UPDATE_TASK_TOOL_NAME,
|
||||||
TRACKER_VISUALIZE_TOOL_NAME,
|
TRACKER_VISUALIZE_TOOL_NAME,
|
||||||
} from './tool-names.js';
|
} from './tool-names.js';
|
||||||
import type { ToolResult } from './tools.js';
|
import type { ToolResult, TodoList } from './tools.js';
|
||||||
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||||
import { ToolErrorType } from './tool-error.js';
|
import { ToolErrorType } from './tool-error.js';
|
||||||
import type { TrackerTask, TaskType } from '../services/trackerTypes.js';
|
import type { TrackerTask, TaskType } from '../services/trackerTypes.js';
|
||||||
import { TaskStatus } from '../services/trackerTypes.js';
|
import { TaskStatus, TASK_TYPE_LABELS } from '../services/trackerTypes.js';
|
||||||
|
import type { TrackerService } from '../services/trackerService.js';
|
||||||
|
|
||||||
|
export async function buildTodosReturnDisplay(
|
||||||
|
service: TrackerService,
|
||||||
|
): Promise<TodoList> {
|
||||||
|
const tasks = await service.listTasks();
|
||||||
|
const childrenMap = new Map<string, TrackerTask[]>();
|
||||||
|
const roots: TrackerTask[] = [];
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (task.parentId) {
|
||||||
|
if (!childrenMap.has(task.parentId)) {
|
||||||
|
childrenMap.set(task.parentId, []);
|
||||||
|
}
|
||||||
|
childrenMap.get(task.parentId)!.push(task);
|
||||||
|
} else {
|
||||||
|
roots.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const todos: TodoList['todos'] = [];
|
||||||
|
|
||||||
|
const addTask = (task: TrackerTask, depth: number, visited: Set<string>) => {
|
||||||
|
if (visited.has(task.id)) {
|
||||||
|
todos.push({
|
||||||
|
description: `${' '.repeat(depth)}[CYCLE DETECTED: ${task.id}]`,
|
||||||
|
status: 'cancelled',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
visited.add(task.id);
|
||||||
|
|
||||||
|
let status: 'pending' | 'in_progress' | 'completed' | 'cancelled' =
|
||||||
|
'pending';
|
||||||
|
if (task.status === TaskStatus.IN_PROGRESS) {
|
||||||
|
status = 'in_progress';
|
||||||
|
} else if (task.status === TaskStatus.CLOSED) {
|
||||||
|
status = 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
const indent = ' '.repeat(depth);
|
||||||
|
const description = `${indent}[${task.id}] ${TASK_TYPE_LABELS[task.type]} ${task.title}`;
|
||||||
|
|
||||||
|
todos.push({ description, status });
|
||||||
|
|
||||||
|
const children = childrenMap.get(task.id) ?? [];
|
||||||
|
for (const child of children) {
|
||||||
|
addTask(child, depth + 1, visited);
|
||||||
|
}
|
||||||
|
visited.delete(task.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
addTask(root, 0, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
return { todos };
|
||||||
|
}
|
||||||
|
|
||||||
// --- tracker_create_task ---
|
// --- tracker_create_task ---
|
||||||
|
|
||||||
@@ -71,7 +129,7 @@ class TrackerCreateTaskInvocation extends BaseToolInvocation<
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
llmContent: `Created task ${task.id}: ${task.title}`,
|
llmContent: `Created task ${task.id}: ${task.title}`,
|
||||||
returnDisplay: `Created task ${task.id}.`,
|
returnDisplay: await buildTodosReturnDisplay(this.service),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
@@ -155,7 +213,7 @@ class TrackerUpdateTaskInvocation extends BaseToolInvocation<
|
|||||||
const task = await this.service.updateTask(id, updates);
|
const task = await this.service.updateTask(id, updates);
|
||||||
return {
|
return {
|
||||||
llmContent: `Updated task ${task.id}. Status: ${task.status}`,
|
llmContent: `Updated task ${task.id}. Status: ${task.status}`,
|
||||||
returnDisplay: `Updated task ${task.id}.`,
|
returnDisplay: await buildTodosReturnDisplay(this.service),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
@@ -239,7 +297,7 @@ class TrackerGetTaskInvocation extends BaseToolInvocation<
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
llmContent: JSON.stringify(task, null, 2),
|
llmContent: JSON.stringify(task, null, 2),
|
||||||
returnDisplay: `Retrieved task ${task.id}.`,
|
returnDisplay: await buildTodosReturnDisplay(this.service),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,7 +385,7 @@ class TrackerListTasksInvocation extends BaseToolInvocation<
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
return {
|
return {
|
||||||
llmContent: content,
|
llmContent: content,
|
||||||
returnDisplay: `Listed ${tasks.length} tasks.`,
|
returnDisplay: await buildTodosReturnDisplay(this.service),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,7 +485,7 @@ class TrackerAddDependencyInvocation extends BaseToolInvocation<
|
|||||||
await this.service.updateTask(task.id, { dependencies: newDeps });
|
await this.service.updateTask(task.id, { dependencies: newDeps });
|
||||||
return {
|
return {
|
||||||
llmContent: `Linked ${task.id} -> ${dep.id}.`,
|
llmContent: `Linked ${task.id} -> ${dep.id}.`,
|
||||||
returnDisplay: 'Dependency added.',
|
returnDisplay: await buildTodosReturnDisplay(this.service),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
@@ -516,12 +574,6 @@ class TrackerVisualizeInvocation extends BaseToolInvocation<
|
|||||||
closed: '✅',
|
closed: '✅',
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeLabels: Record<TaskType, string> = {
|
|
||||||
epic: '[EPIC]',
|
|
||||||
task: '[TASK]',
|
|
||||||
bug: '[BUG]',
|
|
||||||
};
|
|
||||||
|
|
||||||
const childrenMap = new Map<string, TrackerTask[]>();
|
const childrenMap = new Map<string, TrackerTask[]>();
|
||||||
const roots: TrackerTask[] = [];
|
const roots: TrackerTask[] = [];
|
||||||
|
|
||||||
@@ -550,14 +602,15 @@ class TrackerVisualizeInvocation extends BaseToolInvocation<
|
|||||||
visited.add(task.id);
|
visited.add(task.id);
|
||||||
|
|
||||||
const indent = ' '.repeat(depth);
|
const indent = ' '.repeat(depth);
|
||||||
output += `${indent}${statusEmojis[task.status]} ${task.id} ${typeLabels[task.type]} ${task.title}\n`;
|
output += `${indent}${statusEmojis[task.status]} ${task.id} ${TASK_TYPE_LABELS[task.type]} ${task.title}\n`;
|
||||||
if (task.dependencies.length > 0) {
|
if (task.dependencies.length > 0) {
|
||||||
output += `${indent} └─ Depends on: ${task.dependencies.join(', ')}\n`;
|
output += `${indent} └─ Depends on: ${task.dependencies.join(', ')}\n`;
|
||||||
}
|
}
|
||||||
const children = childrenMap.get(task.id) ?? [];
|
const children = childrenMap.get(task.id) ?? [];
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
renderTask(child, depth + 1, new Set(visited));
|
renderTask(child, depth + 1, visited);
|
||||||
}
|
}
|
||||||
|
visited.delete(task.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
@@ -566,7 +619,7 @@ class TrackerVisualizeInvocation extends BaseToolInvocation<
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
llmContent: output,
|
llmContent: output,
|
||||||
returnDisplay: output,
|
returnDisplay: await buildTodosReturnDisplay(this.service),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user