diff --git a/packages/plugins/agent-kimicode/src/index.test.ts b/packages/plugins/agent-kimicode/src/index.test.ts index 579b3fad3..60b8a42c3 100644 --- a/packages/plugins/agent-kimicode/src/index.test.ts +++ b/packages/plugins/agent-kimicode/src/index.test.ts @@ -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; 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///{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 { - // 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 { }; } -/** 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>); + 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>); + 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>); + 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>); + 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>; + // 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// 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>); - - 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 ()` 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>); - - 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>); - - 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>); - - 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>); - - 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 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 ", 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>); + 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>); - - 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>); - - 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>); + 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, + () => "kimi, version 1.38.0" as unknown as ReturnType, ); expect(detect()).toBe(true); }); @@ -886,7 +839,9 @@ describe("detect", () => { .mockImplementationOnce(() => "1.0.0" as unknown as ReturnType) .mockImplementationOnce( () => - "package: kimi-cli\nprotocol: mcp\n" as unknown as ReturnType, + "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, - ) - .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, - ) - .mockImplementationOnce(() => { - throw new Error("no such subcommand: info"); - }); - expect(detect()).toBe(false); - }); }); diff --git a/packages/plugins/agent-kimicode/src/index.ts b/packages/plugins/agent-kimicode/src/index.ts index 3e55960cb..ae5a1ece3 100644 --- a/packages/plugins/agent-kimicode/src/index.ts +++ b/packages/plugins/agent-kimicode/src/index.ts @@ -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/// */ 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 `. */ + 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; - 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(); /** - * 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 { + 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/// + * 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 { - 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 { - 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 { + 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; + const message = entry["message"]; + if (!message || typeof message !== "object" || Array.isArray(message)) continue; + const msg = message as Record; + if (msg["type"] !== "TurnBegin") continue; + const payload = msg["payload"]; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) continue; + const userInput = (payload as Record)["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// 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///. 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(" "); },