Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/undo-clears-turn-outcome.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix the "manually stopped" state lingering after undoing the interrupted turn.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 12 additions & 4 deletions packages/agent-core-v2/src/agent/loop/turnOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,18 @@ 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,
Comment thread
kimi-agent-bot marked this conversation as resolved.
Resolved
};
})
.on(ContextApplyCompaction, (s) => ({ ...s, anchorTurnIds: [] }))
.on(ContextClear, (s) => ({ ...s, anchorTurnIds: [] }))
.on(TurnCancel, (s, e) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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 { ContextUndone } from '#/agent/undo/undoService';
import {
IAgentLifecycleService,
MAIN_AGENT_ID,
Expand Down Expand Up @@ -79,6 +80,11 @@ export class SessionOutcomeMirror extends Disposable implements ISessionOutcomeM
this.write(undefined);
}),
);
subscription.add(
bus.subscribe(ContextUndone, () => {
this.write(undefined);
Comment thread
kimi-agent-bot marked this conversation as resolved.
Resolved
}),
Comment thread
kimi-agent-bot marked this conversation as resolved.
Resolved
);
subscription.add(
bus.subscribe(AgentActivityUpdated, (event) => {
if (this.turnStartedHere) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();

Expand Down
21 changes: 21 additions & 0 deletions packages/agent-core-v2/test/agent/loop/turnOps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,27 @@ 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('starts without a stored outcome', () => {
expect(turnKey.initial().lastEnded).toBeUndefined();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ 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 { ContextUndone } from '#/agent/undo/undoService';
import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import {
Expand Down Expand Up @@ -264,6 +265,22 @@ 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('reattaches when the main agent is disposed and recreated', async () => {
lifecycle.addMain();
await tick();
Expand Down
Loading