mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-16 04:05:58 +00:00
feat(vscode): switch the extension to the v2 engine with a rollback switch (#2916)
Some checks are pending
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-vscode-legacy (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Some checks are pending
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-vscode-legacy (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
The extension now runs on the agent-core-v2 engine by default. The interface, sessions, and workflows do not change. Two rollback paths exist, and one function makes the decision (config/vscode-settings.ts): - the kimi.useAgentCoreV1 setting (temporary; a window reload applies the change); - the KIMI_CODE_LEGACY_FLAG environment variable, which wins over the setting and has the same semantics as in the CLI. An engine startup failure shows an explicit error that names the rollback setting. There is no silent fallback. CI runs the extension test suite on both engines: the sharded run covers the default v2 engine, and a new test-vscode-legacy job reruns the suite with KIMI_CODE_LEGACY_FLAG=1. To keep the v2 path identical to v1 for every method the extension uses, this change also completes the v2-backed SDK client and the v2 engine: - Implement session deletion in the v2 SDK client. - Implement fork truncation at a turn index in the v2 engine, with the same rules as v1, and reject a fork while the source session has an active turn. - Stop the session-level /init run when the turn is cancelled, as v1 does. - Read session metadata without the archived field as not-archived, so sessions written by the v1 engine open correctly. The SDK parity suite now covers session deletion, cancel, and fork truncation. The known-difference list for the methods the extension uses is empty.
This commit is contained in:
parent
741708f948
commit
7475c2e2e3
25 changed files with 1410 additions and 77 deletions
5
.changeset/cancel-init-run.md
Normal file
5
.changeset/cancel-init-run.md
Normal file
|
|
@ -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.
|
||||
5
.changeset/fork-busy-rejection.md
Normal file
5
.changeset/fork-busy-rejection.md
Normal file
|
|
@ -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.
|
||||
5
.changeset/sdk-v2-cancel-init.md
Normal file
5
.changeset/sdk-v2-cancel-init.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code-sdk": patch
|
||||
---
|
||||
|
||||
Cascade turn cancellation to the session-level /init run in the v2-backed client.
|
||||
5
.changeset/sdk-v2-delete-session.md
Normal file
5
.changeset/sdk-v2-delete-session.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code-sdk": patch
|
||||
---
|
||||
|
||||
Implement session deletion in the v2-backed client.
|
||||
5
.changeset/sdk-v2-fork-turn.md
Normal file
5
.changeset/sdk-v2-fork-turn.md
Normal file
|
|
@ -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.
|
||||
5
.changeset/vscode-engine-v2.md
Normal file
5
.changeset/vscode-engine-v2.md
Normal file
|
|
@ -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.
|
||||
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Record<string, string | undefined>>,
|
||||
): 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<boolean>("useAgentCoreV1", false), process.env);
|
||||
},
|
||||
|
||||
getExtensionConfig(): ExtensionConfig {
|
||||
return {
|
||||
yoloMode: this.yoloMode,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@moonshot-ai/kimi-code-sdk")>();
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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<R
|
|||
const runtime = new KimiRuntime({
|
||||
version,
|
||||
homeDir,
|
||||
// The dual-engine CI matrix reruns this suite with KIMI_CODE_LEGACY_FLAG=1;
|
||||
// the vscode mock above keeps the setting itself at its default.
|
||||
useAgentCoreV1: VSCodeSettings.useAgentCoreV1,
|
||||
broadcast: (event: string, data: unknown, webviewId?: string) => {
|
||||
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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@moonshot-ai/kimi-code-sdk")>();
|
||||
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();
|
||||
|
||||
|
|
|
|||
71
apps/vscode/test/vscode-settings.test.ts
Normal file
71
apps/vscode/test/vscode-settings.test.ts
Normal file
|
|
@ -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<string, unknown>() }));
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<number> {
|
||||
const pending: number[] = [];
|
||||
const retained = new Set<number>();
|
||||
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<string, unknown> | undefined {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
|
@ -51,6 +51,11 @@ export interface ForkSessionOptions {
|
|||
readonly newSessionId?: string;
|
||||
readonly title?: string;
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
/**
|
||||
* 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 {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<WireRecord>(
|
||||
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<WireRecord[]> {
|
||||
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<WireRecord>(
|
||||
agentScopeOf(sessionScopeOf(this.handlerScope, sourceSessionId), agentId),
|
||||
AGENT_WIRE_RECORD_KEY,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async pruneTruncatedForkFiles(
|
||||
targetSessionDir: string,
|
||||
agentIds: readonly string[],
|
||||
retainedAgentIds: readonly string[],
|
||||
): Promise<void> {
|
||||
const retained = new Set(retainedAgentIds);
|
||||
const removals: Promise<void>[] = [];
|
||||
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<void> {
|
||||
let entries: readonly HostDirEntry[];
|
||||
try {
|
||||
|
|
@ -686,7 +779,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
|
|||
): Promise<void> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -351,6 +351,136 @@ function appendLogStoreStub(): IAppendLogStore {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory wire store for fork tests: `wires` maps `<sessionId>/<agentId>`
|
||||
* 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<Record<string, readonly unknown[]>>): {
|
||||
readonly store: IAppendLogStore;
|
||||
readonly rewritten: Map<string, unknown[]>;
|
||||
} {
|
||||
const rewritten = new Map<string, unknown[]>();
|
||||
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: <R>(scope: string, key: string): AsyncIterable<R> => {
|
||||
const records = wires[keyOf(scope, key)] ?? [];
|
||||
return (async function* () {
|
||||
for (const record of records) {
|
||||
yield record as R;
|
||||
}
|
||||
})();
|
||||
},
|
||||
rewrite: <R>(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<string, unknown> {
|
||||
return { type: 'metadata', protocol_version: '1.5', created_at: time };
|
||||
}
|
||||
|
||||
function turnPromptRecord(text: string, time: number): Record<string, unknown> {
|
||||
return {
|
||||
type: 'turn.prompt',
|
||||
input: [{ type: 'text', text }],
|
||||
origin: { kind: 'user' },
|
||||
time,
|
||||
};
|
||||
}
|
||||
|
||||
function turnSteerRecord(text: string, time: number): Record<string, unknown> {
|
||||
return {
|
||||
type: 'turn.steer',
|
||||
input: [{ type: 'text', text }],
|
||||
origin: { kind: 'user' },
|
||||
time,
|
||||
};
|
||||
}
|
||||
|
||||
function userTurnRecord(text: string, time: number): Record<string, unknown> {
|
||||
return {
|
||||
type: 'context.append_message',
|
||||
message: { role: 'user', content: [{ type: 'text', text }] },
|
||||
time,
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessageRecord(text: string, time: number): Record<string, unknown> {
|
||||
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<string, unknown>[] {
|
||||
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<string, unknown>,
|
||||
updates: Array<Record<string, unknown>>,
|
||||
): ISessionMetadata {
|
||||
return {
|
||||
...metadataStub(),
|
||||
read: () => Promise.resolve(document as never),
|
||||
update: (patch) => {
|
||||
updates.push(patch as Record<string, unknown>);
|
||||
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<Record<string, unknown>> = [];
|
||||
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<Record<string, unknown>> = [];
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<string, { deadline?: number; turns: number }>();
|
||||
/**
|
||||
|
|
@ -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: <source>") — 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: <source>") — pass an explicit title for
|
||||
* identical results.
|
||||
*/
|
||||
override async forkSession(input: ForkSessionInput): Promise<SessionSummary> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
// 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<void> {
|
||||
const session = this.requireLiveSession(input.sessionId);
|
||||
session.accessor.get(ISessionInitService).cancelInit();
|
||||
const agent = await this.agentFacade(input.sessionId);
|
||||
return agent.cancel();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 `<home>/sessions/<bucket>/<id>`. */
|
||||
async function sessionDirExists(homeDir: string, sessionId: string): Promise<boolean> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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 `<home>/sessions/<workdir-key>/<id>`).
|
||||
*/
|
||||
async function sessionDirPath(home: HomePair, sessionId: string): Promise<string> {
|
||||
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<void> {
|
||||
const mainWire = await readFile(join(sessionDir, 'agents', 'main', 'wire.jsonl'), 'utf-8');
|
||||
const envelope = {
|
||||
...(JSON.parse(mainWire.split('\n', 1)[0]!) as Record<string, unknown>),
|
||||
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<string, unknown>;
|
||||
const agents = (state['agents'] ?? {}) as Record<string, unknown>;
|
||||
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 `<home>/sessions/<workdir-key>/<id>`).
|
||||
*/
|
||||
async function sessionDirExists(home: HomePair, sessionId: string): Promise<boolean> {
|
||||
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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue