diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index a99424095..bf6cf1304 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -661,6 +661,64 @@ Response: } ``` +#### get_entries + +Get all session entries in append order (excluding the session header). The session is an append-only tree of entries with stable ids, so an entry id works as a durable cursor: pass the last entry id you have seen as `since` to get only entries strictly after it, even across client restarts. Unlike `get_messages`, this includes pre-compaction history and abandoned branches. + +```json +{"type": "get_entries"} +``` + +With a cursor: +```json +{"type": "get_entries", "since": "abc123"} +``` + +Response: +```json +{ + "type": "response", + "command": "get_entries", + "success": true, + "data": { + "entries": [ + {"type": "message", "id": "def456", "parentId": "abc123", "timestamp": "...", "message": {"role": "user", "...": "..."}} + ], + "leafId": "def456" + } +} +``` + +`leafId` is the id of the current leaf entry (`null` for an empty session), so a client can tell in one round trip whether the active branch moved. If `since` does not match any entry id, the response is `success: false`. + +#### get_tree + +Get the session as a tree of entries. Each node is `{entry, children, label?, labelTimestamp?}`. A well-formed session has a single root; orphaned entries (broken parent chain) also appear as roots. + +```json +{"type": "get_tree"} +``` + +Response: +```json +{ + "type": "response", + "command": "get_tree", + "success": true, + "data": { + "tree": [ + { + "entry": {"type": "message", "id": "abc123", "parentId": null, "...": "..."}, + "children": [ + {"entry": {"type": "message", "id": "def456", "parentId": "abc123", "...": "..."}, "children": []} + ] + } + ], + "leafId": "def456" + } +} +``` + #### get_last_assistant_text Get the text content of the last assistant message. diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 5830ecdad..0be0a8da5 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -224,6 +224,7 @@ export { type SessionInfoEntry, SessionManager, type SessionMessageEntry, + type SessionTreeNode, type ThinkingLevelChangeEntry, } from "./core/session-manager.ts"; export { diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 4c46feff3..eca52af74 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -10,6 +10,7 @@ import type { ImageContent } from "@earendil-works/pi-ai"; import type { SessionStats } from "../../core/agent-session.ts"; import type { BashResult } from "../../core/bash-executor.ts"; import type { CompactionResult } from "../../core/compaction/index.ts"; +import type { SessionEntry, SessionTreeNode } from "../../core/session-manager.ts"; import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.ts"; import type { RpcCommand, RpcResponse, RpcSessionState, RpcSlashCommand } from "./rpc-types.ts"; @@ -388,6 +389,22 @@ export class RpcClient { return this.getData<{ messages: Array<{ entryId: string; text: string }> }>(response).messages; } + /** + * Get session entries in append order, optionally only those after the `since` entry id. + */ + async getEntries(since?: string): Promise<{ entries: SessionEntry[]; leafId: string | null }> { + const response = await this.send({ type: "get_entries", since }); + return this.getData<{ entries: SessionEntry[]; leafId: string | null }>(response); + } + + /** + * Get the session entry tree. + */ + async getTree(): Promise<{ tree: SessionTreeNode[]; leafId: string | null }> { + const response = await this.send({ type: "get_tree" }); + return this.getData<{ tree: SessionTreeNode[]; leafId: string | null }>(response); + } + /** * Get text of last assistant message. */ diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 1150b8264..38cac6415 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -606,6 +606,24 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise e.id === command.since); + if (sinceIndex === -1) { + return error(id, "get_entries", `Entry not found: ${command.since}`); + } + entries = entries.slice(sinceIndex + 1); + } + return success(id, "get_entries", { entries, leafId: sessionManager.getLeafId() }); + } + + case "get_tree": { + const sessionManager = session.sessionManager; + return success(id, "get_tree", { tree: sessionManager.getTree(), leafId: sessionManager.getLeafId() }); + } + case "get_last_assistant_text": { const text = session.getLastAssistantText(); return success(id, "get_last_assistant_text", { text }); diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index b65f2ab4e..02249ade8 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -10,6 +10,7 @@ import type { ImageContent, Model } from "@earendil-works/pi-ai"; import type { SessionStats } from "../../core/agent-session.ts"; import type { BashResult } from "../../core/bash-executor.ts"; import type { CompactionResult } from "../../core/compaction/index.ts"; +import type { SessionEntry, SessionTreeNode } from "../../core/session-manager.ts"; import type { SourceInfo } from "../../core/source-info.ts"; // ============================================================================ @@ -59,6 +60,8 @@ export type RpcCommand = | { id?: string; type: "fork"; entryId: string } | { id?: string; type: "clone" } | { id?: string; type: "get_fork_messages" } + | { id?: string; type: "get_entries"; since?: string } + | { id?: string; type: "get_tree" } | { id?: string; type: "get_last_assistant_text" } | { id?: string; type: "set_session_name"; name: string } @@ -181,6 +184,20 @@ export type RpcResponse = success: true; data: { messages: Array<{ entryId: string; text: string }> }; } + | { + id?: string; + type: "response"; + command: "get_entries"; + success: true; + data: { entries: SessionEntry[]; leafId: string | null }; + } + | { + id?: string; + type: "response"; + command: "get_tree"; + success: true; + data: { tree: SessionTreeNode[]; leafId: string | null }; + } | { id?: string; type: "response"; diff --git a/packages/coding-agent/test/rpc.test.ts b/packages/coding-agent/test/rpc.test.ts index 87447d3bf..faedcb89f 100644 --- a/packages/coding-agent/test/rpc.test.ts +++ b/packages/coding-agent/test/rpc.test.ts @@ -283,6 +283,62 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T expect(text).toContain("test123"); }, 90000); + test("should get session entries with since cursor", async () => { + await client.start(); + + await client.promptAndWait("Reply with just 'ok'"); + + const { entries, leafId } = await client.getEntries(); + expect(entries.length).toBeGreaterThanOrEqual(2); // user + assistant + for (const entry of entries) { + expect(entry.id).toBeDefined(); + } + expect(leafId).toBe(entries[entries.length - 1].id); + + // since cursor returns only entries strictly after the given id + const since = await client.getEntries(entries[0].id); + expect(since.entries.map((e) => e.id)).toEqual(entries.slice(1).map((e) => e.id)); + expect(since.leafId).toBe(leafId); + + // unknown since id is an error response + await expect(client.getEntries("nonexistent-id")).rejects.toThrow("Entry not found"); + }, 90000); + + test("should get session tree", async () => { + await client.start(); + + await client.promptAndWait("Reply with just 'ok'"); + + const { entries, leafId } = await client.getEntries(); + const { tree, leafId: treeLeafId } = await client.getTree(); + expect(treeLeafId).toBe(leafId); + + // Single root whose chain matches the entries + expect(tree.length).toBe(1); + const chainIds: string[] = []; + let nodes = tree; + while (nodes.length === 1) { + chainIds.push(nodes[0].entry.id); + nodes = nodes[0].children; + } + expect(nodes.length).toBe(0); + expect(chainIds).toEqual(entries.map((e) => e.id)); + }, 90000); + + test("should retain pre-compaction entries in get_entries", async () => { + await client.start(); + + await client.promptAndWait("Reply with just 'ok'"); + const before = await client.getEntries(); + + await client.compact(); + + const after = await client.getEntries(); + // Append-only: pre-compaction entries are still there, in the same order + expect(after.entries.slice(0, before.entries.length).map((e) => e.id)).toEqual(before.entries.map((e) => e.id)); + expect(after.entries.some((e) => e.type === "compaction")).toBe(true); + }, 120000); + test("should set and get session name", async () => { await client.start();