Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/subsystems/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ These pages describe current responsibilities, runtime paths, state, and contrac
| Connecting an agent's config to the gateway, drift, disconnect, WSL twins | [Agent wiring](agent-wiring.md) |
| Providers, keys, subscription accounts, refresh and allowance | [Providers and accounts](providers-accounts.md) |
| Instructions, MCP servers, skills and RTK written into agents | [Library](library.md) |
| Reasonix local conversations and retained native usage | [Reasonix sessions](reasonix-sessions.md) |
| Tray, panel, window, `magpie web` and the page's JSON API | [GUI app shell](gui-shell.md) |

A subsystem can span several packages or repositories. Its reference describes the behavior those parts provide together; it does not need to list every function.
Expand Down
64 changes: 64 additions & 0 deletions docs/subsystems/reasonix-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Reasonix local conversations and usage

Reasonix is an agent, not a subscription provider. Its configuration and separate
Executor/Plan model choices belong to [agent wiring](agent-wiring.md).
Provider plugins authenticate and forward requests; they do not register local
conversation readers.

## Sources and boundaries

[`reasonixFiles`](../../internal/sessions/reasonix.go) discovers native JSONL
conversations under `sessions/` and `projects/*/sessions/`. The root is
`REASONIX_STATE_HOME`, then `REASONIX_HOME`, otherwise `~/.reasonix` on Unix or
`%APPDATA%/reasonix` on Windows. Reads neither start Reasonix nor migrate its data.

Each conversation's `.jsonl.meta` supplies its identity, title and workspace.
Older message files without metadata are admitted from their native message
prefix; unknown workspace and usage attribution are left unknown. Sidecars,
subagent directories and archive/import trees are not separate conversations.
Copied conversations with the same metadata id count once, using the newest
transcript (lexical path breaks a tie).

The associated `.turns.jsonl` ledger supplies retained, non-estimated `usage`
events. A sequence watermark persists with the aggregate across cache reloads.
Planner, Executor and auxiliary usage retain their actual model references.
Prompt includes cached input: uncached input is prompt minus cache hit. Completion
already includes reasoning; cumulative session and context gauges add no tokens.

No ledger, missing identity, a compacted ledger or invalid/estimated usage makes
`usage_incomplete` true. The GUI labels that history incomplete. A checkpoint
contains terminal summaries, not the discarded usage; those totals cannot be
reconstructed. Daily `stats/*.jsonl` have no session id and are not assigned to
conversations by proximity in time. Missing message timestamps do not become
invented daily activity.

## Shared readers and cache behavior

The adapter uses the shared discovery, incremental scanner and summary cache.
Unchanged files reuse their aggregate. Appends read from the last complete line;
truncation, sampled prefix changes or identity changes rebuild it. Metadata-only
changes invalidate the transcript summary. Ledger text/tool-output lines are
filtered before gathering long lines. Prefix validation uses the shared bounded
sampling contract; an unsampled same-size rewrite is not guaranteed detectable.

`TranscriptOf` reads text, thinking, tool calls and results on demand with shared
size limits. Unix resume commands quote the native file path. Deletion and
cross-agent conversion are unsupported: this adapter does not delete sidecars,
content blobs, or rewrite another agent's history.

## Coverage not provided

Framed `sessions-v4`/`desktop-sessions-v5` stores need their own codecs and are not
interpreted as legacy JSONL. This reader does not add native request rows to the
usage ledger, conversation tracing, or library instructions/skills/MCP targets.
Gateway Reasonix request accounting continues independently. Keeping native
summary usage separate avoids counting a routed request twice in that ledger.

## Verification

`go test -tags nogui ./internal/sessions ./internal/agentenv` exercises native
shapes, independent role models, duplicate events/copies, missing/compacted
history, metadata edits, completed tails, replacement and cold cache reloads.
`BenchmarkReasonixLedgerAppend` measures append updates after a text-heavy ledger.
The GUI session conversation and agent-strip tests cover Reasonix filtering,
transcript opening and incomplete-history labels in both engines.
2 changes: 1 addition & 1 deletion internal/agentenv/agentenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ var Vars = []string{
"MIMOCODE_HOME", "MINIMAX_DATA_DIR", "HANA_HOME", "HERMES_HOME", "DSH_HOME",
"WORKBUDDY_CONFIG_DIR", "CODEBUDDY_CONFIG_DIR",
// Reasonix Studio and its native CLI share this config home
"REASONIX_HOME",
"REASONIX_HOME", "REASONIX_STATE_HOME",
// Mister Morph's config file
"MISTER_MORPH_CONFIG",
// T3 Code's base folder (its settings in userdata/)
Expand Down
5 changes: 5 additions & 0 deletions internal/gui/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -16752,6 +16752,10 @@ function sessSpent(s) {
if (tokens) num.append(el("small", "", t("{a} in · {b} out", { a: fmtN(s.input), b: fmtN(s.output) }) + (s.cache_read ? " · " + t("{n} cached", { n: fmtN(s.cache_read) }) : "")));
const sc = sessCost(s);
const cost = el("div", "cost" + (sc === "—" ? " none" : ""), sc);
if (s.usage_incomplete) {
num.append(el("small", "", t("Partial usage history")));
num.title = t("Only retained native usage is counted; older records are unavailable.");
}
if (sc === "—") cost.title = t("No known price for {models}", { models: sessModelNames(s.models.map((m) => m.model)) || "—" });
else if (s.unpriced) cost.title = t("Not counted: {models}, with no known price", { models: sessModelNames(s.models.filter((m) => !m.priced).map((m) => m.model)) });
return [num, cost];
Expand Down Expand Up @@ -16797,6 +16801,7 @@ function sessionItem(s) {

function sessionDetail(s) {
const d = el("div", "sess-detail");
if (s.usage_incomplete) d.append(el("div", "sess-wait", t("Only retained native usage is counted; older records are unavailable.")));
const line = (label, value, extra) => {
const l = el("div", "sess-line");
l.append(el("span", "k", label));
Expand Down
6 changes: 6 additions & 0 deletions internal/gui/assets/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
// works. {name} slots are filled by t(str, vars).
const I18N = {
zh: {
"Partial usage history": "用量历史不完整",
"Only retained native usage is counted; older records are unavailable.": "仅统计现存的本地用量记录,缺失的历史记录无法计入。",
"No installed fonts found": "未找到已安装字体",
"Interface font": "界面字体",
"Code font": "代码字体",
Expand Down Expand Up @@ -3530,6 +3532,8 @@ const I18N = {
"credits": "积分",
},
ja: {
"Partial usage history": "使用履歴が不完全です",
"Only retained native usage is counted; older records are unavailable.": "保存されているローカル使用記録のみを集計します。失われた履歴は含まれません。",
"No installed fonts found": "インストール済みフォントが見つかりません",
"Interface font": "インターフェースのフォント",
"Code font": "コードのフォント",
Expand Down Expand Up @@ -7041,6 +7045,8 @@ const I18N = {
"Main navigation": "メインナビゲーション",
},
de: {
"Partial usage history": "Unvollständiger Nutzungsverlauf",
"Only retained native usage is counted; older records are unavailable.": "Nur erhaltene lokale Nutzungsdaten werden gezählt; ältere Datensätze sind nicht verfügbar.",
"No installed fonts found": "Keine installierten Schriften gefunden",
"Interface font": "Oberflächenschrift",
"Code font": "Codeschrift",
Expand Down
24 changes: 21 additions & 3 deletions internal/gui/tests/sessions-talk.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function serve(lang, calls) {
sess("pi", "p-2", "an older one", 50, { transcript: true, carry: [{ agent: "omp", command: FORK.replace("p-1", "p-2") }] }),
],
opencode: [{ ...sess("opencode", "o-1", "an opencode chat", 8), deletable: false, resume: "opencode -s o-1" }],
reasonix: [{ ...sess("reasonix", "r-1", "a Reasonix chat", 8), deletable: false, transcript: true, usage_incomplete: true, resume: "reasonix --resume '/work/session.jsonl'" }],
};
const state = { agents: [], profiles: [], settings: { lang, theme: "light" } };
return async (route) => {
Expand All @@ -51,11 +52,13 @@ function serve(lang, calls) {
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } });
if (url.pathname === "/api/sessions/manage") {
const agent = url.searchParams.get("agent") === "opencode" ? "opencode" : "pi";
const requested = url.searchParams.get("agent");
const agent = requested === "opencode" || requested === "reasonix" ? requested : "pi";
return json({
agents: [
{ agent: "pi", count: store.pi.length, deletable: true, name: "Pi", icon: "pi" },
{ agent: "opencode", count: store.opencode.length, deletable: false, name: "OpenCode", icon: "opencode" },
{ agent: "reasonix", count: store.reasonix.length, deletable: false, name: "Reasonix Studio", icon: "reasonix-color" },
],
agent, sessions: store[agent], terminal: true, trash: [], trashDir: "~/trash",
});
Expand Down Expand Up @@ -84,10 +87,14 @@ const words = {
line: "Continue in omp", show: "Show conversation", hide: "Hide conversation", talk: "Conversation", you: "You", asst: "Assistant", call: "Tool call", result: "Tool result", thinking: "Thinking" },
zh: { nav: "会话", carry: "换 Agent 继续", head: "换个 Agent 继续", copyNote: "复制命令", termNote: "在所选终端中打开",
line: "用 omp 继续", show: "查看会话", hide: "收起会话", talk: "会话内容" },
ja: { nav: "セッション", carry: "別のエージェントで続ける", head: "別のエージェントで続ける", copyNote: "コマンドをコピー", termNote: "セッション用ターミナルで開く",
line: "omp で続ける", show: "会話を表示", hide: "会話を隠す", talk: "会話", partial: "使用履歴が不完全です" },
de: { nav: "Sitzungen", carry: "Fortsetzen in", head: "In einem anderen Agenten fortsetzen", copyNote: "Befehl kopieren", termNote: "Im Sitzungsterminal öffnen",
line: "In omp fortsetzen", show: "Unterhaltung anzeigen", hide: "Unterhaltung ausblenden", talk: "Unterhaltung", partial: "Unvollständiger Nutzungsverlauf" },
};

for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
for (const lang of ["en", "zh", "ja", "de"]) {
const w = words[lang];
test(`${engine} ${lang}: a session's conversation is shown, and a Pi session is carried on in omp`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
Expand Down Expand Up @@ -188,9 +195,20 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await view.locator(".sess-detail").waitFor();
assert.equal(await view.locator(".sess-talk-btn").count(), 0);

await view.locator(".sm-agents .opt", { hasText: "Reasonix Studio" }).click();
const rx = view.locator('.row.sm-sess[data-id="r-1"]');
await rx.waitFor();
const partial = w.partial || (lang === "zh" ? "用量历史不完整" : "Partial usage history");
assert((await rx.textContent()).includes(partial), "missing usage is visible, not presented as a complete zero");
await rx.locator(".who").click();
assert((await view.locator(".sess-detail").textContent()).includes("reasonix --resume"));
await view.locator(".sess-talk-btn").click();
await view.locator(".sess-talk .cx-part").first().waitFor();
assert(calls.some((c) => c.path === "transcript" && c.agent === "reasonix" && c.id === "r-1"));

if (lang === "zh") {
const missing = await page.evaluate(() => ["Continue in {agent}", "Continue in", "Continue this session in another agent", "Continue in another agent",
"Copy the command", "Conversation", "Show conversation", "Hide conversation", "Reading…"].filter((k) => !I18N.zh[k] || !I18N.ja[k] || !I18N.de[k]));
"Copy the command", "Conversation", "Show conversation", "Hide conversation", "Reading…", "Partial usage history", "Only retained native usage is counted; older records are unavailable."].filter((k) => !I18N.zh[k] || !I18N.ja[k] || !I18N.de[k]));
assert.deepEqual(missing, [], "every string has its Chinese, Japanese and German");
}
assert.deepEqual(errors, []);
Expand Down
8 changes: 4 additions & 4 deletions internal/gui/tests/usage-sessions-agents-fit.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const { test } = require("node:test");
const { chromium, webkit } = require("playwright");

const assets = path.resolve(__dirname, "../assets");
const names = ["Pi", "WorkBuddy", "DeepSeek Harness", "Claude Code", "Codex", "omp", "OpenCode", "Qoder", "ZCode", "Claude Desktop", "Qoder CN", "Grok Build", "Kimi Code", "Cursor", "Zed"];
const names = ["Pi", "WorkBuddy", "DeepSeek Harness", "Claude Code", "Codex", "omp", "OpenCode", "Qoder", "ZCode", "Claude Desktop", "Qoder CN", "Grok Build", "Kimi Code", "Cursor", "Zed", "Reasonix Studio"];
const agents = Object.fromEntries(names.map((n, i) => ["a" + i, n]));
const iso = (d) => [d.getFullYear(), d.getMonth() + 1, d.getDate()].map((n) => String(n).padStart(2, "0")).join("-");
const today = iso(new Date());
Expand Down Expand Up @@ -41,7 +41,7 @@ function serve(lang) {
}

for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
for (const lang of ["en", "zh", "ja", "de"]) {
test(`${engine} ${lang}: many agents don't widen Usage › Sessions`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
Expand Down Expand Up @@ -81,9 +81,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
const lb = await last.boundingBox();
await page.mouse.click(lb.x + lb.width / 2, lb.y + lb.height / 2);
}
await page.waitForFunction(() => document.querySelector("#sessAgent .opt.on")?.textContent === "Zed");
await page.waitForFunction(() => document.querySelector("#sessAgent .opt.on")?.textContent === "Reasonix Studio");
await settle();
assert.equal(await strip.getByRole("button", { pressed: true }).textContent(), "Zed", "the reused button exposes its new selection");
assert.equal(await strip.getByRole("button", { pressed: true }).textContent(), "Reasonix Studio", "the reused button exposes its new selection");
assert.equal(await all.getAttribute("aria-pressed"), "false", "the previous selection is cleared");
const on = await strip.locator(".opt.on").boundingBox();
const sb = await strip.boundingBox();
Expand Down
Loading
Loading