diff --git a/src/agent-runtime/omp-extension.mjs b/src/agent-runtime/omp-extension.mjs index cdf8956a..0d90b6fd 100644 --- a/src/agent-runtime/omp-extension.mjs +++ b/src/agent-runtime/omp-extension.mjs @@ -12,7 +12,7 @@ export default function CanvasTTYLifecycle(api) { } } - for (const event of ["session_start", "session_switch", "session_branch"]) { + for (const event of ["session_start", "session_switch", "session_branch", "session_fork"]) { api.on(event, (_event, ctx) => report(ctx, "idle", event)); } api.on("agent_start", (_event, ctx) => report(ctx, "working", "UserPromptSubmit")); diff --git a/src/agent-runtime/opencode-plugin.mjs b/src/agent-runtime/opencode-plugin.mjs index e0bda1c8..04d8b84d 100644 --- a/src/agent-runtime/opencode-plugin.mjs +++ b/src/agent-runtime/opencode-plugin.mjs @@ -31,7 +31,8 @@ async function lifecycleEvent(event, decisions) { // A call a plugin allowed is answered for every session of this OpenCode (subagents included). if (event.type === "permission.asked" && await decisions.permissionAsked(properties)) return; - if (event.type === "session.created") { + // A resumed root session emits updates, not a second creation event. + if (event.type === "session.created" || (!rootSessionId && event.type === "session.updated")) { const session = info ?? properties; if (session.parentID || session.parentId) return; rootSessionId = stringField(session.id, sessionId); diff --git a/src/agent-runtime/runtime-protocol.mjs b/src/agent-runtime/runtime-protocol.mjs index 3bcac236..4aad8753 100644 --- a/src/agent-runtime/runtime-protocol.mjs +++ b/src/agent-runtime/runtime-protocol.mjs @@ -31,7 +31,11 @@ const MINIMAX_SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; export function normalizeThreadId(provider, value) { if (typeof value !== "string") return undefined; - if (["codex", "claude", "grok", "qwen", "kimi", "pi", "omp", "cursor"].includes(provider)) { + if (provider === "kimi") { + const uuid = value.startsWith("session_") ? value.slice(8) : value.startsWith("ses_") ? value.slice(4) : value; + return CANONICAL_UUID_RE.test(uuid) ? value.toLowerCase() : undefined; + } + if (["codex", "claude", "grok", "qwen", "pi", "omp", "cursor"].includes(provider)) { return CANONICAL_UUID_RE.test(value) ? value.toLowerCase() : undefined; } if (provider === "opencode") return OPENCODE_SESSION_RE.test(value) ? value : undefined; diff --git a/src/main/services/AgentChatHistoryService.ts b/src/main/services/AgentChatHistoryService.ts index 8be199ea..0b361744 100644 --- a/src/main/services/AgentChatHistoryService.ts +++ b/src/main/services/AgentChatHistoryService.ts @@ -39,7 +39,7 @@ export class AgentChatHistoryService { opencode: opencodeHistory(paths.opencode), claude: jsonlHistory("claude", paths.claude), qwen: jsonlHistory("qwen", paths.qwen), - kimi: kimiHistory(paths.kimi), + kimi: kimiHistory(paths.kimi, paths.kimiCode), omp: jsonlHistory("omp", paths.omp), pi: jsonlHistory("pi", paths.pi), cursor: cursorHistory(paths.cursor), diff --git a/src/main/services/agent-history/historyPaths.ts b/src/main/services/agent-history/historyPaths.ts index 8b32f476..6f884e85 100644 --- a/src/main/services/agent-history/historyPaths.ts +++ b/src/main/services/agent-history/historyPaths.ts @@ -54,6 +54,7 @@ export function resolveAgentHistoryPaths(options: HistoryPathOptions = {}) { claude: path.join(env("CLAUDE_CONFIG_DIR") || path.join(home, ".claude"), "projects"), qwen: [path.join(qwen, "projects"), path.join(qwen, "tmp")], kimi: env("KIMI_SHARE_DIR") || path.join(home, ".kimi"), + kimiCode: env("KIMI_CODE_HOME") || path.join(home, ".kimi-code"), omp: env("PI_CODING_AGENT_SESSION_DIR") || path.join(ompData, "sessions"), pi: env("PI_CODING_AGENT_SESSION_DIR") ? expand(env("PI_CODING_AGENT_SESSION_DIR")!) : path.join(expand(env("PI_CODING_AGENT_DIR") || path.join(home, ".pi", "agent")), "sessions"), diff --git a/src/main/services/agent-history/kimiHistory.ts b/src/main/services/agent-history/kimiHistory.ts index 32281a67..22a4b832 100644 --- a/src/main/services/agent-history/kimiHistory.ts +++ b/src/main/services/agent-history/kimiHistory.ts @@ -1,11 +1,71 @@ import { createHash } from "node:crypto"; import { lstat, readdir } from "node:fs/promises"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { normalizeThreadId } from "../../../agent-runtime/runtime-protocol.mjs"; import type { AgentChatHistoryItem } from "../../../shared/contracts.ts"; -import { historyItem, historyPromptTitle, missing, readJson, readJsonMetadata, record, type HistoryAdapter } from "./historyFiles.ts"; +import { historyFiles, historyItem, historyPromptTitle, missing, readJson, readJsonLines, readJsonMetadata, record, type HistoryAdapter } from "./historyFiles.ts"; -export function kimiHistory(home: string): HistoryAdapter { +export function kimiHistory(home: string, codeHome?: string): HistoryAdapter { + const legacy = legacyKimiHistory(home); + return { async read(signal) { + if (!codeHome) return legacy.read(signal); + let modern: Awaited> | undefined; + let modernError: unknown; + try { modern = await kimiCodeHistory(codeHome).read(signal); } + catch (error) { if (!missing(error)) throw error; modernError = error; } + let old: Awaited> | undefined; + let legacyError: unknown; + try { old = await legacy.read(signal); } + catch (error) { if (!missing(error)) throw error; legacyError = error; } + if (!modern && !old) throw legacyError ?? modernError; + const modernItems = modern?.items ?? []; + const migratedLegacyIds = new Set(modernItems.map(legacyIdForModern).filter((id): id is string => Boolean(id))); + return { + items: [...modernItems, ...(old?.items ?? []).filter(item => !migratedLegacyIds.has(item.id))], + skipped: (modern?.skipped ?? 0) + (old?.skipped ?? 0) + }; + } }; +} + +function legacyIdForModern(item: AgentChatHistoryItem): string | undefined { + const native = item.id.startsWith("session_") ? item.id.slice(8) : item.id.startsWith("ses_") ? item.id.slice(4) : ""; + return normalizeThreadId("kimi", native); +} + +function kimiCodeHistory(home: string): HistoryAdapter { + return { async read(signal) { + const items: AgentChatHistoryItem[] = []; + let skipped = 0; + for await (const path of historyFiles(join(home, "sessions"), name => name === "state.json", signal)) { + const id = basename(dirname(path)); + if ((!id.startsWith("session_") && !id.startsWith("ses_")) || !normalizeThreadId("kimi", id)) continue; + try { + const state = record(await readJson(path)); + if (state.id !== id || state.version !== 2) { skipped += 1; continue; } + if (record(state.custom).child_session_kind === "child") continue; + let title = historyPromptTitle(state.title) || historyPromptTitle(state.lastPrompt); + if (!title) { + try { + await readJsonLines(join(dirname(path), "agents", "main", "wire.jsonl"), value => { + const row = record(value); + if (row.type === "turn.prompt" && row.agentId === "main" && record(row.origin).kind === "user") { + title = historyPromptTitle(row.input) || title; + } + }, signal, line => /"type"\s*:\s*"turn\.prompt"/.test(line)); + } catch (error) { if (!missing(error)) throw error; } + } + const item = historyItem("kimi", { + id, cwd: state.cwd, title, + lastActivityAt: state.updatedAt ?? state.createdAt + }); + if (item) items.push(item); else skipped += 1; + } catch { signal.throwIfAborted(); skipped += 1; } + } + return { items, skipped }; + } }; +} + +function legacyKimiHistory(home: string): HistoryAdapter { return { async read(signal) { signal.throwIfAborted(); const metadata = record(await readJson(join(home, "kimi.json"))); diff --git a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts index ea0f2181..3d3b6168 100644 --- a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts +++ b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts @@ -100,7 +100,7 @@ export interface PreparedProviderRuntimeLaunch { releaseConfiguration(): void; } -const HOOK_PROVIDERS: ReadonlySet = new Set(["claude", "codex", "qwen", "opencode", "kimi", "hermes", "grok", "omp"]); +const HOOK_PROVIDERS: ReadonlySet = new Set(["claude", "codex", "qwen", "opencode", "kimi", "hermes", "grok", "omp", "pi"]); export class ProviderRuntimeLaunchAdapters { private readonly options: ProviderRuntimeLaunchOptions; @@ -187,8 +187,8 @@ export class ProviderRuntimeLaunchAdapters { ...(gate && provider !== "opencode" ? decisionHookCommands(provider as DecisionHookProvider, this.options.permissionGate!, this.platform, decisionBudgetMs) : []) ]; const openCodeDecisions = gate && provider === "opencode"; - // Only providers with a hook adapter get lifecycle configuration. Anything else (pi, - // cursor, minimax, devin, antigravity) must never reach Grok's shared hook overlay. + // Only providers with a hook adapter get lifecycle configuration. Cursor, + // MiniMax, Devin and Antigravity must never reach Grok's shared hook overlay. const hasHooks = HOOK_PROVIDERS.has(provider) && (coreHooksEnabled || pluginCommands.length > 0 || (provider === "opencode" && (pluginRegistrations.length > 0 || openCodeDecisions))); const environment = hasHooks @@ -235,7 +235,7 @@ export class ProviderRuntimeLaunchAdapters { ) }); } - if (provider === "omp") { + if (provider === "omp" || provider === "pi") { return prepared(coreHooksEnabled ? ["--extension", join(dirname(this.options.openCodePluginPath), "omp-extension.mjs")] : [], environment); diff --git a/tests/agent-chat-history.test.mjs b/tests/agent-chat-history.test.mjs index f430907c..d77254d8 100644 --- a/tests/agent-chat-history.test.mjs +++ b/tests/agent-chat-history.test.mjs @@ -23,6 +23,67 @@ async function jsonl(path, rows) { await writeFile(path, rows.map(row => JSON.stringify(row)).join("\n") + "\n"); await utimes(path, at / 1000, at / 1000); } +async function legacyConversation(home, cwd, sessionId, title = "") { + await mkdir(home, { recursive: true }); + await writeFile(join(home, "kimi.json"), JSON.stringify({ work_dirs: [{ path: cwd }] })); + const dir = join(home, "sessions", createHash("md5").update(cwd).digest("hex"), sessionId); + await mkdir(dir, { recursive: true }); + await jsonl(join(dir, "context.jsonl"), [{ role: "user", content: title || "Legacy prompt" }]); + if (title) await writeFile(join(dir, "state.json"), JSON.stringify({ custom_title: title })); +} +async function modernState(home, sessionId, fields = {}) { + const dir = join(home, "sessions", "wd_project", sessionId); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "state.json"), JSON.stringify({ + id: sessionId, version: 2, cwd: fields.cwd, title: fields.title || "Modern title", + createdAt: at - 1000, updatedAt: at, custom: {}, ...fields + })); +} + +test("Kimi retains legacy conversations when the modern sessions store is empty", async t => { + const root = await directory(t); + const legacy = join(root, "legacy"); + const modern = join(root, "modern"); + await legacyConversation(legacy, root, id, "Legacy title"); + await mkdir(join(modern, "sessions"), { recursive: true }); + const result = await kimiHistory(legacy, modern).read(signal()); + assert.deepEqual(result.items.map(item => item.id), [id]); +}); + +test("Kimi retains legacy conversations when modern records are excluded or invalid", async t => { + const root = await directory(t); + const legacy = join(root, "legacy"); + const modern = join(root, "modern"); + await legacyConversation(legacy, root, id, "Legacy title"); + await modernState(modern, `session_${id}`, { custom: { child_session_kind: "child" } }); + await modernState(modern, "session_22222222-2222-4222-8222-222222222222", { version: 1 }); + const result = await kimiHistory(legacy, modern).read(signal()); + assert.deepEqual(result.items.map(item => item.id), [id]); +}); + +test("Kimi prefers the native ID for migrated history and keeps distinct legacy chats with equal titles", async t => { + const root = await directory(t); + const legacy = join(root, "legacy"); + const modern = join(root, "modern"); + const otherId = "22222222-2222-4222-8222-222222222222"; + await legacyConversation(legacy, root, id, "Same title"); + await legacyConversation(legacy, root, otherId, "Same title"); + await modernState(modern, `session_${id}`, { + title: "Migrated renamed title", + custom: { imported_from_kimi_cli: true, kimi_cli_session_id: id } + }); + const result = await kimiHistory(legacy, modern).read(signal()); + assert.deepEqual(result.items.map(item => item.id), [`session_${id}`, otherId]); + assert.equal(result.items[0].title, "Migrated renamed title"); +}); + +test("Kimi accepts the native ses ID emitted by the legacy migrator", async t => { + const root = await directory(t); + const modern = join(root, "modern"); + await modernState(modern, `ses_${id}`, { cwd: root, title: "Imported" }); + const result = await kimiHistory(join(root, "missing-legacy"), modern).read(signal()); + assert.deepEqual(result.items.map(item => item.id), [`ses_${id}`]); +}); test("OpenCode activity stays in milliseconds", async t => { const root = await directory(t); @@ -35,6 +96,55 @@ test("OpenCode activity stays in milliseconds", async t => { assert.deepEqual(result.items, [{ provider: "opencode", id: "ses_abc123", title: "Project chat", cwd: root, lastActivityAt: at }]); }); +test("Kimi Code reads current session metadata, omits child sessions and prefers it over migrated legacy copies", async t => { + const root = await directory(t); + const legacy = join(root, "legacy"); + const current = join(root, "current"); + const currentId = `session_${id}`; + const session = join(current, "sessions", "wd_project", currentId); + await mkdir(session, { recursive: true }); + await mkdir(legacy); + await writeFile(join(legacy, "kimi.json"), JSON.stringify({ work_dirs: [{ path: root }] })); + const migrated = join(legacy, "sessions", createHash("md5").update(root).digest("hex"), id); + await mkdir(migrated, { recursive: true }); + await jsonl(join(migrated, "context.jsonl"), [{ role: "user", content: "Outdated legacy title" }]); + await writeFile(join(session, "state.json"), JSON.stringify({ + id: currentId, version: 2, cwd: root, title: "Renamed current chat", + createdAt: at - 1000, updatedAt: at, custom: {}, agents: { main: { type: "main" } } + })); + const childId = "session_22222222-2222-4222-8222-222222222222"; + const child = join(current, "sessions", "wd_project", childId); + await mkdir(child, { recursive: true }); + await writeFile(join(child, "state.json"), JSON.stringify({ + id: childId, version: 2, cwd: root, updatedAt: at, custom: { child_session_kind: "child", parent_session_id: currentId } + })); + const result = await kimiHistory(legacy, current).read(signal()); + assert.deepEqual(result.items, [{ provider: "kimi", id: currentId, cwd: root, title: "Renamed current chat", lastActivityAt: at }]); + const launch = resolveTerminalLaunch("kimi", "normal", [], { + providerCli: { provider: "kimi", state: "available", executable: "/resolved/kimi", launcher: "native", environment: {} }, + resumePrevious: true, resumeThreadId: currentId + }); + assert.deepEqual(launch.args, ["--session", currentId]); +}); + +test("Kimi Code derives an unnamed chat's title from its last user prompt in the main agent wire log", async t => { + const root = await directory(t); + const currentId = `session_${id}`; + const session = join(root, "sessions", "wd_project", currentId); + await mkdir(join(session, "agents", "main"), { recursive: true }); + await writeFile(join(session, "state.json"), JSON.stringify({ + id: currentId, version: 2, cwd: root, updatedAt: at, custom: {} + })); + await jsonl(join(session, "agents", "main", "wire.jsonl"), [ + { type: "profile.bind", systemPrompt: "x".repeat(80 * 1024) }, + { type: "turn.prompt", agentId: "main", origin: { kind: "user" }, input: [{ type: "text", text: "First question" }] }, + { type: "turn.prompt", agentId: "main", origin: { kind: "user" }, input: [{ type: "text", text: "Latest question" }] }, + { type: "turn.prompt", agentId: "main", origin: { kind: "system" }, input: [{ type: "text", text: "Internal reminder" }] } + ]); + const result = await kimiHistory(join(root, "legacy"), root).read(signal()); + assert.equal(result.items[0]?.title, "Latest question"); +}); + test("a complete metadata record exactly at the tail boundary is retained", async t => { const root = await directory(t); const path = join(root, "boundary.jsonl"); @@ -89,6 +199,7 @@ test("Kimi lists local workspaces with custom titles and excludes remote workspa await writeFile(join(dir, "state.json"), JSON.stringify({ custom_title: "Renamed" })); const result = await kimiHistory(root).read(signal()); assert.deepEqual(result.items, [{ provider: "kimi", id, cwd: root, title: "Renamed", lastActivityAt: at }]); + assert.deepEqual(await kimiHistory(root, join(root, "absent-current-store")).read(signal()), result); }); test("MiniMax extracts only metadata from the confirmed runtime store", async t => { diff --git a/tests/agent-history-paths.test.mjs b/tests/agent-history-paths.test.mjs index 1e060310..a1e64e5c 100644 --- a/tests/agent-history-paths.test.mjs +++ b/tests/agent-history-paths.test.mjs @@ -13,7 +13,7 @@ const { outputFiles } = await build({ const { AgentChatHistoryService } = await import(`data:text/javascript;base64,${Buffer.from(outputFiles[0].contents).toString("base64")}`); const id = "11111111-1111-4111-8111-111111111111"; -const pathVariables = ["OPENCODE_HOME", "XDG_DATA_HOME", "PI_CONFIG_DIR", "PI_CODING_AGENT_DIR", +const pathVariables = ["OPENCODE_HOME", "XDG_DATA_HOME", "PI_CONFIG_DIR", "PI_CODING_AGENT_DIR", "KIMI_CODE_HOME", "KIMI_SHARE_DIR", "PI_CODING_AGENT_SESSION_DIR", "OMP_PROFILE", "PI_PROFILE", "QWEN_CODE_HOME", "QWEN_HOME", "QWEN_RUNTIME_DIR"]; async function setup(t, environment) { @@ -62,6 +62,16 @@ test("history service honors Pi session-directory override", async t => { assert.equal(page.items[0]?.id, id); }); +test("history service finds Kimi Code in KIMI_CODE_HOME and preserves its native prefixed id", async t => { + const { root, service } = await setup(t, root => ({ KIMI_CODE_HOME: root, KIMI_SHARE_DIR: join(root, "legacy") })); + const session = join(root, "sessions", "wd_project", `session_${id}`); + await mkdir(session, { recursive: true }); + await writeFile(join(session, "state.json"), JSON.stringify({ id: `session_${id}`, version: 2, cwd: root, updatedAt: Date.now(), title: "Current chat", custom: {} })); + const page = await service.list("kimi"); + assert.equal(page.error, undefined); + assert.equal(page.items[0]?.id, `session_${id}`); +}); + test("history service finds Qwen's current projects and legacy tmp stores without duplicates", async t => { const { root, service } = await setup(t, root => ({ QWEN_RUNTIME_DIR: root })); await transcript(join(root, "projects", "workspace", "chats"), "qwen"); @@ -114,21 +124,21 @@ for (const [platform, home, expected] of [ ["darwin", "/Users/Alice Smith", { codex: "/Users/Alice Smith/.codex", grok: "/Users/Alice Smith/.grok", opencode: "/Users/Alice Smith/.local/share/opencode", claude: "/Users/Alice Smith/.claude/projects", - qwen: ["/Users/Alice Smith/.qwen/projects", "/Users/Alice Smith/.qwen/tmp"], kimi: "/Users/Alice Smith/.kimi", + qwen: ["/Users/Alice Smith/.qwen/projects", "/Users/Alice Smith/.qwen/tmp"], kimi: "/Users/Alice Smith/.kimi", kimiCode: "/Users/Alice Smith/.kimi-code", omp: "/Users/Alice Smith/.omp/agent/sessions", pi: "/Users/Alice Smith/.pi/agent/sessions", cursor: "/Users/Alice Smith/.cursor", minimax: "/Users/Alice Smith/.minimax" }], ["linux", "/home/runner", { codex: "/home/runner/.codex", grok: "/home/runner/.grok", opencode: "/home/runner/.local/share/opencode", claude: "/home/runner/.claude/projects", - qwen: ["/home/runner/.qwen/projects", "/home/runner/.qwen/tmp"], kimi: "/home/runner/.kimi", + qwen: ["/home/runner/.qwen/projects", "/home/runner/.qwen/tmp"], kimi: "/home/runner/.kimi", kimiCode: "/home/runner/.kimi-code", omp: "/home/runner/.omp/agent/sessions", pi: "/home/runner/.pi/agent/sessions", cursor: "/home/runner/.cursor", minimax: "/home/runner/.minimax" }], ["win32", "C:\\Users\\Alice Smith", { codex: "C:\\Users\\Alice Smith\\.codex", grok: "C:\\Users\\Alice Smith\\.grok", opencode: "C:\\Users\\Alice Smith\\.local\\share\\opencode", claude: "C:\\Users\\Alice Smith\\.claude\\projects", - qwen: ["C:\\Users\\Alice Smith\\.qwen\\projects", "C:\\Users\\Alice Smith\\.qwen\\tmp"], kimi: "C:\\Users\\Alice Smith\\.kimi", + qwen: ["C:\\Users\\Alice Smith\\.qwen\\projects", "C:\\Users\\Alice Smith\\.qwen\\tmp"], kimi: "C:\\Users\\Alice Smith\\.kimi", kimiCode: "C:\\Users\\Alice Smith\\.kimi-code", omp: "C:\\Users\\Alice Smith\\.omp\\agent\\sessions", pi: "C:\\Users\\Alice Smith\\.pi\\agent\\sessions", cursor: "C:\\Users\\Alice Smith\\.cursor", minimax: "C:\\Users\\Alice Smith\\.minimax" }] diff --git a/tests/agent-runtime-gateway.test.mjs b/tests/agent-runtime-gateway.test.mjs index e2acda83..e56f5c36 100644 --- a/tests/agent-runtime-gateway.test.mjs +++ b/tests/agent-runtime-gateway.test.mjs @@ -27,7 +27,10 @@ async function fixture(t) { return root; } -test("RuntimeGateway accepts one authenticated hook event over a mode-0600 local socket", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { +for (const [provider, threadId] of [ + ["claude", "5f1c2a90-aa11-4b22-9c33-0d44e55f6677"], + ["kimi", "session_5f1c2a90-aa11-4b22-9c33-0d44e55f6677"] +]) test(`RuntimeGateway accepts an authenticated ${provider} hook with its native session ID`, POSIX_RUNTIME_GATEWAY_TEST, async (t) => { const root = await fixture(t); const signals = []; const gateway = new RuntimeGateway({ runtimeDirectory: root, onSignal: (id, signal) => signals.push({ id, signal }) }); @@ -35,7 +38,7 @@ test("RuntimeGateway accepts one authenticated hook event over a mode-0600 local t.after(() => gateway.close()); assert.equal((await stat(address)).mode & 0o777, 0o600); - const capability = gateway.registerSession("terminal-one", "claude"); + const capability = gateway.registerSession("terminal-one", provider); const helper = new URL("../src/agent-runtime/hook-helper.mjs", import.meta.url); const child = spawn(process.execPath, [helper.pathname, "working", "UserPromptSubmit"], { env: { @@ -47,7 +50,7 @@ test("RuntimeGateway accepts one authenticated hook event over a mode-0600 local }, stdio: ["pipe", "ignore", "pipe"] }); - child.stdin.end(JSON.stringify({ prompt: "must stay local", prompt_id: "turn-one", session_id: "5f1c2a90-aa11-4b22-9c33-0d44e55f6677" })); + child.stdin.end(JSON.stringify({ prompt: "must stay local", prompt_id: "turn-one", session_id: threadId })); const result = await childResult(child); assert.equal(result.code, 0, result.stderr); assert.deepEqual(signals, [{ @@ -56,7 +59,7 @@ test("RuntimeGateway accepts one authenticated hook event over a mode-0600 local state: "working", event: "UserPromptSubmit", turnId: "turn-one", - threadId: "5f1c2a90-aa11-4b22-9c33-0d44e55f6677" + threadId } }]); assert.equal(JSON.stringify(signals).includes("must stay local"), false); @@ -386,6 +389,40 @@ test("OpenCode question dialogs report needs-input and resume working afterward" ]); }); +test("OpenCode resumed sessions bind from native updates before status events, while child updates remain isolated", POSIX_RUNTIME_GATEWAY_TEST, async t => { + const root = await fixture(t); + const signals = []; + const gateway = new RuntimeGateway({ runtimeDirectory: root, onSignal: (_, signal) => signals.push(signal) }); + await gateway.start(); + t.after(() => gateway.close()); + const capability = gateway.registerSession("terminal-resumed-opencode", "opencode"); + const previous = Object.fromEntries(Object.values(AGENT_RUNTIME_ENV).map(key => [key, process.env[key]])); + const previousEnabled = process.env.CANVASTTY_LIFECYCLE_HOOKS_ENABLED; + for (const [key, value] of Object.entries(capability)) { + if (AGENT_RUNTIME_ENV[key]) process.env[AGENT_RUNTIME_ENV[key]] = value; + } + process.env.CANVASTTY_LIFECYCLE_HOOKS_ENABLED = "1"; + t.after(() => { + for (const [key, value] of Object.entries({ ...previous, CANVASTTY_LIFECYCLE_HOOKS_ENABLED: previousEnabled })) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + }); + const { CanvasTTYLifecycle } = await import("../src/agent-runtime/opencode-plugin.mjs?resumed-history-test"); + const plugin = await CanvasTTYLifecycle(); + const id = "ses_resumed123"; + await plugin.event({ event: { type: "session.updated", properties: { info: { id: "ses_child123", parentID: id } } } }); + assert.equal(signals.length, 0); + await plugin.event({ event: { type: "session.updated", properties: { info: { id } } } }); + await plugin.event({ event: { type: "session.status", properties: { sessionID: id, status: { type: "busy" } } } }); + await plugin.event({ event: { type: "session.updated", properties: { info: { id: "ses_other123" } } } }); + await plugin.event({ event: { type: "session.status", properties: { sessionID: "ses_other123", status: { type: "idle" } } } }); + await plugin.event({ event: { type: "session.idle", properties: { sessionID: id } } }); + assert.deepEqual(signals.map(s => [s.state, s.event]), [ + ["idle", "session.updated"], ["working", "session.status:busy"], ["idle", "session.idle"] + ]); + assert.equal(signals[0].threadId, id); +}); + function message(capability, state, event, turnId) { return { v: RUNTIME_PROTOCOL_VERSION, diff --git a/tests/agent-runtime-provider-launch.test.mjs b/tests/agent-runtime-provider-launch.test.mjs index 43db66bd..9914d0b2 100644 --- a/tests/agent-runtime-provider-launch.test.mjs +++ b/tests/agent-runtime-provider-launch.test.mjs @@ -85,7 +85,7 @@ test("revoking CanvasTTY lifecycle hooks leaves every provider launch unmodified test("providers without a hook adapter never write Grok's shared hook configuration", async (t) => { const root = await fixture(t); const adapters = adaptersFor(root); - for (const provider of ["pi", "cursor", "minimax", "devin", "antigravity"]) { + for (const provider of ["cursor", "minimax", "devin", "antigravity"]) { const launch = adapters.prepare(provider, `session-${provider}`, true); assert.deepEqual(launch.args, [], provider); assert.deepEqual(launch.environment, {}, provider); @@ -103,6 +103,13 @@ test("ordinary OMP launches load the lifecycle extension without modifying share launch.releaseConfiguration(); }); +test("ordinary Pi launches receive a session identity extension", async t => { + const root = await fixture(t); + const launch = adaptersFor(root).prepare("pi", "session-pi"); + assert.deepEqual(launch.args, ["--extension", join(root, "omp-extension.mjs")]); + launch.releaseConfiguration(); +}); + test("a revoked shared-config launch removes stale hooks held by an older live session", async (t) => { const root = await fixture(t); const adapters = adaptersFor(root); diff --git a/tests/omp-runtime-extension.test.mjs b/tests/omp-runtime-extension.test.mjs index 36f2a048..71baac9d 100644 --- a/tests/omp-runtime-extension.test.mjs +++ b/tests/omp-runtime-extension.test.mjs @@ -11,7 +11,7 @@ const FIRST = "11111111-1111-4111-8111-111111111111"; const SECOND = "22222222-2222-4222-8222-222222222222"; const THIRD = "33333333-3333-4333-8333-333333333333"; -test("OMP identifies ordinary launches, keeps same-project sessions separate, and follows session switches", { +for (const provider of ["omp", "pi"]) test(`${provider} identifies ordinary launches, keeps same-project sessions separate, and follows session switches`, { skip: process.platform === "win32" ? "POSIX runtime socket integration" : false }, async t => { const { default: extension } = await import("../src/agent-runtime/omp-extension.mjs").catch(error => { @@ -41,8 +41,8 @@ test("OMP identifies ordinary launches, keeps same-project sessions separate, an }); await gateway.start(); function create(threadId) { - const session = manager.create({ provider: "omp", profile: "normal", cwd: root, position: { x: 0, y: 0 } }); - const capability = gateway.registerSession(session.id, "omp"); + const session = manager.create({ provider, profile: "normal", cwd: root, position: { x: 0, y: 0 } }); + const capability = gateway.registerSession(session.id, provider); const handlers = new Map(); extension({ on: (event, handler) => handlers.set(event, handler) }); const context = { cwd: root, agent: { kind: "main", depth: 0 }, sessionManager: { getSessionId: () => threadId } }; @@ -58,8 +58,8 @@ test("OMP identifies ordinary launches, keeps same-project sessions separate, an const second = create(SECOND); await first.emit("session_start"); await second.emit("session_start"); - assert.equal(manager.findLocalConversation("omp", FIRST)?.id, first.session.id); - assert.equal(manager.findLocalConversation("omp", SECOND)?.id, second.session.id); + assert.equal(manager.findLocalConversation(provider, FIRST)?.id, first.session.id); + assert.equal(manager.findLocalConversation(provider, SECOND)?.id, second.session.id); await first.emit("agent_start"); assert.equal(manager.getMetadata(first.session.id).status, "working"); await first.emit("agent_end"); @@ -67,14 +67,17 @@ test("OMP identifies ordinary launches, keeps same-project sessions separate, an assert.equal(manager.getMetadata(first.session.id).turnCompleted, true); first.setThread(THIRD); await first.emit("session_switch"); - assert.equal(manager.findLocalConversation("omp", FIRST), null); - assert.equal(manager.findLocalConversation("omp", THIRD)?.id, first.session.id); + assert.equal(manager.findLocalConversation(provider, FIRST), null); + assert.equal(manager.findLocalConversation(provider, THIRD)?.id, first.session.id); first.setThread(FIRST); await first.emit("session_branch"); - assert.equal(manager.findLocalConversation("omp", FIRST)?.id, first.session.id); + assert.equal(manager.findLocalConversation(provider, FIRST)?.id, first.session.id); + first.setThread(THIRD); + await first.emit("session_fork"); + assert.equal(manager.findLocalConversation(provider, THIRD)?.id, first.session.id); const before = signals.length; await first.emit("session_start", { ...first.context, agent: { kind: "sub", depth: 0 }, sessionManager: { getSessionId: () => THIRD } }); await first.emit("agent_end", { ...first.context, agent: { kind: "sub", depth: 1 } }); assert.equal(signals.length, before, "subagents must not replace the terminal's conversation or status"); - assert.equal(manager.getMetadata(first.session.id).threadId, FIRST); + assert.equal(manager.getMetadata(first.session.id).threadId, THIRD); });