f8d07dab61
- omp-usage.js / opencode-usage.js: poll each CLI's own session store into sessions (tool omp/opencode), token_usage per model and subagent agents; transcript routes read them (omp journals, OpenCode's opencode.db). - Grok runs the Claude hooks in ~/.claude/settings.json: its hooks are now recognised (hookEventName + workspaceRoot), tagged tool=grok, and their transcript_path dropped instead of being parsed as a Claude transcript. - Catalog `resumeById`: omp/Grok/Cursor `--resume <id>`, OpenCode `--session <id>`; session ids may carry `_`. The client offers Resume for any tool that resumes by id. - Kimi Code and GitHub Copilot leave the catalog (their session stores could not be verified).
189 lines
8.1 KiB
JavaScript
189 lines
8.1 KiB
JavaScript
/**
|
|
* @file Tests server/lib/opencode-usage.js and the OpenCode branch of the
|
|
* transcript routes — OpenCode sessions from its SQLite store: a root session
|
|
* created as `tool = 'opencode'` with its title, cwd, model and per-model
|
|
* tokens (its subagent session's included, reasoning counted as output), a
|
|
* subagent row per child session, an unchanged store never re-read, a
|
|
* session the user deleted staying deleted until it is updated, and the
|
|
* conversation (text, reasoning, tool call + result) in the Claude
|
|
* transcript shape. The store is a temp file with the tables and columns
|
|
* opencode 1.19 writes.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const os = require("node:os");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-usage-"));
|
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
|
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
|
process.env.AGENTHUB_OPENCODE_DIR = path.join(ROOT, "opencode");
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const { db, Database } = require("../db");
|
|
const oc = require("../lib/opencode-usage");
|
|
const { createApp, startServer } = require("../index");
|
|
|
|
const ID = "ses_eac8393b4ffeEz26VGbeyq8sir";
|
|
const CHILD = "ses_eac8393b4fffChildSession01";
|
|
const CWD = path.join(fs.realpathSync(ROOT), "work");
|
|
const STORE = path.join(process.env.AGENTHUB_OPENCODE_DIR, "opencode.db");
|
|
const T0 = Date.parse("2026-10-19T09:27:05.000Z");
|
|
|
|
let server;
|
|
let BASE;
|
|
after(() => {
|
|
server?.close();
|
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
});
|
|
|
|
function withStore(fn) {
|
|
const conn = new Database(STORE);
|
|
try {
|
|
fn(conn);
|
|
} finally {
|
|
conn.close();
|
|
}
|
|
}
|
|
|
|
const assistant = (model, tokens) => JSON.stringify({ role: "assistant", modelID: model, tokens });
|
|
const tokens = (input, output, reasoning = 0, read = 0, write = 0) => ({
|
|
input,
|
|
output,
|
|
reasoning,
|
|
cache: { read, write },
|
|
});
|
|
|
|
before(async () => {
|
|
fs.mkdirSync(path.dirname(STORE), { recursive: true });
|
|
withStore((c) => {
|
|
c.exec(`
|
|
CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
|
|
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL,
|
|
model TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_archived INTEGER);
|
|
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
|
|
time_updated INTEGER NOT NULL, data TEXT NOT NULL);
|
|
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL,
|
|
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL);
|
|
`);
|
|
const s = c.prepare(
|
|
"INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, model, time_created, time_updated) VALUES (?, 'global', ?, 'x', ?, ?, '1.19.3', ?, ?, ?)"
|
|
);
|
|
s.run(ID, null, CWD, "Fix the login bug", '{"id":"claude-sonnet-4-5","providerID":"anthropic"}', T0, T0 + 5000);
|
|
s.run(CHILD, ID, CWD, "Explore auth (@explore subagent)", null, T0 + 1000, T0 + 3000);
|
|
const m = c.prepare("INSERT INTO message VALUES (?, ?, ?, ?, ?)");
|
|
m.run("msg_1", ID, T0 + 10, T0 + 10, JSON.stringify({ role: "user" }));
|
|
m.run("msg_2", ID, T0 + 20, T0 + 20, assistant("claude-sonnet-4-5", tokens(100, 20, 5, 1000, 50)));
|
|
m.run("msg_3", CHILD, T0 + 1100, T0 + 1100, JSON.stringify({ role: "user" }));
|
|
m.run("msg_4", CHILD, T0 + 1200, T0 + 1200, assistant("claude-haiku-4-5", tokens(10, 4)));
|
|
const p = c.prepare("INSERT INTO part VALUES (?, ?, ?, ?, ?, ?)");
|
|
p.run("prt_1", "msg_1", ID, T0 + 11, T0 + 11, JSON.stringify({ type: "text", text: "Fix the login bug" }));
|
|
p.run("prt_2", "msg_2", ID, T0 + 21, T0 + 21, JSON.stringify({ type: "reasoning", text: "Look first" }));
|
|
p.run(
|
|
"prt_3",
|
|
"msg_2",
|
|
ID,
|
|
T0 + 22,
|
|
T0 + 22,
|
|
JSON.stringify({
|
|
type: "tool",
|
|
tool: "read",
|
|
callID: "call_1",
|
|
state: { status: "completed", input: { filePath: "auth.ts" }, output: "export {}" },
|
|
})
|
|
);
|
|
p.run("prt_4", "msg_2", ID, T0 + 23, T0 + 23, JSON.stringify({ type: "step-start" }));
|
|
p.run("prt_5", "msg_3", CHILD, T0 + 1101, T0 + 1101, JSON.stringify({ type: "text", text: "Explore auth" }));
|
|
p.run("prt_6", "msg_4", CHILD, T0 + 1201, T0 + 1201, JSON.stringify({ type: "text", text: "Found it" }));
|
|
});
|
|
server = await startServer(createApp(), 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
});
|
|
|
|
const sessionRow = () => db.prepare("SELECT * FROM sessions WHERE id = ?").get(ID);
|
|
|
|
describe("opencode usage ingest", () => {
|
|
it("creates the root session with its title, cwd, model and its tree's tokens", async () => {
|
|
const r = await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
|
|
assert.equal(r.files, 1);
|
|
const s = sessionRow();
|
|
assert.equal(s.tool, "opencode");
|
|
assert.equal(s.name, "Fix the login bug");
|
|
assert.equal(s.cwd, CWD);
|
|
assert.equal(s.model, "claude-sonnet-4-5");
|
|
assert.equal(s.status, "completed");
|
|
assert.equal(db.prepare("SELECT 1 FROM sessions WHERE id = ?").get(CHILD), undefined);
|
|
const usage = db
|
|
.prepare(
|
|
"SELECT model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens FROM token_usage WHERE session_id = ? ORDER BY model"
|
|
)
|
|
.all(ID);
|
|
assert.deepEqual(usage, [
|
|
{ model: "claude-haiku-4-5", input_tokens: 10, output_tokens: 4, cache_read_tokens: 0, cache_write_tokens: 0 },
|
|
{ model: "claude-sonnet-4-5", input_tokens: 100, output_tokens: 25, cache_read_tokens: 1000, cache_write_tokens: 50 },
|
|
]);
|
|
const agent = db.prepare("SELECT * FROM agents WHERE id = ?").get(`opencode-${CHILD}`);
|
|
assert.equal(agent.session_id, ID);
|
|
assert.equal(agent.type, "subagent");
|
|
assert.equal(agent.name, "Explore auth (@explore subagent)");
|
|
assert.equal(agent.status, "completed");
|
|
});
|
|
|
|
it("leaves an unchanged store unread", async () => {
|
|
const r = await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
|
|
assert.equal(r.files, 0);
|
|
});
|
|
|
|
it("maps the store to the transcript shape, and lists the subagent's", async () => {
|
|
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
|
|
assert.deepEqual(
|
|
list.transcripts.map((t) => [t.id, t.type, t.db_agent_id]),
|
|
[
|
|
["main", "main", null],
|
|
[CHILD, "subagent", `opencode-${CHILD}`],
|
|
]
|
|
);
|
|
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
|
|
assert.deepEqual(
|
|
body.messages.map((m) => [m.type, m.sender, m.content.map((c) => c.type).join("+")]),
|
|
[
|
|
["user", "user", "text"],
|
|
["assistant", "assistant", "thinking+tool_use"],
|
|
["user", "tool", "tool_result"],
|
|
]
|
|
);
|
|
assert.equal(body.messages[2].content[0].output, "export {}");
|
|
assert.equal(body.last_line, 3);
|
|
const tail = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?after=2`)).json();
|
|
assert.equal(tail.messages.length, 1);
|
|
const sub = await (
|
|
await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=${CHILD}`)
|
|
).json();
|
|
assert.deepEqual(
|
|
sub.messages.map((m) => [m.sender, m.content[0].text]),
|
|
[
|
|
["orchestrator", "Explore auth"],
|
|
["assistant", "Found it"],
|
|
]
|
|
);
|
|
});
|
|
|
|
it("keeps a deleted session deleted until OpenCode updates it", async () => {
|
|
db.prepare("DELETE FROM sessions WHERE id = ?").run(ID);
|
|
const bump = (t) => {
|
|
withStore((c) => c.prepare("UPDATE session SET title = title WHERE id = ?").run(ID));
|
|
const at = new Date(t);
|
|
fs.utimesSync(STORE, at, at);
|
|
};
|
|
bump(Date.now() + 5000);
|
|
await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
|
|
assert.equal(sessionRow(), undefined);
|
|
withStore((c) => c.prepare("UPDATE session SET time_updated = ? WHERE id = ?").run(T0 + 9000, ID));
|
|
fs.utimesSync(STORE, new Date(Date.now() + 10_000), new Date(Date.now() + 10_000));
|
|
await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
|
|
assert.equal(sessionRow()?.tool, "opencode");
|
|
});
|
|
});
|