fix(watcher): safely parse structured subagent output and clean up tests

This commit is contained in:
Aishanee Shah
2026-04-10 02:57:57 +00:00
parent 4b88dc1bcf
commit e2ee279f6f
3 changed files with 45 additions and 94 deletions
+8 -36
View File
@@ -941,10 +941,6 @@ export class Config implements McpContext, AgentLoopContext {
private readonly adminSkillsEnabled: boolean;
private readonly experimentalJitContext: boolean;
private readonly experimentalMemoryManager: boolean;
private readonly experimentalAgentHistoryTruncation: boolean;
private readonly experimentalAgentHistoryTruncationThreshold: number;
private readonly experimentalAgentHistoryRetainedMessages: number;
private readonly experimentalAgentHistorySummarization: boolean;
private readonly experimentalWatcher: boolean;
private readonly experimentalWatcherInterval: number;
private readonly memoryBoundaryMarkers: readonly string[];
@@ -1158,14 +1154,6 @@ export class Config implements McpContext, AgentLoopContext {
this.experimentalJitContext = params.experimentalJitContext ?? false;
this.experimentalMemoryManager = params.experimentalMemoryManager ?? false;
this.experimentalAgentHistoryTruncation =
params.experimentalAgentHistoryTruncation ?? false;
this.experimentalAgentHistoryTruncationThreshold =
params.experimentalAgentHistoryTruncationThreshold ?? 30;
this.experimentalAgentHistoryRetainedMessages =
params.experimentalAgentHistoryRetainedMessages ?? 15;
this.experimentalAgentHistorySummarization =
params.experimentalAgentHistorySummarization ?? false;
this.experimentalWatcher = params.experimentalWatcher ?? false;
this.experimentalWatcherInterval = params.experimentalWatcherInterval ?? 20;
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
@@ -2452,6 +2440,14 @@ export class Config implements McpContext, AgentLoopContext {
return this.experimentalMemoryManager;
}
isExperimentalWatcherEnabled(): boolean {
return this.experimentalWatcher;
}
getExperimentalWatcherInterval(): number {
return this.experimentalWatcherInterval;
}
getContextManagementConfig(): ContextManagementConfig {
return this.contextManagement;
}
@@ -2468,30 +2464,6 @@ export class Config implements McpContext, AgentLoopContext {
};
}
isExperimentalAgentHistoryTruncationEnabled(): boolean {
return this.experimentalAgentHistoryTruncation;
}
getExperimentalAgentHistoryTruncationThreshold(): number {
return this.experimentalAgentHistoryTruncationThreshold;
}
getExperimentalAgentHistoryRetainedMessages(): number {
return this.experimentalAgentHistoryRetainedMessages;
}
isExperimentalAgentHistorySummarizationEnabled(): boolean {
return this.experimentalAgentHistorySummarization;
}
isExperimentalWatcherEnabled(): boolean {
return this.experimentalWatcher;
}
getExperimentalWatcherInterval(): number {
return this.experimentalWatcherInterval;
}
isTopicUpdateNarrationEnabled(): boolean {
return this.topicUpdateNarration;
}
+7 -4
View File
@@ -47,7 +47,7 @@ import type { ContentGenerator } from './contentGenerator.js';
import { LoopDetectionService } from '../services/loopDetectionService.js';
import { ChatCompressionService } from '../context/chatCompressionService.js';
import { AgentHistoryProvider } from '../context/agentHistoryProvider.js';
import type { WatcherProgress } from '../agents/types.js';
import { isSubagentProgress, type WatcherProgress } from '../agents/types.js';
import { WatcherReportSchema } from '../agents/watcher-agent.js';
import { ideContextStore } from '../ide/ideContext.js';
import {
@@ -1390,10 +1390,13 @@ export class GeminiClient {
const invocation = watcherTool.build({ recentHistory });
const result = await invocation.execute(signal);
if (result.llmContent) {
if (
isSubagentProgress(result.returnDisplay) &&
result.returnDisplay.result
) {
try {
const contentString = partListUnionToString(result.llmContent);
const parsed = WatcherReportSchema.parse(JSON.parse(contentString));
const rawOutput = result.returnDisplay.result;
const parsed = WatcherReportSchema.parse(JSON.parse(rawOutput));
// Internally write the status report to avoid requiring user permission
const projectTempDir = this.config.storage.getProjectTempDir();
+30 -54
View File
@@ -11,7 +11,6 @@ import { GeminiClient } from './client.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { ApprovalMode } from '../policy/types.js';
import type { WatcherProgress } from '../agents/types.js';
import type { Config } from '../config/config.js';
describe('GeminiClient Watcher Integration', () => {
@@ -57,25 +56,17 @@ describe('GeminiClient Watcher Integration', () => {
}
});
it('should trigger watcher periodically when enabled', async () => {
vi.spyOn(config, 'isExperimentalWatcherEnabled').mockReturnValue(true);
vi.spyOn(config, 'getExperimentalWatcherInterval').mockReturnValue(2);
vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.DEFAULT);
// Mock toolRegistry before initialize calls startChat
const mockWatcherTool = {
const createMockWatcherTool = (resultData: unknown) => ({
build: vi.fn().mockReturnValue({
execute: vi.fn().mockResolvedValue({
llmContent: [
{
text: JSON.stringify({
userDirections: 'Keep testing',
progressSummary: 'Test in progress',
evaluation: 'Good',
feedback: 'Keep going',
} as WatcherProgress),
},
],
llmContent: [{ text: 'Subagent finished' }],
returnDisplay: {
isSubagentProgress: true,
agentName: 'watcher',
recentActivity: [],
state: 'completed',
result: resultData ? JSON.stringify(resultData) : undefined,
},
}),
}),
name: 'watcher',
@@ -88,7 +79,19 @@ describe('GeminiClient Watcher Integration', () => {
outputName: 'report',
schema: {},
},
};
});
it('should trigger watcher periodically when enabled', async () => {
vi.spyOn(config, 'isExperimentalWatcherEnabled').mockReturnValue(true);
vi.spyOn(config, 'getExperimentalWatcherInterval').mockReturnValue(2);
vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.DEFAULT);
const mockWatcherTool = createMockWatcherTool({
userDirections: 'Keep testing',
progressSummary: 'Test in progress',
evaluation: 'Good',
feedback: 'Keep going',
});
const mockToolRegistry = {
getFunctionDeclarations: vi.fn().mockReturnValue([]),
@@ -101,7 +104,6 @@ describe('GeminiClient Watcher Integration', () => {
discoverAllTools: vi.fn(),
};
// Use type assertion for testing purposes to access protected members
const clientAccess = client as unknown as {
context: AgentLoopContext;
};
@@ -130,7 +132,7 @@ describe('GeminiClient Watcher Integration', () => {
promptId,
);
for await (const _ of generator) {
// Intentionally consume
// consume
}
expect(mockWatcherTool.build).toHaveBeenCalled();
@@ -141,7 +143,6 @@ describe('GeminiClient Watcher Integration', () => {
vi.spyOn(config, 'getExperimentalWatcherInterval').mockReturnValue(2);
vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.DEFAULT);
// Mock toolRegistry before initialize calls startChat
const mockWatcherTool = {
build: vi.fn(),
name: 'watcher',
@@ -158,7 +159,6 @@ describe('GeminiClient Watcher Integration', () => {
discoverAllTools: vi.fn(),
};
// Use type assertion for testing purposes to access protected members
const clientAccess = client as unknown as {
context: AgentLoopContext;
};
@@ -187,7 +187,7 @@ describe('GeminiClient Watcher Integration', () => {
promptId,
);
for await (const _ of generator) {
// Intentionally consume
// consume
}
expect(mockWatcherTool.build).not.toHaveBeenCalled();
@@ -201,35 +201,12 @@ describe('GeminiClient Watcher Integration', () => {
);
vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.DEFAULT);
const mockWatcherTool = {
build: vi.fn().mockReturnValue({
execute: vi.fn().mockResolvedValue({
llmContent: [
{
text: JSON.stringify({
userDirections: 'Keep testing',
progressSummary: 'Test in progress',
evaluation: 'Good',
feedback: 'Keep going',
} as WatcherProgress),
},
],
}),
}),
name: 'watcher',
displayName: 'Watcher',
description: 'Watcher tool',
inputConfig: {
inputName: 'history',
description: 'history',
schema: {},
},
outputConfig: {
outputName: 'report',
description: 'report',
schema: {},
},
};
const mockWatcherTool = createMockWatcherTool({
userDirections: 'Keep testing',
progressSummary: 'Test in progress',
evaluation: 'Good',
feedback: 'Keep going',
});
const mockToolRegistry = {
getFunctionDeclarations: vi.fn().mockReturnValue([]),
@@ -242,7 +219,6 @@ describe('GeminiClient Watcher Integration', () => {
discoverAllTools: vi.fn(),
};
// Use type assertion for testing purposes to access protected members
const clientAccess = client as unknown as {
context: AgentLoopContext;
};