From 3b601b3d90a57d113c666c3922f6064a8f1ff2a8 Mon Sep 17 00:00:00 2001
From: Abhi <43648792+abhipatel12@users.noreply.github.com>
Date: Fri, 13 Mar 2026 13:25:13 -0400
Subject: [PATCH 1/4] refactor(ui): extract SessionBrowser static ui components
(#22348)
---
.../cli/src/ui/components/SessionBrowser.tsx | 35 ++-----------------
.../SessionBrowser/SessionBrowserEmpty.tsx | 19 ++++++++++
.../SessionBrowser/SessionBrowserError.tsx | 24 +++++++++++++
.../SessionBrowser/SessionBrowserLoading.tsx | 18 ++++++++++
.../SessionBrowserStates.test.tsx | 35 +++++++++++++++++++
.../SessionBrowserStates.test.tsx.snap | 18 ++++++++++
6 files changed, 117 insertions(+), 32 deletions(-)
create mode 100644 packages/cli/src/ui/components/SessionBrowser/SessionBrowserEmpty.tsx
create mode 100644 packages/cli/src/ui/components/SessionBrowser/SessionBrowserError.tsx
create mode 100644 packages/cli/src/ui/components/SessionBrowser/SessionBrowserLoading.tsx
create mode 100644 packages/cli/src/ui/components/SessionBrowser/SessionBrowserStates.test.tsx
create mode 100644 packages/cli/src/ui/components/SessionBrowser/__snapshots__/SessionBrowserStates.test.tsx.snap
diff --git a/packages/cli/src/ui/components/SessionBrowser.tsx b/packages/cli/src/ui/components/SessionBrowser.tsx
index 9e2843c570..0fc80a1d4e 100644
--- a/packages/cli/src/ui/components/SessionBrowser.tsx
+++ b/packages/cli/src/ui/components/SessionBrowser.tsx
@@ -116,38 +116,9 @@ const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
>
);
-/**
- * Loading state component displayed while sessions are being loaded.
- */
-const SessionBrowserLoading = (): React.JSX.Element => (
-
- Loading sessions…
-
-);
-
-/**
- * Error state component displayed when session loading fails.
- */
-const SessionBrowserError = ({
- state,
-}: {
- state: SessionBrowserState;
-}): React.JSX.Element => (
-
- Error: {state.error}
- Press q to exit
-
-);
-
-/**
- * Empty state component displayed when no sessions are found.
- */
-const SessionBrowserEmpty = (): React.JSX.Element => (
-
- No auto-saved conversations found.
- Press q to exit
-
-);
+import { SessionBrowserLoading } from './SessionBrowser/SessionBrowserLoading.js';
+import { SessionBrowserError } from './SessionBrowser/SessionBrowserError.js';
+import { SessionBrowserEmpty } from './SessionBrowser/SessionBrowserEmpty.js';
import { sortSessions, filterSessions } from './SessionBrowser/utils.js';
diff --git a/packages/cli/src/ui/components/SessionBrowser/SessionBrowserEmpty.tsx b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserEmpty.tsx
new file mode 100644
index 0000000000..31c9544cd8
--- /dev/null
+++ b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserEmpty.tsx
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Box, Text } from 'ink';
+import { Colors } from '../../colors.js';
+
+/**
+ * Empty state component displayed when no sessions are found.
+ */
+export const SessionBrowserEmpty = (): React.JSX.Element => (
+
+ No auto-saved conversations found.
+ Press q to exit
+
+);
diff --git a/packages/cli/src/ui/components/SessionBrowser/SessionBrowserError.tsx b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserError.tsx
new file mode 100644
index 0000000000..cf46fb8954
--- /dev/null
+++ b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserError.tsx
@@ -0,0 +1,24 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Box, Text } from 'ink';
+import { Colors } from '../../colors.js';
+import type { SessionBrowserState } from '../SessionBrowser.js';
+
+/**
+ * Error state component displayed when session loading fails.
+ */
+export const SessionBrowserError = ({
+ state,
+}: {
+ state: SessionBrowserState;
+}): React.JSX.Element => (
+
+ Error: {state.error}
+ Press q to exit
+
+);
diff --git a/packages/cli/src/ui/components/SessionBrowser/SessionBrowserLoading.tsx b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserLoading.tsx
new file mode 100644
index 0000000000..e0c372eca2
--- /dev/null
+++ b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserLoading.tsx
@@ -0,0 +1,18 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Box, Text } from 'ink';
+import { Colors } from '../../colors.js';
+
+/**
+ * Loading state component displayed while sessions are being loaded.
+ */
+export const SessionBrowserLoading = (): React.JSX.Element => (
+
+ Loading sessions…
+
+);
diff --git a/packages/cli/src/ui/components/SessionBrowser/SessionBrowserStates.test.tsx b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserStates.test.tsx
new file mode 100644
index 0000000000..2b816a8211
--- /dev/null
+++ b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserStates.test.tsx
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../../test-utils/render.js';
+import { describe, it, expect } from 'vitest';
+import { SessionBrowserLoading } from './SessionBrowserLoading.js';
+import { SessionBrowserError } from './SessionBrowserError.js';
+import { SessionBrowserEmpty } from './SessionBrowserEmpty.js';
+import type { SessionBrowserState } from '../SessionBrowser.js';
+
+describe('SessionBrowser UI States', () => {
+ it('SessionBrowserLoading renders correctly', async () => {
+ const { lastFrame, waitUntilReady } = render();
+ await waitUntilReady();
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('SessionBrowserError renders correctly', async () => {
+ const mockState = { error: 'Test error message' } as SessionBrowserState;
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('SessionBrowserEmpty renders correctly', async () => {
+ const { lastFrame, waitUntilReady } = render();
+ await waitUntilReady();
+ expect(lastFrame()).toMatchSnapshot();
+ });
+});
diff --git a/packages/cli/src/ui/components/SessionBrowser/__snapshots__/SessionBrowserStates.test.tsx.snap b/packages/cli/src/ui/components/SessionBrowser/__snapshots__/SessionBrowserStates.test.tsx.snap
new file mode 100644
index 0000000000..e5939219cb
--- /dev/null
+++ b/packages/cli/src/ui/components/SessionBrowser/__snapshots__/SessionBrowserStates.test.tsx.snap
@@ -0,0 +1,18 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`SessionBrowser UI States > SessionBrowserEmpty renders correctly 1`] = `
+" No auto-saved conversations found.
+ Press q to exit
+"
+`;
+
+exports[`SessionBrowser UI States > SessionBrowserError renders correctly 1`] = `
+" Error: Test error message
+ Press q to exit
+"
+`;
+
+exports[`SessionBrowser UI States > SessionBrowserLoading renders correctly 1`] = `
+" Loading sessions…
+"
+`;
From bbd80c9393e11f4fb09e62761d153706793877a1 Mon Sep 17 00:00:00 2001
From: Abhi <43648792+abhipatel12@users.noreply.github.com>
Date: Fri, 13 Mar 2026 13:26:13 -0400
Subject: [PATCH 2/4] docs: overhaul subagents documentation and add /agents
command (#22345)
---
docs/core/subagents.md | 152 ++++++++++++++++++++++++++++++++-----
docs/reference/commands.md | 25 ++++++
2 files changed, 159 insertions(+), 18 deletions(-)
diff --git a/docs/core/subagents.md b/docs/core/subagents.md
index e937f28e77..659ed6d640 100644
--- a/docs/core/subagents.md
+++ b/docs/core/subagents.md
@@ -38,6 +38,34 @@ main agent calls the tool, it delegates the task to the subagent. Once the
subagent completes its task, it reports back to the main agent with its
findings.
+## How to use subagents
+
+You can use subagents through automatic delegation or by explicitly forcing them
+in your prompt.
+
+### Automatic delegation
+
+Gemini CLI's main agent is instructed to use specialized subagents when a task
+matches their expertise. For example, if you ask "How does the auth system
+work?", the main agent may decide to call the `codebase_investigator` subagent
+to perform the research.
+
+### Forcing a subagent (@ syntax)
+
+You can explicitly direct a task to a specific subagent by using the `@` symbol
+followed by the subagent's name at the beginning of your prompt. This is useful
+when you want to bypass the main agent's decision-making and go straight to a
+specialist.
+
+**Example:**
+
+```bash
+@codebase_investigator Map out the relationship between the AgentRegistry and the LocalAgentExecutor.
+```
+
+When you use the `@` syntax, the CLI injects a system note that nudges the
+primary model to use that specific subagent tool immediately.
+
## Built-in subagents
Gemini CLI comes with the following built-in subagents:
@@ -49,15 +77,17 @@ Gemini CLI comes with the following built-in subagents:
dependencies.
- **When to use:** "How does the authentication system work?", "Map out the
dependencies of the `AgentRegistry` class."
-- **Configuration:** Enabled by default. You can configure it in
- `settings.json`. Example (forcing a specific model):
+- **Configuration:** Enabled by default. You can override its settings in
+ `settings.json` under `agents.overrides`. Example (forcing a specific model
+ and increasing turns):
```json
{
- "experimental": {
- "codebaseInvestigatorSettings": {
- "enabled": true,
- "maxNumTurns": 20,
- "model": "gemini-2.5-pro"
+ "agents": {
+ "overrides": {
+ "codebase_investigator": {
+ "modelConfig": { "model": "gemini-3-flash-preview" },
+ "runConfig": { "maxTurns": 50 }
+ }
}
}
}
@@ -233,7 +263,7 @@ kind: local
tools:
- read_file
- grep_search
-model: gemini-2.5-pro
+model: gemini-3-flash-preview
temperature: 0.2
max_turns: 10
---
@@ -254,16 +284,102 @@ it yourself; just report it.
### Configuration schema
-| Field | Type | Required | Description |
-| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------ |
-| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. |
-| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. |
-| `kind` | string | No | `local` (default) or `remote`. |
-| `tools` | array | No | List of tool names this agent can use. If omitted, it may have access to a default set. |
-| `model` | string | No | Specific model to use (e.g., `gemini-2.5-pro`). Defaults to `inherit` (uses the main session model). |
-| `temperature` | number | No | Model temperature (0.0 - 2.0). |
-| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `15`. |
-| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `5`. |
+| Field | Type | Required | Description |
+| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. |
+| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. |
+| `kind` | string | No | `local` (default) or `remote`. |
+| `tools` | array | No | List of tool names this agent can use. Supports wildcards: `*` (all tools), `mcp_*` (all MCP tools), `mcp_server_*` (all tools from a server). **If omitted, it inherits all tools from the parent session.** |
+| `model` | string | No | Specific model to use (e.g., `gemini-3-preview`). Defaults to `inherit` (uses the main session model). |
+| `temperature` | number | No | Model temperature (0.0 - 2.0). Defaults to `1`. |
+| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `30`. |
+| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `10`. |
+
+### Tool wildcards
+
+When defining `tools` for a subagent, you can use wildcards to quickly grant
+access to groups of tools:
+
+- `*`: Grant access to all available built-in and discovered tools.
+- `mcp_*`: Grant access to all tools from all connected MCP servers.
+- `mcp_my-server_*`: Grant access to all tools from a specific MCP server named
+ `my-server`.
+
+### Isolation and recursion protection
+
+Each subagent runs in its own isolated context loop. This means:
+
+- **Independent history:** The subagent's conversation history does not bloat
+ the main agent's context.
+- **Isolated tools:** The subagent only has access to the tools you explicitly
+ grant it.
+- **Recursion protection:** To prevent infinite loops and excessive token usage,
+ subagents **cannot** call other subagents. If a subagent is granted the `*`
+ tool wildcard, it will still be unable to see or invoke other agents.
+
+## Managing subagents
+
+You can manage subagents interactively using the `/agents` command or
+persistently via `settings.json`.
+
+### Interactive management (/agents)
+
+If you are in an interactive CLI session, you can use the `/agents` command to
+manage subagents without editing configuration files manually. This is the
+recommended way to quickly enable, disable, or re-configure agents on the fly.
+
+For a full list of sub-commands and usage, see the
+[`/agents` command reference](../reference/commands.md#agents).
+
+### Persistent configuration (settings.json)
+
+While the `/agents` command and agent definition files provide a starting point,
+you can use `settings.json` for global, persistent overrides. This is useful for
+enforcing specific models or execution limits across all sessions.
+
+#### `agents.overrides`
+
+Use this to enable or disable specific agents or override their run
+configurations.
+
+```json
+{
+ "agents": {
+ "overrides": {
+ "security-auditor": {
+ "enabled": false,
+ "runConfig": {
+ "maxTurns": 20,
+ "maxTimeMinutes": 10
+ }
+ }
+ }
+ }
+}
+```
+
+#### `modelConfigs.overrides`
+
+You can target specific subagents with custom model settings (like system
+instruction prefixes or specific safety settings) using the `overrideScope`
+field.
+
+```json
+{
+ "modelConfigs": {
+ "overrides": [
+ {
+ "match": { "overrideScope": "security-auditor" },
+ "modelConfig": {
+ "generateContentConfig": {
+ "temperature": 0.1
+ }
+ }
+ }
+ ]
+ }
+}
+```
### Optimizing your subagent
diff --git a/docs/reference/commands.md b/docs/reference/commands.md
index c7c25cba1e..e9383152d2 100644
--- a/docs/reference/commands.md
+++ b/docs/reference/commands.md
@@ -14,6 +14,31 @@ Slash commands provide meta-level control over the CLI itself.
- **Description:** Show version info. Share this information when filing issues.
+### `/agents`
+
+- **Description:** Manage local and remote subagents.
+- **Note:** This command is experimental and requires
+ `experimental.enableAgents: true` in your `settings.json`.
+- **Sub-commands:**
+ - **`list`**:
+ - **Description:** Lists all discovered agents, including built-in, local,
+ and remote agents.
+ - **Usage:** `/agents list`
+ - **`reload`** (alias: `refresh`):
+ - **Description:** Rescans agent directories (`~/.gemini/agents` and
+ `.gemini/agents`) and reloads the registry.
+ - **Usage:** `/agents reload`
+ - **`enable`**:
+ - **Description:** Enables a specific subagent.
+ - **Usage:** `/agents enable `
+ - **`disable`**:
+ - **Description:** Disables a specific subagent.
+ - **Usage:** `/agents disable `
+ - **`config`**:
+ - **Description:** Opens a configuration dialog for the specified agent to
+ adjust its model, temperature, or execution limits.
+ - **Usage:** `/agents config `
+
### `/auth`
- **Description:** Open a dialog that lets you change the authentication method.
From d368997ca3bdf9cc3e99b79cf73c894e278a6d2b Mon Sep 17 00:00:00 2001
From: Gaurav <39389231+gsquared94@users.noreply.github.com>
Date: Fri, 13 Mar 2026 10:49:33 -0700
Subject: [PATCH 3/4] test: add Object.create context regression test and tool
confirmation integration test (#22356)
---
.../browser-agent.confirmation.responses | 1 +
integration-tests/browser-agent.test.ts | 29 +++++++++++++++
.../core/src/agents/subagent-tool-wrapper.ts | 8 +---
packages/core/src/scheduler/policy.test.ts | 37 +++++++++++++++++++
4 files changed, 69 insertions(+), 6 deletions(-)
create mode 100644 integration-tests/browser-agent.confirmation.responses
diff --git a/integration-tests/browser-agent.confirmation.responses b/integration-tests/browser-agent.confirmation.responses
new file mode 100644
index 0000000000..4f645c6531
--- /dev/null
+++ b/integration-tests/browser-agent.confirmation.responses
@@ -0,0 +1 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"file_path":"test.txt","content":"hello"}}},{"text":"I've successfully written \"hello\" to test.txt. The file has been created with the specified content."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
diff --git a/integration-tests/browser-agent.test.ts b/integration-tests/browser-agent.test.ts
index 0fdb3e717b..f9f07d4c9e 100644
--- a/integration-tests/browser-agent.test.ts
+++ b/integration-tests/browser-agent.test.ts
@@ -203,4 +203,33 @@ describe.skipIf(!chromeAvailable)('browser-agent', () => {
// Should successfully complete all operations
assertModelHasOutput(result);
});
+
+ it('should handle tool confirmation for write_file without crashing', async () => {
+ rig.setup('tool-confirmation', {
+ fakeResponsesPath: join(
+ __dirname,
+ 'browser-agent.confirmation.responses',
+ ),
+ settings: {
+ agents: {
+ browser_agent: {
+ headless: true,
+ sessionMode: 'isolated',
+ },
+ },
+ },
+ });
+
+ const run = await rig.runInteractive({ approvalMode: 'default' });
+
+ await run.type('Write hello to test.txt');
+ await run.type('\r');
+
+ await run.expectText('Allow', 15000);
+
+ await run.type('y');
+ await run.type('\r');
+
+ await run.expectText('successfully written', 15000);
+ });
});
diff --git a/packages/core/src/agents/subagent-tool-wrapper.ts b/packages/core/src/agents/subagent-tool-wrapper.ts
index ff64d4a03f..cf6d1e7112 100644
--- a/packages/core/src/agents/subagent-tool-wrapper.ts
+++ b/packages/core/src/agents/subagent-tool-wrapper.ts
@@ -10,7 +10,7 @@ import {
type ToolInvocation,
type ToolResult,
} from '../tools/tools.js';
-import type { Config } from '../config/config.js';
+
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import type { AgentDefinition, AgentInputs } from './types.js';
import { LocalSubagentInvocation } from './local-invocation.js';
@@ -54,10 +54,6 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
);
}
- private get config(): Config {
- return this.context.config;
- }
-
/**
* Creates an invocation instance for executing the subagent.
*
@@ -89,7 +85,7 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
// Special handling for browser agent - needs async MCP setup
if (definition.name === BROWSER_AGENT_NAME) {
return new BrowserAgentInvocation(
- this.config,
+ this.context,
params,
effectiveMessageBus,
_toolName,
diff --git a/packages/core/src/scheduler/policy.test.ts b/packages/core/src/scheduler/policy.test.ts
index 750b14c2ed..e802a4b220 100644
--- a/packages/core/src/scheduler/policy.test.ts
+++ b/packages/core/src/scheduler/policy.test.ts
@@ -676,6 +676,43 @@ describe('policy.ts', () => {
}),
);
});
+
+ it('should work when context is created via Object.create (prototype chain)', async () => {
+ const mockConfig = {
+ setApprovalMode: vi.fn(),
+ } as unknown as Mocked;
+ const mockMessageBus = {
+ publish: vi.fn(),
+ } as unknown as Mocked;
+
+ const baseContext = {
+ config: mockConfig,
+ messageBus: mockMessageBus,
+ };
+ const protoContext: AgentLoopContext = Object.create(baseContext);
+
+ expect(Object.keys(protoContext)).toHaveLength(0);
+ expect(protoContext.config).toBe(mockConfig);
+ expect(protoContext.messageBus).toBe(mockMessageBus);
+
+ const tool = { name: 'test-tool' } as AnyDeclarativeTool;
+
+ await updatePolicy(
+ tool,
+ ToolConfirmationOutcome.ProceedAlways,
+ undefined,
+ protoContext,
+ mockMessageBus,
+ );
+
+ expect(mockMessageBus.publish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageBusType.UPDATE_POLICY,
+ toolName: 'test-tool',
+ persist: false,
+ }),
+ );
+ });
});
describe('getPolicyDenialError', () => {
From dd8d4c98b37cb822513514d580c5a47a4ae048d2 Mon Sep 17 00:00:00 2001
From: anj-s <32556631+anj-s@users.noreply.github.com>
Date: Fri, 13 Mar 2026 11:18:33 -0700
Subject: [PATCH 4/4] feat(tracker): return TodoList display for tracker tools
(#22060)
---
.../cli/src/ui/components/messages/Todo.tsx | 2 +-
packages/core/src/services/trackerTypes.ts | 6 ++
packages/core/src/tools/trackerTools.test.ts | 88 +++++++++++++++++++
packages/core/src/tools/trackerTools.ts | 85 ++++++++++++++----
4 files changed, 164 insertions(+), 17 deletions(-)
diff --git a/packages/cli/src/ui/components/messages/Todo.tsx b/packages/cli/src/ui/components/messages/Todo.tsx
index a7201b12fb..e1fbd78a86 100644
--- a/packages/cli/src/ui/components/messages/Todo.tsx
+++ b/packages/cli/src/ui/components/messages/Todo.tsx
@@ -18,7 +18,7 @@ export const TodoTray: React.FC = () => {
const uiState = useUIState();
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--) {
const entry = uiState.history[i];
if (entry.type !== 'tool_group') {
diff --git a/packages/core/src/services/trackerTypes.ts b/packages/core/src/services/trackerTypes.ts
index 7c48f5bcd4..6c21456fe1 100644
--- a/packages/core/src/services/trackerTypes.ts
+++ b/packages/core/src/services/trackerTypes.ts
@@ -13,6 +13,12 @@ export enum TaskType {
}
export const TaskTypeSchema = z.nativeEnum(TaskType);
+export const TASK_TYPE_LABELS: Record = {
+ [TaskType.EPIC]: '[EPIC]',
+ [TaskType.TASK]: '[TASK]',
+ [TaskType.BUG]: '[BUG]',
+};
+
export enum TaskStatus {
OPEN = 'open',
IN_PROGRESS = 'in_progress',
diff --git a/packages/core/src/tools/trackerTools.test.ts b/packages/core/src/tools/trackerTools.test.ts
index ec0bd0e889..7edafb0fa3 100644
--- a/packages/core/src/tools/trackerTools.test.ts
+++ b/packages/core/src/tools/trackerTools.test.ts
@@ -14,12 +14,14 @@ import {
TrackerUpdateTaskTool,
TrackerVisualizeTool,
TrackerAddDependencyTool,
+ buildTodosReturnDisplay,
} from './trackerTools.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import { TaskStatus, TaskType } from '../services/trackerTypes.js';
+import type { TrackerService } from '../services/trackerService.js';
describe('Tracker Tools Integration', () => {
let tempDir: string;
@@ -142,4 +144,90 @@ describe('Tracker Tools Integration', () => {
expect(vizResult.llmContent).toContain('Child Task');
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',
+ },
+ ]);
+ });
+ });
});
diff --git a/packages/core/src/tools/trackerTools.ts b/packages/core/src/tools/trackerTools.ts
index 03ee3c3a97..0a7101f55e 100644
--- a/packages/core/src/tools/trackerTools.ts
+++ b/packages/core/src/tools/trackerTools.ts
@@ -23,11 +23,69 @@ import {
TRACKER_UPDATE_TASK_TOOL_NAME,
TRACKER_VISUALIZE_TOOL_NAME,
} 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 { ToolErrorType } from './tool-error.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 {
+ const tasks = await service.listTasks();
+ const childrenMap = new Map();
+ 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) => {
+ 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 ---
@@ -71,7 +129,7 @@ class TrackerCreateTaskInvocation extends BaseToolInvocation<
});
return {
llmContent: `Created task ${task.id}: ${task.title}`,
- returnDisplay: `Created task ${task.id}.`,
+ returnDisplay: await buildTodosReturnDisplay(this.service),
};
} catch (error) {
const errorMessage =
@@ -155,7 +213,7 @@ class TrackerUpdateTaskInvocation extends BaseToolInvocation<
const task = await this.service.updateTask(id, updates);
return {
llmContent: `Updated task ${task.id}. Status: ${task.status}`,
- returnDisplay: `Updated task ${task.id}.`,
+ returnDisplay: await buildTodosReturnDisplay(this.service),
};
} catch (error) {
const errorMessage =
@@ -239,7 +297,7 @@ class TrackerGetTaskInvocation extends BaseToolInvocation<
}
return {
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');
return {
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 });
return {
llmContent: `Linked ${task.id} -> ${dep.id}.`,
- returnDisplay: 'Dependency added.',
+ returnDisplay: await buildTodosReturnDisplay(this.service),
};
} catch (error) {
const errorMessage =
@@ -516,12 +574,6 @@ class TrackerVisualizeInvocation extends BaseToolInvocation<
closed: '✅',
};
- const typeLabels: Record = {
- epic: '[EPIC]',
- task: '[TASK]',
- bug: '[BUG]',
- };
-
const childrenMap = new Map();
const roots: TrackerTask[] = [];
@@ -550,14 +602,15 @@ class TrackerVisualizeInvocation extends BaseToolInvocation<
visited.add(task.id);
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) {
output += `${indent} └─ Depends on: ${task.dependencies.join(', ')}\n`;
}
const children = childrenMap.get(task.id) ?? [];
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) {
@@ -566,7 +619,7 @@ class TrackerVisualizeInvocation extends BaseToolInvocation<
return {
llmContent: output,
- returnDisplay: output,
+ returnDisplay: await buildTodosReturnDisplay(this.service),
};
}
}