From d7948d5b853e5c04426ea57d8bd870e31da474f3 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:27:59 +0800 Subject: [PATCH 1/3] fix(agent-core-v2): preserve goal completion summaries --- .../src/agent/goal/goalService.ts | 1 + .../test/agent/goal/goal.test.ts | 3 ++- .../goal/injection/goalInjection.test.ts | 20 +++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index cd8ca1ec14..285b5d95b6 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -635,6 +635,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { private cancelPendingContinuation(): void { const pending = this.pendingContinuation; + if (pending?.turnId === this.liveTurnId) return; this.pendingContinuation = undefined; pending?.receipt.abort(); } diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index b57d4a8e85..940f8e847e 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -298,6 +298,7 @@ describe('AgentGoalService', () => { name: 'UpdateGoal', arguments: JSON.stringify({ status: 'complete' }), }); + ctx.mockNextResponse({ type: 'text', text: 'Goal completed.' }); const endedTurnIds: number[] = []; const endedTurnReasons: string[] = []; const continuationTurnIds: number[] = []; @@ -327,7 +328,7 @@ describe('AgentGoalService', () => { await vi.waitFor(() => { expect(endedTurnIds).toHaveLength(2); }); - expect(ctx.llmCalls).toHaveLength(2); + expect(ctx.llmCalls).toHaveLength(3); expect(continuationTurnIds).toEqual(endedTurnIds); expect(endedTurnReasons).toEqual(['completed', 'completed']); expect(goals.getGoal().goal).toBeNull(); diff --git a/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts b/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts index 6251ddd312..3938878b8c 100644 --- a/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts +++ b/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts @@ -342,6 +342,26 @@ describe('GoalInjection integration', () => { expect(await flushedGoalReminderRecords(ctx, persistence)).toHaveLength(2); }); + it('requests a final model response when a continuation completes the goal', async () => { + profile.update({ activeToolNames: ['UpdateGoal'] }); + await goals.createGoal({ objective: 'Finish the task' }); + + ctx.mockNextResponse({ type: 'text', text: 'Working on it.' }); + ctx.mockNextResponse({ + type: 'function', + id: 'call_complete_goal', + name: 'UpdateGoal', + arguments: JSON.stringify({ status: 'complete' }), + }); + ctx.mockNextResponse({ type: 'text', text: 'Finished and verified.' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Start.' }] }); + await ctx.untilTurnEnd(); + await ctx.untilTurnEnd(); + + expect(ctx.llmCalls).toHaveLength(3); + }); + it('writes no goal record when there is no active goal', async () => { await injectDynamic(injector); From 15c0cc048905d7f9dfc8c56b43e15374cbd9d123 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:37:56 +0800 Subject: [PATCH 2/3] fix(agent-core-v2): preserve loop error messages --- .changeset/fix-goal-and-loop-events.md | 5 +++++ .../agent-core-v2/src/agent/loop/loopService.ts | 4 +++- .../agent-core-v2/test/agent/loop/loop.test.ts | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-goal-and-loop-events.md diff --git a/.changeset/fix-goal-and-loop-events.md b/.changeset/fix-goal-and-loop-events.md new file mode 100644 index 0000000000..0d4e993d46 --- /dev/null +++ b/.changeset/fix-goal-and-loop-events.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Preserve goal completion summaries and show untyped LLM errors without an internal error-code prefix in step interruption events. diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 1e15eea7eb..e77307b5ec 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -646,7 +646,9 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { private failLoopStep(runtime: LoopRuntime, error: unknown): LoopErrorDisposition { const reason: LoopInterruptReason = isMaxStepsExceededError(error) ? 'max_steps' : 'error'; - this.emitStepInterrupted(runtime.turnId, runtime.current?.number, reason, toErrorMessage(error)); + const interruptedError = + isError2(error) && error.code === ErrorCodes.INTERNAL && error.cause !== undefined ? error.cause : error; + this.emitStepInterrupted(runtime.turnId, runtime.current?.number, reason, toErrorMessage(interruptedError)); return { type: 'return', result: { type: 'failed', error, steps: runtime.steps } }; } diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 08a427c76e..61a3b5ecb3 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -215,6 +215,23 @@ describe('Agent loop', () => { ); }); + it('reports an untyped LLM error message without an internal-code prefix', async () => { + profile.update({ activeToolNames: [] }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] }); + await ctx.untilTurnEnd(); + + expect(ctx.allEvents).toContainEqual( + expect.objectContaining({ + event: 'turn.step.interrupted', + args: expect.objectContaining({ + reason: 'error', + message: 'Unexpected generate call #1', + }), + }), + ); + }); + it('does not run loop error handlers for aborted turns', async () => { let called = false; loop.registerLoopErrorHandler({ From bdc61e7a00d4552192b3735c08b6c51bfba1d138 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:10:05 +0800 Subject: [PATCH 3/3] fix(agent-core-v2): keep goal cancellation responsive --- .../src/agent/goal/goalService.ts | 10 ++-- .../test/agent/goal/goal.test.ts | 48 ++++++++++++++++++- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index 285b5d95b6..a198e0c19c 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -417,7 +417,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { const snapshot = this.toSnapshot(completed); this.emitCompletion(completed, snapshot, input.reason, actor); this.trackStatusChanged(completed, actor); - this.clearInternal(actor); + this.clearInternal(actor, { preserveLiveContinuation: true }); return snapshot; } @@ -633,9 +633,9 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { return status.state === 'idle' && !status.hasPendingRequests; } - private cancelPendingContinuation(): void { + private cancelPendingContinuation(preserveLiveContinuation = false): void { const pending = this.pendingContinuation; - if (pending?.turnId === this.liveTurnId) return; + if (preserveLiveContinuation && pending?.turnId === this.liveTurnId) return; this.pendingContinuation = undefined; pending?.receipt.abort(); } @@ -673,10 +673,10 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { private clearInternal( actor: GoalActor, - opts: { readonly emit?: boolean; readonly track?: boolean } = {}, + opts: { readonly emit?: boolean; readonly track?: boolean; readonly preserveLiveContinuation?: boolean } = {}, ): void { if (this.goalState === null) return; - this.cancelPendingContinuation(); + this.cancelPendingContinuation(opts.preserveLiveContinuation === true); this.wallClockResumedAt = undefined; this.wire.dispatch(clearGoal({})); if (opts.emit !== false) this.emitGoalUpdated(null); diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index 940f8e847e..daeb866163 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -13,7 +13,7 @@ import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types'; import { IAgentGoalService } from '#/agent/goal/goal'; import { type AgentGoalService } from '#/agent/goal/goalService'; import { UpdateGoalTool, UpdateGoalToolInputSchema } from '#/agent/goal/tools/update-goal'; -import { IAgentLoopService, type AfterStepContext, type Turn } from '#/agent/loop/loop'; +import { IAgentLoopService, type AfterStepContext, type EnqueueReceipt, type Step, type Turn } from '#/agent/loop/loop'; import { MessageStepRequest } from '#/agent/loop/stepRequest'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentUsageService } from '#/agent/usage/usage'; @@ -584,6 +584,28 @@ describe('AgentGoalService core workflow hooks', () => { await ctx?.dispose(); }); + async function startLiveContinuation(): Promise boolean>>> { + const abort = vi.fn<() => boolean>(() => true); + const turn: Turn = { ...makeTurn(41), result: new Promise(() => {}) }; + const step: Step = { + id: 'goal-continuation', + turnId: turn.id, + state: 'queued', + signal: turn.signal, + result: Promise.resolve({ type: 'completed' }), + cancel: () => true, + }; + const receipt: EnqueueReceipt = { assigned: Promise.resolve({ turn, step }), abort }; + vi.spyOn(loopService, 'enqueue').mockReturnValue(receipt); + + await goals.createGoal({ objective: 'finish the task' }); + await goals.markBlocked({ reason: 'need credentials' }); + await goals.resumeGoal({ continueIfBlocked: true }); + await Promise.resolve(); + eventBus.publish({ type: 'turn.started', turnId: turn.id, origin: USER_PROMPT_ORIGIN }); + return abort; + } + it('starts a continuation when a user resumes an idle blocked goal', async () => { await goals.createGoal({ objective: 'finish the task' }); await goals.markBlocked({ reason: 'need credentials' }); @@ -599,6 +621,30 @@ describe('AgentGoalService core workflow hooks', () => { }); }); + it('aborts a live continuation when the user pauses the goal', async () => { + const abort = await startLiveContinuation(); + + await goals.pauseGoal(); + + expect(abort).toHaveBeenCalledOnce(); + }); + + it('aborts a live continuation when the user cancels the goal', async () => { + const abort = await startLiveContinuation(); + + await goals.cancelGoal(); + + expect(abort).toHaveBeenCalledOnce(); + }); + + it('aborts a live continuation when the user replaces the goal', async () => { + const abort = await startLiveContinuation(); + + await goals.createGoal({ objective: 'new task', replace: true }); + + expect(abort).toHaveBeenCalledOnce(); + }); + it.each(['turn', 'token', 'wall-clock'] as const)( 'keeps a goal blocked when its %s budget is exhausted before resume', async (budget) => {