diff --git a/.changeset/goal-v2-web-resume.md b/.changeset/goal-v2-web-resume.md new file mode 100644 index 0000000000..a6b02f6229 --- /dev/null +++ b/.changeset/goal-v2-web-resume.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Resume paused goals when you select Resume. diff --git a/docs/en/guides/goals.md b/docs/en/guides/goals.md index ff2fa0cb61..65cf4a7816 100644 --- a/docs/en/guides/goals.md +++ b/docs/en/guides/goals.md @@ -108,7 +108,7 @@ Write stop conditions into the objective. `/goal` does not have a separate stop- The web UI shows the current goal in a strip below the conversation. Select the strip to expand or collapse its details. When a token budget is configured, the header shows its progress; goals without a token budget do not show a progress bar. -Use the strip actions to pause an active goal, resume a paused or blocked goal, or cancel the current goal. Cancellation requires confirmation because it cannot be resumed afterwards. +Use the strip actions to pause an active goal, resume a paused or blocked goal, or cancel the current goal. Selecting Resume starts the next goal turn so the agent continues the work. Cancellation requires confirmation because it cannot be resumed afterwards. ## Queue upcoming goals diff --git a/docs/zh/guides/goals.md b/docs/zh/guides/goals.md index 64cf6b56bb..104f3cbb14 100644 --- a/docs/zh/guides/goals.md +++ b/docs/zh/guides/goals.md @@ -108,7 +108,7 @@ Kimi Code 会保存该目标,把它作为下一条用户消息发送,并进 Web 界面会在对话下方显示当前目标条。点击目标条可以展开或收起详细信息。配置 token 预算时,标题栏会显示预算进度;没有配置 token 预算的目标不会显示进度条。 -使用目标条中的操作可以暂停进行中的目标、继续已暂停或已阻塞的目标,或取消当前目标。取消操作需要确认,因为取消后无法继续。 +使用目标条中的操作可以暂停进行中的目标、继续已暂停或已阻塞的目标,或取消当前目标。点击继续会启动下一轮目标工作,Agent 会继续处理该目标。取消操作需要确认,因为取消后无法继续。 ## 安排后续目标 diff --git a/packages/agent-core-v2/src/agent/goal/goal.ts b/packages/agent-core-v2/src/agent/goal/goal.ts index a6aee9bba5..1d1702c630 100644 --- a/packages/agent-core-v2/src/agent/goal/goal.ts +++ b/packages/agent-core-v2/src/agent/goal/goal.ts @@ -19,6 +19,7 @@ export interface GoalReasonInput { } export interface ResumeGoalInput extends GoalReasonInput { + readonly continueIfPaused?: boolean; readonly continueIfBlocked?: boolean; } diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index 2ee672f0b1..e8217a6a9d 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -215,6 +215,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { private readonly wallClockDeadline = this._register(new MutableDisposable()); private liveWallClockStartedAt?: number; private pendingContinuation?: PendingContinuation; + private resumeContinuation?: { readonly turnId: number; readonly goalId: string }; constructor( @IWireService private readonly wire: IWireService, @@ -417,8 +418,11 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { `Cannot resume a goal in status "${state.status}"`, ); } + const continuePaused = + actor === 'user' && state.status === 'paused' && input.continueIfPaused === true; const shouldContinue = - state.status === 'blocked' && input.continueIfBlocked === true && actor === 'user'; + continuePaused || + (actor === 'user' && state.status === 'blocked' && input.continueIfBlocked === true); const snapshot = this.applyLifecycle(state, 'active', input.reason, actor); if (!shouldContinue) return snapshot; const budgetBlocked = this.blockIfBudgetReached(this.requireState()); @@ -430,6 +434,8 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { await this.settleGoalAfterContinuationFailure(error, state.goalId); throw error; } + } else if (continuePaused && this.liveTurnId !== undefined) { + this.resumeContinuation = { turnId: this.liveTurnId, goalId: state.goalId }; } return snapshot; } @@ -670,6 +676,17 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { result: Pick, ): Promise { const { goalId, lifecycleGoalId, starterTurn } = this.clearTurnTracking(turnId); + const resumeContinuation = this.resumeContinuation; + if (resumeContinuation?.turnId === turnId) this.resumeContinuation = undefined; + if (resumeContinuation?.turnId === turnId && result.reason === 'cancelled') { + const state = this.goalState; + if (state === null || state.status !== 'active' || state.goalId !== resumeContinuation.goalId) { + return; + } + if (this.blockIfBudgetReached(state) !== null) return; + this.launchContinuationTurn(resumeContinuation.goalId); + return; + } if (goalId === undefined || lifecycleGoalId === undefined) return; if ( result.reason === 'blocked' || @@ -856,6 +873,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { opts: { readonly emit?: boolean; readonly track?: boolean; readonly preserveLiveContinuation?: boolean } = {}, ): void { if (this.goalState === null) return; + this.resumeContinuation = undefined; this.cancelPendingContinuation(opts.preserveLiveContinuation === true); this.wallClockDeadline.clear(); this.liveWallClockStartedAt = undefined; @@ -879,6 +897,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { if (status === 'active') { this.liveWallClockStartedAt = this.deadlineScheduler.now(); } else if (state.status === 'active') { + this.resumeContinuation = undefined; this.cancelPendingContinuation( opts.preserveLiveContinuation === true, opts.cancellationReason, diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index 8d5c5cf03a..65edda89cd 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -128,7 +128,7 @@ export class SessionLegacyService implements ISessionLegacyService { await goal.pauseGoal({}); break; case 'resume': - await goal.resumeGoal({ continueIfBlocked: true }); + await goal.resumeGoal({ continueIfPaused: true, continueIfBlocked: true }); break; case 'cancel': await goal.cancelGoal({}); 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 a016f115a7..70906f082d 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -1111,16 +1111,39 @@ describe('AgentGoalService core workflow hooks', () => { expect(loopService.launches).toHaveLength(1); }); - it('does not launch a continuation when a paused goal resumes', async () => { + it('does not launch a continuation when a paused goal resumes by default', async () => { await goals.createGoal({ objective: 'finish the task' }); await goals.pauseGoal(); - const resumed = await goals.resumeGoal({ continueIfBlocked: true }); + const resumed = await goals.resumeGoal(); expect(resumed.status).toBe('active'); expect(loopService.launches).toEqual([]); }); + it('starts one continuation when a caller opts to resume a paused goal', async () => { + await goals.createGoal({ objective: 'finish the task' }); + await goals.pauseGoal(); + + const resumed = await goals.resumeGoal({ continueIfPaused: true }); + + expect(resumed.status).toBe('active'); + expect(loopService.launches).toHaveLength(1); + }); + + it('starts a continuation after an opted paused resume waits for a cancelled turn', async () => { + await startLiveContinuation(); + const enqueue = vi.mocked(loopService.enqueue); + + await goals.pauseGoal(); + const resumed = await goals.resumeGoal({ continueIfPaused: true }); + endTurn(eventBus, makeTurn(41), { reason: 'cancelled' }); + + await vi.waitFor(() => expect(enqueue).toHaveBeenCalledTimes(2)); + expect(resumed.status).toBe('active'); + expect(goals.getGoal().goal?.status).toBe('active'); + }); + it('counts an active goal turn and launches the next continuation', async () => { await goals.createGoal({ objective: 'finish the task' }); diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 375d822e7d..e7f3ed4ffd 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -219,7 +219,7 @@ describe('server-v2 /api/v1/sessions', () => { expect(body.details?.[0]?.path).toBe('web_log'); }); - async function createBlockedGoalRig() { + async function createStoppedGoalRig(status: 'paused' | 'blocked') { const cwd = home as string; const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); const id = created.body.data.id; @@ -237,10 +237,11 @@ describe('server-v2 /api/v1/sessions', () => { const events: DomainEvent[] = []; const subscription = eventBus.subscribe((event) => events.push(event)); - const blocked = await postJson<{ status: string }>(agentRpc(IAgentGoalService, 'markBlocked', id), { - reason: 'need credentials', - }); - if (blocked.body.data.status !== 'blocked') throw new Error('expected a blocked goal'); + const stopped = await postJson<{ status: string }>( + agentRpc(IAgentGoalService, status === 'blocked' ? 'markBlocked' : 'pauseGoal', id), + status === 'blocked' ? { reason: 'need credentials' } : {}, + ); + if (stopped.body.data.status !== status) throw new Error(`expected a ${status} goal`); return { id, @@ -255,6 +256,10 @@ describe('server-v2 /api/v1/sessions', () => { }; } + async function createBlockedGoalRig() { + return createStoppedGoalRig('blocked'); + } + it('creates a session from metadata.cwd', async () => { const cwd = home as string; const { status, body } = await postJson('/api/v1/sessions', { @@ -519,6 +524,20 @@ describe('server-v2 /api/v1/sessions', () => { } }); + it('starts one continuation when the Web profile resumes a paused goal', async () => { + const rig = await createStoppedGoalRig('paused'); + try { + const resumed = await postJson(`/api/v1/sessions/${rig.id}/profile`, { + agent_config: { goal_control: 'resume' }, + }); + + expect(resumed.body.code).toBe(0); + expect(goalContinuationStarts(rig.events)).toHaveLength(1); + } finally { + await rig.cancel(); + } + }); + it('returns the active goal when the Web refreshes after blocked-goal resume', async () => { const rig = await createBlockedGoalRig(); try {