mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-07-09 17:28:45 +00:00
feat(coding-agent): add get_entries and get_tree RPC commands
Adds two read-only RPC commands exposing existing SessionManager reads: - get_entries: all session entries in append order, with optional since-entry-id cursor (strictly-after semantics, error on unknown id), plus current leafId. - get_tree: getTree() roots plus current leafId. Since sessions are append-only trees with stable entry ids, an entry id is a durable cursor: external orchestrators can use these commands to catch up after a restart without losing pre-compaction history, and can observe branch structure (/tree jumps, abandoned branches) that get_messages hides.
This commit is contained in:
parent
1d48616328
commit
7ba1b6bfef
6 changed files with 167 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -224,6 +224,7 @@ export {
|
|||
type SessionInfoEntry,
|
||||
SessionManager,
|
||||
type SessionMessageEntry,
|
||||
type SessionTreeNode,
|
||||
type ThinkingLevelChangeEntry,
|
||||
} from "./core/session-manager.ts";
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -606,6 +606,24 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
|||
return success(id, "get_fork_messages", { messages });
|
||||
}
|
||||
|
||||
case "get_entries": {
|
||||
const sessionManager = session.sessionManager;
|
||||
let entries = sessionManager.getEntries();
|
||||
if (command.since !== undefined) {
|
||||
const sinceIndex = entries.findIndex((e) => 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 });
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue