mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
fix: opencode v2 lifecycle reporting (#3757)
* fix: support opencode v2 lifecycle reporting * fix: repair Japanese docs * fix: ignore payload-less OpenCode events * fix: harden opencode v2 integration install and reporting Follow-ups on top of the V2 lifecycle support: - create `cli.json` when OpenCode has no V1 TUI preferences (`tui.json` or `kv.json`) to migrate, instead of only registering into an existing file - settle stalled socket attempts with a plain connect timer, and resend the latest lifecycle state after a failed delivery so the pane cannot get stuck - resolve the OpenCode state directory from `XDG_STATE_HOME` - remove the managed `herdr-opencode` directory on uninstall - document failed executions reporting `blocked`, keep the integration test environment independent of an inherited `XDG_STATE_HOME`, and simplify `reconcileBlockers` to reassign its map --------- Co-authored-by: Jonathan Liebig <jonathan.liebig@gmail.com>
This commit is contained in:
co-authored by
Jonathan Liebig
parent
38d002de59
commit
fb5525fc22
@@ -215,7 +215,11 @@ Install the OpenCode plugin:
|
||||
herdr integration install opencode
|
||||
```
|
||||
|
||||
Herdr writes the plugin to `~/.config/opencode/plugins/herdr-agent-state.js`. The OpenCode config directory must already exist. Uninstall removes only that plugin file.
|
||||
The integration supports OpenCode V1 `1.18.29` or later and OpenCode V2 (tested with beta `19242`). The OpenCode config directory must already exist. Herdr installs the server entrypoint at `~/.config/opencode/plugins/herdr-agent-state.js`, the shared TUI plugin at `herdr-tui-session.js`, and a V2 TUI entrypoint at `herdr-opencode/tui.js` in that config directory.
|
||||
|
||||
Install registers the V1 TUI plugin in `tui.jsonc` and the V2 TUI plugin in `cli.json`, preserving other preferences and plugins. If OpenCode still has V1 TUI preferences to import (`tui.json` or `kv.json`), Herdr defers registration so the first-start migration can run; start `opencode2` once and reinstall the integration afterward. Otherwise Herdr creates `cli.json` with the plugin registration. Restart the OpenCode TUI after installation. Uninstall removes the managed plugin files and their configuration entries.
|
||||
|
||||
V2 lifecycle reporting runs in the pane-local TUI, which associates events with its selected root session even when multiple panes share one OpenCode server. Completion and interruption clear the working state; pending permission requests, pending forms, and failed executions keep the pane blocked. V2 Mini and headless clients do not run the TUI plugin and therefore do not provide this lifecycle reporting.
|
||||
|
||||
The plugin reports lifecycle state and session identity while OpenCode runs inside a Herdr pane. After OpenCode emits a session-bearing event, Herdr can use the reported session id to resume the pane with `opencode --session <id>`. Native screen manifest detection remains available when the plugin is not installed.
|
||||
|
||||
|
||||
@@ -217,7 +217,11 @@ OpenCode プラグインをインストールします:
|
||||
herdr integration install opencode
|
||||
```
|
||||
|
||||
Herdr はプラグインを `~/.config/opencode/plugins/herdr-agent-state.js` に書き込みます。OpenCode の設定ディレクトリはあらかじめ存在している必要があります。アンインストールはそのプラグインファイルだけを削除します。
|
||||
この連携は OpenCode V1 `1.18.29` 以降と OpenCode V2(beta `19242` で検証済み)に対応します。OpenCode の設定ディレクトリはあらかじめ存在している必要があります。Herdr はサーバー側のエントリポイントを `~/.config/opencode/plugins/herdr-agent-state.js` に、共通 TUI プラグインを同じ設定ディレクトリの `herdr-tui-session.js` に、V2 TUI エントリポイントを `herdr-opencode/tui.js` にインストールします。
|
||||
|
||||
インストール時、V1 TUI プラグインを `tui.jsonc` に、V2 TUI プラグインを `cli.json` に登録し、他の設定やプラグインは保持します。OpenCode に移行すべき V1 TUI 設定(`tui.json` または `kv.json`)が残っている場合、Herdr は初回起動時の移行を妨げないよう登録を見送ります。その場合は一度 `opencode2` を起動してから連携を再インストールしてください。それ以外の場合は Herdr が `cli.json` を作成してプラグインを登録します。インストール後は OpenCode TUI を再起動してください。アンインストールは管理対象のプラグインファイルと設定項目を削除します。
|
||||
|
||||
V2 のライフサイクル報告はペイン内の TUI で実行され、複数のペインが同じ OpenCode サーバーを共有する場合も、選択されたルートセッションにイベントを対応付けます。完了時または中断時に working 状態を解除し、未処理の権限要求やフォーム、実行失敗は blocked 状態を維持します。V2 Mini とヘッドレスクライアントは TUI プラグインを実行しないため、このライフサイクル報告は利用できません。
|
||||
|
||||
このプラグインは、OpenCode が Herdr のペイン内で動いている間、ライフサイクル状態とセッション識別を報告します。OpenCode がセッション情報を含むイベントを発行した後、Herdr は報告されたセッション id を使って `opencode --session <id>` でペインを resume できます。プラグインがインストールされていないときは、スクリーンマニフェスト検出が引き続き利用できます。
|
||||
|
||||
|
||||
@@ -217,7 +217,11 @@ Herdr 的 Droid 钩子使用 `~/.factory`。Factory 配置目录必须已经存
|
||||
herdr integration install opencode
|
||||
```
|
||||
|
||||
Herdr 把插件写入 `~/.config/opencode/plugins/herdr-agent-state.js`。OpenCode 配置目录必须已经存在。卸载只删除那个插件文件。
|
||||
该集成支持 OpenCode V1 `1.18.29` 及更高版本,以及 OpenCode V2(已验证 beta `19242`)。OpenCode 配置目录必须已经存在。Herdr 将服务端入口安装到 `~/.config/opencode/plugins/herdr-agent-state.js`,并在该配置目录安装共享 TUI 插件 `herdr-tui-session.js` 和 V2 TUI 入口 `herdr-opencode/tui.js`。
|
||||
|
||||
安装会在 `tui.jsonc` 中注册 V1 TUI 插件,并在 `cli.json` 中注册 V2 TUI 插件,同时保留其他设置和插件。如果 OpenCode 仍有 V1 TUI 偏好需要迁移(`tui.json` 或 `kv.json`),Herdr 会推迟注册,以便首次启动时执行迁移;此时请先启动一次 `opencode2`,然后重新安装集成。否则 Herdr 会创建 `cli.json` 并注册插件。安装后请重启 OpenCode TUI。卸载会删除受管理的插件文件及其配置条目。
|
||||
|
||||
V2 生命周期上报在窗格本地的 TUI 中运行。即使多个窗格共享同一个 OpenCode 服务端,事件也只归属于该 TUI 选中的根会话。完成和中断会清除工作状态;待处理的权限请求、表单以及执行失败会使窗格保持阻塞。V2 Mini 和无界面客户端不运行 TUI 插件,因此不提供此生命周期上报。
|
||||
|
||||
该插件在 OpenCode 运行于 Herdr 窗格内时上报生命周期状态和会话身份。在 OpenCode 发出携带会话信息的事件后,Herdr 可以用上报的会话 id 通过 `opencode --session <id>` 恢复该窗格。插件未安装时,屏幕清单检测仍然可用。
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Re
|
||||
}
|
||||
crate::api::schema::IntegrationTarget::Opencode => {
|
||||
let installed = install_opencode()?;
|
||||
vec![
|
||||
let mut messages = vec![
|
||||
format!(
|
||||
"installed opencode integration plugin to {}",
|
||||
installed.plugin_path.display()
|
||||
@@ -157,7 +157,14 @@ fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Re
|
||||
"ensured opencode tui plugin config at {}",
|
||||
installed.tui_config_path.display()
|
||||
),
|
||||
]
|
||||
];
|
||||
if installed.cli_config_path.is_none() {
|
||||
messages.push(
|
||||
"to enable OpenCode V2, start opencode2 once, then reinstall this integration"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
messages
|
||||
}
|
||||
crate::api::schema::IntegrationTarget::Kilo => {
|
||||
let installed = install_kilo()?;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// managed by herdr; reinstalling or updating the integration overwrites this file.
|
||||
// add custom hooks/plugins beside this file instead of editing it.
|
||||
// HERDR_INTEGRATION_ID=opencode
|
||||
// HERDR_INTEGRATION_VERSION=11
|
||||
// HERDR_INTEGRATION_VERSION=12
|
||||
|
||||
import net from "node:net";
|
||||
|
||||
@@ -199,3 +199,12 @@ export const HerdrAgentStatePlugin = async () => {
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// V1 (1.18.29+) calls server(). V2 calls setup() instead. Its shared server
|
||||
// cannot attribute sessions using its process environment: the pane-local TUI
|
||||
// owns both selection and lifecycle reporting there, including remote servers.
|
||||
export default {
|
||||
id: "herdr.opencode",
|
||||
server: HerdrAgentStatePlugin,
|
||||
setup() {},
|
||||
};
|
||||
|
||||
@@ -227,6 +227,16 @@ function requestMethod(request: unknown): unknown {
|
||||
return isRecord(request) ? request.method : undefined;
|
||||
}
|
||||
|
||||
test("dual server entrypoint keeps V1 hooks and never reports from the V2 shared server", async () => {
|
||||
const module = await import(`./herdr-agent-state.js?test=${++importCounter}`);
|
||||
expect(module.default.server).toBe(module.HerdrAgentStatePlugin);
|
||||
expect(await module.default.setup({})).toBeUndefined();
|
||||
expect(requests).toHaveLength(0);
|
||||
const hooks = await module.default.server();
|
||||
await hooks["chat.message"]({ sessionID: "v1-root" });
|
||||
expect(requests.map(requestState)).toEqual(["working"]);
|
||||
});
|
||||
|
||||
function requestState(request: unknown): unknown {
|
||||
return requestParam(request, "state");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// installed by herdr
|
||||
// managed by herdr; reinstalling or updating the integration overwrites this file.
|
||||
// HERDR_INTEGRATION_ID=opencode-tui
|
||||
// HERDR_INTEGRATION_VERSION=11
|
||||
// HERDR_INTEGRATION_VERSION=12
|
||||
|
||||
import net from "node:net";
|
||||
|
||||
@@ -10,11 +10,11 @@ const AGENT = "opencode";
|
||||
const ROUTE_POLL_INTERVAL_MS = 100;
|
||||
const SELECTION_RETRY_DELAYS_MS = [100, 400, 1_000];
|
||||
|
||||
function requestOnce(sessionID) {
|
||||
function requestOnce(sessionID, state, seq, isCurrent = () => true) {
|
||||
const paneId = process.env.HERDR_PANE_ID;
|
||||
const socketPath = process.env.HERDR_SOCKET_PATH;
|
||||
if (!paneId || !socketPath) {
|
||||
return Promise.resolve();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
const socketEndpoint =
|
||||
@@ -23,35 +23,50 @@ function requestOnce(sessionID) {
|
||||
id: `${SOURCE}:tui:${Date.now()}:${Math.floor(Math.random() * 1_000_000)
|
||||
.toString()
|
||||
.padStart(6, "0")}`,
|
||||
method: "pane.report_agent_session",
|
||||
method: state === undefined ? "pane.report_agent_session" : "pane.report_agent",
|
||||
params: {
|
||||
pane_id: paneId,
|
||||
source: SOURCE,
|
||||
agent: AGENT,
|
||||
agent_session_id: sessionID,
|
||||
session_start_source: "select",
|
||||
...(state === undefined ? { session_start_source: "select" } : { state, seq }),
|
||||
},
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let timer;
|
||||
const settle = (delivered) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
client.destroy();
|
||||
resolve(delivered);
|
||||
};
|
||||
const client = net.createConnection(socketEndpoint, () => {
|
||||
if (!isCurrent()) {
|
||||
settle(false);
|
||||
return;
|
||||
}
|
||||
client.write(`${JSON.stringify(request)}\n`);
|
||||
});
|
||||
const finish = () => {
|
||||
client.destroy();
|
||||
resolve();
|
||||
};
|
||||
|
||||
client.setTimeout(500, finish);
|
||||
client.on("data", finish);
|
||||
client.on("error", finish);
|
||||
client.on("end", finish);
|
||||
client.on("close", resolve);
|
||||
// A plain timer, not socket.setTimeout, so a connection that never finishes
|
||||
// connecting still settles and cannot block later reports behind the queue.
|
||||
timer = setTimeout(() => settle(false), 500);
|
||||
timer.unref?.();
|
||||
client.on("data", () => settle(true));
|
||||
client.on("error", () => settle(false));
|
||||
client.on("end", () => settle(false));
|
||||
client.on("close", () => settle(false));
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
id: "herdr.opencode.session-selection",
|
||||
// Keep this plain object dependency-free: V1 and V2 expose different SDK
|
||||
// packages, but both loaders accept their own lifecycle entry on this object.
|
||||
setup,
|
||||
tui: async (api) => {
|
||||
if (
|
||||
process.env.HERDR_ENV !== "1" ||
|
||||
@@ -111,3 +126,203 @@ export default {
|
||||
api.lifecycle.onDispose(() => clearInterval(routePoll));
|
||||
},
|
||||
};
|
||||
|
||||
function setup(api) {
|
||||
if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_SOCKET_PATH || !process.env.HERDR_PANE_ID) return;
|
||||
|
||||
let disposed = false;
|
||||
let selected;
|
||||
let generation = 0;
|
||||
let sequence = Date.now() * 1000;
|
||||
let chain = Promise.resolve();
|
||||
let retryIndex = 0;
|
||||
let nextSelectionAt = 0;
|
||||
let state = "idle";
|
||||
let retryTimer;
|
||||
const sessions = new Map();
|
||||
let blockers = new Map();
|
||||
// Event callbacks may precede cache updates. Retain each delta until the
|
||||
// cache reflects it, so late hydration cannot undo a reply or lose an ask.
|
||||
const blockerChanges = new Map();
|
||||
|
||||
function root(id) {
|
||||
const seen = new Set();
|
||||
while (typeof id === "string" && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
const session = api.data.session.get(id) ?? sessions.get(id);
|
||||
if (!session) return;
|
||||
if (!session.parentID) return id;
|
||||
id = session.parentID;
|
||||
}
|
||||
}
|
||||
|
||||
function current() {
|
||||
const route = api.ui.router.current();
|
||||
return route.type === "session" ? root(route.sessionID) : undefined;
|
||||
}
|
||||
|
||||
// Selection and lifecycle use one queue. Recheck attribution at dispatch,
|
||||
// not just when receiving the event, and reject A -> B -> A stale work too.
|
||||
function enqueue(value) {
|
||||
const sessionID = selected;
|
||||
const revision = generation;
|
||||
const isCurrent = () => !disposed && revision === generation && !!sessionID && current() === sessionID;
|
||||
chain = chain.then(async () => {
|
||||
if (!isCurrent()) return;
|
||||
const delivered = await requestOnce(sessionID, value, value === undefined ? undefined : ++sequence, isCurrent);
|
||||
if (!delivered) scheduleStateRetry();
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// A dropped report must not strand the pane on a stale state once the
|
||||
// selection retry schedule has run out: resend the latest state until the
|
||||
// socket accepts it or the selection is no longer current.
|
||||
function scheduleStateRetry() {
|
||||
if (disposed || retryTimer) return;
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = undefined;
|
||||
publish();
|
||||
}, 500);
|
||||
retryTimer.unref?.();
|
||||
}
|
||||
|
||||
function publish() {
|
||||
enqueue(blockers.size ? "blocked" : state);
|
||||
}
|
||||
|
||||
function changeBlocker(id, kind, requestID, present) {
|
||||
if (typeof requestID !== "string") return;
|
||||
const key = `${kind}:${requestID}`;
|
||||
blockerChanges.set(key, { id, kind, present });
|
||||
if (present) blockers.set(key, id);
|
||||
else blockers.delete(key);
|
||||
}
|
||||
|
||||
function reconcileBlockers() {
|
||||
const next = new Map();
|
||||
const hydrated = new Set();
|
||||
const members = new Set([selected, ...api.data.session.family(selected), ...blockers.values()]);
|
||||
for (const member of members) {
|
||||
if (root(member) !== selected) continue;
|
||||
for (const kind of ["permission", "form"]) {
|
||||
const items = api.data.session[kind].list(member);
|
||||
if (items === undefined) {
|
||||
for (const [key, owner] of blockers) {
|
||||
if (owner === member && key.startsWith(`${kind}:`)) next.set(key, owner);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
hydrated.add(`${kind}:${member}`);
|
||||
for (const item of items) next.set(`${kind}:${item.id}`, member);
|
||||
}
|
||||
}
|
||||
for (const [key, change] of blockerChanges) {
|
||||
if (hydrated.has(`${change.kind}:${change.id}`) && next.has(key) === change.present) {
|
||||
blockerChanges.delete(key);
|
||||
} else if (change.present) {
|
||||
next.set(key, change.id);
|
||||
} else {
|
||||
next.delete(key);
|
||||
}
|
||||
}
|
||||
const changed = (blockers.size > 0) !== (next.size > 0);
|
||||
blockers = next;
|
||||
return changed;
|
||||
}
|
||||
|
||||
function syncSelection() {
|
||||
if (disposed) return;
|
||||
const id = current();
|
||||
if (id !== selected) {
|
||||
selected = id;
|
||||
generation += 1;
|
||||
retryIndex = 0;
|
||||
nextSelectionAt = 0;
|
||||
blockers.clear();
|
||||
blockerChanges.clear();
|
||||
if (id) {
|
||||
state = api.data.session.status(id) === "running" ? "working" : "idle";
|
||||
}
|
||||
}
|
||||
if (!id) return;
|
||||
const blockersChanged = reconcileBlockers();
|
||||
if (Date.now() < nextSelectionAt) {
|
||||
if (blockersChanged) publish();
|
||||
return;
|
||||
}
|
||||
enqueue(undefined);
|
||||
publish();
|
||||
const delay = SELECTION_RETRY_DELAYS_MS[retryIndex++];
|
||||
nextSelectionAt = delay === undefined ? Number.POSITIVE_INFINITY : Date.now() + delay;
|
||||
}
|
||||
|
||||
function receive({ details: event }) {
|
||||
if (disposed) return;
|
||||
const data = event.data;
|
||||
if (data == null) return;
|
||||
if (event.type === "session.created") {
|
||||
sessions.set(data.sessionID, { id: data.sessionID, parentID: data.parentID });
|
||||
}
|
||||
if (event.type === "session.deleted") {
|
||||
const affected = data.sessionID === selected || [...blockers.values()].includes(data.sessionID);
|
||||
sessions.delete(data.sessionID);
|
||||
// Deletion is delivered after the cache can remove the session. Use
|
||||
// stored ownership rather than looking up the deleted child's ancestry.
|
||||
for (const [key, owner] of blockers) if (owner === data.sessionID) blockers.delete(key);
|
||||
for (const [key, change] of blockerChanges) {
|
||||
if (change.id === data.sessionID) blockerChanges.delete(key);
|
||||
}
|
||||
syncSelection();
|
||||
if (selected && affected) publish();
|
||||
return;
|
||||
}
|
||||
syncSelection();
|
||||
const id = event.type === "form.created" ? data.form.sessionID : data.sessionID;
|
||||
if (!selected || root(id) !== selected) return;
|
||||
switch (event.type) {
|
||||
case "permission.asked":
|
||||
changeBlocker(id, "permission", data.id, true);
|
||||
break;
|
||||
case "permission.replied":
|
||||
changeBlocker(id, "permission", data.requestID, false);
|
||||
break;
|
||||
case "form.created":
|
||||
changeBlocker(id, "form", data.form.id, true);
|
||||
break;
|
||||
case "form.replied":
|
||||
case "form.cancelled":
|
||||
changeBlocker(id, "form", data.id, false);
|
||||
break;
|
||||
case "session.execution.started":
|
||||
if (id !== selected) return;
|
||||
state = "working";
|
||||
break;
|
||||
case "session.execution.succeeded":
|
||||
case "session.execution.interrupted":
|
||||
if (id !== selected) return;
|
||||
state = "idle";
|
||||
break;
|
||||
case "session.execution.failed":
|
||||
if (id !== selected) return;
|
||||
state = "blocked";
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
publish();
|
||||
}
|
||||
|
||||
const unsubscribe = api.data.listen(receive);
|
||||
syncSelection();
|
||||
const poll = setInterval(syncSelection, ROUTE_POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
disposed = true;
|
||||
generation += 1;
|
||||
clearTimeout(retryTimer);
|
||||
clearInterval(poll);
|
||||
unsubscribe();
|
||||
sessions.clear();
|
||||
blockers.clear();
|
||||
blockerChanges.clear();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,15 +3,25 @@ import { afterEach, beforeEach, expect, mock, test } from "bun:test";
|
||||
const requests: unknown[] = [];
|
||||
const activeDisposers: Array<() => void> = [];
|
||||
const requestWaiters: Array<() => void> = [];
|
||||
const stateWaiters: Array<() => void> = [];
|
||||
let importCounter = 0;
|
||||
let holdConnections = false;
|
||||
let failConnections = false;
|
||||
const connections: Array<() => void> = [];
|
||||
|
||||
mock.module("node:net", () => ({
|
||||
default: {
|
||||
createConnection(_path: string, onConnect: () => void) {
|
||||
const handlers = new Map<string, () => void>();
|
||||
const client = {
|
||||
destroyed: false,
|
||||
write(input: string) {
|
||||
requests.push(JSON.parse(input.trim()));
|
||||
if (client.destroyed) return;
|
||||
const request = JSON.parse(input.trim());
|
||||
requests.push(request);
|
||||
if (isRecord(request) && isRecord(request.params) && request.params.state !== undefined) {
|
||||
stateWaiters.shift()?.();
|
||||
}
|
||||
requestWaiters.shift()?.();
|
||||
queueMicrotask(() => client.emit("data"));
|
||||
},
|
||||
@@ -19,12 +29,16 @@ mock.module("node:net", () => ({
|
||||
on(event: string, handler: () => void) {
|
||||
handlers.set(event, handler);
|
||||
},
|
||||
destroy() {},
|
||||
destroy() {
|
||||
client.destroyed = true;
|
||||
},
|
||||
emit(event: string) {
|
||||
handlers.get(event)?.();
|
||||
},
|
||||
};
|
||||
queueMicrotask(onConnect);
|
||||
if (holdConnections) connections.push(onConnect);
|
||||
else if (failConnections) queueMicrotask(() => client.emit("error"));
|
||||
else queueMicrotask(onConnect);
|
||||
return client;
|
||||
},
|
||||
},
|
||||
@@ -33,6 +47,10 @@ mock.module("node:net", () => ({
|
||||
beforeEach(() => {
|
||||
requests.length = 0;
|
||||
requestWaiters.length = 0;
|
||||
stateWaiters.length = 0;
|
||||
holdConnections = false;
|
||||
failConnections = false;
|
||||
connections.length = 0;
|
||||
process.env.HERDR_ENV = "1";
|
||||
process.env.HERDR_SOCKET_PATH = "test.sock";
|
||||
process.env.HERDR_PANE_ID = "test:p1";
|
||||
@@ -93,6 +111,10 @@ function waitForNextRequest(): Promise<void> {
|
||||
return new Promise((resolve) => requestWaiters.push(resolve));
|
||||
}
|
||||
|
||||
function waitForStateReport(): Promise<void> {
|
||||
return new Promise((resolve) => stateWaiters.push(resolve));
|
||||
}
|
||||
|
||||
test("reports a root session when only the local route changes", async () => {
|
||||
const plugin = await loadPlugin();
|
||||
const tui = fakeApi();
|
||||
@@ -179,3 +201,199 @@ function requestParam(request: unknown, name: string): unknown {
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function v2Api() {
|
||||
const sessions = new Map([
|
||||
["a", { id: "a" }],
|
||||
["b", { id: "b" }],
|
||||
["child", { id: "child", parentID: "a" }],
|
||||
]);
|
||||
let route = { type: "session", sessionID: "a" };
|
||||
const listeners = new Set<(event: unknown) => void>();
|
||||
const permissions = new Map<string, Array<{ id: string }> | undefined>();
|
||||
const forms = new Map<string, Array<{ id: string }> | undefined>();
|
||||
return {
|
||||
api: {
|
||||
ui: { router: { current: () => route } },
|
||||
data: {
|
||||
session: {
|
||||
get: (id: string) => sessions.get(id),
|
||||
family: () => [...sessions.keys()],
|
||||
status: () => "idle",
|
||||
permission: { list: (id: string) => permissions.get(id) },
|
||||
form: { list: (id: string) => forms.get(id) },
|
||||
},
|
||||
listen: (handler: (event: unknown) => void) => {
|
||||
listeners.add(handler);
|
||||
return () => listeners.delete(handler);
|
||||
},
|
||||
},
|
||||
},
|
||||
select(sessionID: string) { route = { type: "session", sessionID }; },
|
||||
home() { route = { type: "home", sessionID: "" }; },
|
||||
emit(type: string, data?: object) {
|
||||
for (const listener of listeners) listener({ details: { type, data } });
|
||||
},
|
||||
listeners,
|
||||
sessions,
|
||||
permissions,
|
||||
forms,
|
||||
};
|
||||
}
|
||||
|
||||
const flushReports = () => new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const states = () => requests.filter((r) => requestParam(r, "state") !== undefined)
|
||||
.map((r) => requestParam(r, "state"));
|
||||
|
||||
test("V2 ignores events without data", async () => {
|
||||
const plugin = await loadPlugin();
|
||||
const tui = v2Api();
|
||||
const dispose = await plugin.setup(tui.api);
|
||||
activeDisposers.push(dispose);
|
||||
await flushReports();
|
||||
requests.length = 0;
|
||||
expect(() => tui.emit("legacy.event")).not.toThrow();
|
||||
tui.emit("session.execution.started", { sessionID: "a" });
|
||||
await flushReports();
|
||||
expect(states()).toEqual(["working"]);
|
||||
});
|
||||
|
||||
test("V2 completes and interrupts without legacy idle events", async () => {
|
||||
for (const terminal of ["succeeded", "interrupted", "failed"]) {
|
||||
const plugin = await loadPlugin();
|
||||
const tui = v2Api();
|
||||
const dispose = await plugin.setup(tui.api);
|
||||
activeDisposers.push(dispose);
|
||||
await flushReports();
|
||||
requests.length = 0;
|
||||
tui.emit("session.execution.started", { sessionID: "a" });
|
||||
tui.emit(`session.execution.${terminal}`, { sessionID: "a" });
|
||||
await flushReports();
|
||||
expect(states()).toEqual(["working", terminal === "failed" ? "blocked" : "idle"]);
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("V2 aggregates root and child blockers and ignores other roots and child completion", async () => {
|
||||
const plugin = await loadPlugin();
|
||||
const tui = v2Api();
|
||||
const dispose = await plugin.setup(tui.api);
|
||||
activeDisposers.push(dispose);
|
||||
await flushReports();
|
||||
requests.length = 0;
|
||||
tui.emit("session.execution.started", { sessionID: "a" });
|
||||
tui.emit("permission.asked", { sessionID: "a", id: "permission-a" });
|
||||
tui.emit("form.created", { form: { sessionID: "child", id: "form-child" } });
|
||||
tui.emit("permission.replied", { sessionID: "a", requestID: "permission-a" });
|
||||
tui.emit("session.execution.succeeded", { sessionID: "child" });
|
||||
tui.emit("session.execution.started", { sessionID: "b" });
|
||||
tui.emit("permission.asked", { sessionID: "b", id: "other" });
|
||||
await flushReports();
|
||||
expect(states().at(-1)).toBe("blocked");
|
||||
expect(requests.every((r) => requestParam(r, "agent_session_id") === "a")).toBe(true);
|
||||
tui.emit("form.cancelled", { sessionID: "child", id: "form-child" });
|
||||
tui.emit("session.execution.succeeded", { sessionID: "a" });
|
||||
await flushReports();
|
||||
expect(states().slice(-2)).toEqual(["working", "idle"]);
|
||||
});
|
||||
|
||||
test("V2 discards queued reports after selection changes and stops on disposal", async () => {
|
||||
const plugin = await loadPlugin();
|
||||
const tui = v2Api();
|
||||
const dispose = await plugin.setup(tui.api);
|
||||
activeDisposers.push(dispose);
|
||||
await flushReports();
|
||||
requests.length = 0;
|
||||
tui.emit("session.execution.started", { sessionID: "a" });
|
||||
tui.select("b");
|
||||
tui.emit("session.execution.started", { sessionID: "b" });
|
||||
await flushReports();
|
||||
expect(requests.every((r) => requestParam(r, "agent_session_id") === "b")).toBe(true);
|
||||
requests.length = 0;
|
||||
tui.emit("session.execution.succeeded", { sessionID: "b" });
|
||||
tui.home();
|
||||
await flushReports();
|
||||
expect(requests).toHaveLength(0);
|
||||
dispose();
|
||||
expect(tui.listeners.size).toBe(0);
|
||||
tui.select("a");
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
expect(requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("V2 reconciles late blocker hydration without reviving an already-replied request", async () => {
|
||||
const plugin = await loadPlugin();
|
||||
const tui = v2Api();
|
||||
const dispose = await plugin.setup(tui.api);
|
||||
activeDisposers.push(dispose);
|
||||
await flushReports();
|
||||
tui.permissions.set("child", [{ id: "late" }]);
|
||||
await waitForStateReport();
|
||||
expect(states().at(-1)).toBe("blocked");
|
||||
tui.emit("permission.replied", { sessionID: "child", requestID: "late" });
|
||||
await waitForStateReport();
|
||||
expect(states().at(-1)).toBe("idle");
|
||||
tui.permissions.set("child", []);
|
||||
tui.forms.set("child", [{ id: "second" }]);
|
||||
await waitForStateReport();
|
||||
expect(states().at(-1)).toBe("blocked");
|
||||
tui.sessions.delete("child");
|
||||
tui.emit("session.deleted", { sessionID: "child" });
|
||||
await flushReports();
|
||||
expect(states().at(-1)).toBe("idle");
|
||||
});
|
||||
|
||||
test("V2 never writes a delayed connection after disposal or a session switch", async () => {
|
||||
for (const action of ["dispose", "switch"]) {
|
||||
const plugin = await loadPlugin();
|
||||
const tui = v2Api();
|
||||
holdConnections = true;
|
||||
requests.length = 0;
|
||||
const dispose = await plugin.setup(tui.api);
|
||||
activeDisposers.push(dispose);
|
||||
await flushReports();
|
||||
expect(connections.length).toBeGreaterThan(0);
|
||||
if (action === "dispose") dispose();
|
||||
else tui.select("b");
|
||||
holdConnections = false;
|
||||
for (const connect of connections.splice(0)) connect();
|
||||
await flushReports();
|
||||
expect(requests).toHaveLength(0);
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("V2 settles a connection that never completes", async () => {
|
||||
const plugin = await loadPlugin();
|
||||
const tui = v2Api();
|
||||
holdConnections = true;
|
||||
const dispose = await plugin.setup(tui.api);
|
||||
activeDisposers.push(dispose);
|
||||
const started = Date.now();
|
||||
while (connections.length <= 1 && Date.now() - started < 2_000) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
expect(connections.length).toBeGreaterThan(1);
|
||||
dispose();
|
||||
});
|
||||
|
||||
test("V2 resends the latest state after a failed delivery", async () => {
|
||||
const plugin = await loadPlugin();
|
||||
const tui = v2Api();
|
||||
const dispose = await plugin.setup(tui.api);
|
||||
activeDisposers.push(dispose);
|
||||
await flushReports();
|
||||
// Exhaust the selection retry schedule so only the event report remains.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_600));
|
||||
requests.length = 0;
|
||||
tui.emit("session.execution.started", { sessionID: "a" });
|
||||
await flushReports();
|
||||
failConnections = true;
|
||||
tui.emit("session.execution.succeeded", { sessionID: "a" });
|
||||
const resend = waitForStateReport();
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
failConnections = false;
|
||||
await resend;
|
||||
expect(states().at(-1)).toBe("idle");
|
||||
dispose();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// installed by herdr
|
||||
// HERDR_INTEGRATION_ID=opencode-tui-v2
|
||||
// HERDR_INTEGRATION_VERSION=12
|
||||
// V2 resolves the directory's tui entrypoint; V1 uses the original file.
|
||||
export { default } from "../herdr-tui-session.js";
|
||||
@@ -127,6 +127,14 @@ pub(crate) fn opencode_dir() -> io::Result<PathBuf> {
|
||||
Ok(home_dir()?.join(".config/opencode"))
|
||||
}
|
||||
|
||||
pub(crate) fn opencode_state_dir() -> io::Result<PathBuf> {
|
||||
if let Some(value) = std::env::var_os("XDG_STATE_HOME").filter(|value| !value.is_empty()) {
|
||||
return expand_tilde_path(PathBuf::from(value)).map(|path| path.join("opencode"));
|
||||
}
|
||||
|
||||
Ok(home_dir()?.join(".local/state/opencode"))
|
||||
}
|
||||
|
||||
pub(crate) fn kilo_dir() -> io::Result<PathBuf> {
|
||||
Ok(home_dir()?.join(".config/kilo"))
|
||||
}
|
||||
@@ -248,3 +256,34 @@ pub(crate) fn integration_env_lock() -> IntegrationEnvLock {
|
||||
appdata: std::env::var_os("APPDATA"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn opencode_state_dir_defaults_to_local_state() {
|
||||
let _lock = integration_env_lock();
|
||||
let original = std::env::var_os("XDG_STATE_HOME");
|
||||
std::env::remove_var("XDG_STATE_HOME");
|
||||
let expected = home_dir().unwrap().join(".local/state/opencode");
|
||||
assert_eq!(opencode_state_dir().unwrap(), expected);
|
||||
match original {
|
||||
Some(value) => std::env::set_var("XDG_STATE_HOME", value),
|
||||
None => std::env::remove_var("XDG_STATE_HOME"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_state_dir_honors_xdg_state_home() {
|
||||
let _lock = integration_env_lock();
|
||||
let original = std::env::var_os("XDG_STATE_HOME");
|
||||
let xdg = std::env::temp_dir().join("herdr-xdg-state");
|
||||
std::env::set_var("XDG_STATE_HOME", &xdg);
|
||||
assert_eq!(opencode_state_dir().unwrap(), xdg.join("opencode"));
|
||||
match original {
|
||||
Some(value) => std::env::set_var("XDG_STATE_HOME", value),
|
||||
None => std::env::remove_var("XDG_STATE_HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +170,10 @@ const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-st
|
||||
const OPENCODE_TUI_PLUGIN_INSTALL_NAME: &str = "herdr-tui-session.js";
|
||||
const OPENCODE_TUI_PLUGIN_SPEC: &str = "./herdr-tui-session.js";
|
||||
const OPENCODE_TUI_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-tui-session.js");
|
||||
const OPENCODE_INTEGRATION_VERSION: u32 = 11;
|
||||
const OPENCODE_V2_TUI_PLUGIN_DIR: &str = "herdr-opencode";
|
||||
const OPENCODE_V2_TUI_PLUGIN_SPEC: &str = "./herdr-opencode";
|
||||
const OPENCODE_V2_TUI_PLUGIN_ASSET: &str = include_str!("assets/opencode/tui.js");
|
||||
const OPENCODE_INTEGRATION_VERSION: u32 = 12;
|
||||
const KILO_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js";
|
||||
const KILO_PLUGIN_ASSET: &str = include_str!("assets/kilo/herdr-agent-state.js");
|
||||
const KILO_INTEGRATION_VERSION: u32 = 4;
|
||||
|
||||
@@ -13,25 +13,53 @@ pub(crate) fn tui_config_path(config_dir: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
pub(crate) fn validate_tui_plugin_config(config_dir: &Path) -> io::Result<()> {
|
||||
let config_path = tui_config_path(config_dir);
|
||||
validate_plugin_config(&tui_config_path(config_dir), "plugin")?;
|
||||
validate_plugin_config(&config_dir.join("cli.json"), "plugins")
|
||||
}
|
||||
|
||||
fn validate_plugin_config(config_path: &Path, key: &str) -> io::Result<()> {
|
||||
if !config_path.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&config_path)?;
|
||||
let root = parse_root(&content, &config_path)?;
|
||||
let object = root_object(&root, &config_path)?;
|
||||
let content = fs::read_to_string(config_path)?;
|
||||
let root = parse_root(&content, config_path)?;
|
||||
let object = root_object(&root, config_path)?;
|
||||
if object
|
||||
.get("plugin")
|
||||
.get(key)
|
||||
.is_some_and(|property| property.array_value().is_none())
|
||||
{
|
||||
return Err(invalid_plugin_list(&config_path));
|
||||
return Err(invalid_plugin_list(config_path));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn add_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<PathBuf> {
|
||||
let config_path = tui_config_path(config_dir);
|
||||
add_plugin(tui_config_path(config_dir), "plugin", plugin_spec)
|
||||
}
|
||||
|
||||
pub(crate) fn add_cli_plugin(
|
||||
config_dir: &Path,
|
||||
state_dir: &Path,
|
||||
plugin_spec: &str,
|
||||
) -> io::Result<Option<PathBuf>> {
|
||||
let path = config_dir.join("cli.json");
|
||||
// OpenCode imports V1 TUI preferences (`tui.json`, `kv.json`) into cli.json on
|
||||
// its first V2 start, but only while cli.json is absent. Defer registration
|
||||
// while those sources still exist so we do not skip the migration; otherwise
|
||||
// create cli.json ourselves, since OpenCode will never do it for a fresh V2
|
||||
// install with nothing to migrate.
|
||||
if !path.is_file() && cli_migration_pending(config_dir, state_dir) {
|
||||
return Ok(None);
|
||||
}
|
||||
add_plugin(path, "plugins", plugin_spec).map(Some)
|
||||
}
|
||||
|
||||
fn cli_migration_pending(config_dir: &Path, state_dir: &Path) -> bool {
|
||||
config_dir.join("tui.json").is_file() || state_dir.join("kv.json").is_file()
|
||||
}
|
||||
|
||||
fn add_plugin(config_path: PathBuf, key: &str, plugin_spec: &str) -> io::Result<PathBuf> {
|
||||
let content = if config_path.is_file() {
|
||||
fs::read_to_string(&config_path)?
|
||||
} else {
|
||||
@@ -40,7 +68,7 @@ pub(crate) fn add_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result
|
||||
let root = parse_root(&content, &config_path)?;
|
||||
let object = root_object(&root, &config_path)?;
|
||||
|
||||
match object.get("plugin") {
|
||||
match object.get(key) {
|
||||
Some(property) => {
|
||||
let plugins = property
|
||||
.array_value()
|
||||
@@ -56,7 +84,7 @@ pub(crate) fn add_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result
|
||||
}
|
||||
None => {
|
||||
object.append(
|
||||
"plugin",
|
||||
key,
|
||||
CstInputValue::Array(vec![CstInputValue::String(plugin_spec.to_string())]),
|
||||
);
|
||||
}
|
||||
@@ -67,20 +95,27 @@ pub(crate) fn add_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result
|
||||
}
|
||||
|
||||
pub(crate) fn remove_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<bool> {
|
||||
let config_path = tui_config_path(config_dir);
|
||||
remove_plugin(&tui_config_path(config_dir), "plugin", plugin_spec)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_cli_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<bool> {
|
||||
remove_plugin(&config_dir.join("cli.json"), "plugins", plugin_spec)
|
||||
}
|
||||
|
||||
fn remove_plugin(config_path: &Path, key: &str, plugin_spec: &str) -> io::Result<bool> {
|
||||
if !config_path.is_file() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&config_path)?;
|
||||
let root = parse_root(&content, &config_path)?;
|
||||
let object = root_object(&root, &config_path)?;
|
||||
let Some(property) = object.get("plugin") else {
|
||||
let content = fs::read_to_string(config_path)?;
|
||||
let root = parse_root(&content, config_path)?;
|
||||
let object = root_object(&root, config_path)?;
|
||||
let Some(property) = object.get(key) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let plugins = property
|
||||
.array_value()
|
||||
.ok_or_else(|| invalid_plugin_list(&config_path))?;
|
||||
.ok_or_else(|| invalid_plugin_list(config_path))?;
|
||||
let mut removed = false;
|
||||
for entry in plugins.elements() {
|
||||
if entry
|
||||
@@ -98,23 +133,30 @@ pub(crate) fn remove_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Res
|
||||
property.remove();
|
||||
}
|
||||
|
||||
fs::write(&config_path, root.to_string())?;
|
||||
fs::write(config_path, root.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn tui_plugin_is_configured(config_dir: &Path, plugin_spec: &str) -> bool {
|
||||
let config_path = tui_config_path(config_dir);
|
||||
let Ok(content) = fs::read_to_string(&config_path) else {
|
||||
plugin_is_configured(&tui_config_path(config_dir), "plugin", plugin_spec)
|
||||
}
|
||||
|
||||
pub(crate) fn cli_plugin_is_configured(config_dir: &Path, plugin_spec: &str) -> bool {
|
||||
plugin_is_configured(&config_dir.join("cli.json"), "plugins", plugin_spec)
|
||||
}
|
||||
|
||||
fn plugin_is_configured(config_path: &Path, key: &str, plugin_spec: &str) -> bool {
|
||||
let Ok(content) = fs::read_to_string(config_path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(root) = parse_root(&content, &config_path) else {
|
||||
let Ok(root) = parse_root(&content, config_path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(object) = root_object(&root, &config_path) else {
|
||||
let Ok(object) = root_object(&root, config_path) else {
|
||||
return false;
|
||||
};
|
||||
object
|
||||
.get("plugin")
|
||||
.get(key)
|
||||
.and_then(|property| property.array_value())
|
||||
.is_some_and(|plugins| {
|
||||
plugins.elements().iter().any(|entry| {
|
||||
@@ -154,6 +196,7 @@ fn jsonc_parse_options() -> ParseOptions {
|
||||
|
||||
fn plugin_entry_matches(entry: &Value, plugin_spec: &str) -> bool {
|
||||
entry.as_str() == Some(plugin_spec)
|
||||
|| entry.get("package").and_then(Value::as_str) == Some(plugin_spec)
|
||||
|| entry
|
||||
.as_array()
|
||||
.and_then(|parts| parts.first())
|
||||
@@ -295,4 +338,73 @@ mod tests {
|
||||
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_registration_preserves_options_and_other_preferences() {
|
||||
let dir = unique_dir();
|
||||
let state = unique_dir();
|
||||
let path = dir.join("cli.json");
|
||||
fs::write(&path, r#"{"theme":{"name":"catppuccin"},"plugins":[{"package":"./herdr-opencode","options":{"custom":true}},"example"]}"#).unwrap();
|
||||
add_cli_plugin(&dir, &state, "./herdr-opencode").unwrap();
|
||||
assert!(cli_plugin_is_configured(&dir, "./herdr-opencode"));
|
||||
assert_eq!(parse_config(&path)["plugins"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(parse_config(&path)["plugins"][0]["options"]["custom"], true);
|
||||
assert!(remove_cli_plugin(&dir, "./herdr-opencode").unwrap());
|
||||
assert_eq!(parse_config(&path)["plugins"], json!(["example"]));
|
||||
assert_eq!(parse_config(&path)["theme"]["name"], "catppuccin");
|
||||
add_cli_plugin(&dir, &state, "./herdr-opencode").unwrap();
|
||||
add_cli_plugin(&dir, &state, "./herdr-opencode").unwrap();
|
||||
assert_eq!(
|
||||
parse_config(&path)["plugins"],
|
||||
json!(["example", "./herdr-opencode"])
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
fs::remove_dir_all(state).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_registration_creates_missing_config_when_no_migration_pending() {
|
||||
let dir = unique_dir();
|
||||
let state = unique_dir();
|
||||
let path = add_cli_plugin(&dir, &state, "./herdr-opencode")
|
||||
.unwrap()
|
||||
.expect("cli.json should be created when OpenCode has nothing to migrate");
|
||||
assert_eq!(path, dir.join("cli.json"));
|
||||
assert_eq!(
|
||||
parse_config(&path),
|
||||
json!({ "plugins": ["./herdr-opencode"] })
|
||||
);
|
||||
assert!(cli_plugin_is_configured(&dir, "./herdr-opencode"));
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
fs::remove_dir_all(state).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_registration_defers_while_migration_pending() {
|
||||
let dir = unique_dir();
|
||||
let state = unique_dir();
|
||||
fs::write(dir.join("tui.json"), "{}").unwrap();
|
||||
assert!(add_cli_plugin(&dir, &state, "./herdr-opencode")
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(!dir.join("cli.json").exists());
|
||||
|
||||
fs::remove_file(dir.join("tui.json")).unwrap();
|
||||
fs::write(state.join("kv.json"), "{}").unwrap();
|
||||
assert!(add_cli_plugin(&dir, &state, "./herdr-opencode")
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(!dir.join("cli.json").exists());
|
||||
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
fs::remove_dir_all(state).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cli_plugin_list_fails_preflight() {
|
||||
let dir = unique_dir();
|
||||
fs::write(dir.join("cli.json"), r#"{"plugins":{}}"#).unwrap();
|
||||
assert!(validate_tui_plugin_config(&dir).is_err());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,6 +427,18 @@ fn opencode_tui_integration_is_valid(plugin_path: &Path, expected_version: u32)
|
||||
config_dir,
|
||||
super::OPENCODE_TUI_PLUGIN_SPEC,
|
||||
)
|
||||
&& (!config_dir.join("cli.json").exists()
|
||||
|| (super::opencode_config::cli_plugin_is_configured(
|
||||
config_dir,
|
||||
super::OPENCODE_V2_TUI_PLUGIN_SPEC,
|
||||
) && fs::read_to_string(
|
||||
config_dir
|
||||
.join(super::OPENCODE_V2_TUI_PLUGIN_DIR)
|
||||
.join("tui.js"),
|
||||
)
|
||||
.ok()
|
||||
.and_then(|content| parse_integration_version(&content))
|
||||
.is_some_and(|version| version >= expected_version)))
|
||||
}
|
||||
|
||||
pub(crate) fn integration_status_at(
|
||||
|
||||
@@ -22,13 +22,14 @@ use super::config_edit::{
|
||||
use super::env::{
|
||||
antigravity_cli_dir, claude_dir, codex_dir, copilot_dir, cursor_dir, devin_dir, droid_dir,
|
||||
grok_dir, hermes_dir, hermes_plugin_dir, kilo_dir, kimi_dir, mastracode_dir, omp_extension_dir,
|
||||
opencode_dir, pi_extension_dir, qodercli_dir, qwen_dir,
|
||||
opencode_dir, opencode_state_dir, pi_extension_dir, qodercli_dir, qwen_dir,
|
||||
};
|
||||
use super::file_ops::{
|
||||
make_executable, remove_dir_all_if_exists, remove_file_if_exists, remove_legacy_bash_hook_file,
|
||||
};
|
||||
use super::opencode_config::{
|
||||
add_tui_plugin, remove_tui_plugin, tui_config_path, validate_tui_plugin_config,
|
||||
add_cli_plugin, add_tui_plugin, remove_cli_plugin, remove_tui_plugin, tui_config_path,
|
||||
validate_tui_plugin_config,
|
||||
};
|
||||
use super::types::{
|
||||
AntigravityCliInstallPaths, AntigravityCliUninstallResult, ClaudeInstallPaths,
|
||||
@@ -468,11 +469,20 @@ pub(crate) fn install_opencode() -> io::Result<OpenCodeInstallPaths> {
|
||||
let tui_plugin_path = dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME);
|
||||
fs::write(&tui_plugin_path, OPENCODE_TUI_PLUGIN_ASSET)?;
|
||||
let tui_config_path = add_tui_plugin(&dir, OPENCODE_TUI_PLUGIN_SPEC)?;
|
||||
let v2_dir = dir.join(super::OPENCODE_V2_TUI_PLUGIN_DIR);
|
||||
fs::create_dir_all(&v2_dir)?;
|
||||
fs::write(v2_dir.join("tui.js"), super::OPENCODE_V2_TUI_PLUGIN_ASSET)?;
|
||||
let cli_config_path = add_cli_plugin(
|
||||
&dir,
|
||||
&opencode_state_dir()?,
|
||||
super::OPENCODE_V2_TUI_PLUGIN_SPEC,
|
||||
)?;
|
||||
|
||||
Ok(OpenCodeInstallPaths {
|
||||
plugin_path,
|
||||
tui_plugin_path,
|
||||
tui_config_path,
|
||||
cli_config_path,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -821,6 +831,15 @@ pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> {
|
||||
let plugin_path = dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME);
|
||||
let tui_plugin_path = dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME);
|
||||
let mut errors = Vec::new();
|
||||
remove_cli_plugin(&dir, super::OPENCODE_V2_TUI_PLUGIN_SPEC).unwrap_or_else(|err| {
|
||||
errors.push(err.to_string());
|
||||
false
|
||||
});
|
||||
let v2_dir = dir.join(super::OPENCODE_V2_TUI_PLUGIN_DIR);
|
||||
remove_dir_all_if_exists(&v2_dir).unwrap_or_else(|err| {
|
||||
errors.push(format!("failed to remove {}: {err}", v2_dir.display()));
|
||||
false
|
||||
});
|
||||
let updated_tui_config =
|
||||
remove_tui_plugin(&dir, OPENCODE_TUI_PLUGIN_SPEC).unwrap_or_else(|err| {
|
||||
errors.push(err.to_string());
|
||||
|
||||
@@ -134,6 +134,7 @@ fn clear_integration_path_env() {
|
||||
std::env::remove_var(COPILOT_HOME_ENV_VAR);
|
||||
std::env::remove_var(KIMI_CODE_HOME_ENV_VAR);
|
||||
std::env::remove_var("XDG_CONFIG_HOME");
|
||||
std::env::remove_var("XDG_STATE_HOME");
|
||||
#[cfg(windows)]
|
||||
std::env::remove_var("APPDATA");
|
||||
std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
|
||||
@@ -2301,11 +2302,105 @@ fn install_opencode_writes_server_and_tui_plugins() {
|
||||
let tui_config: Value =
|
||||
serde_json::from_str(&fs::read_to_string(&installed.tui_config_path).unwrap()).unwrap();
|
||||
assert_eq!(tui_config["plugin"], json!([OPENCODE_TUI_PLUGIN_SPEC]));
|
||||
let cli_config_path = installed
|
||||
.cli_config_path
|
||||
.expect("cli.json should be created when OpenCode has nothing to migrate");
|
||||
assert_eq!(cli_config_path, opencode_dir.join("cli.json"));
|
||||
let cli_config: Value =
|
||||
serde_json::from_str(&fs::read_to_string(&cli_config_path).unwrap()).unwrap();
|
||||
assert_eq!(cli_config["plugins"], json!([OPENCODE_V2_TUI_PLUGIN_SPEC]));
|
||||
|
||||
std::env::remove_var("HOME");
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_install_defers_v2_registration_while_migration_pending() {
|
||||
let _lock = integration_env_lock();
|
||||
let base = unique_base();
|
||||
let home = base.join("home");
|
||||
let opencode_dir = home.join(".config/opencode");
|
||||
fs::create_dir_all(&opencode_dir).unwrap();
|
||||
fs::write(opencode_dir.join("tui.json"), "{}").unwrap();
|
||||
std::env::set_var("HOME", &home);
|
||||
|
||||
let installed = install_opencode().unwrap();
|
||||
|
||||
assert!(installed.cli_config_path.is_none());
|
||||
assert!(!opencode_dir.join("cli.json").exists());
|
||||
assert!(opencode_dir
|
||||
.join(OPENCODE_V2_TUI_PLUGIN_DIR)
|
||||
.join("tui.js")
|
||||
.is_file());
|
||||
|
||||
std::env::remove_var("HOME");
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_v2_install_status_and_uninstall_preserve_cli_preferences() {
|
||||
let _lock = integration_env_lock();
|
||||
let base = unique_base();
|
||||
let home = base.join("home");
|
||||
let dir = home.join(".config/opencode");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
std::env::set_var("HOME", &home);
|
||||
let cli = dir.join("cli.json");
|
||||
fs::write(
|
||||
&cli,
|
||||
r#"{"theme":{"name":"catppuccin"},"plugins":["other"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let installed = install_opencode().unwrap();
|
||||
assert_eq!(installed.cli_config_path, Some(cli.clone()));
|
||||
let status = || {
|
||||
integration_status_at(
|
||||
crate::api::schema::IntegrationTarget::Opencode,
|
||||
installed.plugin_path.clone(),
|
||||
OPENCODE_INTEGRATION_VERSION,
|
||||
)
|
||||
.state
|
||||
};
|
||||
assert_eq!(status(), IntegrationStatusKind::Current);
|
||||
let entry = dir.join(OPENCODE_V2_TUI_PLUGIN_DIR).join("tui.js");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&entry).unwrap(),
|
||||
OPENCODE_V2_TUI_PLUGIN_ASSET
|
||||
);
|
||||
fs::remove_file(&entry).unwrap();
|
||||
assert_eq!(status(), IntegrationStatusKind::Outdated);
|
||||
install_opencode().unwrap();
|
||||
super::opencode_config::remove_cli_plugin(&dir, OPENCODE_V2_TUI_PLUGIN_SPEC).unwrap();
|
||||
assert_eq!(status(), IntegrationStatusKind::Outdated);
|
||||
install_opencode().unwrap();
|
||||
uninstall_opencode().unwrap();
|
||||
assert!(!entry.exists());
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&fs::read_to_string(cli).unwrap()).unwrap(),
|
||||
json!({"theme":{"name":"catppuccin"},"plugins":["other"]})
|
||||
);
|
||||
std::env::remove_var("HOME");
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_invalid_cli_config_does_not_overwrite_existing_plugins() {
|
||||
let _lock = integration_env_lock();
|
||||
let base = unique_base();
|
||||
let home = base.join("home");
|
||||
let dir = home.join(".config/opencode");
|
||||
fs::create_dir_all(dir.join("plugins")).unwrap();
|
||||
std::env::set_var("HOME", &home);
|
||||
let plugin = dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME);
|
||||
fs::write(&plugin, "previous integration").unwrap();
|
||||
fs::write(dir.join("cli.json"), r#"{"plugins":{}}"#).unwrap();
|
||||
assert!(install_opencode().is_err());
|
||||
assert_eq!(fs::read_to_string(plugin).unwrap(), "previous integration");
|
||||
assert!(!dir.join("tui.jsonc").exists());
|
||||
std::env::remove_var("HOME");
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_status_requires_the_tui_plugin_and_config_entry() {
|
||||
let _lock = integration_env_lock();
|
||||
|
||||
@@ -44,6 +44,7 @@ pub(crate) struct OpenCodeInstallPaths {
|
||||
pub plugin_path: PathBuf,
|
||||
pub tui_plugin_path: PathBuf,
|
||||
pub tui_config_path: PathBuf,
|
||||
pub cli_config_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
Reference in New Issue
Block a user