feat(acp): extract and stream plan events from agent thoughts

- Updated default model configurations (Gemini 3/3.1) to enforce `includeThoughts: true` and `ThinkingLevel.HIGH` and updated schema, doc and tests to reflect this.
- Extended the code assist converters to safely extract and format the `thought` field, preventing type errors during `CountToken` API calls.
- Implemented real-time `extractPlanEntries` parsing using regex that gracefully extracts markdown task lists (e.g., `[TODO]`, `[x]`, `[]`) while safely ignoring content inside code blocks.
- Added logic in the ACP client that incrementally reads the agent's thought buffer and emits `sessionUpdate: 'plan'` events directly to the IDE whenever the tracked tasks change.
- Formatted `agent_thought_chunk` outputs to replace escaped literal newlines (`\n`) with actual native newlines, ensuring the IDE chat displays main thought sections cleanly without visual artifacts.
- Added comprehensive unit tests for the plan parser and ACP integration.
This commit is contained in:
Sri Pasumarthi
2026-03-10 15:51:41 -07:00
parent fc51e50bc6
commit 4d5c85ac8b
13 changed files with 657 additions and 133 deletions
+76 -45
View File
@@ -91,52 +91,44 @@ vi.mock('../ui/commands/initCommand.js', () => ({
action: vi.fn(),
},
}));
vi.mock(
'@google/gemini-cli-core',
async (
importOriginal: () => Promise<typeof import('@google/gemini-cli-core')>,
) => {
const actual = await importOriginal();
return {
...actual,
ReadManyFilesTool: vi.fn().mockImplementation(() => ({
name: 'read_many_files',
kind: 'read',
build: vi.fn().mockReturnValue({
getDescription: () => 'Read files',
toolLocations: () => [],
execute: vi.fn().mockResolvedValue({
llmContent: ['--- file.txt ---\n\nFile content\n\n'],
}),
}),
})),
logToolCall: vi.fn(),
isWithinRoot: vi.fn().mockReturnValue(true),
LlmRole: {
MAIN: 'main',
SUBAGENT: 'subagent',
UTILITY_TOOL: 'utility_tool',
UTILITY_COMPRESSOR: 'utility_compressor',
UTILITY_SUMMARIZER: 'utility_summarizer',
UTILITY_ROUTER: 'utility_router',
UTILITY_LOOP_DETECTOR: 'utility_loop_detector',
UTILITY_NEXT_SPEAKER: 'utility_next_speaker',
UTILITY_EDIT_CORRECTOR: 'utility_edit_corrector',
UTILITY_AUTOCOMPLETE: 'utility_autocomplete',
UTILITY_FAST_ACK_HELPER: 'utility_fast_ack_helper',
},
CoreToolCallStatus: {
Validating: 'validating',
Scheduled: 'scheduled',
Error: 'error',
Success: 'success',
Executing: 'executing',
Cancelled: 'cancelled',
AwaitingApproval: 'awaiting_approval',
},
};
const { actualCore } = await vi.hoisted(async () => {
const actual = await import('@google/gemini-cli-core');
return { actualCore: actual };
});
vi.mock('@google/gemini-cli-core', () => ({
...actualCore,
ReadManyFilesTool: vi.fn().mockImplementation(() => ({
name: 'read_many_files',
kind: 'read',
build: vi.fn().mockReturnValue({
getDescription: () => 'Read files',
toolLocations: () => [],
execute: vi.fn().mockResolvedValue({
llmContent: ['--- file.txt ---\n\nFile content\n\n'],
}),
}),
})),
logToolCall: vi.fn(),
isWithinRoot: vi.fn().mockReturnValue(true),
// Ensure critical enums/objects are present if they were overridden
LlmRole: {
...actualCore.LlmRole,
MAIN: 'main',
SUBAGENT: 'subagent',
UTILITY_TOOL: 'utility_tool',
},
);
CoreToolCallStatus: {
...actualCore.CoreToolCallStatus,
Validating: 'validating',
Scheduled: 'scheduled',
Error: 'error',
Success: 'success',
Executing: 'executing',
Cancelled: 'cancelled',
AwaitingApproval: 'awaiting_approval',
},
}));
// Helper to create mock streams
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1588,4 +1580,43 @@ describe('Session', () => {
expect(handleCommandSpy).toHaveBeenCalledWith('/memory', expect.anything());
});
it('should extract and emit plan events from thought chunks', async () => {
const stream = createMockStream([
{
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: {
parts: [
{
thought: '- [TODO] First task\n- [x] Done task',
},
],
},
},
],
},
},
]);
mockChat.sendMessageStream.mockResolvedValue(stream);
await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Hi' }],
});
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'plan',
entries: [
{ content: 'First task', status: 'pending', priority: 'medium' },
{ content: 'Done task', status: 'completed', priority: 'medium' },
],
}),
}),
);
});
});
+47 -4
View File
@@ -49,6 +49,8 @@ import {
getDisplayString,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { z } from 'zod';
import { extractPlanEntries } from './planParser.js';
import { AcpFileSystemService } from './fileSystemService.js';
import { getAcpErrorMessage } from './acpErrors.js';
import { Readable, Writable } from 'node:stream';
@@ -64,7 +66,6 @@ import {
} from '../config/settings.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import { loadCliConfig, type CliArgs } from '../config/config.js';
@@ -716,7 +717,7 @@ export class Session {
(await this.config.getGemini31Launched?.()) ?? false,
);
const responseStream = await chat.sendMessageStream(
{ model },
{ model, isChatModel: true },
nextMessage?.parts ?? [],
promptId,
pendingSend.signal,
@@ -724,6 +725,9 @@ export class Session {
);
nextMessage = null;
let currentThoughtTextBuffer = '';
let currentTurnPlanStr = '';
for await (const resp of responseStream) {
if (pendingSend.signal.aborted) {
return { stopReason: CoreToolCallStatus.Cancelled };
@@ -736,13 +740,52 @@ export class Session {
) {
const candidate = resp.value.candidates[0];
for (const part of candidate.content?.parts ?? []) {
if (!part.text) {
const thoughtValue = (part as { thought?: unknown }).thought;
debugLogger.log(
`[ACP] Received part: thought=${
thoughtValue ? typeof thoughtValue : 'undefined'
}, text=${part.text ? 'present' : 'empty'}`,
);
if (typeof thoughtValue === 'string') {
debugLogger.log(
`[ACP] Thought content: ${thoughtValue.substring(0, 50)}...`,
);
}
if (part.thought) {
const thoughtSubText =
typeof part.thought === 'string'
? part.thought
: (part.text ?? '');
currentThoughtTextBuffer += thoughtSubText;
const entries = extractPlanEntries(currentThoughtTextBuffer);
if (entries) {
const newPlanStr = JSON.stringify(entries);
if (newPlanStr !== currentTurnPlanStr) {
currentTurnPlanStr = newPlanStr;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.sendUpdate({
sessionUpdate: 'plan',
entries,
});
}
}
}
const textToStream =
typeof part.thought === 'string'
? part.thought
: (part.text ?? '');
if (!textToStream) {
continue;
}
const content: acp.ContentBlock = {
type: 'text',
text: part.text,
text: part.thought
? textToStream.replace(/\\n/g, '\n')
: textToStream,
};
// eslint-disable-next-line @typescript-eslint/no-floating-promises
+56
View File
@@ -0,0 +1,56 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { extractPlanEntries } from './planParser.js';
describe('planParser', () => {
it('should extract simple tasks', () => {
const text = `
- [ ] Task 1
- [x] Task 2
- [/] Task 3
`;
const entries = extractPlanEntries(text);
expect(entries).toEqual([
{ content: 'Task 1', status: 'pending', priority: 'medium' },
{ content: 'Task 2', status: 'completed', priority: 'medium' },
{ content: 'Task 3', status: 'in_progress', priority: 'medium' },
]);
});
it('should ignore tasks in code blocks', () => {
const text = `
- [ ] Valid task
\`\`\`
- [ ] Ignored task
\`\`\`
`;
const entries = extractPlanEntries(text);
expect(entries).toEqual([
{ content: 'Valid task', status: 'pending', priority: 'medium' },
]);
});
it('should handle alternative status labels', () => {
const text = `
1. [TODO] Task 1
2. [DONE] Task 2
3. [IN_PROGRESS] Task 3
`;
const entries = extractPlanEntries(text);
expect(entries).toEqual([
{ content: 'Task 1', status: 'pending', priority: 'medium' },
{ content: 'Task 2', status: 'completed', priority: 'medium' },
{ content: 'Task 3', status: 'in_progress', priority: 'medium' },
]);
});
it('should return null if no tasks are found', () => {
const text = 'Just some text without tasks.';
expect(extractPlanEntries(text)).toBeNull();
});
});
+56
View File
@@ -0,0 +1,56 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type PlanEntry } from '@agentclientprotocol/sdk';
/**
* Extracts plan entries from the provided markdown text.
* It ignores content within triple-backtick code blocks.
*/
export function extractPlanEntries(text: string): PlanEntry[] | null {
// 1. Strip triple-backtick code blocks to avoid false positives.
const textWithoutCodeBlocks = text.replace(/```[\s\S]*?(?:```|$)/g, '');
// 2. Match markdown list items with task indicators.
// Supports: - [ ], - [x], - [/], 1. [TODO], 2. [DONE], - [] etc.
const taskRegex =
/^\s*(?:[-*]|\d+\.)\s*\[\s*(x|X|\/| |DONE|TODO|IN_PROGRESS|IN PROGRESS|PENDING|COMPLETED)?\s*\]\s+(.*?)$/gm;
const entries: PlanEntry[] = [];
let match;
while ((match = taskRegex.exec(textWithoutCodeBlocks)) !== null) {
const rawStatus = (match[1] || '').toUpperCase().trim();
const content = match[2].trim();
if (!content) continue;
let status: 'pending' | 'in_progress' | 'completed' = 'pending';
switch (rawStatus) {
case 'X':
case 'DONE':
case 'COMPLETED':
status = 'completed';
break;
case '/':
case 'IN_PROGRESS':
case 'IN PROGRESS':
status = 'in_progress';
break;
case '':
case 'TODO':
case 'PENDING':
default:
status = 'pending';
break;
}
entries.push({ content, status, priority: 'medium' });
}
return entries.length > 0 ? entries : null;
}
@@ -18,20 +18,8 @@ Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
"
`;
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = `
"
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
"
`;
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
"
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
"
`;
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = `
"
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
"
`;