From e73354f0553cc25eb280f5b9ab3af1102c465670 Mon Sep 17 00:00:00 2001 From: dorokuma Date: Thu, 20 Aug 2026 00:34:14 +0800 Subject: [PATCH 1/6] feat(agent-history): add Antigravity CLI (agy) history reader --- src/agent-history/agy-reader.ts | 143 ++++++++++++++++++++++++++++++++ src/agent-history/discovery.ts | 54 ++++++++++++ src/agent-history/service.ts | 2 + src/observability/contracts.ts | 1 + 4 files changed, 200 insertions(+) create mode 100644 src/agent-history/agy-reader.ts diff --git a/src/agent-history/agy-reader.ts b/src/agent-history/agy-reader.ts new file mode 100644 index 0000000..049029f --- /dev/null +++ b/src/agent-history/agy-reader.ts @@ -0,0 +1,143 @@ +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"; + +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[] = []; + + 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 === "USER_INPUT") { + 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 (source === "MODEL" && text) { + messages.push({ ref: refValue, role: "assistant", text, timestamp }); + continue; + } + + const toolCalls = Array.isArray(entry.value.tool_calls) ? entry.value.tool_calls : []; + if (toolCalls.length > 0) { + const firstCall = record(toolCalls[0]); + const toolName = stringValue(firstCall.name) ?? "unknown"; + const args = firstCall.args; + const text = formatToolCallText(toolName, args); + const compact = compactToolResult({ isError: false, 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 formatToolCallText(toolName: string, args: unknown): string { + if (typeof args === "string") { + const trimmed = args.trim(); + return trimmed.length > 0 ? `${toolName}: ${trimmed}` : toolName; + } + if (typeof args === "object" && args !== null) { + const serialized = JSON.stringify(args); + return serialized && serialized !== "{}" ? `${toolName}: ${serialized}` : toolName; + } + return toolName; +} + +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..57503ec 100644 --- a/src/agent-history/discovery.ts +++ b/src/agent-history/discovery.ts @@ -41,6 +41,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 +77,14 @@ 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")))); + } if (agent === "opencode") { const ref = discoverOpenCodeSession({ cwd, homeDir, sessionId: null }); if (ref) return ref; @@ -82,6 +110,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 +185,25 @@ async function scanGeminiRoot(root: string): Promise { return candidates; } +async function scanAgyRoot(root: string): Promise { + if (!existsSync(root)) return []; + const entries = await readdir(root, { withFileTypes: true }).catch(() => []); + const candidates: Candidate[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const path = join(root, entry.name, ".system_generated", "logs", "transcript.jsonl"); + const stats = await stat(path).catch(() => null); + if (!stats?.isFile()) continue; + candidates.push({ + cwd: await readCandidateCwd(path), + 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" From 643a94a27261906be23cb6bf6bde6222142db323 Mon Sep 17 00:00:00 2001 From: dorokuma Date: Thu, 20 Aug 2026 00:34:21 +0800 Subject: [PATCH 2/6] test(agent-history): add agy reader and discovery tests --- test/unit/agent-history-discovery.test.ts | 113 ++++++++- test/unit/agent-history-readers.test.ts | 290 ++++++++++++++++++++++ 2 files changed, 402 insertions(+), 1 deletion(-) diff --git a/test/unit/agent-history-discovery.test.ts b/test/unit/agent-history-discovery.test.ts index 580ff25..c2b7e15 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,99 @@ describe("agent history discovery", () => { value: sessionPath, }); }); + + test("discovers Antigravity JSONL session from brain directory", async () => { + const homeDir = await tempHome("shepherd-agy-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: null, + foregroundCwd: null, + homeDir, + }), + ).resolves.toMatchObject({ + kind: "discovered_file", + path: transcriptPath, + source: "agy-jsonl", + value: transcriptPath, + }); + }); + + test("discovers latest Antigravity session ordered by mtime when multiple sessions exist", async () => { + const homeDir = await tempHome("shepherd-agy-multi-home-"); + const brainDir = join(homeDir, ".gemini", "antigravity-cli", "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: "older" })}\n`, + ); + await writeFile( + newerTranscript, + `${JSON.stringify({ step_index: 0, source: "USER_EXPLICIT", type: "USER_INPUT", content: "newer" })}\n`, + ); + await utimes(olderTranscript, new Date(1000), new Date(1000)); + await utimes(newerTranscript, new Date(2000), new Date(2000)); + + await expect( + discoverAgentHistory({ + agent: "agy", + agentSession: null, + cwd: null, + foregroundCwd: null, + homeDir, + }), + ).resolves.toMatchObject({ + kind: "discovered_file", + path: newerTranscript, + source: "agy-jsonl", + value: newerTranscript, + }); + }); + + 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", + }); + }); }); diff --git a/test/unit/agent-history-readers.test.ts b/test/unit/agent-history-readers.test.ts index b10de33..8524185 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,292 @@ describe("GeminiHistoryReader", () => { ).resolves.toEqual([]); }); }); + +describe("AgyHistoryReader", () => { + test("reads user, assistant, and tool_calls 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: "ctx_fs", args: { action: "ls", path: "/repo" } }], + }, + { + step_index: 2, + source: "MODEL", + type: "GENERIC", + created_at: "2026-08-20T10:00:02.000Z", + content: "some output", + }, + { + 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: "ctx_fs", + timestamp: "2026-08-20T10:00:01.000Z", + }); + expect(messages[1]?.compact?.text).toContain("ctx_fs"); + 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("ctx_fs"); + }); + + test("handles compact history when all assistant responses are pure tool_calls", async () => { + const homeDir = await tempHome("shepherd-agy-toolonly-"); + 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", + tool_calls: [{ name: "run_cmd", args: "echo 1" }], + }, + ] + .map((e) => JSON.stringify(e)) + .join("\n")}\n`, + ); + + const reader = new AgyHistoryReader(); + const compact = await reader.readCompact({ + kind: "discovered_file", + path: transcriptPath, + source: "agy-jsonl", + value: transcriptPath, + }); + expect(compact.messageCount).toBe(2); + expect(compact.lastUserMessage?.text).toBe("do work"); + expect(compact.lastAssistantMessage).toBeNull(); + expect(compact.lastToolResult?.toolName).toBe("run_cmd"); + }); + + 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: "PLANNER_RESPONSE", + created_at: "2026-08-20T10:00:02.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" }); + 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 transcriptDir = join( + homeDir, + ".gemini", + "antigravity-cli", + "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 service = createAgentHistoryService({ homeDir }); + await expect( + service.read( + { agent: "agy", agentSession: null, cwd: null, foregroundCwd: null }, + { limit: 10 }, + ), + ).resolves.toMatchObject({ + historyRef: { source: "agy-jsonl", path: transcriptPath }, + messages: [expect.objectContaining({ role: "user", text: "service integration" })], + }); + }); +}); From da3ad187a711943e58a022524329d8927200dd11 Mon Sep 17 00:00:00 2001 From: dorokuma Date: Thu, 20 Aug 2026 06:43:32 +0800 Subject: [PATCH 3/6] fix(agent-history): filter agy discovery by cwd via conversation summaries --- src/agent-history/discovery.ts | 82 +++++++++++++++++++++- test/unit/agent-history-discovery.test.ts | 83 +++++++++++++++++++++++ 2 files changed, 163 insertions(+), 2 deletions(-) diff --git a/src/agent-history/discovery.ts b/src/agent-history/discovery.ts index 57503ec..37f0528 100644 --- a/src/agent-history/discovery.ts +++ b/src/agent-history/discovery.ts @@ -83,7 +83,9 @@ export async function discoverAgentHistory( agent.includes("antigravity") || agent.includes("agy") ) { - candidates.push(...(await scanAgyRoot(join(homeDir, ".gemini", "antigravity-cli", "brain")))); + candidates.push( + ...(await scanAgyRoot(join(homeDir, ".gemini", "antigravity-cli", "brain"), homeDir, cwd)), + ); } if (agent === "opencode") { const ref = discoverOpenCodeSession({ cwd, homeDir, sessionId: null }); @@ -185,8 +187,84 @@ async function scanGeminiRoot(root: string): Promise { return candidates; } -async function scanAgyRoot(root: string): Promise { +function normalizePath(uriOrPath: string): string { + const raw = uriOrPath.startsWith("file://") ? uriOrPath.slice(7) : uriOrPath; + const trimmed = raw.replace(/\/+$/, ""); + const normalized = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return normalized === "" ? "/" : normalized; +} + +function matchesWorkspaceUri(workspaceUri: string, cwd: string): boolean { + const ws = normalizePath(workspaceUri); + const target = normalizePath(cwd); + if (ws === target) return true; + if (ws === "/") return true; + if (target === "/") return true; + if (target.startsWith(`${ws}/`)) return true; + if (ws.startsWith(`${target}/`)) return true; + return false; +} + +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)) return []; + const summaries = readAgyConversationSummaries(homeDir); + + if (summaries !== null) { + if (!cwd) 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; + } + const entries = await readdir(root, { withFileTypes: true }).catch(() => []); const candidates: Candidate[] = []; for (const entry of entries) { diff --git a/test/unit/agent-history-discovery.test.ts b/test/unit/agent-history-discovery.test.ts index c2b7e15..5efbf40 100644 --- a/test/unit/agent-history-discovery.test.ts +++ b/test/unit/agent-history-discovery.test.ts @@ -252,4 +252,87 @@ describe("agent history discovery", () => { 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("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(); + }); }); From 3254a40d85045726789b178b552abd62b9e393db Mon Sep 17 00:00:00 2001 From: dorokuma Date: Mon, 31 Aug 2026 09:27:22 +0800 Subject: [PATCH 4/6] fix(agent-history): address PR review comments for agy history reader --- README.ja.md | 2 +- README.md | 2 +- src/agent-history/agy-reader.ts | 92 ++++++--- src/agent-history/discovery.ts | 61 +++--- test/unit/agent-history-discovery.test.ts | 228 +++++++++++++++++++--- test/unit/agent-history-readers.test.ts | 225 ++++++++++++++++++--- test/unit/shepherd-pi-extension.test.ts | 52 +++-- 7 files changed, 520 insertions(+), 142 deletions(-) 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 index 049029f..9bd5f4b 100644 --- a/src/agent-history/agy-reader.ts +++ b/src/agent-history/agy-reader.ts @@ -13,6 +13,17 @@ import { 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", +]); + export class AgyHistoryReader implements AgentHistoryReader { private readonly homeDir?: string; @@ -42,6 +53,7 @@ export class AgyHistoryReader implements AgentHistoryReader { } const messages: AgentHistoryMessage[] = []; + const pendingToolCalls: string[] = []; for (const entry of entries) { const type = stringValue(entry.value.type); @@ -55,7 +67,12 @@ export class AgyHistoryReader implements AgentHistoryReader { 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 }); @@ -65,27 +82,32 @@ export class AgyHistoryReader implements AgentHistoryReader { if (type === "PLANNER_RESPONSE") { const text = textFromContent(entry.value.content); - if (source === "MODEL" && text) { + if (text) { messages.push({ ref: refValue, role: "assistant", text, timestamp }); - continue; } const toolCalls = Array.isArray(entry.value.tool_calls) ? entry.value.tool_calls : []; - if (toolCalls.length > 0) { - const firstCall = record(toolCalls[0]); - const toolName = stringValue(firstCall.name) ?? "unknown"; - const args = firstCall.args; - const text = formatToolCallText(toolName, args); - const compact = compactToolResult({ isError: false, ref: refValue, text, toolName }); - messages.push({ - compact, - ref: refValue, - role: "tool_result", - text: compact.text, - timestamp, - toolName, - }); + for (const call of toolCalls) { + const callRecord = record(call); + const name = stringValue(callRecord.name) ?? "unknown"; + pendingToolCalls.push(name); } + 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, + }); } } @@ -122,16 +144,34 @@ export function resolveAgyTranscriptPath( ); } -function formatToolCallText(toolName: string, args: unknown): string { - if (typeof args === "string") { - const trimmed = args.trim(); - return trimmed.length > 0 ? `${toolName}: ${trimmed}` : toolName; - } - if (typeof args === "object" && args !== null) { - const serialized = JSON.stringify(args); - return serialized && serialized !== "{}" ? `${toolName}: ${serialized}` : toolName; - } - return toolName; +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 { diff --git a/src/agent-history/discovery.ts b/src/agent-history/discovery.ts index 37f0528..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 = { @@ -187,22 +188,22 @@ async function scanGeminiRoot(root: string): Promise { return candidates; } -function normalizePath(uriOrPath: string): string { +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; - const trimmed = raw.replace(/\/+$/, ""); - const normalized = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; - return normalized === "" ? "/" : normalized; + return resolve(raw); } -function matchesWorkspaceUri(workspaceUri: string, cwd: string): boolean { - const ws = normalizePath(workspaceUri); - const target = normalizePath(cwd); - if (ws === target) return true; - if (ws === "/") return true; - if (target === "/") return true; - if (target.startsWith(`${ws}/`)) return true; - if (ws.startsWith(`${target}/`)) return true; - return false; +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 { @@ -243,37 +244,19 @@ async function scanAgyRoot( homeDir: string, cwd: string | null, ): Promise { - if (!existsSync(root)) return []; + if (!existsSync(root) || !cwd) return []; const summaries = readAgyConversationSummaries(homeDir); + if (summaries === null) return []; - if (summaries !== null) { - if (!cwd) 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; - } - - const entries = await readdir(root, { withFileTypes: true }).catch(() => []); const candidates: Candidate[] = []; - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const path = join(root, entry.name, ".system_generated", "logs", "transcript.jsonl"); + 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: await readCandidateCwd(path), + cwd, mtimeMs: stats.mtimeMs, path, source: "agy-jsonl", diff --git a/test/unit/agent-history-discovery.test.ts b/test/unit/agent-history-discovery.test.ts index 5efbf40..cf6bc1c 100644 --- a/test/unit/agent-history-discovery.test.ts +++ b/test/unit/agent-history-discovery.test.ts @@ -158,8 +158,8 @@ describe("agent history discovery", () => { }); }); - test("discovers Antigravity JSONL session from brain directory", async () => { - const homeDir = await tempHome("shepherd-agy-home-"); + 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 }); @@ -173,37 +173,60 @@ describe("agent history discovery", () => { discoverAgentHistory({ agent: "agy", agentSession: null, - cwd: null, + cwd: "/repo", foregroundCwd: null, homeDir, }), - ).resolves.toMatchObject({ - kind: "discovered_file", - path: transcriptPath, - source: "agy-jsonl", - value: transcriptPath, - }); + ).resolves.toBeNull(); }); - test("discovers latest Antigravity session ordered by mtime when multiple sessions exist", async () => { - const homeDir = await tempHome("shepherd-agy-multi-home-"); - const brainDir = join(homeDir, ".gemini", "antigravity-cli", "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"); + 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( - olderTranscript, - `${JSON.stringify({ step_index: 0, source: "USER_EXPLICIT", type: "USER_INPUT", content: "older" })}\n`, + 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( - newerTranscript, - `${JSON.stringify({ step_index: 0, source: "USER_EXPLICIT", type: "USER_INPUT", content: "newer" })}\n`, + transcriptPath, + `${JSON.stringify({ step_index: 0, source: "USER_EXPLICIT", type: "USER_INPUT", content: "hello" })}\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-123", JSON.stringify(["file:///repo"])); + sqlite.close(); await expect( discoverAgentHistory({ @@ -213,12 +236,7 @@ describe("agent history discovery", () => { foregroundCwd: null, homeDir, }), - ).resolves.toMatchObject({ - kind: "discovered_file", - path: newerTranscript, - source: "agy-jsonl", - value: newerTranscript, - }); + ).resolves.toBeNull(); }); test("discovers Antigravity session by id", async () => { @@ -303,6 +321,156 @@ describe("agent history discovery", () => { }); }); + 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"); diff --git a/test/unit/agent-history-readers.test.ts b/test/unit/agent-history-readers.test.ts index 8524185..6ddb976 100644 --- a/test/unit/agent-history-readers.test.ts +++ b/test/unit/agent-history-readers.test.ts @@ -265,7 +265,7 @@ describe("GeminiHistoryReader", () => { }); describe("AgyHistoryReader", () => { - test("reads user, assistant, and tool_calls messages in multi-turn conversation", async () => { + 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, @@ -293,14 +293,14 @@ describe("AgyHistoryReader", () => { source: "MODEL", type: "PLANNER_RESPONSE", created_at: "2026-08-20T10:00:01.000Z", - tool_calls: [{ name: "ctx_fs", args: { action: "ls", path: "/repo" } }], + tool_calls: [{ name: "list_dir", args: { path: "/repo" } }], }, { step_index: 2, source: "MODEL", - type: "GENERIC", + type: "LIST_DIRECTORY", created_at: "2026-08-20T10:00:02.000Z", - content: "some output", + content: "a.ts\nb.ts", }, { step_index: 3, @@ -328,10 +328,11 @@ describe("AgyHistoryReader", () => { }); expect(messages[1]).toMatchObject({ role: "tool_result", - toolName: "ctx_fs", - timestamp: "2026-08-20T10:00:01.000Z", + toolName: "list_dir", + timestamp: "2026-08-20T10:00:02.000Z", }); - expect(messages[1]?.compact?.text).toContain("ctx_fs"); + 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", @@ -347,11 +348,12 @@ describe("AgyHistoryReader", () => { 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("ctx_fs"); + expect(compact.lastToolResult?.toolName).toBe("list_dir"); + expect(compact.lastToolResult?.text).toBe("a.ts\nb.ts"); }); - test("handles compact history when all assistant responses are pure tool_calls", async () => { - const homeDir = await tempHome("shepherd-agy-toolonly-"); + 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, @@ -361,7 +363,159 @@ describe("AgyHistoryReader", () => { source: "USER_EXPLICIT", type: "USER_INPUT", created_at: "2026-08-20T10:00:00.000Z", - content: "do work", + 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, @@ -376,16 +530,25 @@ describe("AgyHistoryReader", () => { ); 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(2); - expect(compact.lastUserMessage?.text).toBe("do work"); + expect(compact.messageCount).toBe(1); + expect(compact.lastUserMessage?.text).toBe("interrupted task"); expect(compact.lastAssistantMessage).toBeNull(); - expect(compact.lastToolResult?.toolName).toBe("run_cmd"); + expect(compact.lastToolResult).toBeNull(); }); test("parses assistant messages with structured array content and ignores empty content on tool_calls", async () => { @@ -412,8 +575,15 @@ describe("AgyHistoryReader", () => { { step_index: 2, source: "MODEL", - type: "PLANNER_RESPONSE", + 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" }], }, ] @@ -429,7 +599,7 @@ describe("AgyHistoryReader", () => { 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" }); + expect(messages[1]).toMatchObject({ role: "tool_result", toolName: "run_cmd", text: "1" }); expect(messages[2]).toMatchObject({ role: "assistant", text: "hello array assistant" }); }); @@ -519,15 +689,8 @@ describe("AgyHistoryReader", () => { test("is registered in the default agent history service", async () => { const homeDir = await tempHome("shepherd-agy-service-"); - const transcriptDir = join( - homeDir, - ".gemini", - "antigravity-cli", - "brain", - "sess_service", - ".system_generated", - "logs", - ); + 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( @@ -540,10 +703,20 @@ describe("AgyHistoryReader", () => { })}\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: null, foregroundCwd: null }, + { agent: "agy", agentSession: null, cwd: "/repo", foregroundCwd: null }, { limit: 10 }, ), ).resolves.toMatchObject({ 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); } }); From 3006be6e267c00fc23373562d98efd4fb239104a Mon Sep 17 00:00:00 2001 From: dorokuma Date: Mon, 31 Aug 2026 10:42:46 +0800 Subject: [PATCH 5/6] fix(agent-history): filter orphan agy transcript entries by known tool execution types --- src/agent-history/agy-reader.ts | 40 ++++++++++- test/unit/agent-history-readers.test.ts | 90 +++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/agent-history/agy-reader.ts b/src/agent-history/agy-reader.ts index 9bd5f4b..24df168 100644 --- a/src/agent-history/agy-reader.ts +++ b/src/agent-history/agy-reader.ts @@ -24,6 +24,35 @@ const ignoredEntryTypes = new Set([ "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; @@ -95,7 +124,16 @@ export class AgyHistoryReader implements AgentHistoryReader { continue; } - const toolName = pendingToolCalls.shift() ?? inferToolName(type, entry.value); + let toolName: string; + if (pendingToolCalls.length > 0) { + toolName = pendingToolCalls.shift()!; + } else { + if (!isKnownToolExecutionType(type)) { + continue; + } + toolName = inferToolName(type, entry.value); + } + const text = textFromToolResult(entry.value); if (text !== null) { const isError = isToolResultError(entry.value); diff --git a/test/unit/agent-history-readers.test.ts b/test/unit/agent-history-readers.test.ts index 6ddb976..67ee356 100644 --- a/test/unit/agent-history-readers.test.ts +++ b/test/unit/agent-history-readers.test.ts @@ -724,4 +724,94 @@ describe("AgyHistoryReader", () => { 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" }); + }); }); + From 157a7d20626e8f74ea7fe7e17eeb385eeac473dc Mon Sep 17 00:00:00 2001 From: dorokuma Date: Mon, 31 Aug 2026 11:14:18 +0800 Subject: [PATCH 6/6] fix(agent-history): do not consume pending agy tool call queue on unknown entry types --- src/agent-history/agy-reader.ts | 12 ++-- test/unit/agent-history-readers.test.ts | 90 ++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/agent-history/agy-reader.ts b/src/agent-history/agy-reader.ts index 24df168..ca267c7 100644 --- a/src/agent-history/agy-reader.ts +++ b/src/agent-history/agy-reader.ts @@ -124,16 +124,12 @@ export class AgyHistoryReader implements AgentHistoryReader { continue; } - let toolName: string; - if (pendingToolCalls.length > 0) { - toolName = pendingToolCalls.shift()!; - } else { - if (!isKnownToolExecutionType(type)) { - continue; - } - toolName = inferToolName(type, entry.value); + 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); diff --git a/test/unit/agent-history-readers.test.ts b/test/unit/agent-history-readers.test.ts index 67ee356..7bc30db 100644 --- a/test/unit/agent-history-readers.test.ts +++ b/test/unit/agent-history-readers.test.ts @@ -813,5 +813,93 @@ describe("AgyHistoryReader", () => { 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" }); + }); +});