Skip to content

Commit 3cda045

Browse files
committed
fix(agent-core-v2): clear the turn outcome when an undo rewinds the turn it describes
Undoing an interrupted turn left its outcome ('manually stopped', failed, completed) visible in the activity view and in the persisted session metadata. Clear the tracked outcome whenever an undo rewinds to or past the turn it describes, keep it when only later turns are rewound, and reconcile the persisted outcome against the replayed wire on restore.
1 parent 7040eed commit 3cda045

7 files changed

Lines changed: 266 additions & 21 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Fix the "manually stopped" state lingering after undoing the interrupted turn.

‎packages/agent-core-v2/src/agent/activityView/activityViewService.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types';
3434
import type { PromptOrigin } from '#/agent/contextMemory/types';
3535
import type { TurnEndReason } from '#/agent/loop/turnEvents';
3636
import { IEventDispatcher } from '#/state/eventDispatcher';
37+
import { ContextUndone } from '#/agent/undo/undoService';
3738

3839
import type {
3940
ActivityLastTurnState,
@@ -140,6 +141,9 @@ export class AgentActivityView extends Disposable implements IAgentActivityView
140141
this._register(
141142
this.eventBus.subscribe(TurnEnded, (e) => this.onTurnEnded(e.turnId, e.reason)),
142143
);
144+
this._register(
145+
this.eventBus.subscribe(ContextUndone, (e) => this.onContextUndone(e.fromTurnId)),
146+
);
143147
this._register(
144148
this.eventBus.subscribe(PermissionApprovalRequested, (e) =>
145149
this.onApprovalRequested(e.id ?? e.toolCallId, e.toolCallId),
@@ -296,6 +300,14 @@ export class AgentActivityView extends Disposable implements IAgentActivityView
296300
this.publish();
297301
}
298302

303+
private onContextUndone(fromTurnId: number | undefined): void {
304+
const last = this.lastTurn;
305+
if (last === undefined) return;
306+
if (fromTurnId !== undefined && last.turnId < fromTurnId) return;
307+
this.lastTurn = undefined;
308+
this.publish();
309+
}
310+
299311
private onStepStarted(step: number): void {
300312
this.mutateTurn((t) => {
301313
t.step = step;

‎packages/agent-core-v2/src/agent/loop/turnOps.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,19 @@ export const turnKey = defineState(
139139
return { ...next, anchorTurnIds: [...s.anchorTurnIds, s.nextTurnId] };
140140
})
141141
.on(TurnSteer, () => {})
142-
.on(ContextUndo, (s, e) => ({
143-
...s,
144-
anchorTurnIds: s.anchorTurnIds.slice(0, Math.max(0, s.anchorTurnIds.length - e.count)),
145-
}))
142+
.on(ContextUndo, (s, e) => {
143+
const firstRemoved = s.anchorTurnIds[s.anchorTurnIds.length - e.count];
144+
const lastEnded = s.lastEnded;
145+
return {
146+
...s,
147+
anchorTurnIds: s.anchorTurnIds.slice(0, Math.max(0, s.anchorTurnIds.length - e.count)),
148+
lastEnded:
149+
lastEnded !== undefined &&
150+
(firstRemoved === undefined || lastEnded.turnId >= firstRemoved)
151+
? undefined
152+
: lastEnded,
153+
};
154+
})
146155
.on(ContextApplyCompaction, (s) => ({ ...s, anchorTurnIds: [] }))
147156
.on(ContextClear, (s) => ({ ...s, anchorTurnIds: [] }))
148157
.on(TurnCancel, (s, e) => {

‎packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts‎

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { LifecycleScope } from '#/app/scopes';
44
import { IEventBus } from '#/app/event/eventBus';
55
import { AgentActivityUpdated } from '#/agent/activityView/activityView';
66
import { TurnStarted } from '#/agent/loop/turnEvents';
7-
import { TurnEnded } from '#/agent/loop/turnOps';
7+
import { TurnEnded, turnKey } from '#/agent/loop/turnOps';
8+
import { ContextUndone } from '#/agent/undo/undoService';
9+
import { IAgentStateService } from '#/agent/state/agentState';
10+
import { IEventDispatcher } from '#/state/eventDispatcher';
811
import {
912
IAgentLifecycleService,
1013
MAIN_AGENT_ID,
@@ -18,16 +21,18 @@ export class SessionOutcomeMirror extends Disposable implements ISessionOutcomeM
1821
declare readonly _serviceBrand: undefined;
1922

2023
private lastPersisted: SessionTurnOutcome | undefined;
24+
private lastPersistedTurnId: number | undefined;
2125
private adopted = false;
2226
private turnStartedHere = false;
2327
private mainSubscription: DisposableStore | undefined;
28+
private readonly metadataReady: Promise<void>;
2429

2530
constructor(
2631
@IAgentLifecycleService private readonly agents: IAgentLifecycleService,
2732
@ISessionMetadata private readonly metadata: ISessionMetadata,
2833
) {
2934
super();
30-
void this.metadata
35+
this.metadataReady = this.metadata
3136
.read()
3237
.then((meta) => {
3338
if (!this.adopted) this.lastPersisted = meta.lastTurnReason;
@@ -52,24 +57,33 @@ export class SessionOutcomeMirror extends Disposable implements ISessionOutcomeM
5257

5358
private attachMain(): void {
5459
if (this.mainSubscription !== undefined) return;
55-
const bus = this.agents.handleOf(MAIN_AGENT_ID)?.accessor.get(IEventBus) as
56-
| IEventBus
57-
| undefined;
60+
const handle = this.agents.handleOf(MAIN_AGENT_ID);
61+
const bus = handle?.accessor.get(IEventBus) as IEventBus | undefined;
5862
if (bus === undefined) return;
5963
const subscription = new DisposableStore();
6064
this.mainSubscription = subscription;
65+
const dispatcher = handle?.accessor.get(IEventDispatcher) as IEventDispatcher | undefined;
66+
const agentStates = handle?.accessor.get(IAgentStateService) as IAgentStateService | undefined;
67+
if (dispatcher !== undefined && agentStates !== undefined) {
68+
subscription.add(
69+
dispatcher.hooks.onDidRestore.register('session-outcome-mirror', async (_ctx, next) => {
70+
await next();
71+
await this.reconcileAfterRestore(agentStates);
72+
}),
73+
);
74+
}
6175
subscription.add(
6276
bus.subscribe(TurnEnded, (event) => {
6377
if (event.reason === 'completed') {
64-
this.write('completed');
78+
this.write('completed', { turnId: event.turnId });
6579
return;
6680
}
6781
if (event.reason === 'failed' || event.reason === 'blocked') {
68-
this.write('failed');
82+
this.write('failed', { turnId: event.turnId });
6983
return;
7084
}
7185
if (event.reason === 'cancelled' && event.interruptReason === 'user_cancelled') {
72-
this.write('cancelled');
86+
this.write('cancelled', { turnId: event.turnId });
7387
}
7488
}),
7589
);
@@ -79,31 +93,67 @@ export class SessionOutcomeMirror extends Disposable implements ISessionOutcomeM
7993
this.write(undefined);
8094
}),
8195
);
96+
subscription.add(
97+
bus.subscribe(ContextUndone, (event) => {
98+
if (
99+
event.fromTurnId !== undefined &&
100+
this.lastPersistedTurnId !== undefined &&
101+
this.lastPersistedTurnId < event.fromTurnId
102+
) {
103+
return;
104+
}
105+
this.write(undefined);
106+
}),
107+
);
82108
subscription.add(
83109
bus.subscribe(AgentActivityUpdated, (event) => {
84110
if (this.turnStartedHere) return;
85111
if (this.lastPersisted !== undefined) return;
86112
const reason = event.lastTurn?.reason;
87113
if (reason === 'completed' || reason === 'cancelled') {
88-
this.write(reason, { touchUpdatedAt: false });
114+
this.write(reason, { touchUpdatedAt: false, turnId: event.lastTurn?.turnId });
89115
} else if (reason === 'failed' || reason === 'blocked') {
90-
this.write('failed', { touchUpdatedAt: false });
116+
this.write('failed', { touchUpdatedAt: false, turnId: event.lastTurn?.turnId });
91117
}
92118
}),
93119
);
94120
}
95121

122+
private async reconcileAfterRestore(agentStates: IAgentStateService): Promise<void> {
123+
await this.metadataReady;
124+
if (this.lastPersisted === undefined) return;
125+
if (this.turnStartedHere) return;
126+
if (!agentStates.has(turnKey)) return;
127+
const lastEnded = agentStates.get(turnKey).lastEnded;
128+
if (lastEnded === undefined) {
129+
this.write(undefined, { touchUpdatedAt: false });
130+
return;
131+
}
132+
if (this.lastPersistedTurnId === undefined) this.lastPersistedTurnId = lastEnded.turnId;
133+
}
134+
96135
private write(
97136
outcome: SessionTurnOutcome | undefined,
98-
opts?: { readonly touchUpdatedAt?: boolean },
137+
opts?: { readonly touchUpdatedAt?: boolean; readonly turnId?: number },
99138
): void {
100-
if (outcome === this.lastPersisted) return;
139+
if (outcome === this.lastPersisted) {
140+
if (opts?.turnId !== undefined) this.lastPersistedTurnId = opts.turnId;
141+
return;
142+
}
101143
this.adopted = true;
102144
const previous = this.lastPersisted;
145+
const previousTurnId = this.lastPersistedTurnId;
103146
this.lastPersisted = outcome;
104-
void this.metadata.update({ lastTurnReason: outcome }, opts).catch(() => {
105-
if (this.lastPersisted === outcome) this.lastPersisted = previous;
106-
});
147+
this.lastPersistedTurnId =
148+
outcome === undefined ? undefined : (opts?.turnId ?? this.lastPersistedTurnId);
149+
void this.metadata
150+
.update({ lastTurnReason: outcome }, { touchUpdatedAt: opts?.touchUpdatedAt })
151+
.catch(() => {
152+
if (this.lastPersisted === outcome) {
153+
this.lastPersisted = previous;
154+
this.lastPersistedTurnId = previousTurnId;
155+
}
156+
});
107157
}
108158
}
109159

‎packages/agent-core-v2/test/agent/activityView/activityView.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
} from '#/agent/toolApproval/toolApprovalService';
2727
import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction';
2828
import type { FullCompactionTask } from '#/agent/fullCompaction/fullCompaction';
29+
import { ContextUndone } from '#/agent/undo/undoService';
2930
import { OrderedHookSlot } from '#/hooks';
3031
import { IEventDispatcher } from '#/state/eventDispatcher';
3132
import { stubAgentContext } from '../agentContext/stubs';
@@ -234,6 +235,37 @@ describe('AgentActivityView', () => {
234235
expect(view.state().lastTurn).toMatchObject({ turnId: 2, reason: 'completed' });
235236
});
236237

238+
it('clears the last outcome when an undo rewinds the turn it describes', () => {
239+
const { bus, view } = harness();
240+
241+
bus.publish(new TurnStarted({ agentId: 'main', turnId: 1, origin: { kind: 'user' } }));
242+
bus.publish(new TurnEnded({ agentId: 'main', turnId: 1, reason: 'cancelled' }));
243+
expect(view.state().lastTurn).toMatchObject({ turnId: 1, reason: 'cancelled' });
244+
245+
bus.publish(new ContextUndone({ agentId: 'main', turns: 1, fromTurnId: 1 }));
246+
expect(view.state().lastTurn).toBeUndefined();
247+
});
248+
249+
it('keeps the last outcome when an undo rewinds only later turns', () => {
250+
const { bus, view } = harness();
251+
252+
bus.publish(new TurnStarted({ agentId: 'main', turnId: 1, origin: { kind: 'user' } }));
253+
bus.publish(new TurnEnded({ agentId: 'main', turnId: 1, reason: 'completed' }));
254+
255+
bus.publish(new ContextUndone({ agentId: 'main', turns: 1, fromTurnId: 2 }));
256+
expect(view.state().lastTurn).toMatchObject({ turnId: 1, reason: 'completed' });
257+
});
258+
259+
it('clears the last outcome when the undo range cannot be determined', () => {
260+
const { bus, view } = harness();
261+
262+
bus.publish(new TurnStarted({ agentId: 'main', turnId: 1, origin: { kind: 'user' } }));
263+
bus.publish(new TurnEnded({ agentId: 'main', turnId: 1, reason: 'failed' }));
264+
265+
bus.publish(new ContextUndone({ agentId: 'main', turns: 1 }));
266+
expect(view.state().lastTurn).toBeUndefined();
267+
});
268+
237269
it('exposes the engine-minted interaction id as the approval id', () => {
238270
const { bus, view } = harness();
239271

‎packages/agent-core-v2/test/agent/loop/turnOps.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,36 @@ describe('turnKey lastEnded', () => {
6767
expect(s.lastEnded?.reason).toBe('completed');
6868
});
6969

70+
it('clears the stored outcome when an undo rewinds the turn it describes', () => {
71+
let s = turnKey.initial();
72+
s = fold(s, new TurnPrompt({ agentId: 'main', input: [], origin: { kind: 'user' } }));
73+
s = fold(s, new TurnEnded({ agentId: 'main', turnId: 0, reason: 'completed', durationMs: 10 }));
74+
s = fold(s, new TurnPrompt({ agentId: 'main', input: [], origin: { kind: 'user' } }));
75+
s = fold(s, new TurnEnded({ agentId: 'main', turnId: 1, reason: 'cancelled', durationMs: 10 }));
76+
expect(s.lastEnded?.reason).toBe('cancelled');
77+
s = fold(s, new ContextUndo({ agentId: 'main', count: 1 }));
78+
expect(s.anchorTurnIds).toEqual([0]);
79+
expect(s.lastEnded).toBeUndefined();
80+
});
81+
82+
it('keeps the stored outcome when an undo rewinds only later turns', () => {
83+
let s = turnKey.initial();
84+
s = fold(s, new TurnPrompt({ agentId: 'main', input: [], origin: { kind: 'user' } }));
85+
s = fold(s, new TurnEnded({ agentId: 'main', turnId: 0, reason: 'completed', durationMs: 10 }));
86+
s = fold(s, new TurnPrompt({ agentId: 'main', input: [], origin: { kind: 'user' } }));
87+
s = fold(s, new ContextUndo({ agentId: 'main', count: 1 }));
88+
expect(s.lastEnded).toMatchObject({ turnId: 0, reason: 'completed' });
89+
});
90+
91+
it('clears the stored outcome when the undo count exceeds the tracked anchors', () => {
92+
let s = turnKey.initial();
93+
s = fold(s, new TurnPrompt({ agentId: 'main', input: [], origin: { kind: 'user' } }));
94+
s = fold(s, new TurnEnded({ agentId: 'main', turnId: 0, reason: 'cancelled', durationMs: 10 }));
95+
s = fold(s, new ContextUndo({ agentId: 'main', count: 2 }));
96+
expect(s.anchorTurnIds).toEqual([]);
97+
expect(s.lastEnded).toBeUndefined();
98+
});
99+
70100
it('starts without a stored outcome', () => {
71101
expect(turnKey.initial().lastEnded).toBeUndefined();
72102
});

0 commit comments

Comments
 (0)