diff --git a/.changeset/cancel-init-run.md b/.changeset/cancel-init-run.md new file mode 100644 index 000000000..9bccc792b --- /dev/null +++ b/.changeset/cancel-init-run.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Cancel an in-flight /init run together with the turn instead of letting it run to completion. diff --git a/.changeset/fork-busy-rejection.md b/.changeset/fork-busy-rejection.md new file mode 100644 index 000000000..02279bad5 --- /dev/null +++ b/.changeset/fork-busy-rejection.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show a clear error when forking a session while its turn is running, instead of copying a partially written turn. diff --git a/.changeset/sdk-v2-cancel-init.md b/.changeset/sdk-v2-cancel-init.md new file mode 100644 index 000000000..f31bd9c04 --- /dev/null +++ b/.changeset/sdk-v2-cancel-init.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": patch +--- + +Cascade turn cancellation to the session-level /init run in the v2-backed client. diff --git a/.changeset/sdk-v2-delete-session.md b/.changeset/sdk-v2-delete-session.md new file mode 100644 index 000000000..f35a3702c --- /dev/null +++ b/.changeset/sdk-v2-delete-session.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": patch +--- + +Implement session deletion in the v2-backed client. diff --git a/.changeset/sdk-v2-fork-turn.md b/.changeset/sdk-v2-fork-turn.md new file mode 100644 index 000000000..fc46c329e --- /dev/null +++ b/.changeset/sdk-v2-fork-turn.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": patch +--- + +Support forking a session from a specific turn in the v2-backed client, and reject forking a live session while its turn is running. diff --git a/.changeset/vscode-engine-v2.md b/.changeset/vscode-engine-v2.md new file mode 100644 index 000000000..7e60637d6 --- /dev/null +++ b/.changeset/vscode-engine-v2.md @@ -0,0 +1,5 @@ +--- +"kimi-code": minor +--- + +Run the extension on the v2 agent engine by default; the interface, sessions, and workflows are unchanged. To roll back, enable the `kimi.useAgentCoreV1` setting and reload the window. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f470a476a..636568d71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,27 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm --filter @moonshot-ai/pi-tui test + # The VS Code extension suite runs on the default (v2) engine as part of the + # sharded root run above; this job reruns it on the legacy v1 engine, which + # the extension selects through the rollback env var. + test-vscode-legacy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm --filter kimi-code test + env: + KIMI_CODE_LEGACY_FLAG: "1" + test-windows: runs-on: windows-latest # Temporarily disabled while Windows tests are being stabilized. diff --git a/apps/vscode/package.json b/apps/vscode/package.json index a474840e0..a03d923c7 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -95,6 +95,11 @@ "Share when active file changes" ], "description": "Control when to share the active editor's file and cursor position with Kimi" + }, + "kimi.useAgentCoreV1": { + "type": "boolean", + "default": false, + "description": "Temporary rollback switch: run Kimi on the legacy v1 engine instead of the current one. This setting will be removed in a future version. Requires a window reload to take effect." } } }, diff --git a/apps/vscode/src/bridge-handler.ts b/apps/vscode/src/bridge-handler.ts index 04ef41a5e..e4d410710 100644 --- a/apps/vscode/src/bridge-handler.ts +++ b/apps/vscode/src/bridge-handler.ts @@ -37,14 +37,28 @@ export class BridgeHandler { private readonly showLogs: ShowLogsFn, private readonly writeLog: (message: string) => void, ) { - this.runtime = new KimiRuntime({ - version: VSCodeSettings.getExtensionConfig().version, - broadcast, - captureBaseline: (session, filePath, webviewIds) => { - this.captureFileBaseline(session, filePath, webviewIds); - }, - log: (message, error) => this.logRuntimeError(message, error), - }); + const useAgentCoreV1 = VSCodeSettings.useAgentCoreV1; + try { + this.runtime = new KimiRuntime({ + version: VSCodeSettings.getExtensionConfig().version, + useAgentCoreV1, + broadcast, + captureBaseline: (session, filePath, webviewIds) => { + this.captureFileBaseline(session, filePath, webviewIds); + }, + log: (message, error) => this.logRuntimeError(message, error), + }); + } catch (error) { + // No silent fallback: report the failure with the rollback path, so the + // user can report it or switch engines and reload. + const rollbackHint = useAgentCoreV1 + ? "" + : " You can roll back to the legacy engine: enable the 'kimi.useAgentCoreV1' setting and reload the window."; + throw new Error( + `Failed to start the Kimi engine: ${error instanceof Error ? error.message : String(error)}.${rollbackHint}`, + { cause: error }, + ); + } this.baselineManager = new BaselineManager(globalStoragePath, this.runtime.harness.homeDir); this.fileManager = new FileManager(this.baselineManager, broadcast); } diff --git a/apps/vscode/src/config/vscode-settings.ts b/apps/vscode/src/config/vscode-settings.ts index 9bca3b126..76dd6e99a 100644 --- a/apps/vscode/src/config/vscode-settings.ts +++ b/apps/vscode/src/config/vscode-settings.ts @@ -4,6 +4,27 @@ import type { ExtensionConfig } from "../../shared/types"; declare const __EXTENSION_VERSION__: string; const EXTENSION_VERSION = typeof __EXTENSION_VERSION__ !== "undefined" ? __EXTENSION_VERSION__ : "0.0.0"; +/** Support backdoor with the highest priority: a truthy value forces the legacy v1 engine. */ +export const LEGACY_ENGINE_ENV = "KIMI_CODE_LEGACY_FLAG"; + +const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on"]); + +/** + * The single engine-selection decision for the whole extension. A truthy + * `KIMI_CODE_LEGACY_FLAG` wins over the `kimi.useAgentCoreV1` setting, so + * support and headless test runs can force the legacy engine without + * touching user settings. Both default to the v2 engine. + */ +export function resolveUseAgentCoreV1( + settingValue: boolean, + env: Readonly>, +): boolean { + if (TRUTHY_ENV_VALUES.has((env[LEGACY_ENGINE_ENV] ?? "").trim().toLowerCase())) { + return true; + } + return settingValue; +} + function getConfig() { return vscode.workspace.getConfiguration("kimi"); } @@ -37,6 +58,11 @@ export const VSCodeSettings = { return getConfig().get<"never" | "onConversationStart" | "onFileChange">("editorContext", "never"); }, + /** Read once at activation; a change needs a window reload to take effect. */ + get useAgentCoreV1(): boolean { + return resolveUseAgentCoreV1(getConfig().get("useAgentCoreV1", false), process.env); + }, + getExtensionConfig(): ExtensionConfig { return { yoloMode: this.yoloMode, diff --git a/apps/vscode/src/runtime/kimi-runtime.ts b/apps/vscode/src/runtime/kimi-runtime.ts index d07af86f8..f3c6db4c6 100644 --- a/apps/vscode/src/runtime/kimi-runtime.ts +++ b/apps/vscode/src/runtime/kimi-runtime.ts @@ -1,5 +1,6 @@ import { createKimiHarness, + createKimiHarnessV2, type KimiHarness, type Session, type SessionSummary, @@ -29,6 +30,12 @@ export interface KimiRuntimeOptions { readonly log: (message: string, error?: unknown) => void; readonly homeDir?: string; readonly harness?: KimiHarness; + /** + * Engine rollback: create the legacy v1 harness instead of the default v2 + * one. The decision is made once in `config/vscode-settings.ts`; a change + * applies on the next window reload, when the runtime is rebuilt. + */ + readonly useAgentCoreV1?: boolean; } export interface OpenSessionOptions { @@ -55,10 +62,11 @@ export class KimiRuntime { this.broadcast = options.broadcast; this.captureBaseline = options.captureBaseline; this.log = options.log; + const createHarness = options.useAgentCoreV1 ? createKimiHarness : createKimiHarnessV2; this.harness = options.harness ?? - createKimiHarness({ - ...(options.homeDir === undefined ? {} : { homeDir: options.homeDir }), + createHarness({ + homeDir: options.homeDir, identity: { productName: "kimi-code-vscode", version: options.version, diff --git a/apps/vscode/test/bridge-handler.test.ts b/apps/vscode/test/bridge-handler.test.ts index 51b203823..9a6023e37 100644 --- a/apps/vscode/test/bridge-handler.test.ts +++ b/apps/vscode/test/bridge-handler.test.ts @@ -55,6 +55,8 @@ const host = vi.hoisted(() => { Uri, watcher, harness, + createKimiHarness: vi.fn(() => harness), + createKimiHarnessV2: vi.fn(() => harness), showWarningMessage, workspaceFolders: [] as Array<{ uri: Uri }>, }; @@ -75,7 +77,11 @@ vi.mock("vscode", () => ({ vi.mock("@moonshot-ai/kimi-code-sdk", async (importOriginal) => { const original = await importOriginal(); - return { ...original, createKimiHarness: () => host.harness }; + return { + ...original, + createKimiHarness: () => host.createKimiHarness(), + createKimiHarnessV2: () => host.createKimiHarnessV2(), + }; }); let bridge: BridgeHandler; @@ -92,6 +98,8 @@ beforeEach(async () => { host.harness.resumeSession.mockReset(); host.harness.getConfig.mockReset(); host.harness.getConfig.mockResolvedValue({ models: {} }); + host.createKimiHarness.mockImplementation(() => host.harness); + host.createKimiHarnessV2.mockImplementation(() => host.harness); host.showWarningMessage.mockReset(); host.showWarningMessage.mockResolvedValue(undefined); workspaceState = { get: vi.fn((_key, fallback) => fallback), update: vi.fn() }; @@ -108,9 +116,44 @@ beforeEach(async () => { afterEach(async () => { await bridge.dispose(); vi.clearAllMocks(); + vi.unstubAllEnvs(); await rm(root, { recursive: true, force: true }); }); +describe("Engine startup", () => { + function constructBridge(): void { + new BridgeHandler( + vi.fn(), + workspaceState as unknown as vscode.Memento, + join(root, "global-storage-2"), + vi.fn(), + showLogs, + writeLog, + ); + } + + it("reports the rollback setting when the default engine cannot start", () => { + // Keep v2 the default even when the suite itself runs under the legacy flag. + vi.stubEnv("KIMI_CODE_LEGACY_FLAG", ""); + host.createKimiHarnessV2.mockImplementationOnce(() => { + throw new Error("engine boom"); + }); + + expect(constructBridge).toThrow( + /Failed to start the Kimi engine: engine boom\..*kimi\.useAgentCoreV1/s, + ); + }); + + it("reports no rollback hint when the legacy engine itself cannot start", () => { + vi.stubEnv("KIMI_CODE_LEGACY_FLAG", "1"); + host.createKimiHarness.mockImplementationOnce(() => { + throw new Error("legacy boom"); + }); + + expect(constructBridge).toThrow(/^Failed to start the Kimi engine: legacy boom\.$/); + }); +}); + describe("Webview RPC boundary (validates requests before host dispatch)", () => { it("returns a readable error when the envelope is not a plain object", async () => { const result = await bridge.handle([], "view-1"); diff --git a/apps/vscode/test/kimi-harness.integration.test.ts b/apps/vscode/test/kimi-harness.integration.test.ts index ea53fae3d..0822b1093 100644 --- a/apps/vscode/test/kimi-harness.integration.test.ts +++ b/apps/vscode/test/kimi-harness.integration.test.ts @@ -2,6 +2,7 @@ * Scenario: the VS Code host and another Node SDK client share one in-process Kimi home. * Responsibilities: outbound host identity, config/session interoperability, MCP credential/edit compatibility, and terminal provider failures. * Wiring: KimiRuntime, KimiHarness, core, storage, and HTTP provider adapter are real; only the remote provider is local. + * The runtime harness follows the extension engine decision (v2 by default, the legacy v1 under KIMI_CODE_LEGACY_FLAG). * Run: pnpm --filter kimi-code exec vitest run test/kimi-harness.integration.test.ts */ @@ -42,6 +43,7 @@ import { chatHandlers } from "../src/handlers/chat.handler"; import { mcpHandlers } from "../src/handlers/mcp.handler"; import { parseHostSlashCommand, runHostSlashCommand } from "../src/handlers/slash-command"; import type { HandlerContext } from "../src/handlers/types"; +import { VSCodeSettings } from "../src/config/vscode-settings"; import { KimiRuntime } from "../src/runtime/kimi-runtime"; import type { SessionRuntime } from "../src/runtime/session-runtime"; @@ -105,6 +107,9 @@ async function createRuntimeRig(extraAliases: readonly string[] = []): Promise { broadcasts.push({ event, data, webviewId }); }, @@ -220,7 +225,9 @@ model = "mock-model" max_context_size = 128000 ${extra} [loop_control] +# The v1 engine reads max_retries_per_step; v2 renamed it to max_attempts_per_step. max_retries_per_step = 1 +max_attempts_per_step = 1 `, "utf8", ); diff --git a/apps/vscode/test/kimi-runtime.test.ts b/apps/vscode/test/kimi-runtime.test.ts index 6a86f7f2d..94fb08f99 100644 --- a/apps/vscode/test/kimi-runtime.test.ts +++ b/apps/vscode/test/kimi-runtime.test.ts @@ -20,11 +20,31 @@ import type { SessionSummary, ThinkingEffort, } from "@moonshot-ai/kimi-code-sdk"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { Events } from "../shared/bridge"; import { KimiRuntime, type OpenSessionOptions } from "../src/runtime/kimi-runtime"; +const sdkFactories = vi.hoisted(() => { + const v1Harness = { homeDir: "/tmp/kimi-runtime-v1-home", close: vi.fn(async () => undefined) }; + const v2Harness = { homeDir: "/tmp/kimi-runtime-v2-home", close: vi.fn(async () => undefined) }; + return { + v1Harness, + v2Harness, + createKimiHarness: vi.fn(() => v1Harness), + createKimiHarnessV2: vi.fn(() => v2Harness), + }; +}); + +vi.mock("@moonshot-ai/kimi-code-sdk", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + createKimiHarness: sdkFactories.createKimiHarness, + createKimiHarnessV2: sdkFactories.createKimiHarnessV2, + }; +}); + interface FakeSessionBoundary { readonly session: Session; readonly setModels: string[]; @@ -246,6 +266,39 @@ function createRuntime( } describe("Kimi runtime (owns shared SDK sessions for Webviews)", () => { + it("creates the v2 harness by default and the v1 harness for rollback", async () => { + const defaults = new KimiRuntime({ + version: "0.6.0", + broadcast: () => undefined, + captureBaseline: () => undefined, + log: () => undefined, + }); + expect(sdkFactories.createKimiHarnessV2).toHaveBeenCalledOnce(); + expect(sdkFactories.createKimiHarnessV2).toHaveBeenCalledWith({ + homeDir: undefined, + identity: { + productName: "kimi-code-vscode", + version: "0.6.0", + platform: "kimi_code_vscode", + }, + uiMode: "vscode", + }); + expect(sdkFactories.createKimiHarness).not.toHaveBeenCalled(); + expect(defaults.harness).toBe(sdkFactories.v2Harness as unknown as KimiHarness); + await defaults.dispose(); + + const rollback = new KimiRuntime({ + version: "0.6.0", + useAgentCoreV1: true, + broadcast: () => undefined, + captureBaseline: () => undefined, + log: () => undefined, + }); + expect(sdkFactories.createKimiHarness).toHaveBeenCalledOnce(); + expect(rollback.harness).toBe(sdkFactories.v1Harness as unknown as KimiHarness); + await rollback.dispose(); + }); + it("forwards the requested settings when creating an SDK session", async () => { const { runtime, sdk } = createRuntime(); diff --git a/apps/vscode/test/vscode-settings.test.ts b/apps/vscode/test/vscode-settings.test.ts new file mode 100644 index 000000000..9156b0075 --- /dev/null +++ b/apps/vscode/test/vscode-settings.test.ts @@ -0,0 +1,71 @@ +/** + * Scenario: the engine rollback switch is the single engine-selection decision. + * Responsibilities: default to the v2 engine, honor the setting, let a truthy + * KIMI_CODE_LEGACY_FLAG override the setting, and ignore non-truthy env values. + * Wiring: the real VSCodeSettings module; the vscode configuration store is a + * mutable in-memory fake. + * Run: pnpm --filter kimi-code exec vitest run test/vscode-settings.test.ts + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const configStore = vi.hoisted(() => ({ values: new Map() })); + +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: () => ({ + get: (key: string, fallback: unknown) => configStore.values.get(key) ?? fallback, + }), + }, +})); + +import { + LEGACY_ENGINE_ENV, + resolveUseAgentCoreV1, + VSCodeSettings, +} from "../src/config/vscode-settings"; + +beforeEach(() => { + // A developer shell may export the flag; the cases set it explicitly. + vi.stubEnv(LEGACY_ENGINE_ENV, ""); +}); + +afterEach(() => { + configStore.values.clear(); + vi.unstubAllEnvs(); +}); + +describe("resolveUseAgentCoreV1", () => { + it("defaults to the v2 engine", () => { + expect(resolveUseAgentCoreV1(false, {})).toBe(false); + }); + + it("honors the setting when the env var is absent", () => { + expect(resolveUseAgentCoreV1(true, {})).toBe(true); + }); + + it("lets a truthy env var override a false setting", () => { + for (const value of ["1", "true", "TRUE", " yes ", "on"]) { + expect(resolveUseAgentCoreV1(false, { [LEGACY_ENGINE_ENV]: value })).toBe(true); + } + }); + + it("ignores non-truthy env values and falls back to the setting", () => { + for (const value of ["", "0", "false", "off", "anything"]) { + expect(resolveUseAgentCoreV1(true, { [LEGACY_ENGINE_ENV]: value })).toBe(true); + expect(resolveUseAgentCoreV1(false, { [LEGACY_ENGINE_ENV]: value })).toBe(false); + } + }); +}); + +describe("VSCodeSettings.useAgentCoreV1", () => { + it("reads the kimi.useAgentCoreV1 setting", () => { + expect(VSCodeSettings.useAgentCoreV1).toBe(false); + configStore.values.set("useAgentCoreV1", true); + expect(VSCodeSettings.useAgentCoreV1).toBe(true); + }); + + it("lets the env var override the setting", () => { + vi.stubEnv(LEGACY_ENGINE_ENV, "1"); + expect(VSCodeSettings.useAgentCoreV1).toBe(true); + }); +}); diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 6de1a390f..4858d0e63 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -10,7 +10,9 @@ * document always carries the `agents` / `custom` maps — seeded at creation, * backfilled and persisted on load for documents written before the seeding * existed (without touching `updatedAt`, so a format heal never reorders - * session listings). `updatedAt` tracks content activity only: management + * session listings) — and `archived` always reads back as a boolean: + * documents written before the flag existed (including v1-engine documents, + * which never carry it) normalize to not-archived at load. `updatedAt` tracks content activity only: management * writes (rename via `setTitle`, archive/restore via `setArchived`, the * generated-title write-back) keep the persisted value through * `touchUpdatedAt: false`, an explicit `patch.updatedAt` always wins (fork @@ -305,6 +307,7 @@ export function normalizeSessionMeta(raw: SessionMeta, sessionId: string): Sessi titleKind, createdAt: toEpochMs(legacyCreatedAt), updatedAt: toEpochMs(legacyUpdatedAt), + archived: clean.archived === true, }; } diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts new file mode 100644 index 000000000..4649222fb --- /dev/null +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts @@ -0,0 +1,233 @@ +/** + * `sessionLifecycle` domain — fork-time turn truncation over raw wire records. + * + * Ports the v1 engine's `forkSession(turnIndex)` slicing onto the flat + * `WireRecord` shape: a user-visible turn boundary is a + * `context.append_message` user record with an interactive origin (plain user + * input, a user-slash skill / plugin command, or a shell-command input line), + * the retained main prefix runs through the addressed turn inclusive, and + * `turn.prompt` / `turn.steer` inputs inside the prefix survive only when + * matched (origin kind, then content exact-then-fuzzy) to a retained boundary. + * Subagent wires time-cut at the retained main prefix's latest record time, + * and the fork's `lastPrompt` re-derives from the addressed turn's record + * through the `prompt` domain's shared metadata-text normalization. Pure + * functions over already-read records — own no scoped state. + */ + +import { Error2, ErrorCodes } from '#/errors'; +import type { ContentPart } from '#/kosong/contract/message'; +import { + promptMetadataTextFromContentParts, + promptMetadataTextFromText, +} from '#/agent/prompt/promptMetadataText'; +import type { WireRecord } from '#/wire/record'; + +export interface MainTurnSlice { + readonly records: readonly WireRecord[]; + readonly cutoffTime?: number; + readonly lastPrompt?: string; +} + +export function assertForkTurnIndex(turnIndex: number | undefined): void { + if (turnIndex === undefined) return; + if (Number.isSafeInteger(turnIndex) && turnIndex >= 0) return; + throw new Error2( + ErrorCodes.REQUEST_INVALID, + 'forkSession turnIndex must be a non-negative safe integer', + { details: { turnIndex } }, + ); +} + +export function sliceMainRecordsAtTurn( + records: readonly WireRecord[], + 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 start = turnStarts[turnIndex]; + if (start === undefined) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + `Turn ${String(turnIndex)} was not found in session "${sourceSessionId}"`, + { details: { turnIndex, availableTurns: turnStarts.length } }, + ); + } + + const end = turnStarts[turnIndex + 1] ?? records.length; + const retainedTurnInputs = turnInputIndicesThrough(records, turnIndex); + const retained = records + .slice(0, end) + .filter( + (record, index) => !isUserVisibleTurnInputRecord(record) || retainedTurnInputs.has(index), + ); + const cutoffTimes = retained + .map(recordTime) + .filter((time): time is number => time !== undefined); + const lastPrompt = promptMetadataFromTurnRecord(records[start]!); + return { + records: retained, + cutoffTime: cutoffTimes.length === 0 ? undefined : Math.max(...cutoffTimes), + lastPrompt, + }; +} + +export function sliceSubagentRecordsAtTime( + records: readonly WireRecord[], + cutoffTime: number | undefined, +): readonly WireRecord[] { + if (cutoffTime === undefined) return []; + let end = records.length; + for (let index = 0; index < records.length; index += 1) { + const time = recordTime(records[index]!); + if (time !== undefined && time > cutoffTime) { + end = index; + break; + } + } + return records.slice(0, end); +} + +function isUserVisibleTurnRecord(record: WireRecord): boolean { + if (record.type !== 'context.append_message') return false; + const message = asRecord(record['message']); + if (message === undefined || message['role'] !== 'user') return false; + const origin = asRecord(message['origin']); + switch (origin?.['kind']) { + case undefined: + case 'user': + return true; + case 'skill_activation': + case 'plugin_command': + return origin?.['trigger'] === 'user-slash'; + case 'shell_command': + return origin?.['phase'] === 'input'; + default: + return false; + } +} + +function isUserVisibleTurnInputRecord(record: WireRecord): boolean { + if (record.type !== 'turn.prompt' && record.type !== 'turn.steer') return false; + const origin = asRecord(record['origin']); + switch (origin?.['kind']) { + case 'user': + return true; + case 'skill_activation': + case 'plugin_command': + return origin?.['trigger'] === 'user-slash'; + case 'shell_command': + return origin?.['phase'] === 'input'; + default: + return false; + } +} + +function turnInputIndicesThrough( + records: readonly WireRecord[], + turnIndex: number, +): ReadonlySet { + const pending: number[] = []; + const retained = new Set(); + let visibleTurnIndex = 0; + for (let index = 0; index < records.length; index += 1) { + const record = records[index]!; + if (isUserVisibleTurnInputRecord(record)) { + pending.push(index); + continue; + } + if (!isUserVisibleTurnRecord(record)) continue; + + const matchAt = findMatchingTurnInput(records, pending, record); + if (matchAt !== -1) { + const [inputIndex] = pending.splice(matchAt, 1); + if (visibleTurnIndex <= turnIndex && inputIndex !== undefined) { + retained.add(inputIndex); + } + } + visibleTurnIndex += 1; + } + return retained; +} + +function findMatchingTurnInput( + records: readonly WireRecord[], + pending: readonly number[], + turnRecord: WireRecord, +): number { + const exact = pending.findIndex((index) => + turnInputMatchesRecord(records[index]!, turnRecord, true), + ); + if (exact !== -1) return exact; + return pending.findIndex((index) => turnInputMatchesRecord(records[index]!, turnRecord, false)); +} + +function turnInputMatchesRecord( + inputRecord: WireRecord, + turnRecord: WireRecord, + compareContent: boolean, +): boolean { + if (inputRecord.type !== 'turn.prompt' && inputRecord.type !== 'turn.steer') return false; + if (turnRecord.type !== 'context.append_message') return false; + const message = asRecord(turnRecord['message']); + if (message === undefined || message['role'] !== 'user') return false; + const inputKind = asRecord(inputRecord['origin'])?.['kind']; + if (typeof inputKind !== 'string') return false; + const messageKind = asRecord(message['origin'])?.['kind']; + if (messageKind !== undefined && typeof messageKind !== 'string') return false; + if (!sameTurnOrigin(inputKind, messageKind)) return false; + return ( + !compareContent || + JSON.stringify(inputRecord['input']) === JSON.stringify(message['content']) + ); +} + +function sameTurnOrigin(inputKind: string, messageKind: string | undefined): boolean { + if (inputKind === 'user') return messageKind === undefined || messageKind === 'user'; + return inputKind === messageKind; +} + +function recordTime(record: WireRecord): number | undefined { + if (typeof record.time === 'number' && Number.isFinite(record.time)) return record.time; + if (record.type === 'metadata') { + const createdAt = record['created_at']; + if (typeof createdAt === 'number' && Number.isFinite(createdAt)) return createdAt; + } + return undefined; +} + +function promptMetadataFromTurnRecord(record: WireRecord): string | undefined { + if (record.type !== 'context.append_message') return undefined; + const message = asRecord(record['message']); + if (message === undefined || message['role'] !== 'user') return undefined; + const origin = asRecord(message['origin']); + if (origin?.['kind'] === 'skill_activation') { + const name = origin['skillName']; + if (typeof name !== 'string') return undefined; + return promptMetadataTextFromText(slashCommandText(`/${name}`, origin['skillArgs'])); + } + if (origin?.['kind'] === 'plugin_command') { + const pluginId = origin['pluginId']; + const commandName = origin['commandName']; + if (typeof pluginId !== 'string' || typeof commandName !== 'string') return undefined; + return promptMetadataTextFromText( + slashCommandText(`/${pluginId}:${commandName}`, origin['commandArgs']), + ); + } + const content = message['content']; + if (!Array.isArray(content)) return undefined; + return promptMetadataTextFromContentParts(content as readonly ContentPart[]); +} + +function slashCommandText(command: string, args: unknown): string { + const trimmed = typeof args === 'string' ? args.trim() : undefined; + return trimmed === undefined || trimmed.length === 0 ? command : `${command} ${trimmed}`; +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts index d3a332d90..a2c8e566b 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts @@ -51,6 +51,11 @@ export interface ForkSessionOptions { readonly newSessionId?: string; readonly title?: string; readonly metadata?: Record; + /** + * Zero-based index of the user-visible turn to retain through. When omitted, + * the complete session is copied (the existing fork behavior). + */ + readonly turnIndex?: number; } 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 604e17807..b252f2269 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -38,6 +38,22 @@ * live Agent wire journals, normalizes a missing protocol envelope, and * appends the fork boundary before restoring the target Agent; fork is * confined to this handler (source and target share the workspace bucket). + * Fork rejects a LIVE source with an active turn + * (`session.fork_active_turn`, read off the agents' `activityView`); a + * closed source forks from disk unchecked. The copied file set drops the + * v1-only `upcoming-goals.json` goal queue on every fork. A fork carrying + * a `turnIndex` truncates the copy through the addressed user-visible turn + * (the slicing itself lives in `internal/forkTurnSlice.ts`, with the + * `prompt` domain's metadata-text normalization deriving the fork's + * `lastPrompt` from the addressed turn): the main wire is sliced at the + * turn boundary keeping only matched turn inputs, subagent wires time-cut + * at the main slice's latest record time and subagents left empty are + * dropped with their copied files, retained agents' `tasks/` and `cron/` + * dirs are cleared, and cron duplication is skipped. The slice runs before + * any target artifact exists, so an out-of-range index fails without a + * cleanup pass. v1's missing-parent sweep has no counterpart: v2 agent metas + * parent `main` by construction, so a retained agent's chain cannot dangle + * outside fabricated wires. * Fork restores the source's recency onto the target: the metadata write * carries an explicit `updatedAt` and runs after agent recreation as the * fork's final metadata write (agent registration is non-touching), ahead @@ -157,8 +173,14 @@ import { IWorkspaceAgentProfileLoader, } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { IAgentActivityView } from '#/agent/activityView/activityView'; import { agentScopeOf, sessionDirOf, sessionScopeOf } from './internal/addressing'; +import { + assertForkTurnIndex, + sliceMainRecordsAtTurn, + sliceSubagentRecordsAtTime, +} from './internal/forkTurnSlice'; import { type CreateChildSessionOptions, type CreateSessionOptions, @@ -508,6 +530,18 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sourceId} does not exist`); } + if (sourceHandle !== undefined) { + for (const agent of sourceHandle.accessor.get(IAgentLifecycleService).list()) { + if (agent.accessor.get(IAgentActivityView).state().turn !== undefined) { + throw new Error2( + ErrorCodes.SESSION_FORK_ACTIVE_TURN, + `Session "${sourceId}" cannot be forked while a turn is running`, + { details: { sessionId: sourceId } }, + ); + } + } + } + assertForkTurnIndex(opts.turnIndex); let targetId: string | undefined; let target: ISessionScopeHandle | undefined; @@ -531,6 +565,15 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ); } + const turnSlice = + opts.turnIndex === undefined + ? undefined + : sliceMainRecordsAtTurn( + await this.readSourceWireRecords(sourceHandle, sourceId, MAIN_AGENT_ID), + sourceId, + opts.turnIndex, + ); + targetSessionDir = sessionDirOf(this.bootstrap.homeDir, this.handlerScope, targetId); await this.copySessionFiles( sessionDirOf(this.bootstrap.homeDir, this.handlerScope, sourceId), @@ -546,18 +589,38 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const sourceAgents = sourceMeta?.agents ?? {}; const agentIds = Object.keys(sourceAgents); + const retainedAgentIds: string[] = []; for (const agentId of agentIds) { + let slicedRecords: readonly WireRecord[] | undefined; + if (turnSlice !== undefined) { + if (agentId === MAIN_AGENT_ID) { + slicedRecords = turnSlice.records; + } else { + const subagentRecords = sliceSubagentRecordsAtTime( + await this.readSourceWireRecords(sourceHandle, sourceId, agentId), + turnSlice.cutoffTime, + ); + if (subagentRecords.length === 0) continue; + slicedRecords = subagentRecords; + } + } await this.copyAgentWire({ sourceHandle, sourceSessionId: sourceId, agentId, targetSessionId: targetCtx.sessionId, + records: slicedRecords, }); + retainedAgentIds.push(agentId); + } + + if (turnSlice !== undefined) { + await this.pruneTruncatedForkFiles(targetSessionDir, agentIds, retainedAgentIds); } const title = opts.title ?? `Fork: ${sourceMeta?.title || sourceId}`; - for (const agentId of agentIds) { + for (const agentId of retainedAgentIds) { const sourceAgent = sourceAgents[agentId]!; await target.accessor.get(IAgentLifecycleService).create({ agentId, @@ -572,7 +635,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec forkedFrom: sourceId, archived: false, updatedAt: toEpochMs(sourceMeta?.updatedAt) || Date.now(), - lastPrompt: sourceMeta?.lastPrompt, + lastPrompt: turnSlice === undefined ? sourceMeta?.lastPrompt : turnSlice.lastPrompt, // The fork continues the source's conversation, so it inherits the // last turn's outcome too — otherwise a restart would drop a failure // the warm fork was still reporting. @@ -580,7 +643,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec custom: forkCustomMetadata(sourceMeta?.custom, opts.metadata), }); - await this.duplicateCronTasks(sourceId, targetId); + if (turnSlice === undefined) { + await this.duplicateCronTasks(sourceId, targetId); + } await this.appendSessionIndexEntry(targetId, this.workspaceContext.cwd); this._onDidForkSession.fire({ @@ -637,22 +702,12 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec readonly sourceSessionId: string; readonly agentId: string; readonly targetSessionId: string; + readonly records?: readonly WireRecord[]; }): Promise { - if (args.sourceHandle !== undefined) { - const agentHandle = args.sourceHandle.accessor - .get(IAgentLifecycleService) - .get(args.agentId); - if (agentHandle !== undefined) { - await agentHandle.accessor.get(IWireService).flush(); - } - } - - const records = await collect( - this.appendLogStore.read( - agentScopeOf(sessionScopeOf(this.handlerScope, args.sourceSessionId), args.agentId), - AGENT_WIRE_RECORD_KEY, - ), - ); + const records = [ + ...(args.records ?? + (await this.readSourceWireRecords(args.sourceHandle, args.sourceSessionId, args.agentId))), + ]; if (records.length === 0) { records.push(createWireMetadataRecord()); } else if (records[0]?.type !== 'metadata') { @@ -667,6 +722,44 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ); } + private async readSourceWireRecords( + sourceHandle: ISessionScopeHandle | undefined, + sourceSessionId: string, + agentId: string, + ): Promise { + if (sourceHandle !== undefined) { + const agentHandle = sourceHandle.accessor.get(IAgentLifecycleService).get(agentId); + if (agentHandle !== undefined) { + await agentHandle.accessor.get(IWireService).flush(); + } + } + return collect( + this.appendLogStore.read( + agentScopeOf(sessionScopeOf(this.handlerScope, sourceSessionId), agentId), + AGENT_WIRE_RECORD_KEY, + ), + ); + } + + private async pruneTruncatedForkFiles( + targetSessionDir: string, + agentIds: readonly string[], + retainedAgentIds: readonly string[], + ): Promise { + const retained = new Set(retainedAgentIds); + const removals: Promise[] = []; + for (const agentId of agentIds) { + if (retained.has(agentId)) continue; + removals.push(this.hostFs.remove(join(targetSessionDir, 'agents', agentId))); + } + for (const agentId of retainedAgentIds) { + const agentDir = join(targetSessionDir, 'agents', agentId); + removals.push(this.hostFs.remove(join(agentDir, 'tasks'))); + removals.push(this.hostFs.remove(join(agentDir, 'cron'))); + } + await Promise.all(removals); + } + private async copySessionFiles(sourceDir: string, targetDir: string): Promise { let entries: readonly HostDirEntry[]; try { @@ -686,7 +779,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ): Promise { for (const entry of entries) { const rel = relBase === '' ? entry.name : `${relBase}/${entry.name}`; - if (rel === 'state.json' || rel === 'logs' || entry.name === AGENT_WIRE_RECORD_KEY) { + if (rel === 'state.json' || rel === 'logs' || rel === 'upcoming-goals.json' || entry.name === AGENT_WIRE_RECORD_KEY) { continue; } if (entry.isSymbolicLink === true) continue; diff --git a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts index fe6b7b597..eec65f147 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -161,6 +161,21 @@ describe('SessionMetadata', () => { expect(next.updatedAt).toBe(1234); }); + it('reads a loaded document without the archived field as not-archived', async () => { + const store = ix.get(IAtomicDocumentStore); + await store.set(META_SCOPE, 'state.json', { + id: 's1', + version: 2, + createdAt: 1700000000000, + updatedAt: 1700000000000, + agents: {}, + custom: {}, + }); + + const meta = ix.get(ISessionMetadata); + expect(await meta.read()).toMatchObject({ id: 's1', archived: false }); + }); + it('mirrors a boolean archived to the read model even when the loaded document lacks the field', async () => { const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts index 17f017311..b2195b6d4 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts @@ -351,6 +351,136 @@ function appendLogStoreStub(): IAppendLogStore { }; } +/** + * In-memory wire store for fork tests: `wires` maps `/` + * to the source records served by `read`; every `rewrite` is captured under + * the same key shape so tests can assert exactly what a fork persisted. + */ +function wireStoreStub(wires: Readonly>): { + readonly store: IAppendLogStore; + readonly rewritten: Map; +} { + const rewritten = new Map(); + const keyOf = (scope: string, key: string): string => { + const match = /([^/]+)\/agents\/([^/]+)$/.exec(scope); + return match === null ? `${scope}/${key}` : `${match[1]}/${match[2]}`; + }; + const store: IAppendLogStore = { + _serviceBrand: undefined, + append: () => {}, + read: (scope: string, key: string): AsyncIterable => { + const records = wires[keyOf(scope, key)] ?? []; + return (async function* () { + for (const record of records) { + yield record as R; + } + })(); + }, + rewrite: (scope: string, key: string, records: readonly R[]) => { + rewritten.set(keyOf(scope, key), [...records]); + return Promise.resolve(); + }, + flush: () => Promise.resolve(), + close: () => Promise.resolve(), + acquire: () => ({ dispose: () => {} }), + }; + return { store, rewritten }; +} + +function wireEnvelopeRecord(time: number): Record { + return { type: 'metadata', protocol_version: '1.5', created_at: time }; +} + +function turnPromptRecord(text: string, time: number): Record { + return { + type: 'turn.prompt', + input: [{ type: 'text', text }], + origin: { kind: 'user' }, + time, + }; +} + +function turnSteerRecord(text: string, time: number): Record { + return { + type: 'turn.steer', + input: [{ type: 'text', text }], + origin: { kind: 'user' }, + time, + }; +} + +function userTurnRecord(text: string, time: number): Record { + return { + type: 'context.append_message', + message: { role: 'user', content: [{ type: 'text', text }] }, + time, + }; +} + +function assistantMessageRecord(text: string, time: number): Record { + return { + type: 'context.append_message', + message: { role: 'assistant', content: [{ type: 'text', text }] }, + time, + }; +} + +/** Three user-visible turns on the main agent, each prompt + user + assistant. */ +function threeTurnMainWire(): Record[] { + return [ + wireEnvelopeRecord(1), + turnPromptRecord('first question', 2), + userTurnRecord('first question', 3), + assistantMessageRecord('first answer', 4), + turnPromptRecord('second question', 5), + userTurnRecord('second question', 6), + assistantMessageRecord('second answer', 7), + turnPromptRecord('third question', 8), + userTurnRecord('third question', 9), + assistantMessageRecord('third answer', 10), + ]; +} + +function agentHandleStub(agentId: string): IAgentScopeHandle { + return { + id: agentId, + kind: LifecycleScope.Agent, + accessor: { + get: () => { + throw new Error('unexpected agent service access'); + }, + }, + dispose: () => {}, + } as unknown as IAgentScopeHandle; +} + +/** Agent lifecycle whose `create` resolves and records the created agent ids. */ +function agentLifecycleCreatingStub(created: string[]): IAgentLifecycleService { + return { + ...agentLifecycleStub(), + create: (opts) => { + const agentId = opts?.agentId ?? MAIN_AGENT_ID; + created.push(agentId); + return Promise.resolve(agentHandleStub(agentId)); + }, + }; +} + +/** Session metadata stub serving a fixed source document and capturing updates. */ +function sessionMetadataStubFor( + document: Record, + updates: Array>, +): ISessionMetadata { + return { + ...metadataStub(), + read: () => Promise.resolve(document as never), + update: (patch) => { + updates.push(patch as Record); + return Promise.resolve(); + }, + }; +} + function atomicDocumentStoreStub(): IAtomicDocumentStore { return { _serviceBrand: undefined, @@ -1241,7 +1371,7 @@ describe('SessionLifecycleService', () => { }); }); - it('forks successfully even while the source has a busy agent (crash-equivalent copy)', async () => { + it('rejects fork of a live source session while a turn is running', async () => { const busyAgent = { id: MAIN_AGENT_ID, kind: LifecycleScope.Agent, @@ -1270,8 +1400,37 @@ describe('SessionLifecycleService', () => { await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); - const target = await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }); + await expect(svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' })).rejects.toMatchObject({ + code: ErrorCodes.SESSION_FORK_ACTIVE_TURN, + message: 'Session "src" cannot be forked while a turn is running', + details: { sessionId: 'src' }, + }); + expect(svc.get('dst')).toBeUndefined(); + }); + + it('forks a closed source session without consulting live activity (crash-equivalent copy)', async () => { + const wireStore = wireStoreStub({ 'src/main': threeTurnMainWire() }); + const created: string[] = []; + const svc = await build([ + stubPair(ISessionIndex, sessionIndexWithSummary('src', '/tmp/proj', 'wd_stub')), + stubPair(IAtomicDocumentStore, { + ...atomicDocumentStoreStub(), + get: (scope: string, key: string) => + Promise.resolve( + scope === 'sessions/wd_stub/src' && key === 'state.json' + ? { agents: { main: { type: 'main' } } } + : undefined, + ), + } as unknown as IAtomicDocumentStore), + stubPair(IAppendLogStore, wireStore.store), + stubPair(IAgentLifecycleService, agentLifecycleCreatingStub(created)), + ]); + + const target = await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst', turnIndex: 1 }); + expect(target.id).toBe('dst'); + expect(created).toEqual([MAIN_AGENT_ID]); + expect(wireStore.rewritten.get('dst/main')).toHaveLength(8); }); it('fires onDidCreateSession with the new handle', async () => { @@ -2027,6 +2186,212 @@ describe('SessionLifecycleService', () => { }); }); + describe('fork turnIndex truncation', () => { + it('slices the main wire at the turn boundary and keeps only matched turn inputs', async () => { + const wireStore = wireStoreStub({ + 'src/main': [ + wireEnvelopeRecord(1), + turnPromptRecord('first question', 2), + userTurnRecord('first question', 3), + assistantMessageRecord('first answer', 4), + turnPromptRecord('second question', 5), + userTurnRecord('second question', 6), + assistantMessageRecord('second answer', 7), + turnSteerRecord('a stray steer', 7.1), + turnSteerRecord('third question', 7.2), + turnPromptRecord('third question', 8), + userTurnRecord('third question', 9), + assistantMessageRecord('third answer', 10), + ], + }); + const updates: Array> = []; + const created: string[] = []; + const svc = await build([ + stubPair(IAppendLogStore, wireStore.store), + stubPair( + ISessionMetadata, + sessionMetadataStubFor( + { lastPrompt: 'third question', agents: { main: { type: 'main' } } }, + updates, + ), + ), + stubPair(IAgentLifecycleService, agentLifecycleCreatingStub(created)), + ]); + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + + await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst', turnIndex: 1 }); + + // Both steers drop: the stray one matches no visible turn, and the one + // echoing turn 2's content matches a turn that is itself cut. + expect(wireStore.rewritten.get('dst/main')).toEqual([ + wireEnvelopeRecord(1), + turnPromptRecord('first question', 2), + userTurnRecord('first question', 3), + assistantMessageRecord('first answer', 4), + turnPromptRecord('second question', 5), + userTurnRecord('second question', 6), + assistantMessageRecord('second answer', 7), + { type: 'forked', time: expect.any(Number) }, + ]); + expect(created).toEqual([MAIN_AGENT_ID]); + // The fork's lastPrompt re-derives from the retained turn instead of + // inheriting the source's (turn 2's) value. + const forkUpdate = updates.find((patch) => 'forkedFrom' in patch); + expect(forkUpdate?.['lastPrompt']).toBe('second question'); + }); + + it('retains the whole wire when turnIndex addresses the last turn', async () => { + const wireStore = wireStoreStub({ 'src/main': threeTurnMainWire() }); + const updates: Array> = []; + const svc = await build([ + stubPair(IAppendLogStore, wireStore.store), + stubPair( + ISessionMetadata, + sessionMetadataStubFor({ agents: { main: { type: 'main' } } }, updates), + ), + stubPair(IAgentLifecycleService, agentLifecycleCreatingStub([])), + ]); + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + + await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst', turnIndex: 2 }); + + expect(wireStore.rewritten.get('dst/main')).toEqual([ + ...threeTurnMainWire(), + { type: 'forked', time: expect.any(Number) }, + ]); + const forkUpdate = updates.find((patch) => 'forkedFrom' in patch); + expect(forkUpdate?.['lastPrompt']).toBe('third question'); + }); + + it('time-cuts subagent wires at the main cutoff, drops emptied subagents, and prunes copied task state', async () => { + const root = await makeTmpRoot(); + const wireStore = wireStoreStub({ + 'src/main': threeTurnMainWire(), + 'src/sub_old': [ + wireEnvelopeRecord(2), + userTurnRecord('old sub task', 3), + assistantMessageRecord('old sub done', 4), + ], + 'src/sub_new': [wireEnvelopeRecord(8), userTurnRecord('late sub task', 9)], + }); + const created: string[] = []; + const svc = await build([ + stubPair(IBootstrapService, tmpBootstrapStub(root)), + stubPair(IAppendLogStore, wireStore.store), + stubPair( + ISessionMetadata, + sessionMetadataStubFor( + { + agents: { + main: { type: 'main' }, + sub_old: { type: 'sub' }, + sub_new: { type: 'sub' }, + }, + }, + [], + ), + ), + stubPair(IAgentLifecycleService, agentLifecycleCreatingStub(created)), + ]); + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + const srcDir = join(root, 'sessions', 'wd_stub', 'src'); + await mkdir(join(srcDir, 'agents', 'main', 'tasks'), { recursive: true }); + await writeFile(join(srcDir, 'agents', 'main', 'tasks', 't1.json'), '{}'); + await mkdir(join(srcDir, 'agents', 'main', 'plans'), { recursive: true }); + await writeFile(join(srcDir, 'agents', 'main', 'plans', 'p1.md'), '# plan'); + await mkdir(join(srcDir, 'agents', 'sub_old', 'tasks'), { recursive: true }); + await writeFile(join(srcDir, 'agents', 'sub_old', 'tasks', 't2.json'), '{}'); + await mkdir(join(srcDir, 'agents', 'sub_new', 'plans'), { recursive: true }); + await writeFile(join(srcDir, 'agents', 'sub_new', 'plans', 'p2.md'), '# late'); + + await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst', turnIndex: 1 }); + + // The main cutoff is 7 (turn 1's assistant reply): sub_old survives in + // full, while sub_new's only records sit past the cut and it is dropped. + expect(created).toEqual([MAIN_AGENT_ID, 'sub_old']); + expect(wireStore.rewritten.get('dst/sub_old')).toEqual([ + wireEnvelopeRecord(2), + userTurnRecord('old sub task', 3), + assistantMessageRecord('old sub done', 4), + { type: 'forked', time: expect.any(Number) }, + ]); + expect(wireStore.rewritten.has('dst/sub_new')).toBe(false); + const dstDir = join(root, 'sessions', 'wd_stub', 'dst'); + await expect(stat(join(dstDir, 'agents', 'sub_new'))).rejects.toThrow(); + await expect(stat(join(dstDir, 'agents', 'main', 'tasks'))).rejects.toThrow(); + await expect(stat(join(dstDir, 'agents', 'sub_old', 'tasks'))).rejects.toThrow(); + await expect(readFile(join(dstDir, 'agents', 'main', 'plans', 'p1.md'), 'utf8')).resolves.toBe( + '# plan', + ); + }); + + it('does not duplicate cron tasks on a truncated fork', async () => { + const cron = cronStoreStub([ + { + id: 'task-src', + cron: '0 9 * * *', + prompt: 'standup', + createdAt: 1, + tags: { [CRON_SESSION_TAG]: 'src' }, + }, + ]); + const svc = await build([ + stubPair(IAppendLogStore, wireStoreStub({ 'src/main': threeTurnMainWire() }).store), + stubPair( + ISessionMetadata, + sessionMetadataStubFor({ agents: { main: { type: 'main' } } }, []), + ), + stubPair(IAgentLifecycleService, agentLifecycleCreatingStub([])), + stubPair(ICronTaskPersistence, cron), + ]); + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + + await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst', turnIndex: 1 }); + + expect([...cron.docs.values()]).toHaveLength(1); + }); + + it('rejects an invalid turnIndex with request.invalid and creates nothing', async () => { + const svc = await build(); + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + + for (const turnIndex of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + await expect( + svc.fork({ sourceSessionId: 'src', newSessionId: 'dst', turnIndex }), + ).rejects.toMatchObject({ + code: ErrorCodes.REQUEST_INVALID, + message: 'forkSession turnIndex must be a non-negative safe integer', + details: { turnIndex }, + }); + } + expect(svc.get('dst')).toBeUndefined(); + }); + + it('rejects an out-of-range turnIndex with the available turn count and creates nothing', async () => { + const root = await makeTmpRoot(); + const svc = await build([ + stubPair(IBootstrapService, tmpBootstrapStub(root)), + stubPair(IAppendLogStore, wireStoreStub({ 'src/main': threeTurnMainWire() }).store), + stubPair( + ISessionMetadata, + sessionMetadataStubFor({ agents: { main: { type: 'main' } } }, []), + ), + stubPair(IAgentLifecycleService, agentLifecycleCreatingStub([])), + ]); + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + + await expect( + svc.fork({ sourceSessionId: 'src', newSessionId: 'dst', turnIndex: 5 }), + ).rejects.toMatchObject({ + code: ErrorCodes.REQUEST_INVALID, + message: 'Turn 5 was not found in session "src"', + details: { turnIndex: 5, availableTurns: 3 }, + }); + expect(svc.get('dst')).toBeUndefined(); + await expect(stat(join(root, 'sessions', 'wd_stub', 'dst'))).rejects.toThrow(); + }); + }); + describe('defaultPlanMode bootstrap', () => { it('enters plan mode on a fresh session when config.defaultPlanMode is true', async () => { const { lifecycle, enter, create } = agentLifecycleCapturingPlanSpy(); diff --git a/packages/klient/src/contract/session/lifecycle.ts b/packages/klient/src/contract/session/lifecycle.ts index edf502c85..800d76652 100644 --- a/packages/klient/src/contract/session/lifecycle.ts +++ b/packages/klient/src/contract/session/lifecycle.ts @@ -37,15 +37,17 @@ export const resumeSessionOptionsSchema = z.object({ mcpServers: z.record(z.string(), mcpServerConfigSchema).optional(), }); +/** Same fields as `ForkSessionOptions` in the engine — keep in sync. */ export const forkSessionOptionsSchema = z.object({ sourceSessionId: z.string(), newSessionId: z.string().optional(), title: z.string().optional(), metadata: z.record(z.string(), z.unknown()).optional(), + turnIndex: z.number().optional(), }); -/** Same fields as `ForkSessionOptions` in the engine — keep in sync. */ -export const createChildSessionOptionsSchema = forkSessionOptionsSchema; +/** Same fields as `ForkSessionOptions` in the engine, minus the fork-only truncation. */ +export const createChildSessionOptionsSchema = forkSessionOptionsSchema.omit({ turnIndex: true }); /** `IScopeHandle` as it survives JSON — `{ id, kind }` plus extras. */ export const handleWireSchema = z.looseObject({ diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index d32b6e0a7..6523ed174 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -26,17 +26,15 @@ * is needed. Unlike the config domain, the v2 plugin service serializes * every read behind its own initial load, so there is no ready trap here. * - `listSessions` / `createSession` / `renameSession` / `forkSession` / - * `closeSession` / `resumeSession` / `reloadSession` / + * `closeSession` / `resumeSession` / `reloadSession` / `deleteSession` / * `updateSessionMetadata` / `addAdditionalDir` → the session lifecycle * batch: `klient.global.sessions.list` plus the `klient.session(id)` * metadata mutations where the facade reaches, and the * `IWorkspaceLifecycleService` / handler chain / session-scope services through * {@link engineAccessor} where it does not (explicit session ids, resume, - * fork ids, the workspace-level add-dir surface). The v1 `SessionSummary` / `SessionMeta` + * fork ids, delete, the workspace-level add-dir surface). The v1 `SessionSummary` / `SessionMeta` * shapes are restored by the pure mapping layer in - * `src/v2/session-mapper.ts`. `deleteSession` stays `not_implemented` — - * the v2 engine has no session-deletion capability anywhere (tracked in - * `.tmp/v2-migration-tracker.md`). The resumed results carry the full v1 + * `src/v2/session-mapper.ts`. The resumed results carry the full v1 * per-agent snapshot: the live slices are read from the restored agent * scope (profile / permission / swarm services + the klient agent facade), * while `replay` and `toolStore` are folded from each agent's `wire.jsonl` @@ -44,8 +42,10 @@ * (`src/v2/resume-replay.ts`) — `includeSubagents` and `replayTurnLimit` * included. * - `setModel` / `setPermission` / `setPlanMode` / `getPlan` / `clearPlan` / - * `getContext` / `getUsage` / `cancel` / `listCommands` / `runCommand` → - * the `klient.session(id).agent(id)` facade; `setThinking` / `compact` / + * `getContext` / `getUsage` / `listCommands` / `runCommand` → + * the `klient.session(id).agent(id)` facade; `cancel` → the same facade + * plus `ISessionInitService.cancelInit` (v1's cascade to the session-level + * /init run); `setThinking` / `compact` / * `cancelCompaction` / `undoHistory` / `clearContext` / `importContext` → * agent-scope services through the live * session handle (no facade exists); `getStatus` → the same six-slice @@ -369,7 +369,8 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * Per-session print-steer state for `handlePrintMainTurnCompleted`: v1 * keeps the deadline/turn counters on the `Session` object, so they reset * when the session closes (a resume builds a fresh `Session`); mirrored - * here by deleting the entry in `closeSession` / `reloadSession`. + * here by deleting the entry in {@link unwireSession}, which every close + * path (client, engine, delete) funnels through. */ private readonly printSteerStates = new Map(); /** @@ -913,6 +914,9 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } private unwireSession(sessionId: string): void { + // v1's print-steer counters die with the Session object; drop ours with + // every close path (ours, the engine's, or a delete). + this.printSteerStates.delete(sessionId); const wiring = this.sessionWirings.get(sessionId); if (wiring === undefined) return; this.sessionWirings.delete(sessionId); @@ -1277,20 +1281,15 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { /** * Through `engineAccessor` (the handler chain's `ISessionLifecycleService.fork`) because the - * klient facade fork takes no explicit target id. Known gaps vs v1: the - * engine's fork is unconditional — it never rejects an in-flight source - * turn (v1's SESSION_FORK_ACTIVE_TURN) — and `turnIndex` truncation has no - * v2 counterpart at all, so it fails loudly. The default title also differs - * by design (v1: "New Session", v2: "Fork: ") — pass an explicit - * title for identical results. + * 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. */ override async forkSession(input: ForkSessionInput): Promise { - if (input.turnIndex !== undefined) { - throw new KimiError( - ErrorCodes.NOT_IMPLEMENTED, - 'forkSession turnIndex truncation is not wired to agent-core-v2 yet.', - ); - } // The source session's reads (metadata, wire flush) stay atomic against // its close/reload through the per-session queue; an explicit target id // takes a second (sorted) queue so fork(A→X) is also atomic against @@ -1305,6 +1304,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { newSessionId: input.forkId, title: input.title, metadata: input.metadata, + turnIndex: input.turnIndex, }); this.wireSession(handle); return this.resumedSessionSummary(handle); @@ -1313,13 +1313,43 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } override async closeSession(input: SessionIdRpcInput): Promise { - // v1's print-steer counters die with the Session object; drop ours too. - this.printSteerStates.delete(input.sessionId); await this.runSessionAccess(input.sessionId, () => this.klient.session(input.sessionId).close(), ); } + /** + * Through `engineAccessor` (the handler chain's + * `ISessionLifecycleService.delete`) because the klient facade's + * `session(id).delete()` reports a missing session with its own + * `RPCError(NOT_FOUND)` where v1's store failure is a + * `KimiError(SESSION_NOT_FOUND)` — the pre-check here keeps the v1 shape. + * The engine's delete mirrors v1's order: close the live session first + * (which also drops this client's wiring via the close subscription), then + * remove the session dir, the index entry, and journal the deletion. + */ + override async deleteSession(input: SessionIdRpcInput): Promise { + // Same per-session queue as close/reload: a delete serializes against + // every other lifecycle operation on the session. + return this.runSessionAccess(input.sessionId, async () => { + const handler = await handlerForSession(this.engineAccessor, input.sessionId); + if (handler === undefined) throw SDKRpcClientV2.sessionNotFound(input.sessionId); + try { + await handler.accessor.get(ISessionLifecycleService).delete(input.sessionId); + } catch (error) { + // The session vanished between the index check and the delete: the + // engine's own not-found crosses as an Error2 — restate it in v1's shape. + if ( + error instanceof Error && + (error as { code?: unknown }).code === ErrorCodes.SESSION_NOT_FOUND + ) { + throw SDKRpcClientV2.sessionNotFound(input.sessionId); + } + throw error; + } + }); + } + /** * Materializes the session through `engineAccessor` * (`resumeSessionById` through the handler chain; the facade only offers `restore`, @@ -1381,9 +1411,6 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { if (live !== undefined) { await closeSessionById(this.engineAccessor, sessionId); } - // Same print-steer reset as closeSession: v1's reload rebuilds the - // Session, and with it the counters. - this.printSteerStates.delete(sessionId); const handle = await resumeSessionById(this.engineAccessor, sessionId); if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(sessionId); const main = handle.accessor.get(IAgentLifecycleService).get(MAIN_AGENT_ID); @@ -1670,7 +1697,16 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { }; } + /** + * Facade (`agentLoopService.cancelFromUser`) plus the session-level init + * run: v1's cancel cascades from the agent's turn to every foreground + * subagent run of the session, and /init is the one session-level run v2 + * keeps off the agent turn lane — its abort controller lives in + * `ISessionInitService` (a silent no-op when no init is running). + */ override async cancel(input: SessionIdRpcInput): Promise { + const session = this.requireLiveSession(input.sessionId); + session.accessor.get(ISessionInitService).cancelInit(); const agent = await this.agentFacade(input.sessionId); return agent.cancel(); } diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 697385b63..26401b72b 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -1,9 +1,10 @@ /** - * Scenario: v2 wiring MVP — the harness talks to the in-process agent-core-v2 + * Scenario: v2 wiring — the harness talks to the in-process agent-core-v2 * engine (klient memory transport) instead of the v1 KimiCore RPC pair. - * Responsibilities: `getExperimentalFeatures` is migrated end-to-end; every - * not-yet-migrated method fails loudly with `not_implemented` instead of - * silently hitting a v1 core. + * Responsibilities: v2-client behaviors the v1↔v2 parity gate does not + * compare (engine telemetry forwarding, host request headers, the Windows + * Git Bash probe, workspace trust, the config write cascade, deleteSession, + * foldAgentWireReplay). * Wiring: real v2 engine bootstrapped on a temp KIMI_CODE_HOME; remote provider calls are stubbed. * Run: pnpm exec vitest run test/sdk-rpc-client-v2.test.ts */ @@ -21,7 +22,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createKimiHarnessV2, ErrorCodes, - KimiError, KimiHarness, removeProviderFromConfig, SDKRpcClientV2, @@ -92,7 +92,26 @@ async function makeHarness(): Promise<{ harness: KimiHarness; homeDir: string }> return { harness: createKimiHarnessV2({ homeDir, identity: TEST_IDENTITY }), homeDir }; } -describe('SDKRpcClientV2 (agent-core-v2 wiring MVP)', () => { +/** Whether the persisted session directory exists under `/sessions//`. */ +async function sessionDirExists(homeDir: string, sessionId: string): Promise { + let buckets: readonly string[]; + try { + buckets = await readdir(join(homeDir, 'sessions')); + } catch { + return false; + } + for (const bucket of buckets) { + try { + await readdir(join(homeDir, 'sessions', bucket, sessionId)); + return true; + } catch { + // Not under this bucket. + } + } + return false; +} + +describe('SDKRpcClientV2 (agent-core-v2 wiring)', () => { it('reports global MCP authorization from the persisted v2 credential store', async () => { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); tempDirs.push(homeDir); @@ -792,15 +811,19 @@ key = "${titleOAuthRef.key}" } }); - it('fails loudly with not_implemented for methods not yet migrated', async () => { - const { harness } = await makeHarness(); + it('deleteSession removes a session and rejects a missing id with session_not_found', async () => { + const { harness, homeDir } = await makeHarness(); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); try { - // `deleteSession` is the permanent case: the v2 engine has no - // session-deletion capability, so it stays not_implemented by design - // (tracked in `.tmp/v2-migration-tracker.md`). - await expect(harness.deleteSession('session_missing')).rejects.toThrowError(KimiError); + const session = await harness.createSession({ workDir }); + await harness.deleteSession(session.id); + await expect(harness.resumeSession({ id: session.id })).rejects.toMatchObject({ + code: ErrorCodes.SESSION_NOT_FOUND, + }); + expect(await sessionDirExists(homeDir, session.id)).toBe(false); await expect(harness.deleteSession('session_missing')).rejects.toMatchObject({ - code: ErrorCodes.NOT_IMPLEMENTED, + code: ErrorCodes.SESSION_NOT_FOUND, }); } finally { await harness.close(); diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index d176b6e3a..712ab8f63 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -1209,9 +1209,7 @@ describe('v1↔v2 plugin parity', () => { // referenced path, so sharing it makes the workDir / additionalDirs / // configPath comparisons exact). Explicit session ids keep identity // comparisons meaningful. No provider calls anywhere — create / resume / -// reload / fork never touch a model. `deleteSession` has no parity case: -// the v2 engine has no session-deletion capability, so the v2 side stays -// not_implemented by design (tracked in `.tmp/v2-migration-tracker.md`). +// reload / fork / delete never touch a model. // --------------------------------------------------------------------------- interface SessionParityPair { @@ -1297,6 +1295,96 @@ async function appendMainWireRecord( throw new Error(`wire.jsonl for ${sessionId} not found under ${sessionsRoot}`); } +/** + * The on-disk session directory under the given home (both engines lay + * sessions out as `/sessions//`). + */ +async function sessionDirPath(home: HomePair, sessionId: string): Promise { + const sessionsRoot = join(home.raw, 'sessions'); + for (const bucket of await readdir(sessionsRoot)) { + const dir = join(sessionsRoot, bucket, sessionId); + try { + await readdir(dir); + return dir; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + throw new Error(`session dir for ${sessionId} not found under ${sessionsRoot}`); +} + +/** + * Fabricate a subagent on disk the way the engine would have left it: a + * `wire.jsonl` opening with the main wire's protocol envelope re-stamped to + * the subagent's creation time, plus a state.json `agents` entry. Feeds the + * SAME subagent through both engines' fork paths. + */ +async function fabricateSubagentWire( + sessionDir: string, + agentId: string, + createdAt: number, + records: readonly JsonObject[], +): Promise { + const mainWire = await readFile(join(sessionDir, 'agents', 'main', 'wire.jsonl'), 'utf-8'); + const envelope = { + ...(JSON.parse(mainWire.split('\n', 1)[0]!) as Record), + created_at: createdAt, + }; + const agentDir = join(sessionDir, 'agents', agentId); + await mkdir(agentDir, { recursive: true }); + await writeFile( + join(agentDir, 'wire.jsonl'), + `${[envelope, ...records].map((record) => JSON.stringify(record)).join('\n')}\n`, + 'utf-8', + ); + const statePath = join(sessionDir, 'state.json'); + const state = JSON.parse(await readFile(statePath, 'utf-8')) as Record; + const agents = (state['agents'] ?? {}) as Record; + agents[agentId] = { homedir: agentDir, type: 'sub', parentAgentId: 'main' }; + state['agents'] = agents; + await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf-8'); +} + +/** The `user:`/`assistant:` text lines of a fork/resume result's main replay. */ +function replayMessageTexts(resumed: ResumedSessionSummary): readonly string[] { + const entries: string[] = []; + for (const record of resumed.agents['main']?.replay ?? []) { + if (record.type !== 'message') continue; + const message = record.message; + entries.push( + `${message.role}:${message.content + .filter((part) => part.type === 'text') + .map((part) => part.text ?? '') + .join('')}`, + ); + } + return entries; +} + +/** + * Whether the persisted session directory still exists under the home (both + * engines lay sessions out as `/sessions//`). + */ +async function sessionDirExists(home: HomePair, sessionId: string): Promise { + const sessionsRoot = join(home.raw, 'sessions'); + let buckets: readonly string[]; + try { + buckets = await readdir(sessionsRoot); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } + for (const bucket of buckets) { + try { + await readdir(join(sessionsRoot, bucket, sessionId)); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT' && (error as NodeJS.ErrnoException).code !== 'ENOTDIR') throw error; + } + } + return false; +} + describe('v1↔v2 session lifecycle parity', () => { it('createSession returns the same summary for the same input', async () => { const pair = await makeSessionParityPair(); @@ -1451,6 +1539,41 @@ describe('v1↔v2 session lifecycle parity', () => { } }); + it('deleteSession removes the session from the listing and disk on both engines', async () => { + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_delete' }); + // Deleting a live session closes it first on both engines. + await Promise.all([ + pair.v1.deleteSession({ sessionId: 'session_parity_delete' }), + pair.v2.deleteSession({ sessionId: 'session_parity_delete' }), + ]); + const project = KNOWN_DIFFS.listSessions; + const [v1List, v2List] = await Promise.all([ + pair.v1.listSessions(), + pair.v2.listSessions(), + ]); + expect(normalize(project(v2List, pair.v2Home), 'id')).toEqual( + normalize(project(v1List, pair.v1Home), 'id'), + ); + expect(v1List).toHaveLength(0); + // The persisted session directory is gone on both engines. + expect(await sessionDirExists(pair.v1Home, 'session_parity_delete')).toBe(false); + expect(await sessionDirExists(pair.v2Home, 'session_parity_delete')).toBe(false); + // Resume and re-delete reject with session_not_found on both engines. + for (const client of [pair.v1, pair.v2]) { + await expect(client.resumeSession({ id: 'session_parity_delete' })).rejects.toMatchObject({ + code: ErrorCodes.SESSION_NOT_FOUND, + }); + await expect( + client.deleteSession({ sessionId: 'session_parity_delete' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + } + } finally { + await closeSessionPair(pair); + } + }); + it('forkSession copies the session with merged metadata on both engines', async () => { const pair = await makeSessionParityPair(); try { @@ -1495,6 +1618,143 @@ describe('v1↔v2 session lifecycle parity', () => { } }); + it('forkSession truncates at the given turn index identically on both engines', async () => { + const pair = await makeSessionParityPair(); + try { + // Source metadata: a metadata-less fork reports `{}` on v1 vs an unset + // field on v2 (pre-existing gap the full-fork case above never hits) — + // a non-empty map compares exactly. + await createOnBoth(pair, { + id: 'session_parity_fork_turns', + metadata: { origin: 'fork-turns' }, + }); + // See materializeMainAgentOnBoth: v2's main agent is lazy, so + // materialize it on the source first. + await materializeMainAgentOnBoth(pair, 'session_parity_fork_turns'); + await Promise.all([ + pair.v1.closeSession({ sessionId: 'session_parity_fork_turns' }), + pair.v2.closeSession({ sessionId: 'session_parity_fork_turns' }), + ]); + // Both sessions closed: feed the SAME three user-visible turns into + // each engine's main wire (a turn.prompt op plus the user/assistant + // append_message pair, the shape a real prompt journals). Times start + // at the close moment so every pre-existing record sits before them. + const t0 = Date.now(); + const turnRecords = (question: string, answer: string, start: number): JsonObject[] => [ + { + type: 'turn.prompt', + input: [{ type: 'text', text: question }], + origin: { kind: 'user' }, + time: start, + }, + { + type: 'context.append_message', + message: { role: 'user', content: [{ type: 'text', text: question }] }, + time: start + 1, + }, + { + type: 'context.append_message', + message: { role: 'assistant', content: [{ type: 'text', text: answer }] }, + time: start + 2, + }, + ]; + const records = [ + ...turnRecords('first question', 'first answer', t0 + 1), + ...turnRecords('second question', 'second answer', t0 + 11), + ...turnRecords('third question', 'third answer', t0 + 21), + ]; + for (const record of records) { + await appendMainWireRecord(pair.v1Home, 'session_parity_fork_turns', record); + await appendMainWireRecord(pair.v2Home, 'session_parity_fork_turns', record); + } + // Two subagents fabricated on disk for both engines: one whose records + // predate the cut (retained), one created after it (dropped). + for (const home of [pair.v1Home, pair.v2Home]) { + const sessionDir = await sessionDirPath(home, 'session_parity_fork_turns'); + await fabricateSubagentWire(sessionDir, 'sub_old', t0 + 4, [ + { + type: 'context.append_message', + message: { role: 'user', content: [{ type: 'text', text: 'old sub task' }] }, + time: t0 + 5, + }, + { + type: 'context.append_message', + message: { role: 'assistant', content: [{ type: 'text', text: 'old sub done' }] }, + time: t0 + 6, + }, + ]); + await fabricateSubagentWire(sessionDir, 'sub_new', t0 + 23, [ + { + type: 'context.append_message', + message: { role: 'user', content: [{ type: 'text', text: 'late sub task' }] }, + time: t0 + 24, + }, + ]); + } + + const input = { + id: 'session_parity_fork_turns', + forkId: 'session_parity_fork_cut', + title: 'Cut Fork', + turnIndex: 1, + } as const; + const [v1Fork, v2Fork] = await Promise.all([ + pair.v1.forkSession(input), + pair.v2.forkSession(input), + ]); + const project = KNOWN_DIFFS.forkSession; + expect(project(v2Fork as ResumedSessionSummary, pair.v2Home)).toEqual( + project(v1Fork as ResumedSessionSummary, pair.v1Home), + ); + // lastPrompt re-derives from the retained turn, and the replay holds + // exactly the turns through the cut — on both engines. + expect(v1Fork.lastPrompt).toBe('second question'); + expect(v2Fork.lastPrompt).toBe('second question'); + const expectedReplay = [ + 'user:first question', + 'assistant:first answer', + 'user:second question', + 'assistant:second answer', + ]; + expect(replayMessageTexts(v1Fork as ResumedSessionSummary)).toEqual(expectedReplay); + expect(replayMessageTexts(v2Fork as ResumedSessionSummary)).toEqual(expectedReplay); + // The subagent created after the cut is omitted on both engines. + expect( + Object.keys((v1Fork as ResumedSessionSummary).sessionMetadata.agents).toSorted(), + ).toEqual(['main', 'sub_old']); + expect( + Object.keys((v2Fork as ResumedSessionSummary).sessionMetadata.agents).toSorted(), + ).toEqual(['main', 'sub_old']); + + // A negative index rejects with request.invalid on both engines. + await expect( + pair.v1.forkSession({ id: input.id, forkId: 'session_parity_fork_neg', turnIndex: -1 }), + ).rejects.toMatchObject({ code: ErrorCodes.REQUEST_INVALID, details: { turnIndex: -1 } }); + await expect( + pair.v2.forkSession({ id: input.id, forkId: 'session_parity_fork_neg', turnIndex: -1 }), + ).rejects.toMatchObject({ code: ErrorCodes.REQUEST_INVALID, details: { turnIndex: -1 } }); + + // An out-of-range index rejects with the available turn count and + // leaves no fork behind on either engine. + await expect( + pair.v1.forkSession({ id: input.id, forkId: 'session_parity_fork_oob', turnIndex: 5 }), + ).rejects.toMatchObject({ + code: ErrorCodes.REQUEST_INVALID, + details: { turnIndex: 5, availableTurns: 3 }, + }); + await expect( + pair.v2.forkSession({ id: input.id, forkId: 'session_parity_fork_oob', turnIndex: 5 }), + ).rejects.toMatchObject({ + code: ErrorCodes.REQUEST_INVALID, + details: { turnIndex: 5, availableTurns: 3 }, + }); + expect(await sessionDirExists(pair.v1Home, 'session_parity_fork_oob')).toBe(false); + expect(await sessionDirExists(pair.v2Home, 'session_parity_fork_oob')).toBe(false); + } finally { + await closeSessionPair(pair); + } + }); + it('resumeSession returns the same session state modulo the pinned agents gap', async () => { const pair = await makeSessionParityPair(); const extraDir = await makeTempDir('kimi-sdk-parity-extra-'); @@ -2511,6 +2771,26 @@ describe('v1↔v2 agent interaction parity', () => { } }); + it('cancel is a silent no-op on an idle session and rejects a missing session identically', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_cancel' }); + const input = { sessionId: 'session_parity_agent_cancel' } as const; + // No turn is running: cancel resolves without an effect on both engines. + await Promise.all([pair.v1.cancel(input), pair.v2.cancel(input)]); + await expect(pair.v1.cancel({ sessionId: 'session_missing' })).rejects.toMatchObject({ + code: ErrorCodes.SESSION_NOT_FOUND, + }); + await expect(pair.v2.cancel({ sessionId: 'session_missing' })).rejects.toMatchObject({ + code: ErrorCodes.SESSION_NOT_FOUND, + }); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + it('activateSkill renders, launches, and rejects identically', async () => { const restoreEnv = scrubConfigEnv(); const pair = await makeSessionParityPair();