diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index 633a4d91..455c85a5 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -80,6 +80,11 @@ Non-enforceable lessons — environment/CI/platform quirks no static rule can re - **`archgate` command = `bun run cli`** — This is the CLI repo itself, so the `archgate` binary is not installed in PATH. Use `bun run cli ` (e.g., `bun run cli check`, `bun run cli adr list`) instead of `archgate `. The `bun run cli` script maps to `bun run src/cli.ts`. +## Sibling Repo: archgate/plugins + +- [Skill files for opencode/cursor are GENERATED, not hand-authored](project_plugins_generated_skill_files.md) — canonical source is `claude-code/plugins/archgate/skills//SKILL.md`; edit build scripts (`scripts/build--plugin.ts`) for platform-specific wording, not the generated copies directly +- [session-context opencode --skip 1 bug fixed via --root](project_session_context_skip_root_fix.md) — same bug class likely still open for claude-code/cursor/copilot, live-reproduced for claude-code but not fixed + ## Distribution / Packaging - **npm shim + GitHub Releases** — The npm package is a thin shim (`bin/archgate.cjs`). On first run, the shim downloads the platform binary from GitHub Releases and caches it to `~/.archgate/bin/`. No platform-specific npm packages. diff --git a/.claude/agent-memory/archgate-developer/project_plugins_generated_skill_files.md b/.claude/agent-memory/archgate-developer/project_plugins_generated_skill_files.md new file mode 100644 index 00000000..68fec864 --- /dev/null +++ b/.claude/agent-memory/archgate-developer/project_plugins_generated_skill_files.md @@ -0,0 +1,12 @@ +--- +name: project-plugins-generated-skill-files +description: archgate/plugins skill files for opencode/cursor-plugin/cursor-install are GENERATED from claude-code source — never hand-edit them directly +metadata: + type: project +--- + +The sibling repo `E:\archgate\plugins` (separate git repo, `github.com/archgate/plugins`) holds the AI-facing skill/agent markdown consumed by the `archgate` Claude Code plugin, opencode, Cursor, VS Code Copilot, etc. `claude-code/plugins/archgate/skills//SKILL.md` is the **only** canonical source (per that repo's ARCH-003 "Platform Distribution Build Pattern"). Every other platform's copy — `opencode/plugins/archgate/skills/archgate-/SKILL.md`, `cursor-plugin/...`, `cursor-install/...` — is a build artifact generated by `scripts/build--plugin.ts`, which applies regex-based text transformations (tool-name rewrites, `@mention` syntax, editor-specific `session-context ` substitutions, etc.) and is checked into git for diff review, but is NOT hand-authored. + +**Why:** Hand-editing a generated copy (e.g. `opencode/plugins/archgate/skills/archgate-lessons-learned/SKILL.md`) works until the next `bun scripts/build-opencode-plugin.ts` (or `build-artifacts.ts`) run, which silently overwrites it back to whatever the regex transform of the canonical claude-code source produces. Learned this the hard way on 2026-07-01: edited the opencode SKILL.md directly to fix the `--skip 1` bug (see [[project_session_context_skip_root_fix]]), then ran the build script to regenerate the tarball artifact — it reverted my edit instantly. + +**How to apply:** To change platform-specific wording that should NOT apply to all editors (e.g. opencode needing `--root` while claude-code/cursor keep `--skip 1`), add a new transformation step inside that platform's `adaptForOpencode()`-equivalent function in `scripts/build--plugin.ts` (do a literal string/regex replace keyed off the exact canonical source text), NOT a direct edit to the generated file. To change wording that SHOULD apply to all editors, edit the canonical `claude-code/plugins/archgate/skills//SKILL.md` source instead. Either way, finish with `bun scripts/build-artifacts.ts` (regenerates all platform artifacts + embedded bridge files in one pass) then `bun scripts/check-artifacts.ts` to confirm everything is back in sync before considering the change done. That repo's `package.json` `validate` script and ARCH-003 are the authoritative references — read ARCH-003 in full via `archgate adr show ARCH-003` in that repo before making build-script changes. diff --git a/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md b/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md new file mode 100644 index 00000000..c1d705e8 --- /dev/null +++ b/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md @@ -0,0 +1,16 @@ +--- +name: project-session-context-skip-root-fix +description: opencode session-context --skip 1 sibling/inline-skill bug fixed via new --root flag (2026-07-01); same class of bug likely still open for claude-code/cursor/copilot +metadata: + type: project +--- + +Fixed a real, reproduced bug in `archgate session-context opencode --skip 1`: it could return a completely unrelated sibling sub-agent's session transcript instead of the true parent/development session. Fix shipped 2026-07-01 as a new `--root` flag on `archgate session-context opencode` (see `src/helpers/session-context-opencode.ts` and `src/commands/session-context/opencode.ts`). + +**Why:** `readOpencodeSession`'s `--skip N` selected the Nth-most-recently-updated session sharing a project directory, ignoring opencode's real `session.parent_id` column entirely. This breaks in two ways: (1) the opencode `Skill` tool runs inline in the current session (no new session row), so "skip past my own session" skips past the actual parent instead; (2) sibling sub-agent sessions fanned out from the same parent (e.g. the `archgate:reviewer` skill's parallel domain-review agents) interleave in recency order with the parent, so `--skip 1` can land on any sibling. Verified this concretely against the user's real `~/.local/share/opencode/opencode.db`: a live `archgate-lessons-learned` skill invocation's `--skip 1` call returned the "General process ADR review" sub-agent's private transcript instead of the parent session. + +`--root` fixes this by filtering to `parent_id IS NULL` before applying `skip`, which is correct regardless of nesting depth or sibling count. The `archgate-lessons-learned` skill's opencode variant was updated to use `--root` instead of `--skip 1` — see [[project_plugins_generated_skill_files]] for where that source actually lives. + +**Open/unresolved:** The _same class_ of bug likely still affects the other 3 editors' lessons-learned skill guidance (`archgate session-context claude-code --skip 1`, `cursor --skip 1`). Live-reproduced for Claude Code in this same session: running `archgate session-context claude-code --skip 1` from within an inline Skill invocation (this very lessons-learned run) returned an unrelated, unconnected past session (`6ee6f0a5-...` about "ExitWorktree"), not the current/parent conversation — because Claude Code's Skill tool also runs inline, so `--skip 1` skips past the correct (current) session file. Unlike opencode, Claude Code/Cursor have no `parent_id`-equivalent — sessions are flat per-conversation JSONL/directory files with no structural parent link, so the `--root` fix pattern doesn't directly transfer; a different fix (e.g. detecting "am I inline vs a real sub-agent" some other way, or defaulting to `--skip 0` for inline skill runs) would be needed. Not investigated for Copilot. This has NOT been fixed — flag to the user before doing more lessons-learned/reviewer work that depends on `--skip 1` being reliable for non-opencode editors. + +**How to apply:** If asked to investigate "session context returns wrong data" for claude-code, cursor, or copilot, start here — this is the same architectural flaw already fixed once for opencode, not a new mystery. diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 38f93ebb..19a7a1e4 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -4388,17 +4388,20 @@ archgate session-context cursor [options] ### archgate session-context opencode -Read the opencode session transcript for the project. Sessions are matched by the project `path` field in session metadata. +Read the opencode session transcript for the project. Sessions are matched by the `directory` field in session metadata. ```bash archgate session-context opencode [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------ | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions (useful when running as sub-agent) | -| `--session-id ` | Specific session ID to read | +| Option | Description | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | +| `--skip ` | Skip the N most recent sessions (combine with `--root` to skip within root sessions only) | +| `--session-id ` | Specific session ID to read | +| `--root` | Read the top-level session (no parent) instead of guessing by recency — use this when running as a sub-agent or inline skill | + +Opencode records real parent/child session links. A plain `--skip 1` picks the second most-recently-updated session sharing this directory, which is only the parent when exactly one other session touched it — a fan-out of sibling sub-agents can land `--skip 1` on an unrelated sibling instead. `--root` resolves the actual top-level session directly, regardless of how many sub-agent or inline-skill sessions exist underneath it. ## Examples @@ -4432,6 +4435,12 @@ Read the parent session (skip the sub-agent's own session): archgate session-context claude-code --skip 1 ``` +Read the top-level opencode session from a sub-agent or inline skill: + +```bash +archgate session-context opencode --root +``` + --- ## Reference: archgate telemetry diff --git a/docs/src/content/docs/nb/reference/cli/session-context.mdx b/docs/src/content/docs/nb/reference/cli/session-context.mdx index 95e9b72d..871d4896 100644 --- a/docs/src/content/docs/nb/reference/cli/session-context.mdx +++ b/docs/src/content/docs/nb/reference/cli/session-context.mdx @@ -54,17 +54,20 @@ archgate session-context cursor [options] ### archgate session-context opencode -Les opencode-sesjonsloggen for prosjektet. Sesjoner matches etter prosjektets `path`-felt i sesjonsmetadataene. +Les opencode-sesjonsloggen for prosjektet. Sesjoner matches etter `directory`-feltet i sesjonsmetadataene. ```bash archgate session-context opencode [options] ``` -| Valg | Beskrivelse | -| ------------------- | ------------------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -| `--skip ` | Hopp over de N nyeste sesjonene (nyttig ved kjøring som underagent) | -| `--session-id ` | Spesifikk sesjons-ID å lese | +| Valg | Beskrivelse | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | +| `--skip ` | Hopp over de N nyeste sesjonene (kombiner med `--root` for å hoppe kun blant rotsesjoner) | +| `--session-id ` | Spesifikk sesjons-ID å lese | +| `--root` | Les toppnivåsesjonen (uten forelder) i stedet for å gjette basert på nylighet — bruk dette ved kjøring som underagent eller innebygd skill | + +Opencode registrerer ekte forelder/barn-koblinger mellom sesjoner. En enkel `--skip 1` velger den nest nyeste oppdaterte sesjonen som deler denne mappen, noe som bare er forelderen når nøyaktig én annen sesjon har rørt den — en vifte av søsken-underagenter kan gjøre at `--skip 1` havner på et urelatert søsken i stedet. `--root` løser den faktiske toppnivåsesjonen direkte, uansett hvor mange underagent- eller innebygd-skill-sesjoner som finnes under den. ## Eksempler @@ -97,3 +100,9 @@ Les foreldresesjonen (hopp over underagentens egen sesjon): ```bash archgate session-context claude-code --skip 1 ``` + +Les toppnivåsesjonen for opencode fra en underagent eller innebygd skill: + +```bash +archgate session-context opencode --root +``` diff --git a/docs/src/content/docs/pt-br/reference/cli/session-context.mdx b/docs/src/content/docs/pt-br/reference/cli/session-context.mdx index 23afb3c0..578f94e7 100644 --- a/docs/src/content/docs/pt-br/reference/cli/session-context.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/session-context.mdx @@ -54,17 +54,20 @@ archgate session-context cursor [options] ### archgate session-context opencode -Lê a transcrição de sessão do opencode para o projeto. Sessões são correspondidas pelo campo `path` do projeto nos metadados da sessão. +Lê a transcrição de sessão do opencode para o projeto. Sessões são correspondidas pelo campo `directory` nos metadados da sessão. ```bash archgate session-context opencode [options] ``` -| Opção | Descrição | -| ------------------- | ------------------------------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -| `--skip ` | Pular as N sessões mais recentes (útil ao executar como sub-agente) | -| `--session-id ` | ID específico da sessão a ser lida | +| Opção | Descrição | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | +| `--skip ` | Pular as N sessões mais recentes (combine com `--root` para pular apenas entre sessões raiz) | +| `--session-id ` | ID específico da sessão a ser lida | +| `--root` | Lê a sessão de nível superior (sem pai) em vez de adivinhar pela recência — use isso ao executar como sub-agente ou skill inline | + +O opencode registra vínculos reais de pai/filho entre sessões. Um simples `--skip 1` seleciona a segunda sessão mais recentemente atualizada que compartilha esse diretório, o que só corresponde à sessão pai quando exatamente uma outra sessão a tocou — um leque de sub-agentes irmãos pode fazer `--skip 1` cair em um irmão não relacionado. `--root` resolve diretamente a sessão de nível superior real, independentemente de quantas sessões de sub-agente ou skill inline existam abaixo dela. ## Exemplos @@ -97,3 +100,9 @@ Ler a sessão pai (pular a sessão do sub-agente): ```bash archgate session-context claude-code --skip 1 ``` + +Ler a sessão de nível superior do opencode a partir de um sub-agente ou skill inline: + +```bash +archgate session-context opencode --root +``` diff --git a/docs/src/content/docs/reference/cli/session-context.mdx b/docs/src/content/docs/reference/cli/session-context.mdx index 624a9d6a..d8aa6c19 100644 --- a/docs/src/content/docs/reference/cli/session-context.mdx +++ b/docs/src/content/docs/reference/cli/session-context.mdx @@ -54,17 +54,20 @@ archgate session-context cursor [options] ### archgate session-context opencode -Read the opencode session transcript for the project. Sessions are matched by the project `path` field in session metadata. +Read the opencode session transcript for the project. Sessions are matched by the `directory` field in session metadata. ```bash archgate session-context opencode [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------ | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions (useful when running as sub-agent) | -| `--session-id ` | Specific session ID to read | +| Option | Description | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | +| `--skip ` | Skip the N most recent sessions (combine with `--root` to skip within root sessions only) | +| `--session-id ` | Specific session ID to read | +| `--root` | Read the top-level session (no parent) instead of guessing by recency — use this when running as a sub-agent or inline skill | + +Opencode records real parent/child session links. A plain `--skip 1` picks the second most-recently-updated session sharing this directory, which is only the parent when exactly one other session touched it — a fan-out of sibling sub-agents can land `--skip 1` on an unrelated sibling instead. `--root` resolves the actual top-level session directly, regardless of how many sub-agent or inline-skill sessions exist underneath it. ## Examples @@ -97,3 +100,9 @@ Read the parent session (skip the sub-agent's own session): ```bash archgate session-context claude-code --skip 1 ``` + +Read the top-level opencode session from a sub-agent or inline skill: + +```bash +archgate session-context opencode --root +``` diff --git a/src/commands/session-context/opencode.ts b/src/commands/session-context/opencode.ts index 7e1a7d18..240e5d89 100644 --- a/src/commands/session-context/opencode.ts +++ b/src/commands/session-context/opencode.ts @@ -16,7 +16,7 @@ const maxEntriesOption = new Option( const skipOption = new Option( "--skip ", - "skip the N most recent sessions (useful when running as a sub-agent)" + "skip the N most recent sessions (for --root, skips within root sessions only; for finding a parent session use --root instead)" ) .argParser((val) => Math.trunc(Number(val))) .default(0); @@ -28,6 +28,10 @@ export function registerOpencodeSessionContextCommand(parent: Command) { .addOption(maxEntriesOption) .addOption(skipOption) .option("--session-id ", "Specific session ID to read") + .option( + "--root", + "read the top-level session (no parent) for the project instead of guessing by recency — use this instead of --skip when running as a sub-agent or inline skill" + ) .action(async (opts) => { try { const projectRoot = findProjectRoot(); @@ -35,6 +39,7 @@ export function registerOpencodeSessionContextCommand(parent: Command) { maxEntries: opts.maxEntries, skip: opts.skip, sessionId: opts.sessionId, + root: opts.root, }); if (!result.ok) { diff --git a/src/helpers/session-context-opencode.ts b/src/helpers/session-context-opencode.ts index f4311df5..5d89b43e 100644 --- a/src/helpers/session-context-opencode.ts +++ b/src/helpers/session-context-opencode.ts @@ -23,6 +23,18 @@ interface OpencodeSessionSummary { interface ReadOpencodeSessionOptions extends ReadSessionOptions { sessionId?: string; + /** + * Restrict selection to root sessions only (`parent_id IS NULL`) before + * applying `skip`. Opencode is the only session-context backend with a + * real parent/child session graph, so this option lives here rather than + * in the shared `ReadSessionOptions`. + * + * Use this — not a bare `skip: 1` — when you need "the top-level + * development session" from a sub-agent or an inline Skill invocation: + * a plain recency-based skip cannot distinguish the true parent from a + * sibling sub-agent session once more than one sibling exists. + */ + root?: boolean; } type OpencodeSessionResult = @@ -50,6 +62,12 @@ function normalizePath(p: string): string { * * Sessions are matched by comparing the `directory` field in session rows * to the provided project root. + * + * Opencode sessions form a real parent/child tree via the `session.parent_id` + * column (sub-agent spawns create a child row; an inline Skill invocation + * reuses the current row and creates no child at all). Pass `root: true` to + * resolve the top-level session for the directory instead of guessing by + * recency via `skip` — see `ReadOpencodeSessionOptions.root`. */ export function readOpencodeSession( projectRoot: string | null, @@ -80,10 +98,11 @@ export function readOpencodeSession( id: string; directory: string; time_updated: number; + parent_id: string | null; } const allSessions = db .query( - "SELECT id, directory, time_updated FROM session ORDER BY time_updated DESC" + "SELECT id, directory, time_updated, parent_id FROM session ORDER BY time_updated DESC" ) .all(); @@ -105,17 +124,34 @@ export function readOpencodeSession( }; } - // 3. Select session by ID or most recent (with optional skip) + // 3. Select session by ID, or by recency within the chosen scope (with + // optional skip). + // + // Opencode records true parent/child linkage via `parent_id` — a + // sub-agent session (or an inline Skill invocation, which appends to the + // *current* session row instead of creating a new one) can have any + // number of sibling sessions sharing this directory. Sorting every + // matching session by recency and taking the Nth (the plain `--skip` + // behavior) cannot reliably reach "the top-level session": once more + // than one sibling exists, whichever sibling was touched most recently + // occupies that slot, not the parent. `root: true` sidesteps the + // guesswork by filtering to sessions with no parent (the true root of + // the tree) before applying `skip`. const skip = options?.skip ?? 0; + const rootOnly = options?.root ?? false; + const candidates = rootOnly + ? matching.filter((s) => s.parent_id === null) + : matching; const target = options?.sessionId ? matching.find((s) => s.id === options.sessionId) - : matching[skip]; + : candidates[skip]; if (!target) { + const scope = rootOnly ? "root session(s)" : "session(s)"; const error = options?.sessionId ? `Session not found: ${options.sessionId}` - : `Only ${String(matching.length)} session(s) available but --skip ${String(skip)} requested`; - return { ok: false, error, available: matching.map((s) => s.id) }; + : `Only ${String(candidates.length)} ${scope} available but --skip ${String(skip)} requested`; + return { ok: false, error, available: candidates.map((s) => s.id) }; } // 4. Read messages for the session diff --git a/tests/commands/session-context/opencode.test.ts b/tests/commands/session-context/opencode.test.ts index 42af1992..dd32d404 100644 --- a/tests/commands/session-context/opencode.test.ts +++ b/tests/commands/session-context/opencode.test.ts @@ -70,6 +70,14 @@ describe("registerOpencodeSessionContextCommand", () => { const opt = sub.options.find((o) => o.long === "--session-id"); expect(opt).toBeDefined(); }); + + test("accepts --root option", () => { + const parent = new Command("session-context"); + registerOpencodeSessionContextCommand(parent); + const sub = parent.commands.find((c) => c.name() === "opencode")!; + const opt = sub.options.find((o) => o.long === "--root"); + expect(opt).toBeDefined(); + }); }); describe("opencode action handler", () => { @@ -184,6 +192,25 @@ describe("opencode action handler", () => { maxEntries: undefined, skip: 0, sessionId: undefined, + root: undefined, + }); + }); + + test("passes root: true when --root is given", async () => { + mockReadOpencodeSession.mockResolvedValue({ ok: true, data: {} }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "opencode", + "--root", + ]); + + expect(mockReadOpencodeSession).toHaveBeenCalledWith(tempDir, { + maxEntries: undefined, + skip: 0, + sessionId: undefined, + root: true, }); }); }); diff --git a/tests/helpers/session-context-opencode-root.test.ts b/tests/helpers/session-context-opencode-root.test.ts new file mode 100644 index 00000000..249c4129 --- /dev/null +++ b/tests/helpers/session-context-opencode-root.test.ts @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { Database } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { readOpencodeSession } from "../../src/helpers/session-context-opencode"; + +/** + * Tests for readOpencodeSession's `root` option — resolving the true + * top-level session via `session.parent_id` instead of guessing by recency. + * + * Split from session-context-opencode.test.ts to stay under oxlint's + * max-lines limit. + */ +describe("readOpencodeSession — root option", () => { + const uniqueId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const projectRoot = resolve(`/__archgate_opencode_root_test_${uniqueId}`); + let tempDir: string; + let dbPath: string; + let originalXdg: string | undefined; + + beforeEach(() => { + tempDir = join( + tmpdir(), + `archgate-opencode-root-test-${uniqueId}-${Date.now()}` + ); + mkdirSync(join(tempDir, "opencode"), { recursive: true }); + dbPath = join(tempDir, "opencode", "opencode.db"); + originalXdg = Bun.env.XDG_DATA_HOME; + Bun.env.XDG_DATA_HOME = tempDir; + }); + + afterEach(() => { + if (originalXdg === undefined) { + delete Bun.env.XDG_DATA_HOME; + } else { + Bun.env.XDG_DATA_HOME = originalXdg; + } + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // On Windows, SQLite file handles may persist briefly after close. + } + }); + + function createDb(): Database { + const db = new Database(dbPath); + db.exec("PRAGMA journal_mode = DELETE"); + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL DEFAULT '', + parent_id TEXT, + directory TEXT NOT NULL DEFAULT '', + time_created INTEGER NOT NULL DEFAULT 0, + time_updated INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL DEFAULT 0, + time_updated INTEGER NOT NULL DEFAULT 0, + data TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL DEFAULT 0, + time_updated INTEGER NOT NULL DEFAULT 0, + data TEXT NOT NULL DEFAULT '{}' + ); + `); + return db; + } + + /** Insert a session with a single user message, optionally as a child. */ + function makeSession( + db: Database, + sessionId: string, + timeUpdated: number, + parentId: string | null = null + ): void { + db.run( + "INSERT INTO session (id, directory, parent_id, time_created, time_updated) VALUES (?, ?, ?, ?, ?)", + [sessionId, projectRoot, parentId, timeUpdated, timeUpdated] + ); + const msgId = `msg_${sessionId}`; + db.run( + "INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)", + [msgId, sessionId, timeUpdated + 1, timeUpdated + 1, '{"role":"user"}'] + ); + db.run( + "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)", + [ + `prt_${msgId}`, + msgId, + sessionId, + timeUpdated + 1, + timeUpdated + 1, + JSON.stringify({ type: "text", text: sessionId }), + ] + ); + } + + test("root reads the true parent even when sibling sub-agents are more recent", async () => { + // Real-world fan-out: one parent spawns sibling sub-agent sessions + // against the same directory. Plain --skip 1 lands on whichever sibling + // sits second in recency order, not the parent — --root fixes this by + // filtering to parent_id IS NULL first. + const db = createDb(); + makeSession(db, "ses_parent", 1000); + makeSession(db, "ses_sibling_a", 3000, "ses_parent"); + makeSession(db, "ses_sibling_b", 2000, "ses_parent"); + db.close(); + + const skipped = await readOpencodeSession(projectRoot, { skip: 1 }); + if (skipped.ok) expect(skipped.data.sessionId).toBe("ses_sibling_b"); + + const rooted = await readOpencodeSession(projectRoot, { root: true }); + expect(rooted.ok).toBe(true); + if (rooted.ok) expect(rooted.data.sessionId).toBe("ses_parent"); + }); + + test("root combined with skip selects among root sessions only", async () => { + const db = createDb(); + makeSession(db, "ses_root_old", 1000); + makeSession(db, "ses_child", 1500, "ses_root_old"); + makeSession(db, "ses_root_new", 2000); + db.close(); + + // The intervening child is excluded from the root-only list, so skip 1 + // lands on the older root session, not the child. + const result = await readOpencodeSession(projectRoot, { + root: true, + skip: 1, + }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.data.sessionId).toBe("ses_root_old"); + }); + + test("root returns error when no root sessions are available", async () => { + const db = createDb(); + makeSession(db, "ses_orphan_child", 1000, "ses_missing_parent"); + db.close(); + + const result = await readOpencodeSession(projectRoot, { root: true }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("root session(s)"); + expect(result.error).toContain("--skip 0 requested"); + } + }); +});