1495 lines
56 KiB
TypeScript
1495 lines
56 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
createActivitySignal,
|
|
type Session,
|
|
type RuntimeHandle,
|
|
type AgentLaunchConfig,
|
|
type ProjectConfig,
|
|
} from "@aoagents/ao-core";
|
|
import {
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
readFileSync,
|
|
utimesSync,
|
|
symlinkSync,
|
|
realpathSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { createHash } from "node:crypto";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock homedir() so kimiShareDir() points at a per-test temp dir.
|
|
// fakeHome is assigned in beforeEach.
|
|
// ---------------------------------------------------------------------------
|
|
let fakeHome = "";
|
|
vi.mock("node:os", async (importOriginal) => {
|
|
const actual = (await importOriginal()) as Record<string, unknown>;
|
|
return {
|
|
...actual,
|
|
homedir: () => fakeHome,
|
|
};
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Core activity-log mocks — only the shared helpers, not fs primitives.
|
|
// isWindows is mocked so platform-aware tests can flip it without affecting
|
|
// the host platform. Default false matches the POSIX assertions used
|
|
// throughout this file.
|
|
// ---------------------------------------------------------------------------
|
|
const {
|
|
mockReadLastActivityEntry,
|
|
mockRecordTerminalActivity,
|
|
mockSetupPathWrapperWorkspace,
|
|
mockIsWindows,
|
|
} = vi.hoisted(() => ({
|
|
mockReadLastActivityEntry: vi.fn().mockResolvedValue(null),
|
|
mockRecordTerminalActivity: vi.fn().mockResolvedValue(undefined),
|
|
mockSetupPathWrapperWorkspace: vi.fn().mockResolvedValue(undefined),
|
|
mockIsWindows: vi.fn(() => false),
|
|
}));
|
|
|
|
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
|
const actual = (await importOriginal()) as Record<string, unknown>;
|
|
return {
|
|
...actual,
|
|
readLastActivityEntry: mockReadLastActivityEntry,
|
|
recordTerminalActivity: mockRecordTerminalActivity,
|
|
setupPathWrapperWorkspace: mockSetupPathWrapperWorkspace,
|
|
isWindows: mockIsWindows,
|
|
};
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// child_process mocks — tmux/ps for isProcessRunning, execFileSync for detect.
|
|
// ---------------------------------------------------------------------------
|
|
const { mockExecFileAsync } = vi.hoisted(() => ({
|
|
mockExecFileAsync: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("node:child_process", () => {
|
|
const execFile = Object.assign((..._args: unknown[]) => {}, {
|
|
[Symbol.for("nodejs.util.promisify.custom")]: mockExecFileAsync,
|
|
});
|
|
return {
|
|
execFile,
|
|
execFileSync: vi.fn(),
|
|
};
|
|
});
|
|
|
|
import {
|
|
create,
|
|
manifest,
|
|
default as defaultExport,
|
|
detect,
|
|
_resetSessionMatchCache,
|
|
} from "./index.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Kimi on-disk layout helpers — mirrors the real kimi-cli 1.38 storage
|
|
// (~/.kimi/sessions/<md5(cwd)>/<session-uuid>/{context,wire}.jsonl).
|
|
// ---------------------------------------------------------------------------
|
|
function workspaceHash(workspacePath: string): string {
|
|
return createHash("md5").update(workspacePath).digest("hex");
|
|
}
|
|
|
|
function writeKimiSession(
|
|
workspacePath: string,
|
|
sessionId: string,
|
|
opts: { contextAgeMs?: number; wireAgeMs?: number; wireContent?: string } = {},
|
|
): string {
|
|
const bucket = join(fakeHome, ".kimi", "sessions", workspaceHash(workspacePath));
|
|
const sessionDir = join(bucket, sessionId);
|
|
mkdirSync(sessionDir, { recursive: true });
|
|
|
|
const contextPath = join(sessionDir, "context.jsonl");
|
|
const wirePath = join(sessionDir, "wire.jsonl");
|
|
writeFileSync(contextPath, '{"role":"_system_prompt","content":"hello"}\n');
|
|
writeFileSync(
|
|
wirePath,
|
|
opts.wireContent ??
|
|
[
|
|
'{"type":"metadata","protocol_version":"1.9"}',
|
|
'{"timestamp":1776875930,"message":{"type":"TurnBegin","payload":{"user_input":"say hello"}}}',
|
|
'{"timestamp":1776875931,"message":{"type":"TurnEnd","payload":{}}}',
|
|
].join("\n") + "\n",
|
|
);
|
|
|
|
if (opts.contextAgeMs !== undefined) {
|
|
const ts = new Date(Date.now() - opts.contextAgeMs);
|
|
utimesSync(contextPath, ts, ts);
|
|
}
|
|
if (opts.wireAgeMs !== undefined) {
|
|
const ts = new Date(Date.now() - opts.wireAgeMs);
|
|
utimesSync(wirePath, ts, ts);
|
|
}
|
|
return sessionDir;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test helpers
|
|
// ---------------------------------------------------------------------------
|
|
function makeSession(overrides: Partial<Session> = {}): Session {
|
|
// Minimal Session stub; the plugin never reads `lifecycle`.
|
|
const base = {
|
|
id: "kimi-1",
|
|
projectId: "test-project",
|
|
status: "working",
|
|
activity: "active",
|
|
activitySignal: createActivitySignal("valid", {
|
|
activity: "active",
|
|
timestamp: new Date(),
|
|
source: "native",
|
|
}),
|
|
branch: "feat/test",
|
|
issueId: null,
|
|
pr: null,
|
|
workspacePath: "/workspace/test",
|
|
runtimeHandle: null,
|
|
agentInfo: null,
|
|
createdAt: new Date(),
|
|
lastActivityAt: new Date(),
|
|
metadata: {},
|
|
...overrides,
|
|
};
|
|
return base as unknown as Session;
|
|
}
|
|
|
|
function makeTmuxHandle(id = "test-session"): RuntimeHandle {
|
|
return { id, runtimeName: "tmux", data: {} };
|
|
}
|
|
|
|
function makeProcessHandle(pid?: number): RuntimeHandle {
|
|
return { id: "proc-1", runtimeName: "process", data: pid !== undefined ? { pid } : {} };
|
|
}
|
|
|
|
function makeLaunchConfig(overrides: Partial<AgentLaunchConfig> = {}): AgentLaunchConfig {
|
|
return {
|
|
sessionId: "sess-1",
|
|
projectConfig: {
|
|
name: "my-project",
|
|
repo: "owner/repo",
|
|
path: "/workspace/repo",
|
|
defaultBranch: "main",
|
|
sessionPrefix: "my",
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeProject(overrides: Partial<ProjectConfig> = {}): ProjectConfig {
|
|
return {
|
|
name: "proj",
|
|
repo: "o/r",
|
|
path: "/p",
|
|
defaultBranch: "main",
|
|
sessionPrefix: "p",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function mockTmuxWithProcess(processName: string, found = true) {
|
|
mockExecFileAsync.mockImplementation((cmd: string) => {
|
|
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys005\n", stderr: "" });
|
|
if (cmd === "ps") {
|
|
const line = found ? ` 444 ttys005 ${processName}` : " 444 ttys005 zsh";
|
|
return Promise.resolve({
|
|
stdout: ` PID TT ARGS\n${line}\n`,
|
|
stderr: "",
|
|
});
|
|
}
|
|
return Promise.reject(new Error(`unexpected ${cmd}`));
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
_resetSessionMatchCache();
|
|
mockReadLastActivityEntry.mockResolvedValue(null);
|
|
mockRecordTerminalActivity.mockResolvedValue(undefined);
|
|
mockSetupPathWrapperWorkspace.mockResolvedValue(undefined);
|
|
mockIsWindows.mockReturnValue(false);
|
|
// realpath so that on macOS we get /private/var/... instead of /var/...
|
|
// (/var is a symlink on macOS). Without this, the plugin hashes
|
|
// realpath(workspacePath) while the test wrote under the unresolved path,
|
|
// and the hashes diverge.
|
|
fakeHome = realpathSync(mkdtempSync(join(tmpdir(), "kimicode-test-")));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(fakeHome, { recursive: true, force: true });
|
|
});
|
|
|
|
// =============================================================================
|
|
// Manifest & exports
|
|
// =============================================================================
|
|
describe("manifest & exports", () => {
|
|
it("has correct manifest", () => {
|
|
expect(manifest).toEqual({
|
|
name: "kimicode",
|
|
slot: "agent",
|
|
description: "Agent plugin: Kimi Code CLI (MoonshotAI)",
|
|
version: "0.1.0",
|
|
displayName: "Kimi Code",
|
|
});
|
|
});
|
|
|
|
it("create() returns agent with correct name and processName", () => {
|
|
const agent = create();
|
|
expect(agent.name).toBe("kimicode");
|
|
expect(agent.processName).toBe("kimi");
|
|
});
|
|
|
|
it("uses inline prompt delivery (kimi's -p does not exit after prompt)", () => {
|
|
const agent = create();
|
|
expect(agent.promptDelivery === undefined || agent.promptDelivery === "inline").toBe(true);
|
|
});
|
|
|
|
it("default export is a valid PluginModule", () => {
|
|
expect(defaultExport.manifest).toBe(manifest);
|
|
expect(typeof defaultExport.create).toBe("function");
|
|
expect(typeof defaultExport.detect).toBe("function");
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// getLaunchCommand
|
|
// =============================================================================
|
|
describe("getLaunchCommand", () => {
|
|
const agent = create();
|
|
|
|
it("generates base command with --work-dir", () => {
|
|
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe(
|
|
"kimi --work-dir '/workspace/repo'",
|
|
);
|
|
});
|
|
|
|
it("adds --yolo when permissions=permissionless", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "permissionless" }));
|
|
expect(cmd).toContain("--yolo");
|
|
});
|
|
|
|
it("adds --yolo when permissions=auto-edit", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "auto-edit" }));
|
|
expect(cmd).toContain("--yolo");
|
|
});
|
|
|
|
it("omits --yolo when permissions=suggest", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "suggest" }));
|
|
expect(cmd).not.toContain("--yolo");
|
|
});
|
|
|
|
it("passes --model shell-escaped", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "kimi-k2" }));
|
|
expect(cmd).toContain("--model 'kimi-k2'");
|
|
});
|
|
|
|
it("inlines systemPromptFile contents into --prompt (kimi's --agent-file requires YAML)", () => {
|
|
const sysFile = join(fakeHome, "system-prompt.md");
|
|
writeFileSync(sysFile, "# Orchestrator\n\nYou coordinate agents.", "utf-8");
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPromptFile: sysFile }));
|
|
expect(cmd).not.toContain("--agent-file");
|
|
expect(cmd).toContain("--prompt");
|
|
expect(cmd).toContain("You coordinate agents.");
|
|
});
|
|
|
|
it("concatenates systemPromptFile + prompt with separator when both set", () => {
|
|
const sysFile = join(fakeHome, "system-prompt.md");
|
|
writeFileSync(sysFile, "SYSTEM_INSTRUCTIONS", "utf-8");
|
|
const cmd = agent.getLaunchCommand(
|
|
makeLaunchConfig({ systemPromptFile: sysFile, prompt: "USER_TASK" }),
|
|
);
|
|
expect(cmd).toContain("SYSTEM_INSTRUCTIONS");
|
|
expect(cmd).toContain("USER_TASK");
|
|
// System instructions appear before the user task
|
|
expect(cmd.indexOf("SYSTEM_INSTRUCTIONS")).toBeLessThan(cmd.indexOf("USER_TASK"));
|
|
});
|
|
|
|
it("passes --prompt inline (kimi's -p is a prompt string, not a mode switch)", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "do the thing" }));
|
|
expect(cmd).toContain("--prompt 'do the thing'");
|
|
});
|
|
|
|
it("shell-escapes prompts with special characters", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "it's complicated" }));
|
|
// shellEscape uses POSIX escape on Unix and PowerShell-style on Windows.
|
|
// The host platform decides which one we hit at test time.
|
|
if (process.platform === "win32") {
|
|
expect(cmd).toContain("--prompt 'it''s complicated'");
|
|
} else {
|
|
expect(cmd).toContain("--prompt 'it'\\''s complicated'");
|
|
}
|
|
});
|
|
|
|
it("passes --agent when config.subagent is set", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ subagent: "okabe" }));
|
|
expect(cmd).toContain("--agent 'okabe'");
|
|
});
|
|
|
|
it("combines model + yolo + agent + inlined system prompt + prompt", () => {
|
|
const sysFile = join(fakeHome, "system-prompt.md");
|
|
writeFileSync(sysFile, "SYS", "utf-8");
|
|
const cmd = agent.getLaunchCommand(
|
|
makeLaunchConfig({
|
|
permissions: "permissionless",
|
|
model: "kimi-k2",
|
|
subagent: "default",
|
|
systemPromptFile: sysFile,
|
|
prompt: "Go",
|
|
}),
|
|
);
|
|
expect(cmd).toContain("--yolo");
|
|
expect(cmd).toContain("--model 'kimi-k2'");
|
|
expect(cmd).toContain("--agent 'default'");
|
|
expect(cmd).not.toContain("--agent-file");
|
|
expect(cmd).toContain("SYS");
|
|
expect(cmd).toContain("Go");
|
|
});
|
|
|
|
// Worktree-mode parity: when AO runs in worktree workspace mode,
|
|
// workspacePath (per-session checkout) differs from projectConfig.path
|
|
// (the original repo root). --work-dir must take the workspacePath so
|
|
// kimi's md5(cwd) bucket matches the one session-discovery scans.
|
|
it("--work-dir prefers workspacePath over projectConfig.path", () => {
|
|
const cmd = agent.getLaunchCommand(
|
|
makeLaunchConfig({
|
|
workspacePath: "/worktrees/sess-1",
|
|
}),
|
|
);
|
|
expect(cmd).toContain("--work-dir '/worktrees/sess-1'");
|
|
expect(cmd).not.toContain("/workspace/repo");
|
|
});
|
|
|
|
it("--work-dir falls back to projectConfig.path when workspacePath is undefined", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ workspacePath: undefined }));
|
|
expect(cmd).toContain("--work-dir '/workspace/repo'");
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// getEnvironment
|
|
// =============================================================================
|
|
describe("getEnvironment", () => {
|
|
const agent = create();
|
|
|
|
it("sets AO_SESSION_ID", () => {
|
|
const env = agent.getEnvironment(makeLaunchConfig());
|
|
expect(env["AO_SESSION_ID"]).toBe("sess-1");
|
|
});
|
|
|
|
it("sets AO_ISSUE_ID only when provided", () => {
|
|
expect(agent.getEnvironment(makeLaunchConfig()).AO_ISSUE_ID).toBeUndefined();
|
|
expect(agent.getEnvironment(makeLaunchConfig({ issueId: "GH-42" })).AO_ISSUE_ID).toBe(
|
|
"GH-42",
|
|
);
|
|
});
|
|
|
|
// PATH and GH_PATH are not set here — session-manager injects them for
|
|
// every agent (see session-manager.ts: buildAgentPath + PREFERRED_GH_PATH).
|
|
// Setting them locally would just be overwritten and cause the values to
|
|
// diverge from the centralized Windows-aware logic.
|
|
it("does not set PATH or GH_PATH (session-manager owns those)", () => {
|
|
const env = agent.getEnvironment(makeLaunchConfig());
|
|
expect(env["PATH"]).toBeUndefined();
|
|
expect(env["GH_PATH"]).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// detectActivity
|
|
// =============================================================================
|
|
describe("detectActivity", () => {
|
|
const agent = create();
|
|
|
|
it("idle for empty/whitespace output", () => {
|
|
expect(agent.detectActivity("")).toBe("idle");
|
|
expect(agent.detectActivity(" \n ")).toBe("idle");
|
|
});
|
|
|
|
it("idle when generic shell/REPL prompt is visible", () => {
|
|
expect(agent.detectActivity("tokens: 1k\n> ")).toBe("idle");
|
|
expect(agent.detectActivity("tokens: 1k\n$ ")).toBe("idle");
|
|
});
|
|
|
|
it("idle when kimi-specific prompt is visible", () => {
|
|
expect(agent.detectActivity("output\nkimi> ")).toBe("idle");
|
|
expect(agent.detectActivity("output\nkimi: ")).toBe("idle");
|
|
});
|
|
|
|
it("waiting_input for (Y)es/(N)o confirmations", () => {
|
|
expect(agent.detectActivity("Allow edit to foo.ts?\n(Y)es/(N)o")).toBe("waiting_input");
|
|
});
|
|
|
|
it("waiting_input for [y/n] style confirmations", () => {
|
|
expect(agent.detectActivity("Continue? [y/n]")).toBe("waiting_input");
|
|
});
|
|
|
|
it("waiting_input for a bare 'approve?' prompt line (not mid-sentence)", () => {
|
|
expect(agent.detectActivity("Run rm -rf build/\napprove?")).toBe("waiting_input");
|
|
});
|
|
|
|
it("does NOT match 'approve' in agent narration (false-positive guard)", () => {
|
|
const narration = "I approve of this approach and will proceed.\nReading src/index.ts";
|
|
expect(agent.detectActivity(narration)).toBe("active");
|
|
});
|
|
|
|
it("waiting_input for 'Do you want to proceed?' prompts", () => {
|
|
expect(agent.detectActivity("This will modify 3 files.\nDo you want to proceed?")).toBe(
|
|
"waiting_input",
|
|
);
|
|
});
|
|
|
|
it("blocked on error: prefix", () => {
|
|
expect(agent.detectActivity("error: failed to parse response\n")).toBe("blocked");
|
|
});
|
|
|
|
it("blocked on line-anchored 'failed to authenticate'", () => {
|
|
expect(agent.detectActivity("failed to authenticate with Kimi API\n")).toBe("blocked");
|
|
});
|
|
|
|
it("does NOT match 'failed to connect' mid-sentence (false-positive guard)", () => {
|
|
const narration =
|
|
"Earlier I failed to connect on the first try, but the retry worked.\nGenerating code...";
|
|
expect(agent.detectActivity(narration)).toBe("active");
|
|
});
|
|
|
|
it("active for ongoing work output", () => {
|
|
expect(agent.detectActivity("Generating code...\nReading src/index.ts")).toBe("active");
|
|
});
|
|
|
|
it("waiting_input wins when confirmation is shown above a re-rendered kimi> prompt (#6)", () => {
|
|
// Real kimi terminal output: a confirmation request appears, then the
|
|
// UI re-renders `kimi>` on the last line as part of the prompt chrome.
|
|
// Old ordering (idle-first) misclassified this as idle and left the
|
|
// session hanging. Actionable states MUST win.
|
|
const output = [
|
|
"Allow file write?",
|
|
"(Y)es/(N)o",
|
|
"kimi> ",
|
|
].join("\n");
|
|
expect(agent.detectActivity(output)).toBe("waiting_input");
|
|
});
|
|
|
|
it("blocked wins over idle-prompt tail (#6)", () => {
|
|
const output = ["error: LLM quota exceeded", "kimi> "].join("\n");
|
|
expect(agent.detectActivity(output)).toBe("blocked");
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// isProcessRunning
|
|
// =============================================================================
|
|
describe("isProcessRunning", () => {
|
|
const agent = create();
|
|
|
|
it("returns true when `kimi` is running on the pane TTY", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true);
|
|
});
|
|
|
|
it("returns true when the dot-prefixed shim `.kimi` is running", async () => {
|
|
mockTmuxWithProcess("/usr/local/bin/.kimi");
|
|
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true);
|
|
});
|
|
|
|
it("returns true when invoked as `uv run kimi`", async () => {
|
|
mockTmuxWithProcess("uv run kimi --yolo");
|
|
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true);
|
|
});
|
|
|
|
it("returns true when invoked as `python -m kimi`", async () => {
|
|
mockTmuxWithProcess("python -m kimi --print");
|
|
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true);
|
|
});
|
|
|
|
it("returns false when `kimi` appears only in argv[>0] (not as the executable)", async () => {
|
|
// A user running `cat kimi.log` or `vim ~/.kimi/config.toml` on the pane
|
|
// must NOT count as kimi running.
|
|
mockTmuxWithProcess("cat kimi.log", true);
|
|
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
|
|
});
|
|
|
|
it("returns false for `/usr/bin/vim ~/.kimi/config.toml`", async () => {
|
|
mockTmuxWithProcess("/usr/bin/vim /home/harsh/.kimi/config.toml", true);
|
|
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
|
|
});
|
|
|
|
it("returns false when kimi is not on the pane TTY", async () => {
|
|
mockTmuxWithProcess("zsh", false);
|
|
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
|
|
});
|
|
|
|
it("returns true for process runtime with alive PID", async () => {
|
|
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
|
expect(await agent.isProcessRunning(makeProcessHandle(123))).toBe(true);
|
|
killSpy.mockRestore();
|
|
});
|
|
|
|
it("returns false for process runtime with dead PID", async () => {
|
|
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
|
|
throw new Error("ESRCH");
|
|
});
|
|
expect(await agent.isProcessRunning(makeProcessHandle(456))).toBe(false);
|
|
killSpy.mockRestore();
|
|
});
|
|
|
|
it("returns true for PID that throws EPERM (permission denied ≠ dead)", async () => {
|
|
const err = Object.assign(new Error("EPERM"), { code: "EPERM" });
|
|
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
|
|
throw err;
|
|
});
|
|
expect(await agent.isProcessRunning(makeProcessHandle(789))).toBe(true);
|
|
killSpy.mockRestore();
|
|
});
|
|
|
|
it("returns false on tmux command failure", async () => {
|
|
mockExecFileAsync.mockRejectedValue(new Error("tmux gone"));
|
|
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
|
|
});
|
|
|
|
it("returns false for unknown runtime without PID", async () => {
|
|
const handle: RuntimeHandle = { id: "x", runtimeName: "other", data: {} };
|
|
expect(await agent.isProcessRunning(handle)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// getActivityState — the mandatory 5-step cascade plus ordering + decay
|
|
// =============================================================================
|
|
describe("getActivityState", () => {
|
|
const agent = create();
|
|
// workspace is a per-test path under fakeHome that is intentionally NOT
|
|
// created on disk. resolveWorkspacePath stat()s before realpath()ing —
|
|
// a path that doesn't exist on the host falls back to the raw string, so
|
|
// production's hash matches the test fixture's hash. fakeHome is unique
|
|
// per test (mkdtemp), so this stays stable across hosts that may have
|
|
// unrelated dirs at literal paths like "/workspace".
|
|
let workspace: string;
|
|
beforeEach(() => {
|
|
workspace = join(fakeHome, "wsdir");
|
|
});
|
|
|
|
it("1. returns exited when process is not running", async () => {
|
|
mockTmuxWithProcess("zsh", false);
|
|
const result = await agent.getActivityState(
|
|
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
|
|
);
|
|
expect(result?.state).toBe("exited");
|
|
});
|
|
|
|
it("1b. returns exited when runtimeHandle is null", async () => {
|
|
const result = await agent.getActivityState(makeSession({ runtimeHandle: null }));
|
|
expect(result?.state).toBe("exited");
|
|
});
|
|
|
|
it("2. returns waiting_input from AO activity JSONL", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
mockReadLastActivityEntry.mockResolvedValueOnce({
|
|
entry: { ts: new Date().toISOString(), state: "waiting_input", source: "terminal" },
|
|
modifiedAt: new Date(),
|
|
});
|
|
|
|
const result = await agent.getActivityState(
|
|
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
|
|
);
|
|
expect(result?.state).toBe("waiting_input");
|
|
});
|
|
|
|
it("3. returns blocked from AO activity JSONL", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
mockReadLastActivityEntry.mockResolvedValueOnce({
|
|
entry: { ts: new Date().toISOString(), state: "blocked", source: "terminal" },
|
|
modifiedAt: new Date(),
|
|
});
|
|
|
|
const result = await agent.getActivityState(
|
|
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
|
|
);
|
|
expect(result?.state).toBe("blocked");
|
|
});
|
|
|
|
it("4. returns active from native signal when session files are fresh", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
writeKimiSession(workspace, "sess-abc"); // fresh (age ~ 0)
|
|
|
|
const result = await agent.getActivityState(
|
|
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
|
|
);
|
|
expect(result?.state).toBe("active");
|
|
});
|
|
|
|
it("4b. returns ready from native signal when mtime falls in the ready window", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
writeKimiSession(workspace, "sess-abc", {
|
|
contextAgeMs: 2 * 60 * 1000,
|
|
wireAgeMs: 2 * 60 * 1000,
|
|
});
|
|
|
|
// AO session predates the kimi session — the createdAt floor must not
|
|
// reject a legitimate mtime that's still within the ready window.
|
|
const result = await agent.getActivityState(
|
|
makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: workspace,
|
|
createdAt: new Date(Date.now() - 10 * 60 * 1000),
|
|
}),
|
|
);
|
|
expect(result?.state).toBe("ready");
|
|
});
|
|
|
|
it("4c. returns idle from native signal when mtime is older than readyThreshold", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
writeKimiSession(workspace, "sess-abc", {
|
|
contextAgeMs: 10 * 60 * 1000,
|
|
wireAgeMs: 10 * 60 * 1000,
|
|
});
|
|
|
|
const result = await agent.getActivityState(
|
|
makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: workspace,
|
|
createdAt: new Date(Date.now() - 30 * 60 * 1000),
|
|
}),
|
|
);
|
|
expect(result?.state).toBe("idle");
|
|
});
|
|
|
|
it("cascade: JSONL waiting_input wins over native signal even when a session dir exists", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
writeKimiSession(workspace, "sess-abc"); // would be "active"
|
|
|
|
mockReadLastActivityEntry.mockResolvedValueOnce({
|
|
entry: { ts: new Date().toISOString(), state: "waiting_input", source: "terminal" },
|
|
modifiedAt: new Date(),
|
|
});
|
|
|
|
const result = await agent.getActivityState(
|
|
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
|
|
);
|
|
expect(result?.state).toBe("waiting_input");
|
|
});
|
|
|
|
it("native signal prefers the fresher of context.jsonl vs wire.jsonl mtimes", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
// wire.jsonl is fresh, context.jsonl is stale → mtime = wire (fresh) → active.
|
|
writeKimiSession(workspace, "sess-abc", {
|
|
contextAgeMs: 10 * 60 * 1000,
|
|
wireAgeMs: 0,
|
|
});
|
|
|
|
const result = await agent.getActivityState(
|
|
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
|
|
);
|
|
expect(result?.state).toBe("active");
|
|
});
|
|
|
|
it("picks the most recently modified session UUID when multiple exist in the bucket", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
writeKimiSession(workspace, "sess-old", {
|
|
contextAgeMs: 10 * 60 * 1000,
|
|
wireAgeMs: 10 * 60 * 1000,
|
|
});
|
|
writeKimiSession(workspace, "sess-new"); // fresh
|
|
|
|
const result = await agent.getActivityState(
|
|
makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: workspace,
|
|
createdAt: new Date(Date.now() - 30 * 60 * 1000),
|
|
}),
|
|
);
|
|
expect(result?.state).toBe("active");
|
|
});
|
|
|
|
it("ignores UUIDs older than session.createdAt (stray/crash leftovers)", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
// A UUID dir that belonged to a previous AO session. Must not attach
|
|
// to the current session — its summary/UUID would be wrong.
|
|
writeKimiSession(workspace, "old-leftover", {
|
|
contextAgeMs: 60 * 60 * 1000,
|
|
wireAgeMs: 60 * 60 * 1000,
|
|
});
|
|
|
|
const result = await agent.getActivityState(
|
|
makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: workspace,
|
|
// AO session started just now, 60min AFTER the leftover session's
|
|
// last activity → leftover fails the createdAt - 60s floor.
|
|
createdAt: new Date(),
|
|
}),
|
|
);
|
|
// Falls through to the JSONL fallback (which is null in this test).
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("pin file (.ao/kimi-session-id.json) wins over recency", async () => {
|
|
const realWorkspace = join(fakeHome, "workspace-pin-1");
|
|
mkdirSync(join(realWorkspace, ".ao"), { recursive: true });
|
|
writeKimiSession(realWorkspace, "sess-newer"); // newer, but not pinned
|
|
writeKimiSession(realWorkspace, "pinned-uuid", {
|
|
contextAgeMs: 2 * 60 * 1000,
|
|
wireAgeMs: 2 * 60 * 1000,
|
|
});
|
|
writeFileSync(
|
|
join(realWorkspace, ".ao", "kimi-session-id.json"),
|
|
JSON.stringify({ sessionId: "pinned-uuid", pinnedAt: "2026-04-01T00:00:00.000Z" }),
|
|
);
|
|
|
|
mockTmuxWithProcess("kimi");
|
|
const info = await agent.getSessionInfo(
|
|
makeSession({ workspacePath: realWorkspace }),
|
|
);
|
|
expect(info?.agentSessionId).toBe("pinned-uuid");
|
|
});
|
|
|
|
it("first successful match writes the pin file (locks in the recency winner)", async () => {
|
|
const realWorkspace = join(fakeHome, "workspace-pin-write");
|
|
mkdirSync(realWorkspace, { recursive: true });
|
|
writeKimiSession(realWorkspace, "ao-spawned");
|
|
|
|
mockTmuxWithProcess("kimi");
|
|
const info = await agent.getSessionInfo(
|
|
makeSession({ workspacePath: realWorkspace }),
|
|
);
|
|
expect(info?.agentSessionId).toBe("ao-spawned");
|
|
|
|
const pin = JSON.parse(
|
|
readFileSync(join(realWorkspace, ".ao", "kimi-session-id.json"), "utf-8"),
|
|
);
|
|
expect(pin.sessionId).toBe("ao-spawned");
|
|
expect(typeof pin.pinnedAt).toBe("string");
|
|
});
|
|
|
|
// Regression: even if a different (e.g. manual `kimi`) UUID becomes the
|
|
// freshest in the bucket later, the pinned UUID stays put.
|
|
it("pin file holds even when a newer non-pinned UUID appears later", async () => {
|
|
const realWorkspace = join(fakeHome, "workspace-pin-stable");
|
|
mkdirSync(join(realWorkspace, ".ao"), { recursive: true });
|
|
writeKimiSession(realWorkspace, "ao-original", {
|
|
contextAgeMs: 2 * 60 * 1000,
|
|
wireAgeMs: 2 * 60 * 1000,
|
|
});
|
|
writeFileSync(
|
|
join(realWorkspace, ".ao", "kimi-session-id.json"),
|
|
JSON.stringify({ sessionId: "ao-original", pinnedAt: "2026-04-01T00:00:00.000Z" }),
|
|
);
|
|
|
|
// A manual kimi run lands a newer UUID in the same bucket.
|
|
writeKimiSession(realWorkspace, "manual-newer");
|
|
|
|
mockTmuxWithProcess("kimi");
|
|
_resetSessionMatchCache();
|
|
const info = await agent.getSessionInfo(
|
|
makeSession({ workspacePath: realWorkspace }),
|
|
);
|
|
expect(info?.agentSessionId).toBe("ao-original");
|
|
});
|
|
|
|
it("negative cache window is short (~2s) — picks up a session that appears mid-poll", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
const session = makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: workspace,
|
|
});
|
|
|
|
// First call: no session dir exists yet → null, cached negatively.
|
|
const first = await agent.getActivityState(session);
|
|
expect(first).toBeNull();
|
|
|
|
// Session appears.
|
|
writeKimiSession(workspace, "sess-abc");
|
|
|
|
// Negative TTL is 2s; wait it out, then expect the match to surface.
|
|
await new Promise((r) => setTimeout(r, 2_100));
|
|
const second = await agent.getActivityState(session);
|
|
expect(second?.state).toBe("active");
|
|
}, 10_000);
|
|
|
|
it("baseline file: pre-existing UUIDs are partitioned out (manual kimi run in same dir)", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
|
|
// Use a real on-disk workspace so the plugin can read the baseline file.
|
|
const realWorkspace = join(fakeHome, "workspace-baseline-1");
|
|
mkdirSync(join(realWorkspace, ".ao"), { recursive: true });
|
|
|
|
writeKimiSession(realWorkspace, "manual-run-uuid"); // pre-AO
|
|
writeKimiSession(realWorkspace, "ao-launched-uuid"); // post-AO
|
|
|
|
writeFileSync(
|
|
join(realWorkspace, ".ao", "kimi-baseline.json"),
|
|
JSON.stringify({
|
|
preExistingUuids: ["manual-run-uuid"],
|
|
capturedAt: new Date().toISOString(),
|
|
}),
|
|
);
|
|
|
|
const info = await agent.getSessionInfo(
|
|
makeSession({ workspacePath: realWorkspace }),
|
|
);
|
|
expect(info?.agentSessionId).toBe("ao-launched-uuid");
|
|
});
|
|
|
|
it("baseline file: returns null if every UUID was pre-existing", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
|
|
const realWorkspace = join(fakeHome, "workspace-baseline-2");
|
|
mkdirSync(join(realWorkspace, ".ao"), { recursive: true });
|
|
|
|
writeKimiSession(realWorkspace, "old-1");
|
|
writeKimiSession(realWorkspace, "old-2");
|
|
|
|
writeFileSync(
|
|
join(realWorkspace, ".ao", "kimi-baseline.json"),
|
|
JSON.stringify({
|
|
preExistingUuids: ["old-1", "old-2"],
|
|
capturedAt: new Date().toISOString(),
|
|
}),
|
|
);
|
|
|
|
const result = await agent.getActivityState(
|
|
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: realWorkspace }),
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("preLaunchSetup writes a baseline that excludes pre-existing UUIDs from later discovery", async () => {
|
|
const realWorkspace = join(fakeHome, "workspace-baseline-3");
|
|
mkdirSync(realWorkspace, { recursive: true });
|
|
|
|
// Pre-existing UUID before AO ever ran.
|
|
writeKimiSession(realWorkspace, "pre-existing");
|
|
|
|
// preLaunchSetup snapshots the bucket — captures "pre-existing" in baseline.
|
|
await agent.preLaunchSetup!(realWorkspace);
|
|
|
|
// Later, AO's kimi spawn creates a new UUID dir.
|
|
writeKimiSession(realWorkspace, "ao-spawned");
|
|
|
|
mockTmuxWithProcess("kimi");
|
|
const info = await agent.getSessionInfo(
|
|
makeSession({ workspacePath: realWorkspace }),
|
|
);
|
|
expect(info?.agentSessionId).toBe("ao-spawned");
|
|
});
|
|
|
|
// Regression: captureKimiBaseline used to run in postLaunchSetup, which
|
|
// races against kimi's own startup — kimi may create its UUID dir before
|
|
// postLaunchSetup runs, in which case the new UUID lands in
|
|
// preExistingUuids and gets filtered out forever, so discovery returns
|
|
// null permanently. Pre-launch capture closes that window.
|
|
it("preLaunchSetup runs before kimi creates UUIDs — new UUID is attached, not partitioned out", async () => {
|
|
const realWorkspace = join(fakeHome, "workspace-baseline-race");
|
|
mkdirSync(realWorkspace, { recursive: true });
|
|
|
|
// Pre-existing UUID (created by a prior run, manual kimi, etc.).
|
|
writeKimiSession(realWorkspace, "pre-existing-uuid");
|
|
|
|
// Step 1: preLaunchSetup runs FIRST, snapshots only the pre-existing UUID.
|
|
await agent.preLaunchSetup!(realWorkspace);
|
|
|
|
// Step 2: simulate kimi launching and creating a new UUID dir.
|
|
writeKimiSession(realWorkspace, "kimi-just-created-this");
|
|
|
|
mockTmuxWithProcess("kimi");
|
|
const info = await agent.getSessionInfo(
|
|
makeSession({ workspacePath: realWorkspace }),
|
|
);
|
|
// The newly-created UUID must NOT have been treated as pre-existing —
|
|
// pre-launch baseline didn't see it. Discovery must attach to it.
|
|
expect(info?.agentSessionId).toBe("kimi-just-created-this");
|
|
});
|
|
|
|
it("preLaunchSetup is write-once — restore preserves the original baseline", async () => {
|
|
const realWorkspace = join(fakeHome, "workspace-baseline-4");
|
|
mkdirSync(join(realWorkspace, ".ao"), { recursive: true });
|
|
|
|
const originalBaseline = {
|
|
preExistingUuids: ["original-pre-existing"],
|
|
capturedAt: "2026-04-01T00:00:00.000Z",
|
|
};
|
|
writeFileSync(
|
|
join(realWorkspace, ".ao", "kimi-baseline.json"),
|
|
JSON.stringify(originalBaseline),
|
|
);
|
|
|
|
// Add a UUID that would be in the bucket on restore (the one AO created
|
|
// during the original launch). If preLaunchSetup overwrote the baseline
|
|
// here, this UUID would be partitioned out as "pre-existing" — wrong.
|
|
writeKimiSession(realWorkspace, "ao-created-on-first-launch");
|
|
|
|
await agent.preLaunchSetup!(realWorkspace);
|
|
|
|
const baselineNow = JSON.parse(
|
|
readFileSync(join(realWorkspace, ".ao", "kimi-baseline.json"), "utf-8"),
|
|
);
|
|
expect(baselineNow.preExistingUuids).toEqual(["original-pre-existing"]);
|
|
expect(baselineNow.capturedAt).toBe("2026-04-01T00:00:00.000Z");
|
|
});
|
|
|
|
it("pinned UUID that no longer exists returns null (no fallback to recency)", async () => {
|
|
const realWorkspace = join(fakeHome, "workspace-pin-missing");
|
|
mkdirSync(join(realWorkspace, ".ao"), { recursive: true });
|
|
writeKimiSession(realWorkspace, "some-other-uuid"); // exists but not pinned
|
|
writeFileSync(
|
|
join(realWorkspace, ".ao", "kimi-session-id.json"),
|
|
JSON.stringify({ sessionId: "pinned-missing", pinnedAt: "2026-04-01T00:00:00.000Z" }),
|
|
);
|
|
|
|
mockTmuxWithProcess("kimi");
|
|
const info = await agent.getSessionInfo(
|
|
makeSession({ workspacePath: realWorkspace }),
|
|
);
|
|
expect(info).toBeNull();
|
|
});
|
|
|
|
it("5. returns active from JSONL entry fallback when native signal is unavailable (fresh entry)", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
// No ~/.kimi/sessions/<hash>/ dir for this workspace — fakeHome is empty.
|
|
|
|
const now = new Date();
|
|
mockReadLastActivityEntry.mockResolvedValueOnce({
|
|
entry: { ts: now.toISOString(), state: "active", source: "terminal" },
|
|
modifiedAt: now,
|
|
});
|
|
|
|
const result = await agent.getActivityState(
|
|
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
|
|
);
|
|
expect(result?.state).toBe("active");
|
|
});
|
|
|
|
it("6. returns idle from JSONL entry fallback with age decay (old entry)", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
|
|
const old = new Date(Date.now() - 10 * 60 * 1000);
|
|
mockReadLastActivityEntry.mockResolvedValueOnce({
|
|
entry: { ts: old.toISOString(), state: "active", source: "terminal" },
|
|
modifiedAt: old,
|
|
});
|
|
|
|
const result = await agent.getActivityState(
|
|
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
|
|
);
|
|
expect(result?.state).toBe("idle");
|
|
});
|
|
|
|
it("7. returns null when both native signal and JSONL are unavailable", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
mockReadLastActivityEntry.mockResolvedValueOnce(null);
|
|
|
|
const result = await agent.getActivityState(
|
|
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when workspacePath is missing (no source of truth)", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
const result = await agent.getActivityState(
|
|
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: null }),
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("resolves symlinked workspace paths before hashing (kimi sees realpath as cwd)", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
// Create a real target dir + a symlink pointing at it. Kimi's process
|
|
// resolves symlinks via os.getcwd(), so its MD5 bucket is keyed by the
|
|
// realpath. Our plugin must match by realpath too.
|
|
const real = join(fakeHome, "workspaces", "real-project");
|
|
mkdirSync(real, { recursive: true });
|
|
const link = join(fakeHome, "workspaces", "link-to-project");
|
|
symlinkSync(real, link);
|
|
|
|
// Write a session under the realpath bucket, then look it up via the
|
|
// symlink path — our resolveWorkspacePath should make them equivalent.
|
|
writeKimiSession(real, "sess-abc");
|
|
|
|
const result = await agent.getActivityState(
|
|
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: link }),
|
|
);
|
|
expect(result?.state).toBe("active");
|
|
});
|
|
|
|
it("rejects session dirs that have no live-signal files (stray/incomplete)", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
const bucket = join(fakeHome, ".kimi", "sessions", workspaceHash(workspace));
|
|
// UUID dir exists but context.jsonl / wire.jsonl haven't been created.
|
|
// Could be a crash leftover or a stray temp dir — must NOT be trusted
|
|
// (#3 from illegalcall's review). The JSONL activity fallback covers
|
|
// the startup race window instead.
|
|
mkdirSync(join(bucket, "empty-session"), { recursive: true });
|
|
|
|
const result = await agent.getActivityState(
|
|
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
// Defensive: a symlinked context.jsonl / wire.jsonl inside the bucket
|
|
// would let stat() / createReadStream() follow it to /etc/passwd, /dev/zero,
|
|
// a FIFO, etc. — escaping the kimi-sessions sandbox. lstat-based checks
|
|
// reject anything that isn't a regular file.
|
|
it("rejects session dirs whose live-signal files are symlinks (sandbox escape)", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
const bucket = join(fakeHome, ".kimi", "sessions", workspaceHash(workspace));
|
|
const sessionDir = join(bucket, "symlinked-session");
|
|
mkdirSync(sessionDir, { recursive: true });
|
|
|
|
// Create symlinks where context.jsonl / wire.jsonl should be — pointing
|
|
// at unrelated files outside the sessions tree.
|
|
const decoy = join(fakeHome, "decoy.txt");
|
|
writeFileSync(decoy, "should never be read");
|
|
symlinkSync(decoy, join(sessionDir, "context.jsonl"));
|
|
symlinkSync(decoy, join(sessionDir, "wire.jsonl"));
|
|
|
|
const result = await agent.getActivityState(
|
|
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// recordActivity
|
|
// =============================================================================
|
|
describe("recordActivity", () => {
|
|
const agent = create();
|
|
|
|
it("delegates to recordTerminalActivity", async () => {
|
|
await agent.recordActivity!(makeSession(), "kimi is generating");
|
|
expect(mockRecordTerminalActivity).toHaveBeenCalledWith(
|
|
"/workspace/test",
|
|
"kimi is generating",
|
|
expect.any(Function),
|
|
);
|
|
});
|
|
|
|
it("is a no-op when workspacePath is null", async () => {
|
|
await agent.recordActivity!(makeSession({ workspacePath: null }), "output");
|
|
expect(mockRecordTerminalActivity).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// getSessionInfo
|
|
// =============================================================================
|
|
describe("getSessionInfo", () => {
|
|
const agent = create();
|
|
// workspace is a per-test path under fakeHome that is intentionally NOT
|
|
// created on disk. resolveWorkspacePath stat()s before realpath()ing —
|
|
// a path that doesn't exist on the host falls back to the raw string, so
|
|
// production's hash matches the test fixture's hash. fakeHome is unique
|
|
// per test (mkdtemp), so this stays stable across hosts that may have
|
|
// unrelated dirs at literal paths like "/workspace".
|
|
let workspace: string;
|
|
beforeEach(() => {
|
|
workspace = join(fakeHome, "wsdir");
|
|
});
|
|
|
|
it("returns null when workspacePath is missing", async () => {
|
|
expect(await agent.getSessionInfo(makeSession({ workspacePath: null }))).toBeNull();
|
|
});
|
|
|
|
it("returns null when no matching kimi session dir exists", async () => {
|
|
expect(await agent.getSessionInfo(makeSession({ workspacePath: workspace }))).toBeNull();
|
|
});
|
|
|
|
it("returns the session UUID as agentSessionId", async () => {
|
|
writeKimiSession(workspace, "6ec34626-aedf-4659-a061-c5fbfa4cf166");
|
|
const info = await agent.getSessionInfo(makeSession({ workspacePath: workspace }));
|
|
expect(info).not.toBeNull();
|
|
expect(info!.agentSessionId).toBe("6ec34626-aedf-4659-a061-c5fbfa4cf166");
|
|
expect(info!.summaryIsFallback).toBe(true);
|
|
expect(info!.cost).toBeUndefined();
|
|
});
|
|
|
|
it("extracts the first user input from wire.jsonl as a summary", async () => {
|
|
writeKimiSession(workspace, "sess-abc", {
|
|
wireContent:
|
|
[
|
|
'{"type":"metadata","protocol_version":"1.9"}',
|
|
'{"timestamp":1,"message":{"type":"TurnBegin","payload":{"user_input":"fix the login bug"}}}',
|
|
'{"timestamp":2,"message":{"type":"TurnEnd","payload":{}}}',
|
|
].join("\n") + "\n",
|
|
});
|
|
const info = await agent.getSessionInfo(makeSession({ workspacePath: workspace }));
|
|
expect(info!.summary).toBe("fix the login bug");
|
|
});
|
|
|
|
it("truncates a long user input to 120 chars + ellipsis", async () => {
|
|
const longInput = "A".repeat(200);
|
|
writeKimiSession(workspace, "sess-abc", {
|
|
wireContent:
|
|
[
|
|
'{"type":"metadata","protocol_version":"1.9"}',
|
|
`{"timestamp":1,"message":{"type":"TurnBegin","payload":{"user_input":"${longInput}"}}}`,
|
|
].join("\n") + "\n",
|
|
});
|
|
const info = await agent.getSessionInfo(makeSession({ workspacePath: workspace }));
|
|
expect(info!.summary).toHaveLength(123);
|
|
expect(info!.summary!.endsWith("...")).toBe(true);
|
|
});
|
|
|
|
it("returns null summary when wire.jsonl has no TurnBegin entry", async () => {
|
|
writeKimiSession(workspace, "sess-abc", {
|
|
wireContent: '{"type":"metadata","protocol_version":"1.9"}\n',
|
|
});
|
|
const info = await agent.getSessionInfo(makeSession({ workspacePath: workspace }));
|
|
expect(info!.summary).toBeNull();
|
|
expect(info!.agentSessionId).toBe("sess-abc");
|
|
});
|
|
|
|
it("prefers kimi.json last_session_id over recency when populated", async () => {
|
|
const realWorkspace = join(fakeHome, "workspace-kimijson-1");
|
|
mkdirSync(realWorkspace, { recursive: true });
|
|
|
|
// Two sessions: "newer-uuid" is more recent, but kimi.json says
|
|
// "older-uuid" is the last_session_id for this workspace.
|
|
writeKimiSession(realWorkspace, "newer-uuid");
|
|
writeKimiSession(realWorkspace, "older-uuid", {
|
|
contextAgeMs: 2 * 60 * 1000,
|
|
wireAgeMs: 2 * 60 * 1000,
|
|
});
|
|
|
|
// Write kimi.json pointing at the older session.
|
|
writeFileSync(
|
|
join(fakeHome, ".kimi", "kimi.json"),
|
|
JSON.stringify({
|
|
work_dirs: [
|
|
{ path: realWorkspace, kaos: "local", last_session_id: "older-uuid" },
|
|
],
|
|
}),
|
|
);
|
|
|
|
const info = await agent.getSessionInfo(
|
|
makeSession({
|
|
workspacePath: realWorkspace,
|
|
createdAt: new Date(Date.now() - 10 * 60 * 1000),
|
|
}),
|
|
);
|
|
expect(info?.agentSessionId).toBe("older-uuid");
|
|
});
|
|
|
|
it("falls back to recency when kimi.json last_session_id is null", async () => {
|
|
const realWorkspace = join(fakeHome, "workspace-kimijson-2");
|
|
mkdirSync(realWorkspace, { recursive: true });
|
|
|
|
writeKimiSession(realWorkspace, "only-uuid");
|
|
|
|
writeFileSync(
|
|
join(fakeHome, ".kimi", "kimi.json"),
|
|
JSON.stringify({
|
|
work_dirs: [
|
|
{ path: realWorkspace, kaos: "local", last_session_id: null },
|
|
],
|
|
}),
|
|
);
|
|
|
|
const info = await agent.getSessionInfo(
|
|
makeSession({ workspacePath: realWorkspace }),
|
|
);
|
|
expect(info?.agentSessionId).toBe("only-uuid");
|
|
});
|
|
|
|
it("falls back to recency when kimi.json is missing", async () => {
|
|
// No kimi.json exists — hash-based discovery should still work.
|
|
writeKimiSession(workspace, "hash-found-uuid");
|
|
|
|
const info = await agent.getSessionInfo(
|
|
makeSession({ workspacePath: workspace }),
|
|
);
|
|
expect(info?.agentSessionId).toBe("hash-found-uuid");
|
|
});
|
|
|
|
// Regression: a stale kimi.json last_session_id pointing at a baseline
|
|
// UUID (e.g. left over from a prior manual `kimi` run) used to bypass
|
|
// the baseline filter and get pinned permanently. The pin file is
|
|
// long-lived, so this would route every subsequent getActivityState /
|
|
// getSessionInfo / getRestoreCommand call at the wrong conversation
|
|
// with no self-healing path. The soft-pin candidate must pass the same
|
|
// baseline + createdAt filters as the recency contest.
|
|
it("rejects kimi.json soft-pin when it points at a baseline UUID", async () => {
|
|
const realWorkspace = join(fakeHome, "workspace-kimijson-stale");
|
|
mkdirSync(join(realWorkspace, ".ao"), { recursive: true });
|
|
|
|
// "stale-uuid" was left in the bucket and in kimi.json by an earlier
|
|
// manual run. "ao-spawned" is the UUID kimi created for this AO session.
|
|
writeKimiSession(realWorkspace, "stale-uuid");
|
|
writeKimiSession(realWorkspace, "ao-spawned");
|
|
|
|
// Baseline (captured by preLaunchSetup) flags "stale-uuid" as pre-AO.
|
|
writeFileSync(
|
|
join(realWorkspace, ".ao", "kimi-baseline.json"),
|
|
JSON.stringify({
|
|
preExistingUuids: ["stale-uuid"],
|
|
capturedAt: new Date().toISOString(),
|
|
}),
|
|
);
|
|
|
|
// kimi.json still points at the stale UUID — kimi-cli hasn't yet
|
|
// updated last_session_id for the new AO-spawned session.
|
|
writeFileSync(
|
|
join(fakeHome, ".kimi", "kimi.json"),
|
|
JSON.stringify({
|
|
work_dirs: [
|
|
{ path: realWorkspace, kaos: "local", last_session_id: "stale-uuid" },
|
|
],
|
|
}),
|
|
);
|
|
|
|
const info = await agent.getSessionInfo(
|
|
makeSession({ workspacePath: realWorkspace }),
|
|
);
|
|
expect(info?.agentSessionId).toBe("ao-spawned");
|
|
|
|
// And the AO pin file must record "ao-spawned", not "stale-uuid" —
|
|
// otherwise every later call would resolve to the wrong conversation.
|
|
const pin = JSON.parse(
|
|
readFileSync(join(realWorkspace, ".ao", "kimi-session-id.json"), "utf-8"),
|
|
);
|
|
expect(pin.sessionId).toBe("ao-spawned");
|
|
});
|
|
|
|
// Companion case: kimi.json points at a UUID older than the AO session's
|
|
// createdAt. The soft-pin must be filtered by the createdAt floor too.
|
|
it("rejects kimi.json soft-pin when its UUID predates session.createdAt", async () => {
|
|
const realWorkspace = join(fakeHome, "workspace-kimijson-old");
|
|
mkdirSync(join(realWorkspace, ".ao"), { recursive: true });
|
|
|
|
// "old-uuid" exists with mtime far before session.createdAt; nothing
|
|
// is in the baseline file, but the createdAt floor should still
|
|
// reject it. "fresh-uuid" is the legitimate post-launch session.
|
|
writeKimiSession(realWorkspace, "old-uuid", {
|
|
contextAgeMs: 30 * 60 * 1000,
|
|
wireAgeMs: 30 * 60 * 1000,
|
|
});
|
|
writeKimiSession(realWorkspace, "fresh-uuid");
|
|
|
|
writeFileSync(
|
|
join(fakeHome, ".kimi", "kimi.json"),
|
|
JSON.stringify({
|
|
work_dirs: [
|
|
{ path: realWorkspace, kaos: "local", last_session_id: "old-uuid" },
|
|
],
|
|
}),
|
|
);
|
|
|
|
const info = await agent.getSessionInfo(
|
|
makeSession({
|
|
workspacePath: realWorkspace,
|
|
// createdAt 5 minutes ago — old-uuid (30 min) is well below the
|
|
// createdAt - 60s floor; fresh-uuid is well above.
|
|
createdAt: new Date(Date.now() - 5 * 60 * 1000),
|
|
}),
|
|
);
|
|
expect(info?.agentSessionId).toBe("fresh-uuid");
|
|
});
|
|
|
|
it("skips malformed wire.jsonl lines without crashing", async () => {
|
|
writeKimiSession(workspace, "sess-abc", {
|
|
wireContent:
|
|
[
|
|
"not json at all",
|
|
'{"type":"metadata","protocol_version":"1.9"}',
|
|
'{"timestamp":1,"message":{"type":"TurnBegin","payload":{"user_input":"recovered"}}}',
|
|
].join("\n") + "\n",
|
|
});
|
|
const info = await agent.getSessionInfo(makeSession({ workspacePath: workspace }));
|
|
expect(info!.summary).toBe("recovered");
|
|
});
|
|
|
|
// Defensive: a symlinked wire.jsonl in the bucket (e.g. pointing at
|
|
// /etc/passwd, /dev/zero, or a FIFO) must not be opened by
|
|
// extractKimiSummary. lstat-rejects symlinks before createReadStream.
|
|
it("returns null summary when wire.jsonl is a symlink (sandbox escape)", async () => {
|
|
mockTmuxWithProcess("kimi");
|
|
const bucket = join(fakeHome, ".kimi", "sessions", workspaceHash(workspace));
|
|
const sessionDir = join(bucket, "symlinked-wire");
|
|
mkdirSync(sessionDir, { recursive: true });
|
|
// context.jsonl is real so getKimiLiveSignalMtime succeeds and the dir
|
|
// is selected. Then extractKimiSummary tries to open the symlinked
|
|
// wire.jsonl and must refuse.
|
|
writeFileSync(join(sessionDir, "context.jsonl"), '{"role":"_system_prompt"}\n');
|
|
const decoy = join(fakeHome, "decoy-wire.txt");
|
|
writeFileSync(decoy, '{"timestamp":1,"message":{"type":"TurnBegin","payload":{"user_input":"leaked"}}}\n');
|
|
symlinkSync(decoy, join(sessionDir, "wire.jsonl"));
|
|
|
|
const info = await agent.getSessionInfo(makeSession({ workspacePath: workspace }));
|
|
// Discovery still produced a match (context.jsonl is fine), but the
|
|
// summary read MUST NOT have followed the symlink.
|
|
expect(info?.summary).toBeNull();
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// getRestoreCommand
|
|
// =============================================================================
|
|
describe("getRestoreCommand", () => {
|
|
const agent = create();
|
|
// workspace is a per-test path under fakeHome that is intentionally NOT
|
|
// created on disk. resolveWorkspacePath stat()s before realpath()ing —
|
|
// a path that doesn't exist on the host falls back to the raw string, so
|
|
// production's hash matches the test fixture's hash. fakeHome is unique
|
|
// per test (mkdtemp), so this stays stable across hosts that may have
|
|
// unrelated dirs at literal paths like "/workspace".
|
|
let workspace: string;
|
|
beforeEach(() => {
|
|
workspace = join(fakeHome, "wsdir");
|
|
});
|
|
|
|
it("returns null when workspacePath is missing", async () => {
|
|
const result = await agent.getRestoreCommand!(
|
|
makeSession({ workspacePath: null }),
|
|
makeProject(),
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when no kimi session dir exists", async () => {
|
|
const result = await agent.getRestoreCommand!(
|
|
makeSession({ workspacePath: workspace }),
|
|
makeProject(),
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("uses --resume <session_uuid>", async () => {
|
|
writeKimiSession(workspace, "6ec34626-aedf-4659-a061-c5fbfa4cf166");
|
|
const result = await agent.getRestoreCommand!(
|
|
makeSession({ workspacePath: workspace }),
|
|
makeProject(),
|
|
);
|
|
expect(result).toBe("kimi --resume '6ec34626-aedf-4659-a061-c5fbfa4cf166'");
|
|
});
|
|
|
|
it("passes --yolo and --model from project.agentConfig", async () => {
|
|
writeKimiSession(workspace, "sess-abc");
|
|
const result = await agent.getRestoreCommand!(
|
|
makeSession({ workspacePath: workspace }),
|
|
makeProject({
|
|
agentConfig: { permissions: "permissionless", model: "kimi-k2" },
|
|
}),
|
|
);
|
|
expect(result).toContain("kimi --resume 'sess-abc'");
|
|
expect(result).toContain("--yolo");
|
|
expect(result).toContain("--model 'kimi-k2'");
|
|
});
|
|
|
|
it("picks the most recently modified session UUID when multiple exist", async () => {
|
|
writeKimiSession(workspace, "sess-old", {
|
|
contextAgeMs: 60 * 60 * 1000,
|
|
wireAgeMs: 60 * 60 * 1000,
|
|
});
|
|
writeKimiSession(workspace, "sess-new");
|
|
|
|
const result = await agent.getRestoreCommand!(
|
|
makeSession({ workspacePath: workspace }),
|
|
makeProject(),
|
|
);
|
|
expect(result).toBe("kimi --resume 'sess-new'");
|
|
});
|
|
|
|
// Worktree-mode end-to-end: AO's workspace plugin gives the session a
|
|
// per-session checkout (workspacePath) different from the project root
|
|
// (projectConfig.path). kimi launched with --work-dir=<workspacePath>
|
|
// hashes that path for its bucket — so discovery must hash the
|
|
// workspacePath, NOT the project root, or it will look in the wrong
|
|
// bucket and find nothing.
|
|
it("worktree mode: discovery hashes workspacePath, not projectConfig.path", async () => {
|
|
const workspaceWorktree = join(fakeHome, "worktrees", "sess-wt");
|
|
mkdirSync(workspaceWorktree, { recursive: true });
|
|
|
|
// kimi launched with --work-dir=workspaceWorktree wrote its session
|
|
// under md5(workspaceWorktree).
|
|
writeKimiSession(workspaceWorktree, "wt-uuid");
|
|
|
|
// Project root is somewhere completely different — md5(projectRoot)
|
|
// must NOT be where discovery looks.
|
|
const projectRoot = join(fakeHome, "repos", "main-repo");
|
|
mkdirSync(projectRoot, { recursive: true });
|
|
|
|
const result = await agent.getRestoreCommand!(
|
|
makeSession({ workspacePath: workspaceWorktree }),
|
|
makeProject({ path: projectRoot }),
|
|
);
|
|
expect(result).toBe("kimi --resume 'wt-uuid'");
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// setupWorkspaceHooks / postLaunchSetup
|
|
// =============================================================================
|
|
describe("workspace hooks", () => {
|
|
const agent = create();
|
|
|
|
it("setupWorkspaceHooks delegates to setupPathWrapperWorkspace", async () => {
|
|
await agent.setupWorkspaceHooks!("/workspace/test", { dataDir: "/tmp/ao-data", sessionId: "s" });
|
|
expect(mockSetupPathWrapperWorkspace).toHaveBeenCalledWith("/workspace/test");
|
|
});
|
|
|
|
it("postLaunchSetup is a no-op when workspacePath is null", async () => {
|
|
await agent.postLaunchSetup!(makeSession({ workspacePath: null }));
|
|
expect(mockSetupPathWrapperWorkspace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("postLaunchSetup installs PATH wrappers", async () => {
|
|
await agent.postLaunchSetup!(makeSession());
|
|
expect(mockSetupPathWrapperWorkspace).toHaveBeenCalledWith("/workspace/test");
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// detect()
|
|
// =============================================================================
|
|
describe("detect", () => {
|
|
it("returns true when `kimi info` prints the kimi-cli vendor string", async () => {
|
|
const { execFileSync } = await import("node:child_process");
|
|
vi.mocked(execFileSync).mockImplementationOnce(
|
|
() =>
|
|
"kimi-cli version: 1.38.0\nagent spec versions: 1\n" as unknown as ReturnType<
|
|
typeof execFileSync
|
|
>,
|
|
);
|
|
expect(detect()).toBe(true);
|
|
});
|
|
|
|
it("returns true for kimi-code vendor string", async () => {
|
|
const { execFileSync } = await import("node:child_process");
|
|
vi.mocked(execFileSync).mockImplementationOnce(
|
|
() => "package: kimi-code\n" as unknown as ReturnType<typeof execFileSync>,
|
|
);
|
|
expect(detect()).toBe(true);
|
|
});
|
|
|
|
it("returns true when output mentions Moonshot", async () => {
|
|
const { execFileSync } = await import("node:child_process");
|
|
vi.mocked(execFileSync).mockImplementationOnce(
|
|
() => "moonshot kimi 1.38\n" as unknown as ReturnType<typeof execFileSync>,
|
|
);
|
|
expect(detect()).toBe(true);
|
|
});
|
|
|
|
it("returns false when `kimi info` throws (binary missing or unrelated)", async () => {
|
|
const { execFileSync } = await import("node:child_process");
|
|
vi.mocked(execFileSync).mockImplementationOnce(() => {
|
|
throw new Error("command not found");
|
|
});
|
|
expect(detect()).toBe(false);
|
|
});
|
|
|
|
it("rejects an unrelated `kimi` binary whose output lacks the vendor marker", async () => {
|
|
// E.g. a hypothetical keyboard-input-manager named `kimi` whose output
|
|
// contains plain "kimi" but no kimi-cli / kimi-code / moonshot marker.
|
|
const { execFileSync } = await import("node:child_process");
|
|
vi.mocked(execFileSync).mockImplementationOnce(
|
|
() =>
|
|
"kimi v0.1 — keyboard input manager\n" as unknown as ReturnType<typeof execFileSync>,
|
|
);
|
|
expect(detect()).toBe(false);
|
|
});
|
|
});
|