Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/cli/src/ui/hooks/useGeminiStream.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ const mockParseAndFormatApiError = vi.hoisted(() =>
);
const mockLogApiCancel = vi.hoisted(() => vi.fn());
const mockGetActiveGoal = vi.hoisted(() => vi.fn());
const mockSetActiveGoal = vi.hoisted(() => vi.fn());
const mockClearActiveGoal = vi.hoisted(() => vi.fn());

vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actualCoreModule = (await importOriginal()) as any;
Expand All @@ -90,6 +92,8 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
parseAndFormatApiError: mockParseAndFormatApiError,
logApiCancel: mockLogApiCancel,
getActiveGoal: mockGetActiveGoal,
setActiveGoal: mockSetActiveGoal,
clearActiveGoal: mockClearActiveGoal,
};
});

Expand Down Expand Up @@ -4638,6 +4642,41 @@ describe('useGeminiStream', () => {
});

describe('StopHookLoop Event', () => {
it('syncs active_goal events into the active goal store', async () => {
const activeGoal = {
condition: 'finish the refactor',
iterations: 1,
setAt: 123,
tokensAtStart: 456,
hookId: 'goal-hook-id',
lastReason: 'still missing verification',
};
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.ActiveGoal,
value: activeGoal,
};
yield {
type: ServerGeminiEventType.ActiveGoal,
value: null,
};
})(),
);

const { result } = renderTestHook();

await act(async () => {
await result.current.submitQuery('continue goal');
});

expect(mockSetActiveGoal).toHaveBeenCalledWith(
'test-session-id',
activeGoal,
);
expect(mockClearActiveGoal).toHaveBeenCalledWith('test-session-id');
});

it('should handle StopHookLoop event and add stop hook loop history item', async () => {
mockSendMessageStream.mockReturnValue(
(async function* () {
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/ui/hooks/useGeminiStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
ToolCallRequestInfo,
GeminiErrorEventValue,
StopFailureErrorType,
ActiveGoal,
} from '@qwen-code/qwen-code-core';
import {
GeminiEventType as ServerGeminiEventType,
Expand All @@ -52,6 +53,8 @@ import {
getUnsupportedImageFormatWarning,
generateToolUseSummary,
getActiveGoal,
setActiveGoal,
clearActiveGoal,
} from '@qwen-code/qwen-code-core';
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
import type {
Expand Down Expand Up @@ -1339,6 +1342,18 @@ export const useGeminiStream = (
[addItem, config, pendingHistoryItemRef, setPendingHistoryItem],
);

const handleActiveGoalEvent = useCallback(
(activeGoal: ActiveGoal | null) => {
const sessionId = config.getSessionId();
if (activeGoal) {
setActiveGoal(sessionId, activeGoal);
return;
}
clearActiveGoal(sessionId);
},
[config],
);

const processGeminiStreamEvents = useCallback(
async (
stream: AsyncIterable<GeminiEvent>,
Expand Down Expand Up @@ -1573,6 +1588,9 @@ export const useGeminiStream = (
flushBufferedStreamEvents();
handleStopHookLoopEvent(event.value, userMessageTimestamp);
break;
case ServerGeminiEventType.ActiveGoal:
handleActiveGoalEvent(event.value);
break;
default: {
// enforces exhaustive switch-case
const unreachable: never = event;
Expand Down Expand Up @@ -1609,6 +1627,7 @@ export const useGeminiStream = (
setPendingHistoryItem,
handleUserPromptSubmitBlockedEvent,
handleStopHookLoopEvent,
handleActiveGoalEvent,
addItem,
dualOutput,
],
Expand Down
89 changes: 89 additions & 0 deletions packages/core/src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ import { promptIdContext } from '../utils/promptIdContext.js';
import { setSimulate429 } from '../utils/testUtils.js';
import { ideContextStore } from '../ide/ideContext.js';
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
import {
__resetActiveGoalStoreForTests,
clearActiveGoal,
setActiveGoal,
} from '../goals/activeGoalStore.js';

// Mock fs module to prevent actual file system operations during tests
const mockFileSystem = new Map<string, string>();
Expand Down Expand Up @@ -514,6 +519,7 @@ describe('Gemini Client (client.ts)', () => {

afterEach(() => {
vi.restoreAllMocks();
__resetActiveGoalStoreForTests();
});

describe('initialize', () => {
Expand Down Expand Up @@ -4501,6 +4507,89 @@ Other open files:
client['chat'] = mockChat as GeminiChat;
});

it('emits active_goal when a goal is active for the turn', async () => {
setActiveGoal('test-session-id', {
condition: 'finish the refactor',
iterations: 2,
setAt: 123,
tokensAtStart: 456,
hookId: 'goal-hook-id',
lastReason: 'still missing verification',
});

const events = await fromAsync(
client.sendMessageStream(
[{ text: 'Hi' }],
new AbortController().signal,
'prompt-active-goal',
),
);

expect(events[0]).toEqual({
type: GeminiEventType.ActiveGoal,
value: {
condition: 'finish the refactor',
iterations: 2,
setAt: 123,
tokensAtStart: 456,
hookId: 'goal-hook-id',
lastReason: 'still missing verification',
},
});
});

it('emits active_goal null when the Stop hook clears the goal', async () => {
setActiveGoal('test-session-id', {
condition: 'finish the refactor',
iterations: 2,
setAt: 123,
tokensAtStart: 456,
hookId: 'goal-hook-id',
lastReason: 'still missing verification',
});
const mockMessageBus = {
request: vi.fn().mockImplementation(async () => {
clearActiveGoal('test-session-id');
return {};
}),
response: vi.fn(),
};
vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(false);
vi.mocked(mockConfig.getMessageBus).mockReturnValue(
mockMessageBus as unknown as ReturnType<Config['getMessageBus']>,
);
vi.mocked(mockConfig.hasHooksForEvent).mockImplementation(
(event: string) => event === 'Stop',
);
client['chat'] = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([
{
role: 'model',
parts: [{ text: 'done' }],
},
]),
} as unknown as GeminiChat;
mockTurnRunFn.mockReturnValue(
(async function* () {
yield { type: GeminiEventType.Content, value: 'done' };
})(),
);

const events = await fromAsync(
client.sendMessageStream(
[{ text: 'Hi' }],
new AbortController().signal,
'prompt-cleared-active-goal',
),
);

expect(events).toContainEqual({
type: GeminiEventType.ActiveGoal,
value: null,
});
});

it('should skip messageBus.request for UserPromptSubmit when hasHooksForEvent returns false', async () => {
// Enable hooks and provide messageBus
const mockMessageBus = {
Expand Down
56 changes: 55 additions & 1 deletion packages/core/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,28 @@ import { ApprovalMode, type Config } from '../config/config.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { recordStartupEvent } from '../utils/startupEventSink.js';
import { microcompactHistory } from '../services/microcompaction/microcompact.js';
import { getActiveGoal } from '../goals/activeGoalStore.js';
import { getActiveGoal, type ActiveGoal } from '../goals/activeGoalStore.js';
import { abortGoalForStopHookCap } from '../goals/goalHook.js';
import { formatStopHookBlockingCapWarning } from '../hooks/stopHookCap.js';

const debugLogger = createDebugLogger('CLIENT');

function activeGoalEquals(
left: ActiveGoal | undefined,
right: ActiveGoal | undefined,
): boolean {
if (left === right) return true;
if (!left || !right) return false;
return (
left.condition === right.condition &&
left.iterations === right.iterations &&
left.setAt === right.setAt &&
left.tokensAtStart === right.tokensAtStart &&
left.hookId === right.hookId &&
left.lastReason === right.lastReason
);
}

// Core modules
import { GeminiChat } from './geminiChat.js';
import {
Expand Down Expand Up @@ -1454,6 +1470,14 @@ export class GeminiClient {
requestToSend = [...systemReminders, ...requestToSend];
}

const activeGoalAtTurnStart = getActiveGoal(this.config.getSessionId());
if (activeGoalAtTurnStart) {
yield {
type: GeminiEventType.ActiveGoal,
value: activeGoalAtTurnStart,
};
}

const resultStream = turn.run(model, requestToSend, signal);
let didUpdateIdeContextState = false;
for await (const event of resultStream) {
Expand Down Expand Up @@ -1554,6 +1578,9 @@ export class GeminiClient {
.map((p) => p.text)
.join('') || '[no response text]';

const activeGoalBeforeStopHook = getActiveGoal(
this.config.getSessionId(),
);
const response = await messageBus.request<
HookExecutionRequest,
HookExecutionResponse
Expand Down Expand Up @@ -1581,6 +1608,13 @@ export class GeminiClient {
: undefined;

const stopOutput = hookOutput as StopHookOutput | undefined;
const activeGoalAfterStopHook = getActiveGoal(
this.config.getSessionId(),
);
const didActiveGoalChange = !activeGoalEquals(
activeGoalBeforeStopHook,
activeGoalAfterStopHook,
);

// This should happen regardless of the hook's decision
if (stopOutput?.systemMessage) {
Expand Down Expand Up @@ -1626,6 +1660,12 @@ export class GeminiClient {
this.config.getSessionId(),
warning,
);
if (activeGoalBeforeStopHook || activeGoalAfterStopHook) {
yield {
type: GeminiEventType.ActiveGoal,
value: null,
};
}
yield {
type: GeminiEventType.HookSystemMessage,
value: warning,
Expand All @@ -1635,6 +1675,13 @@ export class GeminiClient {
return turn;
}

if (didActiveGoalChange) {
yield {
type: GeminiEventType.ActiveGoal,
value: activeGoalAfterStopHook ?? null,
};
}

yield {
type: GeminiEventType.StopHookLoop,
value: {
Expand Down Expand Up @@ -1665,6 +1712,13 @@ export class GeminiClient {
endInteractionSpan(signal.aborted ? 'cancelled' : 'ok');
return hookTurn;
}

if (didActiveGoalChange) {
yield {
type: GeminiEventType.ActiveGoal,
value: activeGoalAfterStopHook ?? null,
};
}
}

if (!turn.pendingToolCalls.length && signal && !signal.aborted) {
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/core/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
type ThoughtSummary,
} from '../utils/thoughtUtils.js';
import type { LoopType } from '../telemetry/types.js';
import type { ActiveGoal } from '../goals/activeGoalStore.js';

// Define a structure for tools passed to the server
export interface ServerTool {
Expand Down Expand Up @@ -64,6 +65,7 @@ export enum GeminiEventType {
HookSystemMessage = 'hook_system_message',
UserPromptSubmitBlocked = 'user_prompt_submit_blocked',
StopHookLoop = 'stop_hook_loop',
ActiveGoal = 'active_goal',
}

export type ServerGeminiRetryEvent = {
Expand Down Expand Up @@ -233,8 +235,14 @@ export type ServerGeminiStopHookLoopEvent = {
};
};

export type ServerGeminiActiveGoalEvent = {
type: GeminiEventType.ActiveGoal;
value: ActiveGoal | null;
};

// The original union type, now composed of the individual types
export type ServerGeminiStreamEvent =
| ServerGeminiActiveGoalEvent
| ServerGeminiChatCompressedEvent
| ServerGeminiCitationEvent
| ServerGeminiContentEvent
Expand Down
Loading