diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index 633a4d91..71e8d24e 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -43,6 +43,10 @@ Non-enforceable lessons — environment/CI/platform quirks no static rule can re - **`oxfmt` formats markdown too — run `bun run format` after editing any `.md`, including ADRs** — `format:check` (part of `bun run validate` and the CI "Lint, Test & Check" job) runs `oxfmt --check .` over ALL files, not just `.ts`. It normalizes markdown (e.g., emphasis `*word*` → `_word_`). The `adr-author` skill writes ADR markdown but does NOT auto-format it, so ADR edits frequently fail CI `format:check` even when the TS changes are clean. Always run `bun run format` before committing ADR/markdown edits. Tripped CI on PR #372 (ARCH-005 edit). - **YAML double-quoted strings require escaped backslashes for Windows paths in tests** — YAML interprets `\` as an escape character inside double-quoted strings. Writing `cwd: "E:\project"` silently corrupts the parsed value because `\p` is not a valid escape sequence. Fix: use `JSON.stringify(path)` to produce properly escaped YAML values (e.g., `cwd: ${JSON.stringify(cwd)}`). JSON and YAML double-quoted strings share the same escape syntax. Encountered in Copilot CLI session-context tests (`workspace.yaml` with Windows paths). - **`Bun.Glob.match()` triggers oxlint `prefer-regexp-test`** — `Bun.Glob.match()` returns a boolean (not a RegExp), but oxlint can't tell. Suppress with `// oxlint-disable-next-line prefer-regexp-test -- Bun.Glob.match() returns boolean, not RegExp`. +- **Live `opencode.db` can't be opened `readonly: true` while opencode runs** — `new Database(path, { readonly: true })` fails with `SQLITE_CANTOPEN` (errno 14) on the live WAL-mode DB (`~/.local/share/opencode/opencode.db`). To inspect real data, copy `opencode.db` + `.db-wal` + `.db-shm` to a temp dir and open the copy. Data model facts load-bearing for `session-context opencode`: sub-agent runs are child sessions (`parent_id` set) sharing the parent's `directory`, and opencode skills run INLINE in the calling session (no own session row) — recency selection must filter `parent_id IS NULL` (fixed 2026-07-01; `--root` resolves a `--session-id` child to its top-level ancestor). +- **The distributed opencode lessons-learned skill references session-context CLI flags — sequence releases** — the skill instructs `archgate session-context opencode --root` (was `--skip 1`, which read sub-agent transcripts). When changing session-context CLI semantics, update the shipped skill in the same effort AND sequence releases: the CLI ships the flag first, the plugin follows — a skill referencing a flag the installed CLI lacks dies with "unknown option". Don't hand-edit the installed copy under `~/.config/opencode/skills/` before the CLI release. +- **Verify haiku reviewer findings against the cited ADR's text before blocking** — A haiku review sub-agent reported "await on a synchronous helper" as an ARCH-012 violation; ARCH-012 only mandates try-catch boundaries/exit codes and its automated rules passed. Re-read the cited ADR's Decision/Do's before accepting a FAIL; overrule misattributed style nits but still surface them as non-ADR notes. Recurred 2026-07-01: the same await-on-sync-helper nit came back self-labeled "ARCH-NONE" — a finding citing no ADR can never block. +- **Git Bash `/tmp` is invisible to Windows-native tools** — A bash redirect to `/tmp/x` writes into Git Bash's virtual mount, but Windows-native python/node then fail with `FileNotFoundError` on that path (gh.exe survives only because Git Bash rewrites path-looking arguments). When piping a file between bash and Windows-native tools, use a repo-relative path (and clean it up) or `$TMPDIR`. - **oxlint `no-negated-condition`** — Always write ternaries with the positive condition first: `x === null ? A : B` not `x !== null ? B : A`. Applies to both `if/else` blocks and ternary expressions. - **oxlint `no-unused-vars` on catch parameters** — Use bare `catch { }` (no parameter) when the caught error is not used. `catch (err) { }` with unused `err` triggers the rule. - **oxlint `no-await-in-loop`** — Sequential `await` inside a `for` loop is flagged (warning). When the sequential order is intentional (e.g., build steps with per-step output), suppress with `// oxlint-disable-next-line no-await-in-loop -- `. 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..d2e4b95e --- /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 2026-07-01 (top-level default + --root ancestry walk); 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 (see `src/helpers/session-context-opencode.ts` and `src/commands/session-context/opencode.ts`): recency selection now only considers **top-level sessions** (`parent_id IS NULL`) by default, and a new `--root` flag walks the `parent_id` chain from a `--session-id` child session up to its top-level ancestor (cycle-guarded). Without `--session-id`, `--root` is an explicit alias for the new default. + +**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. + +Top-level filtering fixes the common case regardless of nesting depth or sibling count; `--session-id --root` gives deterministic ancestry resolution when the caller knows a session inside the right conversation tree (relevant when several top-level sessions share a directory — recency alone can pick a different conversation's root). The distributed archgate editor plugin's opencode lessons-learned skill was updated to use `--root` instead of `--skip 1`. + +**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: running `archgate session-context claude-code --skip 1` from within an inline Skill invocation 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..ce4149ca 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 comparing the session `directory` field to the project root. opencode records sub-agent runs as child sessions that share the parent's directory — these are excluded from recency selection, so the most recent top-level session is always the main development session. Use `--session-id` to read a specific session, including a sub-agent child session. + +When several top-level sessions exist for the same directory, recency selection picks the most recently updated one — which may not be the conversation you are part of. If you know a session ID inside the right conversation tree (for example, a sub-agent knows its own child session ID), pass `--session-id --root` to resolve its top-level ancestor deterministically instead of relying on recency. ```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 top-level sessions (sub-agent sessions always excluded) | +| `--session-id ` | Specific session ID to read | +| `--root` | Resolve to the top-level (root) session — with `--session-id`, walks up from a sub-agent child session | ## Examples @@ -4432,6 +4435,12 @@ Read the parent session (skip the sub-agent's own session): archgate session-context claude-code --skip 1 ``` +Resolve an opencode sub-agent child session to its top-level ancestor: + +```bash +archgate session-context opencode --session-id ses_child123 --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..8786fda5 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 ved å sammenligne sesjonens `directory`-felt med prosjektroten. opencode lagrer underagent-kjøringer som barnesesjoner som deler foreldrenes katalog — disse ekskluderes fra utvalget etter nylighet, slik at den nyeste toppnivåsesjonen alltid er hovedutviklingssesjonen. Bruk `--session-id` for å lese en bestemt sesjon, inkludert en underagent-barnesesjon. + +Når flere toppnivåsesjoner finnes for samme katalog, velger nylighetsutvalget den som sist ble oppdatert — som kanskje ikke er samtalen du er en del av. Hvis du kjenner en sesjons-ID i riktig samtaletre (for eksempel kjenner en underagent sin egen barnesesjons-ID), send `--session-id --root` for å løse opp toppnivåforelderen deterministisk i stedet for å stole på nylighet. ```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 toppnivåsesjonene (underagentsesjoner ekskluderes alltid) | +| `--session-id ` | Spesifikk sesjons-ID å lese | +| `--root` | Løs opp til toppnivåsesjonen (rot) — med `--session-id` gås det oppover fra en underagent-barnesesjon | ## Eksempler @@ -97,3 +100,9 @@ Les foreldresesjonen (hopp over underagentens egen sesjon): ```bash archgate session-context claude-code --skip 1 ``` + +Løs opp en underagent-barnesesjon i opencode til dens toppnivåforelder: + +```bash +archgate session-context opencode --session-id ses_child123 --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..8247e4f3 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 comparando o campo `directory` da sessão com a raiz do projeto. O opencode registra execuções de sub-agentes como sessões filhas que compartilham o diretório da sessão pai — elas são excluídas da seleção por recência, de modo que a sessão de nível superior mais recente é sempre a sessão principal de desenvolvimento. Use `--session-id` para ler uma sessão específica, incluindo uma sessão filha de sub-agente. + +Quando existem várias sessões de nível superior para o mesmo diretório, a seleção por recência escolhe a atualizada mais recentemente — que pode não ser a conversa da qual você faz parte. Se você conhece um ID de sessão dentro da árvore de conversa correta (por exemplo, um sub-agente conhece o ID da sua própria sessão filha), passe `--session-id --root` para resolver o ancestral de nível superior de forma determinística em vez de depender da recência. ```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 de nível superior mais recentes (sessões de sub-agente são sempre excluídas) | +| `--session-id ` | ID específico da sessão a ser lida | +| `--root` | Resolve para a sessão de nível superior (raiz) — com `--session-id`, sobe a partir de uma sessão filha de sub-agente | ## 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 ``` + +Resolver uma sessão filha de sub-agente do opencode para seu ancestral de nível superior: + +```bash +archgate session-context opencode --session-id ses_child123 --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..131b4886 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 comparing the session `directory` field to the project root. opencode records sub-agent runs as child sessions that share the parent's directory — these are excluded from recency selection, so the most recent top-level session is always the main development session. Use `--session-id` to read a specific session, including a sub-agent child session. + +When several top-level sessions exist for the same directory, recency selection picks the most recently updated one — which may not be the conversation you are part of. If you know a session ID inside the right conversation tree (for example, a sub-agent knows its own child session ID), pass `--session-id --root` to resolve its top-level ancestor deterministically instead of relying on recency. ```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 top-level sessions (sub-agent sessions always excluded) | +| `--session-id ` | Specific session ID to read | +| `--root` | Resolve to the top-level (root) session — with `--session-id`, walks up from a sub-agent child session | ## Examples @@ -97,3 +100,9 @@ Read the parent session (skip the sub-agent's own session): ```bash archgate session-context claude-code --skip 1 ``` + +Resolve an opencode sub-agent child session to its top-level ancestor: + +```bash +archgate session-context opencode --session-id ses_child123 --root +``` diff --git a/src/commands/session-context/opencode.ts b/src/commands/session-context/opencode.ts index 7e1a7d18..d530c767 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 top-level sessions (sub-agent sessions are always excluded)" ) .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", + "resolve to the top-level (root) session — with --session-id, walks up from a sub-agent child session" + ) .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..c850b914 100644 --- a/src/helpers/session-context-opencode.ts +++ b/src/helpers/session-context-opencode.ts @@ -23,6 +23,23 @@ interface OpencodeSessionSummary { interface ReadOpencodeSessionOptions extends ReadSessionOptions { sessionId?: string; + /** + * Resolve to the top-level (root) session. Without `sessionId` this is + * an explicit alias for the default behavior (recency selection already + * only considers top-level sessions); combined with a `sessionId` that + * names a sub-agent child session, walks the `parent_id` chain up to the + * top-level ancestor. + * + * 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`. A recency-based guess (the old bare `skip: 1`) + * cannot distinguish the true parent from a sibling sub-agent session + * once more than one sibling exists — and an inline Skill invocation + * creates no session row at all, so there is nothing to skip past. + * Ancestry via `parent_id` is correct regardless of nesting depth or + * sibling fan-out. + */ + root?: boolean; } type OpencodeSessionResult = @@ -45,11 +62,20 @@ function normalizePath(p: string): string { * Opencode stores data in a SQLite database at * `$XDG_DATA_HOME/opencode/opencode.db` (default `~/.local/share/opencode/opencode.db`): * - `session` table — session metadata with `directory` for project matching + * and `parent_id` linking sub-agent sessions to their parent * - `message` table — messages with `role` in the `data` JSON column * - `part` table — content parts with `type` and `text` in the `data` JSON column * * Sessions are matched by comparing the `directory` field in session rows * to the provided project root. + * + * Sub-agent runs are stored as child sessions (`parent_id` set) that share + * the parent's `directory`, so recency-based selection (`skip`) only + * considers top-level sessions — otherwise sub-agent transcripts shadow the + * main session. Note that opencode skills run inline in the calling session + * (they do NOT create their own session), so no `skip` is needed to read + * the current development session. An explicit `sessionId` can still read + * any session, including sub-agent children. */ export function readOpencodeSession( projectRoot: string | null, @@ -79,11 +105,12 @@ export function readOpencodeSession( interface SessionRow { id: string; directory: string; + parent_id: string | null; time_updated: number; } const allSessions = db .query( - "SELECT id, directory, time_updated FROM session ORDER BY time_updated DESC" + "SELECT id, directory, parent_id, time_updated FROM session ORDER BY time_updated DESC" ) .all(); @@ -105,17 +132,44 @@ export function readOpencodeSession( }; } - // 3. Select session by ID or most recent (with optional skip) + // 3. Select session by ID or most recent (with optional skip). + // Recency selection only considers top-level sessions: sub-agent runs + // are child sessions (parent_id set) sharing the parent's directory, + // and would otherwise shadow the main session in the skip index. + // An explicit --session-id can read any session, including children. + const topLevel = matching.filter((s) => s.parent_id === null); const skip = options?.skip ?? 0; const target = options?.sessionId ? matching.find((s) => s.id === options.sessionId) - : matching[skip]; + : topLevel[skip]; if (!target) { - 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) }; + if (options?.sessionId) { + return { + ok: false, + error: `Session not found: ${options.sessionId}`, + available: matching.map((s) => s.id), + }; + } + return { + ok: false, + error: `Only ${String(topLevel.length)} top-level session(s) available but --skip ${String(skip)} requested`, + available: topLevel.map((s) => s.id), + }; + } + + // 3b. With --root, walk the parent_id chain up to the top-level + // ancestor (relevant when --session-id names a sub-agent child). + let selected = target; + if (options?.root === true) { + const byId = new Map(allSessions.map((s) => [s.id, s])); + const seen = new Set(); + while (selected.parent_id !== null && !seen.has(selected.id)) { + seen.add(selected.id); + const parent = byId.get(selected.parent_id); + if (!parent) break; + selected = parent; + } } // 4. Read messages for the session @@ -127,7 +181,7 @@ export function readOpencodeSession( .query( "SELECT id, json_extract(data, '$.role') as role FROM message WHERE session_id = ? ORDER BY time_created" ) - .all(target.id); + .all(selected.id); if (messages.length === 0) { return { @@ -176,7 +230,7 @@ export function readOpencodeSession( return { ok: true, data: { - sessionId: target.id, + sessionId: selected.id, totalEntries: messages.length, relevantEntries: relevant.length, transcript: trimmed, diff --git a/tests/commands/session-context/opencode.test.ts b/tests/commands/session-context/opencode.test.ts index 42af1992..85e677b5 100644 --- a/tests/commands/session-context/opencode.test.ts +++ b/tests/commands/session-context/opencode.test.ts @@ -184,6 +184,7 @@ describe("opencode action handler", () => { maxEntries: undefined, skip: 0, sessionId: undefined, + root: undefined, }); }); }); diff --git a/tests/helpers/session-context-opencode.test.ts b/tests/helpers/session-context-opencode.test.ts index 705fcb65..11a47f9b 100644 --- a/tests/helpers/session-context-opencode.test.ts +++ b/tests/helpers/session-context-opencode.test.ts @@ -88,12 +88,13 @@ describe("readOpencodeSession", () => { sessionId: string, sessionDir: string, messages?: Array<{ id: string; role: string; content: string }>, - timeUpdated?: number + timeUpdated?: number, + parentId?: string ): void { const now = timeUpdated ?? Date.now(); db.run( - "INSERT INTO session (id, directory, time_created, time_updated) VALUES (?, ?, ?, ?)", - [sessionId, sessionDir, now, now] + "INSERT INTO session (id, parent_id, directory, time_created, time_updated) VALUES (?, ?, ?, ?, ?)", + [sessionId, parentId ?? null, sessionDir, now, now] ); if (!messages) return; @@ -125,6 +126,53 @@ describe("readOpencodeSession", () => { } } + /** Insert a session under `projectRoot` with a single user message. */ + function makeSimpleSession( + db: Database, + id: string, + content: string, + timeUpdated: number, + parentId?: string + ): void { + makeSession( + db, + id, + projectRoot, + [{ id: `msg_${id}`, role: "user", content }], + timeUpdated, + parentId + ); + } + + /** Insert a raw message row with the given role. */ + function insertMessage( + db: Database, + id: string, + sessionId: string, + t: number, + role: string + ): void { + db.run( + "INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)", + [id, sessionId, t, t, JSON.stringify({ role })] + ); + } + + /** Insert a raw part row with arbitrary JSON data. */ + function insertPart( + db: Database, + id: string, + messageId: string, + sessionId: string, + t: number, + data: Record + ): void { + db.run( + "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)", + [id, messageId, sessionId, t, t, JSON.stringify(data)] + ); + } + test("returns data for most recent session matching project", async () => { const db = createDb(); const sessionId = `ses_${uniqueId}_1`; @@ -156,20 +204,8 @@ describe("readOpencodeSession", () => { const sessionId1 = `ses_${uniqueId}_first`; const sessionId2 = `ses_${uniqueId}_second`; - makeSession( - db, - sessionId1, - projectRoot, - [{ id: "msg_001", role: "user", content: "first session" }], - 1000 - ); - makeSession( - db, - sessionId2, - projectRoot, - [{ id: "msg_002", role: "user", content: "second session" }], - 2000 - ); + makeSimpleSession(db, sessionId1, "first session", 1000); + makeSimpleSession(db, sessionId2, "second session", 2000); db.close(); const result = await readOpencodeSession(projectRoot, { @@ -185,9 +221,7 @@ describe("readOpencodeSession", () => { test("returns error when sessionId not found (with available list)", async () => { const db = createDb(); const sessionId = `ses_${uniqueId}_real`; - makeSession(db, sessionId, projectRoot, [ - { id: "msg_001", role: "user", content: "real" }, - ]); + makeSimpleSession(db, sessionId, "real", 1000); db.close(); const result = await readOpencodeSession(projectRoot, { @@ -206,36 +240,17 @@ describe("readOpencodeSession", () => { // Insert messages with various roles — system and tool should be filtered out const now = Date.now(); - db.run( - "INSERT INTO session (id, directory, time_created, time_updated) VALUES (?, ?, ?, ?)", - [sessionId, projectRoot, now, now] - ); + makeSession(db, sessionId, projectRoot, undefined, now); const roles = ["system", "tool", "user", "assistant"]; const contents = ["system msg", "tool output", "visible", "also visible"]; for (let i = 0; i < roles.length; i++) { const msgId = `msg_${String(i).padStart(3, "0")}`; - db.run( - "INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)", - [ - msgId, - sessionId, - now + i + 1, - now + i + 1, - JSON.stringify({ role: roles[i] }), - ] - ); - db.run( - "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)", - [ - `prt_${msgId}`, - msgId, - sessionId, - now + i + 1, - now + i + 1, - JSON.stringify({ type: "text", text: contents[i] }), - ] - ); + insertMessage(db, msgId, sessionId, now + i + 1, roles[i] ?? ""); + insertPart(db, `prt_${msgId}`, msgId, sessionId, now + i + 1, { + type: "text", + text: contents[i], + }); } db.close(); @@ -338,46 +353,19 @@ describe("readOpencodeSession", () => { const db = createDb(); const sessionId = `ses_${uniqueId}_synthetic`; const now = Date.now(); - - db.run( - "INSERT INTO session (id, directory, time_created, time_updated) VALUES (?, ?, ?, ?)", - [sessionId, projectRoot, now, now] - ); - - // User message with a synthetic part and a real part - const msgId = "msg_syn_001"; - db.run( - "INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)", - [msgId, sessionId, now + 1, now + 1, JSON.stringify({ role: "user" })] - ); - // Synthetic part (editor context) - db.run( - "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)", - [ - "prt_syn_001", - msgId, - sessionId, - now + 1, - now + 1, - JSON.stringify({ - type: "text", - text: "Note: The user opened the file", - synthetic: true, - }), - ] - ); - // Real part (user input) - db.run( - "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)", - [ - "prt_real_001", - msgId, - sessionId, - now + 2, - now + 2, - JSON.stringify({ type: "text", text: "actual user question" }), - ] - ); + makeSession(db, sessionId, projectRoot, undefined, now); + + // User message with a synthetic part (editor context) and a real part + insertMessage(db, "msg_syn", sessionId, now + 1, "user"); + insertPart(db, "prt_syn", "msg_syn", sessionId, now + 1, { + type: "text", + text: "Note: The user opened the file", + synthetic: true, + }); + insertPart(db, "prt_real", "msg_syn", sessionId, now + 2, { + type: "text", + text: "actual user question", + }); db.close(); const result = await readOpencodeSession(projectRoot); @@ -393,94 +381,136 @@ describe("readOpencodeSession", () => { ); }); - test("skip=1 reads the parent session instead of the sub-agent session", async () => { + test("excludes sub-agent child sessions from recency selection", async () => { const db = createDb(); - makeSession( - db, - "ses_parent", - projectRoot, - [{ id: "msg_p1", role: "user", content: "parent question" }], - 1000 - ); - makeSession( - db, - "ses_subagent", - projectRoot, - [{ id: "msg_s1", role: "user", content: "sub-agent init" }], - 2000 - ); + makeSimpleSession(db, "ses_parent", "parent question", 1000); + // Child session is newer — it must NOT shadow the parent session + makeSimpleSession(db, "ses_sub", "sub-agent init", 2000, "ses_parent"); + db.close(); + + const result = await readOpencodeSession(projectRoot); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.sessionId).toBe("ses_parent"); + expect(result.data.transcript[0]?.contentPreview).toBe("parent question"); + } + }); + + test("sessionId can read a sub-agent child session explicitly", async () => { + const db = createDb(); + makeSimpleSession(db, "ses_parent", "parent question", 1000); + makeSimpleSession(db, "ses_sub", "sub-agent init", 2000, "ses_parent"); + db.close(); + + const result = await readOpencodeSession(projectRoot, { + sessionId: "ses_sub", + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.sessionId).toBe("ses_sub"); + expect(result.data.transcript[0]?.contentPreview).toBe("sub-agent init"); + } + }); + + test("skip indexes over top-level sessions only", async () => { + const db = createDb(); + makeSimpleSession(db, "ses_older", "older top-level", 1000); + makeSimpleSession(db, "ses_newer", "newer top-level", 2000); + // Child session between the two — must not consume a skip index + makeSimpleSession(db, "ses_child", "child", 1500, "ses_older"); db.close(); const noSkip = await readOpencodeSession(projectRoot); expect(noSkip.ok).toBe(true); - if (noSkip.ok) expect(noSkip.data.sessionId).toBe("ses_subagent"); + if (noSkip.ok) expect(noSkip.data.sessionId).toBe("ses_newer"); const skipped = await readOpencodeSession(projectRoot, { skip: 1 }); expect(skipped.ok).toBe(true); - if (skipped.ok) expect(skipped.data.sessionId).toBe("ses_parent"); + if (skipped.ok) expect(skipped.data.sessionId).toBe("ses_older"); }); - test("skip beyond available matching sessions returns error", async () => { + test("root resolves to the top-level ancestor session", async () => { const db = createDb(); - makeSession( - db, - "ses_only", - projectRoot, - [{ id: "msg_001", role: "user", content: "only" }], - 1000 - ); + makeSimpleSession(db, "ses_parent", "parent question", 1000); + makeSimpleSession(db, "ses_sub", "sub-agent init", 2000, "ses_parent"); + // Grandchild — root must walk the whole parent_id chain + makeSimpleSession(db, "ses_grand", "grandchild", 3000, "ses_sub"); db.close(); - const result = await readOpencodeSession(projectRoot, { skip: 3 }); + const viaChild = await readOpencodeSession(projectRoot, { + sessionId: "ses_grand", + root: true, + }); + expect(viaChild.ok).toBe(true); + if (viaChild.ok) expect(viaChild.data.sessionId).toBe("ses_parent"); + + const noId = await readOpencodeSession(projectRoot, { root: true }); + expect(noId.ok).toBe(true); + if (noId.ok) expect(noId.data.sessionId).toBe("ses_parent"); + }); + + test("selects the true parent when sibling sub-agents fan out and are more recent", async () => { + // Real-world fan-out reproduced from a live incident: one parent session + // spawns several sibling sub-agent sessions against the same directory + // (e.g. the reviewer skill's parallel domain reviews). Every sibling + // sorts ahead of the parent by recency; none of them may shadow it. + // The old recency-based `--skip 1` landed on whichever sibling sat + // second in recency order instead of the parent. + const db = createDb(); + makeSimpleSession(db, "ses_parent", "parent development session", 1000); + makeSimpleSession(db, "ses_sib_a", "domain review a", 2000, "ses_parent"); + makeSimpleSession(db, "ses_sib_b", "domain review b", 3000, "ses_parent"); + makeSimpleSession(db, "ses_sib_c", "domain review c", 4000, "ses_parent"); + makeSimpleSession(db, "ses_sib_d", "domain review d", 5000, "ses_parent"); + db.close(); + + // skip: 1 now errors honestly — only one top-level session exists, so + // there is no second one to skip to (previously it returned a sibling). + const skipped = await readOpencodeSession(projectRoot, { skip: 1 }); + expect(skipped.ok).toBe(false); + + // Default selection and explicit root both resolve to the parent. + const byDefault = await readOpencodeSession(projectRoot); + expect(byDefault.ok).toBe(true); + if (byDefault.ok) expect(byDefault.data.sessionId).toBe("ses_parent"); + + const rooted = await readOpencodeSession(projectRoot, { root: true }); + expect(rooted.ok).toBe(true); + if (rooted.ok) expect(rooted.data.sessionId).toBe("ses_parent"); + }); + + test("skip beyond available top-level sessions returns error", async () => { + const db = createDb(); + makeSimpleSession(db, "ses_only", "only", 1000); + // Child sessions must not count toward the top-level total + makeSimpleSession(db, "ses_child", "child", 2000, "ses_only"); + db.close(); + + const result = await readOpencodeSession(projectRoot, { skip: 1 }); expect(result.ok).toBe(false); - if (!result.ok) expect(result.error).toContain("--skip 3 requested"); + if (!result.ok) { + expect(result.error).toContain( + "Only 1 top-level session(s) available but --skip 1 requested" + ); + expect(result.available).toEqual(["ses_only"]); + } }); test("includes tool parts as [tool: name]", async () => { const db = createDb(); const sessionId = `ses_${uniqueId}_tools`; const now = Date.now(); + makeSession(db, sessionId, projectRoot, undefined, now); - db.run( - "INSERT INTO session (id, directory, time_created, time_updated) VALUES (?, ?, ?, ?)", - [sessionId, projectRoot, now, now] - ); - - const msgId = "msg_tool_001"; - db.run( - "INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)", - [ - msgId, - sessionId, - now + 1, - now + 1, - JSON.stringify({ role: "assistant" }), - ] - ); - // Text part - db.run( - "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)", - [ - "prt_t1", - msgId, - sessionId, - now + 1, - now + 1, - JSON.stringify({ type: "text", text: "Let me check that." }), - ] - ); - // Tool part - db.run( - "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)", - [ - "prt_t2", - msgId, - sessionId, - now + 2, - now + 2, - JSON.stringify({ type: "tool", tool: "glob" }), - ] - ); + insertMessage(db, "msg_tool", sessionId, now + 1, "assistant"); + insertPart(db, "prt_t1", "msg_tool", sessionId, now + 1, { + type: "text", + text: "Let me check that.", + }); + insertPart(db, "prt_t2", "msg_tool", sessionId, now + 2, { + type: "tool", + tool: "glob", + }); db.close(); const result = await readOpencodeSession(projectRoot);