From b594e07378413346fc26c7ecdf399ae220045ff9 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 24 Sep 2026 15:25:35 +0800 Subject: [PATCH 1/7] feat: allow turn-level session fork, including while a turn is running The session fork REST action now accepts turn_index to fork from an earlier user-visible turn, and a live session no longer rejects fork while a turn is running or queued: when quiescence cannot be acquired the fork is capped at the last completed turn (explicit indices beyond it fail request.invalid; only a session with no completed turn yet still fails session.fork_active_turn). --- .changeset/fork-at-turn.md | 5 + docs/en/reference/server-api.md | 12 +- docs/zh/reference/server-api.md | 12 +- .../internal/forkTurnSlice.ts | 29 ++- .../sessionLifecycleService.ts | 94 ++++++--- .../sessionLifecycle/forkTurnSlice.test.ts | 79 ++++++- packages/kap-server/src/protocol/session.ts | 1 + packages/kap-server/src/routes/sessions.ts | 2 + packages/kap-server/test/sessions.test.ts | 62 ++++++ packages/node-sdk/src/sdk-rpc-client-v2.ts | 13 +- .../test/session-prompt-events.test.ts | 199 ++++++++++++++++++ 11 files changed, 461 insertions(+), 47 deletions(-) create mode 100644 .changeset/fork-at-turn.md diff --git a/.changeset/fork-at-turn.md b/.changeset/fork-at-turn.md new file mode 100644 index 00000000000..cbeedca9224 --- /dev/null +++ b/.changeset/fork-at-turn.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Sessions can now be forked while a response is still running — the fork stops at the last completed turn — and the `:fork` server endpoint accepts a `turn_index` to fork from an earlier turn. diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md index 0bdd3f54067..a7248deac4a 100644 --- a/docs/en/reference/server-api.md +++ b/docs/en/reference/server-api.md @@ -708,16 +708,20 @@ Session actions are dispatched through one route: the path tail is parsed as `{s #### `POST /api/v1/sessions/{session_id}:fork` -Copies the session — its transcript, agent state, and files — into a new session in the same workspace, and broadcasts `event.session.created`. Forking is rejected while any of the session's agents has an active turn. +Copies the session — its transcript, agent state, and files — into a new session in the same workspace, and broadcasts `event.session.created`. Pass `turn_index` to fork from an earlier turn instead of the latest state: the copy keeps only the conversation through that user-visible turn (turn `0` is the first prompt) and discards everything after it. + +Forking is allowed while the session has an active or queued turn. In that case the fork point is capped at the last completed turn: a `turn_index` beyond it fails `40001`, and omitting `turn_index` forks through the last completed turn rather than the very latest state. | Parameter | In | Type | Description | | --- | --- | --- | --- | | `title` | body | string | Title for the fork (at least 1 character). Default `Fork: ` | | `metadata` | body | object | Custom metadata for the fork | +| `turn_index` | body | integer | Zero-based index of the user-visible turn to fork from. Default: the latest state, or the last completed turn while a turn is active | On success, `data` is [the session object](#the-session-object) of the new session. -- `40901`: the session has an active turn and cannot be forked +- `40001`: `turn_index` is not a non-negative integer, or points past the last available turn +- `40901`: a turn is active and no turn has completed yet, so there is nothing to fork #### `POST /api/v1/sessions/{session_id}:compact` @@ -787,7 +791,7 @@ On success, `data` is `{ items, has_more }` where each item is [the session obje #### `POST /api/v1/sessions/{session_id}/children` -Creates a child session: a fork of this session recorded as its child, so it shows up under `GET /api/v1/sessions/{session_id}/children`. The same active-turn restriction as `:fork` applies. +Creates a child session: a fork of this session recorded as its child, so it shows up under `GET /api/v1/sessions/{session_id}/children`. The same rules as `:fork` apply while a turn is active: the copy is capped at the last completed turn, and it is rejected only when no turn has completed yet. | Parameter | In | Type | Description | | --- | --- | --- | --- | @@ -797,7 +801,7 @@ Creates a child session: a fork of this session recorded as its child, so it sho On success, `data` is [the session object](#the-session-object) of the new session, and the server broadcasts `event.session.created`. -- `40901`: the session has an active turn and cannot be forked +- `40901`: a turn is active and no turn has completed yet, so there is nothing to fork #### `GET /api/v1/sessions/{session_id}/status` diff --git a/docs/zh/reference/server-api.md b/docs/zh/reference/server-api.md index 32cad85a455..aa44e90586b 100644 --- a/docs/zh/reference/server-api.md +++ b/docs/zh/reference/server-api.md @@ -708,16 +708,20 @@ schema 还接受 `agent_config` 内的 `system_prompt`、`tools`、`mcp_servers` #### `POST /api/v1/sessions/{session_id}:fork` -将会话——其转录、Agent 状态与文件——复制到同一工作区中的新会话,并广播 `event.session.created`。当会话中任一 Agent 有进行中的轮次时,fork 会被拒绝。 +将会话——其转录、Agent 状态与文件——复制到同一工作区中的新会话,并广播 `event.session.created`。传入 `turn_index` 可从更早的轮次分叉,而不是复制最新状态:副本只保留到该用户可见轮次为止的对话(轮次 `0` 是首个提示词),其后的内容全部丢弃。 + +会话有进行中或排队中的轮次时也可以 fork。此时分叉点以最后一个已完成轮次为上限:超出上限的 `turn_index` 返回 `40001`;不传 `turn_index` 则复制到最后一个已完成轮次,而不是最新状态。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | | `title` | body | string | fork 的标题(至少 1 个字符)。默认 `Fork: ` | | `metadata` | body | object | fork 的自定义元数据 | +| `turn_index` | body | integer | 要从中分叉的用户可见轮次下标,从 0 开始。默认为最新状态;有进行中轮次时为最后一个已完成轮次 | 成功时,`data` 为新会话的 [session 对象](#session-对象)。 -- `40901`:会话有进行中的轮次,无法 fork +- `40001`:`turn_index` 不是非负整数,或超出最后一个可用轮次 +- `40901`:有轮次正在进行且尚无任何已完成轮次,没有可 fork 的内容 #### `POST /api/v1/sessions/{session_id}:compact` @@ -787,7 +791,7 @@ schema 还接受 `agent_config` 内的 `system_prompt`、`tools`、`mcp_servers` #### `POST /api/v1/sessions/{session_id}/children` -创建子会话:fork 当前会话并记录为其子会话,因此会出现在 `GET /api/v1/sessions/{session_id}/children` 下。适用与 `:fork` 相同的进行中轮次限制。 +创建子会话:fork 当前会话并记录为其子会话,因此会出现在 `GET /api/v1/sessions/{session_id}/children` 下。适用与 `:fork` 相同的规则:有进行中轮次时复制到最后一个已完成轮次,仅当尚无已完成轮次时才会被拒绝。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | @@ -797,7 +801,7 @@ schema 还接受 `agent_config` 内的 `system_prompt`、`tools`、`mcp_servers` 成功时,`data` 为新会话的 [session 对象](#session-对象),并且服务端广播 `event.session.created`。 -- `40901`:会话有进行中的轮次,无法 fork +- `40901`:有轮次正在进行且尚无任何已完成轮次,没有可 fork 的内容 #### `GET /api/v1/sessions/{session_id}/status` diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts index 559346caba2..704645fc5ec 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts @@ -28,10 +28,7 @@ export function sliceMainRecordsAtTurn( sourceSessionId: string, turnIndex: number, ): MainTurnSlice { - const turnStarts: number[] = []; - for (let index = 0; index < records.length; index += 1) { - if (isUserVisibleTurnRecord(records[index]!)) turnStarts.push(index); - } + const turnStarts = userVisibleTurnStartIndices(records); const start = turnStarts[turnIndex]; if (start === undefined) { throw new Error2( @@ -77,6 +74,30 @@ export function sliceSubagentRecordsAtTime( return records.slice(0, end); } +export function countCompletedUserVisibleTurns(records: readonly WireRecord[]): number { + const turnStarts = userVisibleTurnStartIndices(records); + let completed = 0; + for (let start = 0; start < turnStarts.length; start += 1) { + const from = turnStarts[start]! + 1; + const to = turnStarts[start + 1] ?? records.length; + for (let index = from; index < to; index += 1) { + if (records[index]!.type === 'turn.ended') { + completed += 1; + break; + } + } + } + return completed; +} + +function userVisibleTurnStartIndices(records: readonly WireRecord[]): number[] { + const turnStarts: number[] = []; + for (let index = 0; index < records.length; index += 1) { + if (isUserVisibleTurnRecord(records[index]!)) turnStarts.push(index); + } + return turnStarts; +} + function isUserVisibleTurnRecord(record: WireRecord): boolean { if (record.type !== 'context.append_message') return false; const message = asRecord(record['message']); diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 0db40969805..b4d6a59d52c 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -101,6 +101,8 @@ import { agentScopeOf, sessionDirOf, sessionScopeOf } from './internal/addressin import { SessionArchived, SessionDeleted } from './sessionLifecycleEvents'; import { assertForkTurnIndex, + countCompletedUserVisibleTurns, + type MainTurnSlice, sliceMainRecordsAtTurn, sliceSubagentRecordsAtTime, } from './internal/forkTurnSlice'; @@ -511,13 +513,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec for (const agent of sourceAgents.list()) { const agentHandle = sourceAgents.handleOf(agent.agentId); if (agentHandle === undefined) continue; - if (agentHandle.accessor.get(IAgentLoopService).snapshot().state === 'running') { - throw new Error2( - ErrorCodes.SESSION_FORK_ACTIVE_TURN, - `Session "${sourceId}" cannot be forked while a turn is running`, - { details: { sessionId: sourceId } }, - ); - } await agentHandle.accessor.get(IEventDispatcher).flush(); } await this.appendLogStore.flush(); @@ -528,20 +523,13 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec let targetSessionDir: string | undefined; const quiescenceHolds: IDisposable[] = []; try { + let busy = false; if (sourceHandle !== undefined) { - const sourceAgents = sourceHandle.accessor.get(IAgentLifecycleService); - for (const agent of sourceAgents.list()) { - const agentHandle = sourceAgents.handleOf(agent.agentId); - if (agentHandle === undefined) continue; - const hold = agentHandle.accessor.get(IAgentLoopService).tryAcquireQuiescence(); - if (hold === undefined) { - throw new Error2( - ErrorCodes.SESSION_FORK_ACTIVE_TURN, - `Session "${sourceId}" cannot be forked while a turn is running or queued, or while another fork is copying it`, - { details: { sessionId: sourceId, agentId: agent.agentId } }, - ); - } - quiescenceHolds.push(hold); + const holds = this.tryAcquireForkQuiescence(sourceHandle); + if (holds === undefined) { + busy = true; + } else { + quiescenceHolds.push(...holds); } } await drainSessionMetadataWrites(); @@ -566,14 +554,12 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ); } - const turnSlice = - opts.turnIndex === undefined - ? undefined - : sliceMainRecordsAtTurn( - flattenChain(await this.readSourceWireRecords(sourceHandle, sourceId, MAIN_AGENT_ID)), - sourceId, - opts.turnIndex, - ); + const turnSlice = await this.resolveForkTurnSlice( + sourceHandle, + sourceId, + opts.turnIndex, + busy, + ); targetSessionDir = sessionDirOf(this.bootstrap.homeDir, this.handlerScope, targetId); await this.copySessionFiles( @@ -702,6 +688,58 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec }); } + private tryAcquireForkQuiescence(sourceHandle: ISessionScopeHandle): IDisposable[] | undefined { + const holds: IDisposable[] = []; + const sourceAgents = sourceHandle.accessor.get(IAgentLifecycleService); + for (const agent of sourceAgents.list()) { + const agentHandle = sourceAgents.handleOf(agent.agentId); + if (agentHandle === undefined) continue; + const hold = agentHandle.accessor.get(IAgentLoopService).tryAcquireQuiescence(); + if (hold === undefined) { + for (const acquired of holds) acquired.dispose(); + return undefined; + } + holds.push(hold); + } + return holds; + } + + private async resolveForkTurnSlice( + sourceHandle: ISessionScopeHandle | undefined, + sourceId: string, + turnIndex: number | undefined, + busy: boolean, + ): Promise { + if (!busy) { + return turnIndex === undefined + ? undefined + : sliceMainRecordsAtTurn( + flattenChain(await this.readSourceWireRecords(sourceHandle, sourceId, MAIN_AGENT_ID)), + sourceId, + turnIndex, + ); + } + const records = flattenChain( + await this.readSourceWireRecords(sourceHandle, sourceId, MAIN_AGENT_ID), + ); + const completedTurns = countCompletedUserVisibleTurns(records); + if (completedTurns === 0) { + throw new Error2( + ErrorCodes.SESSION_FORK_ACTIVE_TURN, + `Session "${sourceId}" cannot be forked yet because no turn has completed`, + { details: { sessionId: sourceId } }, + ); + } + if (turnIndex !== undefined && turnIndex > completedTurns - 1) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + `Turn ${String(turnIndex)} was not found in session "${sourceId}"`, + { details: { turnIndex, availableTurns: completedTurns } }, + ); + } + return sliceMainRecordsAtTurn(records, sourceId, turnIndex ?? completedTurns - 1); + } + private async resolveSourceTitle(sourceId: string): Promise { const live = this.sessions.get(sourceId); if (live !== undefined) { diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts index a67eada7e47..64a8f3028b7 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { sliceMainRecordsAtTurn } from '#/workspace/sessionLifecycle/internal/forkTurnSlice'; +import { + countCompletedUserVisibleTurns, + sliceMainRecordsAtTurn, +} from '#/workspace/sessionLifecycle/internal/forkTurnSlice'; import type { WireRecord } from '#/wire/record'; function userTurnRecord(text: string, time: number): WireRecord { @@ -15,6 +18,14 @@ function userTurnRecord(text: string, time: number): WireRecord { }; } +function turnEndedRecord( + turnId: number, + time: number, + reason: 'completed' | 'cancelled' | 'failed' | 'blocked' = 'completed', +): WireRecord { + return { type: 'turn.ended', agentId: 'main', turnId, reason, time }; +} + describe('sliceMainRecordsAtTurn', () => { it('derives a fork last prompt from readable client metadata while retaining the original message', () => { const record: WireRecord = { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'serialized' }], origin: { kind: 'user', clientMetadata: [{ display_text: 'Save button · Rename it' }] } }, time: 2 }; @@ -49,3 +60,69 @@ describe('sliceMainRecordsAtTurn', () => { expect(types).toContain('context.append_message'); }); }); + +describe('countCompletedUserVisibleTurns', () => { + it('returns zero for empty records and for a first turn still running', () => { + expect(countCompletedUserVisibleTurns([])).toBe(0); + expect( + countCompletedUserVisibleTurns([ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + userTurnRecord('running turn', 2), + ]), + ).toBe(0); + }); + + it('counts turns closed by a turn.ended record and ignores a running tail turn', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + userTurnRecord('first', 2), + turnEndedRecord(0, 3), + userTurnRecord('second', 4), + turnEndedRecord(1, 5), + userTurnRecord('third still running', 6), + ]; + expect(countCompletedUserVisibleTurns(records)).toBe(2); + }); + + it.each(['cancelled', 'failed', 'blocked'] as const)( + 'counts a turn ended with reason "%s" as completed', + (reason) => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + userTurnRecord('interrupted', 2), + turnEndedRecord(0, 3, reason), + ]; + expect(countCompletedUserVisibleTurns(records)).toBe(1); + }, + ); + + it('treats a steer-split pair sharing one turn.ended as a single completable turn', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + userTurnRecord('original prompt', 2), + { type: 'turn.steer', turnId: 0, messageId: 'msg_steer', origin: { kind: 'user' }, time: 3 }, + userTurnRecord('steered input', 4), + turnEndedRecord(0, 5), + ]; + expect(countCompletedUserVisibleTurns(records)).toBe(1); + }); + + it('does not treat system-triggered appends as turn starts', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + userTurnRecord('real prompt', 2), + turnEndedRecord(0, 3), + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'system follow-up' }], + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }, + time: 4, + }, + turnEndedRecord(1, 5), + ]; + expect(countCompletedUserVisibleTurns(records)).toBe(1); + }); +}); diff --git a/packages/kap-server/src/protocol/session.ts b/packages/kap-server/src/protocol/session.ts index f408383451b..4a4a4fb33dc 100644 --- a/packages/kap-server/src/protocol/session.ts +++ b/packages/kap-server/src/protocol/session.ts @@ -75,6 +75,7 @@ export type SessionCreate = z.infer; export const sessionForkSchema = z.object({ title: z.string().min(1).optional(), metadata: z.record(z.string(), z.unknown()).optional(), + turn_index: z.number().int().nonnegative().optional(), }); export type SessionFork = z.infer; diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 386b01e9201..ddd9ed4e920 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -162,6 +162,7 @@ const sessionActionRequestSchema = z.preprocess( instruction: z.string().optional(), count: z.number().int().positive().optional(), page_size: z.number().int().min(1).max(100).optional(), + turn_index: z.number().int().nonnegative().optional(), }), ); @@ -900,6 +901,7 @@ async function forkSessionAction( sourceSessionId: id, title: body.title, metadata: body.metadata, + turnIndex: body.turn_index, }); const session = toWireSession( { ...meta, workspaceId: forkHandler.workspaceId }, diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 9b558a1f0ca..bcbc8ed5d2d 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -1282,6 +1282,68 @@ describe('server-v2 /api/v1/sessions', () => { expect(forkedState.titleKind).toBe('custom'); }); + it('passes turn_index through :fork to the session manager fork', async () => { + const cwd = home as string; + const parent = await postJson('/api/v1/sessions', { metadata: { cwd } }); + const parentId = parent.body.data.id; + const manager = (server as RunningServer).core.accessor.get(ISessionManager); + const fork = vi + .spyOn(manager, 'fork') + .mockRejectedValue(new Error2(ErrorCodes.SESSION_BUSY, 'session is busy')); + + try { + const response = await postJson(`/api/v1/sessions/${parentId}:fork`, { + turn_index: 2, + }); + + expect(response.body.code).toBe(40901); + expect(fork).toHaveBeenCalledWith({ + sourceSessionId: parentId, + title: undefined, + metadata: undefined, + turnIndex: 2, + }); + } finally { + fork.mockRestore(); + } + }); + + it('forks through :fork without turn_index as before', async () => { + const cwd = home as string; + const parent = await postJson('/api/v1/sessions', { metadata: { cwd } }); + const parentId = parent.body.data.id; + const manager = (server as RunningServer).core.accessor.get(ISessionManager); + const fork = vi.spyOn(manager, 'fork'); + + try { + const forked = await postJson(`/api/v1/sessions/${parentId}:fork`, {}); + + expect(forked.body.code).toBe(0); + expect(fork).toHaveBeenCalledWith({ + sourceSessionId: parentId, + title: undefined, + metadata: undefined, + turnIndex: undefined, + }); + } finally { + fork.mockRestore(); + } + }); + + it.each([{ turn_index: 1.5 }, { turn_index: -1 }])( + 'rejects an out-of-contract turn_index on :fork (%s)', + async (body) => { + const cwd = home as string; + const parent = await postJson('/api/v1/sessions', { metadata: { cwd } }); + const response = await postJson( + `/api/v1/sessions/${parent.body.data.id}:fork`, + body, + ); + expect(response.body.code).toBe(40001); + expect(response.body.details?.[0]?.path).toBe('turn_index'); + }, + ); + it.each([ { count: 1, code: 0, prompts: [] as string[], texts: [] as string[] }, { count: 2, code: 40911, prompts: ['original prompt'], texts: ['answer before steer', 'steered prompt', 'answer after steer', 'second steer', 'answer after second steer'] }, diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 5c463473159..c366c63be8c 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -1439,12 +1439,13 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { /** * Through `engineAccessor` (the handler chain's `ISessionLifecycleService.fork`) because the * klient facade fork takes no explicit target id. `turnIndex` truncation and - * the live-source busy rejection (v1's `SESSION_FORK_ACTIVE_TURN`) are the - * engine's own now, so their failures cross the in-process call with v1's - * codes and details (`request.invalid` with `{turnIndex, availableTurns}` / - * `session.fork_active_turn`). The default title still differs by design - * (v1: "New Session", v2: "Fork: ") — pass an explicit title for - * identical results. + * the busy-source handling (a live session mid-turn forks through its last + * completed turn; v1's `SESSION_FORK_ACTIVE_TURN` only when no turn has + * completed yet) are the engine's own now, so their failures cross the + * in-process call with v1's codes and details (`request.invalid` with + * `{turnIndex, availableTurns}` / `session.fork_active_turn`). The default + * title still differs by design (v1: "New Session", v2: "Fork: ") — + * pass an explicit title for identical results. */ override async forkSession(input: ForkSessionInput): Promise { // The source session's reads (metadata, wire flush) stay atomic against diff --git a/packages/node-sdk/test/session-prompt-events.test.ts b/packages/node-sdk/test/session-prompt-events.test.ts index e0eea9e7d96..a8241d9a62d 100644 --- a/packages/node-sdk/test/session-prompt-events.test.ts +++ b/packages/node-sdk/test/session-prompt-events.test.ts @@ -771,6 +771,187 @@ describe('Session.prompt events', () => { } }); + it('forks a running session clamped to the last completed turn', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); + + try { + await configureFakeProvider(harness); + const source = await harness.createSession({ id: 'ses_turn_fork_running_source', workDir }); + await runPrompt(source, 'first question', 'first answer'); + await runPrompt(source, 'second question', 'second answer'); + + const gate = deferredResponse(); + fetchStub!.mockImplementationOnce(() => gate.promise); + const started = waitForEvent(source, (event) => event.type === 'turn.started'); + const ended = waitForEvent(source, (event) => event.type === 'turn.ended'); + await source.prompt('third question'); + await started; + + const fork = await harness.forkSession({ + id: source.id, + forkId: 'ses_turn_fork_running_child', + }); + await fork.close(); + const resumed = await harness.resumeSession({ id: fork.id }); + const replayText = visibleReplayText(resumed.getResumeState()?.agents['main']?.replay ?? []); + + expect(replayText).toEqual([ + 'user:first question', + 'assistant:first answer', + 'user:second question', + 'assistant:second answer', + ]); + + gate.resolve(sseResponse('third answer')); + await ended; + } finally { + await harness.close(); + } + }); + + it('forks a running session at an explicit completed turn', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); + + try { + await configureFakeProvider(harness); + const source = await harness.createSession({ id: 'ses_turn_fork_running_pick_source', workDir }); + await runPrompt(source, 'first question', 'first answer'); + await runPrompt(source, 'second question', 'second answer'); + + const gate = deferredResponse(); + fetchStub!.mockImplementationOnce(() => gate.promise); + const started = waitForEvent(source, (event) => event.type === 'turn.started'); + const ended = waitForEvent(source, (event) => event.type === 'turn.ended'); + await source.prompt('third question'); + await started; + + const fork = await harness.forkSession({ + id: source.id, + forkId: 'ses_turn_fork_running_pick_child', + turnIndex: 0, + }); + await fork.close(); + const resumed = await harness.resumeSession({ id: fork.id }); + const replayText = visibleReplayText(resumed.getResumeState()?.agents['main']?.replay ?? []); + + expect(replayText).toEqual(['user:first question', 'assistant:first answer']); + + gate.resolve(sseResponse('third answer')); + await ended; + } finally { + await harness.close(); + } + }); + + it('rejects an explicit turn beyond the last completed turn while running', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); + + try { + await configureFakeProvider(harness); + const source = await harness.createSession({ id: 'ses_turn_fork_running_range_source', workDir }); + await runPrompt(source, 'first question', 'first answer'); + await runPrompt(source, 'second question', 'second answer'); + + const gate = deferredResponse(); + fetchStub!.mockImplementationOnce(() => gate.promise); + const started = waitForEvent(source, (event) => event.type === 'turn.started'); + const ended = waitForEvent(source, (event) => event.type === 'turn.ended'); + await source.prompt('third question'); + await started; + + await expect( + harness.forkSession({ id: source.id, turnIndex: 2 }), + ).rejects.toMatchObject({ + name: 'KimiError', + code: 'request.invalid', + details: { turnIndex: 2, availableTurns: 2 }, + }); + + gate.resolve(sseResponse('third answer')); + await ended; + } finally { + await harness.close(); + } + }); + + it('rejects forking a running session without any completed turn', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); + + try { + await configureFakeProvider(harness); + const source = await harness.createSession({ id: 'ses_turn_fork_first_turn_source', workDir }); + + const gate = deferredResponse(); + fetchStub!.mockImplementationOnce(() => gate.promise); + const started = waitForEvent(source, (event) => event.type === 'turn.started'); + const ended = waitForEvent(source, (event) => event.type === 'turn.ended'); + await source.prompt('first question'); + await started; + + await expect(harness.forkSession({ id: source.id })).rejects.toMatchObject({ + name: 'KimiError', + code: 'session.fork_active_turn', + }); + + gate.resolve(sseResponse('first answer')); + await ended; + } finally { + await harness.close(); + } + }); + + it('clamps a running fork when another prompt is queued behind the active turn', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); + + try { + await configureFakeProvider(harness); + const source = await harness.createSession({ id: 'ses_turn_fork_queued_source', workDir }); + await runPrompt(source, 'first question', 'first answer'); + + const gate = deferredResponse(); + fetchStub!.mockImplementationOnce(() => gate.promise); + const started = waitForEvent(source, (event) => event.type === 'turn.started'); + await source.prompt('second question'); + await started; + await source.prompt('third question'); + + const fork = await harness.forkSession({ + id: source.id, + forkId: 'ses_turn_fork_queued_child', + }); + await fork.close(); + const resumed = await harness.resumeSession({ id: fork.id }); + const replayText = visibleReplayText(resumed.getResumeState()?.agents['main']?.replay ?? []); + + expect(replayText).toEqual(['user:first question', 'assistant:first answer']); + + const endedTurns: unknown[] = []; + const unsubscribe = source.onEvent((event) => { + if (event.type === 'turn.ended') endedTurns.push(event); + }); + gate.resolve(sseResponse('second answer')); + await vi.waitFor( + () => { + expect(endedTurns).toHaveLength(2); + }, + { timeout: 5_000 }, + ); + unsubscribe(); + } finally { + await harness.close(); + } + }); + it('rejects empty prompt input', async () => { const homeDir = await makeTempDir(); const workDir = await makeTempDir(); @@ -802,6 +983,24 @@ async function runPrompt( await done; } +function sseResponse(text: string): Response { + return new Response(sseBody(text), { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); +} + +function deferredResponse(): { + promise: Promise; + resolve: (response: Response) => void; +} { + let resolve!: (response: Response) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + function visibleReplayText( records: readonly { readonly type: string; From f72cd67205848333f2cbf27a41e25eb04a8d1901 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 8 Oct 2026 15:09:39 +0800 Subject: [PATCH 2/7] fix: correct busy-fork slicing for steered turns, non-turn quiescence holds, and fork outcome - clamp busy forks to the last completed user-visible turn index instead of the completed-turn count, so a steer-split turn keeps the steered input and its answer, and explicit indexes into it are accepted - reject forking while quiescence is held by a non-turn operation such as manual compaction, undo, or another fork, instead of treating it as turn activity and copying a wire in an inconsistent state - derive the fork's lastTurnReason from the retained wire's terminal turn.ended rather than copying the source metadata, which an active turn may have cleared --- packages/agent-core-v2/src/agent/loop/loop.ts | 2 + .../src/agent/loop/loopService.ts | 8 ++ .../internal/forkTurnSlice.ts | 26 +++++- .../sessionLifecycleService.ts | 32 ++++++-- .../agent-core-v2/test/agent/loop/stubs.ts | 1 + .../toolSelect/toolSelectService.test.ts | 4 + .../sessionLifecycle/forkTurnSlice.test.ts | 65 ++++++++++++--- packages/kap-server/test/sessions.test.ts | 80 ++++++++++++++++++- .../test/session-prompt-events.test.ts | 72 +++++++++++++++++ 9 files changed, 265 insertions(+), 25 deletions(-) diff --git a/packages/agent-core-v2/src/agent/loop/loop.ts b/packages/agent-core-v2/src/agent/loop/loop.ts index b82190b040e..6284723cef5 100644 --- a/packages/agent-core-v2/src/agent/loop/loop.ts +++ b/packages/agent-core-v2/src/agent/loop/loop.ts @@ -216,6 +216,8 @@ export interface IAgentLoopService { tryAcquireQuiescence(): IDisposable | undefined; + hasTurnActivity(): boolean; + notify(note?: LoopNotify): LoopNotifyHandle; buildAttachBundle(): MachineEngineAttachBundle; diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 2c2d69c6def..e3f23502eef 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -777,6 +777,14 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { return toDisposable(() => this.releaseQuiescence()); } + hasTurnActivity(): boolean { + return ( + this.active !== undefined || + this.hasPendingRequests() || + this.pendingMachineTurn !== undefined + ); + } + private releaseQuiescence(): void { if (this.quiescenceDepth === 0) return; this.quiescenceDepth -= 1; diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts index 704645fc5ec..889791abc93 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts @@ -11,6 +11,7 @@ export interface MainTurnSlice { readonly records: readonly WireRecord[]; readonly cutoffTime?: number; readonly lastPrompt?: string; + readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed'; } export function assertForkTurnIndex(turnIndex: number | undefined): void { @@ -55,6 +56,7 @@ export function sliceMainRecordsAtTurn( records: retained, cutoffTime: cutoffTimes.length === 0 ? undefined : Math.max(...cutoffTimes), lastPrompt, + lastTurnReason: turnOutcomeOfRecords(retained), }; } @@ -74,20 +76,22 @@ export function sliceSubagentRecordsAtTime( return records.slice(0, end); } -export function countCompletedUserVisibleTurns(records: readonly WireRecord[]): number { +export function lastCompletedUserVisibleTurnIndex( + records: readonly WireRecord[], +): number | undefined { const turnStarts = userVisibleTurnStartIndices(records); - let completed = 0; + let lastCompleted: number | undefined; for (let start = 0; start < turnStarts.length; start += 1) { const from = turnStarts[start]! + 1; const to = turnStarts[start + 1] ?? records.length; for (let index = from; index < to; index += 1) { if (records[index]!.type === 'turn.ended') { - completed += 1; + lastCompleted = start; break; } } } - return completed; + return lastCompleted; } function userVisibleTurnStartIndices(records: readonly WireRecord[]): number[] { @@ -224,6 +228,20 @@ function recordTime(record: WireRecord): number | undefined { return undefined; } +function turnOutcomeOfRecords( + records: readonly WireRecord[], +): 'completed' | 'cancelled' | 'failed' | undefined { + for (let index = records.length - 1; index >= 0; index -= 1) { + const record = records[index]!; + if (record.type !== 'turn.ended') continue; + const reason = record['reason']; + if (reason === 'completed' || reason === 'cancelled') return reason; + if (reason === 'failed' || reason === 'blocked') return 'failed'; + return undefined; + } + return undefined; +} + function promptMetadataFromTurnRecord(record: WireRecord): string | undefined { if (record.type !== 'context.append_message') return undefined; const message = asRecord(record['message']); diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index b4d6a59d52c..f94b852f920 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -101,7 +101,7 @@ import { agentScopeOf, sessionDirOf, sessionScopeOf } from './internal/addressin import { SessionArchived, SessionDeleted } from './sessionLifecycleEvents'; import { assertForkTurnIndex, - countCompletedUserVisibleTurns, + lastCompletedUserVisibleTurnIndex, type MainTurnSlice, sliceMainRecordsAtTurn, sliceSubagentRecordsAtTime, @@ -527,6 +527,13 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec if (sourceHandle !== undefined) { const holds = this.tryAcquireForkQuiescence(sourceHandle); if (holds === undefined) { + if (!this.hasTurnActivity(sourceHandle)) { + throw new Error2( + ErrorCodes.SESSION_FORK_ACTIVE_TURN, + `Session "${sourceId}" cannot be forked while another operation is in progress`, + { details: { sessionId: sourceId } }, + ); + } busy = true; } else { quiescenceHolds.push(...holds); @@ -637,7 +644,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec agents, custom: forkCustomMetadata(sourceMeta?.custom, opts.metadata), lastPrompt: turnSlice === undefined ? sourceMeta?.lastPrompt : turnSlice.lastPrompt, - lastTurnReason: sourceMeta?.lastTurnReason, + lastTurnReason: + turnSlice === undefined ? sourceMeta?.lastTurnReason : turnSlice.lastTurnReason, }; await this.docs.set( sessionScopeOf(this.handlerScope, targetId), @@ -704,6 +712,16 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec return holds; } + private hasTurnActivity(sourceHandle: ISessionScopeHandle): boolean { + const sourceAgents = sourceHandle.accessor.get(IAgentLifecycleService); + for (const agent of sourceAgents.list()) { + const agentHandle = sourceAgents.handleOf(agent.agentId); + if (agentHandle === undefined) continue; + if (agentHandle.accessor.get(IAgentLoopService).hasTurnActivity()) return true; + } + return false; + } + private async resolveForkTurnSlice( sourceHandle: ISessionScopeHandle | undefined, sourceId: string, @@ -722,22 +740,22 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const records = flattenChain( await this.readSourceWireRecords(sourceHandle, sourceId, MAIN_AGENT_ID), ); - const completedTurns = countCompletedUserVisibleTurns(records); - if (completedTurns === 0) { + const lastCompleted = lastCompletedUserVisibleTurnIndex(records); + if (lastCompleted === undefined) { throw new Error2( ErrorCodes.SESSION_FORK_ACTIVE_TURN, `Session "${sourceId}" cannot be forked yet because no turn has completed`, { details: { sessionId: sourceId } }, ); } - if (turnIndex !== undefined && turnIndex > completedTurns - 1) { + if (turnIndex !== undefined && turnIndex > lastCompleted) { throw new Error2( ErrorCodes.REQUEST_INVALID, `Turn ${String(turnIndex)} was not found in session "${sourceId}"`, - { details: { turnIndex, availableTurns: completedTurns } }, + { details: { turnIndex, availableTurns: lastCompleted + 1 } }, ); } - return sliceMainRecordsAtTurn(records, sourceId, turnIndex ?? completedTurns - 1); + return sliceMainRecordsAtTurn(records, sourceId, turnIndex ?? lastCompleted); } private async resolveSourceTitle(sourceId: string): Promise { diff --git a/packages/agent-core-v2/test/agent/loop/stubs.ts b/packages/agent-core-v2/test/agent/loop/stubs.ts index 5b71d3d03a9..ebba50899a4 100644 --- a/packages/agent-core-v2/test/agent/loop/stubs.ts +++ b/packages/agent-core-v2/test/agent/loop/stubs.ts @@ -198,6 +198,7 @@ export function stubLoopWithHooks(options: StubLoopOptions = {}): StubLoop { promptHandle: (id) => handles.get(id), cancel(target, reason) { cancels.push({ turnId: target?.turnId, reason }); if (target?.promptId !== undefined) return true; if (active === undefined || (target?.turnId !== undefined && active.id !== target.turnId)) return false; active.cancel(reason); return true; }, tryAcquireQuiescence: () => toDisposable(() => {}), + hasTurnActivity: () => active !== undefined || pending.length > 0, buildAttachBundle: () => stubAttachBundle(), attachEngine: () => stubAttachEngine(), resetMachineEngine: () => Promise.resolve(), diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts index 041f4de8907..f2fbb4c956e 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -247,6 +247,10 @@ class FakeLoopService implements IAgentLoopService { return toDisposable(() => {}); } + hasTurnActivity(): boolean { + return false; + } + buildAttachBundle(): never { throw new Error('unused in this suite'); } diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts index 64a8f3028b7..4215318a1a1 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { - countCompletedUserVisibleTurns, + lastCompletedUserVisibleTurnIndex, sliceMainRecordsAtTurn, } from '#/workspace/sessionLifecycle/internal/forkTurnSlice'; import type { WireRecord } from '#/wire/record'; @@ -61,18 +61,56 @@ describe('sliceMainRecordsAtTurn', () => { }); }); -describe('countCompletedUserVisibleTurns', () => { - it('returns zero for empty records and for a first turn still running', () => { - expect(countCompletedUserVisibleTurns([])).toBe(0); +describe('sliceMainRecordsAtTurn lastTurnReason', () => { + it('derives the outcome from the last retained turn.ended record', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + userTurnRecord('first', 2), + turnEndedRecord(0, 3, 'completed'), + userTurnRecord('second', 4), + turnEndedRecord(1, 5, 'cancelled'), + userTurnRecord('third', 6), + turnEndedRecord(2, 7, 'failed'), + ]; + expect(sliceMainRecordsAtTurn(records, 'ses_source', 0).lastTurnReason).toBe('completed'); + expect(sliceMainRecordsAtTurn(records, 'ses_source', 1).lastTurnReason).toBe('cancelled'); + expect(sliceMainRecordsAtTurn(records, 'ses_source', 2).lastTurnReason).toBe('failed'); + }); + + it('maps a blocked outcome to failed', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + userTurnRecord('blocked turn', 2), + turnEndedRecord(0, 3, 'blocked'), + ]; + expect(sliceMainRecordsAtTurn(records, 'ses_source', 0).lastTurnReason).toBe('failed'); + }); + + it('returns undefined when the slice retains no turn.ended record', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + userTurnRecord('original prompt', 2), + { type: 'turn.steer', turnId: 0, messageId: 'msg_steer', origin: { kind: 'user' }, time: 3 }, + userTurnRecord('steered input', 4), + turnEndedRecord(0, 5), + ]; + expect(sliceMainRecordsAtTurn(records, 'ses_source', 0).lastTurnReason).toBeUndefined(); + expect(sliceMainRecordsAtTurn(records, 'ses_source', 1).lastTurnReason).toBe('completed'); + }); +}); + +describe('lastCompletedUserVisibleTurnIndex', () => { + it('returns undefined for empty records and for a first turn still running', () => { + expect(lastCompletedUserVisibleTurnIndex([])).toBeUndefined(); expect( - countCompletedUserVisibleTurns([ + lastCompletedUserVisibleTurnIndex([ { type: 'metadata', protocol_version: '1.5', created_at: 1 }, userTurnRecord('running turn', 2), ]), - ).toBe(0); + ).toBeUndefined(); }); - it('counts turns closed by a turn.ended record and ignores a running tail turn', () => { + it('returns the last turn start closed by a turn.ended record and ignores a running tail turn', () => { const records: WireRecord[] = [ { type: 'metadata', protocol_version: '1.5', created_at: 1 }, userTurnRecord('first', 2), @@ -81,30 +119,31 @@ describe('countCompletedUserVisibleTurns', () => { turnEndedRecord(1, 5), userTurnRecord('third still running', 6), ]; - expect(countCompletedUserVisibleTurns(records)).toBe(2); + expect(lastCompletedUserVisibleTurnIndex(records)).toBe(1); }); it.each(['cancelled', 'failed', 'blocked'] as const)( - 'counts a turn ended with reason "%s" as completed', + 'treats a turn ended with reason "%s" as completed', (reason) => { const records: WireRecord[] = [ { type: 'metadata', protocol_version: '1.5', created_at: 1 }, userTurnRecord('interrupted', 2), turnEndedRecord(0, 3, reason), ]; - expect(countCompletedUserVisibleTurns(records)).toBe(1); + expect(lastCompletedUserVisibleTurnIndex(records)).toBe(0); }, ); - it('treats a steer-split pair sharing one turn.ended as a single completable turn', () => { + it('returns the steered start index when a steer-split turn completes before a running tail', () => { const records: WireRecord[] = [ { type: 'metadata', protocol_version: '1.5', created_at: 1 }, userTurnRecord('original prompt', 2), { type: 'turn.steer', turnId: 0, messageId: 'msg_steer', origin: { kind: 'user' }, time: 3 }, userTurnRecord('steered input', 4), turnEndedRecord(0, 5), + userTurnRecord('follow-up still running', 6), ]; - expect(countCompletedUserVisibleTurns(records)).toBe(1); + expect(lastCompletedUserVisibleTurnIndex(records)).toBe(1); }); it('does not treat system-triggered appends as turn starts', () => { @@ -123,6 +162,6 @@ describe('countCompletedUserVisibleTurns', () => { }, turnEndedRecord(1, 5), ]; - expect(countCompletedUserVisibleTurns(records)).toBe(1); + expect(lastCompletedUserVisibleTurnIndex(records)).toBe(0); }); }); diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index bcbc8ed5d2d..97eaf28c74e 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -22,6 +22,7 @@ import { IAgentConversationUndoService, IAgentCronService, IAgentLifecycleService, + IAgentLoopService, IEventBus, IEventDispatcher, IEventService, @@ -38,7 +39,7 @@ import { type ScopeSeed, } from '@moonshot-ai/agent-core-v2'; import { SessionMetaUpdated } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetaEvents'; -import { TurnSteer } from '@moonshot-ai/agent-core-v2/agent/loop/turnOps'; +import { TurnSteer, TurnEnded } from '@moonshot-ai/agent-core-v2/agent/loop/turnOps'; import type { AgentTranscriptSnapshot } from '@moonshot-ai/transcript'; import { TurnStarted } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; import { sessionWarningsResponseSchema } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; @@ -1344,6 +1345,83 @@ describe('server-v2 /api/v1/sessions', () => { }, ); + it('rejects :fork while a non-turn operation holds the session quiescence', async () => { + const cwd = home as string; + const parent = await postJson('/api/v1/sessions', { metadata: { cwd } }); + const parentId = parent.body.data.id; + const session = getLiveSessionById((server as RunningServer).core.accessor, parentId); + expect(session).toBeDefined(); + await session!.accessor.get(IAgentLifecycleService).create({ agentId: MAIN_AGENT_ID }); + const agent = session!.accessor.get(IAgentLifecycleService).handleOf(MAIN_AGENT_ID)!; + agent.accessor.get(IAgentContextMemoryService).append( + { role: 'user', content: [{ type: 'text', text: 'first prompt' }], toolCalls: [], origin: { kind: 'user' } }, + { role: 'assistant', content: [{ type: 'text', text: 'first answer' }], toolCalls: [] }, + ); + await agent.accessor.get(IEventDispatcher).dispatch( + new TurnEnded({ agentId: MAIN_AGENT_ID, turnId: 0, reason: 'completed' }), + ); + await agent.accessor.get(IWireService).flush(); + const hold = agent.accessor.get(IAgentLoopService).tryAcquireQuiescence(); + expect(hold).toBeDefined(); + + try { + const response = await postJson(`/api/v1/sessions/${parentId}:fork`, {}); + expect(response.body.code).toBe(40901); + } finally { + hold!.dispose(); + } + + const forked = await postJson(`/api/v1/sessions/${parentId}:fork`, {}); + expect(forked.body.code).toBe(0); + }); + + it('derives the fork outcome from the sliced wire instead of the source metadata', async () => { + const cwd = home as string; + const parent = await postJson('/api/v1/sessions', { metadata: { cwd } }); + const parentId = parent.body.data.id; + const session = getLiveSessionById((server as RunningServer).core.accessor, parentId); + expect(session).toBeDefined(); + await session!.accessor.get(IAgentLifecycleService).create({ agentId: MAIN_AGENT_ID }); + const agent = session!.accessor.get(IAgentLifecycleService).handleOf(MAIN_AGENT_ID)!; + const context = agent.accessor.get(IAgentContextMemoryService); + const user = (text: string): ContextMessage => ({ + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + origin: { kind: 'user' }, + }); + const assistant = (text: string): ContextMessage => ({ + role: 'assistant', + content: [{ type: 'text', text }], + toolCalls: [], + }); + context.append(user('first prompt'), assistant('first answer')); + await agent.accessor.get(IEventDispatcher).dispatch( + new TurnEnded({ agentId: MAIN_AGENT_ID, turnId: 0, reason: 'completed' }), + ); + context.append(user('second prompt'), assistant('second answer')); + await agent.accessor.get(IEventDispatcher).dispatch( + new TurnEnded({ agentId: MAIN_AGENT_ID, turnId: 1, reason: 'failed' }), + ); + await session!.accessor.get(ISessionMetadata).update({ lastTurnReason: 'failed' }); + await agent.accessor.get(IWireService).flush(); + + const forked = await postJson(`/api/v1/sessions/${parentId}:fork`, { + turn_index: 0, + }); + expect(forked.body.code).toBe(0); + const forkedId = forked.body.data.id; + + const metaFiles = (await readdir(home as string, { recursive: true })).filter( + (path) => path.endsWith(`${forkedId}/state.json`), + ); + expect(metaFiles).toHaveLength(1); + const diskMeta = JSON.parse( + await readFile(join(home as string, metaFiles[0]!), 'utf8'), + ) as { lastTurnReason?: unknown }; + expect(diskMeta.lastTurnReason).toBe('completed'); + }); + it.each([ { count: 1, code: 0, prompts: [] as string[], texts: [] as string[] }, { count: 2, code: 40911, prompts: ['original prompt'], texts: ['answer before steer', 'steered prompt', 'answer after steer', 'second steer', 'answer after second steer'] }, diff --git a/packages/node-sdk/test/session-prompt-events.test.ts b/packages/node-sdk/test/session-prompt-events.test.ts index a8241d9a62d..8d7d6804555 100644 --- a/packages/node-sdk/test/session-prompt-events.test.ts +++ b/packages/node-sdk/test/session-prompt-events.test.ts @@ -793,6 +793,10 @@ describe('Session.prompt events', () => { id: source.id, forkId: 'ses_turn_fork_running_child', }); + const forkSummary = (await harness.listSessions()).find( + (summary) => summary.id === fork.id, + ); + expect(forkSummary?.lastTurnReason).toBe('completed'); await fork.close(); const resumed = await harness.resumeSession({ id: fork.id }); const replayText = visibleReplayText(resumed.getResumeState()?.agents['main']?.replay ?? []); @@ -952,6 +956,74 @@ describe('Session.prompt events', () => { } }); + it('forks a running session through a steered turn at the steered index', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); + + try { + await configureFakeProvider(harness); + const source = await harness.createSession({ id: 'ses_turn_fork_steer_source', workDir }); + + const firstGate = deferredResponse(); + fetchStub!.mockImplementationOnce(() => firstGate.promise); + const started = waitForEvent(source, (event) => event.type === 'turn.started'); + const steeredTurnEnded = waitForEvent(source, (event) => event.type === 'turn.ended'); + await source.prompt('original question'); + await started; + await source.steer('steered input'); + fakeProviderState.responseText = 'answer after steer'; + firstGate.resolve(sseResponse('answer before steer')); + await steeredTurnEnded; + + const followUpGate = deferredResponse(); + fetchStub!.mockImplementationOnce(() => followUpGate.promise); + const followUpStarted = waitForEvent(source, (event) => event.type === 'turn.started'); + const followUpEnded = waitForEvent(source, (event) => event.type === 'turn.ended'); + await source.prompt('follow-up question'); + await followUpStarted; + + const expectedReplay = [ + 'user:original question', + 'assistant:answer before steer', + 'user:steered input', + 'assistant:answer after steer', + ]; + + const fork = await harness.forkSession({ + id: source.id, + forkId: 'ses_turn_fork_steer_child', + }); + await fork.close(); + const resumed = await harness.resumeSession({ id: fork.id }); + expect( + visibleReplayText(resumed.getResumeState()?.agents['main']?.replay ?? []), + ).toEqual(expectedReplay); + + const picked = await harness.forkSession({ + id: source.id, + forkId: 'ses_turn_fork_steer_pick_child', + turnIndex: 1, + }); + await picked.close(); + const resumedPicked = await harness.resumeSession({ id: picked.id }); + expect( + visibleReplayText(resumedPicked.getResumeState()?.agents['main']?.replay ?? []), + ).toEqual(expectedReplay); + + await expect(harness.forkSession({ id: source.id, turnIndex: 2 })).rejects.toMatchObject({ + name: 'KimiError', + code: 'request.invalid', + details: { turnIndex: 2, availableTurns: 2 }, + }); + + followUpGate.resolve(sseResponse('follow-up answer')); + await followUpEnded; + } finally { + await harness.close(); + } + }); + it('rejects empty prompt input', async () => { const homeDir = await makeTempDir(); const workDir = await makeTempDir(); From 5f9d80d99f7cc0c3e9b8e1f35a19f507307d75e5 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 8 Oct 2026 16:07:09 +0800 Subject: [PATCH 3/7] feat: address the fork turn point by prompt id instead of a turn index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REST :fork body now takes prompt_id (a durable id persisted in the wire as turn.prompt/turn.steer records) and the engine resolves it to the user-visible turn index against the full wire history. Addressing by a client-computed index drifted whenever the client could not see the whole conversation — paginated transcripts silently forked at a turn near the session start, and turns the client does not render (e.g. shell-command turns) shifted every later index. - resolve promptId to a visible turn index via the append message id; promptId and turnIndex are mutually exclusive, an unknown id or one past the last completed turn fails request.invalid - kap-server fork schema, action boundary schema, docs, and the node-sdk fork input speak prompt_id; the engine and SDK keep the turnIndex option for full-history consumers (e.g. the VSCode bridge) --- .changeset/fork-at-turn.md | 2 +- docs/en/reference/server-api.md | 8 +- docs/zh/reference/server-api.md | 8 +- .../internal/forkTurnSlice.ts | 12 ++ .../sessionLifecycle/sessionLifecycle.ts | 1 + .../sessionLifecycleService.ts | 42 ++++-- .../sessionLifecycle/forkTurnSlice.test.ts | 51 ++++++- packages/kap-server/src/protocol/session.ts | 2 +- packages/kap-server/src/routes/sessions.ts | 4 +- packages/kap-server/test/sessions.test.ts | 25 ++-- .../klient/src/contract/session/lifecycle.ts | 6 +- packages/node-sdk/src/kimi-harness.ts | 1 + packages/node-sdk/src/sdk-rpc-client-v2.ts | 7 +- packages/node-sdk/src/types.ts | 9 +- .../test/session-prompt-events.test.ts | 134 +++++++++++++----- 15 files changed, 233 insertions(+), 79 deletions(-) diff --git a/.changeset/fork-at-turn.md b/.changeset/fork-at-turn.md index cbeedca9224..e84a2f5f271 100644 --- a/.changeset/fork-at-turn.md +++ b/.changeset/fork-at-turn.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Sessions can now be forked while a response is still running — the fork stops at the last completed turn — and the `:fork` server endpoint accepts a `turn_index` to fork from an earlier turn. +Sessions can now be forked while a response is still running — the fork stops at the last completed turn — and the `:fork` server endpoint accepts a `prompt_id` to fork from an earlier prompt. diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md index a7248deac4a..cc1f99fcfa8 100644 --- a/docs/en/reference/server-api.md +++ b/docs/en/reference/server-api.md @@ -708,19 +708,19 @@ Session actions are dispatched through one route: the path tail is parsed as `{s #### `POST /api/v1/sessions/{session_id}:fork` -Copies the session — its transcript, agent state, and files — into a new session in the same workspace, and broadcasts `event.session.created`. Pass `turn_index` to fork from an earlier turn instead of the latest state: the copy keeps only the conversation through that user-visible turn (turn `0` is the first prompt) and discards everything after it. +Copies the session — its transcript, agent state, and files — into a new session in the same workspace, and broadcasts `event.session.created`. Pass `prompt_id` to fork from an earlier point instead of the latest state: the copy keeps only the conversation through that prompt's turn segment and discards everything after it. -Forking is allowed while the session has an active or queued turn. In that case the fork point is capped at the last completed turn: a `turn_index` beyond it fails `40001`, and omitting `turn_index` forks through the last completed turn rather than the very latest state. +Forking is allowed while the session has an active or queued turn. In that case the fork point is capped at the last completed turn: a `prompt_id` belonging to a later turn fails `40001`, and omitting `prompt_id` forks through the last completed turn rather than the very latest state. | Parameter | In | Type | Description | | --- | --- | --- | --- | | `title` | body | string | Title for the fork (at least 1 character). Default `Fork: ` | | `metadata` | body | object | Custom metadata for the fork | -| `turn_index` | body | integer | Zero-based index of the user-visible turn to fork from. Default: the latest state, or the last completed turn while a turn is active | +| `prompt_id` | body | string | Id of the user prompt to fork from. Default: the latest state, or the last completed turn while a turn is active | On success, `data` is [the session object](#the-session-object) of the new session. -- `40001`: `turn_index` is not a non-negative integer, or points past the last available turn +- `40001`: `prompt_id` is unknown in the session, or belongs to a turn past the last completed one - `40901`: a turn is active and no turn has completed yet, so there is nothing to fork #### `POST /api/v1/sessions/{session_id}:compact` diff --git a/docs/zh/reference/server-api.md b/docs/zh/reference/server-api.md index aa44e90586b..80e4d5fef97 100644 --- a/docs/zh/reference/server-api.md +++ b/docs/zh/reference/server-api.md @@ -708,19 +708,19 @@ schema 还接受 `agent_config` 内的 `system_prompt`、`tools`、`mcp_servers` #### `POST /api/v1/sessions/{session_id}:fork` -将会话——其转录、Agent 状态与文件——复制到同一工作区中的新会话,并广播 `event.session.created`。传入 `turn_index` 可从更早的轮次分叉,而不是复制最新状态:副本只保留到该用户可见轮次为止的对话(轮次 `0` 是首个提示词),其后的内容全部丢弃。 +将会话——其转录、Agent 状态与文件——复制到同一工作区中的新会话,并广播 `event.session.created`。传入 `prompt_id` 可从更早的位置分叉,而不是复制最新状态:副本只保留到该提示词所在轮次段结束为止的对话,其后的内容全部丢弃。 -会话有进行中或排队中的轮次时也可以 fork。此时分叉点以最后一个已完成轮次为上限:超出上限的 `turn_index` 返回 `40001`;不传 `turn_index` 则复制到最后一个已完成轮次,而不是最新状态。 +会话有进行中或排队中的轮次时也可以 fork。此时分叉点以最后一个已完成轮次为上限:`prompt_id` 属于更晚的轮次时返回 `40001`;不传 `prompt_id` 则复制到最后一个已完成轮次,而不是最新状态。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | | `title` | body | string | fork 的标题(至少 1 个字符)。默认 `Fork: ` | | `metadata` | body | object | fork 的自定义元数据 | -| `turn_index` | body | integer | 要从中分叉的用户可见轮次下标,从 0 开始。默认为最新状态;有进行中轮次时为最后一个已完成轮次 | +| `prompt_id` | body | string | 要从中分叉的用户提示词 id。默认为最新状态;有进行中轮次时为最后一个已完成轮次 | 成功时,`data` 为新会话的 [session 对象](#session-对象)。 -- `40001`:`turn_index` 不是非负整数,或超出最后一个可用轮次 +- `40001`:`prompt_id` 在会话中不存在,或属于最后一个已完成轮次之后的轮次 - `40901`:有轮次正在进行且尚无任何已完成轮次,没有可 fork 的内容 #### `POST /api/v1/sessions/{session_id}:compact` diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts index 889791abc93..cb9b23d19bc 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts @@ -94,6 +94,18 @@ export function lastCompletedUserVisibleTurnIndex( return lastCompleted; } +export function visibleTurnIndexOfPrompt( + records: readonly WireRecord[], + promptId: string, +): number | undefined { + const turnStarts = userVisibleTurnStartIndices(records); + for (let start = 0; start < turnStarts.length; start += 1) { + const message = asRecord(records[turnStarts[start]!]!['message']); + if (message?.['id'] === promptId) return start; + } + return undefined; +} + function userVisibleTurnStartIndices(records: readonly WireRecord[]): number[] { const turnStarts: number[] = []; for (let index = 0; index < records.length; index += 1) { diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts index 1d0a7fcb9f8..d019fd1d327 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts @@ -23,6 +23,7 @@ export interface ForkSessionOptions { readonly title?: string; readonly metadata?: Record; readonly turnIndex?: number; + readonly promptId?: string; } export interface ResumeSessionOptions { diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index f94b852f920..c4b5ac764fa 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -105,6 +105,7 @@ import { type MainTurnSlice, sliceMainRecordsAtTurn, sliceSubagentRecordsAtTime, + visibleTurnIndexOfPrompt, } from './internal/forkTurnSlice'; import { type CreateChildSessionOptions, @@ -518,6 +519,13 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await this.appendLogStore.flush(); } assertForkTurnIndex(opts.turnIndex); + if (opts.turnIndex !== undefined && opts.promptId !== undefined) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + 'forkSession turnIndex and promptId are mutually exclusive', + { details: { turnIndex: opts.turnIndex, promptId: opts.promptId } }, + ); + } let targetId: string | undefined; let targetSessionDir: string | undefined; @@ -565,6 +573,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec sourceHandle, sourceId, opts.turnIndex, + opts.promptId, busy, ); @@ -726,20 +735,27 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec sourceHandle: ISessionScopeHandle | undefined, sourceId: string, turnIndex: number | undefined, + promptId: string | undefined, busy: boolean, ): Promise { - if (!busy) { - return turnIndex === undefined - ? undefined - : sliceMainRecordsAtTurn( - flattenChain(await this.readSourceWireRecords(sourceHandle, sourceId, MAIN_AGENT_ID)), - sourceId, - turnIndex, - ); - } + if (!busy && turnIndex === undefined && promptId === undefined) return undefined; const records = flattenChain( await this.readSourceWireRecords(sourceHandle, sourceId, MAIN_AGENT_ID), ); + let resolved = turnIndex; + if (promptId !== undefined) { + resolved = visibleTurnIndexOfPrompt(records, promptId); + if (resolved === undefined) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + `Prompt "${promptId}" was not found in session "${sourceId}"`, + { details: { promptId, sessionId: sourceId } }, + ); + } + } + if (!busy) { + return sliceMainRecordsAtTurn(records, sourceId, resolved!); + } const lastCompleted = lastCompletedUserVisibleTurnIndex(records); if (lastCompleted === undefined) { throw new Error2( @@ -748,14 +764,14 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec { details: { sessionId: sourceId } }, ); } - if (turnIndex !== undefined && turnIndex > lastCompleted) { + if (resolved !== undefined && resolved > lastCompleted) { throw new Error2( ErrorCodes.REQUEST_INVALID, - `Turn ${String(turnIndex)} was not found in session "${sourceId}"`, - { details: { turnIndex, availableTurns: lastCompleted + 1 } }, + `Turn ${String(resolved)} was not found in session "${sourceId}"`, + { details: { turnIndex: resolved, availableTurns: lastCompleted + 1 } }, ); } - return sliceMainRecordsAtTurn(records, sourceId, turnIndex ?? lastCompleted); + return sliceMainRecordsAtTurn(records, sourceId, resolved ?? lastCompleted); } private async resolveSourceTitle(sourceId: string): Promise { diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts index 4215318a1a1..ccfeb211cad 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts @@ -3,13 +3,15 @@ import { describe, expect, it } from 'vitest'; import { lastCompletedUserVisibleTurnIndex, sliceMainRecordsAtTurn, + visibleTurnIndexOfPrompt, } from '#/workspace/sessionLifecycle/internal/forkTurnSlice'; import type { WireRecord } from '#/wire/record'; -function userTurnRecord(text: string, time: number): WireRecord { +function userTurnRecord(text: string, time: number, id?: string): WireRecord { return { type: 'context.append_message', message: { + ...(id === undefined ? {} : { id }), role: 'user', content: [{ type: 'text', text }], origin: { kind: 'user' }, @@ -99,6 +101,53 @@ describe('sliceMainRecordsAtTurn lastTurnReason', () => { }); }); +describe('visibleTurnIndexOfPrompt', () => { + it('resolves the visible turn index of a prompt message id', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + userTurnRecord('first', 2, 'p1'), + turnEndedRecord(0, 3), + userTurnRecord('second', 4, 'p2'), + turnEndedRecord(1, 5), + userTurnRecord('third still running', 6, 'p3'), + ]; + expect(visibleTurnIndexOfPrompt(records, 'p1')).toBe(0); + expect(visibleTurnIndexOfPrompt(records, 'p2')).toBe(1); + expect(visibleTurnIndexOfPrompt(records, 'p3')).toBe(2); + }); + + it('resolves the index of a steered prompt message id', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + userTurnRecord('original prompt', 2, 'p1'), + { type: 'turn.steer', turnId: 0, messageId: 'p2', origin: { kind: 'user' }, time: 3 }, + userTurnRecord('steered input', 4, 'p2'), + turnEndedRecord(0, 5), + ]; + expect(visibleTurnIndexOfPrompt(records, 'p2')).toBe(1); + }); + + it('returns undefined for an unknown prompt id or a non-visible append', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + userTurnRecord('real prompt', 2, 'p1'), + turnEndedRecord(0, 3), + { + type: 'context.append_message', + message: { + id: 'cron-1', + role: 'user', + content: [{ type: 'text', text: 'cron follow-up' }], + origin: { kind: 'cron_job', jobId: 'job-1' }, + }, + time: 4, + }, + ]; + expect(visibleTurnIndexOfPrompt(records, 'missing')).toBeUndefined(); + expect(visibleTurnIndexOfPrompt(records, 'cron-1')).toBeUndefined(); + }); +}); + describe('lastCompletedUserVisibleTurnIndex', () => { it('returns undefined for empty records and for a first turn still running', () => { expect(lastCompletedUserVisibleTurnIndex([])).toBeUndefined(); diff --git a/packages/kap-server/src/protocol/session.ts b/packages/kap-server/src/protocol/session.ts index 4a4a4fb33dc..cef6b3732c6 100644 --- a/packages/kap-server/src/protocol/session.ts +++ b/packages/kap-server/src/protocol/session.ts @@ -75,7 +75,7 @@ export type SessionCreate = z.infer; export const sessionForkSchema = z.object({ title: z.string().min(1).optional(), metadata: z.record(z.string(), z.unknown()).optional(), - turn_index: z.number().int().nonnegative().optional(), + prompt_id: z.string().min(1).optional(), }); export type SessionFork = z.infer; diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index ddd9ed4e920..d5844000347 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -162,7 +162,7 @@ const sessionActionRequestSchema = z.preprocess( instruction: z.string().optional(), count: z.number().int().positive().optional(), page_size: z.number().int().min(1).max(100).optional(), - turn_index: z.number().int().nonnegative().optional(), + prompt_id: z.string().min(1).optional(), }), ); @@ -901,7 +901,7 @@ async function forkSessionAction( sourceSessionId: id, title: body.title, metadata: body.metadata, - turnIndex: body.turn_index, + promptId: body.prompt_id, }); const session = toWireSession( { ...meta, workspaceId: forkHandler.workspaceId }, diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 97eaf28c74e..30d1d636f9b 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -1283,7 +1283,7 @@ describe('server-v2 /api/v1/sessions', () => { expect(forkedState.titleKind).toBe('custom'); }); - it('passes turn_index through :fork to the session manager fork', async () => { + it('passes prompt_id through :fork to the session manager fork', async () => { const cwd = home as string; const parent = await postJson('/api/v1/sessions', { metadata: { cwd } }); const parentId = parent.body.data.id; @@ -1294,7 +1294,7 @@ describe('server-v2 /api/v1/sessions', () => { try { const response = await postJson(`/api/v1/sessions/${parentId}:fork`, { - turn_index: 2, + prompt_id: 'prompt-1', }); expect(response.body.code).toBe(40901); @@ -1302,14 +1302,14 @@ describe('server-v2 /api/v1/sessions', () => { sourceSessionId: parentId, title: undefined, metadata: undefined, - turnIndex: 2, + promptId: 'prompt-1', }); } finally { fork.mockRestore(); } }); - it('forks through :fork without turn_index as before', async () => { + it('forks through :fork without prompt_id as before', async () => { const cwd = home as string; const parent = await postJson('/api/v1/sessions', { metadata: { cwd } }); const parentId = parent.body.data.id; @@ -1324,15 +1324,15 @@ describe('server-v2 /api/v1/sessions', () => { sourceSessionId: parentId, title: undefined, metadata: undefined, - turnIndex: undefined, + promptId: undefined, }); } finally { fork.mockRestore(); } }); - it.each([{ turn_index: 1.5 }, { turn_index: -1 }])( - 'rejects an out-of-contract turn_index on :fork (%s)', + it.each([{ prompt_id: 1 }, { prompt_id: '' }])( + 'rejects an out-of-contract prompt_id on :fork (%s)', async (body) => { const cwd = home as string; const parent = await postJson('/api/v1/sessions', { metadata: { cwd } }); @@ -1341,7 +1341,7 @@ describe('server-v2 /api/v1/sessions', () => { body, ); expect(response.body.code).toBe(40001); - expect(response.body.details?.[0]?.path).toBe('turn_index'); + expect(response.body.details?.[0]?.path).toBe('prompt_id'); }, ); @@ -1384,22 +1384,23 @@ describe('server-v2 /api/v1/sessions', () => { await session!.accessor.get(IAgentLifecycleService).create({ agentId: MAIN_AGENT_ID }); const agent = session!.accessor.get(IAgentLifecycleService).handleOf(MAIN_AGENT_ID)!; const context = agent.accessor.get(IAgentContextMemoryService); - const user = (text: string): ContextMessage => ({ + const user = (text: string, promptId?: string): ContextMessage => ({ role: 'user', content: [{ type: 'text', text }], toolCalls: [], origin: { kind: 'user' }, + ...(promptId === undefined ? {} : { id: promptId }), }); const assistant = (text: string): ContextMessage => ({ role: 'assistant', content: [{ type: 'text', text }], toolCalls: [], }); - context.append(user('first prompt'), assistant('first answer')); + context.append(user('first prompt', 'p1'), assistant('first answer')); await agent.accessor.get(IEventDispatcher).dispatch( new TurnEnded({ agentId: MAIN_AGENT_ID, turnId: 0, reason: 'completed' }), ); - context.append(user('second prompt'), assistant('second answer')); + context.append(user('second prompt', 'p2'), assistant('second answer')); await agent.accessor.get(IEventDispatcher).dispatch( new TurnEnded({ agentId: MAIN_AGENT_ID, turnId: 1, reason: 'failed' }), ); @@ -1407,7 +1408,7 @@ describe('server-v2 /api/v1/sessions', () => { await agent.accessor.get(IWireService).flush(); const forked = await postJson(`/api/v1/sessions/${parentId}:fork`, { - turn_index: 0, + prompt_id: 'p1', }); expect(forked.body.code).toBe(0); const forkedId = forked.body.data.id; diff --git a/packages/klient/src/contract/session/lifecycle.ts b/packages/klient/src/contract/session/lifecycle.ts index f50ed2cb7b2..c056aa24e7a 100644 --- a/packages/klient/src/contract/session/lifecycle.ts +++ b/packages/klient/src/contract/session/lifecycle.ts @@ -41,10 +41,14 @@ export const forkSessionOptionsSchema = z.object({ title: z.string().optional(), metadata: z.record(z.string(), z.unknown()).optional(), turnIndex: z.number().optional(), + promptId: z.string().optional(), }); /** Same fields as `ForkSessionOptions` in the engine, minus the fork-only truncation. */ -export const createChildSessionOptionsSchema = forkSessionOptionsSchema.omit({ turnIndex: true }); +export const createChildSessionOptionsSchema = forkSessionOptionsSchema.omit({ + turnIndex: true, + promptId: true, +}); /** `IScopeHandle` as it survives JSON — `{ id, kind }` plus extras. */ export const handleWireSchema = z.looseObject({ diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 288b4efc182..07bb294f37a 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -266,6 +266,7 @@ export class KimiHarness { title: input.title, metadata: input.metadata, turnIndex: input.turnIndex, + promptId: input.promptId, }); const session = new Session({ id: summary.id, diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index c366c63be8c..6f9441fbef6 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -1438,12 +1438,12 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { /** * Through `engineAccessor` (the handler chain's `ISessionLifecycleService.fork`) because the - * klient facade fork takes no explicit target id. `turnIndex` truncation and + * klient facade fork takes no explicit target id. `promptId` truncation and * the busy-source handling (a live session mid-turn forks through its last * completed turn; v1's `SESSION_FORK_ACTIVE_TURN` only when no turn has * completed yet) are the engine's own now, so their failures cross the - * in-process call with v1's codes and details (`request.invalid` with - * `{turnIndex, availableTurns}` / `session.fork_active_turn`). The default + * in-process call with v1's codes and details (`request.invalid` / + * `session.fork_active_turn`). The default * title still differs by design (v1: "New Session", v2: "Fork: ") — * pass an explicit title for identical results. */ @@ -1464,6 +1464,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { title: input.title, metadata: input.metadata, turnIndex: input.turnIndex, + promptId: input.promptId, }); const handle = await resumeSessionById(this.engineAccessor, meta.id); if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(meta.id); diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 0f05c076011..b12346ce566 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -302,9 +302,16 @@ export interface ForkSessionInput { readonly metadata?: JsonObject; /** * Zero-based index of the user-visible turn to retain through. Omit it to - * preserve the existing full-session fork behavior. + * preserve the existing full-session fork behavior. Mutually exclusive with + * `promptId`. */ readonly turnIndex?: number; + /** + * Id of the user prompt to retain through (the fork keeps everything up to + * the end of that prompt's turn segment), resolved by the engine against the + * full wire history. Mutually exclusive with `turnIndex`. + */ + readonly promptId?: string; } export interface ExportSessionInput { diff --git a/packages/node-sdk/test/session-prompt-events.test.ts b/packages/node-sdk/test/session-prompt-events.test.ts index 8d7d6804555..ad2d55f5a7f 100644 --- a/packages/node-sdk/test/session-prompt-events.test.ts +++ b/packages/node-sdk/test/session-prompt-events.test.ts @@ -541,7 +541,7 @@ describe('Session.prompt events', () => { } }); - it('persists only conversation through the selected turn across resume', async () => { + it('persists only conversation through the selected prompt across resume', async () => { const homeDir = await makeTempDir(); const workDir = await makeTempDir(); const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); @@ -549,14 +549,14 @@ describe('Session.prompt events', () => { try { await configureFakeProvider(harness); const source = await harness.createSession({ id: 'ses_turn_fork_source', workDir }); - await runPrompt(source, 'first question', 'first answer'); - await runPrompt(source, 'second question', 'second answer'); - await runPrompt(source, 'third question', 'third answer'); + await runPrompt(source, 'first question', 'first answer', 'p1'); + await runPrompt(source, 'second question', 'second answer', 'p2'); + await runPrompt(source, 'third question', 'third answer', 'p3'); const fork = await harness.forkSession({ id: source.id, forkId: 'ses_turn_fork_child', - turnIndex: 1, + promptId: 'p2', }); await fork.close(); const resumed = await harness.resumeSession({ id: fork.id }); @@ -585,15 +585,15 @@ describe('Session.prompt events', () => { workDir, metadata: { source: 'vscode' }, }); - await runPrompt(source, 'branch here', 'kept answer'); - await runPrompt(source, 'future prompt', 'discarded answer'); + await runPrompt(source, 'branch here', 'kept answer', 'p1'); + await runPrompt(source, 'future prompt', 'discarded answer', 'p2'); const fork = await harness.forkSession({ id: source.id, forkId: 'ses_turn_fork_metadata_child', title: 'Historical branch', metadata: { branch: 'historical' }, - turnIndex: 0, + promptId: 'p1', }); const state = fork.getResumeState(); @@ -623,16 +623,16 @@ describe('Session.prompt events', () => { try { await configureFakeProvider(harness); const source = await harness.createSession({ id: 'ses_turn_fork_undo_source', workDir }); - await runPrompt(source, 'kept prompt one', 'kept answer one'); - await runPrompt(source, 'kept prompt two', 'kept answer two'); - await runPrompt(source, 'retracted prompt', 'retracted answer'); + await runPrompt(source, 'kept prompt one', 'kept answer one', 'p1'); + await runPrompt(source, 'kept prompt two', 'kept answer two', 'p2'); + await runPrompt(source, 'retracted prompt', 'retracted answer', 'p3'); await source.undoHistory(1); - await runPrompt(source, 'follow-up prompt', 'follow-up answer'); + await runPrompt(source, 'follow-up prompt', 'follow-up answer', 'p4'); const fork = await harness.forkSession({ id: source.id, forkId: 'ses_turn_fork_undo_child', - turnIndex: 2, + promptId: 'p4', }); await fork.close(); const resumed = await harness.resumeSession({ id: fork.id }); @@ -688,9 +688,9 @@ describe('Session.prompt events', () => { try { await configureFakeProvider(harness); const source = await harness.createSession({ id: 'ses_turn_fork_id_source', workDir }); - await runPrompt(source, 'kept prompt', 'kept answer'); - await runPrompt(source, 'future prompt', 'future answer'); - const fork = await harness.forkSession({ id: source.id, turnIndex: 0 }); + await runPrompt(source, 'kept prompt', 'kept answer', 'p1'); + await runPrompt(source, 'future prompt', 'future answer', 'p2'); + const fork = await harness.forkSession({ id: source.id, promptId: 'p1' }); const started = waitForEvent(fork, (event) => event.type === 'turn.started'); const ended = waitForEvent(fork, (event) => event.type === 'turn.ended'); @@ -711,11 +711,11 @@ describe('Session.prompt events', () => { try { await configureFakeProvider(harness); const source = await harness.createSession({ id: 'ses_turn_fork_agents_source', workDir }); - await runPrompt(source, 'kept prompt', 'kept answer'); - await runPrompt(source, 'future prompt', 'future answer'); + await runPrompt(source, 'kept prompt', 'kept answer', 'p1'); + await runPrompt(source, 'future prompt', 'future answer', 'p2'); await source.init(); - const fork = await harness.forkSession({ id: source.id, turnIndex: 0 }); + const fork = await harness.forkSession({ id: source.id, promptId: 'p1' }); expect(Object.keys(fork.getResumeState()?.sessionMetadata.agents ?? {})).toEqual(['main']); } finally { @@ -771,6 +771,56 @@ describe('Session.prompt events', () => { } }); + it('rejects an unknown fork prompt id without creating the fork', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); + + try { + await configureFakeProvider(harness); + const source = await harness.createSession({ id: 'ses_prompt_fork_unknown_source', workDir }); + await runPrompt(source, 'only question', 'only answer', 'p1'); + + await expect( + harness.forkSession({ + id: source.id, + forkId: 'ses_prompt_fork_unknown_child', + promptId: 'missing', + }), + ).rejects.toMatchObject({ + name: 'KimiError', + code: 'request.invalid', + details: { promptId: 'missing', sessionId: source.id }, + }); + await expect( + harness.listSessions({ sessionId: 'ses_prompt_fork_unknown_child' }), + ).resolves.toEqual([]); + } finally { + await harness.close(); + } + }); + + it('rejects a fork with both turnIndex and promptId', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); + + try { + await configureFakeProvider(harness); + const source = await harness.createSession({ id: 'ses_prompt_fork_both_source', workDir }); + await runPrompt(source, 'only question', 'only answer', 'p1'); + + await expect( + harness.forkSession({ id: source.id, turnIndex: 0, promptId: 'p1' }), + ).rejects.toMatchObject({ + name: 'KimiError', + code: 'request.invalid', + }); + } finally { + await harness.close(); + } + }); + it('forks a running session clamped to the last completed turn', async () => { const homeDir = await makeTempDir(); const workDir = await makeTempDir(); @@ -815,7 +865,7 @@ describe('Session.prompt events', () => { } }); - it('forks a running session at an explicit completed turn', async () => { + it('forks a running session at an explicit completed prompt', async () => { const homeDir = await makeTempDir(); const workDir = await makeTempDir(); const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); @@ -823,8 +873,8 @@ describe('Session.prompt events', () => { try { await configureFakeProvider(harness); const source = await harness.createSession({ id: 'ses_turn_fork_running_pick_source', workDir }); - await runPrompt(source, 'first question', 'first answer'); - await runPrompt(source, 'second question', 'second answer'); + await runPrompt(source, 'first question', 'first answer', 'p1'); + await runPrompt(source, 'second question', 'second answer', 'p2'); const gate = deferredResponse(); fetchStub!.mockImplementationOnce(() => gate.promise); @@ -836,7 +886,7 @@ describe('Session.prompt events', () => { const fork = await harness.forkSession({ id: source.id, forkId: 'ses_turn_fork_running_pick_child', - turnIndex: 0, + promptId: 'p1', }); await fork.close(); const resumed = await harness.resumeSession({ id: fork.id }); @@ -851,7 +901,7 @@ describe('Session.prompt events', () => { } }); - it('rejects an explicit turn beyond the last completed turn while running', async () => { + it('rejects a prompt of the running turn while forking', async () => { const homeDir = await makeTempDir(); const workDir = await makeTempDir(); const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); @@ -859,18 +909,18 @@ describe('Session.prompt events', () => { try { await configureFakeProvider(harness); const source = await harness.createSession({ id: 'ses_turn_fork_running_range_source', workDir }); - await runPrompt(source, 'first question', 'first answer'); - await runPrompt(source, 'second question', 'second answer'); + await runPrompt(source, 'first question', 'first answer', 'p1'); + await runPrompt(source, 'second question', 'second answer', 'p2'); const gate = deferredResponse(); fetchStub!.mockImplementationOnce(() => gate.promise); const started = waitForEvent(source, (event) => event.type === 'turn.started'); const ended = waitForEvent(source, (event) => event.type === 'turn.ended'); - await source.prompt('third question'); + await source.prompt('third question', { promptId: 'p3' }); await started; await expect( - harness.forkSession({ id: source.id, turnIndex: 2 }), + harness.forkSession({ id: source.id, promptId: 'p3' }), ).rejects.toMatchObject({ name: 'KimiError', code: 'request.invalid', @@ -980,7 +1030,7 @@ describe('Session.prompt events', () => { fetchStub!.mockImplementationOnce(() => followUpGate.promise); const followUpStarted = waitForEvent(source, (event) => event.type === 'turn.started'); const followUpEnded = waitForEvent(source, (event) => event.type === 'turn.ended'); - await source.prompt('follow-up question'); + await source.prompt('follow-up question', { promptId: 'p3' }); await followUpStarted; const expectedReplay = [ @@ -996,14 +1046,23 @@ describe('Session.prompt events', () => { }); await fork.close(); const resumed = await harness.resumeSession({ id: fork.id }); - expect( - visibleReplayText(resumed.getResumeState()?.agents['main']?.replay ?? []), - ).toEqual(expectedReplay); + const replayRecords = resumed.getResumeState()?.agents['main']?.replay ?? []; + expect(visibleReplayText(replayRecords)).toEqual(expectedReplay); + const steerMessage = replayRecords.find( + (record): record is Extract<(typeof replayRecords)[number], { type: 'message' }> => + record.type === 'message' && + record.message.role === 'user' && + record.message.content.some( + (part) => part.type === 'text' && part.text === 'steered input', + ), + ); + const steerPromptId = (steerMessage?.message as { id?: string } | undefined)?.id; + expect(steerPromptId).toBeDefined(); const picked = await harness.forkSession({ id: source.id, forkId: 'ses_turn_fork_steer_pick_child', - turnIndex: 1, + promptId: steerPromptId!, }); await picked.close(); const resumedPicked = await harness.resumeSession({ id: picked.id }); @@ -1011,7 +1070,7 @@ describe('Session.prompt events', () => { visibleReplayText(resumedPicked.getResumeState()?.agents['main']?.replay ?? []), ).toEqual(expectedReplay); - await expect(harness.forkSession({ id: source.id, turnIndex: 2 })).rejects.toMatchObject({ + await expect(harness.forkSession({ id: source.id, promptId: 'p3' })).rejects.toMatchObject({ name: 'KimiError', code: 'request.invalid', details: { turnIndex: 2, availableTurns: 2 }, @@ -1045,13 +1104,16 @@ describe('Session.prompt events', () => { }); async function runPrompt( - session: Parameters[0] & { prompt(input: string): Promise }, + session: Parameters[0] & { + prompt(input: string, options?: { promptId?: string }): Promise; + }, input: string, response: string, + promptId?: string, ): Promise { fakeProviderState.responseText = response; const done = waitForEvent(session, (event) => event.type === 'turn.ended'); - await session.prompt(input); + await session.prompt(input, promptId === undefined ? undefined : { promptId }); await done; } From 24770b152a8d42270b9d020a81bd0d167205af1d Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 8 Oct 2026 16:52:33 +0800 Subject: [PATCH 4/7] fix: close busy-fork gaps around turn completion, prompt resolution, and contention - count a shell command as a completed turn once its output append lands, so busy forks stop clamping past (or rejecting on) a finished shell command - cap busy-fork slices at the start of the currently active engine turn, so a running non-visible turn's records never leak into the fork - keep the loop non-quiescent while a turn is finalizing (endTurn covers persistence of turn.ended), so a fork can never copy a wire whose terminal record is still in flight; settled() waits for the same point - classify quiescence contention per agent: any agent holding the guard without turn activity (compaction, undo, another fork) rejects the fork instead of being masked by another agent's active turn - resolve fork prompt ids through turn.steer promptIds (merged steer) and reject ids matching more than one visible turn (duplicated after a restart) instead of silently picking the first --- .../src/agent/loop/loopService.ts | 133 ++++++++++-------- .../internal/forkTurnSlice.ts | 84 +++++++++-- .../sessionLifecycleService.ts | 77 +++++----- .../test/agent/contextMemory/context.test.ts | 2 + .../test/agent/loop/loop.test.ts | 29 ++++ .../test/agent/plugin/agentPlugin.test.ts | 1 + .../agent/toolSelect/toolSelect.e2e.test.ts | 2 + .../test/features/skill/activateSkill.test.ts | 2 + .../test/features/swarm/swarm.test.ts | 1 + .../sessionLifecycle/forkTurnSlice.test.ts | 133 ++++++++++++++++-- packages/kap-server/test/prompts.test.ts | 5 + packages/kap-server/test/sessions.test.ts | 2 +- .../test/transcriptContract.e2e.test.ts | 10 +- .../test/session-prompt-events.test.ts | 67 +++++++++ 14 files changed, 425 insertions(+), 123 deletions(-) diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index e3f23502eef..9ce76314422 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -151,6 +151,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { private machineTurnSuppressed = false; private readonly settleWaiters: Array<() => void> = []; private quiescenceDepth = 0; + private pendingFinalizations = 0; private activeRequestTrace: LLMRequestTrace | undefined; private engine: MachineEngine | undefined; @@ -768,7 +769,8 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { this.quiescenceDepth > 0 || this.active !== undefined || this.hasPendingRequests() || - this.pendingMachineTurn !== undefined + this.pendingMachineTurn !== undefined || + this.pendingFinalizations > 0 ) { return undefined; } @@ -781,7 +783,8 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { return ( this.active !== undefined || this.hasPendingRequests() || - this.pendingMachineTurn !== undefined + this.pendingMachineTurn !== undefined || + this.pendingFinalizations > 0 ); } @@ -919,7 +922,8 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if ( this.active === undefined && !this.hasPendingRequests() && - this.pendingMachineTurn === undefined + this.pendingMachineTurn === undefined && + this.pendingFinalizations === 0 ) { return Promise.resolve(); } @@ -932,6 +936,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if ( this.active !== undefined || this.pendingMachineTurn !== undefined || + this.pendingFinalizations > 0 || this.hasPendingRequests() ) return; if (this.settleWaiters.length === 0) return; @@ -2038,74 +2043,80 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { private async endTurn(turn: ActiveTurn, result: TurnResult): Promise { if (this.active !== turn) return; this.active = undefined; - await this.wire.drainPersisted().catch(() => undefined); - for (const nudge of this.nudges.slice(this.nudgeCursor)) { - if (nudge.turnScoped && !nudge.dropped) { - nudge.dropped = true; - nudge.onDrop?.(); + this.pendingFinalizations += 1; + try { + await this.wire.drainPersisted().catch(() => undefined); + for (const nudge of this.nudges.slice(this.nudgeCursor)) { + if (nudge.turnScoped && !nudge.dropped) { + nudge.dropped = true; + nudge.onDrop?.(); + } } - } - turn.turn.state = result.type; - if (!turn.readyResolved) { - if (result.type === 'failed') { - turn.ready.reject(result.error); - } else if (result.type === 'cancelled') { - turn.ready.reject( - result.reason instanceof Error ? result.reason : abortError('Turn cancelled'), - ); - } else { - turn.ready.reject(new Error2(ErrorCodes.INTERNAL, 'Turn ended before first step')); + turn.turn.state = result.type; + if (!turn.readyResolved) { + if (result.type === 'failed') { + turn.ready.reject(result.error); + } else if (result.type === 'cancelled') { + turn.ready.reject( + result.reason instanceof Error ? result.reason : abortError('Turn cancelled'), + ); + } else { + turn.ready.reject(new Error2(ErrorCodes.INTERNAL, 'Turn ended before first step')); + } } - } - const durationMs = Date.now() - turn.startedAt; - const traceId = - result.type === 'completed' ? this.lastRequestTraceId : this.activeRequestTrace?.traceId; - const error = result.type === 'failed' ? toKimiErrorPayload(result.error) : undefined; - const interruptReason = result.type === 'completed' ? undefined : interruptReasonFor(result); - void this.dispatcher.dispatch( - new TurnEnded({ - agentId: this.scopeContext.agentId, - turnId: turn.id, - reason: result.type, - error, - durationMs, - interruptReason, - stopReason: result.type === 'completed' ? result.stopReason : undefined, - traceId, - }), - ); - if (error !== undefined) { + const durationMs = Date.now() - turn.startedAt; + const traceId = + result.type === 'completed' ? this.lastRequestTraceId : this.activeRequestTrace?.traceId; + const error = result.type === 'failed' ? toKimiErrorPayload(result.error) : undefined; + const interruptReason = result.type === 'completed' ? undefined : interruptReasonFor(result); void this.dispatcher.dispatch( - new AgentErrorEvent({ ...error, agentId: this.scopeContext.agentId }), + new TurnEnded({ + agentId: this.scopeContext.agentId, + turnId: turn.id, + reason: result.type, + error, + durationMs, + interruptReason, + stopReason: result.type === 'completed' ? result.stopReason : undefined, + traceId, + }), ); - } - if (interruptReason !== undefined) { - const interrupted: TurnInterruptedEvent = { + if (error !== undefined) { + void this.dispatcher.dispatch( + new AgentErrorEvent({ ...error, agentId: this.scopeContext.agentId }), + ); + } + if (interruptReason !== undefined) { + const interrupted: TurnInterruptedEvent = { + turn_id: turn.id, + at_step: result.steps, + mode: turn.mode ?? 'agent', + interrupt_reason: interruptReason, + provider_type: turn.providerType, + protocol: turn.protocol, + trace_id: traceId, + }; + this.telemetry.track2('turn_interrupted', interrupted); + } + const ended: TurnEndedTelemetryEvent = { turn_id: turn.id, - at_step: result.steps, + reason: result.type, + duration_ms: durationMs, mode: turn.mode ?? 'agent', - interrupt_reason: interruptReason, + error_type: error?.code, provider_type: turn.providerType, protocol: turn.protocol, trace_id: traceId, + enabled_plugins: this.plugins.enabledPluginIds()?.join(','), }; - this.telemetry.track2('turn_interrupted', interrupted); - } - const ended: TurnEndedTelemetryEvent = { - turn_id: turn.id, - reason: result.type, - duration_ms: durationMs, - mode: turn.mode ?? 'agent', - error_type: error?.code, - provider_type: turn.providerType, - protocol: turn.protocol, - trace_id: traceId, - enabled_plugins: this.plugins.enabledPluginIds()?.join(','), - }; - this.telemetry.track2('turn_ended', ended); - this.telemetry.setContext({ turn_id: undefined, trace_id: undefined, thinking_effort: undefined }); - this.activeRequestTrace = undefined; - this.lastRequestTraceId = undefined; + this.telemetry.track2('turn_ended', ended); + this.telemetry.setContext({ turn_id: undefined, trace_id: undefined, thinking_effort: undefined }); + this.activeRequestTrace = undefined; + this.lastRequestTraceId = undefined; + await this.dispatcher.flush().catch(() => undefined); + } finally { + this.pendingFinalizations -= 1; + } turn.result.resolve(result); this.maybeSettle(); } diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts index cb9b23d19bc..91e4f73e37e 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts @@ -84,26 +84,92 @@ export function lastCompletedUserVisibleTurnIndex( for (let start = 0; start < turnStarts.length; start += 1) { const from = turnStarts[start]! + 1; const to = turnStarts[start + 1] ?? records.length; - for (let index = from; index < to; index += 1) { - if (records[index]!.type === 'turn.ended') { - lastCompleted = start; - break; - } + if (turnCompletionAt(records, from, to, records[turnStarts[start]!]!) !== -1) { + lastCompleted = start; } } return lastCompleted; } -export function visibleTurnIndexOfPrompt( +export function capForkRecordsAtActiveTurn( + records: readonly WireRecord[], + lastCompleted: number, +): readonly WireRecord[] { + const turnStarts = userVisibleTurnStartIndices(records); + const completedAt = turnCompletionAt( + records, + turnStarts[lastCompleted]! + 1, + turnStarts[lastCompleted + 1] ?? records.length, + records[turnStarts[lastCompleted]!]!, + ); + if (completedAt === -1) return records; + for (let index = completedAt + 1; index < records.length; index += 1) { + const type = records[index]!.type; + if (type === 'agent.turn.started' || type === 'turn.prompt') { + return records.slice(0, index); + } + } + return records; +} + +export type ForkPromptResolution = + | { readonly status: 'found'; readonly index: number } + | { readonly status: 'unknown' } + | { readonly status: 'ambiguous' }; + +export function resolveForkPromptIndex( records: readonly WireRecord[], promptId: string, -): number | undefined { +): ForkPromptResolution { const turnStarts = userVisibleTurnStartIndices(records); + const targetIds = new Set([promptId]); + for (const record of records) { + if (record.type !== 'turn.steer') continue; + const promptIds = record['promptIds']; + if (!Array.isArray(promptIds) || !promptIds.includes(promptId)) continue; + const messageId = record['messageId']; + if (typeof messageId === 'string' && messageId.length > 0) targetIds.add(messageId); + } + let found: number | undefined; + let ambiguous = false; for (let start = 0; start < turnStarts.length; start += 1) { const message = asRecord(records[turnStarts[start]!]!['message']); - if (message?.['id'] === promptId) return start; + const id = message?.['id']; + if (typeof id !== 'string' || !targetIds.has(id)) continue; + if (found !== undefined && found !== start) ambiguous = true; + found = found ?? start; } - return undefined; + if (ambiguous) return { status: 'ambiguous' }; + return found === undefined ? { status: 'unknown' } : { status: 'found', index: found }; +} + +function turnCompletionAt( + records: readonly WireRecord[], + from: number, + to: number, + startRecord: WireRecord, +): number { + const shellStart = isShellCommandTurnStart(startRecord); + for (let index = from; index < to; index += 1) { + const record = records[index]!; + if (record.type === 'turn.ended') return index; + if (shellStart && isShellCommandOutputRecord(record)) return index; + } + return -1; +} + +function isShellCommandTurnStart(record: WireRecord): boolean { + if (record.type !== 'context.append_message') return false; + const message = asRecord(record['message']); + const origin = asRecord(message?.['origin']); + return origin?.['kind'] === 'shell_command' && origin?.['phase'] === 'input'; +} + +function isShellCommandOutputRecord(record: WireRecord): boolean { + if (record.type !== 'context.append_message') return false; + const message = asRecord(record['message']); + const origin = asRecord(message?.['origin']); + return origin?.['kind'] === 'shell_command' && origin?.['phase'] === 'output'; } function userVisibleTurnStartIndices(records: readonly WireRecord[]): number[] { diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index c4b5ac764fa..038bdd360d8 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -101,11 +101,12 @@ import { agentScopeOf, sessionDirOf, sessionScopeOf } from './internal/addressin import { SessionArchived, SessionDeleted } from './sessionLifecycleEvents'; import { assertForkTurnIndex, + capForkRecordsAtActiveTurn, lastCompletedUserVisibleTurnIndex, type MainTurnSlice, + resolveForkPromptIndex, sliceMainRecordsAtTurn, sliceSubagentRecordsAtTime, - visibleTurnIndexOfPrompt, } from './internal/forkTurnSlice'; import { type CreateChildSessionOptions, @@ -533,16 +534,31 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec try { let busy = false; if (sourceHandle !== undefined) { - const holds = this.tryAcquireForkQuiescence(sourceHandle); - if (holds === undefined) { - if (!this.hasTurnActivity(sourceHandle)) { - throw new Error2( - ErrorCodes.SESSION_FORK_ACTIVE_TURN, - `Session "${sourceId}" cannot be forked while another operation is in progress`, - { details: { sessionId: sourceId } }, - ); + const holds: IDisposable[] = []; + let nonTurnContention = false; + const sourceAgents = sourceHandle.accessor.get(IAgentLifecycleService); + for (const agent of sourceAgents.list()) { + const agentHandle = sourceAgents.handleOf(agent.agentId); + if (agentHandle === undefined) continue; + const loop = agentHandle.accessor.get(IAgentLoopService); + const hold = loop.tryAcquireQuiescence(); + if (hold === undefined) { + if (!loop.hasTurnActivity()) nonTurnContention = true; + busy = true; + } else { + holds.push(hold); } - busy = true; + } + if (nonTurnContention) { + for (const hold of holds) hold.dispose(); + throw new Error2( + ErrorCodes.SESSION_FORK_ACTIVE_TURN, + `Session "${sourceId}" cannot be forked while another operation is in progress`, + { details: { sessionId: sourceId } }, + ); + } + if (busy) { + for (const hold of holds) hold.dispose(); } else { quiescenceHolds.push(...holds); } @@ -705,32 +721,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec }); } - private tryAcquireForkQuiescence(sourceHandle: ISessionScopeHandle): IDisposable[] | undefined { - const holds: IDisposable[] = []; - const sourceAgents = sourceHandle.accessor.get(IAgentLifecycleService); - for (const agent of sourceAgents.list()) { - const agentHandle = sourceAgents.handleOf(agent.agentId); - if (agentHandle === undefined) continue; - const hold = agentHandle.accessor.get(IAgentLoopService).tryAcquireQuiescence(); - if (hold === undefined) { - for (const acquired of holds) acquired.dispose(); - return undefined; - } - holds.push(hold); - } - return holds; - } - - private hasTurnActivity(sourceHandle: ISessionScopeHandle): boolean { - const sourceAgents = sourceHandle.accessor.get(IAgentLifecycleService); - for (const agent of sourceAgents.list()) { - const agentHandle = sourceAgents.handleOf(agent.agentId); - if (agentHandle === undefined) continue; - if (agentHandle.accessor.get(IAgentLoopService).hasTurnActivity()) return true; - } - return false; - } - private async resolveForkTurnSlice( sourceHandle: ISessionScopeHandle | undefined, sourceId: string, @@ -744,14 +734,17 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ); let resolved = turnIndex; if (promptId !== undefined) { - resolved = visibleTurnIndexOfPrompt(records, promptId); - if (resolved === undefined) { + const resolution = resolveForkPromptIndex(records, promptId); + if (resolution.status !== 'found') { throw new Error2( ErrorCodes.REQUEST_INVALID, - `Prompt "${promptId}" was not found in session "${sourceId}"`, + resolution.status === 'ambiguous' + ? `Prompt "${promptId}" matches multiple turns in session "${sourceId}"` + : `Prompt "${promptId}" was not found in session "${sourceId}"`, { details: { promptId, sessionId: sourceId } }, ); } + resolved = resolution.index; } if (!busy) { return sliceMainRecordsAtTurn(records, sourceId, resolved!); @@ -771,7 +764,11 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec { details: { turnIndex: resolved, availableTurns: lastCompleted + 1 } }, ); } - return sliceMainRecordsAtTurn(records, sourceId, resolved ?? lastCompleted); + return sliceMainRecordsAtTurn( + capForkRecordsAtActiveTurn(records, lastCompleted), + sourceId, + resolved ?? lastCompleted, + ); } private async resolveSourceTitle(sourceId: string): Promise { diff --git a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts index bd7f1f967c2..5e0b5970968 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts @@ -3,6 +3,7 @@ import type { ToolCall } from '#human/llm/message'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { estimateTokens, estimateTokensForMessages } from '#/llm-adapter/contract/tokens'; +import { IAgentLoopService } from '#/agent/loop/loop'; import { buildImageCompressionCaption } from '#/agent/media/image-compress'; import { buildContextCompactionShape, @@ -692,6 +693,7 @@ describe('Agent context', () => { input: [{ type: 'text', text: `inspect this image ${caption}` }], }); await ctx.untilTurnEnd(); + await ctx.get(IAgentLoopService).settled(); expect(context.get()).toMatchObject([ { 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 6781551d8d1..e9cd055af24 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -950,6 +950,33 @@ describe('Agent loop', () => { await expect(active.result).resolves.toMatchObject({ type: 'completed' }); }); + it('refuses a quiescence lease while a turn is finalizing', async () => { + let releaseDrain!: () => void; + const drainGate = new Promise((resolve) => { + releaseDrain = resolve; + }); + const drain = vi + .spyOn(ctx.wire, 'drainPersisted') + .mockImplementationOnce(() => drainGate); + + ctx.mockNextResponse({ type: 'text', text: 'done' }); + const active = submitTurn(loop, 'go').turn; + await vi.waitFor(() => { + expect(drain).toHaveBeenCalled(); + }); + + expect(loop.snapshot().state).toBe('idle'); + expect(loop.tryAcquireQuiescence()).toBeUndefined(); + expect(loop.hasTurnActivity()).toBe(true); + + releaseDrain(); + await expect(active.result).resolves.toMatchObject({ type: 'completed' }); + drain.mockRestore(); + + expect(loop.tryAcquireQuiescence()).toBeDefined(); + expect(loop.hasTurnActivity()).toBe(false); + }); + it('holds new admissions until an idle quiescence lease is released', async () => { const lease = loop.tryAcquireQuiescence(); expect(lease).toBeDefined(); @@ -1624,6 +1651,7 @@ describe('turn telemetry', () => { }); await local.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] }); await local.untilTurnEnd(); + await local.get(IAgentLoopService).settled(); local.appendExchange(2, 'old user two', 'old assistant two', 80); local.mockNextProviderResponse({ @@ -1957,6 +1985,7 @@ describe('interruption reminder', () => { const subscription = cancelOnFirstDelta(); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] }); await ctx.untilTurnEnd(); + await ctx.get(IAgentLoopService).settled(); subscription.dispose(); expect(interruptionReminders()).toHaveLength(1); diff --git a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts index 949970282f8..67bb8aa4e40 100644 --- a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts +++ b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts @@ -309,6 +309,7 @@ describe('AgentPluginService plugin session-start wiring', () => { ctx.mockNextResponse({ type: 'text', text: 'second answer' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'second prompt' }] }); await ctx.untilTurnEnd(); + await ctx.get(IAgentLoopService).settled(); await ctx.undoHistory(1); ctx.mockNextResponse({ type: 'text', text: 'third answer' }); diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts index 04e1932d46e..4a7a70e7d60 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentConversationUndoService } from '#/agent/undo/undo'; import type { ContextMessage } from '#/agent/contextMemory/types'; import type { ExecutableTool, ToolExecution } from '#/tool/toolContract'; @@ -220,6 +221,7 @@ describe('progressive tool disclosure end-to-end', () => { ctx.mockNextResponse({ type: 'text', text: 'alpha is loaded' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'load alpha' }] }); await ctx.untilTurnEnd(); + await ctx.get(IAgentLoopService).settled(); await ctx.get(IAgentConversationUndoService).undo(1); const afterUndo = ctx.get(IAgentContextMemoryService).get(); diff --git a/packages/agent-core-v2/test/features/skill/activateSkill.test.ts b/packages/agent-core-v2/test/features/skill/activateSkill.test.ts index fb71883f2d2..175be8b1027 100644 --- a/packages/agent-core-v2/test/features/skill/activateSkill.test.ts +++ b/packages/agent-core-v2/test/features/skill/activateSkill.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { InMemorySkillCatalog } from '#/features/skill/catalog/registry'; +import { IAgentLoopService } from '#/agent/loop/loop'; import { stubSkill } from './catalog/stubs'; import { createTestAgent, skillServices, type TestAgentContext } from '../../harness'; @@ -175,6 +176,7 @@ describe('promptWithSkills', () => { skills: [{ name: 'review' }, { name: 'security' }], }); await ctx.untilTurnEnd(); + await ctx.get(IAgentLoopService).settled(); expect(ctx.context.get().length).toBeGreaterThan(0); const undone = await ctx.rpc.undoHistory({ count: 1 }); diff --git a/packages/agent-core-v2/test/features/swarm/swarm.test.ts b/packages/agent-core-v2/test/features/swarm/swarm.test.ts index 0808c5bb348..a355bd56ecf 100644 --- a/packages/agent-core-v2/test/features/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/features/swarm/swarm.test.ts @@ -660,6 +660,7 @@ describe('swarm context reconciliation', () => { ctx.mockNextResponse({ type: 'text', text: 'second answer' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'second prompt' }] }); await ctx.untilTurnEnd(); + await ctx.get(IAgentLoopService).settled(); await ctx.undoHistory(1); ctx.mockNextResponse({ type: 'text', text: 'third answer' }); diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts index ccfeb211cad..e8dbe482bb3 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest'; import { + capForkRecordsAtActiveTurn, lastCompletedUserVisibleTurnIndex, + resolveForkPromptIndex, sliceMainRecordsAtTurn, - visibleTurnIndexOfPrompt, } from '#/workspace/sessionLifecycle/internal/forkTurnSlice'; import type { WireRecord } from '#/wire/record'; @@ -11,7 +12,7 @@ function userTurnRecord(text: string, time: number, id?: string): WireRecord { return { type: 'context.append_message', message: { - ...(id === undefined ? {} : { id }), + id, role: 'user', content: [{ type: 'text', text }], origin: { kind: 'user' }, @@ -20,6 +21,22 @@ function userTurnRecord(text: string, time: number, id?: string): WireRecord { }; } +function shellCommandRecord( + text: string, + time: number, + phase: 'input' | 'output', +): WireRecord { + return { + type: 'context.append_message', + message: { + role: phase === 'input' ? 'user' : 'assistant', + content: [{ type: 'text', text }], + origin: { kind: 'shell_command', phase }, + }, + time, + }; +} + function turnEndedRecord( turnId: number, time: number, @@ -101,7 +118,7 @@ describe('sliceMainRecordsAtTurn lastTurnReason', () => { }); }); -describe('visibleTurnIndexOfPrompt', () => { +describe('resolveForkPromptIndex', () => { it('resolves the visible turn index of a prompt message id', () => { const records: WireRecord[] = [ { type: 'metadata', protocol_version: '1.5', created_at: 1 }, @@ -111,9 +128,9 @@ describe('visibleTurnIndexOfPrompt', () => { turnEndedRecord(1, 5), userTurnRecord('third still running', 6, 'p3'), ]; - expect(visibleTurnIndexOfPrompt(records, 'p1')).toBe(0); - expect(visibleTurnIndexOfPrompt(records, 'p2')).toBe(1); - expect(visibleTurnIndexOfPrompt(records, 'p3')).toBe(2); + expect(resolveForkPromptIndex(records, 'p1')).toEqual({ status: 'found', index: 0 }); + expect(resolveForkPromptIndex(records, 'p2')).toEqual({ status: 'found', index: 1 }); + expect(resolveForkPromptIndex(records, 'p3')).toEqual({ status: 'found', index: 2 }); }); it('resolves the index of a steered prompt message id', () => { @@ -124,10 +141,40 @@ describe('visibleTurnIndexOfPrompt', () => { userTurnRecord('steered input', 4, 'p2'), turnEndedRecord(0, 5), ]; - expect(visibleTurnIndexOfPrompt(records, 'p2')).toBe(1); + expect(resolveForkPromptIndex(records, 'p2')).toEqual({ status: 'found', index: 1 }); + }); + + it('resolves an original prompt id of a merged steer through the steer record', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + userTurnRecord('original prompt', 2, 'p1'), + { + type: 'turn.steer', + turnId: 0, + messageId: 'merged-1', + promptIds: ['p2', 'p3'], + origin: { kind: 'user' }, + time: 3, + }, + userTurnRecord('merged steered input', 4, 'merged-1'), + turnEndedRecord(0, 5), + ]; + expect(resolveForkPromptIndex(records, 'p2')).toEqual({ status: 'found', index: 1 }); + expect(resolveForkPromptIndex(records, 'p3')).toEqual({ status: 'found', index: 1 }); }); - it('returns undefined for an unknown prompt id or a non-visible append', () => { + it('reports ambiguous when a prompt id matches multiple visible turns', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + userTurnRecord('first', 2, 'p1'), + turnEndedRecord(0, 3), + userTurnRecord('second', 4, 'p1'), + turnEndedRecord(1, 5), + ]; + expect(resolveForkPromptIndex(records, 'p1')).toEqual({ status: 'ambiguous' }); + }); + + it('reports unknown for a missing prompt id or a non-visible append', () => { const records: WireRecord[] = [ { type: 'metadata', protocol_version: '1.5', created_at: 1 }, userTurnRecord('real prompt', 2, 'p1'), @@ -143,8 +190,56 @@ describe('visibleTurnIndexOfPrompt', () => { time: 4, }, ]; - expect(visibleTurnIndexOfPrompt(records, 'missing')).toBeUndefined(); - expect(visibleTurnIndexOfPrompt(records, 'cron-1')).toBeUndefined(); + expect(resolveForkPromptIndex(records, 'missing')).toEqual({ status: 'unknown' }); + expect(resolveForkPromptIndex(records, 'cron-1')).toEqual({ status: 'unknown' }); + }); +}); + +describe('capForkRecordsAtActiveTurn', () => { + it('cuts the records before an active non-visible turn', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + userTurnRecord('done', 2, 'p1'), + turnEndedRecord(0, 3), + { + type: 'turn.prompt', + turnId: 1, + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + time: 4, + }, + { type: 'agent.turn.started', turnId: 1, time: 5 }, + ]; + const capped = capForkRecordsAtActiveTurn(records, 0); + expect(capped.map((record) => record.type)).toEqual([ + 'metadata', + 'context.append_message', + 'turn.ended', + ]); + }); + + it('cuts the records before an active turn that follows a shell command', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + shellCommandRecord('!ls', 2, 'input'), + shellCommandRecord('file.ts', 3, 'output'), + { type: 'turn.prompt', turnId: 0, origin: { kind: 'user' }, time: 4 }, + { type: 'agent.turn.started', turnId: 0, time: 5 }, + ]; + const capped = capForkRecordsAtActiveTurn(records, 0); + expect(capped.map((record) => record.type)).toEqual([ + 'metadata', + 'context.append_message', + 'context.append_message', + ]); + }); + + it('keeps the records when no engine turn follows the last completed one', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + userTurnRecord('done', 2, 'p1'), + turnEndedRecord(0, 3), + ]; + expect(capForkRecordsAtActiveTurn(records, 0)).toBe(records); }); }); @@ -195,6 +290,24 @@ describe('lastCompletedUserVisibleTurnIndex', () => { expect(lastCompletedUserVisibleTurnIndex(records)).toBe(1); }); + it('counts a shell command with an output append as completed', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + shellCommandRecord('!ls', 2, 'input'), + shellCommandRecord('file.ts', 3, 'output'), + userTurnRecord('follow-up still running', 4, 'p1'), + ]; + expect(lastCompletedUserVisibleTurnIndex(records)).toBe(0); + }); + + it('does not count a shell command still running as completed', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + shellCommandRecord('!ls', 2, 'input'), + ]; + expect(lastCompletedUserVisibleTurnIndex(records)).toBeUndefined(); + }); + it('does not treat system-triggered appends as turn starts', () => { const records: WireRecord[] = [ { type: 'metadata', protocol_version: '1.5', created_at: 1 }, diff --git a/packages/kap-server/test/prompts.test.ts b/packages/kap-server/test/prompts.test.ts index acc157cc36b..85d9ff1746f 100644 --- a/packages/kap-server/test/prompts.test.ts +++ b/packages/kap-server/test/prompts.test.ts @@ -365,6 +365,7 @@ describe('server-v2 /api/v1 prompts', () => { await vi.waitFor(() => { expect(ended).toHaveLength(1); }); + await main.accessor.get(IAgentLoopService).settled(); subscription.dispose(); expect(ended[0]).toMatchObject({ reason: 'failed', @@ -427,6 +428,7 @@ describe('server-v2 /api/v1 prompts', () => { }, { timeout: 15000 }, ); + await main.accessor.get(IAgentLoopService).settled(); subscription.dispose(); expect(ended[0]?.reason).toBe('failed'); expect(ended[0]?.error?.message).toContain('stub'); @@ -1713,6 +1715,9 @@ describe('server-v2 /api/v1 prompts', () => { content: [{ type: 'text', text: 'hello' }], }); const promptId = submitted.body.data.prompt_id; + const session = getLiveSessionById(server!.core.accessor, id); + const main = session!.accessor.get(IAgentLifecycleService).handleOf('main')!; + await main.accessor.get(IAgentLoopService).settled(); const aborted = await call<{ aborted: boolean }>( 'POST', diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 30d1d636f9b..425de90b0fd 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -1389,7 +1389,7 @@ describe('server-v2 /api/v1/sessions', () => { content: [{ type: 'text', text }], toolCalls: [], origin: { kind: 'user' }, - ...(promptId === undefined ? {} : { id: promptId }), + id: promptId, }); const assistant = (text: string): ContextMessage => ({ role: 'assistant', diff --git a/packages/kap-server/test/transcriptContract.e2e.test.ts b/packages/kap-server/test/transcriptContract.e2e.test.ts index 042e5cb09a9..3b9829d54d1 100644 --- a/packages/kap-server/test/transcriptContract.e2e.test.ts +++ b/packages/kap-server/test/transcriptContract.e2e.test.ts @@ -8,6 +8,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { WebSocket, type RawData } from 'ws'; import { IAgentLifecycleService, + IAgentLoopService, IConfigService, closeSessionById, MAIN_AGENT_ID, @@ -268,8 +269,13 @@ describe('transcript contract e2e', () => { await server!.core.accessor.get(IConfigService).reload(); } - const idle = (server: RunningServer, base: string, sid: string) => - until('session idle', async () => !(await getSessionFacts(server, base, sid)).busy); + const idle = async (server: RunningServer, base: string, sid: string): Promise => { + await until('session idle', async () => !(await getSessionFacts(server, base, sid)).busy); + const main = getLiveSessionById(server.core.accessor, sid) + ?.accessor.get(IAgentLifecycleService) + .handleOf('main'); + await main?.accessor.get(IAgentLoopService).settled(); + }; function dumpState(tx: TxSnapshot, hits: string[]): string { const turns = tx.items diff --git a/packages/node-sdk/test/session-prompt-events.test.ts b/packages/node-sdk/test/session-prompt-events.test.ts index ad2d55f5a7f..5ddce054fd2 100644 --- a/packages/node-sdk/test/session-prompt-events.test.ts +++ b/packages/node-sdk/test/session-prompt-events.test.ts @@ -7,8 +7,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createKimiHarness, type Event, type KimiHarness } from '#/index'; +import { + getLiveSessionById, + IAgentLifecycleService, + IAgentLoopService, + MAIN_AGENT_ID, + type ServicesAccessor, +} from '@moonshot-ai/agent-core-v2'; + import { TEST_IDENTITY } from './test-identity'; +async function settleSessionLoop(harness: KimiHarness, sessionId: string): Promise { + const accessor = (harness as unknown as { rpc: { engineAccessor: ServicesAccessor } }).rpc + .engineAccessor; + const agent = getLiveSessionById(accessor, sessionId) + ?.accessor.get(IAgentLifecycleService) + .handleOf(MAIN_AGENT_ID); + await agent?.accessor.get(IAgentLoopService).settled(); +} + const MODEL_URL = 'https://model.example.test/v1/chat/completions'; const fakeProviderState = vi.hoisted(() => ({ @@ -626,6 +643,7 @@ describe('Session.prompt events', () => { await runPrompt(source, 'kept prompt one', 'kept answer one', 'p1'); await runPrompt(source, 'kept prompt two', 'kept answer two', 'p2'); await runPrompt(source, 'retracted prompt', 'retracted answer', 'p3'); + await settleSessionLoop(harness, source.id); await source.undoHistory(1); await runPrompt(source, 'follow-up prompt', 'follow-up answer', 'p4'); @@ -1083,6 +1101,55 @@ describe('Session.prompt events', () => { } }); + it('forks a running session clamped through a completed shell command', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); + + try { + await configureFakeProvider(harness); + const source = await harness.createSession({ id: 'ses_turn_fork_shell_source', workDir }); + await source.runShellCommand('echo shell-output'); + + const gate = deferredResponse(); + fetchStub!.mockImplementationOnce(() => gate.promise); + const started = waitForEvent(source, (event) => event.type === 'turn.started'); + const ended = waitForEvent(source, (event) => event.type === 'turn.ended'); + await source.prompt('first question'); + await started; + + const fork = await harness.forkSession({ + id: source.id, + forkId: 'ses_turn_fork_shell_child', + }); + await fork.close(); + const resumed = await harness.resumeSession({ id: fork.id }); + const replay = resumed.getResumeState()?.agents['main']?.replay ?? []; + const shellTexts = replay + .filter( + (record): record is Extract<(typeof replay)[number], { type: 'message' }> => + record.type === 'message' && + (record.message.origin as { kind?: string } | undefined)?.kind === 'shell_command', + ) + .map((record) => + record.message.content + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join(''), + ); + expect(shellTexts).toEqual([ + '\necho shell-output\n', + 'shell-output\n', + ]); + expect(visibleReplayText(replay)).toEqual([]); + + gate.resolve(sseResponse('first answer')); + await ended; + } finally { + await harness.close(); + } + }); + it('rejects empty prompt input', async () => { const homeDir = await makeTempDir(); const workDir = await makeTempDir(); From 950b501236a6241585a717bf53b7fa5cd5d91d4a Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 8 Oct 2026 17:50:19 +0800 Subject: [PATCH 5/7] fix: keep busy forks complete and open quiescence when the turn ends - cap busy-fork slices at the first unterminated turn instead of the first turn after the last completed visible one, so completed continuation turns before the active turn stay in the fork - stop the fork outcome scan at the last turn's start boundary, so a slice cut before its terminal record no longer inherits an earlier turn's last_turn_reason - gate quiescence on the turn-close window (pendingTurnCloses, released once turn.ended is published) while settled() keeps waiting for the turn result to resolve (pendingFinalizations), so undo, compaction, and fork called from turn-completion continuations no longer fail with a spurious busy error --- .../src/agent/loop/loopService.ts | 12 ++- .../internal/forkTurnSlice.ts | 45 +++++++++--- .../test/agent/contextMemory/context.test.ts | 2 - .../test/agent/loop/loop.test.ts | 12 ++- .../test/agent/plugin/agentPlugin.test.ts | 1 - .../agent/toolSelect/toolSelect.e2e.test.ts | 2 - .../test/features/skill/activateSkill.test.ts | 2 - .../test/features/swarm/swarm.test.ts | 1 - .../sessionLifecycle/forkTurnSlice.test.ts | 73 +++++++++++++++++++ 9 files changed, 128 insertions(+), 22 deletions(-) diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 9ce76314422..0c68b165c5d 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -151,6 +151,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { private machineTurnSuppressed = false; private readonly settleWaiters: Array<() => void> = []; private quiescenceDepth = 0; + private pendingTurnCloses = 0; private pendingFinalizations = 0; private activeRequestTrace: LLMRequestTrace | undefined; private engine: MachineEngine | undefined; @@ -770,7 +771,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { this.active !== undefined || this.hasPendingRequests() || this.pendingMachineTurn !== undefined || - this.pendingFinalizations > 0 + this.pendingTurnCloses > 0 ) { return undefined; } @@ -784,7 +785,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { this.active !== undefined || this.hasPendingRequests() || this.pendingMachineTurn !== undefined || - this.pendingFinalizations > 0 + this.pendingTurnCloses > 0 ); } @@ -2043,6 +2044,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { private async endTurn(turn: ActiveTurn, result: TurnResult): Promise { if (this.active !== turn) return; this.active = undefined; + this.pendingTurnCloses += 1; this.pendingFinalizations += 1; try { await this.wire.drainPersisted().catch(() => undefined); @@ -2113,11 +2115,15 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { this.telemetry.setContext({ turn_id: undefined, trace_id: undefined, thinking_effort: undefined }); this.activeRequestTrace = undefined; this.lastRequestTraceId = undefined; + } finally { + this.pendingTurnCloses -= 1; + } + try { await this.dispatcher.flush().catch(() => undefined); + turn.result.resolve(result); } finally { this.pendingFinalizations -= 1; } - turn.result.resolve(result); this.maybeSettle(); } diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts index 91e4f73e37e..6e956e0d2b6 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts @@ -103,13 +103,30 @@ export function capForkRecordsAtActiveTurn( records[turnStarts[lastCompleted]!]!, ); if (completedAt === -1) return records; + let openTurnStart = -1; + let openShellInput = false; for (let index = completedAt + 1; index < records.length; index += 1) { - const type = records[index]!.type; - if (type === 'agent.turn.started' || type === 'turn.prompt') { - return records.slice(0, index); + const record = records[index]!; + if (openTurnStart === -1) { + if (isEngineTurnStartRecord(record) || isUserVisibleTurnRecord(record)) { + openTurnStart = index; + openShellInput = isShellCommandTurnStart(record); + } + continue; + } + if (record.type === 'turn.ended') { + openTurnStart = -1; + continue; + } + if (isShellCommandTurnStart(record)) { + openShellInput = true; + continue; + } + if (openShellInput && isShellCommandOutputRecord(record)) { + openTurnStart = -1; } } - return records; + return openTurnStart === -1 ? records : records.slice(0, openTurnStart); } export type ForkPromptResolution = @@ -311,15 +328,25 @@ function turnOutcomeOfRecords( ): 'completed' | 'cancelled' | 'failed' | undefined { for (let index = records.length - 1; index >= 0; index -= 1) { const record = records[index]!; - if (record.type !== 'turn.ended') continue; - const reason = record['reason']; - if (reason === 'completed' || reason === 'cancelled') return reason; - if (reason === 'failed' || reason === 'blocked') return 'failed'; - return undefined; + if (record.type === 'turn.ended') { + const reason = record['reason']; + if (reason === 'completed' || reason === 'cancelled') return reason; + if (reason === 'failed' || reason === 'blocked') return 'failed'; + return undefined; + } + if (isEngineTurnStartRecord(record) || isUserVisibleTurnRecord(record)) return undefined; } return undefined; } +function isEngineTurnStartRecord(record: WireRecord): boolean { + return ( + record.type === 'turn.prompt' || + record.type === 'turn.steer' || + record.type === 'agent.turn.started' + ); +} + function promptMetadataFromTurnRecord(record: WireRecord): string | undefined { if (record.type !== 'context.append_message') return undefined; const message = asRecord(record['message']); diff --git a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts index 5e0b5970968..bd7f1f967c2 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts @@ -3,7 +3,6 @@ import type { ToolCall } from '#human/llm/message'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { estimateTokens, estimateTokensForMessages } from '#/llm-adapter/contract/tokens'; -import { IAgentLoopService } from '#/agent/loop/loop'; import { buildImageCompressionCaption } from '#/agent/media/image-compress'; import { buildContextCompactionShape, @@ -693,7 +692,6 @@ describe('Agent context', () => { input: [{ type: 'text', text: `inspect this image ${caption}` }], }); await ctx.untilTurnEnd(); - await ctx.get(IAgentLoopService).settled(); expect(context.get()).toMatchObject([ { 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 e9cd055af24..bf68faa834e 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -977,6 +977,16 @@ describe('Agent loop', () => { expect(loop.hasTurnActivity()).toBe(false); }); + it('exposes a quiescence lease once the turn.ended event is observable', async () => { + ctx.mockNextResponse({ type: 'text', text: 'done' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'go' }] }); + await ctx.untilTurnEnd(); + + const lease = loop.tryAcquireQuiescence(); + expect(lease).toBeDefined(); + lease?.dispose(); + }); + it('holds new admissions until an idle quiescence lease is released', async () => { const lease = loop.tryAcquireQuiescence(); expect(lease).toBeDefined(); @@ -1651,7 +1661,6 @@ describe('turn telemetry', () => { }); await local.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] }); await local.untilTurnEnd(); - await local.get(IAgentLoopService).settled(); local.appendExchange(2, 'old user two', 'old assistant two', 80); local.mockNextProviderResponse({ @@ -1985,7 +1994,6 @@ describe('interruption reminder', () => { const subscription = cancelOnFirstDelta(); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] }); await ctx.untilTurnEnd(); - await ctx.get(IAgentLoopService).settled(); subscription.dispose(); expect(interruptionReminders()).toHaveLength(1); diff --git a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts index 67bb8aa4e40..949970282f8 100644 --- a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts +++ b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts @@ -309,7 +309,6 @@ describe('AgentPluginService plugin session-start wiring', () => { ctx.mockNextResponse({ type: 'text', text: 'second answer' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'second prompt' }] }); await ctx.untilTurnEnd(); - await ctx.get(IAgentLoopService).settled(); await ctx.undoHistory(1); ctx.mockNextResponse({ type: 'text', text: 'third answer' }); diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts index 4a7a70e7d60..04e1932d46e 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentConversationUndoService } from '#/agent/undo/undo'; import type { ContextMessage } from '#/agent/contextMemory/types'; import type { ExecutableTool, ToolExecution } from '#/tool/toolContract'; @@ -221,7 +220,6 @@ describe('progressive tool disclosure end-to-end', () => { ctx.mockNextResponse({ type: 'text', text: 'alpha is loaded' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'load alpha' }] }); await ctx.untilTurnEnd(); - await ctx.get(IAgentLoopService).settled(); await ctx.get(IAgentConversationUndoService).undo(1); const afterUndo = ctx.get(IAgentContextMemoryService).get(); diff --git a/packages/agent-core-v2/test/features/skill/activateSkill.test.ts b/packages/agent-core-v2/test/features/skill/activateSkill.test.ts index 175be8b1027..fb71883f2d2 100644 --- a/packages/agent-core-v2/test/features/skill/activateSkill.test.ts +++ b/packages/agent-core-v2/test/features/skill/activateSkill.test.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, it } from 'vitest'; import { InMemorySkillCatalog } from '#/features/skill/catalog/registry'; -import { IAgentLoopService } from '#/agent/loop/loop'; import { stubSkill } from './catalog/stubs'; import { createTestAgent, skillServices, type TestAgentContext } from '../../harness'; @@ -176,7 +175,6 @@ describe('promptWithSkills', () => { skills: [{ name: 'review' }, { name: 'security' }], }); await ctx.untilTurnEnd(); - await ctx.get(IAgentLoopService).settled(); expect(ctx.context.get().length).toBeGreaterThan(0); const undone = await ctx.rpc.undoHistory({ count: 1 }); diff --git a/packages/agent-core-v2/test/features/swarm/swarm.test.ts b/packages/agent-core-v2/test/features/swarm/swarm.test.ts index a355bd56ecf..0808c5bb348 100644 --- a/packages/agent-core-v2/test/features/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/features/swarm/swarm.test.ts @@ -660,7 +660,6 @@ describe('swarm context reconciliation', () => { ctx.mockNextResponse({ type: 'text', text: 'second answer' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'second prompt' }] }); await ctx.untilTurnEnd(); - await ctx.get(IAgentLoopService).settled(); await ctx.undoHistory(1); ctx.mockNextResponse({ type: 'text', text: 'third answer' }); diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts index e8dbe482bb3..800a8c7cfd2 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts @@ -116,6 +116,21 @@ describe('sliceMainRecordsAtTurn lastTurnReason', () => { expect(sliceMainRecordsAtTurn(records, 'ses_source', 0).lastTurnReason).toBeUndefined(); expect(sliceMainRecordsAtTurn(records, 'ses_source', 1).lastTurnReason).toBe('completed'); }); + + it('returns undefined when a later steered turn is cut before its terminal record', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + userTurnRecord('first', 2), + turnEndedRecord(0, 3, 'completed'), + userTurnRecord('original prompt', 4), + { type: 'turn.steer', turnId: 1, messageId: 'msg_steer', origin: { kind: 'user' }, time: 5 }, + userTurnRecord('steered input', 6), + turnEndedRecord(1, 7), + ]; + expect(sliceMainRecordsAtTurn(records, 'ses_source', 0).lastTurnReason).toBe('completed'); + expect(sliceMainRecordsAtTurn(records, 'ses_source', 1).lastTurnReason).toBeUndefined(); + expect(sliceMainRecordsAtTurn(records, 'ses_source', 2).lastTurnReason).toBe('completed'); + }); }); describe('resolveForkPromptIndex', () => { @@ -241,6 +256,64 @@ describe('capForkRecordsAtActiveTurn', () => { ]; expect(capForkRecordsAtActiveTurn(records, 0)).toBe(records); }); + + it('keeps completed continuation turns before the active turn', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + userTurnRecord('done', 2, 'p1'), + turnEndedRecord(0, 3), + { + type: 'turn.prompt', + turnId: 1, + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + time: 4, + }, + { type: 'agent.turn.started', turnId: 1, time: 5 }, + { + type: 'context.append_message', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'continuation reply' }], + }, + time: 6, + }, + turnEndedRecord(1, 7), + { + type: 'turn.prompt', + turnId: 2, + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + time: 8, + }, + { type: 'agent.turn.started', turnId: 2, time: 9 }, + ]; + const capped = capForkRecordsAtActiveTurn(records, 0); + expect(capped.map((record) => record.type)).toEqual([ + 'metadata', + 'context.append_message', + 'turn.ended', + 'turn.prompt', + 'agent.turn.started', + 'context.append_message', + 'turn.ended', + ]); + }); + + it('keeps the records when every turn after the last completed one has completed', () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + userTurnRecord('done', 2, 'p1'), + turnEndedRecord(0, 3), + { + type: 'turn.prompt', + turnId: 1, + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + time: 4, + }, + { type: 'agent.turn.started', turnId: 1, time: 5 }, + turnEndedRecord(1, 6), + ]; + expect(capForkRecordsAtActiveTurn(records, 0)).toBe(records); + }); }); describe('lastCompletedUserVisibleTurnIndex', () => { From e5e316d61bae9edb59150e6242fa0f2f0bd49885 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 8 Oct 2026 18:47:36 +0800 Subject: [PATCH 6/7] feat: fork a session from an earlier prompt, idle only - fork now requires quiescence from every agent of the source session and rejects with session.fork_active_turn while any operation is in progress; the busy-fork slicing path (cap at the last completed turn) is removed and will return in a follow-up built on loop-reported fork boundaries - keep the prompt_id addressing (merged-steer resolution, ambiguous rejection), the turn-sliced fork, and the turn-close quiescence fix that lets undo, compaction, and fork run right after turn.ended --- .changeset/fork-at-turn.md | 2 +- docs/en/reference/server-api.md | 12 +- docs/zh/reference/server-api.md | 12 +- .../internal/forkTurnSlice.ts | 82 ----- .../sessionLifecycleService.ts | 60 +--- .../sessionLifecycle/forkTurnSlice.test.ts | 209 ------------- .../test/session-prompt-events.test.ts | 290 +----------------- 7 files changed, 29 insertions(+), 638 deletions(-) diff --git a/.changeset/fork-at-turn.md b/.changeset/fork-at-turn.md index e84a2f5f271..957cb2c0302 100644 --- a/.changeset/fork-at-turn.md +++ b/.changeset/fork-at-turn.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Sessions can now be forked while a response is still running — the fork stops at the last completed turn — and the `:fork` server endpoint accepts a `prompt_id` to fork from an earlier prompt. +The `:fork` server endpoint accepts a `prompt_id` to fork the session from an earlier prompt instead of the latest state. diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md index cc1f99fcfa8..1db2c917d00 100644 --- a/docs/en/reference/server-api.md +++ b/docs/en/reference/server-api.md @@ -710,18 +710,18 @@ Session actions are dispatched through one route: the path tail is parsed as `{s Copies the session — its transcript, agent state, and files — into a new session in the same workspace, and broadcasts `event.session.created`. Pass `prompt_id` to fork from an earlier point instead of the latest state: the copy keeps only the conversation through that prompt's turn segment and discards everything after it. -Forking is allowed while the session has an active or queued turn. In that case the fork point is capped at the last completed turn: a `prompt_id` belonging to a later turn fails `40001`, and omitting `prompt_id` forks through the last completed turn rather than the very latest state. +Forking is rejected while the session has an active or queued turn, or while another operation (compaction, undo, another fork) is in progress; retry once the session is idle. | Parameter | In | Type | Description | | --- | --- | --- | --- | | `title` | body | string | Title for the fork (at least 1 character). Default `Fork: ` | | `metadata` | body | object | Custom metadata for the fork | -| `prompt_id` | body | string | Id of the user prompt to fork from. Default: the latest state, or the last completed turn while a turn is active | +| `prompt_id` | body | string | Id of the user prompt to fork from. Default: the latest state | On success, `data` is [the session object](#the-session-object) of the new session. -- `40001`: `prompt_id` is unknown in the session, or belongs to a turn past the last completed one -- `40901`: a turn is active and no turn has completed yet, so there is nothing to fork +- `40001`: `prompt_id` is unknown in the session +- `40901`: a turn is active or another operation is in progress, so the session cannot be forked right now #### `POST /api/v1/sessions/{session_id}:compact` @@ -791,7 +791,7 @@ On success, `data` is `{ items, has_more }` where each item is [the session obje #### `POST /api/v1/sessions/{session_id}/children` -Creates a child session: a fork of this session recorded as its child, so it shows up under `GET /api/v1/sessions/{session_id}/children`. The same rules as `:fork` apply while a turn is active: the copy is capped at the last completed turn, and it is rejected only when no turn has completed yet. +Creates a child session: a fork of this session recorded as its child, so it shows up under `GET /api/v1/sessions/{session_id}/children`. The same rules as `:fork` apply: it is rejected while a turn is active or another operation is in progress. | Parameter | In | Type | Description | | --- | --- | --- | --- | @@ -801,7 +801,7 @@ Creates a child session: a fork of this session recorded as its child, so it sho On success, `data` is [the session object](#the-session-object) of the new session, and the server broadcasts `event.session.created`. -- `40901`: a turn is active and no turn has completed yet, so there is nothing to fork +- `40901`: a turn is active or another operation is in progress, so the session cannot be forked right now #### `GET /api/v1/sessions/{session_id}/status` diff --git a/docs/zh/reference/server-api.md b/docs/zh/reference/server-api.md index 80e4d5fef97..6e0be2516e0 100644 --- a/docs/zh/reference/server-api.md +++ b/docs/zh/reference/server-api.md @@ -710,18 +710,18 @@ schema 还接受 `agent_config` 内的 `system_prompt`、`tools`、`mcp_servers` 将会话——其转录、Agent 状态与文件——复制到同一工作区中的新会话,并广播 `event.session.created`。传入 `prompt_id` 可从更早的位置分叉,而不是复制最新状态:副本只保留到该提示词所在轮次段结束为止的对话,其后的内容全部丢弃。 -会话有进行中或排队中的轮次时也可以 fork。此时分叉点以最后一个已完成轮次为上限:`prompt_id` 属于更晚的轮次时返回 `40001`;不传 `prompt_id` 则复制到最后一个已完成轮次,而不是最新状态。 +会话有进行中或排队中的轮次,或者有其他操作(压缩、撤销、另一个 fork)在进行时,fork 会被拒绝;等会话空闲后重试。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | | `title` | body | string | fork 的标题(至少 1 个字符)。默认 `Fork: ` | | `metadata` | body | object | fork 的自定义元数据 | -| `prompt_id` | body | string | 要从中分叉的用户提示词 id。默认为最新状态;有进行中轮次时为最后一个已完成轮次 | +| `prompt_id` | body | string | 要从中分叉的用户提示词 id。默认为最新状态 | 成功时,`data` 为新会话的 [session 对象](#session-对象)。 -- `40001`:`prompt_id` 在会话中不存在,或属于最后一个已完成轮次之后的轮次 -- `40901`:有轮次正在进行且尚无任何已完成轮次,没有可 fork 的内容 +- `40001`:`prompt_id` 在会话中不存在 +- `40901`:有轮次正在进行或有其他操作在进行,暂时无法 fork #### `POST /api/v1/sessions/{session_id}:compact` @@ -791,7 +791,7 @@ schema 还接受 `agent_config` 内的 `system_prompt`、`tools`、`mcp_servers` #### `POST /api/v1/sessions/{session_id}/children` -创建子会话:fork 当前会话并记录为其子会话,因此会出现在 `GET /api/v1/sessions/{session_id}/children` 下。适用与 `:fork` 相同的规则:有进行中轮次时复制到最后一个已完成轮次,仅当尚无已完成轮次时才会被拒绝。 +创建子会话:fork 当前会话并记录为其子会话,因此会出现在 `GET /api/v1/sessions/{session_id}/children` 下。适用与 `:fork` 相同的规则:有轮次正在进行或有其他操作在进行时会被拒绝。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | @@ -801,7 +801,7 @@ schema 还接受 `agent_config` 内的 `system_prompt`、`tools`、`mcp_servers` 成功时,`data` 为新会话的 [session 对象](#session-对象),并且服务端广播 `event.session.created`。 -- `40901`:有轮次正在进行且尚无任何已完成轮次,没有可 fork 的内容 +- `40901`:有轮次正在进行或有其他操作在进行,暂时无法 fork #### `GET /api/v1/sessions/{session_id}/status` diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts index 6e956e0d2b6..eb9d93ca91d 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts @@ -76,59 +76,6 @@ export function sliceSubagentRecordsAtTime( return records.slice(0, end); } -export function lastCompletedUserVisibleTurnIndex( - records: readonly WireRecord[], -): number | undefined { - const turnStarts = userVisibleTurnStartIndices(records); - let lastCompleted: number | undefined; - for (let start = 0; start < turnStarts.length; start += 1) { - const from = turnStarts[start]! + 1; - const to = turnStarts[start + 1] ?? records.length; - if (turnCompletionAt(records, from, to, records[turnStarts[start]!]!) !== -1) { - lastCompleted = start; - } - } - return lastCompleted; -} - -export function capForkRecordsAtActiveTurn( - records: readonly WireRecord[], - lastCompleted: number, -): readonly WireRecord[] { - const turnStarts = userVisibleTurnStartIndices(records); - const completedAt = turnCompletionAt( - records, - turnStarts[lastCompleted]! + 1, - turnStarts[lastCompleted + 1] ?? records.length, - records[turnStarts[lastCompleted]!]!, - ); - if (completedAt === -1) return records; - let openTurnStart = -1; - let openShellInput = false; - for (let index = completedAt + 1; index < records.length; index += 1) { - const record = records[index]!; - if (openTurnStart === -1) { - if (isEngineTurnStartRecord(record) || isUserVisibleTurnRecord(record)) { - openTurnStart = index; - openShellInput = isShellCommandTurnStart(record); - } - continue; - } - if (record.type === 'turn.ended') { - openTurnStart = -1; - continue; - } - if (isShellCommandTurnStart(record)) { - openShellInput = true; - continue; - } - if (openShellInput && isShellCommandOutputRecord(record)) { - openTurnStart = -1; - } - } - return openTurnStart === -1 ? records : records.slice(0, openTurnStart); -} - export type ForkPromptResolution = | { readonly status: 'found'; readonly index: number } | { readonly status: 'unknown' } @@ -160,35 +107,6 @@ export function resolveForkPromptIndex( return found === undefined ? { status: 'unknown' } : { status: 'found', index: found }; } -function turnCompletionAt( - records: readonly WireRecord[], - from: number, - to: number, - startRecord: WireRecord, -): number { - const shellStart = isShellCommandTurnStart(startRecord); - for (let index = from; index < to; index += 1) { - const record = records[index]!; - if (record.type === 'turn.ended') return index; - if (shellStart && isShellCommandOutputRecord(record)) return index; - } - return -1; -} - -function isShellCommandTurnStart(record: WireRecord): boolean { - if (record.type !== 'context.append_message') return false; - const message = asRecord(record['message']); - const origin = asRecord(message?.['origin']); - return origin?.['kind'] === 'shell_command' && origin?.['phase'] === 'input'; -} - -function isShellCommandOutputRecord(record: WireRecord): boolean { - if (record.type !== 'context.append_message') return false; - const message = asRecord(record['message']); - const origin = asRecord(message?.['origin']); - return origin?.['kind'] === 'shell_command' && origin?.['phase'] === 'output'; -} - function userVisibleTurnStartIndices(records: readonly WireRecord[]): number[] { const turnStarts: number[] = []; for (let index = 0; index < records.length; index += 1) { diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 038bdd360d8..cf2c1b96bf1 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -101,8 +101,6 @@ import { agentScopeOf, sessionDirOf, sessionScopeOf } from './internal/addressin import { SessionArchived, SessionDeleted } from './sessionLifecycleEvents'; import { assertForkTurnIndex, - capForkRecordsAtActiveTurn, - lastCompletedUserVisibleTurnIndex, type MainTurnSlice, resolveForkPromptIndex, sliceMainRecordsAtTurn, @@ -532,35 +530,21 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec let targetSessionDir: string | undefined; const quiescenceHolds: IDisposable[] = []; try { - let busy = false; if (sourceHandle !== undefined) { - const holds: IDisposable[] = []; - let nonTurnContention = false; const sourceAgents = sourceHandle.accessor.get(IAgentLifecycleService); for (const agent of sourceAgents.list()) { const agentHandle = sourceAgents.handleOf(agent.agentId); if (agentHandle === undefined) continue; - const loop = agentHandle.accessor.get(IAgentLoopService); - const hold = loop.tryAcquireQuiescence(); + const hold = agentHandle.accessor.get(IAgentLoopService).tryAcquireQuiescence(); if (hold === undefined) { - if (!loop.hasTurnActivity()) nonTurnContention = true; - busy = true; - } else { - holds.push(hold); + for (const acquired of quiescenceHolds) acquired.dispose(); + throw new Error2( + ErrorCodes.SESSION_FORK_ACTIVE_TURN, + `Session "${sourceId}" cannot be forked while another operation is in progress`, + { details: { sessionId: sourceId } }, + ); } - } - if (nonTurnContention) { - for (const hold of holds) hold.dispose(); - throw new Error2( - ErrorCodes.SESSION_FORK_ACTIVE_TURN, - `Session "${sourceId}" cannot be forked while another operation is in progress`, - { details: { sessionId: sourceId } }, - ); - } - if (busy) { - for (const hold of holds) hold.dispose(); - } else { - quiescenceHolds.push(...holds); + quiescenceHolds.push(hold); } } await drainSessionMetadataWrites(); @@ -590,7 +574,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec sourceId, opts.turnIndex, opts.promptId, - busy, ); targetSessionDir = sessionDirOf(this.bootstrap.homeDir, this.handlerScope, targetId); @@ -726,9 +709,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec sourceId: string, turnIndex: number | undefined, promptId: string | undefined, - busy: boolean, ): Promise { - if (!busy && turnIndex === undefined && promptId === undefined) return undefined; + if (turnIndex === undefined && promptId === undefined) return undefined; const records = flattenChain( await this.readSourceWireRecords(sourceHandle, sourceId, MAIN_AGENT_ID), ); @@ -746,29 +728,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } resolved = resolution.index; } - if (!busy) { - return sliceMainRecordsAtTurn(records, sourceId, resolved!); - } - const lastCompleted = lastCompletedUserVisibleTurnIndex(records); - if (lastCompleted === undefined) { - throw new Error2( - ErrorCodes.SESSION_FORK_ACTIVE_TURN, - `Session "${sourceId}" cannot be forked yet because no turn has completed`, - { details: { sessionId: sourceId } }, - ); - } - if (resolved !== undefined && resolved > lastCompleted) { - throw new Error2( - ErrorCodes.REQUEST_INVALID, - `Turn ${String(resolved)} was not found in session "${sourceId}"`, - { details: { turnIndex: resolved, availableTurns: lastCompleted + 1 } }, - ); - } - return sliceMainRecordsAtTurn( - capForkRecordsAtActiveTurn(records, lastCompleted), - sourceId, - resolved ?? lastCompleted, - ); + return sliceMainRecordsAtTurn(records, sourceId, resolved!); } private async resolveSourceTitle(sourceId: string): Promise { diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts index 800a8c7cfd2..14afd5094c3 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from 'vitest'; import { - capForkRecordsAtActiveTurn, - lastCompletedUserVisibleTurnIndex, resolveForkPromptIndex, sliceMainRecordsAtTurn, } from '#/workspace/sessionLifecycle/internal/forkTurnSlice'; @@ -21,22 +19,6 @@ function userTurnRecord(text: string, time: number, id?: string): WireRecord { }; } -function shellCommandRecord( - text: string, - time: number, - phase: 'input' | 'output', -): WireRecord { - return { - type: 'context.append_message', - message: { - role: phase === 'input' ? 'user' : 'assistant', - content: [{ type: 'text', text }], - origin: { kind: 'shell_command', phase }, - }, - time, - }; -} - function turnEndedRecord( turnId: number, time: number, @@ -209,194 +191,3 @@ describe('resolveForkPromptIndex', () => { expect(resolveForkPromptIndex(records, 'cron-1')).toEqual({ status: 'unknown' }); }); }); - -describe('capForkRecordsAtActiveTurn', () => { - it('cuts the records before an active non-visible turn', () => { - const records: WireRecord[] = [ - { type: 'metadata', protocol_version: '1.5', created_at: 1 }, - userTurnRecord('done', 2, 'p1'), - turnEndedRecord(0, 3), - { - type: 'turn.prompt', - turnId: 1, - origin: { kind: 'system_trigger', name: 'goal_continuation' }, - time: 4, - }, - { type: 'agent.turn.started', turnId: 1, time: 5 }, - ]; - const capped = capForkRecordsAtActiveTurn(records, 0); - expect(capped.map((record) => record.type)).toEqual([ - 'metadata', - 'context.append_message', - 'turn.ended', - ]); - }); - - it('cuts the records before an active turn that follows a shell command', () => { - const records: WireRecord[] = [ - { type: 'metadata', protocol_version: '1.5', created_at: 1 }, - shellCommandRecord('!ls', 2, 'input'), - shellCommandRecord('file.ts', 3, 'output'), - { type: 'turn.prompt', turnId: 0, origin: { kind: 'user' }, time: 4 }, - { type: 'agent.turn.started', turnId: 0, time: 5 }, - ]; - const capped = capForkRecordsAtActiveTurn(records, 0); - expect(capped.map((record) => record.type)).toEqual([ - 'metadata', - 'context.append_message', - 'context.append_message', - ]); - }); - - it('keeps the records when no engine turn follows the last completed one', () => { - const records: WireRecord[] = [ - { type: 'metadata', protocol_version: '1.5', created_at: 1 }, - userTurnRecord('done', 2, 'p1'), - turnEndedRecord(0, 3), - ]; - expect(capForkRecordsAtActiveTurn(records, 0)).toBe(records); - }); - - it('keeps completed continuation turns before the active turn', () => { - const records: WireRecord[] = [ - { type: 'metadata', protocol_version: '1.5', created_at: 1 }, - userTurnRecord('done', 2, 'p1'), - turnEndedRecord(0, 3), - { - type: 'turn.prompt', - turnId: 1, - origin: { kind: 'system_trigger', name: 'goal_continuation' }, - time: 4, - }, - { type: 'agent.turn.started', turnId: 1, time: 5 }, - { - type: 'context.append_message', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'continuation reply' }], - }, - time: 6, - }, - turnEndedRecord(1, 7), - { - type: 'turn.prompt', - turnId: 2, - origin: { kind: 'system_trigger', name: 'goal_continuation' }, - time: 8, - }, - { type: 'agent.turn.started', turnId: 2, time: 9 }, - ]; - const capped = capForkRecordsAtActiveTurn(records, 0); - expect(capped.map((record) => record.type)).toEqual([ - 'metadata', - 'context.append_message', - 'turn.ended', - 'turn.prompt', - 'agent.turn.started', - 'context.append_message', - 'turn.ended', - ]); - }); - - it('keeps the records when every turn after the last completed one has completed', () => { - const records: WireRecord[] = [ - { type: 'metadata', protocol_version: '1.5', created_at: 1 }, - userTurnRecord('done', 2, 'p1'), - turnEndedRecord(0, 3), - { - type: 'turn.prompt', - turnId: 1, - origin: { kind: 'system_trigger', name: 'goal_continuation' }, - time: 4, - }, - { type: 'agent.turn.started', turnId: 1, time: 5 }, - turnEndedRecord(1, 6), - ]; - expect(capForkRecordsAtActiveTurn(records, 0)).toBe(records); - }); -}); - -describe('lastCompletedUserVisibleTurnIndex', () => { - it('returns undefined for empty records and for a first turn still running', () => { - expect(lastCompletedUserVisibleTurnIndex([])).toBeUndefined(); - expect( - lastCompletedUserVisibleTurnIndex([ - { type: 'metadata', protocol_version: '1.5', created_at: 1 }, - userTurnRecord('running turn', 2), - ]), - ).toBeUndefined(); - }); - - it('returns the last turn start closed by a turn.ended record and ignores a running tail turn', () => { - const records: WireRecord[] = [ - { type: 'metadata', protocol_version: '1.5', created_at: 1 }, - userTurnRecord('first', 2), - turnEndedRecord(0, 3), - userTurnRecord('second', 4), - turnEndedRecord(1, 5), - userTurnRecord('third still running', 6), - ]; - expect(lastCompletedUserVisibleTurnIndex(records)).toBe(1); - }); - - it.each(['cancelled', 'failed', 'blocked'] as const)( - 'treats a turn ended with reason "%s" as completed', - (reason) => { - const records: WireRecord[] = [ - { type: 'metadata', protocol_version: '1.5', created_at: 1 }, - userTurnRecord('interrupted', 2), - turnEndedRecord(0, 3, reason), - ]; - expect(lastCompletedUserVisibleTurnIndex(records)).toBe(0); - }, - ); - - it('returns the steered start index when a steer-split turn completes before a running tail', () => { - const records: WireRecord[] = [ - { type: 'metadata', protocol_version: '1.5', created_at: 1 }, - userTurnRecord('original prompt', 2), - { type: 'turn.steer', turnId: 0, messageId: 'msg_steer', origin: { kind: 'user' }, time: 3 }, - userTurnRecord('steered input', 4), - turnEndedRecord(0, 5), - userTurnRecord('follow-up still running', 6), - ]; - expect(lastCompletedUserVisibleTurnIndex(records)).toBe(1); - }); - - it('counts a shell command with an output append as completed', () => { - const records: WireRecord[] = [ - { type: 'metadata', protocol_version: '1.5', created_at: 1 }, - shellCommandRecord('!ls', 2, 'input'), - shellCommandRecord('file.ts', 3, 'output'), - userTurnRecord('follow-up still running', 4, 'p1'), - ]; - expect(lastCompletedUserVisibleTurnIndex(records)).toBe(0); - }); - - it('does not count a shell command still running as completed', () => { - const records: WireRecord[] = [ - { type: 'metadata', protocol_version: '1.5', created_at: 1 }, - shellCommandRecord('!ls', 2, 'input'), - ]; - expect(lastCompletedUserVisibleTurnIndex(records)).toBeUndefined(); - }); - - it('does not treat system-triggered appends as turn starts', () => { - const records: WireRecord[] = [ - { type: 'metadata', protocol_version: '1.5', created_at: 1 }, - userTurnRecord('real prompt', 2), - turnEndedRecord(0, 3), - { - type: 'context.append_message', - message: { - role: 'user', - content: [{ type: 'text', text: 'system follow-up' }], - origin: { kind: 'system_trigger', name: 'goal_continuation' }, - }, - time: 4, - }, - turnEndedRecord(1, 5), - ]; - expect(lastCompletedUserVisibleTurnIndex(records)).toBe(0); - }); -}); diff --git a/packages/node-sdk/test/session-prompt-events.test.ts b/packages/node-sdk/test/session-prompt-events.test.ts index 5ddce054fd2..e20409cf081 100644 --- a/packages/node-sdk/test/session-prompt-events.test.ts +++ b/packages/node-sdk/test/session-prompt-events.test.ts @@ -839,311 +839,33 @@ describe('Session.prompt events', () => { } }); - it('forks a running session clamped to the last completed turn', async () => { + it('rejects forking a session while a turn is running', async () => { const homeDir = await makeTempDir(); const workDir = await makeTempDir(); const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); try { await configureFakeProvider(harness); - const source = await harness.createSession({ id: 'ses_turn_fork_running_source', workDir }); - await runPrompt(source, 'first question', 'first answer'); - await runPrompt(source, 'second question', 'second answer'); - - const gate = deferredResponse(); - fetchStub!.mockImplementationOnce(() => gate.promise); - const started = waitForEvent(source, (event) => event.type === 'turn.started'); - const ended = waitForEvent(source, (event) => event.type === 'turn.ended'); - await source.prompt('third question'); - await started; - - const fork = await harness.forkSession({ - id: source.id, - forkId: 'ses_turn_fork_running_child', - }); - const forkSummary = (await harness.listSessions()).find( - (summary) => summary.id === fork.id, - ); - expect(forkSummary?.lastTurnReason).toBe('completed'); - await fork.close(); - const resumed = await harness.resumeSession({ id: fork.id }); - const replayText = visibleReplayText(resumed.getResumeState()?.agents['main']?.replay ?? []); - - expect(replayText).toEqual([ - 'user:first question', - 'assistant:first answer', - 'user:second question', - 'assistant:second answer', - ]); - - gate.resolve(sseResponse('third answer')); - await ended; - } finally { - await harness.close(); - } - }); - - it('forks a running session at an explicit completed prompt', async () => { - const homeDir = await makeTempDir(); - const workDir = await makeTempDir(); - const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); - - try { - await configureFakeProvider(harness); - const source = await harness.createSession({ id: 'ses_turn_fork_running_pick_source', workDir }); - await runPrompt(source, 'first question', 'first answer', 'p1'); - await runPrompt(source, 'second question', 'second answer', 'p2'); - - const gate = deferredResponse(); - fetchStub!.mockImplementationOnce(() => gate.promise); - const started = waitForEvent(source, (event) => event.type === 'turn.started'); - const ended = waitForEvent(source, (event) => event.type === 'turn.ended'); - await source.prompt('third question'); - await started; - - const fork = await harness.forkSession({ - id: source.id, - forkId: 'ses_turn_fork_running_pick_child', - promptId: 'p1', - }); - await fork.close(); - const resumed = await harness.resumeSession({ id: fork.id }); - const replayText = visibleReplayText(resumed.getResumeState()?.agents['main']?.replay ?? []); - - expect(replayText).toEqual(['user:first question', 'assistant:first answer']); - - gate.resolve(sseResponse('third answer')); - await ended; - } finally { - await harness.close(); - } - }); - - it('rejects a prompt of the running turn while forking', async () => { - const homeDir = await makeTempDir(); - const workDir = await makeTempDir(); - const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); - - try { - await configureFakeProvider(harness); - const source = await harness.createSession({ id: 'ses_turn_fork_running_range_source', workDir }); + const source = await harness.createSession({ id: 'ses_turn_fork_busy_source', workDir }); await runPrompt(source, 'first question', 'first answer', 'p1'); - await runPrompt(source, 'second question', 'second answer', 'p2'); - - const gate = deferredResponse(); - fetchStub!.mockImplementationOnce(() => gate.promise); - const started = waitForEvent(source, (event) => event.type === 'turn.started'); - const ended = waitForEvent(source, (event) => event.type === 'turn.ended'); - await source.prompt('third question', { promptId: 'p3' }); - await started; - - await expect( - harness.forkSession({ id: source.id, promptId: 'p3' }), - ).rejects.toMatchObject({ - name: 'KimiError', - code: 'request.invalid', - details: { turnIndex: 2, availableTurns: 2 }, - }); - - gate.resolve(sseResponse('third answer')); - await ended; - } finally { - await harness.close(); - } - }); - - it('rejects forking a running session without any completed turn', async () => { - const homeDir = await makeTempDir(); - const workDir = await makeTempDir(); - const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); - - try { - await configureFakeProvider(harness); - const source = await harness.createSession({ id: 'ses_turn_fork_first_turn_source', workDir }); const gate = deferredResponse(); fetchStub!.mockImplementationOnce(() => gate.promise); const started = waitForEvent(source, (event) => event.type === 'turn.started'); const ended = waitForEvent(source, (event) => event.type === 'turn.ended'); - await source.prompt('first question'); + await source.prompt('second question'); await started; await expect(harness.forkSession({ id: source.id })).rejects.toMatchObject({ name: 'KimiError', code: 'session.fork_active_turn', }); - - gate.resolve(sseResponse('first answer')); - await ended; - } finally { - await harness.close(); - } - }); - - it('clamps a running fork when another prompt is queued behind the active turn', async () => { - const homeDir = await makeTempDir(); - const workDir = await makeTempDir(); - const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); - - try { - await configureFakeProvider(harness); - const source = await harness.createSession({ id: 'ses_turn_fork_queued_source', workDir }); - await runPrompt(source, 'first question', 'first answer'); - - const gate = deferredResponse(); - fetchStub!.mockImplementationOnce(() => gate.promise); - const started = waitForEvent(source, (event) => event.type === 'turn.started'); - await source.prompt('second question'); - await started; - await source.prompt('third question'); - - const fork = await harness.forkSession({ - id: source.id, - forkId: 'ses_turn_fork_queued_child', - }); - await fork.close(); - const resumed = await harness.resumeSession({ id: fork.id }); - const replayText = visibleReplayText(resumed.getResumeState()?.agents['main']?.replay ?? []); - - expect(replayText).toEqual(['user:first question', 'assistant:first answer']); - - const endedTurns: unknown[] = []; - const unsubscribe = source.onEvent((event) => { - if (event.type === 'turn.ended') endedTurns.push(event); - }); - gate.resolve(sseResponse('second answer')); - await vi.waitFor( - () => { - expect(endedTurns).toHaveLength(2); - }, - { timeout: 5_000 }, - ); - unsubscribe(); - } finally { - await harness.close(); - } - }); - - it('forks a running session through a steered turn at the steered index', async () => { - const homeDir = await makeTempDir(); - const workDir = await makeTempDir(); - const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); - - try { - await configureFakeProvider(harness); - const source = await harness.createSession({ id: 'ses_turn_fork_steer_source', workDir }); - - const firstGate = deferredResponse(); - fetchStub!.mockImplementationOnce(() => firstGate.promise); - const started = waitForEvent(source, (event) => event.type === 'turn.started'); - const steeredTurnEnded = waitForEvent(source, (event) => event.type === 'turn.ended'); - await source.prompt('original question'); - await started; - await source.steer('steered input'); - fakeProviderState.responseText = 'answer after steer'; - firstGate.resolve(sseResponse('answer before steer')); - await steeredTurnEnded; - - const followUpGate = deferredResponse(); - fetchStub!.mockImplementationOnce(() => followUpGate.promise); - const followUpStarted = waitForEvent(source, (event) => event.type === 'turn.started'); - const followUpEnded = waitForEvent(source, (event) => event.type === 'turn.ended'); - await source.prompt('follow-up question', { promptId: 'p3' }); - await followUpStarted; - - const expectedReplay = [ - 'user:original question', - 'assistant:answer before steer', - 'user:steered input', - 'assistant:answer after steer', - ]; - - const fork = await harness.forkSession({ - id: source.id, - forkId: 'ses_turn_fork_steer_child', - }); - await fork.close(); - const resumed = await harness.resumeSession({ id: fork.id }); - const replayRecords = resumed.getResumeState()?.agents['main']?.replay ?? []; - expect(visibleReplayText(replayRecords)).toEqual(expectedReplay); - const steerMessage = replayRecords.find( - (record): record is Extract<(typeof replayRecords)[number], { type: 'message' }> => - record.type === 'message' && - record.message.role === 'user' && - record.message.content.some( - (part) => part.type === 'text' && part.text === 'steered input', - ), - ); - const steerPromptId = (steerMessage?.message as { id?: string } | undefined)?.id; - expect(steerPromptId).toBeDefined(); - - const picked = await harness.forkSession({ - id: source.id, - forkId: 'ses_turn_fork_steer_pick_child', - promptId: steerPromptId!, - }); - await picked.close(); - const resumedPicked = await harness.resumeSession({ id: picked.id }); - expect( - visibleReplayText(resumedPicked.getResumeState()?.agents['main']?.replay ?? []), - ).toEqual(expectedReplay); - - await expect(harness.forkSession({ id: source.id, promptId: 'p3' })).rejects.toMatchObject({ + await expect(harness.forkSession({ id: source.id, promptId: 'p1' })).rejects.toMatchObject({ name: 'KimiError', - code: 'request.invalid', - details: { turnIndex: 2, availableTurns: 2 }, - }); - - followUpGate.resolve(sseResponse('follow-up answer')); - await followUpEnded; - } finally { - await harness.close(); - } - }); - - it('forks a running session clamped through a completed shell command', async () => { - const homeDir = await makeTempDir(); - const workDir = await makeTempDir(); - const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); - - try { - await configureFakeProvider(harness); - const source = await harness.createSession({ id: 'ses_turn_fork_shell_source', workDir }); - await source.runShellCommand('echo shell-output'); - - const gate = deferredResponse(); - fetchStub!.mockImplementationOnce(() => gate.promise); - const started = waitForEvent(source, (event) => event.type === 'turn.started'); - const ended = waitForEvent(source, (event) => event.type === 'turn.ended'); - await source.prompt('first question'); - await started; - - const fork = await harness.forkSession({ - id: source.id, - forkId: 'ses_turn_fork_shell_child', + code: 'session.fork_active_turn', }); - await fork.close(); - const resumed = await harness.resumeSession({ id: fork.id }); - const replay = resumed.getResumeState()?.agents['main']?.replay ?? []; - const shellTexts = replay - .filter( - (record): record is Extract<(typeof replay)[number], { type: 'message' }> => - record.type === 'message' && - (record.message.origin as { kind?: string } | undefined)?.kind === 'shell_command', - ) - .map((record) => - record.message.content - .filter((part) => part.type === 'text') - .map((part) => part.text) - .join(''), - ); - expect(shellTexts).toEqual([ - '\necho shell-output\n', - 'shell-output\n', - ]); - expect(visibleReplayText(replay)).toEqual([]); - gate.resolve(sseResponse('first answer')); + gate.resolve(sseResponse('second answer')); await ended; } finally { await harness.close(); From 8bc5e8ec710950619c1762b51da062f11349eeba Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 8 Oct 2026 19:03:09 +0800 Subject: [PATCH 7/7] refactor: drop the now-unused loop hasTurnActivity query Its only production caller was the removed busy-fork contention classification; the interface method stays out until the busy-fork follow-up needs it again --- packages/agent-core-v2/src/agent/loop/loop.ts | 2 -- packages/agent-core-v2/src/agent/loop/loopService.ts | 9 --------- packages/agent-core-v2/test/agent/loop/loop.test.ts | 2 -- packages/agent-core-v2/test/agent/loop/stubs.ts | 1 - .../test/agent/toolSelect/toolSelectService.test.ts | 4 ---- 5 files changed, 18 deletions(-) diff --git a/packages/agent-core-v2/src/agent/loop/loop.ts b/packages/agent-core-v2/src/agent/loop/loop.ts index 6284723cef5..b82190b040e 100644 --- a/packages/agent-core-v2/src/agent/loop/loop.ts +++ b/packages/agent-core-v2/src/agent/loop/loop.ts @@ -216,8 +216,6 @@ export interface IAgentLoopService { tryAcquireQuiescence(): IDisposable | undefined; - hasTurnActivity(): boolean; - notify(note?: LoopNotify): LoopNotifyHandle; buildAttachBundle(): MachineEngineAttachBundle; diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 0c68b165c5d..5a4ef78591b 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -780,15 +780,6 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { return toDisposable(() => this.releaseQuiescence()); } - hasTurnActivity(): boolean { - return ( - this.active !== undefined || - this.hasPendingRequests() || - this.pendingMachineTurn !== undefined || - this.pendingTurnCloses > 0 - ); - } - private releaseQuiescence(): void { if (this.quiescenceDepth === 0) return; this.quiescenceDepth -= 1; 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 bf68faa834e..72346638cf2 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -967,14 +967,12 @@ describe('Agent loop', () => { expect(loop.snapshot().state).toBe('idle'); expect(loop.tryAcquireQuiescence()).toBeUndefined(); - expect(loop.hasTurnActivity()).toBe(true); releaseDrain(); await expect(active.result).resolves.toMatchObject({ type: 'completed' }); drain.mockRestore(); expect(loop.tryAcquireQuiescence()).toBeDefined(); - expect(loop.hasTurnActivity()).toBe(false); }); it('exposes a quiescence lease once the turn.ended event is observable', async () => { diff --git a/packages/agent-core-v2/test/agent/loop/stubs.ts b/packages/agent-core-v2/test/agent/loop/stubs.ts index ebba50899a4..5b71d3d03a9 100644 --- a/packages/agent-core-v2/test/agent/loop/stubs.ts +++ b/packages/agent-core-v2/test/agent/loop/stubs.ts @@ -198,7 +198,6 @@ export function stubLoopWithHooks(options: StubLoopOptions = {}): StubLoop { promptHandle: (id) => handles.get(id), cancel(target, reason) { cancels.push({ turnId: target?.turnId, reason }); if (target?.promptId !== undefined) return true; if (active === undefined || (target?.turnId !== undefined && active.id !== target.turnId)) return false; active.cancel(reason); return true; }, tryAcquireQuiescence: () => toDisposable(() => {}), - hasTurnActivity: () => active !== undefined || pending.length > 0, buildAttachBundle: () => stubAttachBundle(), attachEngine: () => stubAttachEngine(), resetMachineEngine: () => Promise.resolve(), diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts index f2fbb4c956e..041f4de8907 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -247,10 +247,6 @@ class FakeLoopService implements IAgentLoopService { return toDisposable(() => {}); } - hasTurnActivity(): boolean { - return false; - } - buildAttachBundle(): never { throw new Error('unused in this suite'); }