From 6132b650b40eb3ab08ecb10a808831d677fc8762 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 30 Jul 2026 14:47:34 +0800 Subject: [PATCH 1/6] feat(agent-core-v2): interruption reminder for user-cancelled turns When the user interrupts a turn with Esc, append a durable (origin: injection/interruption) to the agent context via a new loop aspect watching turn.ended, so the model learns the previous turn was deliberately cut off. The marker persists to the wire, replays on resume, stays hidden from transcripts, skips non-user aborts and steer, and does not stack on repeated cancels. Two supporting fixes: - An aborted LLM stream now persists its accumulated partial text/thinking as content.part loop events instead of dropping every produced token; gated on the turn signal so retried or step-cancelled attempts keep their partial output out of the record. - The turn.cancel wire op carries an optional reason ('user_cancelled' | 'aborted') so cold readers can tell deliberate interrupts from programmatic aborts. Goal-lifecycle cancels now pass an explicit programmatic reason to keep that field honest. --- .changeset/interrupt-reminder.md | 5 + .../agent-core-v2/docs/state-manifest.d.ts | 2 +- .../agent-core-v2/docs/wire-manifest.d.ts | 1 + .../src/agent/goal/goalService.ts | 25 +- .../src/agent/loop/interruptionReminder.ts | 8 + .../agent/loop/interruptionReminderService.ts | 105 ++++++ .../src/agent/loop/loopService.ts | 186 +++++++---- .../src/agent/loop/turnEvents.ts | 16 + .../agent-core-v2/src/agent/loop/turnOps.ts | 7 + .../agent-core-v2/src/app/telemetry/events.ts | 1 + packages/agent-core-v2/src/index.ts | 2 + .../fullCompaction/fullCompaction.test.ts | 1 + .../test/agent/goal/goal.test.ts | 14 +- .../test/agent/loop/loop.test.ts | 298 +++++++++++++++++- .../kap-server/src/protocol/events-zod.ts | 3 + packages/klient/src/contract/agent/events.ts | 4 + .../test/e2e/invalid-input-matrix.test.ts | 13 +- 17 files changed, 614 insertions(+), 77 deletions(-) create mode 100644 .changeset/interrupt-reminder.md create mode 100644 packages/agent-core-v2/src/agent/loop/interruptionReminder.ts create mode 100644 packages/agent-core-v2/src/agent/loop/interruptionReminderService.ts diff --git a/.changeset/interrupt-reminder.md b/.changeset/interrupt-reminder.md new file mode 100644 index 0000000000..2a28b1cf87 --- /dev/null +++ b/.changeset/interrupt-reminder.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Preserve the assistant's partial output when a turn is interrupted with Esc, and remind the model that the previous turn was deliberately interrupted. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 52cf313268..a64f61b718 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1013,7 +1013,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map('agentLoopInterruptionReminderService'); diff --git a/packages/agent-core-v2/src/agent/loop/interruptionReminderService.ts b/packages/agent-core-v2/src/agent/loop/interruptionReminderService.ts new file mode 100644 index 0000000000..edbd4f5327 --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/interruptionReminderService.ts @@ -0,0 +1,105 @@ +/** + * `loop` domain (L4) — user-interruption reminder aspect. + * + * A turn the user deliberately cancelled (Esc) would otherwise leave no mark + * the model can see: the next user prompt lands as an ordinary message right + * after the interrupted turn's residue, with nothing saying the previous turn + * was cut off on purpose. This service watches `turn.ended` and appends a + * durable `` (`origin: { kind: 'injection', variant: + * 'interruption' }`) at the context tail — after the interrupted turn's + * residue, before the next user message — so the marker persists to the wire, + * replays on resume, and stays hidden from transcripts, all through the + * existing injection machinery. Only `user_cancelled` qualifies: timeouts, + * programmatic aborts, and steer (which never cancels the turn) produce no + * marker, and a cancelled queued turn publishes no `turn.ended` at all. If + * the last durable message already is the interruption reminder (repeated + * cancellations with no message in between, a trailing vacuous open + * assistant left by the cancelled turn notwithstanding), appending is + * skipped so markers do not stack in practice — a reminder still deferred + * behind an unsettled tool exchange is invisible to that check, accepted + * since a second cancellation cannot normally arrive before the exchange + * settles. Bound at Agent scope and constructed with the scope so the + * subscription exists before the first turn runs (same rationale as + * `loopContinuation`). + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { IEventBus } from '#/app/event/eventBus'; + +import { IAgentLoopInterruptionReminderService } from './interruptionReminder'; + +export const INTERRUPTION_REMINDER_VARIANT = 'interruption'; + +const INTERRUPTION_REMINDER = [ + 'The previous turn was interrupted by the user before completion;', + 'any partial output shown above is incomplete.', + "The user's next message continues the conversation.", +].join(' '); + +export class AgentLoopInterruptionReminderService + extends Disposable + implements IAgentLoopInterruptionReminderService +{ + declare readonly _serviceBrand: undefined; + + constructor( + @IEventBus eventBus: IEventBus, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, + ) { + super(); + this._register( + eventBus.subscribe('turn.ended', (event) => { + if (event.reason !== 'cancelled' || event.interruptReason !== 'user_cancelled') return; + this.appendInterruptionReminder(); + }), + ); + } + + private appendInterruptionReminder(): void { + const origin = lastDurableMessageOrigin(this.context.get()); + if (origin?.kind === 'injection' && origin.variant === INTERRUPTION_REMINDER_VARIANT) return; + this.reminders.appendSystemReminder(INTERRUPTION_REMINDER, { + kind: 'injection', + variant: INTERRUPTION_REMINDER_VARIANT, + }); + } +} + +/** + * Origin of the last message that survives the fold, skipping a trailing open + * assistant the cancelled turn left with nothing sendable recorded (the fold + * drops it as vacuous at the next `step.begin`). Judging dedup against it + * would let markers stack around the dropped shell (e.g. an empty retry turn + * interrupted before its first token). + */ +function lastDurableMessageOrigin( + messages: readonly ContextMessage[], +): ContextMessage['origin'] | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]!; + if ( + message.role === 'assistant' && + message.partial === true && + message.toolCalls.length === 0 && + message.content.every(isVacuousContentPart) + ) { + continue; + } + return message.origin; + } + return undefined; +} + +registerScopedService( + LifecycleScope.Agent, + IAgentLoopInterruptionReminderService, + AgentLoopInterruptionReminderService, + ScopeActivation.OnScopeCreated, + 'loop', +); diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 69a67fd771..c3b84cf8be 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -53,12 +53,13 @@ import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IConfigService } from '#/app/config/config'; import { IEventBus } from '#/app/event/eventBus'; import { type FinishReason } from '#/kosong/contract/provider'; -import { type StreamedMessagePart } from '#/kosong/contract/message'; +import { mergeInPlace, type ContentPart, type StreamedMessagePart } from '#/kosong/contract/message'; import { type TokenUsage } from '#/kosong/contract/usage'; import { BugIndicatingError, ErrorCodes, Error2, isError2, toKimiErrorPayload } from '#/errors'; import { OrderedHookSlot } from '#/hooks'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import type { @@ -92,7 +93,7 @@ import { type TurnSeed, } from './stepRequest'; import { StepRequestQueue, type StepRequestBatch } from './stepRequestQueue'; -import { isDisplayablePromptOrigin, turnPromptText } from './turnEvents'; +import { isDisplayablePromptOrigin, turnPromptText, type TurnInterruptReason } from './turnEvents'; import { cancelTurn, promptTurn, TurnModel } from './turnOps'; export type LoopInterruptReason = 'aborted' | 'max_steps' | 'error'; @@ -283,7 +284,9 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { private cancelActiveTurn(turnId: number | undefined, cancellation: unknown): boolean { const job = this.activeTurnJob; if (job === undefined || (turnId !== undefined && job.turn.id !== turnId)) return false; - this.wire.dispatch(cancelTurn({ turnId: job.turn.id, target: 'active' })); + this.wire.dispatch( + cancelTurn({ turnId: job.turn.id, target: 'active', reason: cancelReasonFor(cancellation) }), + ); job.controller.abort(cancellation); return true; } @@ -293,7 +296,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if (index < 0) return false; const [job] = this.pendingTurns.splice(index, 1); if (job === undefined || job.turn.state !== 'queued') return false; - this.wire.dispatch(cancelTurn({ turnId, target: 'queued' })); + this.wire.dispatch(cancelTurn({ turnId, target: 'queued', reason: cancelReasonFor(cancellation) })); for (const step of job.steps.values()) step.cancel(cancellation); job.controller.abort(cancellation); job.turn.state = 'cancelled'; @@ -506,20 +509,23 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { : this.activeRequestTrace?.traceId; if (result !== undefined) { const error = result.type === 'failed' ? toKimiErrorPayload(result.error) : undefined; + const interruptReason = + result.type === 'completed' ? undefined : interruptReasonFor(result); this.eventBus.publish({ type: 'turn.ended', turnId: turn.id, reason: result.type, error, durationMs: Date.now() - startedAt, + interruptReason, }); if (error !== undefined) this.eventBus.publish({ type: 'error', ...error }); - if (result.type !== 'completed') { + if (interruptReason !== undefined) { const interrupted: TurnInterruptedEvent = { turn_id: turn.id, at_step: result.steps, mode, - interrupt_reason: interruptReasonFor(result), + interrupt_reason: interruptReason, provider_type, protocol, thinking_effort: thinkingEffort, @@ -618,6 +624,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { const result = await this.executeLoopStep( runtime.turnId, begun.step.signal, + runtime.turnSignal, begun.step.number, begun.step.uuid, options.onStarted, @@ -801,6 +808,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { private async executeLoopStep( turnId: number, signal: AbortSignal, + turnSignal: AbortSignal, currentStep: number, stepUuid: string, onStarted: ((step: number) => void) | undefined, @@ -808,13 +816,20 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { this.activeRequestTrace = undefined; await this.hooks.onWillBeginStep.run({ turnId, step: currentStep, signal }); const markStepStarted = this.beginStep(turnId, signal, currentStep, stepUuid, onStarted); + const streamParts = this.createStreamPartHandler(turnId, markStepStarted); const request = this.llmRequester.start( { source: { type: 'turn', turnId, step: currentStep } }, - this.createStreamPartHandler(turnId, markStepStarted), + streamParts.handle, signal, ); this.activeRequestTrace = request.trace; - const response = await request.result; + let response: AgentLLMRequestFinish; + try { + response = await request.result; + } catch (error) { + this.appendInterruptedStreamContent(turnId, currentStep, stepUuid, streamParts, turnSignal); + throw error; + } this.lastRequestTraceId = request.trace.traceId; this.appendResponseContent(turnId, currentStep, stepUuid, response); const finishReason = await this.executeStepTools( @@ -877,6 +892,37 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } } + /** + * `appendResponseContent` runs only after a full LLM response, so an abort + * mid-stream would otherwise lose every token the model already produced + * (the fold then drops the step's empty partial assistant as vacuous). When + * the turn itself was aborted, persist the accumulated partial text/thinking + * parts so the interrupted step replays to the same partial assistant + * message the user saw. Gated on the turn signal, not the step signal: a + * step-level cancel lets the turn continue with a regenerated step, and a + * failed (non-abort) attempt is retried — both keep their partial output + * out of the record because what follows re-generates it. + */ + private appendInterruptedStreamContent( + turnId: number, + currentStep: number, + stepUuid: string, + streamParts: StreamPartCollector, + turnSignal: AbortSignal, + ): void { + if (!turnSignal.aborted) return; + for (const part of streamParts.drainInterruptedContent()) { + this.context.appendLoopEvent({ + type: 'content.part', + uuid: randomUUID(), + turnId: String(turnId), + step: currentStep, + stepUuid, + part, + }); + } + } + private async executeStepTools( turnId: number, signal: AbortSignal, @@ -1031,54 +1077,72 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { private createStreamPartHandler( turnId: number, onResponseEvent: () => void, - ): (part: StreamedMessagePart) => void { + ): StreamPartCollector { const callsByIndex = new Map(); + const partialContent: ContentPart[] = []; + let mergeBroken = false; + // Mirror kosong's stream merge (`mergeInPlace` + flush-on-boundary, + // including the tool-call boundary) so the drained partials match the + // parts a completed response would have carried. + const accumulate = (part: ContentPart): void => { + const last = partialContent.at(-1); + if (!mergeBroken && last !== undefined && mergeInPlace(last, part)) return; + mergeBroken = false; + partialContent.push({ ...part }); + }; - return (part) => { - switch (part.type) { - case 'text': - onResponseEvent(); - this.eventBus.publish({ type: 'assistant.delta', turnId, delta: part.text }); - return; - case 'think': - onResponseEvent(); - this.eventBus.publish({ type: 'thinking.delta', turnId, delta: part.think }); - return; - case 'image_url': - case 'audio_url': - case 'video_url': - return; - case 'function': { - onResponseEvent(); - callsByIndex.set(part._streamIndex, { id: part.id, name: part.name }); - this.eventBus.publish({ - type: 'tool.call.delta', - turnId, - toolCallId: part.id, - name: part.name, - argumentsPart: part.arguments ?? undefined, - }); - return; - } - case 'tool_call_part': { - if (part.argumentsPart === null) return; - const toolCall = callsByIndex.get(part.index); - if (toolCall === undefined) return; - onResponseEvent(); - this.eventBus.publish({ - type: 'tool.call.delta', - turnId, - toolCallId: toolCall.id, - name: toolCall.name, - argumentsPart: part.argumentsPart, - }); - return; - } - default: { - const _exhaustive: never = part; - return _exhaustive; + return { + handle: (part) => { + switch (part.type) { + case 'text': + onResponseEvent(); + accumulate(part); + this.eventBus.publish({ type: 'assistant.delta', turnId, delta: part.text }); + return; + case 'think': + onResponseEvent(); + accumulate(part); + this.eventBus.publish({ type: 'thinking.delta', turnId, delta: part.think }); + return; + case 'image_url': + case 'audio_url': + case 'video_url': + return; + case 'function': { + onResponseEvent(); + mergeBroken = true; + callsByIndex.set(part._streamIndex, { id: part.id, name: part.name }); + this.eventBus.publish({ + type: 'tool.call.delta', + turnId, + toolCallId: part.id, + name: part.name, + argumentsPart: part.arguments ?? undefined, + }); + return; + } + case 'tool_call_part': { + if (part.argumentsPart === null) return; + const toolCall = callsByIndex.get(part.index); + if (toolCall === undefined) return; + onResponseEvent(); + this.eventBus.publish({ + type: 'tool.call.delta', + turnId, + toolCallId: toolCall.id, + name: toolCall.name, + argumentsPart: part.argumentsPart, + }); + return; + } + default: { + const _exhaustive: never = part; + return _exhaustive; + } } - } + }, + drainInterruptedContent: () => + partialContent.splice(0).filter((part) => !isVacuousContentPart(part)), }; } } @@ -1137,9 +1201,23 @@ interface StepRuntime { type BeginStepResult = { readonly step: StepRuntime } | { readonly result: LoopRunResult }; +interface StreamPartCollector { + readonly handle: (part: StreamedMessagePart) => void; + /** + * Drains the merged partial text/thinking parts accumulated from the stream + * so far (vacuous parts dropped). Never called on a completed request — the + * full response's content is authoritative and lands separately. + */ + drainInterruptedContent(): ContentPart[]; +} + +function cancelReasonFor(cancellation: unknown): 'user_cancelled' | 'aborted' { + return isUserCancellation(cancellation) ? 'user_cancelled' : 'aborted'; +} + function interruptReasonFor( result: Extract, -): TurnInterruptedEvent['interrupt_reason'] { +): TurnInterruptReason { if (result.type === 'cancelled') { return isUserCancellation(result.reason) ? 'user_cancelled' : 'aborted'; } diff --git a/packages/agent-core-v2/src/agent/loop/turnEvents.ts b/packages/agent-core-v2/src/agent/loop/turnEvents.ts index 0d3f60ab49..6811f9b76c 100644 --- a/packages/agent-core-v2/src/agent/loop/turnEvents.ts +++ b/packages/agent-core-v2/src/agent/loop/turnEvents.ts @@ -21,6 +21,20 @@ import type { TokenUsage } from '#/kosong/contract/usage'; /** Why a turn ended. `blocked` folds into `failed` at the wire edge. */ export type TurnEndReason = 'completed' | 'cancelled' | 'failed' | 'blocked'; +/** + * Why a non-completed turn stopped early — the same enum the `turn_interrupted` + * telemetry event reports. `user_cancelled` marks a deliberate user interrupt + * (Esc); every other value is a programmatic or provider-side stop. Absent on + * `turn.ended` when the turn completed. + */ +export type TurnInterruptReason = + | 'user_cancelled' + | 'aborted' + | 'max_steps' + | 'error' + | 'filtered' + | 'blocked'; + export interface TurnStartedEvent { readonly type: 'turn.started'; readonly turnId: number; @@ -50,6 +64,8 @@ export interface TurnEndedEvent { readonly reason: TurnEndReason; readonly error?: KimiErrorPayload; readonly durationMs?: number; + /** Present iff `reason` is not `'completed'`. */ + readonly interruptReason?: TurnInterruptReason; } export interface TurnStepStartedEvent { diff --git a/packages/agent-core-v2/src/agent/loop/turnOps.ts b/packages/agent-core-v2/src/agent/loop/turnOps.ts index 0a6714e92c..b578e3a7ae 100644 --- a/packages/agent-core-v2/src/agent/loop/turnOps.ts +++ b/packages/agent-core-v2/src/agent/loop/turnOps.ts @@ -63,6 +63,13 @@ export const cancelTurn = TurnModel.defineOp('turn.cancel', { schema: z.object({ turnId: z.number().optional(), target: z.enum(['active', 'queued']).optional(), + /** + * Why the turn was cancelled, persisted so a cold reader (transcript + * rebuild, future resume-time interruption detection) can tell a + * deliberate user interrupt apart from a programmatic abort. Absent on + * records written before this field existed. + */ + reason: z.enum(['user_cancelled', 'aborted']).optional(), }), apply: (s, { turnId, target }) => { if (target === undefined || turnId === undefined || turnId < s.nextTurnId) return s; diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index 8c41cf5147..d8d23b5019 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -75,6 +75,7 @@ export interface TurnInterruptedEvent { turn_id: number; at_step: number; mode: 'agent' | 'plan'; + /** Mirrored as `TurnInterruptReason` in `agent/loop/turnEvents.ts` — telemetry (L1) cannot import the loop (L4) type, so the union is duplicated; keep the two in sync. */ interrupt_reason: 'user_cancelled' | 'aborted' | 'max_steps' | 'error' | 'filtered' | 'blocked'; provider_type?: string; protocol?: string; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 3f667afe1e..3cb812560f 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -493,6 +493,8 @@ export * from '#/agent/loop/loop'; export * from '#/agent/loop/loopService'; export * from '#/agent/loop/loopContinuation'; export * from '#/agent/loop/loopContinuationService'; +export * from '#/agent/loop/interruptionReminder'; +export * from '#/agent/loop/interruptionReminderService'; export * from '#/agent/mcp/mcp'; export * from '#/agent/mcp/mcpService'; export * from '#/agent/mcp/mcpDiscoveryOps'; diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index 6160bf672e..d2f0490542 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -1122,6 +1122,7 @@ describe('FullCompaction', () => { code: 'compaction.failed', message: 'APIStatusError: Bad request', }), + interruptReason: 'error', }, }), ); 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 8c59fd7931..4c3dc01b3b 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -6,6 +6,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { isUserCancellation } from '#/_base/utils/abort'; import type { TurnEndedEvent } from '#/agent/loop/turnEvents'; import type { IDisposable } from '#/_base/di/lifecycle'; @@ -1160,7 +1161,10 @@ describe('AgentGoalService core workflow hooks', () => { await goals.cancelGoal(); expect(abort).toHaveBeenCalledOnce(); - expect(cancel).toHaveBeenCalledWith(41); + // Goal-lifecycle cancels are programmatic: the reason must not read as a + // user cancellation (which would also fire the interruption reminder). + expect(cancel).toHaveBeenCalledWith(41, expect.any(Error)); + expect(isUserCancellation(cancel.mock.calls[0]?.[1])).toBe(false); }); it.each(['turn', 'token', 'wall-clock'] as const)( @@ -1928,7 +1932,7 @@ describe('AgentGoalService hard wall-clock deadline', () => { } }); - it('keeps user cancellation authoritative when it precedes the wall-clock deadline', async () => { + it('keeps the goal-cancellation abort authoritative when it precedes the wall-clock deadline', async () => { const clock = new ManualGoalDeadlineScheduler(); const llm = blockingGenerate(); const ctx = createTestAgent(appService(IGoalDeadlineScheduler, clock), { @@ -1944,10 +1948,14 @@ describe('AgentGoalService hard wall-clock deadline', () => { await llm.started; await ctx.rpc.cancelGoal({}); + // A goal-lifecycle cancel is programmatic, not a user turn interrupt — + // and as the first abort it stays the recorded reason once the + // wall-clock deadline fires later. expect(llm.signal()).toMatchObject({ aborted: true, - reason: expect.objectContaining({ userCancelled: true }), + reason: expect.objectContaining({ message: 'Goal cancelled' }), }); + expect(isUserCancellation(llm.signal().reason)).toBe(false); clock.advanceBy(1_000); await ctx.untilTurnEnd(); 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 02a7a24767..8bf8f5a871 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -2,12 +2,15 @@ import { type ToolCall } from '#/kosong/contract/message'; import { emptyUsage } from '#/kosong/contract/usage'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { IDisposable } from '#/_base/di/lifecycle'; import { IAgentProfileService } from '#/index'; import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester'; import type { ModelRequestTiming } from '#/kosong/model/modelRequester'; +import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentGoalService } from '#/agent/goal/goal'; import { IAgentLoopService, type Turn } from '#/agent/loop/loop'; import { ContinuationStepRequest, MessageStepRequest } from '#/agent/loop/stepRequest'; +import { RetryStepRequest } from '#/agent/prompt/promptStepRequests'; import type { ExecutableTool } from '#/tool/toolContract'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentUsageService } from '#/agent/usage/usage'; @@ -115,7 +118,7 @@ describe('Agent loop', () => { [emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "