feat(coding-agent): added history:// internal URL for agent transcripts

Registers a HistoryProtocolHandler with the internal URL router: history:// lists every registered agent (id, status, kind, last activity) and history://<agentId> renders a concise markdown transcript (tool calls collapsed to one line each, thinking elided). Live refs render from the in-memory message array; parked refs load read-only from the JSONL session file via loadSessionMessagesReadOnly (no writer, no lock). System prompt + read tool prompt + docs/tools/read.md learn the new scheme.
This commit is contained in:
can1357
2026-06-10 17:52:13 +02:00
parent 2b28816010
commit a64ff00cb8
11 changed files with 667 additions and 7 deletions
+2 -2
View File
@@ -10,7 +10,7 @@
- `packages/coding-agent/src/tools/archive-reader.ts` — detect `archive.ext:inner/path`, index archives, list/read entries.
- `packages/coding-agent/src/tools/sqlite-reader.ts` — detect SQLite targets, parse selectors, render tables.
- `packages/coding-agent/src/tools/fetch.ts` — URL parsing, fetch/render pipeline, URL cache/artifacts.
- `packages/coding-agent/src/internal-urls/router.ts` — resolve `agent://`, `artifact://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, and `vault://`.
- `packages/coding-agent/src/internal-urls/router.ts` — resolve `agent://`, `artifact://`, `history://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, and `vault://`.
- `packages/coding-agent/src/edit/notebook.ts` — convert `.ipynb` to editable `# %% [...] cell:N` text.
- `packages/coding-agent/src/utils/file-display-mode.ts` — decide hashline vs line-number vs raw display.
- `packages/coding-agent/src/workspace-tree.ts` — render directory trees.
@@ -196,7 +196,7 @@ URL selectors are parsed separately in `packages/coding-agent/src/tools/fetch.ts
### Internal URLs
- `read` does not resolve these itself; it delegates to `session.internalRouter.resolve()`.
- Registered protocols are outside this file, but the router in `packages/coding-agent/src/internal-urls/router.ts` is built for `agent://`, `artifact://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, and `skill://`.
- Registered protocols are outside this file, but the router in `packages/coding-agent/src/internal-urls/router.ts` is built for `agent://`, `artifact://`, `history://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, and `skill://`.
- `#handleInternalUrl()` behavior:
- parses the URL with `parseInternalUrl()` so colons inside the host segment are legal
- for `agent://`, treats non-root path extraction or `?q=` extraction as a special no-pagination mode
@@ -0,0 +1,113 @@
/**
* Protocol handler for history:// URLs.
*
* Exposes agent transcripts as concise markdown. Live refs render from the
* in-memory message array; parked refs (session disposed, sessionFile
* retained) load read-only from the JSONL session file — no writer, no lock.
*
* URL forms:
* - history:// - Index of all registry agents (id, status, kind, last activity)
* - history://<agentId> - Concise markdown transcript of that agent
*/
import type { AgentRef } from "../registry/agent-registry";
import { AgentRegistry } from "../registry/agent-registry";
import { formatSessionHistoryMarkdown } from "../session/session-history-format";
import { loadSessionMessagesReadOnly } from "../session/session-manager";
import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
/** Humanize a last-activity timestamp as `Ns/Nm/Nh/Nd ago`. */
function formatAgo(timestamp: number): string {
const diffMs = Math.max(0, Date.now() - timestamp);
const secs = Math.floor(diffMs / 1000);
if (secs < 60) return `${secs}s ago`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
/**
* Handler for history:// URLs.
*
* Resolves agent ids against the global AgentRegistry, serving transcripts
* for both live and parked agents.
*/
export class HistoryProtocolHandler implements ProtocolHandler {
readonly scheme = "history";
readonly immutable = false;
async resolve(url: InternalUrl): Promise<InternalResource> {
const agentId = url.rawHost || url.hostname;
const registry = AgentRegistry.global();
if (!agentId) {
const content = this.#renderIndex(registry.list());
return {
url: url.href,
content,
contentType: "text/markdown",
size: Buffer.byteLength(content, "utf-8"),
};
}
let ref = registry.get(agentId);
if (!ref) {
// Case-insensitive fallback: agent ids are human-typed (e.g. AuthLoader).
const lower = agentId.toLowerCase();
ref = registry.list().find(candidate => candidate.id.toLowerCase() === lower);
}
if (!ref) {
const known = registry.list().map(candidate => candidate.id);
const knownStr = known.length > 0 ? known.join(", ") : "none";
throw new Error(`Unknown agent: ${agentId}\nKnown agents: ${knownStr}\nList all with history://`);
}
const notes: string[] = [];
let messages: unknown[];
if (ref.session) {
messages = ref.session.messages;
notes.push("Source: live session");
} else if (ref.sessionFile) {
messages = await loadSessionMessagesReadOnly(ref.sessionFile);
notes.push(`Source: session file (read-only, ${ref.status})`);
} else {
throw new Error(`Agent ${ref.id} has no transcript: session is gone and no session file was retained`);
}
const content = formatSessionHistoryMarkdown(messages, { title: `${ref.id} (${ref.status})` });
return {
url: url.href,
content,
contentType: "text/markdown",
size: Buffer.byteLength(content, "utf-8"),
sourcePath: ref.sessionFile ?? undefined,
notes,
};
}
#renderIndex(refs: AgentRef[]): string {
const lines: string[] = ["# Agents", ""];
if (refs.length === 0) {
lines.push("No agents registered.");
return `${lines.join("\n")}\n`;
}
lines.push("| id | status | kind | parent | last activity |", "|---|---|---|---|---|");
for (const ref of refs) {
lines.push(
`| ${ref.id} | ${ref.status} | ${ref.kind} | ${ref.parentId ?? "—"} | ${formatAgo(ref.lastActivity)} |`,
);
}
lines.push("", "Read a transcript with `read history://<id>`.");
return `${lines.join("\n")}\n`;
}
async complete(): Promise<UrlCompletion[]> {
return AgentRegistry.global()
.list()
.map(ref => ({
value: ref.id,
description: `${ref.status} · ${ref.kind}${ref.parentId ? ` · parent ${ref.parentId}` : ""}`,
}));
}
}
@@ -10,6 +10,7 @@
export * from "./agent-protocol";
export * from "./artifact-protocol";
export * from "./history-protocol";
export * from "./issue-pr-protocol";
export * from "./json-query";
export * from "./local-protocol";
@@ -1,5 +1,5 @@
/**
* Internal URL router for internal protocols (`agent://`, `artifact://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, and `vault://`).
* Internal URL router for internal protocols (`agent://`, `artifact://`, `history://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, and `vault://`).
*
* One process-global router with one handler per scheme. Access via
* `InternalUrlRouter.instance()`. Handlers are stateless; per-session and
@@ -7,6 +7,7 @@
*/
import { AgentProtocolHandler } from "./agent-protocol";
import { ArtifactProtocolHandler } from "./artifact-protocol";
import { HistoryProtocolHandler } from "./history-protocol";
import { IssueProtocolHandler, PrProtocolHandler } from "./issue-pr-protocol";
import { LocalProtocolHandler } from "./local-protocol";
import { McpProtocolHandler } from "./mcp-protocol";
@@ -35,6 +36,7 @@ export class InternalUrlRouter {
this.register(new McpProtocolHandler());
this.register(new IssueProtocolHandler());
this.register(new PrProtocolHandler());
this.register(new HistoryProtocolHandler());
}
/** Process-global router instance. */
@@ -1,7 +1,7 @@
/**
* Types for the internal URL routing system.
*
* Internal URLs (`agent://`, `artifact://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, and `vault://`) are resolved by tools like read,
* Internal URLs (`agent://`, `artifact://`, `history://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, and `vault://`) are resolved by tools like read,
* providing access to agent outputs and server resources without exposing filesystem paths.
*/
@@ -149,6 +149,7 @@ With most FS/bash-like tools, static references to them will automatically resol
- `agent://<id>`: full agent output artifact
- `/<path>`: JSON field extraction
- `artifact://<id>`: Artifact content
- `history://<agentId>`: agent transcript as concise markdown; bare `history://` lists agents
- `local://<name>.md`: Plan artifacts and shared content with subagents
{{#if hasObsidian}}
- `vault://<vault>/<path>`: Obsidian vault content (read/edit). `vault://` lists vaults; `vault://_/…` targets the active vault. File-scoped `?op=outline|backlinks|links|tags|properties|tasks|base|…`; vault-scoped `?op=search&q=…|daily|tasks|orphans|unresolved|bases|…`.
@@ -8,7 +8,7 @@ Read files, directories, archives, SQLite databases, images, documents, internal
## Parameters
- `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `memory://`, `rule://`, `local://`, `vault://`, `mcp://`, `omp://`, `issue://`, `pr://`), or URL. Append `:<sel>` for line ranges, raw mode, or special modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).
- `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `history://`, `memory://`, `rule://`, `local://`, `vault://`, `mcp://`, `omp://`, `issue://`, `pr://`), or URL. Append `:<sel>` for line ranges, raw mode, or special modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).
## Selectors
@@ -74,7 +74,7 @@ For `.sqlite`, `.sqlite3`, `.db`, `.db3`:
# Internal URIs
`skill://<name>`, `agent://<id>`, `artifact://<id>`, `memory://root`, `rule://<name>`, `local://<name>.md`, `vault://<vault>/<path>`, `mcp://<uri>`, `omp://<doc>.md`, `issue://<N>`, and `pr://<N>` resolve transparently and accept the same line selectors as filesystem paths. Use `artifact://<id>` to recover full output that a previous bash/eval/tool result spilled or truncated.
`skill://<name>`, `agent://<id>`, `artifact://<id>`, `history://<agentId>`, `memory://root`, `rule://<name>`, `local://<name>.md`, `vault://<vault>/<path>`, `mcp://<uri>`, `omp://<doc>.md`, `issue://<N>`, and `pr://<N>` resolve transparently and accept the same line selectors as filesystem paths. Use `artifact://<id>` to recover full output that a previous bash/eval/tool result spilled or truncated. `history://<agentId>` is an agent's transcript as concise markdown; bare `history://` lists agents.
<critical>
- You MUST use `read` for every file, directory, archive, and URL inspection. `cat`, `head`, `tail`, `less`, `more`, `ls`, `tar`, `unzip`, `curl`, `wget` are FORBIDDEN — any such bash call is a bug, regardless of how short or convenient it looks.
@@ -0,0 +1,246 @@
/**
* Concise markdown transcript serializer for `history://` URLs.
*
* Unlike `session-dump-format.ts` (verbose `/dump` export), this emits a
* compressed transcript: full user/assistant/developer text, tool call +
* result pairs collapsed to single lines, thinking elided, custom messages
* as one-liners. No system prompt, no tool catalog, no config sections.
*/
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, ImageContent, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
import type {
BashExecutionMessage,
BranchSummaryMessage,
CompactionSummaryMessage,
CustomMessage,
FileMentionMessage,
HookMessage,
PythonExecutionMessage,
} from "./messages";
export interface HistoryFormatOptions {
/** Optional H1 prepended to the transcript. */
title?: string;
}
/** Max length of the primary-arg summary inside `→ tool(...)` lines. */
const PRIMARY_ARG_MAX = 120;
/** Per-tool preference order for the most informative scalar argument. */
const PRIMARY_ARG_KEYS = [
"path",
"file_path",
"filePath",
"command",
"cmd",
"pattern",
"url",
"query",
"prompt",
"assignment",
"message",
"op",
"name",
"id",
] as const;
/** Collapse whitespace runs and truncate to `max` chars with an ellipsis. */
function oneLine(text: string, max = PRIMARY_ARG_MAX): string {
const flat = text.replace(/\s+/g, " ").trim();
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
}
/** Join the text blocks of a string-or-blocks content field. Images become `[image]`. */
function contentToText(content: string | readonly (TextContent | ImageContent)[]): string {
if (typeof content === "string") return content;
const parts: string[] = [];
for (const block of content) {
if (block.type === "text") parts.push(block.text);
else parts.push("[image]");
}
return parts.join("\n");
}
function lineCount(text: string): number {
if (!text) return 0;
return text.split("\n").length;
}
/** Pick the most informative scalar argument of a tool call. */
function primaryArg(args: Record<string, unknown> | undefined): string {
if (!args || typeof args !== "object") return "";
for (const key of PRIMARY_ARG_KEYS) {
const value = args[key];
if (typeof value === "string" && value.length > 0) return oneLine(value);
if (Array.isArray(value) && value.length > 0 && value.every(v => typeof v === "string")) {
return oneLine(value.join(", "));
}
}
// Fallback: first non-intent string arg, then a compact JSON of the args.
const rest: Record<string, unknown> = {};
let restCount = 0;
for (const key in args) {
if (key === INTENT_FIELD) continue;
const value = args[key];
if (typeof value === "string" && value.length > 0) return oneLine(value);
rest[key] = value;
restCount++;
}
if (restCount === 0) return "";
try {
return oneLine(JSON.stringify(rest));
} catch {
return "";
}
}
/** One line per tool call: `→ read(src/foo.ts:50-80) ⇒ ok · 31 lines`. */
function toolCallLine(
name: string,
args: Record<string, unknown> | undefined,
result: ToolResultMessage | undefined,
): string {
const head = `→ ${name}(${primaryArg(args)})`;
if (!result) return `${head} ⇒ pending`;
const text = contentToText(result.content);
const lines = lineCount(text);
const count = `${lines} ${lines === 1 ? "line" : "lines"}`;
if (result.isError) {
const firstLine = oneLine(text.split("\n", 1)[0] ?? "");
return firstLine ? `${head} ⇒ error · ${count} — ${firstLine}` : `${head} ⇒ error · ${count}`;
}
return `${head} ⇒ ok · ${count}`;
}
/** One line for a user-initiated `!`/`$` execution. */
function executionLine(
kind: "bash" | "python",
source: string,
msg: BashExecutionMessage | PythonExecutionMessage,
): string {
const status = msg.cancelled
? "cancelled"
: msg.exitCode !== undefined && msg.exitCode !== 0
? `error · exit ${msg.exitCode}`
: "ok";
const lines = lineCount(msg.output);
return `→ ${kind}! ${oneLine(source)} ⇒ ${status} · ${lines} ${lines === 1 ? "line" : "lines"}`;
}
/** One-liner for custom/hook messages: `[irc] A → B: body…`. */
function customOneLiner(msg: CustomMessage | HookMessage): string {
const details = (msg.details ?? {}) as Record<string, unknown>;
const str = (key: string): string => (typeof details[key] === "string" ? (details[key] as string) : "");
switch (msg.customType) {
case "irc:incoming":
return `[irc] ${str("from") || "?"} → me: ${oneLine(str("message"))}`;
case "irc:relay":
return `[irc] ${str("from") || "?"} → ${str("to") || "?"}: ${oneLine(str("body"))}`;
case "async-result": {
const jobs = Array.isArray(details.jobs) && details.jobs.length > 0 ? details.jobs : [details];
const labels = jobs
.map(job => {
const j = (job ?? {}) as Record<string, unknown>;
return typeof j.label === "string" && j.label ? j.label : typeof j.jobId === "string" ? j.jobId : "job";
})
.join(", ");
return `[async-result] ${oneLine(labels)}`;
}
default:
return `[${msg.customType}] ${oneLine(contentToText(msg.content))}`;
}
}
/**
* Format a session's message array as a concise markdown transcript.
*
* `messages` is the session's in-memory message array (or the read-only
* equivalent loaded from a session file) — the same shapes
* `session-dump-format.ts` consumes.
*/
export function formatSessionHistoryMarkdown(messages: unknown[], opts?: HistoryFormatOptions): string {
const typed = messages as AgentMessage[];
const lines: string[] = [];
if (opts?.title) {
lines.push(`# ${opts.title}`, "");
}
// Index tool results by call id so each toolCall collapses to one line.
const resultsByCallId = new Map<string, ToolResultMessage>();
for (const msg of typed) {
if (msg.role === "toolResult") {
resultsByCallId.set(msg.toolCallId, msg);
}
}
const consumed = new Set<string>();
for (const msg of typed) {
switch (msg.role) {
case "user":
case "developer": {
const text = contentToText(msg.content);
if (!text.trim()) break;
lines.push(`## ${msg.role}`, "", text, "");
break;
}
case "assistant": {
const assistantMsg = msg as AssistantMessage;
const body: string[] = [];
for (const block of assistantMsg.content) {
if (block.type === "text") {
if (block.text.trim()) body.push(block.text);
} else if (block.type === "toolCall") {
const result = resultsByCallId.get(block.id);
if (result) consumed.add(block.id);
body.push(toolCallLine(block.name, block.arguments, result));
}
// thinking / redactedThinking elided entirely
}
if (body.length === 0) break;
lines.push("## assistant", "", ...body, "");
break;
}
case "toolResult": {
// Normally consumed by its toolCall; orphans (e.g. truncated history) get their own line.
if (consumed.has(msg.toolCallId)) break;
lines.push(toolCallLine(msg.toolName, undefined, msg), "");
break;
}
case "bashExecution": {
const bashMsg = msg as BashExecutionMessage;
if (bashMsg.excludeFromContext) break;
lines.push(executionLine("bash", bashMsg.command, bashMsg), "");
break;
}
case "pythonExecution": {
const pythonMsg = msg as PythonExecutionMessage;
if (pythonMsg.excludeFromContext) break;
lines.push(executionLine("python", pythonMsg.code, pythonMsg), "");
break;
}
case "custom":
case "hookMessage": {
lines.push(customOneLiner(msg as CustomMessage | HookMessage), "");
break;
}
case "branchSummary": {
const branchMsg = msg as BranchSummaryMessage;
lines.push(`[branch] from ${branchMsg.fromId}: ${oneLine(branchMsg.summary)}`, "");
break;
}
case "compactionSummary": {
const compactMsg = msg as CompactionSummaryMessage;
lines.push(`[compaction] ${oneLine(compactMsg.summary)}`, "");
break;
}
case "fileMention": {
const fileMsg = msg as FileMentionMessage;
lines.push(`[file-mention] ${oneLine(fileMsg.files.map(f => f.path).join(", "))}`, "");
break;
}
}
}
return `${lines.join("\n").trim()}\n`;
}
@@ -0,0 +1,189 @@
/**
* Contracts: history:// protocol handler (rework-contracts.md §6), resolved
* through `InternalUrlRouter.instance().resolve(...)` like real callers.
*
* - Bare `history://` renders an index listing registered agent ids.
* - `history://<id>` with a live ref renders the in-memory transcript.
* - A parked ref (session null, sessionFile retained) renders read-only from
* the JSONL session file.
* - An unknown id fails with an error listing the known ids.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { InternalUrlRouter } from "@oh-my-pi/pi-coding-agent/internal-urls";
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { CURRENT_SESSION_VERSION } from "@oh-my-pi/pi-coding-agent/session/session-manager";
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "history-protocol-"));
try {
return await fn(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
function fakeLiveSession(messages: unknown[]): AgentSession {
return { messages } as unknown as AgentSession;
}
/** Minimal current-version session JSONL: header + a linear user/assistant chain. */
function sessionFixtureJsonl(): string {
const timestamp = new Date().toISOString();
const header = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: "fixture-session",
timestamp,
cwd: "/tmp",
};
const userEntry = {
type: "message",
id: "m1",
parentId: null,
timestamp,
message: { role: "user", content: "parked hello", timestamp: 1 },
};
const assistantEntry = {
type: "message",
id: "m2",
parentId: "m1",
timestamp,
message: {
role: "assistant",
content: [{ type: "text", text: "parked reply" }],
api: "anthropic-messages",
provider: "anthropic",
model: "test-model",
usage: {},
stopReason: "stop",
timestamp: 2,
},
};
return `${JSON.stringify(header)}\n${JSON.stringify(userEntry)}\n${JSON.stringify(assistantEntry)}\n`;
}
describe("history:// protocol", () => {
beforeEach(() => {
AgentRegistry.resetGlobalForTests();
InternalUrlRouter.resetForTests();
});
afterEach(() => {
InternalUrlRouter.resetForTests();
AgentRegistry.resetGlobalForTests();
});
it("bare history:// renders an index listing registered agents", async () => {
AgentRegistry.global().register({
id: "HubAgent",
displayName: "task",
kind: "sub",
session: fakeLiveSession([]),
status: "idle",
});
const resource = await InternalUrlRouter.instance().resolve("history://");
expect(resource.contentType).toBe("text/markdown");
expect(resource.content).toContain("# Agents");
expect(resource.content).toContain("| HubAgent | idle | sub |");
});
it("history://<id> renders a live ref's in-memory transcript", async () => {
AgentRegistry.global().register({
id: "HubAgent",
displayName: "task",
kind: "sub",
session: fakeLiveSession([{ role: "user", content: "hello from live", timestamp: 1 }]),
status: "idle",
});
const resource = await InternalUrlRouter.instance().resolve("history://HubAgent");
expect(resource.content).toContain("# HubAgent (idle)");
expect(resource.content).toContain("## user");
expect(resource.content).toContain("hello from live");
expect(resource.notes).toContain("Source: live session");
});
it("resolves agent ids case-insensitively", async () => {
AgentRegistry.global().register({
id: "HubAgent",
displayName: "task",
kind: "sub",
session: fakeLiveSession([{ role: "user", content: "hello from live", timestamp: 1 }]),
status: "idle",
});
const resource = await InternalUrlRouter.instance().resolve("history://hubagent");
expect(resource.content).toContain("# HubAgent (idle)");
});
it("history://<id> renders a parked ref read-only from its session file", async () => {
await withTempDir(async dir => {
const sessionFile = path.join(dir, "parked.jsonl");
await Bun.write(sessionFile, sessionFixtureJsonl());
AgentRegistry.global().register({
id: "Sleeper",
displayName: "task",
kind: "sub",
session: null,
sessionFile,
status: "parked",
});
const resource = await InternalUrlRouter.instance().resolve("history://Sleeper");
expect(resource.content).toContain("# Sleeper (parked)");
expect(resource.content).toContain("parked hello");
expect(resource.content).toContain("parked reply");
expect(resource.sourcePath).toBe(sessionFile);
expect(resource.notes?.join("\n")).toContain("read-only");
});
});
it("rejects an unknown id with the list of known agents", async () => {
AgentRegistry.global().register({
id: "HubAgent",
displayName: "task",
kind: "sub",
session: fakeLiveSession([]),
status: "idle",
});
const error = await InternalUrlRouter.instance()
.resolve("history://Nope")
.then(
() => null,
err => err as Error,
);
expect(error).toBeInstanceOf(Error);
expect(error?.message).toContain("Unknown agent: Nope");
expect(error?.message).toContain("HubAgent");
});
it("rejects a ref with neither session nor session file", async () => {
AgentRegistry.global().register({
id: "Husk",
displayName: "task",
kind: "sub",
session: null,
sessionFile: null,
status: "aborted",
});
const error = await InternalUrlRouter.instance()
.resolve("history://Husk")
.then(
() => null,
err => err as Error,
);
expect(error?.message).toContain("no transcript");
});
});
@@ -125,7 +125,7 @@ describe("internal-url-autocomplete", () => {
it("exposes the completion-capable schemes", () => {
const schemes = InternalUrlRouter.instance().completionSchemes().sort();
expect(schemes).toEqual(["agent", "artifact", "local", "memory", "omp", "rule", "skill"]);
expect(schemes).toEqual(["agent", "artifact", "history", "local", "memory", "omp", "rule", "skill"]);
});
});
@@ -0,0 +1,108 @@
/**
* Contracts: history:// transcript serializer (rework-contracts.md §5).
*
* - `## user` / `## assistant` headers carry full text.
* - Thinking blocks are elided entirely.
* - Each toolCall collapses with its toolResult into ONE `→ name(…) ⇒ …`
* line (ok and error variants); result bodies are never dumped.
* - Custom messages render as one-liners (`[irc] from → me: …`).
* - No system prompt / tool catalog sections.
*/
import { describe, expect, it } from "bun:test";
import { formatSessionHistoryMarkdown } from "@oh-my-pi/pi-coding-agent/session/session-history-format";
function buildMessages(): unknown[] {
return [
{ role: "user", content: "Please read the config.", timestamp: 1 },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "SECRET-THOUGHT about the approach" },
{ type: "text", text: "Reading it now." },
{ type: "toolCall", id: "tc-1", name: "read", arguments: { path: "src/config.ts" } },
{ type: "toolCall", id: "tc-2", name: "bash", arguments: { command: "bun test" } },
],
api: "anthropic-messages",
provider: "anthropic",
model: "test-model",
usage: {},
stopReason: "toolUse",
timestamp: 2,
},
{
role: "toolResult",
toolCallId: "tc-1",
toolName: "read",
content: [{ type: "text", text: "const a = 1;\nconst b = 2;\nconst c = 3;" }],
isError: false,
timestamp: 3,
},
{
role: "toolResult",
toolCallId: "tc-2",
toolName: "bash",
content: [{ type: "text", text: "FAIL: 1 test failed" }],
isError: true,
timestamp: 4,
},
{
role: "custom",
customType: "irc:incoming",
content: "full rendered irc prompt that must not appear",
details: { from: "Main", message: "status update please" },
timestamp: 5,
},
];
}
describe("formatSessionHistoryMarkdown", () => {
it("renders role headers, collapses tool pairs to one line, and elides thinking", () => {
const output = formatSessionHistoryMarkdown(buildMessages());
expect(output).toContain("## user");
expect(output).toContain("Please read the config.");
expect(output).toContain("## assistant");
expect(output).toContain("Reading it now.");
// Thinking is elided entirely.
expect(output).not.toContain("SECRET-THOUGHT");
// Tool call + result collapse to one line each; bodies are not dumped.
expect(output).toContain("→ read(src/config.ts) ⇒ ok · 3 lines");
expect(output).not.toContain("const a = 1;");
// Error variant carries the first line of the error output.
expect(output).toContain("→ bash(bun test) ⇒ error · 1 line — FAIL: 1 test failed");
// Consumed toolResults do not render a second orphan line.
const toolLines = output.split("\n").filter(line => line.startsWith("→ "));
expect(toolLines).toHaveLength(2);
// Custom messages are one-liners; the rendered prompt body is dropped.
expect(output).toContain("[irc] Main → me: status update please");
expect(output).not.toContain("full rendered irc prompt");
// Concise transcript: no prompt/tool-catalog sections.
expect(output).not.toContain("System Prompt");
expect(output).not.toContain("Available Tools");
});
it("prefixes an H1 title when requested", () => {
const output = formatSessionHistoryMarkdown(buildMessages(), { title: "Spawnling (idle)" });
expect(output.startsWith("# Spawnling (idle)\n")).toBe(true);
});
it("renders an orphan toolResult (truncated history) as its own line", () => {
const output = formatSessionHistoryMarkdown([
{
role: "toolResult",
toolCallId: "tc-orphan",
toolName: "search",
content: [{ type: "text", text: "one match" }],
isError: false,
timestamp: 1,
},
]);
expect(output).toContain("→ search() ⇒ ok · 1 line");
});
});