fix(agent-core-v2): scope interaction turn-end cancellation to the ending agent

turnId is an Agent-scope counter starting at 0 per agent, but
cancelPendingForTurn matched pending interactions by turnId alone, so any
subagent ending its own turn N cancelled approvals/questions parked by other
agents on the same turn number. Match on (turnId, agentId) instead, treating
an origin without agentId as main.
This commit is contained in:
Kaiyi 2026-08-18 15:47:10 +08:00
parent eaa3969dd3
commit f032c088c3
6 changed files with 40 additions and 6 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix pending approval prompts being dismissed when a background subagent finished its own turn.

View file

@ -100,7 +100,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
if (this.interactionBusDisposables.has(handle.id)) return;
const d = handle.accessor
.get(IEventBus)
.subscribe(TurnEnded, (e) => this.interaction.cancelPendingForTurn(e.turnId));
.subscribe(TurnEnded, (e) => this.interaction.cancelPendingForTurn(e.turnId, handle.id));
this.interactionBusDisposables.set(handle.id, d);
}

View file

@ -40,7 +40,7 @@ export interface ISessionInteractionService {
respond(id: string, response: unknown): void;
listPending(kind?: InteractionKind): readonly Interaction[];
isRecentlyResolved(id: string): boolean;
cancelPendingForTurn(turnId: number): void;
cancelPendingForTurn(turnId: number, agentId?: string): void;
readonly onDidChangePending: Event<InteractionPendingChangedEvent>;
readonly onDidResolve: Event<InteractionResolution>;
}

View file

@ -76,10 +76,12 @@ export class SessionInteractionService extends Service implements ISessionIntera
this.states.set(interactionNextIdKey, value);
}
cancelPendingForTurn(turnId: number): void {
cancelPendingForTurn(turnId: number, agentId: string = MAIN_AGENT_ID): void {
let changed = false;
for (const [id, entry] of this.pending) {
if (entry.interaction.origin?.turnId !== turnId) continue;
const origin = entry.interaction.origin;
if (origin?.turnId !== turnId) continue;
if ((origin.agentId ?? MAIN_AGENT_ID) !== agentId) continue;
this.pending.delete(id);
this.rememberResolved(id);
const response = { cancelled: true, reason: 'turn_ended' };

View file

@ -2052,9 +2052,11 @@ export class AgentTestContext {
: interactions.filter((interaction) => interaction.kind === kind);
},
isRecentlyResolved: () => false,
cancelPendingForTurn: (turnId: number) => {
cancelPendingForTurn: (turnId: number, agentId: string = 'main') => {
for (const [id, interaction] of pending) {
if (interaction.origin?.turnId === turnId) pending.delete(id);
if (interaction.origin?.turnId !== turnId) continue;
if ((interaction.origin?.agentId ?? 'main') !== agentId) continue;
pending.delete(id);
}
},
onDidChangePending: Event.None as Event<InteractionPendingChangedEvent>,

View file

@ -215,6 +215,31 @@ describe('SessionInteractionService', () => {
expect(svc.listPending()).toHaveLength(1);
});
it('cancelPendingForTurn only cancels interactions from the ending agent, not same-numbered turns of other agents', () => {
const svc = ix.get(ISessionInteractionService);
svc.enqueue({ id: 'm1', kind: 'approval', payload: {}, origin: { agentId: 'main', turnId: 3 } });
svc.enqueue({ id: 's1', kind: 'approval', payload: {}, origin: { agentId: 'agent-1', turnId: 3 } });
svc.cancelPendingForTurn(3, 'agent-1');
expect(svc.listPending().map((i) => i.id)).toEqual(['m1']);
svc.cancelPendingForTurn(3, 'main');
expect(svc.listPending()).toHaveLength(0);
});
it('cancelPendingForTurn treats an interaction without origin agentId as belonging to main', () => {
const svc = ix.get(ISessionInteractionService);
svc.enqueue({ id: 'a1', kind: 'approval', payload: {}, origin: { turnId: 5 } });
svc.cancelPendingForTurn(5, 'agent-1');
expect(svc.listPending()).toHaveLength(1);
svc.cancelPendingForTurn(5);
expect(svc.listPending()).toHaveLength(0);
});
it('request journals an interaction.request op to the origin agent wire', () => {
const sub = makeFakeAgent('agent-1');
agents.set('agent-1', sub);