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:
parent
2093126a48
commit
bc8b50d2db
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(" ");
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue