diff --git a/CHANGELOG.md b/CHANGELOG.md index 20273d6..b2d3e9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this project are documented here. +## Unreleased + +### Fixed + +- Agent Status no longer lists Codex Guardian / Guardian2 reviewer + sub-sessions, which could fill the recent-task slots and fail with + "cannot resume a live Guardian reviewer". Internal threads are excluded + in SQL from structured metadata (`thread_source`, JSON `source.subagent`, + `agent_path`, and spawn-edge children) before the 12-row limit. A user + chat titled "Guardian" still appears. New Chat targeting uses the same + user-facing filter. + ## 0.2.4 — 2026-09-05 ### Fixed diff --git a/native/Fixtures.swift b/native/Fixtures.swift index bc6587a..67e1b9d 100644 --- a/native/Fixtures.swift +++ b/native/Fixtures.swift @@ -2228,9 +2228,9 @@ func runFixtureAction(_ action: String, arguments: [String]) { emit(ControlResult(ok: false, action: action, requested: nil, model: nil, effort: nil, message: "SQLite fixture query failed"), exitCode: 1) } let valid = ids.contains("aaaaaaaaaaaaaaaa") - && ids.contains("bbbbbbbbbbbbbbbb") + && !ids.contains("bbbbbbbbbbbbbbbb") && sqliteThreadCwdValue(databasePath, "aaaaaaaaaaaaaaaa") == "/tmp/active" - && sqliteThreadCwdValue(databasePath, "bbbbbbbbbbbbbbbb") == "/tmp/archived" + && sqliteThreadCwdValue(databasePath, "bbbbbbbbbbbbbbbb") == nil emit( ControlResult( ok: valid, @@ -2243,4 +2243,29 @@ func runFixtureAction(_ action: String, arguments: [String]) { exitCode: valid ? 0 : 1 ) } + + if action == "--sqlite-guardian-fixture" { + let databasePath = arguments.dropFirst().first ?? "" + guard isRegularCanonicalSQLiteDatabase(databasePath) else { + emit(ControlResult(ok: false, action: action, requested: nil, model: nil, effort: nil, message: "SQLite guardian fixture prevalidation failed"), exitCode: 1) + } + guard let ids = sqliteThreadIds(databasePath) else { + emit(ControlResult(ok: false, action: action, requested: nil, model: nil, effort: nil, message: "SQLite guardian fixture query failed"), exitCode: 1) + } + let valid = ids.contains("aaaaaaaaaaaaaaaa") + && !ids.contains("cccccccccccccccc") + && sqliteThreadCwdValue(databasePath, "aaaaaaaaaaaaaaaa") == "/tmp/parent" + && sqliteThreadCwdValue(databasePath, "cccccccccccccccc") == nil + emit( + ControlResult( + ok: valid, + action: action, + requested: nil, + model: nil, + effort: nil, + message: valid ? "SQLite guardian fixture accepted" : "SQLite guardian fixture rejected" + ), + exitCode: valid ? 0 : 1 + ) + } } diff --git a/native/Targeting.swift b/native/Targeting.swift index 24c1a9d..38fabcd 100644 --- a/native/Targeting.swift +++ b/native/Targeting.swift @@ -361,10 +361,82 @@ func sqliteLines(_ databasePath: String, _ sql: String) -> SQLiteQueryResult { return .success(text.split(separator: "\n").map(String.init)) } -func sqliteThreadIds(_ databasePath: String) -> Set? { - guard case .success(let lines) = sqliteLines(databasePath, "SELECT id FROM threads;") else { +func sqliteColumnNames(_ databasePath: String, _ table: String) -> Set? { + guard table.range(of: "^[A-Za-z_][A-Za-z0-9_]*$", options: .regularExpression) != nil else { return nil } + guard case .success(let lines) = sqliteLines( + databasePath, + "PRAGMA table_info(\(table));" + ) else { return nil } + return Set(lines.compactMap { line in + let parts = line.split(separator: "|", omittingEmptySubsequences: false) + guard parts.count > 1 else { return nil } + let name = String(parts[1]) + return name.isEmpty ? nil : name + }) +} + +func sqliteTableExists(_ databasePath: String, _ table: String) -> Bool { + guard table.range(of: "^[A-Za-z_][A-Za-z0-9_]*$", options: .regularExpression) != nil else { + return false + } + guard case .success(let lines) = sqliteLines( + databasePath, + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '\(table)' LIMIT 1;" + ) else { return false } + return lines.contains("1") +} + +/// User-facing chats only. Never matches titles; archived and Guardian +/// reviewer sub-sessions are excluded when those columns/tables exist. +func sqliteUserFacingPredicate(_ databasePath: String) -> String? { + guard let columns = sqliteColumnNames(databasePath, "threads") else { return nil } + var clauses: [String] = [] + if columns.contains("archived") { clauses.append("t.archived = 0") } + if columns.contains("thread_source") { + clauses.append("t.thread_source IS NOT 'subagent'") + } + if columns.contains("source") { + clauses.append( + """ + CASE + WHEN json_valid(t.source) + THEN json_extract(t.source, '$.subagent') + END IS NULL + """ + ) + } + if columns.contains("agent_path") { + clauses.append("(t.agent_path IS NULL OR t.agent_path = '' OR t.agent_path = '/root')") + } + if sqliteTableExists(databasePath, "thread_spawn_edges"), + let spawnColumns = sqliteColumnNames(databasePath, "thread_spawn_edges"), + spawnColumns.contains("child_thread_id") + { + clauses.append( + """ + NOT EXISTS ( + SELECT 1 FROM thread_spawn_edges child + WHERE child.child_thread_id = t.id + ) + """ + ) + } + guard !clauses.isEmpty else { return "" } + return clauses.map { "AND \($0)" }.joined(separator: "\n") +} + +func sqliteThreadIds(_ databasePath: String) -> Set? { + guard let predicate = sqliteUserFacingPredicate(databasePath) else { return nil } + guard case .success(let lines) = sqliteLines( + databasePath, + """ + SELECT DISTINCT t.id FROM threads t + WHERE 1 = 1 + \(predicate); + """ + ) else { return nil } return Set(lines) } @@ -372,9 +444,14 @@ func sqliteThreadCwd(_ databasePath: String, _ threadId: String) -> SQLiteRowRes guard threadId.range(of: "^[0-9A-Fa-f-]{16,64}$", options: .regularExpression) != nil else { return .failure } + guard let predicate = sqliteUserFacingPredicate(databasePath) else { return .failure } guard case .success(let lines) = sqliteLines( databasePath, - "SELECT cwd FROM threads WHERE id = '\(threadId)';" + """ + SELECT t.cwd FROM threads t + WHERE t.id = '\(threadId)' + \(predicate); + """ ) else { return .failure } return .success(lines.first) } diff --git a/src/lib/codex-store.ts b/src/lib/codex-store.ts index d4a373f..4b08560 100644 --- a/src/lib/codex-store.ts +++ b/src/lib/codex-store.ts @@ -70,6 +70,121 @@ interface ThreadRow { spawn_status: string | null; } +interface ThreadSchema { + columns: Set; + spawnColumns: Set; +} + +interface SqliteNamedRow { + name: string; +} + +/** Root user chats use `/root` or leave this unset; nested paths are sub-sessions. */ +const ROOT_AGENT_PATH = "/root"; + +function tableColumns(database: DatabaseSync, table: string): Set { + return new Set( + ( + database + .prepare(`PRAGMA table_info(${table})`) + .all() as unknown as SqliteNamedRow[] + ) + .map((column) => column.name) + .filter(Boolean), + ); +} + +function inspectThreadSchema(database: DatabaseSync): ThreadSchema { + const columns = tableColumns(database, "threads"); + const spawnTable = database + .prepare( + `SELECT name FROM sqlite_master + WHERE type = 'table' AND name = 'thread_spawn_edges'`, + ) + .get() as SqliteNamedRow | undefined; + return { + columns, + spawnColumns: + spawnTable?.name === "thread_spawn_edges" + ? tableColumns(database, "thread_spawn_edges") + : new Set(), + }; +} + +/** + * Exclude Codex-internal sub-sessions using structured metadata only. + * Titles are never consulted: a user chat named "Guardian" must remain visible. + * + * Signals, each optional and used only when present in this database: + * - threads.archived = 1 + * - threads.thread_source = 'subagent' + * - threads.source JSON with a `subagent` property + * - threads.agent_path set to a non-root path + * - a row in thread_spawn_edges whose child is this thread + */ +function userFacingThreadPredicate(schema: ThreadSchema): string { + const clauses: string[] = []; + if (schema.columns.has("archived")) { + clauses.push(`t.archived = 0`); + } + if (schema.columns.has("thread_source")) { + clauses.push(`t.thread_source IS NOT 'subagent'`); + } + if (schema.columns.has("source")) { + clauses.push( + `CASE + WHEN json_valid(t.source) + THEN json_extract(t.source, '$.subagent') + END IS NULL`, + ); + } + if (schema.columns.has("agent_path")) { + clauses.push( + `(t.agent_path IS NULL OR t.agent_path = '' OR t.agent_path = '${ROOT_AGENT_PATH}')`, + ); + } + if (schema.spawnColumns.has("child_thread_id")) { + clauses.push( + `NOT EXISTS ( + SELECT 1 FROM thread_spawn_edges child + WHERE child.child_thread_id = t.id + )`, + ); + } + return clauses.length === 0 ? "" : `AND ${clauses.join("\n AND ")}`; +} + +function threadSelectSql( + schema: ThreadSchema, + kind: "recent" | "focused", +): string { + const spawnStatus = + schema.spawnColumns.has("status") && + schema.spawnColumns.has("child_thread_id") + ? `(SELECT MAX(child.status) FROM thread_spawn_edges child + WHERE child.child_thread_id = t.id) AS spawn_status` + : "NULL AS spawn_status"; + const visibility = userFacingThreadPredicate(schema); + const recencyFilter = + kind === "recent" + ? `AND t.preview <> '' + AND t.recency_at_ms >= ?` + : "AND t.id = ?"; + const orderLimit = + kind === "recent" + ? `ORDER BY t.recency_at_ms DESC + LIMIT 12` + : "LIMIT 1"; + return `SELECT DISTINCT + t.id, t.rollout_path, t.cwd, t.title, t.preview, t.recency_at_ms, + t.reasoning_effort, t.model, ${spawnStatus} + FROM threads t + WHERE 1 = 1 + ${recencyFilter} + ${visibility} + ${orderLimit}`; +} + function expandHome(path: string): string { return path.startsWith("~/") ? join(homedir(), path.slice(2)) : path; } @@ -251,6 +366,7 @@ export class CodexStore { #recentRolloutPaths = new Set(); #focusedRolloutPath: string | undefined; #modelCache: { identity: FileIdentity; parsed: unknown } | undefined; + #threadSchema: ThreadSchema | undefined; constructor( options: { @@ -285,6 +401,7 @@ export class CodexStore { close(): void { this.#database?.close(); this.#database = undefined; + this.#threadSchema = undefined; } acknowledge(threadId: string, at = Date.now()): void { @@ -377,18 +494,7 @@ export class CodexStore { const database = this.#open(); const cutoff = now - maxAgeMs; const rows = database - .prepare( - `SELECT - t.id, t.rollout_path, t.cwd, t.title, t.preview, t.recency_at_ms, - t.reasoning_effort, t.model, e.status AS spawn_status - FROM threads t - LEFT JOIN thread_spawn_edges e ON e.child_thread_id = t.id - WHERE t.archived = 0 - AND t.preview <> '' - AND t.recency_at_ms >= ? - ORDER BY t.recency_at_ms DESC - LIMIT 12`, - ) + .prepare(threadSelectSql(this.#schema(), "recent")) .all(cutoff) as unknown as ThreadRow[]; const persistedSnapshots = rows.map((row) => @@ -424,15 +530,7 @@ export class CodexStore { return this.#focusedProjectionCache.snapshot; } const row = this.#open() - .prepare( - `SELECT - t.id, t.rollout_path, t.cwd, t.title, t.preview, t.recency_at_ms, - t.reasoning_effort, t.model, e.status AS spawn_status - FROM threads t - LEFT JOIN thread_spawn_edges e ON e.child_thread_id = t.id - WHERE t.archived = 0 AND t.id = ? - LIMIT 1`, - ) + .prepare(threadSelectSql(this.#schema(), "focused")) .get(activeId) as unknown as ThreadRow | undefined; const persisted = row ? this.#projectThreadRow(row, now) : undefined; const snapshot = persisted @@ -824,6 +922,11 @@ export class CodexStore { ); } + #schema(): ThreadSchema { + this.#open(); + return this.#threadSchema!; + } + #open(): DatabaseSync { if (this.#database) return this.#database; const database = new DatabaseSync(this.databasePath, { @@ -832,6 +935,7 @@ export class CodexStore { }); database.exec("PRAGMA query_only = ON"); this.#database = database; + this.#threadSchema = inspectThreadSchema(database); return database; } } diff --git a/test/native-workflow.fixture.test.ts b/test/native-workflow.fixture.test.ts index 6a8a716..1da96b9 100644 --- a/test/native-workflow.fixture.test.ts +++ b/test/native-workflow.fixture.test.ts @@ -164,7 +164,7 @@ describe("native workflow postcondition fixture", () => { expect(result.status).toBe(0); }); - it("queries a real readonly SQLite snapshot including archived IDs", () => { + it("queries a real readonly SQLite snapshot excluding archived IDs", () => { const directory = mkdtempSync(resolve(tmpdir(), "codex-sqlite-")); const database = resolve(directory, "state.sqlite"); try { @@ -204,6 +204,34 @@ describe("native workflow postcondition fixture", () => { } }); + it("excludes a Guardian child from targeting while keeping its parent", () => { + const directory = mkdtempSync(resolve(tmpdir(), "codex-sqlite-guardian-")); + const database = resolve(directory, "state.sqlite"); + try { + const setup = spawnSync("/usr/bin/sqlite3", [ + database, + [ + "CREATE TABLE threads (id TEXT, cwd TEXT, archived INTEGER, thread_source TEXT, source TEXT, agent_path TEXT, title TEXT);", + "CREATE TABLE thread_spawn_edges (parent_thread_id TEXT, child_thread_id TEXT, status TEXT);", + "INSERT INTO threads VALUES ('aaaaaaaaaaaaaaaa', '/tmp/parent', 0, 'user', 'cli', '/root', 'Guardian');", + "INSERT INTO threads VALUES ('cccccccccccccccc', '/tmp/child', 0, 'subagent', '{\"subagent\":{\"other\":\"guardian\"}}', '/root/guardian', 'User work');", + "INSERT INTO thread_spawn_edges VALUES ('aaaaaaaaaaaaaaaa', 'cccccccccccccccc', 'running');", + "INSERT INTO thread_spawn_edges VALUES ('aaaaaaaaaaaaaaaa', 'cccccccccccccccc', 'running');", + ].join(" "), + ]); + expect(setup.status).toBe(0); + const observed = spawnSync( + native, + ["--sqlite-guardian-fixture", realpathSync(database)], + { encoding: "utf8" }, + ); + expect(observed.stdout).toContain("SQLite guardian fixture accepted"); + expect(observed.status).toBe(0); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + it("reads actual temporary log bytes through the backward witness scanner", () => { const directory = mkdtempSync(resolve(tmpdir(), "codex-log-")); const log = resolve(directory, "desktop.log"); diff --git a/test/subsession-filter.test.ts b/test/subsession-filter.test.ts new file mode 100644 index 0000000..2e2ceb3 --- /dev/null +++ b/test/subsession-filter.test.ts @@ -0,0 +1,446 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { describe, expect, it } from "vitest"; +import { CodexStore } from "../src/lib/codex-store.js"; + +type ExtraColumn = "thread_source" | "source" | "agent_path"; + +type FixtureThread = { + id: string; + title: string; + preview?: string; + recencyAtMs: number; + archived?: number; + threadSource?: string | null; + source?: string | null; + agentPath?: string | null; + spawnParentId?: string; +}; + +function createFixture(options: { + extraColumns?: ExtraColumn[]; + spawnEdges?: boolean; + threads: FixtureThread[]; +}): { databasePath: string; close: () => void } { + const root = mkdtempSync(join(tmpdir(), "streamdeck-subsession-")); + const databasePath = join(root, "state.sqlite"); + const rollout = join(root, "rollout.jsonl"); + writeFileSync(rollout, ""); + const extra = new Set(options.extraColumns ?? []); + const extraDefs = [ + extra.has("thread_source") ? "thread_source TEXT" : undefined, + extra.has("source") ? "source TEXT" : undefined, + extra.has("agent_path") ? "agent_path TEXT" : undefined, + ] + .filter((column): column is string => column !== undefined) + .map((column) => `, ${column}`) + .join(""); + const database = new DatabaseSync(databasePath); + database.exec(` + CREATE TABLE threads ( + id TEXT PRIMARY KEY, rollout_path TEXT, cwd TEXT, title TEXT, + preview TEXT, recency_at_ms INTEGER, reasoning_effort TEXT, + model TEXT, archived INTEGER${extraDefs} + ); + `); + if (options.spawnEdges !== false) { + database.exec( + `CREATE TABLE thread_spawn_edges ( + parent_thread_id TEXT, + child_thread_id TEXT, + status TEXT + )`, + ); + } + const insertColumns = [ + "id", + "rollout_path", + "cwd", + "title", + "preview", + "recency_at_ms", + "reasoning_effort", + "model", + "archived", + ...(extra.has("thread_source") ? ["thread_source"] : []), + ...(extra.has("source") ? ["source"] : []), + ...(extra.has("agent_path") ? ["agent_path"] : []), + ]; + const insert = database.prepare( + `INSERT INTO threads (${insertColumns.join(", ")}) + VALUES (${insertColumns.map(() => "?").join(", ")})`, + ); + const spawn = + options.spawnEdges === false + ? undefined + : database.prepare( + `INSERT INTO thread_spawn_edges + (parent_thread_id, child_thread_id, status) + VALUES (?, ?, 'running')`, + ); + for (const thread of options.threads) { + const values: Array = [ + thread.id, + rollout, + "/tmp", + thread.title, + thread.preview ?? "preview", + thread.recencyAtMs, + "medium", + "gpt-5.6-sol", + thread.archived ?? 0, + ]; + if (extra.has("thread_source")) values.push(thread.threadSource ?? "user"); + if (extra.has("source")) values.push(thread.source ?? "cli"); + if (extra.has("agent_path")) values.push(thread.agentPath ?? "/root"); + insert.run(...values); + if (spawn && thread.spawnParentId) { + spawn.run(thread.spawnParentId, thread.id); + } + } + database.close(); + return { + databasePath, + close() { + // CodexStore.close() is the caller's responsibility. + }, + }; +} + +function storeFor(databasePath: string, activeId?: string): CodexStore { + return new CodexStore({ + databasePath, + ...(activeId ? { activeThreadId: () => activeId } : {}), + }); +} + +const FULL_SCHEMA: ExtraColumn[] = ["thread_source", "source", "agent_path"]; + +describe("user-facing thread filter", () => { + it("excludes a Guardian sub-session marked by thread_source", () => { + const now = Date.now(); + const { databasePath } = createFixture({ + extraColumns: FULL_SCHEMA, + threads: [ + { + id: "user", + title: "Ship the release", + recencyAtMs: now, + threadSource: "user", + }, + { + id: "guardian-source", + title: "Ship the release", + recencyAtMs: now + 1, + threadSource: "subagent", + }, + ], + }); + const store = storeFor(databasePath); + try { + expect(store.recentThreads(12).map((thread) => thread.id)).toEqual([ + "user", + ]); + } finally { + store.close(); + } + }); + + it("excludes Guardian and Guardian2 reviewers via source.subagent JSON", () => { + const now = Date.now(); + const { databasePath } = createFixture({ + extraColumns: FULL_SCHEMA, + threads: [ + { + id: "user", + title: "Guardian", + recencyAtMs: now, + source: "cli", + }, + { + id: "guardian", + title: "User chat about reviews", + recencyAtMs: now + 1, + source: JSON.stringify({ subagent: { other: "guardian" } }), + }, + { + id: "guardian2", + title: "User chat about reviews", + recencyAtMs: now + 2, + source: JSON.stringify({ subagent: { other: "guardian2" } }), + }, + ], + }); + const store = storeFor(databasePath); + try { + expect(store.recentThreads(12).map((thread) => thread.id)).toEqual([ + "user", + ]); + } finally { + store.close(); + } + }); + + it("excludes a nested agent_path sub-session without using its title", () => { + const now = Date.now(); + const { databasePath } = createFixture({ + extraColumns: FULL_SCHEMA, + threads: [ + { + id: "user", + title: "Investigate agent_path", + recencyAtMs: now, + agentPath: "/root", + }, + { + id: "nested", + title: "Investigate agent_path", + recencyAtMs: now + 1, + agentPath: "/root/guardian", + }, + ], + }); + const store = storeFor(databasePath); + try { + expect(store.recentThreads(12).map((thread) => thread.id)).toEqual([ + "user", + ]); + } finally { + store.close(); + } + }); + + it("excludes a spawn-edge child even when other metadata looks like a user chat", () => { + const now = Date.now(); + const { databasePath } = createFixture({ + extraColumns: FULL_SCHEMA, + threads: [ + { + id: "parent", + title: "Parent chat", + recencyAtMs: now, + }, + { + id: "child", + title: "Parent chat", + recencyAtMs: now + 1, + threadSource: "user", + source: "cli", + agentPath: "/root", + spawnParentId: "parent", + }, + ], + }); + const store = storeFor(databasePath); + try { + expect(store.recentThreads(12).map((thread) => thread.id)).toEqual([ + "parent", + ]); + } finally { + store.close(); + } + }); + + it("keeps a genuine user chat titled Guardian", () => { + const now = Date.now(); + const { databasePath } = createFixture({ + extraColumns: FULL_SCHEMA, + threads: [ + { + id: "named-guardian", + title: "Guardian", + recencyAtMs: now, + threadSource: "user", + source: "cli", + agentPath: "/root", + }, + { + id: "internal", + title: "Guardian", + recencyAtMs: now + 1, + threadSource: "subagent", + source: JSON.stringify({ subagent: { other: "guardian" } }), + agentPath: "/root/guardian", + }, + ], + }); + const store = storeFor(databasePath); + try { + expect(store.recentThreads(12).map((thread) => thread.id)).toEqual([ + "named-guardian", + ]); + } finally { + store.close(); + } + }); + + it("fills the 12-row limit with user threads even when newer sub-sessions exist", () => { + const now = Date.now(); + const threads: FixtureThread[] = []; + for (let index = 0; index < 20; index += 1) { + threads.push({ + id: `sub-${index}`, + title: "Guardian", + recencyAtMs: now + 100 + index, + threadSource: "subagent", + source: JSON.stringify({ subagent: { other: "guardian" } }), + agentPath: "/root/guardian", + }); + } + for (let index = 0; index < 12; index += 1) { + threads.push({ + id: `user-${index}`, + title: `User ${index}`, + recencyAtMs: now + index, + threadSource: "user", + source: "cli", + agentPath: "/root", + }); + } + const { databasePath } = createFixture({ + extraColumns: FULL_SCHEMA, + threads, + }); + const store = storeFor(databasePath); + try { + expect(store.recentThreads(12).map((thread) => thread.id)).toEqual( + Array.from({ length: 12 }, (_, index) => `user-${11 - index}`), + ); + } finally { + store.close(); + } + }); + + it("still lists threads on an older schema that lacks the extra columns", () => { + const now = Date.now(); + const { databasePath } = createFixture({ + extraColumns: [], + threads: [ + { id: "older", title: "Older schema chat", recencyAtMs: now }, + { id: "guardian-title", title: "Guardian", recencyAtMs: now + 1 }, + ], + }); + const store = storeFor(databasePath); + try { + expect(store.recentThreads(12).map((thread) => thread.id)).toEqual([ + "guardian-title", + "older", + ]); + } finally { + store.close(); + } + }); + + it("still lists threads when thread_spawn_edges is absent", () => { + const now = Date.now(); + const { databasePath } = createFixture({ + extraColumns: FULL_SCHEMA, + spawnEdges: false, + threads: [ + { + id: "user", + title: "No spawn table", + recencyAtMs: now, + threadSource: "user", + }, + { + id: "sub", + title: "No spawn table", + recencyAtMs: now + 1, + threadSource: "subagent", + }, + ], + }); + const store = storeFor(databasePath); + try { + expect(store.recentThreads(12).map((thread) => thread.id)).toEqual([ + "user", + ]); + } finally { + store.close(); + } + }); + + it("keeps a parent chat once when a Guardian child has duplicate spawn edges", () => { + const now = Date.now(); + const { databasePath } = createFixture({ + extraColumns: FULL_SCHEMA, + threads: [ + { + id: "parent", + title: "Parent chat", + recencyAtMs: now, + threadSource: "user", + source: "cli", + agentPath: "/root", + }, + { + id: "guardian-child", + title: "User work", + recencyAtMs: now + 2, + threadSource: "subagent", + source: JSON.stringify({ subagent: { other: "guardian" } }), + agentPath: "/root/guardian", + spawnParentId: "parent", + }, + ], + }); + const extra = new DatabaseSync(databasePath); + extra + .prepare( + `INSERT INTO thread_spawn_edges + (parent_thread_id, child_thread_id, status) + VALUES ('parent', 'guardian-child', 'running')`, + ) + .run(); + extra.close(); + const store = storeFor(databasePath, "guardian-child"); + try { + expect(store.recentThreads(12).map((thread) => thread.id)).toEqual([ + "parent", + ]); + expect(store.focusedThread()).toBeUndefined(); + } finally { + store.close(); + } + }); + + it("skips a focused Guardian sub-session and still returns a user chat titled Guardian", () => { + const now = Date.now(); + const { databasePath } = createFixture({ + extraColumns: FULL_SCHEMA, + threads: [ + { + id: "named-guardian", + title: "Guardian", + recencyAtMs: now, + threadSource: "user", + source: "cli", + agentPath: "/root", + }, + { + id: "focused-sub", + title: "Focused user work", + recencyAtMs: now + 1, + threadSource: "subagent", + source: JSON.stringify({ subagent: { other: "guardian" } }), + agentPath: "/root/guardian", + }, + ], + }); + const skipped = storeFor(databasePath, "focused-sub"); + const focusedUser = storeFor(databasePath, "named-guardian"); + try { + expect(skipped.focusedThread()).toBeUndefined(); + expect(focusedUser.focusedThread()).toMatchObject({ + id: "named-guardian", + title: "Guardian", + }); + } finally { + skipped.close(); + focusedUser.close(); + } + }); +});