Skip to content

Commit 5dae4a3

Browse files
authored
🤖 fix: keep optimistic goal visible during streaming (#3602)
## Summary Fixes a transient UI bug where a goal set **while the agent is streaming** would briefly disappear from the Goal tab (it appears, vanishes, then comes back). ## Background When a goal is set mid-stream, `WorkspaceGoalService` intentionally defers the durable `goal.json` write until stream-end and holds the new goal as **optimistic, transient state** (`pendingGoalSnapshots`, surfaced to the renderer as a `pendingPersistence` goal). `goal.json` (and the persisted activity metadata) keep the **pre-stream** goal. The problem: the live stream keeps emitting *full* workspace activity snapshots — `status_set`, `todo_write`/`propose_plan`, recency bumps, streaming-status updates — each built from persisted metadata, so each still carries the stale pre-stream goal. In the renderer those non-transient snapshots fully replace the in-memory activity entry, dropping the optimistic goal. It only reappeared on the next goal read or at stream-end, producing the flicker: ``` set goal G -> transient overlay shows G (visible) status_set -> full snapshot carries old goal D (G vanishes) get_goal -> transient overlay re-emits G (G reappears) ``` ## Implementation The fix lives at the single activity-emit choke point, `WorkspaceService.emitWorkspaceActivity`. Before emitting, it overlays the goal service's pending optimistic snapshot (via the new `WorkspaceGoalService.getPendingGoalSnapshot`) onto the activity snapshot's `goal` field, so every mid-stream emit surfaces the optimistic goal instead of replaying the stale one. This is robust because the goal service owns the pending state and clears it **before** emitting authoritative transitions: - **Transient goal pushes** already carry the optimistic goal (`transientGoalOnly`) and are skipped by the overlay. - **User abort** (`recordUserStoppedStream`) deletes the pending snapshot *before* pushing the reverted goal, so the overlay no longer fires and the discarded goal correctly disappears. - **Stream-end persistence** (`applyPendingAfterStreamEnd`) likewise clears the pending snapshot before pushing the durable goal. A frontend-only guard was considered but rejected: the renderer cannot distinguish a stale mid-stream emit from an authoritative abort-revert (both carry the persisted goal and can arrive while `streaming` is still `true`), so any renderer heuristic would wrongly pin a discarded goal after an Esc/abort. Anchoring the fix in the goal-state owner avoids that ambiguity. ## Validation - New `WorkspaceService` test drives the real path: queue a goal mid-stream, fire a `status_set`-style activity emit, and assert the emitted snapshot surfaces the optimistic (`pendingPersistence`) goal — then `recordUserStoppedStream` and assert a subsequent emit reverts to the persisted goal with no lingering optimistic state. - `make static-check` and the `workspaceService` / `workspaceGoalService` / `WorkspaceStore` suites pass. ## Risks Low. The overlay is a narrow read-only lookup at emit time that only changes behavior while a goal mutation is queued (mid-stream). Authoritative goal emits are untouched, and the active-goal count already excludes pending goals. The main area to watch is goal-tab/sidebar display during streaming, which the new test exercises end-to-end including the abort path. --- _Generated with `mux` • Model: `anthropic:claude-opus-4-8` • Thinking: `xhigh` • Cost: `n/a`_ <!-- mux-attribution: model=anthropic:claude-opus-4-8 thinking=xhigh costs=n/a -->
1 parent 9dcfc83 commit 5dae4a3

3 files changed

Lines changed: 154 additions & 2 deletions

File tree

src/node/services/workspaceGoalService.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,24 @@ export class WorkspaceGoalService {
439439
this.onActivityChange = listener;
440440
}
441441

442+
/**
443+
* The optimistic goal published while a goal is set mid-stream, before
444+
* stream-end persistence writes goal.json. Returns null when no mutation is
445+
* queued for the workspace.
446+
*
447+
* Consumed by `WorkspaceService.emitWorkspaceActivity` to overlay the
448+
* optimistic goal onto activity snapshots that are built from (still
449+
* pre-stream) persisted metadata — e.g. `status_set`/`todo_write`/recency
450+
* emits during the same stream. Without the overlay those snapshots replay
451+
* the stale pre-stream goal and the Goal tab flickers back to it until the
452+
* next goal read re-emits the optimistic one. This service clears the pending
453+
* snapshot before emitting authoritative reverts (abort) or durable
454+
* persistence (stream-end), so those transitions naturally win.
455+
*/
456+
getPendingGoalSnapshot(workspaceId: string): GoalSnapshot | null {
457+
return this.pendingGoalSnapshots.get(workspaceId) ?? null;
458+
}
459+
442460
setStreamInterrupter(interrupter: (workspaceId: string) => Promise<void>): void {
443461
this.streamInterrupter = interrupter;
444462
}

src/node/services/workspaceService.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1624,6 +1624,99 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => {
16241624
}
16251625
});
16261626

1627+
// A goal set mid-stream is held as optimistic state until stream-end
1628+
// persistence, so goal.json keeps the pre-stream goal. Non-goal activity
1629+
// emits (status_set/todo_write/recency) read that persisted goal and, before
1630+
// this overlay, replaced the activity snapshot with the stale goal — the Goal
1631+
// tab flickered back to the old goal until the next goal read. The overlay
1632+
// keeps the optimistic goal visible, and clears once the goal service drops
1633+
// the pending mutation (abort / stream-end).
1634+
test("mid-stream activity emits surface the optimistic goal, then revert on user abort", async () => {
1635+
const aiEmitter = new EventEmitter();
1636+
const aiService = Object.assign(aiEmitter, {
1637+
isStreaming: mock(() => false),
1638+
}) as unknown as AIService;
1639+
const { config, workspaceService, goalService, cleanup } = await createServices(aiService);
1640+
const workspaceId = "midstream-goal-overlay";
1641+
try {
1642+
await config.addWorkspace("/tmp/midstream-goal-overlay-project", {
1643+
id: workspaceId,
1644+
name: workspaceId,
1645+
projectName: "project",
1646+
projectPath: "/tmp/midstream-goal-overlay-project",
1647+
runtimeConfig: { type: "local" },
1648+
});
1649+
1650+
const created = await setWorkspaceGoalOk(goalService, {
1651+
workspaceId,
1652+
objective: "Pre-stream goal",
1653+
});
1654+
1655+
// Queue a goal set mid-stream (publishes an optimistic, pendingPersistence
1656+
// snapshot without persisting goal.json).
1657+
const goalServiceAccess = goalService as unknown as {
1658+
isWorkspaceStreaming: (workspaceId: string) => Promise<boolean>;
1659+
};
1660+
const isStreamingOriginal = goalServiceAccess.isWorkspaceStreaming;
1661+
goalServiceAccess.isWorkspaceStreaming = () => Promise.resolve(true);
1662+
try {
1663+
const queued = await goalService.setGoal({
1664+
workspaceId,
1665+
objective: "Optimistic mid-stream goal",
1666+
expectedGoalId: created.goalId,
1667+
});
1668+
expect(queued.success).toBe(true);
1669+
} finally {
1670+
goalServiceAccess.isWorkspaceStreaming = isStreamingOriginal;
1671+
}
1672+
1673+
// The durable goal.json still holds the pre-stream goal.
1674+
expect((await goalService.getGoal(workspaceId))?.objective).toBe("Pre-stream goal");
1675+
1676+
const activityEvents: Array<{
1677+
workspaceId: string;
1678+
activity: WorkspaceActivitySnapshot | null;
1679+
}> = [];
1680+
const listener = (event: {
1681+
workspaceId: string;
1682+
activity: WorkspaceActivitySnapshot | null;
1683+
}) => activityEvents.push(event);
1684+
workspaceService.on("activity", listener);
1685+
try {
1686+
// A non-goal activity emit reads persisted metadata (still the pre-stream
1687+
// goal) but must surface the optimistic goal so the Goal tab is stable.
1688+
await workspaceService.updateAgentStatus(workspaceId, { emoji: "🛠️", message: "Working" });
1689+
expect(activityEvents.at(-1)?.activity?.goal).toMatchObject({
1690+
objective: "Optimistic mid-stream goal",
1691+
pendingPersistence: true,
1692+
});
1693+
1694+
// The bootstrap path (renderer reconnect/reload) builds straight from
1695+
// persisted metadata, so it must apply the same overlay.
1696+
const listed = await workspaceService.getActivityList();
1697+
expect(listed[workspaceId]?.goal).toMatchObject({
1698+
objective: "Optimistic mid-stream goal",
1699+
pendingPersistence: true,
1700+
});
1701+
1702+
// User aborts: the goal service drops the queued mutation and reverts the
1703+
// panel to the persisted goal. Subsequent activity emits must show that
1704+
// reverted goal, not the discarded optimistic one.
1705+
await goalService.recordUserStoppedStream(workspaceId);
1706+
await workspaceService.updateAgentStatus(workspaceId, { emoji: "💤", message: "Idle" });
1707+
expect(activityEvents.at(-1)?.activity?.goal).toMatchObject({
1708+
goalId: created.goalId,
1709+
objective: "Pre-stream goal",
1710+
});
1711+
expect(activityEvents.at(-1)?.activity?.goal?.pendingPersistence).toBeUndefined();
1712+
} finally {
1713+
workspaceService.off("activity", listener);
1714+
}
1715+
} finally {
1716+
await cleanup();
1717+
}
1718+
});
1719+
16271720
test("WorkspaceService stream-abort listener leaves queued goal mutations for AgentSession", async () => {
16281721
// Non-user abort goal mutation drains happen in AgentSession after abort
16291722
// accounting. WorkspaceService must not drain here, or the aborted

src/node/services/workspaceService.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2076,10 +2076,42 @@ export class WorkspaceService extends EventEmitter {
20762076
): void {
20772077
this.emit("activity", {
20782078
workspaceId,
2079-
activity: this.mergeCachedActiveWorkflowRunCount(workspaceId, snapshot),
2079+
activity: this.mergeCachedActiveWorkflowRunCount(
2080+
workspaceId,
2081+
this.overlayPendingGoal(workspaceId, snapshot)
2082+
),
20802083
});
20812084
}
20822085

2086+
/**
2087+
* Overlay the optimistic mid-stream goal onto an activity snapshot.
2088+
*
2089+
* A goal set while the agent is streaming is held as optimistic state in the
2090+
* goal service until stream-end persistence; goal.json keeps the pre-stream
2091+
* goal. Activity snapshots built from persisted metadata (status_set,
2092+
* todo_write, recency, streaming) therefore still carry the pre-stream goal,
2093+
* and emitting them as-is makes the Goal tab flicker back to the stale goal
2094+
* mid-stream until the next goal read re-emits the optimistic one. Overlaying
2095+
* the pending snapshot here keeps the displayed goal stable across those
2096+
* emits. Authoritative goal emits authored by the goal service are left
2097+
* untouched: transient goal pushes already carry the optimistic goal
2098+
* (`transientGoalOnly`), and abort reverts / durable persistence clear the
2099+
* pending snapshot before emitting, so they win naturally.
2100+
*/
2101+
private overlayPendingGoal(
2102+
workspaceId: string,
2103+
snapshot: WorkspaceActivitySnapshot | null
2104+
): WorkspaceActivitySnapshot | null {
2105+
if (!snapshot || snapshot.transientGoalOnly === true) {
2106+
return snapshot;
2107+
}
2108+
const pending = this.workspaceGoalService?.getPendingGoalSnapshot(workspaceId);
2109+
if (!pending) {
2110+
return snapshot;
2111+
}
2112+
return { ...snapshot, goal: pending };
2113+
}
2114+
20832115
private async emitWorkspaceActivityUpdate(
20842116
workspaceId: string,
20852117
description: string,
@@ -7960,7 +7992,16 @@ export class WorkspaceService extends EventEmitter {
79607992
}
79617993
return [
79627994
workspaceId,
7963-
mergeActiveWorkflowRunCount(snapshot, activeWorkflowRunCount),
7995+
// Overlay the optimistic mid-stream goal here too: the renderer
7996+
// bootstraps via this list (the subscription does not replay
7997+
// historical snapshots), and `getAllSnapshots()` returns the
7998+
// still-pre-stream persisted goal. Without this, a reconnect/reload
7999+
// during a mid-stream goal set would seed the UI with the stale
8000+
// goal until the next live emit or goal read.
8001+
mergeActiveWorkflowRunCount(
8002+
this.overlayPendingGoal(workspaceId, snapshot),
8003+
activeWorkflowRunCount
8004+
),
79648005
] as const;
79658006
}
79668007
)

0 commit comments

Comments
 (0)