diff --git a/README.ja.md b/README.ja.md index 386cd33..e90c54a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -10,7 +10,7 @@ Shepherd は、Herdr で動く coding agent の状態を記録する daemon ベ Herdr の `herdr agent read` は terminal stream や scrollback を読みます。Shepherd は agent の session data から作業状況、message の抜粋、compact tool result、未読の outcome を取得します。Shepherd は agent を操作しません。agent の start、prompt、wait、pane 操作、terminal control には公式 Herdr CLI または skill を使います。 -現在は Claude Code、Codex、Gemini CLI、OpenCode、Pi のセッション履歴の取得に対応しています。 +現在は Antigravity、Claude Code、Codex、Gemini CLI、OpenCode、Pi のセッション履歴の取得に対応しています。 ## 要件 diff --git a/README.md b/README.md index 527cf45..b4a8aaf 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Shepherd is a daemon-backed observability layer for coding agents running in Her Herdr's `herdr agent read` reads terminal streams or scrollback. Shepherd instead reads agent session data so callers can retrieve work status, structured message excerpts, compact tool results, and unread outcomes without parsing terminal output. Shepherd is read-only; use the official Herdr CLI or skill for agent start, prompts, waits, pane operations, and terminal control. -Shepherd currently supports session history from Claude Code, Codex, Gemini CLI, OpenCode, and Pi. +Shepherd currently supports session history from Antigravity, Claude Code, Codex, Gemini CLI, OpenCode, and Pi. ## Requirements diff --git a/src/agent-history/agy-reader.ts b/src/agent-history/agy-reader.ts new file mode 100644 index 0000000..ca267c7 --- /dev/null +++ b/src/agent-history/agy-reader.ts @@ -0,0 +1,217 @@ +import { join } from "node:path"; +import type { + AgentHistoryMessage, + AgentHistoryRef, + CompactAgentHistory, +} from "@/observability/contracts.js"; +import { + type AgentHistoryReader, + compactFromMessages, + limitMessages, + readJsonl, +} from "./readers.js"; +import { messageRef, textFromContent, timestampFrom } from "./text.js"; +import { compactToolResult } from "./tool-compaction.js"; + +const ignoredEntryTypes = new Set([ + "attachment", + "checkpoint", + "conversation_history", + "ephemeral_message", + "file-history-snapshot", + "mode", + "permission-mode", + "system_message", +]); + +const knownToolExecutionTypes = new Set([ + "ask_question", + "call_mcp_tool", + "command_output", + "edit_file", + "error_message", + "execute_command", + "find_by_name", + "generate_image", + "grep_search", + "list_dir", + "list_directory", + "list_resources", + "read_resource", + "read_url_content", + "replace_file_content", + "run_command", + "search_web", + "tool_call_result", + "tool_result", + "view_file", + "write_file", + "write_to_file", +]); + +function isKnownToolExecutionType(type: string): boolean { + return knownToolExecutionTypes.has(type.toLowerCase()); +} + +export class AgyHistoryReader implements AgentHistoryReader { + private readonly homeDir?: string; + + constructor(options: { homeDir?: string } = {}) { + if (options.homeDir !== undefined) { + this.homeDir = options.homeDir; + } + } + + canRead(ref: AgentHistoryRef): boolean { + return ref.source === "agy-jsonl" && Boolean(ref.path ?? ref.value); + } + + async read( + ref: AgentHistoryRef, + options: { homeDir?: string; limit?: number } = {}, + ): Promise { + const homeDir = options.homeDir ?? this.homeDir; + const path = resolveAgyTranscriptPath(ref, homeDir !== undefined ? { homeDir } : {}); + if (!path) return []; + + let entries: ReturnType extends Promise ? T : never; + try { + entries = await readJsonl(path); + } catch { + return []; + } + + const messages: AgentHistoryMessage[] = []; + const pendingToolCalls: string[] = []; + + for (const entry of entries) { + const type = stringValue(entry.value.type); + const source = stringValue(entry.value.source); + const id = + stringValue(entry.value.id) ?? + (typeof entry.value.step_index === "number" || typeof entry.value.step_index === "string" + ? String(entry.value.step_index) + : undefined); + const timestamp = + timestampFrom(entry.value.created_at) ?? timestampFrom(entry.value.timestamp); + const refValue = messageRef(path, id, entry.line); + + if (!type || isIgnoredEntry(type, source)) { + continue; + } + + if (type === "USER_INPUT") { + pendingToolCalls.length = 0; + const text = textFromContent(entry.value.content); + if (text) { + messages.push({ ref: refValue, role: "user", text, timestamp }); + } + continue; + } + + if (type === "PLANNER_RESPONSE") { + const text = textFromContent(entry.value.content); + if (text) { + messages.push({ ref: refValue, role: "assistant", text, timestamp }); + } + + const toolCalls = Array.isArray(entry.value.tool_calls) ? entry.value.tool_calls : []; + for (const call of toolCalls) { + const callRecord = record(call); + const name = stringValue(callRecord.name) ?? "unknown"; + pendingToolCalls.push(name); + } + continue; + } + + if (!isKnownToolExecutionType(type)) { + continue; + } + + const toolName = pendingToolCalls.shift() ?? inferToolName(type, entry.value); + + const text = textFromToolResult(entry.value); + if (text !== null) { + const isError = isToolResultError(entry.value); + const compact = compactToolResult({ isError, ref: refValue, text, toolName }); + messages.push({ + compact, + ref: refValue, + role: "tool_result", + text: compact.text, + timestamp, + toolName, + }); + } + } + + return limitMessages(messages, options.limit); + } + + async readCompact(ref: AgentHistoryRef): Promise { + return compactFromMessages(ref, await this.read(ref)); + } +} + +export function resolveAgyTranscriptPath( + ref: AgentHistoryRef, + options: { homeDir?: string } = {}, +): string { + if (ref.path && ref.path.length > 0) { + return ref.path; + } + const value = ref.value; + if (!value) return ""; + if (value.includes("/") || value.endsWith(".jsonl")) { + return value; + } + const homeDir = options.homeDir ?? process.env.HOME ?? ""; + return join( + homeDir, + ".gemini", + "antigravity-cli", + "brain", + value, + ".system_generated", + "logs", + "transcript.jsonl", + ); +} + +function isIgnoredEntry(type: string, source: string | null): boolean { + if (source === "SYSTEM" && type !== "ERROR_MESSAGE") return true; + return ignoredEntryTypes.has(type.toLowerCase()); +} + +function inferToolName(type: string, entry: Record): string { + const explicit = + stringValue(entry.tool_name) ?? stringValue(entry.name) ?? stringValue(entry.tool); + if (explicit) return explicit; + if (type === "ERROR_MESSAGE" || type === "GENERIC") return "unknown"; + return type.toLowerCase(); +} + +function textFromToolResult(value: Record): string | null { + const text = textFromContent(value.content); + if (text) return text; + if (typeof value.error === "string" && value.error.length > 0) return value.error; + if (value.error !== undefined && value.error !== null) return JSON.stringify(value.error); + if (typeof value.exit_code === "number") return `exit code ${value.exit_code}`; + return null; +} + +function isToolResultError(value: Record): boolean { + if (value.type === "ERROR_MESSAGE") return true; + if (stringValue(value.status) === "ERROR") return true; + if (typeof value.exit_code === "number" && value.exit_code !== 0) return true; + if (value.error !== undefined && value.error !== null) return true; + return false; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null ? (value as Record) : {}; +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} diff --git a/src/agent-history/discovery.ts b/src/agent-history/discovery.ts index 7d6e919..749b14c 100644 --- a/src/agent-history/discovery.ts +++ b/src/agent-history/discovery.ts @@ -1,7 +1,8 @@ import { existsSync } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { DatabaseSync } from "node:sqlite"; +import { fileURLToPath } from "node:url"; import type { AgentHistoryRef, AgentSessionRef } from "@/observability/contracts.js"; export type AgentHistoryLookupInput = { @@ -41,6 +42,26 @@ export async function discoverAgentHistory( const ref = discoverOpenCodeSession({ cwd, homeDir, sessionId: input.agentSession.value }); if (ref) return { ...ref, kind: "agent_session" }; } + if (source === "agy-jsonl") { + const path = join( + homeDir, + ".gemini", + "antigravity-cli", + "brain", + input.agentSession.value, + ".system_generated", + "logs", + "transcript.jsonl", + ); + if (existsSync(path)) { + return { + kind: "agent_session", + path, + source: "agy-jsonl", + value: input.agentSession.value, + }; + } + } } const agent = input.agent?.toLowerCase() ?? input.agentSession?.agent.toLowerCase() ?? ""; @@ -57,6 +78,16 @@ export async function discoverAgentHistory( if (agent === "gemini") { candidates.push(...(await scanGeminiRoot(join(homeDir, ".gemini", "tmp")))); } + if ( + agent === "agy" || + agent === "antigravity" || + agent.includes("antigravity") || + agent.includes("agy") + ) { + candidates.push( + ...(await scanAgyRoot(join(homeDir, ".gemini", "antigravity-cli", "brain"), homeDir, cwd)), + ); + } if (agent === "opencode") { const ref = discoverOpenCodeSession({ cwd, homeDir, sessionId: null }); if (ref) return ref; @@ -82,6 +113,13 @@ export function historySourceFromSessionRef(ref: AgentSessionRef): AgentHistoryR if (agent === "codex" || source.includes("codex")) return "codex-jsonl"; if (agent === "opencode" || source.includes("opencode")) return "opencode-sqlite"; if (agent === "gemini" || source.includes("gemini")) return "gemini-json"; + if ( + agent === "agy" || + agent === "antigravity" || + source.includes("antigravity") || + source.includes("agy") + ) + return "agy-jsonl"; return "unknown"; } @@ -150,6 +188,83 @@ async function scanGeminiRoot(root: string): Promise { return candidates; } +function normalizeWorkspacePath(uriOrPath: string): string { + try { + if (uriOrPath.startsWith("file://")) { + return resolve(fileURLToPath(uriOrPath)); + } + } catch { + // fallback if fileURLToPath fails + } + const raw = uriOrPath.startsWith("file://") ? uriOrPath.slice(7) : uriOrPath; + return resolve(raw); +} + +export function matchesWorkspaceUri(workspaceUri: string, cwd: string): boolean { + const ws = normalizeWorkspacePath(workspaceUri); + const target = normalizeWorkspacePath(cwd); + return ws === target; +} + +function readAgyConversationSummaries(homeDir: string): Map | null { + const dbPath = join(homeDir, ".gemini", "antigravity-cli", "conversation_summaries.db"); + if (!existsSync(dbPath)) return null; + let sqlite: DatabaseSync | null = null; + try { + sqlite = new DatabaseSync(dbPath, { readOnly: true }); + const rows = sqlite + .prepare("select conversation_id, workspace_uris from conversation_summaries") + .all() as Array<{ conversation_id: unknown; workspace_uris: unknown }>; + const summaries = new Map(); + for (const row of rows) { + if (typeof row.conversation_id === "string" && typeof row.workspace_uris === "string") { + try { + const parsed = JSON.parse(row.workspace_uris); + if (Array.isArray(parsed)) { + summaries.set( + row.conversation_id, + parsed.filter((u): u is string => typeof u === "string"), + ); + } + } catch { + // ignore malformed JSON in individual row + } + } + } + return summaries; + } catch { + return null; + } finally { + sqlite?.close(); + } +} + +async function scanAgyRoot( + root: string, + homeDir: string, + cwd: string | null, +): Promise { + if (!existsSync(root) || !cwd) return []; + const summaries = readAgyConversationSummaries(homeDir); + if (summaries === null) return []; + + const candidates: Candidate[] = []; + for (const [conversationId, workspaceUris] of summaries) { + const matches = workspaceUris.some((uri) => matchesWorkspaceUri(uri, cwd)); + if (!matches) continue; + const path = join(root, conversationId, ".system_generated", "logs", "transcript.jsonl"); + const stats = await stat(path).catch(() => null); + if (!stats?.isFile()) continue; + candidates.push({ + cwd, + mtimeMs: stats.mtimeMs, + path, + source: "agy-jsonl", + }); + } + return candidates; +} + async function listGeminiProjectDirs(root: string): Promise { const entries = await readdir(root, { withFileTypes: true }).catch(() => []); const dirs: string[] = []; diff --git a/src/agent-history/service.ts b/src/agent-history/service.ts index 39a1ea3..f404fcb 100644 --- a/src/agent-history/service.ts +++ b/src/agent-history/service.ts @@ -6,6 +6,7 @@ import type { AgentHistorySourceFingerprint, CompactAgentHistory, } from "@/observability/contracts.js"; +import { AgyHistoryReader } from "./agy-reader.js"; import { ClaudeHistoryReader } from "./claude-reader.js"; import { CodexHistoryReader } from "./codex-reader.js"; import { type AgentHistoryLookupInput, discoverAgentHistory } from "./discovery.js"; @@ -39,6 +40,7 @@ export function createAgentHistoryService( new CodexHistoryReader(), new OpenCodeHistoryReader(), new GeminiHistoryReader(), + new AgyHistoryReader(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}), ]; const discover: Discovery = options.discover ?? diff --git a/src/observability/contracts.ts b/src/observability/contracts.ts index 196ffb4..1f73459 100644 --- a/src/observability/contracts.ts +++ b/src/observability/contracts.ts @@ -48,6 +48,7 @@ export type AgentHistoryRef = { kind: "agent_session" | "discovered_file"; path?: string; source: + | "agy-jsonl" | "claude-jsonl" | "codex-jsonl" | "gemini-json" diff --git a/test/unit/agent-history-discovery.test.ts b/test/unit/agent-history-discovery.test.ts index 580ff25..cf6bc1c 100644 --- a/test/unit/agent-history-discovery.test.ts +++ b/test/unit/agent-history-discovery.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; @@ -43,6 +43,22 @@ describe("agent history discovery", () => { value: "/tmp/g.json", }), ).toBe("gemini-json"); + expect( + historySourceFromSessionRef({ + agent: "agy", + kind: "path", + source: "herdr:agy", + value: "/tmp/transcript.jsonl", + }), + ).toBe("agy-jsonl"); + expect( + historySourceFromSessionRef({ + agent: "antigravity_cli", + kind: "id", + source: "herdr:antigravity", + value: "sess-1", + }), + ).toBe("agy-jsonl"); }); test("discovers Codex JSONL by session_meta cwd", async () => { @@ -141,4 +157,350 @@ describe("agent history discovery", () => { value: sessionPath, }); }); + + test("returns null when conversation_summaries.db is missing (fails closed)", async () => { + const homeDir = await tempHome("shepherd-agy-nodb-home-"); + const brainDir = join(homeDir, ".gemini", "antigravity-cli", "brain"); + const sessDir = join(brainDir, "sess-123", ".system_generated", "logs"); + await mkdir(sessDir, { recursive: true }); + const transcriptPath = join(sessDir, "transcript.jsonl"); + await writeFile( + transcriptPath, + `${JSON.stringify({ step_index: 0, source: "USER_EXPLICIT", type: "USER_INPUT", content: "hello" })}\n`, + ); + + await expect( + discoverAgentHistory({ + agent: "agy", + agentSession: null, + cwd: "/repo", + foregroundCwd: null, + homeDir, + }), + ).resolves.toBeNull(); + }); + + test("returns null when conversation_summaries.db is corrupted or unreadable (fails closed)", async () => { + const homeDir = await tempHome("shepherd-agy-baddb-home-"); + const cliDir = join(homeDir, ".gemini", "antigravity-cli"); + const brainDir = join(cliDir, "brain"); + const sessDir = join(brainDir, "sess-123", ".system_generated", "logs"); + await mkdir(sessDir, { recursive: true }); + const transcriptPath = join(sessDir, "transcript.jsonl"); + await writeFile( + transcriptPath, + `${JSON.stringify({ step_index: 0, source: "USER_EXPLICIT", type: "USER_INPUT", content: "hello" })}\n`, + ); + + const dbPath = join(cliDir, "conversation_summaries.db"); + await writeFile(dbPath, "corrupted database file content"); + + await expect( + discoverAgentHistory({ + agent: "agy", + agentSession: null, + cwd: "/repo", + foregroundCwd: null, + homeDir, + }), + ).resolves.toBeNull(); + }); + + test("returns null when cwd is null for agy discovery", async () => { + const homeDir = await tempHome("shepherd-agy-nocwd-home-"); + const cliDir = join(homeDir, ".gemini", "antigravity-cli"); + const brainDir = join(cliDir, "brain"); + const sessDir = join(brainDir, "sess-123", ".system_generated", "logs"); + await mkdir(sessDir, { recursive: true }); + const transcriptPath = join(sessDir, "transcript.jsonl"); + await writeFile( + transcriptPath, + `${JSON.stringify({ step_index: 0, source: "USER_EXPLICIT", type: "USER_INPUT", content: "hello" })}\n`, + ); + + const dbPath = join(cliDir, "conversation_summaries.db"); + const sqlite = new DatabaseSync(dbPath); + sqlite.exec( + "create table conversation_summaries (conversation_id text primary key, workspace_uris text not null)", + ); + sqlite + .prepare("insert into conversation_summaries (conversation_id, workspace_uris) values (?, ?)") + .run("sess-123", JSON.stringify(["file:///repo"])); + sqlite.close(); + + await expect( + discoverAgentHistory({ + agent: "agy", + agentSession: null, + cwd: null, + foregroundCwd: null, + homeDir, + }), + ).resolves.toBeNull(); + }); + + test("discovers Antigravity session by id", async () => { + const homeDir = await tempHome("shepherd-agy-id-home-"); + const brainDir = join(homeDir, ".gemini", "antigravity-cli", "brain"); + const sessDir = join(brainDir, "sess-456", ".system_generated", "logs"); + await mkdir(sessDir, { recursive: true }); + const transcriptPath = join(sessDir, "transcript.jsonl"); + await writeFile( + transcriptPath, + `${JSON.stringify({ step_index: 0, source: "USER_EXPLICIT", type: "USER_INPUT", content: "hello" })}\n`, + ); + + await expect( + discoverAgentHistory({ + agent: "agy", + agentSession: { + agent: "agy", + kind: "id", + source: "herdr:agy", + value: "sess-456", + }, + cwd: null, + foregroundCwd: null, + homeDir, + }), + ).resolves.toMatchObject({ + kind: "agent_session", + path: transcriptPath, + source: "agy-jsonl", + value: "sess-456", + }); + }); + + test("filters Antigravity discovery by cwd matching conversation summaries workspace_uris", async () => { + const homeDir = await tempHome("shepherd-agy-summaries-home-"); + const cliDir = join(homeDir, ".gemini", "antigravity-cli"); + const brainDir = join(cliDir, "brain"); + const olderSessDir = join(brainDir, "sess-older", ".system_generated", "logs"); + const newerSessDir = join(brainDir, "sess-newer", ".system_generated", "logs"); + await mkdir(olderSessDir, { recursive: true }); + await mkdir(newerSessDir, { recursive: true }); + const olderTranscript = join(olderSessDir, "transcript.jsonl"); + const newerTranscript = join(newerSessDir, "transcript.jsonl"); + await writeFile( + olderTranscript, + `${JSON.stringify({ step_index: 0, source: "USER_EXPLICIT", type: "USER_INPUT", content: "repo-a transcript" })}\n`, + ); + await writeFile( + newerTranscript, + `${JSON.stringify({ step_index: 0, source: "USER_EXPLICIT", type: "USER_INPUT", content: "repo-b transcript" })}\n`, + ); + await utimes(olderTranscript, new Date(1000), new Date(1000)); + await utimes(newerTranscript, new Date(2000), new Date(2000)); + + const dbPath = join(cliDir, "conversation_summaries.db"); + const sqlite = new DatabaseSync(dbPath); + sqlite.exec( + "create table conversation_summaries (conversation_id text primary key, workspace_uris text not null)", + ); + sqlite + .prepare("insert into conversation_summaries (conversation_id, workspace_uris) values (?, ?)") + .run("sess-older", JSON.stringify(["file:///repo-a"])); + sqlite + .prepare("insert into conversation_summaries (conversation_id, workspace_uris) values (?, ?)") + .run("sess-newer", JSON.stringify(["file:///repo-b"])); + sqlite.close(); + + await expect( + discoverAgentHistory({ + agent: "agy", + agentSession: null, + cwd: "/repo-a", + foregroundCwd: null, + homeDir, + }), + ).resolves.toMatchObject({ + kind: "discovered_file", + path: olderTranscript, + source: "agy-jsonl", + value: olderTranscript, + }); + }); + + test("does not match root workspace URI '/' to child paths, nor child to root", async () => { + const homeDir = await tempHome("shepherd-agy-root-boundary-"); + const cliDir = join(homeDir, ".gemini", "antigravity-cli"); + const brainDir = join(cliDir, "brain"); + const sessDir = join(brainDir, "sess-root", ".system_generated", "logs"); + await mkdir(sessDir, { recursive: true }); + const transcriptPath = join(sessDir, "transcript.jsonl"); + await writeFile( + transcriptPath, + `${JSON.stringify({ step_index: 0, source: "USER_EXPLICIT", type: "USER_INPUT", content: "root transcript" })}\n`, + ); + + const dbPath = join(cliDir, "conversation_summaries.db"); + const sqlite = new DatabaseSync(dbPath); + sqlite.exec( + "create table conversation_summaries (conversation_id text primary key, workspace_uris text not null)", + ); + sqlite + .prepare("insert into conversation_summaries (conversation_id, workspace_uris) values (?, ?)") + .run("sess-root", JSON.stringify(["file:///"])); + sqlite.close(); + + // '/' workspace must not match '/repo' + await expect( + discoverAgentHistory({ + agent: "agy", + agentSession: null, + cwd: "/repo", + foregroundCwd: null, + homeDir, + }), + ).resolves.toBeNull(); + + // Exact '/' must match '/' + await expect( + discoverAgentHistory({ + agent: "agy", + agentSession: null, + cwd: "/", + foregroundCwd: null, + homeDir, + }), + ).resolves.toMatchObject({ + kind: "discovered_file", + path: transcriptPath, + source: "agy-jsonl", + }); + }); + + test("does not match parent and child workspace URIs in either direction", async () => { + const homeDir = await tempHome("shepherd-agy-parent-child-boundary-"); + const cliDir = join(homeDir, ".gemini", "antigravity-cli"); + const brainDir = join(cliDir, "brain"); + const parentSessDir = join(brainDir, "sess-parent", ".system_generated", "logs"); + const childSessDir = join(brainDir, "sess-child", ".system_generated", "logs"); + await mkdir(parentSessDir, { recursive: true }); + await mkdir(childSessDir, { recursive: true }); + const parentTranscript = join(parentSessDir, "transcript.jsonl"); + const childTranscript = join(childSessDir, "transcript.jsonl"); + await writeFile( + parentTranscript, + `${JSON.stringify({ step_index: 0, source: "USER_EXPLICIT", type: "USER_INPUT", content: "parent" })}\n`, + ); + await writeFile( + childTranscript, + `${JSON.stringify({ step_index: 0, source: "USER_EXPLICIT", type: "USER_INPUT", content: "child" })}\n`, + ); + + const dbPath = join(cliDir, "conversation_summaries.db"); + const sqlite = new DatabaseSync(dbPath); + sqlite.exec( + "create table conversation_summaries (conversation_id text primary key, workspace_uris text not null)", + ); + sqlite + .prepare("insert into conversation_summaries (conversation_id, workspace_uris) values (?, ?)") + .run("sess-parent", JSON.stringify(["file:///repo"])); + sqlite + .prepare("insert into conversation_summaries (conversation_id, workspace_uris) values (?, ?)") + .run("sess-child", JSON.stringify(["file:///repo/packages/sub"])); + sqlite.close(); + + // cwd '/repo' must not match child '/repo/packages/sub' + const parentResult = await discoverAgentHistory({ + agent: "agy", + agentSession: null, + cwd: "/repo", + foregroundCwd: null, + homeDir, + }); + expect(parentResult?.path).toBe(parentTranscript); + + // cwd '/repo/packages/sub' must not match parent '/repo' + const childResult = await discoverAgentHistory({ + agent: "agy", + agentSession: null, + cwd: "/repo/packages/sub", + foregroundCwd: null, + homeDir, + }); + expect(childResult?.path).toBe(childTranscript); + + // cwd '/repo/packages/other' matches neither + await expect( + discoverAgentHistory({ + agent: "agy", + agentSession: null, + cwd: "/repo/packages/other", + foregroundCwd: null, + homeDir, + }), + ).resolves.toBeNull(); + }); + + test("normalizes file URIs and trailing slashes correctly", async () => { + const homeDir = await tempHome("shepherd-agy-norm-"); + const cliDir = join(homeDir, ".gemini", "antigravity-cli"); + const brainDir = join(cliDir, "brain"); + const sessDir = join(brainDir, "sess-norm", ".system_generated", "logs"); + await mkdir(sessDir, { recursive: true }); + const transcriptPath = join(sessDir, "transcript.jsonl"); + await writeFile( + transcriptPath, + `${JSON.stringify({ step_index: 0, source: "USER_EXPLICIT", type: "USER_INPUT", content: "norm" })}\n`, + ); + + const dbPath = join(cliDir, "conversation_summaries.db"); + const sqlite = new DatabaseSync(dbPath); + sqlite.exec( + "create table conversation_summaries (conversation_id text primary key, workspace_uris text not null)", + ); + sqlite + .prepare("insert into conversation_summaries (conversation_id, workspace_uris) values (?, ?)") + .run("sess-norm", JSON.stringify(["file:///path/to/my-repo/"])); + sqlite.close(); + + await expect( + discoverAgentHistory({ + agent: "agy", + agentSession: null, + cwd: "/path/to/my-repo", + foregroundCwd: null, + homeDir, + }), + ).resolves.toMatchObject({ + kind: "discovered_file", + path: transcriptPath, + source: "agy-jsonl", + }); + }); + + test("returns null when no Antigravity conversation matches the agent cwd", async () => { + const homeDir = await tempHome("shepherd-agy-nomatch-home-"); + const cliDir = join(homeDir, ".gemini", "antigravity-cli"); + const brainDir = join(cliDir, "brain"); + const sessDir = join(brainDir, "sess-1", ".system_generated", "logs"); + await mkdir(sessDir, { recursive: true }); + const transcriptPath = join(sessDir, "transcript.jsonl"); + await writeFile( + transcriptPath, + `${JSON.stringify({ step_index: 0, source: "USER_EXPLICIT", type: "USER_INPUT", content: "content" })}\n`, + ); + + const dbPath = join(cliDir, "conversation_summaries.db"); + const sqlite = new DatabaseSync(dbPath); + sqlite.exec( + "create table conversation_summaries (conversation_id text primary key, workspace_uris text not null)", + ); + sqlite + .prepare("insert into conversation_summaries (conversation_id, workspace_uris) values (?, ?)") + .run("sess-1", JSON.stringify(["file:///repo-other"])); + sqlite.close(); + + await expect( + discoverAgentHistory({ + agent: "agy", + agentSession: null, + cwd: "/repo-unmatched", + foregroundCwd: null, + homeDir, + }), + ).resolves.toBeNull(); + }); }); diff --git a/test/unit/agent-history-readers.test.ts b/test/unit/agent-history-readers.test.ts index b10de33..7bc30db 100644 --- a/test/unit/agent-history-readers.test.ts +++ b/test/unit/agent-history-readers.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { afterEach, describe, expect, test } from "vitest"; +import { AgyHistoryReader } from "@/agent-history/agy-reader.js"; import { CodexHistoryReader } from "@/agent-history/codex-reader.js"; import { GeminiHistoryReader } from "@/agent-history/gemini-reader.js"; import { OpenCodeHistoryReader } from "@/agent-history/opencode-reader.js"; @@ -262,3 +263,643 @@ describe("GeminiHistoryReader", () => { ).resolves.toEqual([]); }); }); + +describe("AgyHistoryReader", () => { + test("reads user, assistant, and actual tool output messages in multi-turn conversation", async () => { + const homeDir = await tempHome("shepherd-agy-reader-"); + const transcriptDir = join( + homeDir, + ".gemini", + "antigravity-cli", + "brain", + "sess_1", + ".system_generated", + "logs", + ); + await mkdir(transcriptDir, { recursive: true }); + const transcriptPath = join(transcriptDir, "transcript.jsonl"); + await writeFile( + transcriptPath, + `${[ + { + step_index: 0, + source: "USER_EXPLICIT", + type: "USER_INPUT", + created_at: "2026-08-20T10:00:00.000Z", + content: "list files", + }, + { + step_index: 1, + source: "MODEL", + type: "PLANNER_RESPONSE", + created_at: "2026-08-20T10:00:01.000Z", + tool_calls: [{ name: "list_dir", args: { path: "/repo" } }], + }, + { + step_index: 2, + source: "MODEL", + type: "LIST_DIRECTORY", + created_at: "2026-08-20T10:00:02.000Z", + content: "a.ts\nb.ts", + }, + { + step_index: 3, + source: "MODEL", + type: "PLANNER_RESPONSE", + created_at: "2026-08-20T10:00:03.000Z", + content: "Here are the files: a.ts, b.ts", + }, + ] + .map((e) => JSON.stringify(e)) + .join("\n")}\n`, + ); + + const reader = new AgyHistoryReader({ homeDir }); + const messages = await reader.read( + { kind: "discovered_file", path: transcriptPath, source: "agy-jsonl", value: transcriptPath }, + { limit: 10 }, + ); + + expect(messages.map((m) => m.role)).toEqual(["user", "tool_result", "assistant"]); + expect(messages[0]).toMatchObject({ + role: "user", + text: "list files", + timestamp: "2026-08-20T10:00:00.000Z", + }); + expect(messages[1]).toMatchObject({ + role: "tool_result", + toolName: "list_dir", + timestamp: "2026-08-20T10:00:02.000Z", + }); + expect(messages[1]?.compact?.text).toBe("a.ts\nb.ts"); + expect(messages[1]?.compact?.isError).toBe(false); + expect(messages[2]).toMatchObject({ + role: "assistant", + text: "Here are the files: a.ts, b.ts", + timestamp: "2026-08-20T10:00:03.000Z", + }); + + const compact = await reader.readCompact({ + kind: "discovered_file", + path: transcriptPath, + source: "agy-jsonl", + value: transcriptPath, + }); + expect(compact.messageCount).toBe(3); + expect(compact.lastUserMessage?.text).toBe("list files"); + expect(compact.lastAssistantMessage?.text).toBe("Here are the files: a.ts, b.ts"); + expect(compact.lastToolResult?.toolName).toBe("list_dir"); + expect(compact.lastToolResult?.text).toBe("a.ts\nb.ts"); + }); + + test("reads tool error entries and marks them as isError", async () => { + const homeDir = await tempHome("shepherd-agy-errors-"); + const transcriptPath = join(homeDir, "transcript.jsonl"); + await writeFile( + transcriptPath, + `${[ + { + step_index: 0, + source: "USER_EXPLICIT", + type: "USER_INPUT", + created_at: "2026-08-20T10:00:00.000Z", + content: "run failing command", + }, + { + step_index: 1, + source: "MODEL", + type: "PLANNER_RESPONSE", + created_at: "2026-08-20T10:00:01.000Z", + tool_calls: [{ name: "run_command", args: { CommandLine: "pnpm test" } }], + }, + { + step_index: 2, + source: "MODEL", + type: "RUN_COMMAND", + status: "ERROR", + exit_code: 1, + created_at: "2026-08-20T10:00:02.000Z", + content: "FAIL test/a.test.ts\nAssertionError: expected 1 to be 0", + }, + { + step_index: 3, + source: "MODEL", + type: "PLANNER_RESPONSE", + created_at: "2026-08-20T10:00:03.000Z", + tool_calls: [{ name: "bad_tool", args: {} }], + }, + { + step_index: 4, + source: "SYSTEM", + type: "ERROR_MESSAGE", + created_at: "2026-08-20T10:00:04.000Z", + error: "There was a problem parsing the tool call.", + content: "Error invalid tool call", + }, + ] + .map((e) => JSON.stringify(e)) + .join("\n")}\n`, + ); + + const reader = new AgyHistoryReader(); + const messages = await reader.read({ + kind: "discovered_file", + path: transcriptPath, + source: "agy-jsonl", + value: transcriptPath, + }); + + expect(messages.map((m) => m.role)).toEqual(["user", "tool_result", "tool_result"]); + expect(messages[1]).toMatchObject({ + role: "tool_result", + toolName: "run_command", + }); + expect(messages[1]?.compact?.isError).toBe(true); + expect(messages[1]?.compact?.text).toContain("AssertionError"); + + expect(messages[2]).toMatchObject({ + role: "tool_result", + toolName: "bad_tool", + }); + expect(messages[2]?.compact?.isError).toBe(true); + expect(messages[2]?.compact?.text).toContain("Error invalid tool call"); + }); + + test("handles multiple tool calls in a single turn and pairs results sequentially", async () => { + const homeDir = await tempHome("shepherd-agy-multicall-"); + const transcriptPath = join(homeDir, "transcript.jsonl"); + await writeFile( + transcriptPath, + `${[ + { + step_index: 0, + source: "USER_EXPLICIT", + type: "USER_INPUT", + created_at: "2026-08-20T10:00:00.000Z", + content: "read two files", + }, + { + step_index: 1, + source: "MODEL", + type: "PLANNER_RESPONSE", + created_at: "2026-08-20T10:00:01.000Z", + tool_calls: [ + { name: "view_file", args: { AbsolutePath: "/repo/a.ts" } }, + { name: "view_file", args: { AbsolutePath: "/repo/b.ts" } }, + ], + }, + { + step_index: 2, + source: "MODEL", + type: "VIEW_FILE", + created_at: "2026-08-20T10:00:02.000Z", + content: "const a = 1;", + }, + { + step_index: 3, + source: "MODEL", + type: "VIEW_FILE", + created_at: "2026-08-20T10:00:03.000Z", + content: "const b = 2;", + }, + { + step_index: 4, + source: "MODEL", + type: "PLANNER_RESPONSE", + created_at: "2026-08-20T10:00:04.000Z", + content: "read both", + }, + ] + .map((e) => JSON.stringify(e)) + .join("\n")}\n`, + ); + + const reader = new AgyHistoryReader(); + const messages = await reader.read({ + kind: "discovered_file", + path: transcriptPath, + source: "agy-jsonl", + value: transcriptPath, + }); + + expect(messages.map((m) => m.role)).toEqual([ + "user", + "tool_result", + "tool_result", + "assistant", + ]); + expect(messages[1]).toMatchObject({ + role: "tool_result", + toolName: "view_file", + text: "const a = 1;", + }); + expect(messages[2]).toMatchObject({ + role: "tool_result", + toolName: "view_file", + text: "const b = 2;", + }); + expect(messages[3]).toMatchObject({ + role: "assistant", + text: "read both", + }); + }); + + test("omits tool results when tool calls have no following execution results", async () => { + const homeDir = await tempHome("shepherd-agy-no-results-"); + const transcriptPath = join(homeDir, "transcript.jsonl"); + await writeFile( + transcriptPath, + `${[ + { + step_index: 0, + source: "USER_EXPLICIT", + type: "USER_INPUT", + created_at: "2026-08-20T10:00:00.000Z", + content: "interrupted task", + }, + { + step_index: 1, + source: "MODEL", + type: "PLANNER_RESPONSE", + created_at: "2026-08-20T10:00:01.000Z", + tool_calls: [{ name: "run_cmd", args: "echo 1" }], + }, + ] + .map((e) => JSON.stringify(e)) + .join("\n")}\n`, + ); + + const reader = new AgyHistoryReader(); + const messages = await reader.read({ + kind: "discovered_file", + path: transcriptPath, + source: "agy-jsonl", + value: transcriptPath, + }); + + expect(messages).toEqual([expect.objectContaining({ role: "user", text: "interrupted task" })]); + + const compact = await reader.readCompact({ + kind: "discovered_file", + path: transcriptPath, + source: "agy-jsonl", + value: transcriptPath, + }); + expect(compact.messageCount).toBe(1); + expect(compact.lastUserMessage?.text).toBe("interrupted task"); + expect(compact.lastAssistantMessage).toBeNull(); + expect(compact.lastToolResult).toBeNull(); + }); + + test("parses assistant messages with structured array content and ignores empty content on tool_calls", async () => { + const homeDir = await tempHome("shepherd-agy-array-content-"); + const transcriptPath = join(homeDir, "transcript.jsonl"); + await writeFile( + transcriptPath, + `${[ + { + step_index: 0, + source: "USER_EXPLICIT", + type: "USER_INPUT", + created_at: "2026-08-20T10:00:00.000Z", + content: [{ type: "text", text: "hello array user" }], + }, + { + step_index: 1, + source: "MODEL", + type: "PLANNER_RESPONSE", + created_at: "2026-08-20T10:00:01.000Z", + content: "", + tool_calls: [{ name: "run_cmd", args: "echo 1" }], + }, + { + step_index: 2, + source: "MODEL", + type: "RUN_COMMAND", + created_at: "2026-08-20T10:00:02.000Z", + content: "1", + }, + { + step_index: 3, + source: "MODEL", + type: "PLANNER_RESPONSE", + created_at: "2026-08-20T10:00:03.000Z", + content: [{ type: "text", text: "hello array assistant" }], + }, + ] + .map((e) => JSON.stringify(e)) + .join("\n")}\n`, + ); + + const reader = new AgyHistoryReader({ homeDir }); + const messages = await reader.read( + { kind: "discovered_file", path: transcriptPath, source: "agy-jsonl", value: transcriptPath }, + { limit: 10 }, + ); + + expect(messages.map((m) => m.role)).toEqual(["user", "tool_result", "assistant"]); + expect(messages[0]).toMatchObject({ role: "user", text: "hello array user" }); + expect(messages[1]).toMatchObject({ role: "tool_result", toolName: "run_cmd", text: "1" }); + expect(messages[2]).toMatchObject({ role: "assistant", text: "hello array assistant" }); + }); + + test("returns empty history for empty file", async () => { + const homeDir = await tempHome("shepherd-agy-empty-"); + const transcriptPath = join(homeDir, "transcript.jsonl"); + await writeFile(transcriptPath, ""); + + const messages = await new AgyHistoryReader().read({ + kind: "discovered_file", + path: transcriptPath, + source: "agy-jsonl", + value: transcriptPath, + }); + expect(messages).toEqual([]); + }); + + test("skips malformed JSON lines and parses valid lines", async () => { + const homeDir = await tempHome("shepherd-agy-bad-json-"); + const transcriptPath = join(homeDir, "transcript.jsonl"); + await writeFile( + transcriptPath, + `{not-valid-json\n${JSON.stringify({ + step_index: 0, + source: "USER_EXPLICIT", + type: "USER_INPUT", + content: "hello", + })}\n{"half":"line\n`, + ); + + const messages = await new AgyHistoryReader().read({ + kind: "discovered_file", + path: transcriptPath, + source: "agy-jsonl", + value: transcriptPath, + }); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ role: "user", text: "hello" }); + }); + + test("returns empty array when file does not exist", async () => { + const homeDir = await tempHome("shepherd-agy-nonexistent-"); + const transcriptPath = join(homeDir, "nonexistent.jsonl"); + + const messages = await new AgyHistoryReader().read({ + kind: "discovered_file", + path: transcriptPath, + source: "agy-jsonl", + value: transcriptPath, + }); + expect(messages).toEqual([]); + }); + + test("resolves transcript path by session ID value and homeDir", async () => { + const homeDir = await tempHome("shepherd-agy-valpath-"); + const sessionId = "30116061-fc68-4ce7-9406-7c9759193f45"; + const transcriptDir = join( + homeDir, + ".gemini", + "antigravity-cli", + "brain", + sessionId, + ".system_generated", + "logs", + ); + await mkdir(transcriptDir, { recursive: true }); + const transcriptPath = join(transcriptDir, "transcript.jsonl"); + await writeFile( + transcriptPath, + `${JSON.stringify({ + step_index: 0, + source: "USER_EXPLICIT", + type: "USER_INPUT", + content: "from sessionId", + })}\n`, + ); + + const reader = new AgyHistoryReader({ homeDir }); + const messages = await reader.read({ + kind: "agent_session", + source: "agy-jsonl", + value: sessionId, + }); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ role: "user", text: "from sessionId" }); + }); + + test("is registered in the default agent history service", async () => { + const homeDir = await tempHome("shepherd-agy-service-"); + const cliDir = join(homeDir, ".gemini", "antigravity-cli"); + const transcriptDir = join(cliDir, "brain", "sess_service", ".system_generated", "logs"); + await mkdir(transcriptDir, { recursive: true }); + const transcriptPath = join(transcriptDir, "transcript.jsonl"); + await writeFile( + transcriptPath, + `${JSON.stringify({ + step_index: 0, + source: "USER_EXPLICIT", + type: "USER_INPUT", + content: "service integration", + })}\n`, + ); + + const dbPath = join(cliDir, "conversation_summaries.db"); + const sqlite = new DatabaseSync(dbPath); + sqlite.exec( + "create table conversation_summaries (conversation_id text primary key, workspace_uris text not null)", + ); + sqlite + .prepare("insert into conversation_summaries (conversation_id, workspace_uris) values (?, ?)") + .run("sess_service", JSON.stringify(["file:///repo"])); + sqlite.close(); + + const service = createAgentHistoryService({ homeDir }); + await expect( + service.read( + { agent: "agy", agentSession: null, cwd: "/repo", foregroundCwd: null }, + { limit: 10 }, + ), + ).resolves.toMatchObject({ + historyRef: { source: "agy-jsonl", path: transcriptPath }, + messages: [expect.objectContaining({ role: "user", text: "service integration" })], + }); + }); + + test("omits orphan entries with unknown/generic types when pendingToolCalls is empty but accepts known execution types", async () => { + const homeDir = await tempHome("shepherd-agy-orphan-filter-"); + const transcriptPath = join(homeDir, "transcript.jsonl"); + await writeFile( + transcriptPath, + `${[ + { + step_index: 0, + source: "USER_EXPLICIT", + type: "USER_INPUT", + created_at: "2026-08-20T10:00:00.000Z", + content: "do work", + }, + { + step_index: 1, + source: "MODEL", + type: "PLANNER_RESPONSE", + created_at: "2026-08-20T10:00:01.000Z", + content: "thinking...", + }, + { + step_index: 2, + source: "MODEL", + type: "GENERIC", + created_at: "2026-08-20T10:00:02.000Z", + content: "internal bookkeeping record", + }, + { + step_index: 3, + source: "MODEL", + type: "CUSTOM_UNKNOWN_METADATA", + created_at: "2026-08-20T10:00:03.000Z", + content: "some other metadata", + }, + { + step_index: 4, + source: "MODEL", + type: "RUN_COMMAND", + created_at: "2026-08-20T10:00:04.000Z", + content: "command executed successfully", + }, + { + step_index: 5, + source: "MODEL", + type: "ERROR_MESSAGE", + created_at: "2026-08-20T10:00:05.000Z", + error: "something failed", + }, + { + step_index: 6, + source: "MODEL", + type: "PLANNER_RESPONSE", + created_at: "2026-08-20T10:00:06.000Z", + content: "all done", + }, + ] + .map((e) => JSON.stringify(e)) + .join("\n")}\n`, + ); + + const reader = new AgyHistoryReader({ homeDir }); + const messages = await reader.read( + { kind: "discovered_file", path: transcriptPath, source: "agy-jsonl", value: transcriptPath }, + { limit: 20 }, + ); + + expect(messages.map((m) => m.role)).toEqual([ + "user", + "assistant", + "tool_result", + "tool_result", + "assistant", + ]); + expect(messages[0]).toMatchObject({ role: "user", text: "do work" }); + expect(messages[1]).toMatchObject({ role: "assistant", text: "thinking..." }); + expect(messages[2]).toMatchObject({ + role: "tool_result", + toolName: "run_command", + text: "command executed successfully", + }); + expect(messages[3]).toMatchObject({ + role: "tool_result", + toolName: "unknown", + text: "something failed", + }); + expect(messages[3]?.compact?.isError).toBe(true); + expect(messages[4]).toMatchObject({ role: "assistant", text: "all done" }); + }); + + test("does not misalign pending tool call queue when generic/unknown entries are interleaved before results", async () => { + const homeDir = await tempHome("shepherd-agy-interleaved-generic-"); + const transcriptPath = join(homeDir, "transcript.jsonl"); + await writeFile( + transcriptPath, + `${[ + { + step_index: 0, + source: "USER_EXPLICIT", + type: "USER_INPUT", + created_at: "2026-08-20T10:00:00.000Z", + content: "read and run", + }, + { + step_index: 1, + source: "MODEL", + type: "PLANNER_RESPONSE", + created_at: "2026-08-20T10:00:01.000Z", + tool_calls: [ + { name: "view_file", args: { AbsolutePath: "/repo/a.ts" } }, + { name: "run_command", args: { CommandLine: "echo ok" } }, + ], + }, + { + step_index: 2, + source: "MODEL", + type: "GENERIC", + created_at: "2026-08-20T10:00:02.000Z", + content: "interleaved internal non-tool entry", + }, + { + step_index: 3, + source: "MODEL", + type: "VIEW_FILE", + created_at: "2026-08-20T10:00:03.000Z", + content: "const a = 1;", + }, + { + step_index: 4, + source: "MODEL", + type: "GENERIC", + created_at: "2026-08-20T10:00:04.000Z", + content: "another non-tool event", + }, + { + step_index: 5, + source: "MODEL", + type: "RUN_COMMAND", + created_at: "2026-08-20T10:00:05.000Z", + content: "ok", + }, + { + step_index: 6, + source: "MODEL", + type: "PLANNER_RESPONSE", + created_at: "2026-08-20T10:00:06.000Z", + content: "all complete", + }, + ] + .map((e) => JSON.stringify(e)) + .join("\n")}\n`, + ); + + const reader = new AgyHistoryReader({ homeDir }); + const messages = await reader.read( + { kind: "discovered_file", path: transcriptPath, source: "agy-jsonl", value: transcriptPath }, + { limit: 20 }, + ); + + expect(messages.map((m) => m.role)).toEqual([ + "user", + "tool_result", + "tool_result", + "assistant", + ]); + expect(messages[0]).toMatchObject({ role: "user", text: "read and run" }); + expect(messages[1]).toMatchObject({ + role: "tool_result", + toolName: "view_file", + text: "const a = 1;", + }); + expect(messages[2]).toMatchObject({ + role: "tool_result", + toolName: "run_command", + text: "ok", + }); + expect(messages[3]).toMatchObject({ role: "assistant", text: "all complete" }); + }); +}); diff --git a/test/unit/shepherd-pi-extension.test.ts b/test/unit/shepherd-pi-extension.test.ts index 865a256..2bce626 100644 --- a/test/unit/shepherd-pi-extension.test.ts +++ b/test/unit/shepherd-pi-extension.test.ts @@ -80,30 +80,44 @@ describe("shepherd-pi orchestrator bridge", () => { }); test("does not connect outside a complete Herdr environment", async () => { - const pi = createFakePi(); - let clients = 0; - const { createShepherdPiExtension } = (await import(extensionModuleUrl)) as Module; - createShepherdPiExtension({ - clientFactory: () => { - clients += 1; - return createFakeClient(); - }, - })(pi); - const ctx = fakeCtx(); - - await pi.emit("session_start", {}, ctx); - expect(clients).toBe(0); - expect(ctx.statuses.get("shepherd")).toBeUndefined(); - await pi.command("", ctx); - expect(ctx.notifications.at(-1)).toEqual(["Shepherd requires a Herdr workspace", "error"]); - - const previous = withHerdrEnv(); + const saved = { + HERDR_ENV: process.env.HERDR_ENV, + HERDR_PANE_ID: process.env.HERDR_PANE_ID, + HERDR_SOCKET_PATH: process.env.HERDR_SOCKET_PATH, + HERDR_WORKSPACE_ID: process.env.HERDR_WORKSPACE_ID, + }; + delete process.env.HERDR_ENV; delete process.env.HERDR_PANE_ID; + delete process.env.HERDR_SOCKET_PATH; + delete process.env.HERDR_WORKSPACE_ID; try { + const pi = createFakePi(); + let clients = 0; + const { createShepherdPiExtension } = (await import(extensionModuleUrl)) as Module; + createShepherdPiExtension({ + clientFactory: () => { + clients += 1; + return createFakeClient(); + }, + })(pi); + const ctx = fakeCtx(); + await pi.emit("session_start", {}, ctx); expect(clients).toBe(0); + expect(ctx.statuses.get("shepherd")).toBeUndefined(); + await pi.command("", ctx); + expect(ctx.notifications.at(-1)).toEqual(["Shepherd requires a Herdr workspace", "error"]); + + const previous = withHerdrEnv(); + delete process.env.HERDR_PANE_ID; + try { + await pi.emit("session_start", {}, ctx); + expect(clients).toBe(0); + } finally { + restoreEnv(previous); + } } finally { - restoreEnv(previous); + restoreEnv(saved); } });