Skip to content
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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] ActiveGoal case is the sole exception among 22+ event handlers that skips flushBufferedStreamEvents(). Every other case (Content, Thought, Citation, HookSystemMessage, StopHookLoop, etc.) calls flush before processing.

When ActiveGoal arrives after content events still in the debounce buffer, the goal-state mutation happens before preceding content is committed to history. Today this is mostly latent (the handler only writes to an in-memory store), but any future change making handleActiveGoalEvent produce a history item or trigger a UI update will immediately cause ordering bugs.

Suggested change
case ServerGeminiEventType.ActiveGoal:
case ServerGeminiEventType.ActiveGoal:
flushBufferedStreamEvents();
handleActiveGoalEvent(event.value);
break;

— qwen-latest-series-invite-beta-v28 via Qwen Code /review

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 () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] All 5 new active_goal tests verify either goal-cleared→null or goal-unchanged→dedup-skipped. No test covers the recordGoalIteration path where the goal is modified mid-turn (new iterations/lastReason), which should emit an updated ActiveGoal event (non-null) through maybeEmitActiveGoalChange.

This is the most common production path (every Stop hook iteration increments iterations), yet it has zero test coverage. Consider adding a test that calls recordGoalIteration(sessionId, 'progress note') inside the messageBus.request callback and asserts the stream contains an ActiveGoal event with the updated iteration count.

— qwen-latest-series-invite-beta-v28 via Qwen Code /review

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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Recursive yield* at L1710 produces duplicate ActiveGoal events in the raw stream. When a Stop hook blocks and triggers continuation, the child sendMessageStream creates a fresh lastEmittedActiveGoal closure and unconditionally emits ActiveGoal(activeGoalAtTurnStart) here — even though the parent already emitted the same value at its own turn start.

The raw stream contains:

ActiveGoal(G)    ← parent turn start
... content ...
ActiveGoal(G)    ← child turn start (DUPLICATE)

The UI handler masks this via activeGoalEquals dedup, but the stream contract ("emit on change") is violated. Any future stream consumer trusting the dedup contract will silently misbehave. No test covers this path.

Fix: Skip the initial emit for hook continuations:

Suggested change
if (activeGoalAtTurnStart) {
const activeGoalAtTurnStart = getActiveGoal(this.config.getSessionId());
if (activeGoalAtTurnStart && options?.type !== SendMessageType.Hook) {
yield {
type: GeminiEventType.ActiveGoal,
value: activeGoalAtTurnStart,
};
}

— qwen-latest-series-invite-beta-v28 via Qwen Code /review

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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] If the Stop hook callback clears the goal during messageBus.request() and signal.aborted is true after the request returns, activeGoalAfterStopHook (this line) is never reached. The active_goal: null event is silently dropped.

activeGoalBeforeStopHook is snapshotted at L1582 before the hook runs. The hook callback can call finishGoal → clearActiveGoal inside messageBus.request(). If the signal is then aborted at the check ~10 lines above this block, we return without emitting the goal-cleared event.

Consider snapshotting activeGoalAfterStopHook immediately after messageBus.request() returns, before the signal.aborted guard, so the change event is emitted even on cancellation.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

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