Skip to content

Support active goal stream events and non-interactive goals#4273

Merged
wenshao merged 9 commits into
QwenLM:mainfrom
qqqys:feat/active-goal-event
May 19, 2026
Merged

Support active goal stream events and non-interactive goals#4273
wenshao merged 9 commits into
QwenLM:mainfrom
qqqys:feat/active-goal-event

Conversation

@qqqys

@qqqys qqqys commented May 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • What changed: Added a first-class active_goal stream event carrying the current active goal snapshot or null, emitted at turn start and when Stop hooks update or clear the goal. The CLI now consumes the event to keep activeGoalStore synchronized. /goal is also available in non-interactive CLI mode and is handled as a submit_prompt slash command.
  • Why it changed: Downstream clients need explicit goal state changes instead of inferring them from Stop hook UI events.
  • Reviewer focus: Event ordering around Stop hook continuations and terminal goal cleanup, plus non-interactive slash-command mode filtering for /goal.

Validation

  • Commands run:
    cd packages/core && npx vitest run src/core/client.test.ts
    cd packages/cli && npx vitest run src/ui/hooks/useGeminiStream.test.tsx
    cd packages/core && npx vitest run src/goals/activeGoalStore.test.ts src/goals/goalHook.test.ts src/core/client.test.ts
    cd packages/cli && npx vitest run src/ui/hooks/useGeminiStream.test.tsx src/ui/components/GoalPill.test.tsx
    cd packages/cli && npx vitest run src/nonInteractiveCliCommands.test.ts src/ui/commands/goalCommand.test.ts
    npm run build && npm run typecheck
    npm run typecheck
    npx prettier --write packages/core/src/core/turn.ts packages/core/src/core/client.ts packages/core/src/core/client.test.ts packages/cli/src/ui/hooks/useGeminiStream.ts packages/cli/src/ui/hooks/useGeminiStream.test.tsx
    npx prettier --write packages/cli/src/ui/commands/goalCommand.ts packages/cli/src/ui/commands/goalCommand.test.ts packages/cli/src/nonInteractiveCliCommands.test.ts
  • Prompts / inputs used: Unit tests simulate an active /goal, Stop hook goal clearing, active_goal / null stream events, and /goal write a hello world script through the non-interactive slash-command handler.
  • Expected result: Active goal state is emitted through the stream, CLI consumers synchronize their local store without adding duplicate UI cards, and non-interactive /goal <condition> registers the goal then submits the instructional prompt.
  • Observed result: All targeted tests passed; full build and typecheck passed. Prettier reported touched files unchanged.
  • Quickest reviewer verification path:
    cd packages/core && npx vitest run src/core/client.test.ts
    cd packages/cli && npx vitest run src/ui/hooks/useGeminiStream.test.tsx src/nonInteractiveCliCommands.test.ts src/ui/commands/goalCommand.test.ts
  • Evidence: src/core/client.test.ts now covers active goal snapshot emission and active_goal: null after Stop hook cleanup; src/ui/hooks/useGeminiStream.test.tsx covers CLI store synchronization; src/nonInteractiveCliCommands.test.ts covers non-interactive /goal execution.

Scope / Risk

  • Main risk or tradeoff: Adds a new stream event type, so any exhaustive stream consumers must handle or ignore active_goal intentionally. Non-interactive /goal now registers a session Stop hook in headless runs.
  • Not covered / not validated: No manual TUI screenshot or video was captured because the change is protocol/store synchronization and non-interactive command handling covered by stream/unit tests.
  • Breaking changes / migration notes: Consumers with exhaustive GeminiEventType switches may need to add an ActiveGoal case.

Testing Matrix

🍏 🪟 🐧
npm run ⚠️ ⚠️
npx ⚠️ ⚠️
Docker N/A N/A N/A
Podman N/A N/A N/A
Seatbelt N/A N/A N/A

Testing matrix notes:

  • Validated locally on macOS with targeted npx vitest commands plus npm run build && npm run typecheck.
  • Windows and Linux were not run locally.

Linked Issues / Bugs

  • N/A

@github-actions

Copy link
Copy Markdown
Contributor

📋 Review Summary

This PR introduces a new active_goal stream event that carries the current active goal snapshot (or null) to keep downstream clients synchronized with goal state changes. The implementation is well-structured with good test coverage, but there are some concerns around event ordering, duplicate emissions, and type safety that should be addressed before merging.

🔍 General Feedback

  • Positive aspects:

    • Clean separation of concerns: core emits events, CLI consumes them
    • Good test coverage for both emission and consumption paths
    • The activeGoalEquals utility is a nice touch to prevent unnecessary emissions
    • Proper handling of both goal activation and clearing scenarios
  • Architectural decisions:

    • Using a stream event for goal state synchronization is appropriate for real-time updates
    • The in-memory store (activeGoalStore.ts) is suitable for session-scoped goal state
    • Event-driven approach allows multiple consumers without tight coupling
  • Recurring themes:

    • Multiple yield points for active_goal events across different code paths
    • Reliance on manual equality checking rather than structural comparison utilities

🎯 Specific Feedback

🔴 Critical

  • File: packages/core/src/core/client.ts:1663-1671 - The active_goal: null emission inside the stopHookBlockingCap warning block may conflict with the later didActiveGoalChange emission at line 1678-1684. If both conditions are true, the consumer will receive duplicate null events. Consider consolidating these emission points.

  • File: packages/core/src/core/client.ts:1678-1690 - The didActiveGoalChange check at line 1678 only guards the emission at 1681-1687, but there's another emission at 1715-1721 that also checks didActiveGoalChange. However, the variable is scoped to the Stop hook block and may not be accessible at line 1715. Verify the scoping is correct (the diff shows both blocks have their own didActiveGoalChange calculation).

🟡 High

  • File: packages/core/src/core/client.ts:1473-1480 - The initial active_goal emission at turn start doesn't check if the goal has changed from the previous turn. If a goal persists across multiple turns, this will emit the same snapshot repeatedly. Consider tracking lastEmittedActiveGoal and only emitting when the goal actually changes, similar to the Stop hook logic.

  • File: packages/core/src/core/client.ts:1581-1684 - The equality check using activeGoalEquals is manual and error-prone. If the ActiveGoal interface gains new fields, this function must be updated. Consider using a structural equality library or JSON serialization for comparison (with appropriate caveats about performance).

  • File: packages/cli/src/ui/hooks/useGeminiStream.ts:1345-1353 - The handleActiveGoalEvent callback doesn't have any deduplication logic. If rapid active_goal events fire with the same value, the store will be updated redundantly. While not harmful, this could trigger unnecessary UI re-renders.

🟢 Medium

  • File: packages/core/src/core/turn.ts:238-242 - The ServerGeminiActiveGoalEvent type uses ActiveGoal | null, which is correct. However, ensure all consumers of ServerGeminiStreamEvent handle this new event type. The PR description mentions this risk, but consider adding a compile-time check or documentation for exhaustive switch statements.

  • File: packages/core/src/core/client.ts:1581-1584 - The activeGoalBeforeStopHook variable is captured before the messageBus request, but the Stop hook could potentially modify the goal during its execution. The current code handles this by reading activeGoalAfterStopHook, but the comment should clarify why both snapshots are needed.

  • File: packages/cli/src/ui/hooks/useGeminiStream.test.tsx:4645-4678 - The test for syncing active_goal events only covers the basic set/clear cycle. Add tests for:

    • Multiple consecutive active_goal events with the same value (deduplication)
    • active_goal events during Stop hook continuations
    • Event ordering when active_goal and StopHookLoop events are interleaved

🔵 Low

  • File: packages/core/src/core/client.ts:21 - The import of getActiveGoal should be grouped with the other imports from activeGoalStore.js if more functions are used. Currently line 21 imports getActiveGoal and line 22 imports activeGoalEquals helper (not shown in diff but implied by usage). Keep related imports together for maintainability.

  • File: packages/core/src/core/turn.ts:37 - The import of ActiveGoal type is added but not used directly in turn.ts (only in type definitions). Consider whether this import is necessary or if inline type annotation would suffice.

  • File: packages/cli/src/ui/hooks/useGeminiStream.ts:29,56-57 - The imports of ActiveGoal, setActiveGoal, and clearActiveGoal should be documented with a comment explaining why the CLI needs to manipulate the core store (i.e., to synchronize stream events with local state).

✅ Highlights

  • Test coverage: The addition of tests in both client.test.ts and useGeminiStream.test.tsx demonstrates thorough validation of the event emission and consumption paths.

  • Equality utility: The activeGoalEquals function shows attention to detail in preventing unnecessary emissions and providing clear semantics for goal comparison.

  • Documentation: The PR description clearly outlines the risk of adding a new stream event type and provides a clear verification path for reviewers.

  • Type safety: The new ServerGeminiActiveGoalEvent type integrates cleanly with the existing event system and maintains TypeScript's exhaustiveness checking.

@wenshao wenshao left a comment

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.

No review findings. Downgraded from Approve to Comment: CI failing: Test (windows-latest, Node 22.x). — gpt-5.5 via Qwen Code /review

@qqqys qqqys changed the title Emit active goal stream events Support active goal stream events and non-interactive goals May 18, 2026

@wenshao wenshao left a comment

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.

No review findings. Downgraded from Approve to Comment: CI failing: Test (windows-latest, Node 22.x). — DeepSeek/deepseek-v4-pro via Qwen Code /review

Comment thread packages/core/src/core/client.ts Outdated
};
}
let lastEmittedActiveGoal: ActiveGoal | undefined = activeGoalAtTurnStart;
const createActiveGoalChangeEvent = (

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] createActiveGoalChangeEvent has hidden mutable state and the outer didActiveGoalChange guards are inconsistent across the three Stop hook paths.

  1. This closure mutates lastEmittedActiveGoal via reference capture (L1475), but its name suggests a pure "create" function — making it easy to call multiple times without realizing the side effect.
  2. The blocking loop path (L1681) and non-blocking path (L1721) wrap calls in if (didActiveGoalChange), which is redundant — the closure already does the same equality check internally via activeGoalEquals. The cap path (L1668) correctly omits the guard.
Suggested change
const createActiveGoalChangeEvent = (
// Tracks the last emitted goal value to suppress duplicate events.
// Mutates `lastEmittedActiveGoal` — call at most once per goal change.
const maybeEmitActiveGoalChange = (
nextActiveGoal: ActiveGoal | undefined,
): ServerGeminiStreamEvent | undefined => {
if (activeGoalEquals(lastEmittedActiveGoal, nextActiveGoal)) {
return undefined;
}
lastEmittedActiveGoal = nextActiveGoal;
return {
type: GeminiEventType.ActiveGoal,
value: nextActiveGoal ?? null,
};
};

Also remove the redundant if (didActiveGoalChange) wrappers on L1681 and L1721 to match the cap path style.

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

Comment thread packages/core/src/core/client.ts Outdated
: 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 finishGoalclearActiveGoal 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

wenshao
wenshao previously approved these changes May 18, 2026

@wenshao wenshao left a comment

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.

No new findings. LGTM! ✅

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

@wenshao wenshao left a comment

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.

补充一个发现(blocking-decision 块内的第二个 signal.aborted 检查未 patch,但因该行不在 diff 范围内无法作为 inline comment 发布):

packages/core/src/core/client.ts L1638 — 第一个 signal.aborted 检查(L1607)正确地通过 maybeEmitActiveGoalChange(activeGoalAfterStopHook) 发射了 goal 变更事件,但 blocking-decision 块内的第二个 signal.aborted 检查直接 return turn,未发射 goal 状态。如果 Stop hook 回调修改了 goal store(如 recordGoalIteration),且 signal 在两次检查之间变为 aborted,UI 将永远不会收到迭代计数变更。窗口极窄(两次检查间无 await),但确实存在。

          if (signal.aborted) {
            const activeGoalEvent = maybeEmitActiveGoalChange(
              activeGoalAfterStopHook,
            );
            if (activeGoalEvent) {
              yield activeGoalEvent;
            }
            if (isTopLevelInteraction) endInteractionSpan('cancelled');
            return turn;
          }

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

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

}

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

let lastEmittedActiveGoal: ActiveGoal | undefined = activeGoalAtTurnStart;
// Tracks the last emitted goal value to suppress duplicate events.
// Mutates `lastEmittedActiveGoal` when an event is returned.
const maybeEmitActiveGoalChange = (

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] Zero observability on the active_goal state-sync path. Neither maybeEmitActiveGoalChange nor handleActiveGoalEvent (in useGeminiStream.ts) produces any debug logging. A stuck or missed goal event is undiagnosable from logs — you'd need a debugger to trace whether core emitted the event, dedup suppressed it, or the CLI handler processed it.

Consider adding debugLogger.debug(...) at emit/suppress/clear decision points in both functions. Even a single line like debugLogger.debug('active_goal emit', { sessionId, value }) would transform this from undiagnosable to trivially traceable.

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


// Check if aborted after hook execution
if (signal.aborted) {
const activeGoalEvent = maybeEmitActiveGoalChange(

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] The pattern const event = maybeEmitActiveGoalChange(...); if (event) { yield event; } is repeated 4 times across exit paths (L1608, L1639, L1691, L1729). Adding a new exit path and forgetting the call would silently leave the CLI with a stale goal view — and because of the missing observability (see L1471 comment), there would be no log to point to the missing emission.

Consider restructuring with a deferred/finally pattern — capture the goal state after the Stop hook, then emit the change in a single place that all paths flow through. This eliminates the class of bug entirely rather than relying on manual discipline across 4+ paths.

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

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

@wenshao wenshao left a comment

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.

No issues found. LGTM! ✅ — qwen-latest-series-invite-beta-v28 via Qwen Code /review

@wenshao

wenshao commented May 19, 2026

Copy link
Copy Markdown
Collaborator

维护者本地真实测试报告

在 worktree(HEAD 71ceb5a8b)跑了完整链路验证:

通过项

验证项 结果
npm ci + 4 工作区 tsc --noEmit clean
目标 vitest(client / activeGoalStore / goalHook / useGeminiStream / GoalPill / nonInteractiveCliCommands / goalCommand / promptHookRunner) 322/322 通过 (8 文件)
qwen --prompt "/goal" 非交互模式 命中 No goal set. Usage: ...,exit 0
qwen --prompt "/goal clear" 命中 No goal set.,exit 0
qwen --prompt "/goal please print hello" 模型识别 目标已收到。 并完成提示,exit 0
qwen --prompt "/goal write a script that prints hi" --output-format stream-json 4 轮 17.6s,实际写出 print_hi.sh,运行输出 hi

兼容性审查

  • 新增 GeminiEventType.ActiveGoal 唯一受影响的穷尽 switch 是 useGeminiStream.ts_exhaustive: never,PR 已加 case。
  • DualOutputBridge.processEvent / BaseJsonOutputAdapter.processEvent / loopDetectionService.addAndCheck 都是 default: break 兜底,新增枚举值零回归风险。
  • activeGoalEquals 用排序键 + JSON 序列化做去重,undefined 处理正确。
  • Stop hook 中断时机的多发射点(signal.aborted × 2、cap warning、StopHookLoop 前、循环末尾)都在 client.ts 加了 maybeEmitActiveGoalChange 调用,由新增 313 行单测覆盖。

风险面

  • 非交互 /goal 注册 session Stop hook,会影响 headless 用户脚本的 stop-hook 计数(新功能,不是回归)。
  • Stream-json 线协议携带 active_goal(设计上仅用于本地 store 同步,与 PR 描述一致)。

LGTM,merge。

@wenshao wenshao merged commit 47f493b into QwenLM:main May 19, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants