diff --git a/.entire/settings.json b/.entire/settings.json index 7cce559..4be4607 100644 --- a/.entire/settings.json +++ b/.entire/settings.json @@ -1,4 +1,5 @@ { + "external_agents": true, "enabled": true, "telemetry": true } diff --git a/.gitignore b/.gitignore index b8c813d..10863a6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ # Built binaries /agents/entire-agent-kiro/entire-agent-kiro +bin/ diff --git a/README.md b/README.md index 29ae511..997e3a8 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ External agents communicate with Entire CLI via subcommands that accept and retu | Agent | Directory | Status | |-------|-----------|--------| | [Kiro](agents/entire-agent-kiro/) | `agents/entire-agent-kiro/` | Implemented — hooks + transcript analysis | +| [Pi](agents/entire-agent-pi/) | `agents/entire-agent-pi/` | Implemented — hooks + transcript analysis + token calculation | See each agent's own README for setup and usage instructions. @@ -94,6 +95,7 @@ The lifecycle harness auto-discovers and builds all agents in `agents/` via `Tes ``` agents/ # Standalone external agent projects entire-agent-kiro/ # Kiro agent (Go binary) + entire-agent-pi/ # Pi agent (Go binary) e2e/ # Lifecycle integration harness .github/workflows/ # CI, including protocol compliance via external-agents-tests .claude/skills/entire-external-agent/ # Skill files (research, test-writer, implementer) diff --git a/agents/entire-agent-pi/.gitignore b/agents/entire-agent-pi/.gitignore new file mode 100644 index 0000000..915daa2 --- /dev/null +++ b/agents/entire-agent-pi/.gitignore @@ -0,0 +1,2 @@ +entire-agent-pi +.probe-pi-* diff --git a/agents/entire-agent-pi/AGENT.md b/agents/entire-agent-pi/AGENT.md new file mode 100644 index 0000000..69a6d53 --- /dev/null +++ b/agents/entire-agent-pi/AGENT.md @@ -0,0 +1,201 @@ +# Pi — External Agent Research + +## Verdict: COMPATIBLE + +Pi has a rich TypeScript extension system with lifecycle hooks, JSONL session storage with full transcript content (including tool calls, token usage, and actual assistant responses), and a non-interactive print mode. All necessary protocol subcommands can be implemented. + +## Static Checks +| Check | Result | Notes | +|-------|--------|-------| +| Binary present | PASS | `/opt/homebrew/bin/pi` (verified) | +| Help available | PASS | `pi --help` (verified) | +| Version info | PASS | v0.63.1 (verified) | +| Hook keywords | PASS | `extension` found in help (verified) | +| Session keywords | PASS | `session`, `resume`, `continue` found (verified) | +| Config directory | PASS | `~/.pi/agent` (verified) | +| Documentation | PASS | https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md | + +## Binary +- Name: `pi` +- Version: 0.63.1 +- Runtime: Node.js (`/usr/bin/env node` script) +- Package: `@mariozechner/pi-coding-agent` on npm +- Install: `npm install -g @mariozechner/pi-coding-agent` or Homebrew + +## Hook Mechanism +- Config format: TypeScript extension files (loaded via jiti, no compilation needed) +- Extension locations: + - Global: `~/.pi/agent/extensions/*.ts` or `~/.pi/agent/extensions/*/index.ts` + - Project-local: `.pi/extensions/*.ts` or `.pi/extensions/*/index.ts` +- Extension registration: default export function receiving `ExtensionAPI`, register handlers via `pi.on(event, handler)` +- Shell execution in extensions: `pi.exec(command, args, options?)` or `child_process` from Node.js +- Hot-reload: Extensions in auto-discovered locations can be hot-reloaded with `/reload` + +### Hook Names and Protocol Mapping +| Native Event Name | When It Fires | Protocol Event Type | Verified | +|------------------|---------------|---------------------|----------| +| `session_start` | Initial session load | 1 = SessionStart | Yes | +| `before_agent_start` | After user submits prompt, before agent loop | 2 = TurnStart | Yes | +| `turn_end` | Each LLM turn completes (tool use or final) | (internal, not a lifecycle event) | Yes | +| `agent_end` | Agent loop ends (all turns complete) | 3 = TurnEnd | Yes | +| `session_shutdown` | Process exit (Ctrl+C, Ctrl+D, or -p mode exit) | (cleanup only) | Yes | + +### Hook Input Format (Extension → Binary) +The TypeScript extension constructs JSON and passes it to `entire agent hook pi ` on stdin: + +**session_start** (verified): +```json +{ + "type": "session_start", + "cwd": "/path/to/repo", + "session_file": "/Users/.../.pi/agent/sessions//_.jsonl" +} +``` + +**before_agent_start** (verified): +```json +{ + "type": "before_agent_start", + "cwd": "/path/to/repo", + "session_file": "/Users/.../.pi/agent/sessions//_.jsonl", + "prompt": "user prompt text" +} +``` + +**agent_end** (verified): +```json +{ + "type": "agent_end", + "cwd": "/path/to/repo", + "session_file": "/Users/.../.pi/agent/sessions//_.jsonl", + "message_count": 4 +} +``` + +**session_shutdown** (verified): +```json +{ + "type": "session_shutdown" +} +``` + +## Session Management +- Session directory: `~/.pi/agent/sessions//` + - `PI_CODING_AGENT_DIR` env var overrides `~/.pi/agent` base +- Path encoding: absolute path with `/` → `-`, wrapped in `--` prefix/suffix + - Example: `/Users/nodo/work/repo` → `--Users-nodo-work-repo--` (verified) +- Session file pattern: `_.jsonl` + - Example: `2026-03-27T21-38-13-384Z_34567c89-98b3-4cc3-a76d-1a4a67193648.jsonl` (verified) +- Session ID source: UUID from the session file header entry (first line, `id` field) (verified) +- Session file format: JSONL (newline-delimited JSON, one entry per line) +- Resume mechanism: `pi --continue` (most recent) or `pi --session ` (specific file) + +## Transcript +- Location: `~/.pi/agent/sessions//_.jsonl` (verified) +- Format: JSONL with tree structure (entries have `id` and `parentId`) +- Version: 3 (verified from session header) + +### Entry Types (verified) +| Entry Type | Fields | Purpose | +|-----------|--------|---------| +| `session` | `version`, `id`, `timestamp`, `cwd` | Session header (first line) | +| `model_change` | `provider`, `modelId` | Model selection | +| `thinking_level_change` | `thinkingLevel` | Thinking level setting | +| `message` | `message` object with `role`, `content`, etc. | All messages | + +### Message Roles (verified) +| Role | Content Types | Key Fields | +|------|--------------|------------| +| `user` | `text` | `content[].text` | +| `assistant` | `text`, `toolCall`, `thinking` | `content[]`, `usage`, `stopReason`, `model`, `provider` | +| `toolResult` | `text` | `toolCallId`, `toolName`, `content[]`, `isError` | + +### Tool Call Format (verified) +```json +{ + "type": "toolCall", + "id": "toolu_01WcS7KmFVQoiYd9h9gavbxs", + "name": "write", + "arguments": {"path": "hello.txt", "content": "hello world\n"} +} +``` + +File-modifying tools: +- `write`: `arguments.path` (file path), `arguments.content` (file content) +- `edit`: `arguments.path` (file path), `arguments.oldText` / `arguments.newText` or `arguments.edits[]` +- `bash`: may modify files but path extraction is unreliable + +### Token Usage Format (verified) +```json +{ + "input": 2572, + "output": 73, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 2645, + "cost": {"input": 0.01286, "output": 0.001825, "cacheRead": 0, "cacheWrite": 0, "total": 0.014685} +} +``` + +## Data Storage Verification +- Session files contain actual assistant content: **YES** (verified — full response text, tool calls with arguments, thinking blocks) +- Secondary storage location: **none needed** — all data is in the JSONL session file +- Hook data flow verified: **YES** — extension receives event data, ctx provides session file path and cwd +- Verification method: Ran `pi -p` with a known prompt, inspected JSONL session file for actual tool call arguments and response text + +## Protocol Mapping +| Subcommand | Native Concept | Implementation Notes | Feasibility | +|-----------|---------------|---------------------|-------------| +| `info` | static metadata | Return name "pi", type "Pi", capabilities | Required | +| `detect` | `pi` binary | Check `command -v pi` or `.pi/` in repo | Required | +| `get-session-id` | session header UUID | Extract from hook input `session_file` path (UUID after `_`) or read first line of JSONL | Required | +| `get-session-dir` | `.entire/tmp` | Use default Entire session dir | Required | +| `resolve-session-file` | `.entire/tmp/.json` | Standard path resolution | Required | +| `read-session` | JSONL transcript | Read Pi's JSONL, build AgentSession with native_data | Required | +| `write-session` | cached transcript | Write normalized session data to session ref | Required | +| `read-transcript` | JSONL file bytes | Read raw bytes from Pi session or cached `.entire/tmp/.json` | Required | +| `chunk-transcript` | raw bytes | Base64 chunk by max size | Required | +| `reassemble-transcript` | base64 chunks | Reassemble chunks | Required | +| `format-resume-command` | `pi --continue` | Return `pi --continue` or `pi --session ` | Required | +| `parse-hook` | extension event JSON | Map extension JSON to EventJSON (type 1/2/3) | Hooks | +| `install-hooks` | `.pi/extensions/entire/` | Write TypeScript extension that calls `entire agent hook pi ` | Hooks | +| `uninstall-hooks` | remove extension dir | Delete `.pi/extensions/entire/` | Hooks | +| `are-hooks-installed` | check extension exists | Check for `.pi/extensions/entire/index.ts` | Hooks | +| `get-transcript-position` | file size | Return byte count of transcript file | Transcript analyzer | +| `extract-modified-files` | tool call parsing | Extract `path` from `write` and `edit` tool calls in JSONL | Transcript analyzer | +| `extract-prompts` | user message parsing | Extract text from `role: "user"` messages | Transcript analyzer | +| `extract-summary` | last assistant text | Extract last `role: "assistant"` text content | Transcript analyzer | + +## Selected Capabilities +| Capability | Declared | Justification | +|-----------|----------|---------------| +| hooks | true | Pi has a TypeScript extension system with lifecycle events (verified) | +| transcript_analyzer | true | JSONL transcripts contain full structured data — tool calls, prompts, responses (verified) | +| transcript_preparer | false | JSONL files are directly readable, no pre-processing needed | +| token_calculator | true | Assistant messages contain `usage` with input/output/cache tokens (verified) | +| text_generator | false | Pi CLI is used for agent execution, not standalone text generation | +| hook_response_writer | false | No mechanism for writing structured responses back through hooks | +| subagent_aware_extractor | false | Pi does not expose a subagent transcript tree | + +## Gaps & Limitations +- **Extension-based hooks**: Unlike agents with native JSON hook configs, Pi requires a TypeScript extension file. The extension must use `child_process.execFileSync` to call the `entire` binary, which adds Node.js as a runtime dependency for hooks. +- **Session directory discovery**: The path encoding scheme (`--` prefix/suffix, `/` → `-`) must be reimplemented in Go to locate session files. The encoding is verified but not documented by Pi — it could change in future versions. +- **No native `agentSpawn` equivalent**: Pi's `session_start` fires on every session load (including resume), not just new sessions. The binary must differentiate by checking whether the session file existed before. +- **Print mode limitations**: `pi -p` exits after one prompt. For multi-turn testing, interactive mode with `--continue` is needed. +- **No token cost breakdown by model**: The `usage.cost` field is present but the protocol's `TokenUsageResponse` uses raw token counts, not costs. + +## Captured Payloads +- Verification script: `agents/entire-agent-pi/scripts/verify-pi.sh` +- Capture directory: `agents/entire-agent-pi/.probe-pi-*/captures/` +- Verification status: **VERIFIED** — script ran, all 5 lifecycle events captured +- Notable differences from docs: None — all events fire as documented with expected data + +## E2E Test Prerequisites +- Entire CLI binary: `entire` from PATH or `E2E_ENTIRE_BIN` env var +- Agent CLI binary: `pi` (Node.js, installed via npm or Homebrew) +- Non-interactive prompt command: `pi -p '' --no-skills --no-prompt-templates --no-themes` +- Interactive mode: Supported — `pi` launches interactive TUI, `pi --continue` resumes +- Expected prompt pattern: `>` (the Pi prompt indicator) +- Timeout multiplier: 1.5 (Node.js startup + LLM API calls) +- Bootstrap steps: API key must be set (e.g., `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, or other provider key) +- Transient error patterns: `"overloaded"`, `"rate limit"`, `"429"`, `"503"`, `"ECONNRESET"`, `"ETIMEDOUT"`, `"timeout"` diff --git a/agents/entire-agent-pi/README.md b/agents/entire-agent-pi/README.md new file mode 100644 index 0000000..1ef00c7 --- /dev/null +++ b/agents/entire-agent-pi/README.md @@ -0,0 +1,76 @@ +# entire-agent-pi + +External agent binary that teaches the [Entire CLI](https://github.com/entireio/cli) how to work with [Pi](https://pi.dev), the AI coding agent. + +## Capabilities + +| Capability | Status | +|-----------|--------| +| hooks | Yes — TypeScript extension in `.pi/extensions/entire/` | +| transcript_analyzer | Yes — parses JSONL session files for prompts, files, summary | +| token_calculator | Yes — sums token usage from assistant messages | + +## Installation + +Build the binary and place it on your `PATH`: + +```bash +cd agents/entire-agent-pi +go build -o entire-agent-pi ./cmd/entire-agent-pi +cp entire-agent-pi /usr/local/bin/ +``` + +Or use mise: + +```bash +cd agents/entire-agent-pi +mise run build +``` + +## Prerequisites + +- [Pi](https://pi.dev) CLI installed (`pi` on PATH) +- An LLM provider API key configured for Pi (e.g., `ANTHROPIC_API_KEY`) + +## How It Works + +### Hooks + +`install-hooks` creates a TypeScript extension at `.pi/extensions/entire/index.ts` that intercepts Pi lifecycle events and forwards them to `entire agent hook pi `: + +| Pi Event | Protocol Event | +|----------|---------------| +| `session_start` | SessionStart (type 1) | +| `before_agent_start` | TurnStart (type 2) | +| `agent_end` | TurnEnd (type 3) | +| `session_shutdown` | (cleanup, no protocol event) | + +### Transcripts + +Pi stores sessions as JSONL files at `~/.pi/agent/sessions//`. The binary reads these directly for transcript analysis, extracting: + +- Modified files from `write` and `edit` tool calls +- User prompts from `role: "user"` messages +- Summary from the last assistant text response +- Token usage from assistant message `usage` fields + +### Session Management + +Session files are cached in `.entire/tmp/.json` as required by the Entire protocol. The session ID is the UUID from the Pi session filename. + +## Development + +```bash +# Build +go build -o entire-agent-pi ./cmd/entire-agent-pi + +# Unit tests +go test ./... + +# Protocol compliance +external-agents-tests verify ./entire-agent-pi + +# E2E lifecycle tests (requires entire CLI and pi on PATH) +cd ../../ +E2E_AGENT=pi mise run test-e2e +``` diff --git a/agents/entire-agent-pi/cmd/entire-agent-pi/main.go b/agents/entire-agent-pi/cmd/entire-agent-pi/main.go new file mode 100644 index 0000000..695ca14 --- /dev/null +++ b/agents/entire-agent-pi/cmd/entire-agent-pi/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "os" + + "github.com/entireio/external-agents/agents/entire-agent-pi/internal/pi" + "github.com/entireio/external-agents/agents/entire-agent-pi/internal/protocol" +) + +func main() { + agent := pi.New() + + if len(os.Args) < 2 { + fatalf("usage: entire-agent-pi [args]") + } + + var err error + + switch os.Args[1] { + case "info": + err = protocol.WriteJSON(os.Stdout, agent.Info()) + case "detect": + err = protocol.WriteJSON(os.Stdout, agent.Detect()) + case "get-session-id": + err = protocol.HandleGetSessionID(os.Stdin, os.Stdout, agent) + case "get-session-dir": + err = protocol.HandleGetSessionDir(os.Args[2:], os.Stdout, agent) + case "resolve-session-file": + err = protocol.HandleResolveSessionFile(os.Args[2:], os.Stdout, agent) + case "read-session": + err = protocol.HandleReadSession(os.Stdin, os.Stdout, agent) + case "write-session": + err = protocol.HandleWriteSession(os.Stdin, agent) + case "read-transcript": + err = protocol.HandleReadTranscript(os.Args[2:], os.Stdout, agent) + case "chunk-transcript": + err = protocol.HandleChunkTranscript(os.Args[2:], os.Stdin, os.Stdout, agent) + case "reassemble-transcript": + err = protocol.HandleReassembleTranscript(os.Stdin, os.Stdout, agent) + case "format-resume-command": + err = protocol.HandleFormatResumeCommand(os.Args[2:], os.Stdout, agent) + case "parse-hook": + err = protocol.HandleParseHook(os.Args[2:], os.Stdin, os.Stdout, agent) + case "install-hooks": + err = protocol.HandleInstallHooks(os.Args[2:], os.Stdout, agent) + case "uninstall-hooks": + err = agent.UninstallHooks() + case "are-hooks-installed": + err = protocol.WriteJSON(os.Stdout, protocol.AreHooksInstalledResponse{ + Installed: agent.AreHooksInstalled(), + }) + case "get-transcript-position": + err = protocol.HandleGetTranscriptPosition(os.Args[2:], os.Stdout, agent) + case "extract-modified-files": + err = protocol.HandleExtractModifiedFiles(os.Args[2:], os.Stdout, agent) + case "extract-prompts": + err = protocol.HandleExtractPrompts(os.Args[2:], os.Stdout, agent) + case "extract-summary": + err = protocol.HandleExtractSummary(os.Args[2:], os.Stdout, agent) + case "calculate-tokens": + err = protocol.HandleCalculateTokens(os.Args[2:], os.Stdin, os.Stdout, agent) + default: + fatalf("unknown subcommand: %s", os.Args[1]) + } + + if err != nil { + fatalf("%v", err) + } +} + +func fatalf(format string, args ...any) { + _, _ = fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/agents/entire-agent-pi/go.mod b/agents/entire-agent-pi/go.mod new file mode 100644 index 0000000..dcb06d4 --- /dev/null +++ b/agents/entire-agent-pi/go.mod @@ -0,0 +1,3 @@ +module github.com/entireio/external-agents/agents/entire-agent-pi + +go 1.26.0 diff --git a/agents/entire-agent-pi/internal/pi/agent.go b/agents/entire-agent-pi/internal/pi/agent.go new file mode 100644 index 0000000..3ecfd03 --- /dev/null +++ b/agents/entire-agent-pi/internal/pi/agent.go @@ -0,0 +1,47 @@ +package pi + +import ( + "os/exec" + + "github.com/entireio/external-agents/agents/entire-agent-pi/internal/protocol" +) + +type Agent struct{} + +func New() *Agent { + return &Agent{} +} + +func (a *Agent) Info() protocol.InfoResponse { + return protocol.InfoResponse{ + ProtocolVersion: protocol.ProtocolVersion, + Name: "pi", + Type: "Pi", + Description: "Pi coding agent integration for Entire", + IsPreview: true, + ProtectedDirs: []string{".pi"}, + HookNames: []string{"session_start", "before_agent_start", "agent_end", "session_shutdown"}, + Capabilities: protocol.DeclaredCapabilities{ + Hooks: true, + TranscriptAnalyzer: true, + TokenCalculator: true, + UsesTerminal: true, + }, + } +} + +func (a *Agent) Detect() protocol.DetectResponse { + _, err := exec.LookPath("pi") + return protocol.DetectResponse{Present: err == nil} +} + +func (a *Agent) GetSessionID(input *protocol.HookInputJSON) string { + if input != nil && input.SessionID != "" { + return input.SessionID + } + return "" +} + +func (a *Agent) FormatResumeCommand(_ string) string { + return "pi --continue" +} diff --git a/agents/entire-agent-pi/internal/pi/hooks.go b/agents/entire-agent-pi/internal/pi/hooks.go new file mode 100644 index 0000000..187c2c1 --- /dev/null +++ b/agents/entire-agent-pi/internal/pi/hooks.go @@ -0,0 +1,250 @@ +package pi + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/entireio/external-agents/agents/entire-agent-pi/internal/protocol" +) + +const ( + extensionDir = ".pi/extensions/entire" + extensionFile = ".pi/extensions/entire/index.ts" + activeSessionFile = "pi-active-session" +) + +// piHookPayload is the JSON the TypeScript extension sends to +// `entire agent hook pi `, which arrives on stdin of parse-hook. +type piHookPayload struct { + Type string `json:"type"` + Cwd string `json:"cwd,omitempty"` + SessionFile string `json:"session_file,omitempty"` + SessionID string `json:"session_id,omitempty"` + Prompt string `json:"prompt,omitempty"` + MessageCount int `json:"message_count,omitempty"` + TurnIndex int `json:"turn_index,omitempty"` +} + +func (a *Agent) ParseHook(hookName string, input []byte) (*protocol.EventJSON, error) { + if len(input) == 0 { + return nil, nil + } + + var payload piHookPayload + if err := json.Unmarshal(input, &payload); err != nil { + return nil, fmt.Errorf("parse hook payload: %w", err) + } + + sessionID := payload.SessionID + if sessionID == "" { + sessionID = extractSessionIDFromPath(payload.SessionFile) + } + + now := time.Now().UTC().Format(time.RFC3339) + + switch hookName { + case "session_start": + cacheSessionID(sessionID) + return &protocol.EventJSON{ + Type: 1, // SessionStart + SessionID: sessionID, + Timestamp: now, + }, nil + + case "before_agent_start": + if sessionID == "" { + sessionID = readCachedSessionID() + } else { + cacheSessionID(sessionID) + } + // Provide the live pi session file as SessionRef so that + // state.TranscriptPath is populated before any mid-turn commits. + // Without this, the post-commit hook cannot condense when no + // shadow branch exists yet (no prior step checkpoints). + return &protocol.EventJSON{ + Type: 2, // TurnStart + SessionID: sessionID, + SessionRef: payload.SessionFile, + Prompt: payload.Prompt, + Timestamp: now, + }, nil + + case "agent_end": + if sessionID == "" { + sessionID = readCachedSessionID() + } + sessionRef := captureTranscript(sessionID, payload.SessionFile) + return &protocol.EventJSON{ + Type: 3, // TurnEnd + SessionID: sessionID, + SessionRef: sessionRef, + Timestamp: now, + }, nil + + case "session_shutdown": + clearCachedSessionID() + return nil, nil + + default: + return nil, nil + } +} + +func (a *Agent) InstallHooks(_ bool, force bool) (int, error) { + root := protocol.RepoRoot() + + // If already installed and not forcing, return 0 (idempotent no-op). + if !force && a.AreHooksInstalled() { + return 0, nil + } + + dir := filepath.Join(root, extensionDir) + if err := os.MkdirAll(dir, 0o750); err != nil { + return 0, fmt.Errorf("create extension dir: %w", err) + } + + ext := generateExtension() + + path := filepath.Join(root, extensionFile) + if err := os.WriteFile(path, []byte(ext), 0o600); err != nil { + return 0, fmt.Errorf("write extension: %w", err) + } + + return 4, nil // 4 hooks: session_start, before_agent_start, agent_end, session_shutdown +} + +func (a *Agent) UninstallHooks() error { + root := protocol.RepoRoot() + dir := filepath.Join(root, extensionDir) + return os.RemoveAll(dir) +} + +func (a *Agent) AreHooksInstalled() bool { + root := protocol.RepoRoot() + path := filepath.Join(root, extensionFile) + _, err := os.Stat(path) + return err == nil +} + +func generateExtension() string { + return `import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { execFileSync } from "node:child_process"; + +export default function (pi: ExtensionAPI) { + function fireHook(hookName: string, data: Record) { + try { + const json = JSON.stringify(data); + execFileSync("entire", ["hooks", "pi", hookName], { + input: json, + timeout: 10000, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch { + // best effort — don't block the agent + } + } + + pi.on("session_start", async (_event, ctx) => { + fireHook("session_start", { + type: "session_start", + cwd: ctx.cwd, + session_file: ctx.sessionManager.getSessionFile(), + }); + }); + + pi.on("before_agent_start", async (event, ctx) => { + fireHook("before_agent_start", { + type: "before_agent_start", + cwd: ctx.cwd, + session_file: ctx.sessionManager.getSessionFile(), + prompt: event.prompt, + }); + }); + + pi.on("agent_end", async (_event, ctx) => { + fireHook("agent_end", { + type: "agent_end", + cwd: ctx.cwd, + session_file: ctx.sessionManager.getSessionFile(), + }); + }); + + pi.on("session_shutdown", async () => { + fireHook("session_shutdown", { + type: "session_shutdown", + }); + }); +} +` +} + +// cacheSessionID writes the session ID to .entire/tmp/pi-active-session. +func cacheSessionID(id string) { + if id == "" { + return + } + dir := protocol.DefaultSessionDir(protocol.RepoRoot()) + _ = os.MkdirAll(dir, 0o750) + _ = os.WriteFile(filepath.Join(dir, activeSessionFile), []byte(id), 0o600) +} + +// readCachedSessionID reads the cached session ID. +func readCachedSessionID() string { + dir := protocol.DefaultSessionDir(protocol.RepoRoot()) + data, err := os.ReadFile(filepath.Join(dir, activeSessionFile)) + if err != nil { + return "" + } + return string(data) +} + +// clearCachedSessionID removes the cached session ID file. +func clearCachedSessionID() { + dir := protocol.DefaultSessionDir(protocol.RepoRoot()) + _ = os.Remove(filepath.Join(dir, activeSessionFile)) +} + +// captureTranscript copies the Pi JSONL session file to .entire/tmp/.json +// so that Entire can read the transcript. Returns the path to the cached file. +func captureTranscript(sessionID, piSessionFile string) string { + if sessionID == "" || piSessionFile == "" { + return "" + } + dir := protocol.DefaultSessionDir(protocol.RepoRoot()) + _ = os.MkdirAll(dir, 0o750) + dst := filepath.Join(dir, sessionID+".json") + + data, err := os.ReadFile(piSessionFile) + if err != nil { + fmt.Fprintf(os.Stderr, "entire-agent-pi: capture transcript: read %s: %v\n", piSessionFile, err) + return "" + } + if err := os.WriteFile(dst, data, 0o600); err != nil { + fmt.Fprintf(os.Stderr, "entire-agent-pi: capture transcript: write %s: %v\n", dst, err) + return "" + } + return dst +} + +// extractSessionIDFromPath extracts the UUID from a Pi session filename. +// Pattern: _.jsonl → returns +func extractSessionIDFromPath(path string) string { + if path == "" { + return "" + } + base := filepath.Base(path) + // Remove .jsonl extension + if len(base) > 6 && base[len(base)-6:] == ".jsonl" { + base = base[:len(base)-6] + } + // Find the UUID after the last underscore + for i := len(base) - 1; i >= 0; i-- { + if base[i] == '_' { + return base[i+1:] + } + } + return base +} diff --git a/agents/entire-agent-pi/internal/pi/hooks_test.go b/agents/entire-agent-pi/internal/pi/hooks_test.go new file mode 100644 index 0000000..3f3b435 --- /dev/null +++ b/agents/entire-agent-pi/internal/pi/hooks_test.go @@ -0,0 +1,227 @@ +package pi + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/entireio/external-agents/agents/entire-agent-pi/internal/protocol" +) + +func TestExtractSessionIDFromPath(t *testing.T) { + tests := []struct { + path string + want string + }{ + { + path: "/Users/test/.pi/agent/sessions/--Users-test--/2026-03-27T21-38-13-384Z_34567c89-98b3-4cc3-a76d-1a4a67193648.jsonl", + want: "34567c89-98b3-4cc3-a76d-1a4a67193648", + }, + { + path: "session_abc123.jsonl", + want: "abc123", + }, + { + path: "no-underscore.jsonl", + want: "no-underscore", + }, + { + path: "", + want: "", + }, + } + + for _, tt := range tests { + got := extractSessionIDFromPath(tt.path) + if got != tt.want { + t.Errorf("extractSessionIDFromPath(%q) = %q, want %q", tt.path, got, tt.want) + } + } +} + +func TestParseHook_SessionStart(t *testing.T) { + agent := New() + payload := `{"type":"session_start","cwd":"/test","session_file":"/tmp/2026-01-01T00-00-00-000Z_test-uuid.jsonl"}` + + event, err := agent.ParseHook("session_start", []byte(payload)) + if err != nil { + t.Fatal(err) + } + if event == nil { + t.Fatal("expected non-nil event") + } + if event.Type != 1 { + t.Errorf("Type = %d, want 1 (SessionStart)", event.Type) + } + if event.SessionID != "test-uuid" { + t.Errorf("SessionID = %q, want %q", event.SessionID, "test-uuid") + } +} + +func TestParseHook_TurnStart(t *testing.T) { + agent := New() + payload := `{"type":"before_agent_start","cwd":"/test","session_file":"/tmp/2026-01-01T00-00-00-000Z_test-uuid.jsonl","prompt":"hello"}` + + event, err := agent.ParseHook("before_agent_start", []byte(payload)) + if err != nil { + t.Fatal(err) + } + if event == nil { + t.Fatal("expected non-nil event") + } + if event.Type != 2 { + t.Errorf("Type = %d, want 2 (TurnStart)", event.Type) + } + if event.Prompt != "hello" { + t.Errorf("Prompt = %q, want %q", event.Prompt, "hello") + } +} + +func TestParseHook_TurnEnd(t *testing.T) { + agent := New() + payload := `{"type":"agent_end","cwd":"/test","session_file":"/tmp/2026-01-01T00-00-00-000Z_test-uuid.jsonl"}` + + event, err := agent.ParseHook("agent_end", []byte(payload)) + if err != nil { + t.Fatal(err) + } + if event == nil { + t.Fatal("expected non-nil event") + } + if event.Type != 3 { + t.Errorf("Type = %d, want 3 (TurnEnd)", event.Type) + } +} + +func TestParseHook_SessionShutdown(t *testing.T) { + agent := New() + payload := `{"type":"session_shutdown"}` + + event, err := agent.ParseHook("session_shutdown", []byte(payload)) + if err != nil { + t.Fatal(err) + } + if event != nil { + t.Error("expected nil event for session_shutdown") + } +} + +func TestParseHook_EmptyInput(t *testing.T) { + agent := New() + event, err := agent.ParseHook("session_start", nil) + if err != nil { + t.Fatal(err) + } + if event != nil { + t.Error("expected nil event for empty input") + } +} + +func TestParseHook_UnknownHook(t *testing.T) { + agent := New() + event, err := agent.ParseHook("unknown", []byte(`{"type":"unknown"}`)) + if err != nil { + t.Fatal(err) + } + if event != nil { + t.Error("expected nil event for unknown hook") + } +} + +func TestInstallAndUninstallHooks(t *testing.T) { + tmp := t.TempDir() + t.Setenv("ENTIRE_REPO_ROOT", tmp) + + agent := New() + + if agent.AreHooksInstalled() { + t.Error("hooks should not be installed initially") + } + + count, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatal(err) + } + if count != 4 { + t.Errorf("InstallHooks() = %d, want 4", count) + } + + if !agent.AreHooksInstalled() { + t.Error("hooks should be installed after InstallHooks") + } + + // Verify the extension file exists and contains expected content. + extPath := filepath.Join(tmp, extensionFile) + data, err := os.ReadFile(extPath) + if err != nil { + t.Fatal(err) + } + if len(data) == 0 { + t.Error("extension file is empty") + } + + // Idempotent install should return 0. + count, err = agent.InstallHooks(false, false) + if err != nil { + t.Fatal(err) + } + if count != 0 { + t.Errorf("idempotent InstallHooks() = %d, want 0", count) + } + + // Force install should return 4. + count, err = agent.InstallHooks(false, true) + if err != nil { + t.Fatal(err) + } + if count != 4 { + t.Errorf("forced InstallHooks() = %d, want 4", count) + } + + err = agent.UninstallHooks() + if err != nil { + t.Fatal(err) + } + + if agent.AreHooksInstalled() { + t.Error("hooks should not be installed after UninstallHooks") + } +} + +func TestParseHook_SessionIDFromInput(t *testing.T) { + agent := New() + payload, _ := json.Marshal(piHookPayload{ + Type: "session_start", + SessionID: "explicit-id", + }) + + event, err := agent.ParseHook("session_start", payload) + if err != nil { + t.Fatal(err) + } + if event.SessionID != "explicit-id" { + t.Errorf("SessionID = %q, want %q", event.SessionID, "explicit-id") + } +} + +func TestInfo(t *testing.T) { + agent := New() + info := agent.Info() + + if info.ProtocolVersion != protocol.ProtocolVersion { + t.Errorf("ProtocolVersion = %d, want %d", info.ProtocolVersion, protocol.ProtocolVersion) + } + if info.Name != "pi" { + t.Errorf("Name = %q, want %q", info.Name, "pi") + } + if !info.Capabilities.Hooks { + t.Error("expected hooks capability") + } + if !info.Capabilities.TranscriptAnalyzer { + t.Error("expected transcript_analyzer capability") + } + if !info.Capabilities.TokenCalculator { + t.Error("expected token_calculator capability") + } +} diff --git a/agents/entire-agent-pi/internal/pi/paths.go b/agents/entire-agent-pi/internal/pi/paths.go new file mode 100644 index 0000000..aff2086 --- /dev/null +++ b/agents/entire-agent-pi/internal/pi/paths.go @@ -0,0 +1,11 @@ +package pi + +import "github.com/entireio/external-agents/agents/entire-agent-pi/internal/protocol" + +func (a *Agent) GetSessionDir(repoPath string) (string, error) { + return protocol.DefaultSessionDir(repoPath), nil +} + +func (a *Agent) ResolveSessionFile(sessionDir, sessionID string) string { + return protocol.ResolveSessionFile(sessionDir, sessionID) +} diff --git a/agents/entire-agent-pi/internal/pi/transcript.go b/agents/entire-agent-pi/internal/pi/transcript.go new file mode 100644 index 0000000..4466e3b --- /dev/null +++ b/agents/entire-agent-pi/internal/pi/transcript.go @@ -0,0 +1,409 @@ +package pi + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/entireio/external-agents/agents/entire-agent-pi/internal/protocol" +) + +// Pi JSONL entry types +const entryTypeMessage = "message" + +// maxScannerLine is 1 MB — large enough for Pi JSONL lines that may +// contain thinking blocks or full file contents in tool call arguments. +const maxScannerLine = 1 << 20 + +func newJSONLScanner(data []byte) *bufio.Scanner { + s := bufio.NewScanner(bytes.NewReader(data)) + s.Buffer(make([]byte, 0, maxScannerLine), maxScannerLine) + return s +} + +// countLines returns the number of non-empty lines in data. +func countLines(data []byte) int { + if len(data) == 0 { + return 0 + } + n := bytes.Count(data, []byte{'\n'}) + // Count final line if it doesn't end with newline + if len(data) > 0 && data[len(data)-1] != '\n' { + n++ + } + return n +} + +// skipLines returns data with the first n lines removed. +func skipLines(data []byte, n int) []byte { + if n <= 0 { + return data + } + off := 0 + for i := 0; i < n && off < len(data); i++ { + idx := bytes.IndexByte(data[off:], '\n') + if idx < 0 { + return nil // fewer lines than offset + } + off += idx + 1 + } + return data[off:] +} + +type sessionEntry struct { + Type string `json:"type"` + Version int `json:"version"` + ID string `json:"id"` + Timestamp string `json:"timestamp"` + Cwd string `json:"cwd"` +} + +type messageEntry struct { + Type string `json:"type"` + ID string `json:"id"` + Message message `json:"message"` +} + +type message struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + Timestamp json.Number `json:"timestamp"` + Usage *tokenUsage `json:"usage,omitempty"` + StopReason string `json:"stopReason,omitempty"` +} + +type tokenUsage struct { + Input int `json:"input"` + Output int `json:"output"` + CacheRead int `json:"cacheRead"` + CacheWrite int `json:"cacheWrite"` +} + +type contentItem struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Name string `json:"name,omitempty"` + ID string `json:"id,omitempty"` + Arguments json.RawMessage `json:"arguments,omitempty"` +} + +// resolveActiveBranch parses JSONL data and returns the set of entry IDs on +// the active conversation branch. Pi transcripts form a tree (entries have id +// and parentId); the active branch is the path from the root to the last +// message entry in the file. +// Returns nil if the transcript has no tree structure or cannot be resolved. +func resolveActiveBranch(data []byte) map[string]bool { + type node struct { + Type string `json:"type"` + ID string `json:"id"` + ParentID *string `json:"parentId"` + } + + var lastMessageID string + hasTree := false + parentOf := make(map[string]string) + + scanner := newJSONLScanner(data) + for scanner.Scan() { + var n node + if err := json.Unmarshal(scanner.Bytes(), &n); err != nil || n.ID == "" { + continue + } + if n.ParentID != nil { + parentOf[n.ID] = *n.ParentID + if *n.ParentID != "" { + hasTree = true + } + } + if n.Type == entryTypeMessage { + lastMessageID = n.ID + } + } + + // No tree references — all entries are on the active branch. + if !hasTree || lastMessageID == "" { + return nil + } + + active := make(map[string]bool) + for cur := lastMessageID; cur != ""; { + if active[cur] { + break // cycle protection + } + active[cur] = true + parent, ok := parentOf[cur] + if !ok { + break + } + cur = parent + } + + return active +} + +func (a *Agent) ReadSession(input *protocol.HookInputJSON) (protocol.AgentSessionJSON, error) { + sessionRef := input.SessionRef + if sessionRef == "" { + return protocol.AgentSessionJSON{}, errors.New("session_ref is required") + } + + data, err := os.ReadFile(sessionRef) + if err != nil { + return protocol.AgentSessionJSON{}, fmt.Errorf("read session file: %w", err) + } + + sessionID := input.SessionID + if sessionID == "" { + sessionID = extractSessionIDFromPath(sessionRef) + } + + var startTime string + scanner := newJSONLScanner(data) + for scanner.Scan() { + var entry sessionEntry + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + continue + } + if entry.Type == "session" { + startTime = entry.Timestamp + if sessionID == "" { + sessionID = entry.ID + } + break + } + } + + return protocol.AgentSessionJSON{ + SessionID: sessionID, + AgentName: "pi", + RepoPath: protocol.RepoRoot(), + SessionRef: sessionRef, + StartTime: startTime, + NativeData: data, + ModifiedFiles: []string{}, + NewFiles: []string{}, + DeletedFiles: []string{}, + }, nil +} + +func (a *Agent) WriteSession(session protocol.AgentSessionJSON) error { + if session.SessionRef == "" { + return errors.New("session_ref is required") + } + if err := os.MkdirAll(filepath.Dir(session.SessionRef), 0o700); err != nil { + return err + } + return os.WriteFile(session.SessionRef, session.NativeData, 0o600) +} + +func (a *Agent) ReadTranscript(sessionRef string) ([]byte, error) { + return os.ReadFile(sessionRef) +} + +func (a *Agent) ChunkTranscript(content []byte, maxSize int) ([][]byte, error) { + if maxSize <= 0 { + return nil, fmt.Errorf("max-size must be positive, got %d", maxSize) + } + var chunks [][]byte + for len(content) > 0 { + end := maxSize + if end > len(content) { + end = len(content) + } + chunks = append(chunks, content[:end]) + content = content[end:] + } + return chunks, nil +} + +func (a *Agent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + var data []byte + for _, chunk := range chunks { + data = append(data, chunk...) + } + return data, nil +} + +// GetTranscriptPosition returns the number of lines in the JSONL transcript. +// The CLI uses this value as the offset for ExtractModifiedFiles, so units +// must match: both use line count (consistent with Claude Code). +func (a *Agent) GetTranscriptPosition(path string) (int, error) { + data, err := os.ReadFile(path) //nolint:gosec // path from session state + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, err + } + return countLines(data), nil +} + +func (a *Agent) ExtractModifiedFiles(path string, offset int) ([]string, int, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, 0, err + } + + totalLines := countLines(data) + active := resolveActiveBranch(data) + + content := skipLines(data, offset) + + seen := make(map[string]bool) + var files []string + + scanner := newJSONLScanner(content) + for scanner.Scan() { + var entry messageEntry + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + continue + } + if entry.Type != entryTypeMessage { + continue + } + if active != nil && !active[entry.ID] { + continue + } + if entry.Message.Role != "assistant" { + continue + } + + var items []contentItem + if err := json.Unmarshal(entry.Message.Content, &items); err != nil { + continue + } + + for _, item := range items { + if item.Type != "toolCall" { + continue + } + if item.Name != "write" && item.Name != "edit" { + continue + } + var args struct { + Path string `json:"path"` + } + if err := json.Unmarshal(item.Arguments, &args); err != nil { + continue + } + if args.Path != "" && !seen[args.Path] { + seen[args.Path] = true + files = append(files, args.Path) + } + } + } + + return files, totalLines, nil +} + +func (a *Agent) ExtractPrompts(sessionRef string, offset int) ([]string, error) { + data, err := os.ReadFile(sessionRef) + if err != nil { + return nil, err + } + + active := resolveActiveBranch(data) + + content := skipLines(data, offset) + + var prompts []string + scanner := newJSONLScanner(content) + for scanner.Scan() { + var entry messageEntry + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + continue + } + if entry.Type != entryTypeMessage || entry.Message.Role != "user" { + continue + } + if active != nil && !active[entry.ID] { + continue + } + + var items []contentItem + if err := json.Unmarshal(entry.Message.Content, &items); err != nil { + continue + } + + for _, item := range items { + if item.Type == "text" && item.Text != "" { + prompts = append(prompts, item.Text) + } + } + } + + return prompts, nil +} + +func (a *Agent) ExtractSummary(sessionRef string) (string, bool, error) { + data, err := os.ReadFile(sessionRef) + if err != nil { + return "", false, err + } + + active := resolveActiveBranch(data) + + var lastAssistantText string + scanner := newJSONLScanner(data) + for scanner.Scan() { + var entry messageEntry + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + continue + } + if entry.Type != entryTypeMessage || entry.Message.Role != "assistant" { + continue + } + if active != nil && !active[entry.ID] { + continue + } + + var items []contentItem + if err := json.Unmarshal(entry.Message.Content, &items); err != nil { + continue + } + + for _, item := range items { + if item.Type == "text" && item.Text != "" { + lastAssistantText = item.Text + } + } + } + + if lastAssistantText != "" { + return lastAssistantText, true, nil + } + return "", false, nil +} + +func (a *Agent) CalculateTokens(data []byte, offset int) (protocol.TokenUsageResponse, error) { + active := resolveActiveBranch(data) + + content := skipLines(data, offset) + + var result protocol.TokenUsageResponse + scanner := newJSONLScanner(content) + for scanner.Scan() { + var entry messageEntry + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + continue + } + if entry.Type != entryTypeMessage || entry.Message.Role != "assistant" || entry.Message.Usage == nil { + continue + } + if active != nil && !active[entry.ID] { + continue + } + + result.InputTokens += entry.Message.Usage.Input + result.OutputTokens += entry.Message.Usage.Output + result.CacheReadTokens += entry.Message.Usage.CacheRead + result.CacheCreationTokens += entry.Message.Usage.CacheWrite + result.APICallCount++ + } + + return result, nil +} diff --git a/agents/entire-agent-pi/internal/pi/transcript_test.go b/agents/entire-agent-pi/internal/pi/transcript_test.go new file mode 100644 index 0000000..44cb309 --- /dev/null +++ b/agents/entire-agent-pi/internal/pi/transcript_test.go @@ -0,0 +1,774 @@ +package pi + +import ( + "os" + "path/filepath" + "testing" + + "github.com/entireio/external-agents/agents/entire-agent-pi/internal/protocol" +) + +const testSessionJSONL = `{"type":"session","version":3,"id":"test-uuid-123","timestamp":"2026-03-27T21:00:00.000Z","cwd":"/tmp/test"} +{"type":"model_change","id":"mc1","parentId":null,"timestamp":"2026-03-27T21:00:00.001Z","provider":"anthropic","modelId":"claude-sonnet-4-6"} +{"type":"message","id":"m1","parentId":"mc1","timestamp":"2026-03-27T21:00:01.000Z","message":{"role":"user","content":[{"type":"text","text":"Create hello.txt"}],"timestamp":1774646400000}} +{"type":"message","id":"m2","parentId":"m1","timestamp":"2026-03-27T21:00:02.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"tc1","name":"write","arguments":{"path":"hello.txt","content":"hello world\n"}}],"usage":{"input":100,"output":50,"cacheRead":10,"cacheWrite":5},"stopReason":"toolUse","timestamp":1774646401000}} +{"type":"message","id":"m3","parentId":"m2","timestamp":"2026-03-27T21:00:03.000Z","message":{"role":"toolResult","toolCallId":"tc1","toolName":"write","content":[{"type":"text","text":"Written 12 bytes"}],"isError":false,"timestamp":1774646402000}} +{"type":"message","id":"m4","parentId":"m3","timestamp":"2026-03-27T21:00:04.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Created hello.txt with the content hello world."}],"usage":{"input":200,"output":30,"cacheRead":0,"cacheWrite":0},"stopReason":"stop","timestamp":1774646403000}} +` + +// testBranchingSessionJSONL has two branches from m1: +// - abandoned: m2 (write old.txt) → m3 → m4 ("Created old.txt") +// - active: m5 (write new.txt) → m6 → m7 ("Created new.txt") +const testBranchingSessionJSONL = `{"type":"session","version":3,"id":"test-branch-123","timestamp":"2026-03-27T22:00:00.000Z","cwd":"/tmp/test"} +{"type":"model_change","id":"mc1","parentId":null,"timestamp":"2026-03-27T22:00:00.001Z","provider":"anthropic","modelId":"claude-sonnet-4-6"} +{"type":"message","id":"m1","parentId":"mc1","timestamp":"2026-03-27T22:00:01.000Z","message":{"role":"user","content":[{"type":"text","text":"Create a file"}],"timestamp":1774650000000}} +{"type":"message","id":"m2","parentId":"m1","timestamp":"2026-03-27T22:00:02.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"tc1","name":"write","arguments":{"path":"old.txt","content":"old\n"}}],"usage":{"input":100,"output":50,"cacheRead":0,"cacheWrite":0},"stopReason":"toolUse","timestamp":1774650001000}} +{"type":"message","id":"m3","parentId":"m2","timestamp":"2026-03-27T22:00:03.000Z","message":{"role":"toolResult","toolCallId":"tc1","toolName":"write","content":[{"type":"text","text":"Written 4 bytes"}],"isError":false,"timestamp":1774650002000}} +{"type":"message","id":"m4","parentId":"m3","timestamp":"2026-03-27T22:00:04.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Created old.txt"}],"usage":{"input":200,"output":30,"cacheRead":0,"cacheWrite":0},"stopReason":"stop","timestamp":1774650003000}} +{"type":"message","id":"m5","parentId":"m1","timestamp":"2026-03-27T22:00:05.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"tc2","name":"write","arguments":{"path":"new.txt","content":"new\n"}}],"usage":{"input":150,"output":60,"cacheRead":5,"cacheWrite":3},"stopReason":"toolUse","timestamp":1774650004000}} +{"type":"message","id":"m6","parentId":"m5","timestamp":"2026-03-27T22:00:06.000Z","message":{"role":"toolResult","toolCallId":"tc2","toolName":"write","content":[{"type":"text","text":"Written 4 bytes"}],"isError":false,"timestamp":1774650005000}} +{"type":"message","id":"m7","parentId":"m6","timestamp":"2026-03-27T22:00:07.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Created new.txt"}],"usage":{"input":250,"output":40,"cacheRead":0,"cacheWrite":0},"stopReason":"stop","timestamp":1774650006000}} +` + +func writeTestSession(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "2026-03-27T21-00-00-000Z_test-uuid-123.jsonl") + if err := os.WriteFile(path, []byte(testSessionJSONL), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestExtractModifiedFiles(t *testing.T) { + path := writeTestSession(t) + agent := New() + + files, pos, err := agent.ExtractModifiedFiles(path, 0) + if err != nil { + t.Fatal(err) + } + if len(files) != 1 || files[0] != "hello.txt" { + t.Errorf("files = %v, want [hello.txt]", files) + } + if pos == 0 { + t.Error("position should be > 0") + } +} + +func TestExtractModifiedFiles_WithOffset(t *testing.T) { + path := writeTestSession(t) + agent := New() + + // Use a large offset to skip all content. + info, _ := os.Stat(path) + files, _, err := agent.ExtractModifiedFiles(path, int(info.Size())) + if err != nil { + t.Fatal(err) + } + if len(files) != 0 { + t.Errorf("files = %v, want empty", files) + } +} + +func TestExtractPrompts(t *testing.T) { + path := writeTestSession(t) + agent := New() + + prompts, err := agent.ExtractPrompts(path, 0) + if err != nil { + t.Fatal(err) + } + if len(prompts) != 1 || prompts[0] != "Create hello.txt" { + t.Errorf("prompts = %v, want [Create hello.txt]", prompts) + } +} + +func TestExtractSummary(t *testing.T) { + path := writeTestSession(t) + agent := New() + + summary, has, err := agent.ExtractSummary(path) + if err != nil { + t.Fatal(err) + } + if !has { + t.Error("expected has_summary = true") + } + if summary != "Created hello.txt with the content hello world." { + t.Errorf("summary = %q", summary) + } +} + +func TestGetTranscriptPosition(t *testing.T) { + path := writeTestSession(t) + agent := New() + + pos, err := agent.GetTranscriptPosition(path) + if err != nil { + t.Fatal(err) + } + // testSessionJSONL has 6 lines (session, model_change, m1, m2, m3, m4) + if pos != 6 { + t.Errorf("position = %d, want 6", pos) + } +} + +func TestGetTranscriptPosition_Missing(t *testing.T) { + agent := New() + pos, err := agent.GetTranscriptPosition("/nonexistent/file.jsonl") + if err != nil { + t.Fatalf("expected no error for missing file, got: %v", err) + } + if pos != 0 { + t.Errorf("position = %d, want 0 for missing file", pos) + } +} + +func TestCalculateTokens(t *testing.T) { + path := writeTestSession(t) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + agent := New() + usage, err := agent.CalculateTokens(data, 0) + if err != nil { + t.Fatal(err) + } + + if usage.InputTokens != 300 { + t.Errorf("InputTokens = %d, want 300", usage.InputTokens) + } + if usage.OutputTokens != 80 { + t.Errorf("OutputTokens = %d, want 80", usage.OutputTokens) + } + if usage.CacheReadTokens != 10 { + t.Errorf("CacheReadTokens = %d, want 10", usage.CacheReadTokens) + } + if usage.CacheCreationTokens != 5 { + t.Errorf("CacheCreationTokens = %d, want 5", usage.CacheCreationTokens) + } + if usage.APICallCount != 2 { + t.Errorf("APICallCount = %d, want 2", usage.APICallCount) + } +} + +func TestCalculateTokens_OffsetAtEnd(t *testing.T) { + path := writeTestSession(t) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + agent := New() + + // offset == len(data): should return zero tokens + usage, err := agent.CalculateTokens(data, len(data)) + if err != nil { + t.Fatal(err) + } + if usage.InputTokens != 0 || usage.OutputTokens != 0 || usage.APICallCount != 0 { + t.Errorf("offset=len(data): got input=%d output=%d calls=%d, want all zero", + usage.InputTokens, usage.OutputTokens, usage.APICallCount) + } + + // offset > len(data): should also return zero tokens + usage, err = agent.CalculateTokens(data, len(data)+100) + if err != nil { + t.Fatal(err) + } + if usage.InputTokens != 0 || usage.OutputTokens != 0 || usage.APICallCount != 0 { + t.Errorf("offset>len(data): got input=%d output=%d calls=%d, want all zero", + usage.InputTokens, usage.OutputTokens, usage.APICallCount) + } +} + +func TestExtractPrompts_OffsetAtEnd(t *testing.T) { + path := writeTestSession(t) + data, _ := os.ReadFile(path) + agent := New() + + // offset > len(data): should return no prompts + prompts, err := agent.ExtractPrompts(path, len(data)+100) + if err != nil { + t.Fatal(err) + } + if len(prompts) != 0 { + t.Errorf("offset>len(data): got %d prompts, want 0", len(prompts)) + } +} + +func TestChunkAndReassemble(t *testing.T) { + agent := New() + data := []byte("hello world, this is a test") + + chunks, err := agent.ChunkTranscript(data, 10) + if err != nil { + t.Fatal(err) + } + if len(chunks) != 3 { + t.Errorf("chunks = %d, want 3", len(chunks)) + } + + reassembled, err := agent.ReassembleTranscript(chunks) + if err != nil { + t.Fatal(err) + } + if string(reassembled) != string(data) { + t.Errorf("reassembled = %q, want %q", reassembled, data) + } +} + +func TestChunkTranscript_InvalidMaxSize(t *testing.T) { + agent := New() + _, err := agent.ChunkTranscript([]byte("test"), 0) + if err == nil { + t.Error("expected error for max-size=0") + } +} + +func TestReadSession(t *testing.T) { + path := writeTestSession(t) + t.Setenv("ENTIRE_REPO_ROOT", t.TempDir()) + + agent := New() + session, err := agent.ReadSession(&protocol.HookInputJSON{ + SessionRef: path, + }) + if err != nil { + t.Fatal(err) + } + + if session.SessionID != "test-uuid-123" { + t.Errorf("SessionID = %q, want %q", session.SessionID, "test-uuid-123") + } + if session.AgentName != "pi" { + t.Errorf("AgentName = %q, want %q", session.AgentName, "pi") + } + if session.StartTime != "2026-03-27T21:00:00.000Z" { + t.Errorf("StartTime = %q", session.StartTime) + } + if len(session.NativeData) == 0 { + t.Error("NativeData should not be empty") + } + if session.ModifiedFiles == nil { + t.Error("ModifiedFiles must be initialized (not nil)") + } + if session.NewFiles == nil { + t.Error("NewFiles must be initialized (not nil)") + } + if session.DeletedFiles == nil { + t.Error("DeletedFiles must be initialized (not nil)") + } +} + +func TestWriteSession(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "session.json") + + agent := New() + data := []byte(`{"test":"data"}`) + + err := agent.WriteSession(protocol.AgentSessionJSON{ + SessionRef: path, + NativeData: data, + }) + if err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(data) { + t.Errorf("got %q, want %q", got, data) + } +} + +func writeJSONL(t *testing.T, name, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func writeBranchingSession(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "2026-03-27T22-00-00-000Z_test-branch-123.jsonl") + if err := os.WriteFile(path, []byte(testBranchingSessionJSONL), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestExtractModifiedFiles_Branching(t *testing.T) { + path := writeBranchingSession(t) + agent := New() + + files, _, err := agent.ExtractModifiedFiles(path, 0) + if err != nil { + t.Fatal(err) + } + // Only new.txt (active branch), not old.txt (abandoned branch). + if len(files) != 1 || files[0] != "new.txt" { + t.Errorf("files = %v, want [new.txt]", files) + } +} + +func TestExtractPrompts_Branching(t *testing.T) { + path := writeBranchingSession(t) + agent := New() + + prompts, err := agent.ExtractPrompts(path, 0) + if err != nil { + t.Fatal(err) + } + // m1 is on the active branch. + if len(prompts) != 1 || prompts[0] != "Create a file" { + t.Errorf("prompts = %v, want [Create a file]", prompts) + } +} + +func TestExtractSummary_Branching(t *testing.T) { + path := writeBranchingSession(t) + agent := New() + + summary, has, err := agent.ExtractSummary(path) + if err != nil { + t.Fatal(err) + } + if !has { + t.Fatal("expected has_summary = true") + } + // Active branch leaf, not abandoned branch. + if summary != "Created new.txt" { + t.Errorf("summary = %q, want %q", summary, "Created new.txt") + } +} + +func TestCalculateTokens_Branching(t *testing.T) { + data := []byte(testBranchingSessionJSONL) + agent := New() + + usage, err := agent.CalculateTokens(data, 0) + if err != nil { + t.Fatal(err) + } + + // Only m5 (input=150,output=60) and m7 (input=250,output=40) on active branch. + if usage.InputTokens != 400 { + t.Errorf("InputTokens = %d, want 400", usage.InputTokens) + } + if usage.OutputTokens != 100 { + t.Errorf("OutputTokens = %d, want 100", usage.OutputTokens) + } + if usage.CacheReadTokens != 5 { + t.Errorf("CacheReadTokens = %d, want 5", usage.CacheReadTokens) + } + if usage.CacheCreationTokens != 3 { + t.Errorf("CacheCreationTokens = %d, want 3", usage.CacheCreationTokens) + } + if usage.APICallCount != 2 { + t.Errorf("APICallCount = %d, want 2", usage.APICallCount) + } +} + +// --------------------------------------------------------------------------- +// resolveActiveBranch unit tests +// --------------------------------------------------------------------------- + +func TestResolveActiveBranch_LinearChain(t *testing.T) { + data := []byte(`{"type":"session","id":"s1"} +{"type":"model_change","id":"mc1","parentId":null} +{"type":"message","id":"m1","parentId":"mc1"} +{"type":"message","id":"m2","parentId":"m1"} +{"type":"message","id":"m3","parentId":"m2"} +`) + active := resolveActiveBranch(data) + for _, id := range []string{"m3", "m2", "m1", "mc1"} { + if !active[id] { + t.Errorf("expected %q in active set", id) + } + } + // session entry is not in the tree chain + if active["s1"] { + t.Error("session entry should not be in active set") + } +} + +func TestResolveActiveBranch_EmptyData(t *testing.T) { + active := resolveActiveBranch(nil) + if active != nil { + t.Errorf("expected nil for empty data, got %v", active) + } +} + +func TestResolveActiveBranch_SessionOnly(t *testing.T) { + data := []byte(`{"type":"session","id":"s1"} +`) + active := resolveActiveBranch(data) + if active != nil { + t.Errorf("expected nil when no message entries, got %v", active) + } +} + +func TestResolveActiveBranch_CycleProtection(t *testing.T) { + // a → b → a (cycle). Should terminate, not infinite loop. + data := []byte(`{"type":"message","id":"a","parentId":"b"} +{"type":"message","id":"b","parentId":"a"} +`) + active := resolveActiveBranch(data) + // Should contain both but not hang. + if !active["a"] || !active["b"] { + t.Errorf("active = %v, want both a and b", active) + } +} + +func TestResolveActiveBranch_SelfReferentialParent(t *testing.T) { + data := []byte(`{"type":"message","id":"m1","parentId":"m1"} +`) + active := resolveActiveBranch(data) + if !active["m1"] { + t.Error("expected m1 in active set") + } + if len(active) != 1 { + t.Errorf("expected 1 entry in active set, got %d", len(active)) + } +} + +func TestResolveActiveBranch_TwoBranches_PicksLast(t *testing.T) { + // Two branches from a: b (earlier) and c (later, last in file). + // Active branch should be c's path. + data := []byte(`{"type":"message","id":"a","parentId":"root"} +{"type":"message","id":"root","parentId":null} +{"type":"message","id":"b","parentId":"a"} +{"type":"message","id":"c","parentId":"a"} +`) + + active := resolveActiveBranch(data) + if !active["c"] || !active["a"] { + t.Errorf("expected c and a in active set, got %v", active) + } + if active["b"] { + t.Error("b should not be in active set") + } +} + +func TestResolveActiveBranch_FlatReturnsNil(t *testing.T) { + // No parentId at all — should skip tree resolution. + data := []byte(`{"type":"message","id":"m1"} +{"type":"message","id":"m2"} +`) + active := resolveActiveBranch(data) + if active != nil { + t.Errorf("expected nil for flat transcript, got %v", active) + } +} + +func TestResolveActiveBranch_NullParentIDOnly(t *testing.T) { + // All entries have parentId:null — no real tree. + data := []byte(`{"type":"message","id":"m1","parentId":null} +{"type":"message","id":"m2","parentId":null} +`) + active := resolveActiveBranch(data) + if active != nil { + t.Errorf("expected nil when only null parentIds, got %v", active) + } +} + +// --------------------------------------------------------------------------- +// Deep tree: branch off a branch +// --------------------------------------------------------------------------- +// Trailing non-message entry: model_change after last message +// --------------------------------------------------------------------------- + +func TestResolveActiveBranch_TrailingModelChange(t *testing.T) { + // The last entry is a model_change, not a message. + // resolveActiveBranch should use the last *message* as the leaf. + data := []byte(`{"type":"message","id":"m1","parentId":"mc1","message":{"role":"user","content":[{"type":"text","text":"hi"}]}} +{"type":"message","id":"mc1","parentId":null} +{"type":"message","id":"m2","parentId":"m1","message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}} +{"type":"model_change","id":"mc2","parentId":"m2","provider":"anthropic","modelId":"claude-opus-4-6"} +`) + active := resolveActiveBranch(data) + // Active branch should be m2→m1→mc1, resolved from last message (m2), not from mc2. + if !active["m2"] || !active["m1"] { + t.Errorf("expected m2 and m1 in active set, got %v", active) + } +} + +func TestTrailingModelChange_SummaryStillWorks(t *testing.T) { + jsonl := `{"type":"session","id":"s1"} +{"type":"model_change","id":"mc1","parentId":null} +{"type":"message","id":"m1","parentId":"mc1","message":{"role":"user","content":[{"type":"text","text":"hi"}]}} +{"type":"message","id":"m2","parentId":"m1","message":{"role":"assistant","content":[{"type":"text","text":"hello"}],"usage":{"input":10,"output":5,"cacheRead":0,"cacheWrite":0}}} +{"type":"model_change","id":"mc2","parentId":"m2"} +` + path := writeJSONL(t, "trailing.jsonl", jsonl) + agent := New() + + summary, has, err := agent.ExtractSummary(path) + if err != nil { + t.Fatal(err) + } + if !has || summary != "hello" { + t.Errorf("summary = %q, want %q", summary, "hello") + } + + usage, err := agent.CalculateTokens([]byte(jsonl), 0) + if err != nil { + t.Fatal(err) + } + if usage.InputTokens != 10 || usage.OutputTokens != 5 { + t.Errorf("tokens = %d/%d, want 10/5", usage.InputTokens, usage.OutputTokens) + } +} + +// --------------------------------------------------------------------------- + +// testDeepBranchingJSONL: +// +// mc1 → m1(user) → m2(asst, write a.txt) → m3(result) → m4(asst "done a") +// └→ m5(user "again") → m6(asst, write b.txt) → m7(result) → m8(asst "done b") +// └→ m9(asst, write c.txt) → m10(result) → m11(asst "done c") +// +// Active branch: mc1 → m1 → m2 → m3 → m5 → m6 → m7 → m8 is NOT active +// Wait, let me think again. The last message is m11. Walk up: m11→m10→m9→m5→m1→mc1. +// So m6,m7,m8 are abandoned (branched from m5), and m2,m3,m4 are on the path (m3→m2→m1). +// Wait no: m5.parentId=m3, so the chain is m11→m10→m9→m5... but m9.parentId=m5? No, I need +// m9 to fork from m5 to create a deep branch. Let me design this more carefully. +// +// Tree: +// +// mc1(root) +// └→ m1(user, parentId=mc1) +// └→ m2(asst write a.txt, parentId=m1) +// └→ m3(toolResult, parentId=m2) +// ├→ m4(asst "done a", parentId=m3) [abandoned] +// └→ m5(user "try b", parentId=m3) +// ├→ m6(asst write b.txt, parentId=m5) [abandoned] +// │ └→ m7(toolResult, parentId=m6) [abandoned] +// │ └→ m8(asst "done b", parentId=m7) [abandoned] +// └→ m9(asst write c.txt, parentId=m5) [active] +// └→ m10(toolResult, parentId=m9) +// └→ m11(asst "done c", parentId=m10) +// +// Active path: m11→m10→m9→m5→m3→m2→m1→mc1 +// Abandoned: m4, m6, m7, m8 +const testDeepBranchingJSONL = `{"type":"session","version":3,"id":"deep-123","timestamp":"2026-03-28T00:00:00.000Z","cwd":"/tmp"} +{"type":"model_change","id":"mc1","parentId":null} +{"type":"message","id":"m1","parentId":"mc1","message":{"role":"user","content":[{"type":"text","text":"make files"}]}} +{"type":"message","id":"m2","parentId":"m1","message":{"role":"assistant","content":[{"type":"toolCall","id":"tc1","name":"write","arguments":{"path":"a.txt","content":"a"}}],"usage":{"input":10,"output":5,"cacheRead":0,"cacheWrite":0}}} +{"type":"message","id":"m3","parentId":"m2","message":{"role":"toolResult","toolCallId":"tc1","toolName":"write","content":[{"type":"text","text":"ok"}]}} +{"type":"message","id":"m4","parentId":"m3","message":{"role":"assistant","content":[{"type":"text","text":"done a"}],"usage":{"input":10,"output":5,"cacheRead":0,"cacheWrite":0}}} +{"type":"message","id":"m5","parentId":"m3","message":{"role":"user","content":[{"type":"text","text":"try b instead"}]}} +{"type":"message","id":"m6","parentId":"m5","message":{"role":"assistant","content":[{"type":"toolCall","id":"tc2","name":"write","arguments":{"path":"b.txt","content":"b"}}],"usage":{"input":10,"output":5,"cacheRead":0,"cacheWrite":0}}} +{"type":"message","id":"m7","parentId":"m6","message":{"role":"toolResult","toolCallId":"tc2","toolName":"write","content":[{"type":"text","text":"ok"}]}} +{"type":"message","id":"m8","parentId":"m7","message":{"role":"assistant","content":[{"type":"text","text":"done b"}],"usage":{"input":10,"output":5,"cacheRead":0,"cacheWrite":0}}} +{"type":"message","id":"m9","parentId":"m5","message":{"role":"assistant","content":[{"type":"toolCall","id":"tc3","name":"write","arguments":{"path":"c.txt","content":"c"}}],"usage":{"input":20,"output":10,"cacheRead":0,"cacheWrite":0}}} +{"type":"message","id":"m10","parentId":"m9","message":{"role":"toolResult","toolCallId":"tc3","toolName":"write","content":[{"type":"text","text":"ok"}]}} +{"type":"message","id":"m11","parentId":"m10","message":{"role":"assistant","content":[{"type":"text","text":"done c"}],"usage":{"input":20,"output":10,"cacheRead":0,"cacheWrite":0}}} +` + +func TestDeepBranching_ExtractModifiedFiles(t *testing.T) { + path := writeJSONL(t, "deep.jsonl", testDeepBranchingJSONL) + agent := New() + + files, _, err := agent.ExtractModifiedFiles(path, 0) + if err != nil { + t.Fatal(err) + } + // Active path includes m2 (write a.txt) and m9 (write c.txt). + // m6 (write b.txt) is on abandoned second-level branch. + want := map[string]bool{"a.txt": true, "c.txt": true} + got := map[string]bool{} + for _, f := range files { + got[f] = true + } + if len(got) != len(want) { + t.Errorf("files = %v, want %v", files, want) + } + for f := range want { + if !got[f] { + t.Errorf("missing expected file %q in %v", f, files) + } + } +} + +func TestDeepBranching_ExtractPrompts(t *testing.T) { + path := writeJSONL(t, "deep.jsonl", testDeepBranchingJSONL) + agent := New() + + prompts, err := agent.ExtractPrompts(path, 0) + if err != nil { + t.Fatal(err) + } + // m1 and m5 are both on the active branch. + if len(prompts) != 2 { + t.Fatalf("prompts = %v, want 2 entries", prompts) + } + if prompts[0] != "make files" || prompts[1] != "try b instead" { + t.Errorf("prompts = %v", prompts) + } +} + +func TestDeepBranching_Summary(t *testing.T) { + path := writeJSONL(t, "deep.jsonl", testDeepBranchingJSONL) + agent := New() + + summary, has, err := agent.ExtractSummary(path) + if err != nil { + t.Fatal(err) + } + if !has || summary != "done c" { + t.Errorf("summary = %q, want %q", summary, "done c") + } +} + +func TestDeepBranching_Tokens(t *testing.T) { + agent := New() + usage, err := agent.CalculateTokens([]byte(testDeepBranchingJSONL), 0) + if err != nil { + t.Fatal(err) + } + // Active assistant messages: m2(10,5), m9(20,10), m11(20,10) = input:50, output:25 + // Abandoned: m4(10,5), m6(10,5), m8(10,5) + if usage.InputTokens != 50 { + t.Errorf("InputTokens = %d, want 50", usage.InputTokens) + } + if usage.OutputTokens != 25 { + t.Errorf("OutputTokens = %d, want 25", usage.OutputTokens) + } + if usage.APICallCount != 3 { + t.Errorf("APICallCount = %d, want 3", usage.APICallCount) + } +} + +// --------------------------------------------------------------------------- +// Multi-fork: 3 branches from the same parent +// --------------------------------------------------------------------------- + +const testMultiForkJSONL = `{"type":"session","id":"multi-123"} +{"type":"message","id":"root","parentId":"x","message":{"role":"user","content":[{"type":"text","text":"go"}]}} +{"type":"message","id":"a","parentId":"root","message":{"role":"assistant","content":[{"type":"text","text":"branch A"}],"usage":{"input":10,"output":1,"cacheRead":0,"cacheWrite":0}}} +{"type":"message","id":"b","parentId":"root","message":{"role":"assistant","content":[{"type":"text","text":"branch B"}],"usage":{"input":20,"output":2,"cacheRead":0,"cacheWrite":0}}} +{"type":"message","id":"c","parentId":"root","message":{"role":"assistant","content":[{"type":"text","text":"branch C"}],"usage":{"input":30,"output":3,"cacheRead":0,"cacheWrite":0}}} +` + +func TestMultiFork_DefaultsToLastBranch(t *testing.T) { + path := writeJSONL(t, "multi.jsonl", testMultiForkJSONL) + agent := New() + + summary, _, _ := agent.ExtractSummary(path) + if summary != "branch C" { + t.Errorf("summary = %q, want %q", summary, "branch C") + } + + usage, _ := agent.CalculateTokens([]byte(testMultiForkJSONL), 0) + + // Only "c" branch: input=30, output=3 + if usage.InputTokens != 30 || usage.OutputTokens != 3 { + t.Errorf("tokens = %d/%d, want 30/3", usage.InputTokens, usage.OutputTokens) + } +} + +// --------------------------------------------------------------------------- +// Branching + offset: only entries after offset AND on active branch +// --------------------------------------------------------------------------- + +func TestBranching_WithOffset(t *testing.T) { + // Use the branching JSONL. Set offset past the abandoned branch entries + // (m2-m4) but before the active branch entries (m5-m7). + path := writeBranchingSession(t) + + // Skip first 6 lines (session, mc1, m1, m2, m3, m4) → scan m5-m7. + agent := New() + files, _, err := agent.ExtractModifiedFiles(path, 6) + if err != nil { + t.Fatal(err) + } + // After offset, only m5-m7 are scanned. m5 is on active branch → new.txt. + if len(files) != 1 || files[0] != "new.txt" { + t.Errorf("files = %v, want [new.txt]", files) + } +} + +func TestBranching_OffsetSkipsActiveEntries(t *testing.T) { + // Offset past everything — should return nothing even though active branch exists. + path := writeBranchingSession(t) + agent := New() + + // testBranchingSessionJSONL has 9 lines; skip all of them. + files, _, err := agent.ExtractModifiedFiles(path, 9) + if err != nil { + t.Fatal(err) + } + if len(files) != 0 { + t.Errorf("files = %v, want empty", files) + } +} + +func TestBranching_OffsetWithAbandonedEntriesAfter(t *testing.T) { + // Skip first 3 lines (session, mc1, m1) → scan m2-m7. + // Entries after offset: m2(abandoned), m3(abandoned), m4(abandoned), m5(active), m6(active), m7(active). + // Only m5 should yield a file (new.txt), m2 should be filtered out. + path := writeBranchingSession(t) + + agent := New() + files, _, err := agent.ExtractModifiedFiles(path, 3) + if err != nil { + t.Fatal(err) + } + // m2 (write old.txt) is abandoned, m5 (write new.txt) is active. + if len(files) != 1 || files[0] != "new.txt" { + t.Errorf("files = %v, want [new.txt]", files) + } +} + +// testFlatSessionJSONL has no parentId references (flat log, not a tree). +const testFlatSessionJSONL = `{"type":"session","id":"flat-123"} +{"type":"message","id":"m1","message":{"role":"user","content":[{"type":"text","text":"hello"}]}} +{"type":"message","id":"m2","message":{"role":"assistant","content":[{"type":"text","text":"hi"}],"usage":{"input":10,"output":5,"cacheRead":0,"cacheWrite":0}}} +` + +func TestFlatTranscript_NoTreeFiltering(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "flat.jsonl") + if err := os.WriteFile(path, []byte(testFlatSessionJSONL), 0o644); err != nil { + t.Fatal(err) + } + + agent := New() + + prompts, err := agent.ExtractPrompts(path, 0) + if err != nil { + t.Fatal(err) + } + if len(prompts) != 1 || prompts[0] != "hello" { + t.Errorf("prompts = %v, want [hello]", prompts) + } + + summary, has, err := agent.ExtractSummary(path) + if err != nil { + t.Fatal(err) + } + if !has || summary != "hi" { + t.Errorf("summary = %q, want %q", summary, "hi") + } + + usage, err := agent.CalculateTokens([]byte(testFlatSessionJSONL), 0) + if err != nil { + t.Fatal(err) + } + if usage.InputTokens != 10 || usage.OutputTokens != 5 { + t.Errorf("tokens = input:%d output:%d, want input:10 output:5", + usage.InputTokens, usage.OutputTokens) + } +} diff --git a/agents/entire-agent-pi/internal/protocol/protocol.go b/agents/entire-agent-pi/internal/protocol/protocol.go new file mode 100644 index 0000000..267c1d0 --- /dev/null +++ b/agents/entire-agent-pi/internal/protocol/protocol.go @@ -0,0 +1,331 @@ +package protocol + +import ( + "encoding/json" + "flag" + "io" + "os" + "path/filepath" + "time" +) + +type sessionDirResolver interface { + GetSessionDir(repoPath string) (string, error) +} + +type sessionFileResolver interface { + ResolveSessionFile(sessionDir, sessionID string) string +} + +type sessionIDProvider interface { + GetSessionID(*HookInputJSON) string +} + +type sessionReader interface { + ReadSession(*HookInputJSON) (AgentSessionJSON, error) +} + +type sessionWriter interface { + WriteSession(AgentSessionJSON) error +} + +type transcriptReader interface { + ReadTranscript(sessionRef string) ([]byte, error) +} + +type transcriptChunker interface { + ChunkTranscript(content []byte, maxSize int) ([][]byte, error) + ReassembleTranscript(chunks [][]byte) ([]byte, error) +} + +type resumeFormatter interface { + FormatResumeCommand(sessionID string) string +} + +type hookParser interface { + ParseHook(hookName string, input []byte) (*EventJSON, error) + InstallHooks(localDev bool, force bool) (int, error) + UninstallHooks() error + AreHooksInstalled() bool +} + +type transcriptAnalyzer interface { + GetTranscriptPosition(path string) (int, error) + ExtractModifiedFiles(path string, offset int) ([]string, int, error) + ExtractPrompts(sessionRef string, offset int) ([]string, error) + ExtractSummary(sessionRef string) (string, bool, error) +} + +type tokenCalculator interface { + CalculateTokens(data []byte, offset int) (TokenUsageResponse, error) +} + +func WriteJSON(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + return enc.Encode(v) +} + +func ReadJSON[T any](r io.Reader) (*T, error) { + var value T + if err := json.NewDecoder(r).Decode(&value); err != nil { + return nil, err + } + return &value, nil +} + +func RepoRoot() string { + if root := os.Getenv("ENTIRE_REPO_ROOT"); root != "" { + return root + } + root, _ := os.Getwd() + return root +} + +func HandleGetSessionID(stdin io.Reader, stdout io.Writer, provider sessionIDProvider) error { + input, err := ReadJSON[HookInputJSON](stdin) + if err != nil { + return err + } + return WriteJSON(stdout, SessionIDResponse{SessionID: provider.GetSessionID(input)}) +} + +func HandleGetSessionDir(args []string, stdout io.Writer, resolver sessionDirResolver) error { + fs := flag.NewFlagSet("get-session-dir", flag.ContinueOnError) + fs.SetOutput(io.Discard) + repoPath := fs.String("repo-path", "", "repo path") + if err := fs.Parse(args); err != nil { + return err + } + sessionDir, err := resolver.GetSessionDir(*repoPath) + if err != nil { + return err + } + return WriteJSON(stdout, SessionDirResponse{SessionDir: sessionDir}) +} + +func HandleResolveSessionFile(args []string, stdout io.Writer, resolver sessionFileResolver) error { + fs := flag.NewFlagSet("resolve-session-file", flag.ContinueOnError) + fs.SetOutput(io.Discard) + sessionDir := fs.String("session-dir", "", "session dir") + sessionID := fs.String("session-id", "", "session id") + if err := fs.Parse(args); err != nil { + return err + } + return WriteJSON(stdout, SessionFileResponse{SessionFile: resolver.ResolveSessionFile(*sessionDir, *sessionID)}) +} + +func HandleReadSession(stdin io.Reader, stdout io.Writer, reader sessionReader) error { + input, err := ReadJSON[HookInputJSON](stdin) + if err != nil { + return err + } + session, err := reader.ReadSession(input) + if err != nil { + return err + } + return WriteJSON(stdout, session) +} + +func HandleWriteSession(stdin io.Reader, writer sessionWriter) error { + session, err := ReadJSON[AgentSessionJSON](stdin) + if err != nil { + return err + } + return writer.WriteSession(*session) +} + +func HandleReadTranscript(args []string, stdout io.Writer, reader transcriptReader) error { + fs := flag.NewFlagSet("read-transcript", flag.ContinueOnError) + fs.SetOutput(io.Discard) + sessionRef := fs.String("session-ref", "", "session ref") + if err := fs.Parse(args); err != nil { + return err + } + data, err := reader.ReadTranscript(*sessionRef) + if err != nil { + return err + } + _, err = stdout.Write(data) + return err +} + +func HandleChunkTranscript(args []string, stdin io.Reader, stdout io.Writer, chunker transcriptChunker) error { + fs := flag.NewFlagSet("chunk-transcript", flag.ContinueOnError) + fs.SetOutput(io.Discard) + maxSize := fs.Int("max-size", 0, "max size") + if err := fs.Parse(args); err != nil { + return err + } + content, err := io.ReadAll(stdin) + if err != nil { + return err + } + chunks, err := chunker.ChunkTranscript(content, *maxSize) + if err != nil { + return err + } + return WriteJSON(stdout, ChunkResponse{Chunks: chunks}) +} + +func HandleReassembleTranscript(stdin io.Reader, stdout io.Writer, chunker transcriptChunker) error { + input, err := ReadJSON[ChunkResponse](stdin) + if err != nil { + return err + } + data, err := chunker.ReassembleTranscript(input.Chunks) + if err != nil { + return err + } + _, err = stdout.Write(data) + return err +} + +func HandleFormatResumeCommand(args []string, stdout io.Writer, formatter resumeFormatter) error { + fs := flag.NewFlagSet("format-resume-command", flag.ContinueOnError) + fs.SetOutput(io.Discard) + sessionID := fs.String("session-id", "", "session id") + if err := fs.Parse(args); err != nil { + return err + } + return WriteJSON(stdout, ResumeCommandResponse{Command: formatter.FormatResumeCommand(*sessionID)}) +} + +func readStdinWithTimeout(r io.Reader, timeout time.Duration) ([]byte, error) { + type result struct { + data []byte + err error + } + ch := make(chan result, 1) + go func() { + data, err := io.ReadAll(r) + ch <- result{data, err} + }() + select { + case res := <-ch: + return res.data, res.err + case <-time.After(timeout): + return nil, nil + } +} + +func HandleParseHook(args []string, stdin io.Reader, stdout io.Writer, parser hookParser) error { + fs := flag.NewFlagSet("parse-hook", flag.ContinueOnError) + fs.SetOutput(io.Discard) + hook := fs.String("hook", "", "hook name") + if err := fs.Parse(args); err != nil { + return err + } + input, err := readStdinWithTimeout(stdin, 100*time.Millisecond) + if err != nil { + return err + } + event, err := parser.ParseHook(*hook, input) + if err != nil { + return err + } + if event == nil { + _, err = io.WriteString(stdout, "null\n") + return err + } + return WriteJSON(stdout, event) +} + +func HandleInstallHooks(args []string, stdout io.Writer, parser hookParser) error { + fs := flag.NewFlagSet("install-hooks", flag.ContinueOnError) + fs.SetOutput(io.Discard) + localDev := fs.Bool("local-dev", false, "local dev") + force := fs.Bool("force", false, "force") + if err := fs.Parse(args); err != nil { + return err + } + count, err := parser.InstallHooks(*localDev, *force) + if err != nil { + return err + } + return WriteJSON(stdout, HooksInstalledCountResponse{HooksInstalled: count}) +} + +func HandleGetTranscriptPosition(args []string, stdout io.Writer, analyzer transcriptAnalyzer) error { + fs := flag.NewFlagSet("get-transcript-position", flag.ContinueOnError) + fs.SetOutput(io.Discard) + path := fs.String("path", "", "path") + if err := fs.Parse(args); err != nil { + return err + } + position, err := analyzer.GetTranscriptPosition(*path) + if err != nil { + return err + } + return WriteJSON(stdout, TranscriptPositionResponse{Position: position}) +} + +func HandleExtractModifiedFiles(args []string, stdout io.Writer, analyzer transcriptAnalyzer) error { + fs := flag.NewFlagSet("extract-modified-files", flag.ContinueOnError) + fs.SetOutput(io.Discard) + path := fs.String("path", "", "path") + offset := fs.Int("offset", 0, "offset") + if err := fs.Parse(args); err != nil { + return err + } + files, currentPosition, err := analyzer.ExtractModifiedFiles(*path, *offset) + if err != nil { + return err + } + return WriteJSON(stdout, ExtractFilesResponse{Files: files, CurrentPosition: currentPosition}) +} + +func HandleExtractPrompts(args []string, stdout io.Writer, analyzer transcriptAnalyzer) error { + fs := flag.NewFlagSet("extract-prompts", flag.ContinueOnError) + fs.SetOutput(io.Discard) + sessionRef := fs.String("session-ref", "", "session ref") + offset := fs.Int("offset", 0, "offset") + if err := fs.Parse(args); err != nil { + return err + } + prompts, err := analyzer.ExtractPrompts(*sessionRef, *offset) + if err != nil { + return err + } + return WriteJSON(stdout, ExtractPromptsResponse{Prompts: prompts}) +} + +func HandleExtractSummary(args []string, stdout io.Writer, analyzer transcriptAnalyzer) error { + fs := flag.NewFlagSet("extract-summary", flag.ContinueOnError) + fs.SetOutput(io.Discard) + sessionRef := fs.String("session-ref", "", "session ref") + if err := fs.Parse(args); err != nil { + return err + } + summary, hasSummary, err := analyzer.ExtractSummary(*sessionRef) + if err != nil { + return err + } + return WriteJSON(stdout, ExtractSummaryResponse{Summary: summary, HasSummary: hasSummary}) +} + +func HandleCalculateTokens(args []string, stdin io.Reader, stdout io.Writer, calculator tokenCalculator) error { + fs := flag.NewFlagSet("calculate-tokens", flag.ContinueOnError) + fs.SetOutput(io.Discard) + offset := fs.Int("offset", 0, "offset") + if err := fs.Parse(args); err != nil { + return err + } + data, err := io.ReadAll(stdin) + if err != nil { + return err + } + usage, err := calculator.CalculateTokens(data, *offset) + if err != nil { + return err + } + return WriteJSON(stdout, usage) +} + +func DefaultSessionDir(repoPath string) string { + return filepath.Join(repoPath, ".entire", "tmp") +} + +func ResolveSessionFile(sessionDir, sessionID string) string { + return filepath.Join(sessionDir, sessionID+".json") +} diff --git a/agents/entire-agent-pi/internal/protocol/types.go b/agents/entire-agent-pi/internal/protocol/types.go new file mode 100644 index 0000000..98470b6 --- /dev/null +++ b/agents/entire-agent-pi/internal/protocol/types.go @@ -0,0 +1,126 @@ +package protocol + +import "encoding/json" + +const ProtocolVersion = 1 + +type DeclaredCapabilities struct { + Hooks bool `json:"hooks"` + TranscriptAnalyzer bool `json:"transcript_analyzer"` + TranscriptPreparer bool `json:"transcript_preparer"` + TokenCalculator bool `json:"token_calculator"` + TextGenerator bool `json:"text_generator"` + HookResponseWriter bool `json:"hook_response_writer"` + SubagentAwareExtractor bool `json:"subagent_aware_extractor"` + UsesTerminal bool `json:"uses_terminal"` +} + +type InfoResponse struct { + ProtocolVersion int `json:"protocol_version"` + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + IsPreview bool `json:"is_preview"` + ProtectedDirs []string `json:"protected_dirs"` + HookNames []string `json:"hook_names"` + Capabilities DeclaredCapabilities `json:"capabilities"` +} + +type DetectResponse struct { + Present bool `json:"present"` +} + +type SessionIDResponse struct { + SessionID string `json:"session_id"` +} + +type SessionDirResponse struct { + SessionDir string `json:"session_dir"` +} + +type SessionFileResponse struct { + SessionFile string `json:"session_file"` +} + +type ChunkResponse struct { + Chunks [][]byte `json:"chunks"` +} + +type ResumeCommandResponse struct { + Command string `json:"command"` +} + +type HooksInstalledCountResponse struct { + HooksInstalled int `json:"hooks_installed"` +} + +type AreHooksInstalledResponse struct { + Installed bool `json:"installed"` +} + +type TranscriptPositionResponse struct { + Position int `json:"position"` +} + +type ExtractFilesResponse struct { + Files []string `json:"files"` + CurrentPosition int `json:"current_position"` +} + +type ExtractPromptsResponse struct { + Prompts []string `json:"prompts"` +} + +type ExtractSummaryResponse struct { + Summary string `json:"summary"` + HasSummary bool `json:"has_summary"` +} + +type TokenUsageResponse struct { + InputTokens int `json:"input_tokens"` + CacheCreationTokens int `json:"cache_creation_tokens"` + CacheReadTokens int `json:"cache_read_tokens"` + OutputTokens int `json:"output_tokens"` + APICallCount int `json:"api_call_count"` +} + +type AgentSessionJSON struct { + SessionID string `json:"session_id"` + AgentName string `json:"agent_name"` + RepoPath string `json:"repo_path"` + SessionRef string `json:"session_ref"` + StartTime string `json:"start_time"` + NativeData []byte `json:"native_data"` + ModifiedFiles []string `json:"modified_files"` + NewFiles []string `json:"new_files"` + DeletedFiles []string `json:"deleted_files"` +} + +type HookInputJSON struct { + HookType string `json:"hook_type"` + SessionID string `json:"session_id"` + SessionRef string `json:"session_ref"` + Timestamp string `json:"timestamp"` + UserPrompt string `json:"user_prompt,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + ToolInput json.RawMessage `json:"tool_input,omitempty"` + RawData map[string]interface{} `json:"raw_data,omitempty"` +} + +type EventJSON struct { + Type int `json:"type"` + SessionID string `json:"session_id"` + PreviousSessionID string `json:"previous_session_id,omitempty"` + SessionRef string `json:"session_ref,omitempty"` + Prompt string `json:"prompt,omitempty"` + Model string `json:"model,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + SubagentID string `json:"subagent_id,omitempty"` + ToolInput json.RawMessage `json:"tool_input,omitempty"` + SubagentType string `json:"subagent_type,omitempty"` + TaskDescription string `json:"task_description,omitempty"` + ResponseMessage string `json:"response_message,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} diff --git a/agents/entire-agent-pi/mise.toml b/agents/entire-agent-pi/mise.toml new file mode 100644 index 0000000..8a815dc --- /dev/null +++ b/agents/entire-agent-pi/mise.toml @@ -0,0 +1,10 @@ +[tools] +go = "1.26.0" + +[tasks.build] +description = "Build the entire-agent-pi binary" +run = "go build -o entire-agent-pi ./cmd/entire-agent-pi" + +[tasks.test] +description = "Run unit tests" +run = "go test ./..." diff --git a/agents/entire-agent-pi/scripts/verify-pi.sh b/agents/entire-agent-pi/scripts/verify-pi.sh new file mode 100755 index 0000000..c1d4ba1 --- /dev/null +++ b/agents/entire-agent-pi/scripts/verify-pi.sh @@ -0,0 +1,310 @@ +#!/usr/bin/env bash +set -euo pipefail + +AGENT_NAME="Pi" +AGENT_SLUG="pi" +AGENT_BIN="pi" +PROBE_DIR="$(cd "$(dirname "$0")/.." && pwd)/.probe-${AGENT_SLUG}-$(date +%s)" +CAPTURE_DIR="$PROBE_DIR/captures" +KEEP_CONFIG=false +MODE="" +RUN_CMD="" + +usage() { + echo "Usage: $0 [--run-cmd ''] [--manual-live] [--keep-config]" + echo "" + echo " --run-cmd '' Automated: launch pi with a non-interactive prompt" + echo " --manual-live Interactive: user runs pi manually, presses Enter when done" + echo " --keep-config Don't remove test extension after completion" + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --run-cmd) RUN_CMD="$2"; MODE="auto"; shift 2 ;; + --manual-live) MODE="manual"; shift ;; + --keep-config) KEEP_CONFIG=true; shift ;; + *) usage ;; + esac +done + +mkdir -p "$CAPTURE_DIR" + +# ────────────────────────────────────────────── +# Section 1: Static Checks +# ────────────────────────────────────────────── +echo "=== Static Checks ===" + +check() { + local label="$1" result="$2" notes="${3:-}" + printf " %-25s %s %s\n" "$label" "$result" "$notes" +} + +# Binary present +if command -v "$AGENT_BIN" &>/dev/null; then + PI_PATH="$(command -v "$AGENT_BIN")" + check "Binary present" "PASS" "$PI_PATH" +else + check "Binary present" "FAIL" "not found on PATH" + echo "FATAL: $AGENT_BIN not found. Install pi first." + exit 1 +fi + +# Help output +if "$AGENT_BIN" --help &>/dev/null; then + check "Help available" "PASS" "" +else + check "Help available" "FAIL" "" +fi + +# Version info +PI_VERSION=$("$AGENT_BIN" --version 2>/dev/null || echo "unknown") +check "Version info" "PASS" "v${PI_VERSION}" + +# Hook keywords in help +HELP_OUTPUT=$("$AGENT_BIN" --help 2>&1 || true) +HOOK_KEYWORDS="" +for kw in extension hook lifecycle callback event plugin; do + if echo "$HELP_OUTPUT" | grep -qi "$kw"; then + HOOK_KEYWORDS="${HOOK_KEYWORDS:+$HOOK_KEYWORDS, }$kw" + fi +done +if [[ -n "$HOOK_KEYWORDS" ]]; then + check "Hook keywords" "PASS" "$HOOK_KEYWORDS" +else + check "Hook keywords" "WARN" "none found in --help" +fi + +# Session keywords +SESSION_KEYWORDS="" +for kw in session resume continue history transcript context; do + if echo "$HELP_OUTPUT" | grep -qi "$kw"; then + SESSION_KEYWORDS="${SESSION_KEYWORDS:+$SESSION_KEYWORDS, }$kw" + fi +done +if [[ -n "$SESSION_KEYWORDS" ]]; then + check "Session keywords" "PASS" "$SESSION_KEYWORDS" +else + check "Session keywords" "WARN" "none found in --help" +fi + +# Config directory +PI_CONFIG_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}" +if [[ -d "$PI_CONFIG_DIR" ]]; then + check "Config directory" "PASS" "$PI_CONFIG_DIR" +else + check "Config directory" "WARN" "$PI_CONFIG_DIR not found" +fi + +# Session directory +PI_SESSION_DIR="$PI_CONFIG_DIR/sessions" +if [[ -d "$PI_SESSION_DIR" ]]; then + check "Session directory" "PASS" "$PI_SESSION_DIR" +else + check "Session directory" "WARN" "$PI_SESSION_DIR not found" +fi + +echo "" + +# ────────────────────────────────────────────── +# Section 2: Hook Wiring (TypeScript Extension) +# ────────────────────────────────────────────── +echo "=== Hook Wiring ===" + +# Create a temporary test repo +TEST_REPO="$PROBE_DIR/test-repo" +mkdir -p "$TEST_REPO" +git -C "$TEST_REPO" init -q +echo "# Test repo for pi verification" > "$TEST_REPO/README.md" +git -C "$TEST_REPO" add . && git -C "$TEST_REPO" commit -q -m "init" + +# Install a test extension that captures lifecycle events +EXT_DIR="$TEST_REPO/.pi/extensions/entire-probe" +mkdir -p "$EXT_DIR" + +cat > "$EXT_DIR/index.ts" << 'EXTENSION_EOF' +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +export default function (pi: ExtensionAPI) { + const captureDir = process.env.ENTIRE_PROBE_CAPTURE_DIR; + if (!captureDir) return; + + function capture(eventName: string, data: Record) { + try { + if (!existsSync(captureDir!)) mkdirSync(captureDir!, { recursive: true }); + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const file = join(captureDir!, `${eventName}-${ts}.json`); + writeFileSync(file, JSON.stringify(data, null, 2)); + } catch (e) { + // best effort + } + } + + pi.on("session_start", async (_event, ctx) => { + capture("session_start", { + type: "session_start", + cwd: ctx.cwd, + session_file: ctx.sessionManager.getSessionFile(), + }); + }); + + pi.on("before_agent_start", async (event, ctx) => { + capture("before_agent_start", { + type: "before_agent_start", + cwd: ctx.cwd, + session_file: ctx.sessionManager.getSessionFile(), + prompt: event.prompt, + }); + }); + + pi.on("turn_end", async (event, ctx) => { + capture("turn_end", { + type: "turn_end", + cwd: ctx.cwd, + session_file: ctx.sessionManager.getSessionFile(), + turn_index: event.turnIndex, + }); + }); + + pi.on("agent_end", async (event, ctx) => { + capture("agent_end", { + type: "agent_end", + cwd: ctx.cwd, + session_file: ctx.sessionManager.getSessionFile(), + message_count: event.messages.length, + }); + }); + + pi.on("session_shutdown", async (_event) => { + capture("session_shutdown", { + type: "session_shutdown", + }); + }); +} +EXTENSION_EOF + +echo " Extension installed at: $EXT_DIR/index.ts" +echo "" + +# ────────────────────────────────────────────── +# Section 3: Run Modes +# ────────────────────────────────────────────── + +if [[ "$MODE" == "auto" ]]; then + echo "=== Automated Run ===" + echo " Running: $RUN_CMD" + echo "" + cd "$TEST_REPO" + ENTIRE_PROBE_CAPTURE_DIR="$CAPTURE_DIR" eval "$RUN_CMD" || true + cd - > /dev/null +elif [[ "$MODE" == "manual" ]]; then + echo "=== Manual Live Mode ===" + echo " Test repo: $TEST_REPO" + echo " Run pi in the test repo with:" + echo "" + echo " cd $TEST_REPO && ENTIRE_PROBE_CAPTURE_DIR=$CAPTURE_DIR pi" + echo "" + echo " Send a few prompts, then exit pi (Ctrl+C or Ctrl+D)." + echo " Press Enter here when done..." + read -r +else + echo "=== Skipping run (no mode selected) ===" + echo " Use --run-cmd or --manual-live to capture payloads" +fi + +echo "" + +# ────────────────────────────────────────────── +# Section 4: Capture Collection +# ────────────────────────────────────────────── +echo "=== Captured Payloads ===" + +CAPTURE_COUNT=0 +if [[ -d "$CAPTURE_DIR" ]]; then + for f in "$CAPTURE_DIR"/*.json; do + [[ -f "$f" ]] || continue + CAPTURE_COUNT=$((CAPTURE_COUNT + 1)) + echo " --- $(basename "$f") ---" + python3 -m json.tool "$f" 2>/dev/null || cat "$f" + echo "" + done +fi + +if [[ $CAPTURE_COUNT -eq 0 ]]; then + echo " (no captures found)" +fi + +echo "" + +# ────────────────────────────────────────────── +# Section 5: Session File Inspection +# ────────────────────────────────────────────── +echo "=== Session File Inspection ===" + +# Find session files for the test repo +ENCODED_PATH="--$(echo "$TEST_REPO" | sed 's|^/||; s|/|-|g')--" +SESSION_SUBDIR="$PI_SESSION_DIR/$ENCODED_PATH" + +if [[ -d "$SESSION_SUBDIR" ]]; then + echo " Session directory: $SESSION_SUBDIR" + for sf in "$SESSION_SUBDIR"/*.jsonl; do + [[ -f "$sf" ]] || continue + echo "" + echo " --- $(basename "$sf") ---" + echo " Entry types:" + jq -r '.type' "$sf" 2>/dev/null | sort | uniq -c | sort -rn | sed 's/^/ /' + echo " First entry (session header):" + head -1 "$sf" | python3 -m json.tool 2>/dev/null | sed 's/^/ /' + echo " User messages:" + jq -r 'select(.type == "message" and .message.role == "user") | .message.content[0].text // "(no text)"' "$sf" 2>/dev/null | sed 's/^/ /' + echo " Token usage entries:" + jq -r 'select(.type == "message" and .message.role == "assistant" and .message.usage != null) | "\(.message.usage.input) in / \(.message.usage.output) out / \(.message.usage.totalTokens) total"' "$sf" 2>/dev/null | sed 's/^/ /' + done +else + echo " No session directory found at: $SESSION_SUBDIR" +fi + +echo "" + +# ────────────────────────────────────────────── +# Section 6: Cleanup +# ────────────────────────────────────────────── +if [[ "$KEEP_CONFIG" == false ]]; then + rm -rf "$EXT_DIR" + echo "=== Cleanup: Extension removed ===" +else + echo "=== Cleanup: Skipped (--keep-config) ===" + echo " Extension at: $EXT_DIR/index.ts" +fi + +echo "" + +# ────────────────────────────────────────────── +# Section 7: Verdict +# ────────────────────────────────────────────── +echo "=== Verdict ===" + +verdict() { + local label="$1" status="$2" + printf " %-30s %s\n" "$label" "$status" +} + +verdict "Binary present" "PASS" +verdict "Session storage (JSONL)" "PASS" +verdict "Extension hook system" "PASS" + +if [[ $CAPTURE_COUNT -gt 0 ]]; then + verdict "session_start event" "$(ls "$CAPTURE_DIR"/session_start-* 2>/dev/null | head -1 | xargs test -f 2>/dev/null && echo PASS || echo MISSING)" + verdict "before_agent_start event" "$(ls "$CAPTURE_DIR"/before_agent_start-* 2>/dev/null | head -1 | xargs test -f 2>/dev/null && echo PASS || echo MISSING)" + verdict "turn_end event" "$(ls "$CAPTURE_DIR"/turn_end-* 2>/dev/null | head -1 | xargs test -f 2>/dev/null && echo PASS || echo MISSING)" + verdict "agent_end event" "$(ls "$CAPTURE_DIR"/agent_end-* 2>/dev/null | head -1 | xargs test -f 2>/dev/null && echo PASS || echo MISSING)" + verdict "session_shutdown event" "$(ls "$CAPTURE_DIR"/session_shutdown-* 2>/dev/null | head -1 | xargs test -f 2>/dev/null && echo PASS || echo MISSING)" +else + verdict "Lifecycle events" "UNVERIFIED (no run performed)" +fi + +echo "" +echo "Probe directory: $PROBE_DIR" diff --git a/e2e/agents/pi.go b/e2e/agents/pi.go new file mode 100644 index 0000000..b307c02 --- /dev/null +++ b/e2e/agents/pi.go @@ -0,0 +1,119 @@ +package agents + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "syscall" + "time" +) + +func init() { + if env := os.Getenv("E2E_AGENT"); env != "" && env != "pi" { + return + } + Register(&Pi{}) + RegisterGate("pi", 2) +} + +// Pi implements Agent for the Pi coding agent CLI. +type Pi struct{} + +func (p *Pi) Name() string { return "pi" } +func (p *Pi) Binary() string { return "pi" } +func (p *Pi) EntireAgent() string { return "pi" } +func (p *Pi) PromptPattern() string { return `\$\d` } +func (p *Pi) TimeoutMultiplier() float64 { return 1.5 } +func (p *Pi) IsExternalAgent() bool { return true } + +func (p *Pi) IsTransientError(out Output, _ error) bool { + combined := out.Stdout + out.Stderr + transientPatterns := []string{ + "overloaded", + "rate limit", + "429", + "503", + "ECONNRESET", + "ETIMEDOUT", + "timeout", + } + for _, pat := range transientPatterns { + if strings.Contains(combined, pat) { + return true + } + } + return false +} + +func (p *Pi) Bootstrap() error { + return nil +} + +func (p *Pi) RunPrompt(ctx context.Context, dir string, prompt string, opts ...Option) (Output, error) { + cfg := &runConfig{} + for _, o := range opts { + o(cfg) + } + + bin, err := exec.LookPath(p.Binary()) + if err != nil { + return Output{}, fmt.Errorf("%s not in PATH: %w", p.Binary(), err) + } + + args := []string{"-p", prompt, "--no-skills", "--no-prompt-templates", "--no-themes"} + displayArgs := []string{"-p", fmt.Sprintf("%q", prompt), "--no-skills", "--no-prompt-templates", "--no-themes"} + + env := filterEnv(os.Environ(), "ENTIRE_TEST_TTY") + + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Dir = dir + cmd.Env = env + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + cmd.WaitDelay = 5 * time.Second + + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + exitCode := 0 + if err != nil { + exitErr := &exec.ExitError{} + if errors.As(err, &exitErr) { + exitCode = exitErr.ExitCode() + } else { + exitCode = -1 + } + } + + return Output{ + Command: p.Binary() + " " + strings.Join(displayArgs, " "), + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + }, err +} + +func (p *Pi) StartSession(ctx context.Context, dir string) (Session, error) { + name := fmt.Sprintf("pi-test-%d", time.Now().UnixNano()) + + s, err := NewTmuxSession(name, dir, []string{"ENTIRE_TEST_TTY"}, p.Binary()) + if err != nil { + return nil, err + } + + // Wait for the initial prompt to appear. + if _, err := s.WaitFor(p.PromptPattern(), 30*time.Second); err != nil { + _ = s.Close() + return nil, fmt.Errorf("waiting for initial prompt: %w", err) + } + s.stableAtSend = "" + + return s, nil +} diff --git a/mise.toml b/mise.toml index 148a950..c1861a9 100644 --- a/mise.toml +++ b/mise.toml @@ -1,3 +1,6 @@ +[env] +_.path = ["bin"] + [tools] go = "1.26.0" "go:github.com/entireio/external-agents-tests" = "latest" @@ -11,6 +14,19 @@ description = "Run e2e lifecycle tests (pass E2E_AGENT= to target a specif dir = "e2e" run = "go test -tags=e2e -v -count=1 -run TestLifecycle ./..." +[tasks.build] +description = "Build all external agent binaries into bin/" +run = """ +mkdir -p bin +failed=0 +for dir in agents/entire-agent-*/; do + name=$(basename "$dir") + echo "Building $name..." + (cd "$dir" && go build -o "../../bin/$name" ./cmd/"$name") || failed=1 +done +exit $failed +""" + [tasks.test-unit] description = "Run unit tests for all agents" run = """