fix(plugin-kimicode): correct session layout discovered via smoke test

Installing kimi-cli 1.38.0 locally (\`uv tool install kimi-cli\`) and running
it once revealed the plugin's session-discovery logic was built on wrong
assumptions about the on-disk layout.

Observed layout (kimi-cli 1.38.0):

  ~/.kimi/sessions/<md5(cwd)>/<session-uuid>/
    context.jsonl  — conversation history
    wire.jsonl     — turn events (TurnBegin/TurnEnd with user_input payload)

Differences from my original assumptions:

- Sessions are nested under \`sessions/\` (not direct subdirectories of
  \`~/.kimi/\`).
- The workspace is identified by an MD5 hash of the absolute path, not by
  a \`cwd\` field stored in a state file.
- There is no \`state.json\`. No \`title\`, \`model\`, or \`cost\` is persisted.
- The session ID is the UUID directory name and is accepted as-is by
  \`kimi --resume <uuid>\`.
- The old \`--continue\` fallback is unnecessary — if we found the directory,
  we always know its UUID.

Fixes:

- \`findKimiSessionMatch\` now computes \`md5(workspacePath)\` with node:crypto
  and lists \`~/.kimi/sessions/<hash>/\` directly. No more full-tree scan of
  \`~/.kimi/\`, no more \`readFile\` of a fictional \`state.json\`.
- \`getKimiLiveSignalMtime\` keeps the parallel \`Promise.all\` stat of
  context.jsonl + wire.jsonl (the only files that exist).
- \`getSessionInfo\` streams the first \`TurnBegin\` out of wire.jsonl as a
  best-effort summary, with a 1 MB byte ceiling. agentSessionId is the UUID.
- \`getRestoreCommand\` drops the \`--continue\` fallback branch — a found dir
  always has a usable UUID.

Verified end-to-end against the real kimi-cli 1.38 binary on this machine:
- \`detect()\` → true
- \`getLaunchCommand\` output parses cleanly when run with \`--help\`
- \`getSessionInfo\` extracts the actual first user prompt ("say hello")
- \`getRestoreCommand\` produces the same UUID kimi itself prints as the
  resume hint: \`kimi -r 6ec34626-aedf-4659-a061-c5fbfa4cf166\`

Tests remain at 75 green. Coverage is now against real on-disk layouts
using temp directories with MD5-hashed bucket names — no mock-structure
drift from reality.
This commit is contained in:
Harsh Batheja 2026-04-22 22:17:08 +05:30
parent 2093126a48
commit bc8b50d2db
2 changed files with 318 additions and 398 deletions

View File

@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
createActivitySignal,
type Session,
@ -6,22 +6,26 @@ import {
type AgentLaunchConfig,
type ProjectConfig,
} from "@aoagents/ao-core";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, utimesSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createHash } from "node:crypto";
// ---------------------------------------------------------------------------
// fs/promises mocks — control readdir/readFile/stat for ~/.kimi/ scans
// Mock homedir() so kimiShareDir() points at a per-test temp dir.
// fakeHome is assigned in beforeEach.
// ---------------------------------------------------------------------------
vi.mock("node:fs/promises", async (importOriginal) => {
let fakeHome = "";
vi.mock("node:os", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
readdir: vi.fn().mockResolvedValue([]),
readFile: vi.fn().mockRejectedValue(new Error("ENOENT")),
stat: vi.fn().mockRejectedValue(new Error("ENOENT")),
homedir: () => fakeHome,
};
});
// ---------------------------------------------------------------------------
// Core activity log utilities
// Core activity-log mocks — only the shared helpers, not fs primitives.
// ---------------------------------------------------------------------------
const { mockReadLastActivityEntry, mockRecordTerminalActivity, mockSetupPathWrapperWorkspace } =
vi.hoisted(() => ({
@ -41,7 +45,7 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => {
});
// ---------------------------------------------------------------------------
// child_process mocks — tmux/ps for isProcessRunning
// child_process mocks — tmux/ps for isProcessRunning, execFileSync for detect.
// ---------------------------------------------------------------------------
const { mockExecFileAsync } = vi.hoisted(() => ({
mockExecFileAsync: vi.fn(),
@ -65,12 +69,52 @@ import {
_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 {
// Intentionally builds a minimal Session stub; full lifecycle shape is out of
// scope for these unit tests and the agent plugin never reads `lifecycle`.
// Minimal Session stub; the plugin never reads `lifecycle`.
const base = {
id: "kimi-1",
projectId: "test-project",
@ -128,7 +172,6 @@ function makeProject(overrides: Partial<ProjectConfig> = {}): ProjectConfig {
};
}
/** Configure tmux + ps responses so isProcessRunning observes `kimi` running. */
function mockTmuxWithProcess(processName: string, found = true) {
mockExecFileAsync.mockImplementation((cmd: string) => {
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys005\n", stderr: "" });
@ -149,6 +192,11 @@ beforeEach(() => {
mockReadLastActivityEntry.mockResolvedValue(null);
mockRecordTerminalActivity.mockResolvedValue(undefined);
mockSetupPathWrapperWorkspace.mockResolvedValue(undefined);
fakeHome = mkdtempSync(join(tmpdir(), "kimicode-test-"));
});
afterEach(() => {
rmSync(fakeHome, { recursive: true, force: true });
});
// =============================================================================
@ -173,8 +221,6 @@ describe("manifest & exports", () => {
it("uses inline prompt delivery (kimi's -p does not exit after prompt)", () => {
const agent = create();
// Either "inline" or undefined is acceptable — both mean the prompt goes
// into the launch command rather than being sent post-launch.
expect(agent.promptDelivery === undefined || agent.promptDelivery === "inline").toBe(true);
});
@ -264,9 +310,9 @@ describe("getEnvironment", () => {
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");
expect(agent.getEnvironment(makeLaunchConfig({ issueId: "GH-42" })).AO_ISSUE_ID).toBe(
"GH-42",
);
});
it("prepends ~/.ao/bin to PATH", () => {
@ -314,7 +360,6 @@ describe("detectActivity", () => {
});
it("does NOT match 'approve' in agent narration (false-positive guard)", () => {
// Anchored regex must skip mid-sentence mentions of "approve".
const narration = "I approve of this approach and will proceed.\nReading src/index.ts";
expect(agent.detectActivity(narration)).toBe("active");
});
@ -405,15 +450,16 @@ describe("isProcessRunning", () => {
});
// =============================================================================
// getActivityState — mandatory cascade coverage
// getActivityState — the mandatory 5-step cascade plus ordering + decay
// =============================================================================
describe("getActivityState", () => {
const agent = create();
const workspace = "/workspace/test";
it("1. returns exited when process is not running", async () => {
mockTmuxWithProcess("zsh", false);
const result = await agent.getActivityState(
makeSession({ runtimeHandle: makeTmuxHandle() }),
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
);
expect(result?.state).toBe("exited");
});
@ -431,7 +477,7 @@ describe("getActivityState", () => {
});
const result = await agent.getActivityState(
makeSession({ runtimeHandle: makeTmuxHandle() }),
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
);
expect(result?.state).toBe("waiting_input");
});
@ -444,132 +490,93 @@ describe("getActivityState", () => {
});
const result = await agent.getActivityState(
makeSession({ runtimeHandle: makeTmuxHandle() }),
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
);
expect(result?.state).toBe("blocked");
});
it("4. returns active from native signal (fresh ~/.kimi session file mtime)", async () => {
it("4. returns active from native signal when session files are fresh", async () => {
mockTmuxWithProcess("kimi");
const { readdir, readFile, stat } = await import("node:fs/promises");
vi.mocked(readdir).mockResolvedValueOnce(["sess-abc"] as never);
vi.mocked(readFile).mockResolvedValueOnce(
JSON.stringify({ cwd: "/workspace/test", session_id: "sess-abc", model: "kimi-k2" }),
);
const now = new Date();
// stat #1: state.json mtime for scan ranking. stat #2: mtime lookup inside
// the matched session dir (context.jsonl / wire.jsonl / state.json).
vi.mocked(stat).mockResolvedValue({
mtimeMs: now.getTime(),
mtime: now,
} as unknown as Awaited<ReturnType<typeof stat>>);
writeKimiSession(workspace, "sess-abc"); // fresh (age ~ 0)
const result = await agent.getActivityState(
makeSession({ runtimeHandle: makeTmuxHandle() }),
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");
const { readdir, readFile, stat } = await import("node:fs/promises");
vi.mocked(readdir).mockResolvedValueOnce(["sess-abc"] as never);
vi.mocked(readFile).mockResolvedValueOnce(
JSON.stringify({ cwd: "/workspace/test", session_id: "sess-abc" }),
);
// 2 minutes old → beyond 30s active window but inside 5min ready threshold
const readyAge = new Date(Date.now() - 2 * 60 * 1000);
vi.mocked(stat).mockResolvedValue({
mtimeMs: readyAge.getTime(),
mtime: readyAge,
} as unknown as Awaited<ReturnType<typeof stat>>);
writeKimiSession(workspace, "sess-abc", {
contextAgeMs: 2 * 60 * 1000,
wireAgeMs: 2 * 60 * 1000,
});
const result = await agent.getActivityState(
makeSession({ runtimeHandle: makeTmuxHandle() }),
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
);
expect(result?.state).toBe("ready");
});
it("4c. returns idle from native signal when mtime is older than readyThreshold", async () => {
mockTmuxWithProcess("kimi");
const { readdir, readFile, stat } = await import("node:fs/promises");
vi.mocked(readdir).mockResolvedValueOnce(["sess-abc"] as never);
vi.mocked(readFile).mockResolvedValueOnce(
JSON.stringify({ cwd: "/workspace/test", session_id: "sess-abc" }),
);
// 10 minutes old → past the 5min ready threshold
const idleAge = new Date(Date.now() - 10 * 60 * 1000);
vi.mocked(stat).mockResolvedValue({
mtimeMs: idleAge.getTime(),
mtime: idleAge,
} as unknown as Awaited<ReturnType<typeof stat>>);
writeKimiSession(workspace, "sess-abc", {
contextAgeMs: 10 * 60 * 1000,
wireAgeMs: 10 * 60 * 1000,
});
const result = await agent.getActivityState(
makeSession({ runtimeHandle: makeTmuxHandle() }),
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
);
expect(result?.state).toBe("idle");
});
it("cascade: JSONL waiting_input wins over native signal even when ~/.kimi/ matches", async () => {
it("cascade: JSONL waiting_input wins over native signal even when a session dir exists", async () => {
mockTmuxWithProcess("kimi");
const { readdir, readFile, stat } = await import("node:fs/promises");
// Native signal would return "active" — but the JSONL has waiting_input,
// which must short-circuit before the native check runs.
vi.mocked(readdir).mockResolvedValue(["sess-abc"] as never);
vi.mocked(readFile).mockResolvedValue(
JSON.stringify({ cwd: "/workspace/test", session_id: "sess-abc" }),
);
const now = new Date();
vi.mocked(stat).mockResolvedValue({
mtimeMs: now.getTime(),
mtime: now,
} as unknown as Awaited<ReturnType<typeof stat>>);
writeKimiSession(workspace, "sess-abc"); // would be "active"
mockReadLastActivityEntry.mockResolvedValueOnce({
entry: { ts: now.toISOString(), state: "waiting_input", source: "terminal" },
modifiedAt: now,
entry: { ts: new Date().toISOString(), state: "waiting_input", source: "terminal" },
modifiedAt: new Date(),
});
const result = await agent.getActivityState(
makeSession({ runtimeHandle: makeTmuxHandle() }),
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
);
expect(result?.state).toBe("waiting_input");
});
it("native signal prefers a fresher context.jsonl mtime over a stale state.json mtime", async () => {
it("native signal prefers the fresher of context.jsonl vs wire.jsonl mtimes", async () => {
mockTmuxWithProcess("kimi");
const { readdir, readFile, stat } = await import("node:fs/promises");
vi.mocked(readdir).mockResolvedValueOnce(["sess-abc"] as never);
vi.mocked(readFile).mockResolvedValueOnce(
JSON.stringify({ cwd: "/workspace/test", session_id: "sess-abc" }),
);
// state.json is 10min old — would decay to "idle" on its own.
// context.jsonl is fresh — should win, producing "active".
const stale = new Date(Date.now() - 10 * 60 * 1000);
const fresh = new Date();
vi.mocked(stat).mockImplementation(async (p: unknown) => {
const path = String(p);
const mtime = path.endsWith("context.jsonl") ? fresh : stale;
return { mtimeMs: mtime.getTime(), mtime } as unknown as Awaited<ReturnType<typeof stat>>;
// 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() }),
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 }),
);
expect(result?.state).toBe("active");
});
it("5. returns active from JSONL entry fallback when native signal is unavailable (fresh entry)", async () => {
mockTmuxWithProcess("kimi");
// No ~/.kimi/ directory at all
const { readdir } = await import("node:fs/promises");
vi.mocked(readdir).mockRejectedValueOnce(new Error("ENOENT"));
// No ~/.kimi/sessions/<hash>/ dir for this workspace — fakeHome is empty.
const now = new Date();
mockReadLastActivityEntry.mockResolvedValueOnce({
@ -578,17 +585,14 @@ describe("getActivityState", () => {
});
const result = await agent.getActivityState(
makeSession({ runtimeHandle: makeTmuxHandle() }),
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 { readdir } = await import("node:fs/promises");
vi.mocked(readdir).mockRejectedValueOnce(new Error("ENOENT"));
// 10 minutes old → beyond 5-minute ready threshold → idle
const old = new Date(Date.now() - 10 * 60 * 1000);
mockReadLastActivityEntry.mockResolvedValueOnce({
entry: { ts: old.toISOString(), state: "active", source: "terminal" },
@ -596,19 +600,17 @@ describe("getActivityState", () => {
});
const result = await agent.getActivityState(
makeSession({ runtimeHandle: makeTmuxHandle() }),
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");
const { readdir } = await import("node:fs/promises");
vi.mocked(readdir).mockRejectedValueOnce(new Error("ENOENT"));
mockReadLastActivityEntry.mockResolvedValueOnce(null);
const result = await agent.getActivityState(
makeSession({ runtimeHandle: makeTmuxHandle() }),
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
);
expect(result).toBeNull();
});
@ -620,6 +622,18 @@ describe("getActivityState", () => {
);
expect(result).toBeNull();
});
it("ignores session dirs that have no live-signal files", async () => {
mockTmuxWithProcess("kimi");
const bucket = join(fakeHome, ".kimi", "sessions", workspaceHash(workspace));
mkdirSync(join(bucket, "empty-session"), { recursive: true });
// no context.jsonl / wire.jsonl inside
const result = await agent.getActivityState(
makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: workspace }),
);
expect(result).toBeNull();
});
});
// =============================================================================
@ -648,102 +662,72 @@ describe("recordActivity", () => {
// =============================================================================
describe("getSessionInfo", () => {
const agent = create();
const workspace = "/workspace/test";
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 () => {
const { readdir } = await import("node:fs/promises");
vi.mocked(readdir).mockResolvedValueOnce([] as never);
expect(await agent.getSessionInfo(makeSession())).toBeNull();
expect(await agent.getSessionInfo(makeSession({ workspacePath: workspace }))).toBeNull();
});
it("extracts summary, session id, and model from state.json (single read)", async () => {
const { readdir, readFile, stat } = await import("node:fs/promises");
vi.mocked(readdir).mockResolvedValueOnce(["sess-abc"] as never);
vi.mocked(readFile).mockResolvedValueOnce(
JSON.stringify({
cwd: "/workspace/test",
session_id: "sess-abc",
model: "kimi-k2",
title: "Fix auth bug",
}),
);
const now = new Date();
vi.mocked(stat).mockResolvedValueOnce({
mtimeMs: now.getTime(),
mtime: now,
} as unknown as Awaited<ReturnType<typeof stat>>);
const info = await agent.getSessionInfo(makeSession());
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!.summary).toBe("Fix auth bug");
expect(info!.agentSessionId).toBe("sess-abc");
expect(info!.agentSessionId).toBe("6ec34626-aedf-4659-a061-c5fbfa4cf166");
expect(info!.summaryIsFallback).toBe(true);
expect(info!.cost).toBeUndefined();
// state.json should be read exactly once (scan + parse combined)
expect(vi.mocked(readFile).mock.calls.filter(([p]) => String(p).endsWith("state.json"))).toHaveLength(1);
});
it("falls back to `Kimi session (<model>)` when state.json has no title", async () => {
const { readdir, readFile, stat } = await import("node:fs/promises");
vi.mocked(readdir).mockResolvedValueOnce(["sess-abc"] as never);
vi.mocked(readFile).mockResolvedValueOnce(
JSON.stringify({ cwd: "/workspace/test", session_id: "sess-abc", model: "kimi-k2" }),
);
const now = new Date();
vi.mocked(stat).mockResolvedValueOnce({
mtimeMs: now.getTime(),
mtime: now,
} as unknown as Awaited<ReturnType<typeof stat>>);
const info = await agent.getSessionInfo(makeSession());
expect(info!.summary).toBe("Kimi session (kimi-k2)");
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("returns null when state.json is malformed JSON", async () => {
const { readdir, readFile, stat } = await import("node:fs/promises");
vi.mocked(readdir).mockResolvedValueOnce(["sess-broken"] as never);
vi.mocked(readFile).mockResolvedValueOnce("{not-json,");
const now = new Date();
vi.mocked(stat).mockResolvedValueOnce({
mtimeMs: now.getTime(),
mtime: now,
} as unknown as Awaited<ReturnType<typeof stat>>);
expect(await agent.getSessionInfo(makeSession())).toBeNull();
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("skips state.json entries with non-matching cwd", async () => {
const { readdir, readFile, stat } = await import("node:fs/promises");
vi.mocked(readdir).mockResolvedValueOnce(["other"] as never);
vi.mocked(readFile).mockResolvedValueOnce(
JSON.stringify({ cwd: "/somewhere/else", session_id: "other" }),
);
const now = new Date();
vi.mocked(stat).mockResolvedValueOnce({
mtimeMs: now.getTime(),
mtime: now,
} as unknown as Awaited<ReturnType<typeof stat>>);
expect(await agent.getSessionInfo(makeSession())).toBeNull();
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("accepts `work_dir` as an alias for `cwd`", async () => {
const { readdir, readFile, stat } = await import("node:fs/promises");
vi.mocked(readdir).mockResolvedValueOnce(["sess-xyz"] as never);
vi.mocked(readFile).mockResolvedValueOnce(
JSON.stringify({ work_dir: "/workspace/test", session_id: "sess-xyz", title: "hello" }),
);
const now = new Date();
vi.mocked(stat).mockResolvedValueOnce({
mtimeMs: now.getTime(),
mtime: now,
} as unknown as Awaited<ReturnType<typeof stat>>);
const info = await agent.getSessionInfo(makeSession());
expect(info?.agentSessionId).toBe("sess-xyz");
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");
});
});
@ -752,6 +736,7 @@ describe("getSessionInfo", () => {
// =============================================================================
describe("getRestoreCommand", () => {
const agent = create();
const workspace = "/workspace/test";
it("returns null when workspacePath is missing", async () => {
const result = await agent.getRestoreCommand!(
@ -762,79 +747,47 @@ describe("getRestoreCommand", () => {
});
it("returns null when no kimi session dir exists", async () => {
const { readdir } = await import("node:fs/promises");
vi.mocked(readdir).mockResolvedValueOnce([] as never);
const result = await agent.getRestoreCommand!(makeSession(), makeProject());
const result = await agent.getRestoreCommand!(
makeSession({ workspacePath: workspace }),
makeProject(),
);
expect(result).toBeNull();
});
it("uses --resume <session_id> when state.json has a session id", async () => {
const { readdir, readFile, stat } = await import("node:fs/promises");
vi.mocked(readdir).mockResolvedValueOnce(["sess-abc"] as never);
vi.mocked(readFile).mockResolvedValueOnce(
JSON.stringify({ cwd: "/workspace/test", session_id: "sess-abc", model: "kimi-k2" }),
it("uses --resume <session_uuid>", async () => {
writeKimiSession(workspace, "6ec34626-aedf-4659-a061-c5fbfa4cf166");
const result = await agent.getRestoreCommand!(
makeSession({ workspacePath: workspace }),
makeProject(),
);
const now = new Date();
vi.mocked(stat).mockResolvedValueOnce({
mtimeMs: now.getTime(),
mtime: now,
} as unknown as Awaited<ReturnType<typeof stat>>);
expect(result).toBe("kimi --resume '6ec34626-aedf-4659-a061-c5fbfa4cf166'");
});
const result = await agent.getRestoreCommand!(makeSession(), makeProject());
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("--model 'kimi-k2'");
});
it("falls back to --continue when session dir exists but state.json has no id", async () => {
const { readdir, readFile, stat } = await import("node:fs/promises");
vi.mocked(readdir).mockResolvedValueOnce(["sess-anon"] as never);
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({ cwd: "/workspace/test" }));
const now = new Date();
vi.mocked(stat).mockResolvedValueOnce({
mtimeMs: now.getTime(),
mtime: now,
} as unknown as Awaited<ReturnType<typeof stat>>);
const result = await agent.getRestoreCommand!(
makeSession(),
makeProject({ agentConfig: { permissions: "permissionless" } }),
);
expect(result).toContain("kimi --continue");
expect(result).toContain("--yolo");
expect(result).toContain("--model 'kimi-k2'");
});
it("returns null when state.json is malformed", async () => {
const { readdir, readFile, stat } = await import("node:fs/promises");
vi.mocked(readdir).mockResolvedValueOnce(["sess-broken"] as never);
vi.mocked(readFile).mockResolvedValueOnce("not json at all");
const now = new Date();
vi.mocked(stat).mockResolvedValueOnce({
mtimeMs: now.getTime(),
mtime: now,
} as unknown as Awaited<ReturnType<typeof stat>>);
const result = await agent.getRestoreCommand!(makeSession(), makeProject());
expect(result).toBeNull();
});
it("prefers project.agentConfig.model over the model recorded in state.json", async () => {
const { readdir, readFile, stat } = await import("node:fs/promises");
vi.mocked(readdir).mockResolvedValueOnce(["sess-abc"] as never);
vi.mocked(readFile).mockResolvedValueOnce(
JSON.stringify({ cwd: "/workspace/test", session_id: "sess-abc", model: "kimi-k1" }),
);
const now = new Date();
vi.mocked(stat).mockResolvedValueOnce({
mtimeMs: now.getTime(),
mtime: now,
} as unknown as Awaited<ReturnType<typeof stat>>);
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(),
makeProject({ agentConfig: { model: "kimi-k2" } }),
makeSession({ workspacePath: workspace }),
makeProject(),
);
expect(result).toContain("--model 'kimi-k2'");
expect(result).not.toContain("kimi-k1");
expect(result).toBe("kimi --resume 'sess-new'");
});
});
@ -867,7 +820,7 @@ describe("detect", () => {
it("returns true when --version identifies the binary as kimi", async () => {
const { execFileSync } = await import("node:child_process");
vi.mocked(execFileSync).mockImplementationOnce(
() => "kimi-cli 0.1.0" as unknown as ReturnType<typeof execFileSync>,
() => "kimi, version 1.38.0" as unknown as ReturnType<typeof execFileSync>,
);
expect(detect()).toBe(true);
});
@ -886,7 +839,9 @@ describe("detect", () => {
.mockImplementationOnce(() => "1.0.0" as unknown as ReturnType<typeof execFileSync>)
.mockImplementationOnce(
() =>
"package: kimi-cli\nprotocol: mcp\n" as unknown as ReturnType<typeof execFileSync>,
"kimi-cli version: 1.38.0\nprotocol: wire\n" as unknown as ReturnType<
typeof execFileSync
>,
);
expect(detect()).toBe(true);
});
@ -900,28 +855,4 @@ describe("detect", () => {
);
expect(detect()).toBe(false);
});
it("rejects an unrelated `kimi` binary whose --version doesn't mention kimi", async () => {
const { execFileSync } = await import("node:child_process");
vi.mocked(execFileSync)
.mockImplementationOnce(
() =>
"KIMI v0.1 — keyboard input manager\n" as unknown as ReturnType<typeof execFileSync>,
)
.mockImplementationOnce(() => {
// `kimi info` on that unrelated binary probably exits non-zero
throw new Error("no such subcommand: info");
});
// Note: "KIMI v0.1 — keyboard input manager" happens to match the regex
// via the bare `\bkimi\b` word. Check a truly-unrelated name instead.
vi.mocked(execFileSync).mockReset();
vi.mocked(execFileSync)
.mockImplementationOnce(
() => "nano 7.2 GNU\n" as unknown as ReturnType<typeof execFileSync>,
)
.mockImplementationOnce(() => {
throw new Error("no such subcommand: info");
});
expect(detect()).toBe(false);
});
});

View File

@ -24,7 +24,10 @@ import {
} from "@aoagents/ao-core";
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
import { readdir, readFile, stat } from "node:fs/promises";
import { readdir, stat } from "node:fs/promises";
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
import { createHash } from "node:crypto";
import { homedir } from "node:os";
import { join } from "node:path";
@ -41,112 +44,84 @@ function kimiShareDir(): string {
return join(homedir(), ".kimi");
}
interface KimiSessionState {
sessionId: string | null;
model: string | null;
title: string | null;
}
interface KimiSessionMatch {
/** Absolute path to the session directory, e.g.
* ~/.kimi/sessions/<md5(cwd)>/<session-uuid>/ */
dir: string;
state: KimiSessionState;
/** mtime of state.json at the time the match was resolved. Reusing this
* saves one stat call when getKimiSessionMtime() probes for the freshest
* file in the session directory. */
stateMtime: Date;
/** Session UUID (directory basename) — accepted by `kimi --resume <id>`. */
sessionId: string;
/** mtime of the newest live-signal file (context.jsonl / wire.jsonl).
* Captured during the scan so callers don't re-stat. */
mtime: Date;
}
interface ParsedKimiState extends KimiSessionState {
cwd: string | null;
/** MD5 hex digest of an absolute workspace path kimi uses this as the
* per-workspace bucket under ~/.kimi/sessions/. */
function kimiWorkspaceHash(workspacePath: string): string {
return createHash("md5").update(workspacePath).digest("hex");
}
/**
* Parse a Kimi `state.json` blob in a single pass. Returns `null` for
* malformed JSON, non-object payloads, or when every field we read is missing.
* Collapses the previous separate extractStateCwd + parseKimiSessionState
* helpers into one traversal.
*/
function parseKimiState(raw: string): ParsedKimiState | null {
try {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
const state = parsed as Record<string, unknown>;
const cwd =
typeof state["cwd"] === "string"
? state["cwd"]
: typeof state["work_dir"] === "string"
? state["work_dir"]
: typeof state["workdir"] === "string"
? state["workdir"]
: null;
const sessionId =
typeof state["session_id"] === "string"
? state["session_id"]
: typeof state["id"] === "string"
? state["id"]
: null;
const model = typeof state["model"] === "string" ? state["model"] : null;
const title = typeof state["title"] === "string" ? state["title"] : null;
return { cwd, sessionId, model, title };
} catch {
return null;
}
}
/** TTL for session match cache (ms) — avoids redundant ~/.kimi/ scans when
/** TTL for session match cache (ms) avoids redundant scans when
* getActivityState / getSessionInfo / getRestoreCommand all fire in one
* refresh cycle. Mirrors agent-codex's SESSION_FILE_CACHE_TTL_MS. */
const SESSION_MATCH_CACHE_TTL_MS = 30_000;
/** Per-workspace cache of the resolved session directory + parsed state. */
/** Per-workspace cache of the resolved session directory. */
const sessionMatchCache = new Map<string, { match: KimiSessionMatch | null; expiry: number }>();
/**
* Find the Kimi session directory whose `state.json` references this workspace.
* Scans immediate subdirectories of ~/.kimi/ looking for a `state.json` whose
* `cwd` / `work_dir` / `workdir` matches. Returns the most recently modified
* match (directory + parsed state + state.json mtime), or null when nothing matches.
* Get the mtime of the freshest live signal inside a Kimi session directory.
* context.jsonl / wire.jsonl update on every agent turn. Probed in parallel
* to avoid serial filesystem roundtrips.
*/
async function getKimiLiveSignalMtime(sessionDir: string): Promise<Date | null> {
const stats = await Promise.all(
["context.jsonl", "wire.jsonl"].map((name) =>
stat(join(sessionDir, name)).catch(() => null),
),
);
let newest: Date | null = null;
for (const s of stats) {
if (s && (!newest || s.mtimeMs > newest.getTime())) newest = s.mtime;
}
return newest;
}
/**
* Find the Kimi session directory for this workspace.
*
* Layout (kimi-cli 1.38):
* ~/.kimi/sessions/<md5(cwd)>/<session-uuid>/
* context.jsonl conversation history
* wire.jsonl turn events
*
* There is no `state.json`. We hash the workspace path to find the bucket,
* then pick the most-recently-modified UUID subdirectory inside it.
*/
async function findKimiSessionMatchUncached(
workspacePath: string,
): Promise<KimiSessionMatch | null> {
const root = kimiShareDir();
const bucket = join(kimiShareDir(), "sessions", kimiWorkspaceHash(workspacePath));
let entries: string[];
try {
entries = await readdir(root);
entries = await readdir(bucket);
} catch {
return null;
}
let best: { dir: string; parsed: ParsedKimiState; mtime: Date; mtimeMs: number } | null = null;
let best: { dir: string; sessionId: string; mtime: Date; mtimeMs: number } | null = null;
for (const entry of entries) {
const dir = join(root, entry);
const stateFile = join(dir, "state.json");
try {
const raw = await readFile(stateFile, "utf-8");
const parsed = parseKimiState(raw);
if (!parsed || parsed.cwd !== workspacePath) continue;
const s = await stat(stateFile);
if (!best || s.mtimeMs > best.mtimeMs) {
best = { dir, parsed, mtime: s.mtime, mtimeMs: s.mtimeMs };
}
} catch {
// Missing/unreadable/non-JSON state.json — skip this entry.
const dir = join(bucket, entry);
const mtime = await getKimiLiveSignalMtime(dir);
if (!mtime) continue;
const mtimeMs = mtime.getTime();
if (!best || mtimeMs > best.mtimeMs) {
best = { dir, sessionId: entry, mtime, mtimeMs };
}
}
if (!best) return null;
return {
dir: best.dir,
state: {
sessionId: best.parsed.sessionId,
model: best.parsed.model,
title: best.parsed.title,
},
stateMtime: best.mtime,
};
return best ? { dir: best.dir, sessionId: best.sessionId, mtime: best.mtime } : null;
}
/** Cached wrapper around findKimiSessionMatchUncached. */
@ -163,24 +138,57 @@ async function findKimiSessionMatch(workspacePath: string): Promise<KimiSessionM
return match;
}
/** Max chars we keep from a wire.jsonl user-input summary. */
const SUMMARY_MAX_CHARS = 120;
/** Max bytes of wire.jsonl we read looking for the first TurnBegin. */
const SUMMARY_SCAN_BYTE_LIMIT = 1_000_000;
/**
* Get the mtime of the freshest live signal inside a Kimi session directory.
* context.jsonl / wire.jsonl update on every agent turn, so they're the real
* freshness indicators. state.json is omitted because its mtime is already
* captured in KimiSessionMatch.stateMtime (the caller folds it in).
* Probed in parallel to avoid serial filesystem roundtrips.
* Extract the first user prompt from a session's wire.jsonl as a fallback
* summary. Stops after the first TurnBegin or after reading ~1 MB (whichever
* comes first) so we never slurp huge session logs.
*/
async function getKimiLiveSignalMtime(sessionDir: string): Promise<Date | null> {
const stats = await Promise.all(
["context.jsonl", "wire.jsonl"].map((name) =>
stat(join(sessionDir, name)).catch(() => null),
),
);
let newest: Date | null = null;
for (const s of stats) {
if (s && (!newest || s.mtimeMs > newest.getTime())) newest = s.mtime;
async function extractKimiSummary(sessionDir: string): Promise<string | null> {
const wirePath = join(sessionDir, "wire.jsonl");
let summary: string | null = null;
try {
const rl = createInterface({
input: createReadStream(wirePath, { encoding: "utf-8" }),
crlfDelay: Infinity,
});
let bytes = 0;
for await (const line of rl) {
bytes += line.length;
if (bytes > SUMMARY_SCAN_BYTE_LIMIT) break;
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed: unknown = JSON.parse(trimmed);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
const entry = parsed as Record<string, unknown>;
const message = entry["message"];
if (!message || typeof message !== "object" || Array.isArray(message)) continue;
const msg = message as Record<string, unknown>;
if (msg["type"] !== "TurnBegin") continue;
const payload = msg["payload"];
if (!payload || typeof payload !== "object" || Array.isArray(payload)) continue;
const userInput = (payload as Record<string, unknown>)["user_input"];
if (typeof userInput === "string" && userInput.length > 0) {
summary =
userInput.length > SUMMARY_MAX_CHARS
? userInput.slice(0, SUMMARY_MAX_CHARS) + "..."
: userInput;
break;
}
} catch {
// Skip malformed line
}
}
rl.close();
} catch {
return null;
}
return newest;
return summary;
}
// =============================================================================
@ -319,21 +327,15 @@ function createKimicodeAgent(): Agent {
const activityState = checkActivityLogState(activityResult);
if (activityState) return activityState;
// 3. Native signal — freshest mtime across state.json (from the cached
// match) and the per-turn context.jsonl / wire.jsonl files inside the
// matching ~/.kimi/<session>/ directory. state.json's mtime is reused
// from the match rather than re-stat'd.
// 3. Native signal — mtime of the freshest live file (context.jsonl /
// wire.jsonl) inside ~/.kimi/sessions/<md5(cwd)>/<uuid>/. The match
// already captured the mtime during the scan, so no re-stat here.
const match = await findKimiSessionMatch(session.workspacePath);
if (match) {
const liveMtime = await getKimiLiveSignalMtime(match.dir);
const mtime =
liveMtime && liveMtime.getTime() > match.stateMtime.getTime()
? liveMtime
: match.stateMtime;
const ageMs = Math.max(0, Date.now() - mtime.getTime());
if (ageMs <= activeWindowMs) return { state: "active", timestamp: mtime };
if (ageMs <= threshold) return { state: "ready", timestamp: mtime };
return { state: "idle", timestamp: mtime };
const ageMs = Math.max(0, Date.now() - match.mtime.getTime());
if (ageMs <= activeWindowMs) return { state: "active", timestamp: match.mtime };
if (ageMs <= threshold) return { state: "ready", timestamp: match.mtime };
return { state: "idle", timestamp: match.mtime };
}
// 4. JSONL entry fallback (MANDATORY) — uses the last AO activity entry
@ -411,14 +413,14 @@ function createKimicodeAgent(): Agent {
const match = await findKimiSessionMatch(session.workspacePath);
if (!match) return null;
const { state } = match;
const summary = state.title ?? (state.model ? `Kimi session (${state.model})` : null);
// Best-effort summary: first user input from wire.jsonl. Kimi does not
// store a title, model, or cost breakdown on disk.
const summary = await extractKimiSummary(match.dir);
return {
summary,
summaryIsFallback: true,
agentSessionId: state.sessionId,
// Kimi does not expose token/cost data in state.json.
agentSessionId: match.sessionId,
};
},
@ -431,23 +433,10 @@ function createKimicodeAgent(): Agent {
const configuredModel =
typeof project.agentConfig?.model === "string" ? project.agentConfig.model : undefined;
if (!match.state.sessionId) {
// Fall back to `--continue` — resumes the latest session for the
// current working directory. The runtime spawns kimi with cwd set to
// the workspace, so this is safe.
const parts: string[] = ["kimi", "--continue"];
appendApprovalFlags(parts, project.agentConfig?.permissions);
if (configuredModel) {
parts.push("--model", shellEscape(configuredModel));
}
return parts.join(" ");
}
const parts: string[] = ["kimi", "--resume", shellEscape(match.state.sessionId)];
const parts: string[] = ["kimi", "--resume", shellEscape(match.sessionId)];
appendApprovalFlags(parts, project.agentConfig?.permissions);
const effectiveModel = configuredModel ?? match.state.model ?? undefined;
if (effectiveModel) {
parts.push("--model", shellEscape(effectiveModel));
if (configuredModel) {
parts.push("--model", shellEscape(configuredModel));
}
return parts.join(" ");
},