fix(agent-core-v2): clear the turn outcome when an undo rewinds the turn it describes (#3278)
Some checks are pending
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-vscode-legacy (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions

* fix(agent-core-v2): clear the turn outcome when an undo rewinds the turn it describes

* fix(agent-core-v2): clear the turn outcome too when an undo outruns the tracked anchors

* fix(agent-core-v2): reconcile the persisted turn outcome against the replayed wire on restore

* fix(agent-core-v2): keep the persisted outcome when an undo rewinds only later turns

---------

Co-authored-by: kimi-agent-bot <kimi-agent-bot@users.noreply.github.com>
This commit is contained in:
Kimi Agent 2026-08-27 03:16:45 +08:00 committed by GitHub
parent a9656841b8
commit b17bd61cef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 266 additions and 21 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix the "manually stopped" state lingering after undoing the interrupted turn.

View file

@ -34,6 +34,7 @@ import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types';
import type { PromptOrigin } from '#/agent/contextMemory/types';
import type { TurnEndReason } from '#/agent/loop/turnEvents';
import { IEventDispatcher } from '#/state/eventDispatcher';
import { ContextUndone } from '#/agent/undo/undoService';
import type {
ActivityLastTurnState,
@ -140,6 +141,9 @@ export class AgentActivityView extends Disposable implements IAgentActivityView
this._register(
this.eventBus.subscribe(TurnEnded, (e) => this.onTurnEnded(e.turnId, e.reason)),
);
this._register(
this.eventBus.subscribe(ContextUndone, (e) => this.onContextUndone(e.fromTurnId)),
);
this._register(
this.eventBus.subscribe(PermissionApprovalRequested, (e) =>
this.onApprovalRequested(e.id ?? e.toolCallId, e.toolCallId),
@ -296,6 +300,14 @@ export class AgentActivityView extends Disposable implements IAgentActivityView
this.publish();
}
private onContextUndone(fromTurnId: number | undefined): void {
const last = this.lastTurn;
if (last === undefined) return;
if (fromTurnId !== undefined && last.turnId < fromTurnId) return;
this.lastTurn = undefined;
this.publish();
}
private onStepStarted(step: number): void {
this.mutateTurn((t) => {
t.step = step;

View file

@ -139,10 +139,19 @@ export const turnKey = defineState(
return { ...next, anchorTurnIds: [...s.anchorTurnIds, s.nextTurnId] };
})
.on(TurnSteer, () => {})
.on(ContextUndo, (s, e) => ({
...s,
anchorTurnIds: s.anchorTurnIds.slice(0, Math.max(0, s.anchorTurnIds.length - e.count)),
}))
.on(ContextUndo, (s, e) => {
const firstRemoved = s.anchorTurnIds[s.anchorTurnIds.length - e.count];
const lastEnded = s.lastEnded;
return {
...s,
anchorTurnIds: s.anchorTurnIds.slice(0, Math.max(0, s.anchorTurnIds.length - e.count)),
lastEnded:
lastEnded !== undefined &&
(firstRemoved === undefined || lastEnded.turnId >= firstRemoved)
? undefined
: lastEnded,
};
})
.on(ContextApplyCompaction, (s) => ({ ...s, anchorTurnIds: [] }))
.on(ContextClear, (s) => ({ ...s, anchorTurnIds: [] }))
.on(TurnCancel, (s, e) => {

View file

@ -4,7 +4,10 @@ import { LifecycleScope } from '#/app/scopes';
import { IEventBus } from '#/app/event/eventBus';
import { AgentActivityUpdated } from '#/agent/activityView/activityView';
import { TurnStarted } from '#/agent/loop/turnEvents';
import { TurnEnded } from '#/agent/loop/turnOps';
import { TurnEnded, turnKey } from '#/agent/loop/turnOps';
import { ContextUndone } from '#/agent/undo/undoService';
import { IAgentStateService } from '#/agent/state/agentState';
import { IEventDispatcher } from '#/state/eventDispatcher';
import {
IAgentLifecycleService,
MAIN_AGENT_ID,
@ -18,16 +21,18 @@ export class SessionOutcomeMirror extends Disposable implements ISessionOutcomeM
declare readonly _serviceBrand: undefined;
private lastPersisted: SessionTurnOutcome | undefined;
private lastPersistedTurnId: number | undefined;
private adopted = false;
private turnStartedHere = false;
private mainSubscription: DisposableStore | undefined;
private readonly metadataReady: Promise<void>;
constructor(
@IAgentLifecycleService private readonly agents: IAgentLifecycleService,
@ISessionMetadata private readonly metadata: ISessionMetadata,
) {
super();
void this.metadata
this.metadataReady = this.metadata
.read()
.then((meta) => {
if (!this.adopted) this.lastPersisted = meta.lastTurnReason;
@ -52,24 +57,33 @@ export class SessionOutcomeMirror extends Disposable implements ISessionOutcomeM
private attachMain(): void {
if (this.mainSubscription !== undefined) return;
const bus = this.agents.handleOf(MAIN_AGENT_ID)?.accessor.get(IEventBus) as
| IEventBus
| undefined;
const handle = this.agents.handleOf(MAIN_AGENT_ID);
const bus = handle?.accessor.get(IEventBus) as IEventBus | undefined;
if (bus === undefined) return;
const subscription = new DisposableStore();
this.mainSubscription = subscription;
const dispatcher = handle?.accessor.get(IEventDispatcher) as IEventDispatcher | undefined;
const agentStates = handle?.accessor.get(IAgentStateService) as IAgentStateService | undefined;
if (dispatcher !== undefined && agentStates !== undefined) {
subscription.add(
dispatcher.hooks.onDidRestore.register('session-outcome-mirror', async (_ctx, next) => {
await next();
await this.reconcileAfterRestore(agentStates);
}),
);
}
subscription.add(
bus.subscribe(TurnEnded, (event) => {
if (event.reason === 'completed') {
this.write('completed');
this.write('completed', { turnId: event.turnId });
return;
}
if (event.reason === 'failed' || event.reason === 'blocked') {
this.write('failed');
this.write('failed', { turnId: event.turnId });
return;
}
if (event.reason === 'cancelled' && event.interruptReason === 'user_cancelled') {
this.write('cancelled');
this.write('cancelled', { turnId: event.turnId });
}
}),
);
@ -79,31 +93,67 @@ export class SessionOutcomeMirror extends Disposable implements ISessionOutcomeM
this.write(undefined);
}),
);
subscription.add(
bus.subscribe(ContextUndone, (event) => {
if (
event.fromTurnId !== undefined &&
this.lastPersistedTurnId !== undefined &&
this.lastPersistedTurnId < event.fromTurnId
) {
return;
}
this.write(undefined);
}),
);
subscription.add(
bus.subscribe(AgentActivityUpdated, (event) => {
if (this.turnStartedHere) return;
if (this.lastPersisted !== undefined) return;
const reason = event.lastTurn?.reason;
if (reason === 'completed' || reason === 'cancelled') {
this.write(reason, { touchUpdatedAt: false });
this.write(reason, { touchUpdatedAt: false, turnId: event.lastTurn?.turnId });
} else if (reason === 'failed' || reason === 'blocked') {
this.write('failed', { touchUpdatedAt: false });
this.write('failed', { touchUpdatedAt: false, turnId: event.lastTurn?.turnId });
}
}),
);
}
private async reconcileAfterRestore(agentStates: IAgentStateService): Promise<void> {
await this.metadataReady;
if (this.lastPersisted === undefined) return;
if (this.turnStartedHere) return;
if (!agentStates.has(turnKey)) return;
const lastEnded = agentStates.get(turnKey).lastEnded;
if (lastEnded === undefined) {
this.write(undefined, { touchUpdatedAt: false });
return;
}
if (this.lastPersistedTurnId === undefined) this.lastPersistedTurnId = lastEnded.turnId;
}
private write(
outcome: SessionTurnOutcome | undefined,
opts?: { readonly touchUpdatedAt?: boolean },
opts?: { readonly touchUpdatedAt?: boolean; readonly turnId?: number },
): void {
if (outcome === this.lastPersisted) return;
if (outcome === this.lastPersisted) {
if (opts?.turnId !== undefined) this.lastPersistedTurnId = opts.turnId;
return;
}
this.adopted = true;
const previous = this.lastPersisted;
const previousTurnId = this.lastPersistedTurnId;
this.lastPersisted = outcome;
void this.metadata.update({ lastTurnReason: outcome }, opts).catch(() => {
if (this.lastPersisted === outcome) this.lastPersisted = previous;
});
this.lastPersistedTurnId =
outcome === undefined ? undefined : (opts?.turnId ?? this.lastPersistedTurnId);
void this.metadata
.update({ lastTurnReason: outcome }, { touchUpdatedAt: opts?.touchUpdatedAt })
.catch(() => {
if (this.lastPersisted === outcome) {
this.lastPersisted = previous;
this.lastPersistedTurnId = previousTurnId;
}
});
}
}

View file

@ -26,6 +26,7 @@ import {
} from '#/agent/toolApproval/toolApprovalService';
import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction';
import type { FullCompactionTask } from '#/agent/fullCompaction/fullCompaction';
import { ContextUndone } from '#/agent/undo/undoService';
import { OrderedHookSlot } from '#/hooks';
import { IEventDispatcher } from '#/state/eventDispatcher';
import { stubAgentContext } from '../agentContext/stubs';
@ -234,6 +235,37 @@ describe('AgentActivityView', () => {
expect(view.state().lastTurn).toMatchObject({ turnId: 2, reason: 'completed' });
});
it('clears the last outcome when an undo rewinds the turn it describes', () => {
const { bus, view } = harness();
bus.publish(new TurnStarted({ agentId: 'main', turnId: 1, origin: { kind: 'user' } }));
bus.publish(new TurnEnded({ agentId: 'main', turnId: 1, reason: 'cancelled' }));
expect(view.state().lastTurn).toMatchObject({ turnId: 1, reason: 'cancelled' });
bus.publish(new ContextUndone({ agentId: 'main', turns: 1, fromTurnId: 1 }));
expect(view.state().lastTurn).toBeUndefined();
});
it('keeps the last outcome when an undo rewinds only later turns', () => {
const { bus, view } = harness();
bus.publish(new TurnStarted({ agentId: 'main', turnId: 1, origin: { kind: 'user' } }));
bus.publish(new TurnEnded({ agentId: 'main', turnId: 1, reason: 'completed' }));
bus.publish(new ContextUndone({ agentId: 'main', turns: 1, fromTurnId: 2 }));
expect(view.state().lastTurn).toMatchObject({ turnId: 1, reason: 'completed' });
});
it('clears the last outcome when the undo range cannot be determined', () => {
const { bus, view } = harness();
bus.publish(new TurnStarted({ agentId: 'main', turnId: 1, origin: { kind: 'user' } }));
bus.publish(new TurnEnded({ agentId: 'main', turnId: 1, reason: 'failed' }));
bus.publish(new ContextUndone({ agentId: 'main', turns: 1 }));
expect(view.state().lastTurn).toBeUndefined();
});
it('exposes the engine-minted interaction id as the approval id', () => {
const { bus, view } = harness();

View file

@ -67,6 +67,36 @@ describe('turnKey lastEnded', () => {
expect(s.lastEnded?.reason).toBe('completed');
});
it('clears the stored outcome when an undo rewinds the turn it describes', () => {
let s = turnKey.initial();
s = fold(s, new TurnPrompt({ agentId: 'main', input: [], origin: { kind: 'user' } }));
s = fold(s, new TurnEnded({ agentId: 'main', turnId: 0, reason: 'completed', durationMs: 10 }));
s = fold(s, new TurnPrompt({ agentId: 'main', input: [], origin: { kind: 'user' } }));
s = fold(s, new TurnEnded({ agentId: 'main', turnId: 1, reason: 'cancelled', durationMs: 10 }));
expect(s.lastEnded?.reason).toBe('cancelled');
s = fold(s, new ContextUndo({ agentId: 'main', count: 1 }));
expect(s.anchorTurnIds).toEqual([0]);
expect(s.lastEnded).toBeUndefined();
});
it('keeps the stored outcome when an undo rewinds only later turns', () => {
let s = turnKey.initial();
s = fold(s, new TurnPrompt({ agentId: 'main', input: [], origin: { kind: 'user' } }));
s = fold(s, new TurnEnded({ agentId: 'main', turnId: 0, reason: 'completed', durationMs: 10 }));
s = fold(s, new TurnPrompt({ agentId: 'main', input: [], origin: { kind: 'user' } }));
s = fold(s, new ContextUndo({ agentId: 'main', count: 1 }));
expect(s.lastEnded).toMatchObject({ turnId: 0, reason: 'completed' });
});
it('clears the stored outcome when the undo count exceeds the tracked anchors', () => {
let s = turnKey.initial();
s = fold(s, new TurnPrompt({ agentId: 'main', input: [], origin: { kind: 'user' } }));
s = fold(s, new TurnEnded({ agentId: 'main', turnId: 0, reason: 'cancelled', durationMs: 10 }));
s = fold(s, new ContextUndo({ agentId: 'main', count: 2 }));
expect(s.anchorTurnIds).toEqual([]);
expect(s.lastEnded).toBeUndefined();
});
it('starts without a stored outcome', () => {
expect(turnKey.initial().lastEnded).toBeUndefined();
});

View file

@ -16,7 +16,10 @@ import type { Event2, Event2Class } from '#/app/event/event2';
import { AgentActivityUpdated } from '#/agent/activityView/activityView';
import type { AgentContext } from '#/agent/agentContext/agentContext';
import { TurnStarted } from '#/agent/loop/turnEvents';
import { TurnEnded } from '#/agent/loop/turnOps';
import { TurnEnded, turnKey, type TurnModelState } from '#/agent/loop/turnOps';
import { ContextUndone } from '#/agent/undo/undoService';
import { IAgentStateService } from '#/agent/state/agentState';
import { IEventDispatcher } from '#/state/eventDispatcher';
import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import {
@ -49,6 +52,8 @@ class FakeBus {
class FakeAgentLifecycle implements IAgentLifecycleService {
declare readonly _serviceBrand: undefined;
readonly bus = new FakeBus();
readonly restoreHooks: Array<(ctx: undefined, next: () => Promise<void>) => Promise<void>> = [];
lastEnded: TurnModelState['lastEnded'];
private readonly context: AgentContext = stubAgentContext(MAIN_AGENT_ID, 1);
private readonly createEmitter = new Emitter<AgentContext>();
private readonly willCloseEmitter = new Emitter<AgentContext>();
@ -58,10 +63,36 @@ class FakeAgentLifecycle implements IAgentLifecycleService {
readonly onWillClose = this.willCloseEmitter.event;
readonly onDidClose = this.didCloseEmitter.event;
private mainPresent = false;
private readonly dispatcher = {
hooks: {
onDidRestore: {
register: (_id: string, fn: (ctx: undefined, next: () => Promise<void>) => Promise<void>) => {
this.restoreHooks.push(fn);
return { dispose: () => {} };
},
},
},
};
private readonly agentStates = {
has: (key: unknown) => key === turnKey,
get: (key: unknown) =>
key === turnKey
? { nextTurnId: 1, cancelledTurnIds: [], anchorTurnIds: [], lastEnded: this.lastEnded }
: undefined,
};
private readonly mainHandle = {
id: MAIN_AGENT_ID,
accessor: { get: (token: unknown) => (token === IEventBus ? this.bus : undefined) },
accessor: {
get: (token: unknown) =>
token === IEventBus
? this.bus
: token === IEventDispatcher
? this.dispatcher
: token === IAgentStateService
? this.agentStates
: undefined,
},
} as unknown as IAgentScopeHandle;
get(agentId: string): AgentContext | undefined {
@ -264,6 +295,82 @@ describe('SessionOutcomeMirror (Session scope)', () => {
expect(writes).toEqual(['completed']);
});
it('clears the persisted outcome when an undo rewinds the turn', async () => {
lifecycle.addMain();
await tick();
ended('cancelled', 'user_cancelled');
expect(writes).toEqual(['cancelled']);
lifecycle.bus.publish(new ContextUndone({ agentId: 'main', turns: 1, fromTurnId: 1 }));
expect(writes).toEqual(['cancelled', undefined]);
});
it('an undo with no stored outcome writes nothing', async () => {
lifecycle.addMain();
await tick();
lifecycle.bus.publish(new ContextUndone({ agentId: 'main', turns: 1 }));
expect(writes).toEqual([]);
});
it('keeps the persisted outcome when an undo rewinds only a later turn', async () => {
lifecycle.addMain();
await tick();
ended('cancelled', 'user_cancelled', 1);
expect(writes).toEqual(['cancelled']);
lifecycle.bus.publish(new ContextUndone({ agentId: 'main', turns: 1, fromTurnId: 2 }));
expect(writes).toEqual(['cancelled']);
});
it('tracks the narrated turn across equal outcomes for the undo range check', async () => {
lifecycle.addMain();
await tick();
ended('completed', undefined, 1);
ended('completed', undefined, 2);
expect(writes).toEqual(['completed']);
lifecycle.bus.publish(new ContextUndone({ agentId: 'main', turns: 1, fromTurnId: 2 }));
expect(writes).toEqual(['completed', undefined]);
});
it('clears a stale persisted outcome when the replayed wire has no ended turn', async () => {
const stale = host.child(LifecycleScope.Session, 'session-stale', [
stubPair(ISessionMetadata, {
read: async () => ({ lastTurnReason: 'cancelled' }) as SessionMeta,
update: async (
patch: { lastTurnReason?: SessionMeta['lastTurnReason'] },
uopts?: { touchUpdatedAt?: boolean },
) => {
writes.push(patch.lastTurnReason);
touches.push(uopts?.touchUpdatedAt !== false);
},
} as unknown as ISessionMetadata),
]);
const staleLifecycle = stale.accessor.get(IAgentLifecycleService) as unknown as FakeAgentLifecycle;
stale.accessor.get(ISessionOutcomeMirror);
staleLifecycle.addMain();
await tick();
expect(writes).toEqual([]);
for (const hook of staleLifecycle.restoreHooks) await hook(undefined, async () => {});
expect(writes).toEqual([undefined]);
expect(touches).toEqual([false]);
});
it('keeps the adopted outcome when the replayed wire still has its ended turn', async () => {
const fresh = host.child(LifecycleScope.Session, 'session-fresh', [
stubPair(ISessionMetadata, {
read: async () => ({ lastTurnReason: 'cancelled' }) as SessionMeta,
update: async (patch: { lastTurnReason?: SessionMeta['lastTurnReason'] }) => {
writes.push(patch.lastTurnReason);
},
} as unknown as ISessionMetadata),
]);
const freshLifecycle = fresh.accessor.get(IAgentLifecycleService) as unknown as FakeAgentLifecycle;
fresh.accessor.get(ISessionOutcomeMirror);
freshLifecycle.addMain();
freshLifecycle.lastEnded = { turnId: 3, reason: 'cancelled', durationMs: 5 };
await tick();
for (const hook of freshLifecycle.restoreHooks) await hook(undefined, async () => {});
expect(writes).toEqual([]);
});
it('reattaches when the main agent is disposed and recreated', async () => {
lifecycle.addMain();
await tick();