Files
oh-my-pi/packages/coding-agent/src/session/session-history-format.ts
T
cuipengfei b506c8ee65 fix(advisor): split Session update into per-message user messages to keep prompt cache growing
The advisor sends its whole Session update as a single ever-growing user
message. Provider prompt caches are prefix-based: a single user message whose
text keeps growing invalidates the entire message on every turn, so cache_read
stays pinned at the instructions/tools boundary (observed 14491 tokens in
production, 11066 in tests) instead of growing with the session.

Split the update into multiple user messages — one per source message —
delivered via a single Agent.prompt(AgentMessage[]) call, so the provider
caches each appended message incrementally. Verified end-to-end: cache_read
grows 0 -> 11126 -> 11457 -> 11583 across turns with the split, versus pinned
11066 on the old single-message behavior.

- delta-split.ts: pure renderAdvisorDeltaChunks using chunked
  formatSessionHistoryMarkdown (shared toolResultIndex/consumedToolCallIds/
  watchedRoleState) so toolCall/result pairing and role collapsing stay
  byte-identical to the old single-block render (equivalence-tested).
- session-history-format.ts: add HistoryFormatOptions.watchedRoleState so
  chunked renders collapse consecutive same-role messages exactly like the
  single-block render.
- runtime.ts: #prepareBatch does a single dedup+render pass; #drain delivers
  agent.prompt(preparedMessages) (array), falling back to the string.
- Keep field-selective fingerprint (candidate 1) + wip-marker-at-tail
  (candidate 3) as complementary wins.

Tests: advisor suite 209 pass / 0 fail; type check clean; lint clean.
Affected subsets (342 tests) green; full suite hits WSL EMFILE fd limit.
2026-08-04 22:36:41 +08:00

474 lines
17 KiB
TypeScript

/**
* 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 type { AssistantMessage, ImageContent, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
import { escapeXmlText } from "@oh-my-pi/pi-utils";
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
import type {
BashExecutionMessage,
BranchSummaryMessage,
CompactionSummaryMessage,
CustomMessage,
FileMentionMessage,
HookMessage,
PythonExecutionMessage,
} from "./messages";
export interface HistoryFormatOptions {
/** Optional H1 prepended to the transcript. */
title?: string;
/** Render assistant thinking blocks (default: elided). */
includeThinking?: boolean;
/** Render tool intent comment before tool call lines. */
includeToolIntent?: boolean;
/** Render watched-session roles as inline `**agent**:` / `**user**:` labels (collapsing consecutive same-role messages) instead of `## ` headings, so a primary transcript embedded inside an advisor turn stays visually distinct. */
watchedRoles?: boolean;
/**
* Expand the primary agent's injected constraint context — plan mode's rules
* (`plan-mode-context`) and the approved plan it implements
* (`plan-mode-reference`) — verbatim instead of as a truncated one-liner,
* wrapped in a `<primary-context>` tag so a reviewer reads it as the primary's
* instructions, not its own. The advisor sets this: a truncated rule (plan
* mode's "NEVER create files … except the plan file") makes it raise false
* blockers. See {@link PRIMARY_CONTEXT_CUSTOM_TYPES}. Other custom messages
* still collapse to a one-liner.
*/
expandPrimaryContext?: boolean;
/**
* Append the full unified diff (from a tool result's `details.diff`) below
* edit/apply_patch tool lines, instead of just the path. The advisor sets
* this so it sees what changed without re-reading the file.
*/
expandEditDiffs?: boolean;
/**
* Chunked rendering support: a caller formatting one logical transcript in
* several calls (the advisor's chunked delta render) passes a result index
* built over the WHOLE delta plus one shared consumed-id set, so a toolCall
* finds its toolResult across chunk boundaries and the result is never
* re-rendered as an orphan in a later chunk.
*/
toolResultIndex?: ReadonlyMap<string, ToolResultMessage>;
consumedToolCallIds?: Set<string>;
/**
* Chunked rendering state: a mutable holder for the watched-role label
* (`**user**:` / `**agent**:`) that ended the previous chunk. Lets a caller
* formatting one logical transcript across several calls (advisor
* multi-message split) keep consecutive same-role collapsing byte-identical
* to the single-block render: pass one object across all chunk calls.
*/
watchedRoleState?: { lastLabel: string | undefined };
}
/** 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",
"note",
"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;
}
export function formatExecutionSourcePreview(source: string): string {
return oneLine(source);
}
/** 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;
}
function primaryArgValue(value: unknown): string {
if (typeof value === "string" && value.length > 0) return value;
if (Array.isArray(value) && value.length > 0 && value.every(v => typeof v === "string")) {
return value.join(", ");
}
return "";
}
/** Pick the most informative scalar argument of a tool call. */
export function formatToolCallPrimaryArg(name: string, args: Record<string, unknown> | undefined): string {
if (!args || typeof args !== "object") return "";
// Advisor note is the most informative summary; preserve severity too.
if (name === "advise") {
const note = typeof args.note === "string" ? args.note : "";
const severity = typeof args.severity === "string" ? args.severity : "";
if (note && severity) return oneLine(`${severity}: ${note}`);
if (note) return oneLine(note);
if (severity) return oneLine(severity);
}
if (name === "grep") {
const pattern = primaryArgValue(args.pattern);
const paths = primaryArgValue(args.path) || primaryArgValue(args.paths);
if (pattern && paths) return oneLine(`${pattern} @ ${paths}`);
if (pattern) return oneLine(pattern);
if (paths) return oneLine(paths);
}
if (name === "glob") {
const paths = primaryArgValue(args.path) || primaryArgValue(args.paths);
if (paths) return oneLine(paths);
}
if (name === "ast_grep") {
const pattern = primaryArgValue(args.pat);
if (pattern) return oneLine(pattern);
}
for (const key of PRIMARY_ARG_KEYS) {
const value = args[key];
const summary = primaryArgValue(value);
if (summary) return oneLine(summary);
}
// 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 "";
}
}
export function formatToolCallIntentPreview(args: Record<string, unknown> | undefined): string | undefined {
const intent = args?.[INTENT_FIELD];
return typeof intent === "string" && intent.trim() ? oneLine(intent, 80) : undefined;
}
export function formatToolResultErrorPreview(content: string | readonly (TextContent | ImageContent)[]): string {
return oneLine(contentToText(content).split("\n", 1)[0] ?? "");
}
/**
* Wrap a diff body in a backtick fence sized to outlast the longest backtick
* run inside it, so a diff that touches markdown (triple backticks) can't break
* out of the fence. Info string `diff` for syntax highlighting.
*/
function fenceDiff(diff: string): string {
const longest = diff.match(/`+/g)?.reduce((m, run) => Math.max(m, run.length), 0) ?? 0;
const fence = "`".repeat(Math.max(3, longest + 1));
return `${fence}diff\n${diff}\n${fence}`;
}
/** 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,
includeToolIntent?: boolean,
expandEditDiffs?: boolean,
): string {
const head = `→ ${name}(${formatToolCallPrimaryArg(name, args)})`;
let base: string;
if (!result) {
base = `${head} ⇒ pending`;
} else {
const text = contentToText(result.content);
const lines = lineCount(text);
const count = `${lines} ${lines === 1 ? "line" : "lines"}`;
if (result.isError) {
const firstLine = formatToolResultErrorPreview(result.content);
base = firstLine ? `${head} ⇒ error · ${count} — ${firstLine}` : `${head} ⇒ error · ${count}`;
} else {
base = `${head} ⇒ ok · ${count}`;
}
}
if (expandEditDiffs) {
const diff = (result?.details as { diff?: unknown } | undefined)?.diff;
if (typeof diff === "string" && diff.trim()) {
base = `${base}\n${fenceDiff(diff)}`;
}
}
const formattedIntent = includeToolIntent ? formatToolCallIntentPreview(args) : undefined;
if (formattedIntent) return `// ${formattedIntent}\n${base}`;
return base;
}
/** One line for a user-initiated `!`/`$` execution. Always attributed to the
* user: these roles never carry agent-run commands (the model's bash goes
* through `toolCall`), so the `user-` prefix makes provenance explicit for the
* advisor and history readers regardless of render mode. */
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);
const sourcePreview = formatExecutionSourcePreview(source);
return `→ user-${kind}! ${sourcePreview} ⇒ ${status} · ${lines} ${lines === 1 ? "line" : "lines"}`;
}
/**
* Hidden custom messages that inject the primary agent's operative *constraints*
* — plan mode's rules and the approved plan it implements. A reviewer (the
* advisor) must read these verbatim; truncating them hides load-bearing
* exceptions (e.g. plan mode permits exactly one plan file). Every other custom
* type stays a one-liner.
*
* Deliberately excludes `goal-mode-context`: its body carries live budget
* counters (tokens/seconds used) that change every turn, so it can neither be
* deduped against a prior copy nor expanded each turn without flooding the
* reviewer — and its constraints don't drive the file-write misreads this
* targets.
*/
export const PRIMARY_CONTEXT_CUSTOM_TYPES: ReadonlySet<string> = new Set(["plan-mode-context", "plan-mode-reference"]);
/** Hidden non-primary custom messages whose content is needed to understand visible transcript entries. */
const CONTEXTUAL_NON_PRIMARY_HIDDEN_CUSTOM_TYPES: Record<string, true> = {
"image-attachment-description": true,
};
/** 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.
// Chunked callers supply a whole-delta index + shared consumed set so
// call/result pairs resolve across chunk boundaries.
let resultsByCallId = opts?.toolResultIndex;
if (!resultsByCallId) {
const local = new Map<string, ToolResultMessage>();
for (const msg of typed) {
if (msg.role === "toolResult") {
local.set(msg.toolCallId, msg);
}
}
resultsByCallId = local;
}
const consumed = opts?.consumedToolCallIds ?? new Set<string>();
// In watched mode, consecutive same-role messages collapse under one label
// (the watched agent emits one assistant message per tool call, so otherwise
// every call repeats `**agent**:`). Cleared whenever a
// non-role-labeled line is emitted so the next turn re-labels.
// Chunked callers seed the previous chunk's trailing label so collapsing
// stays byte-identical to the single-block render.
let lastWatchedLabel: string | undefined = opts?.watchedRoleState?.lastLabel;
// Emit a watched-mode role label, collapsing consecutive same-role turns
// under one label (matching the user/assistant paths). Used for the
// user-attributed `!`/`$` execution lines so the advisor never reads them
// as agent actions.
const pushWatchedRole = (label: string, body: string): void => {
if (lastWatchedLabel === label) {
lines.push(body, "");
} else {
lines.push(label, body, "");
lastWatchedLabel = label;
}
};
for (const msg of typed) {
switch (msg.role) {
case "user":
case "developer": {
const text = contentToText(msg.content);
if (!text.trim()) break;
if (opts?.watchedRoles) {
const label = `**${msg.role}**:`;
if (lastWatchedLabel === label) {
lines.push(text, "");
} else {
lines.push(label, text, "");
lastWatchedLabel = label;
}
} else {
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, opts?.includeToolIntent, opts?.expandEditDiffs),
);
} else if (opts?.includeThinking && block.type === "thinking" && block.thinking.trim()) {
body.push(`_thinking:_ ${block.thinking}`);
}
// redactedThinking elided entirely (no readable text)
}
if (body.length === 0) break;
if (opts?.watchedRoles) {
const label = "**agent**:";
if (lastWatchedLabel === label) {
lines.push(...body, "");
} else {
lines.push(label, ...body, "");
lastWatchedLabel = label;
}
} else {
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, opts?.includeToolIntent, opts?.expandEditDiffs), "");
lastWatchedLabel = undefined;
break;
}
case "bashExecution": {
const bashMsg = msg as BashExecutionMessage;
if (bashMsg.excludeFromContext) break;
const bashLine = executionLine("bash", bashMsg.command, bashMsg);
if (opts?.watchedRoles) {
pushWatchedRole("**user**:", bashLine);
} else {
lines.push(bashLine, "");
lastWatchedLabel = undefined;
}
break;
}
case "pythonExecution": {
const pythonMsg = msg as PythonExecutionMessage;
if (pythonMsg.excludeFromContext) break;
const pythonLine = executionLine("python", pythonMsg.code, pythonMsg);
if (opts?.watchedRoles) {
pushWatchedRole("**user**:", pythonLine);
} else {
lines.push(pythonLine, "");
lastWatchedLabel = undefined;
}
break;
}
case "custom":
case "hookMessage": {
const custom = msg as CustomMessage | HookMessage;
if (
custom.display === false &&
!PRIMARY_CONTEXT_CUSTOM_TYPES.has(custom.customType) &&
CONTEXTUAL_NON_PRIMARY_HIDDEN_CUSTOM_TYPES[custom.customType] !== true
) {
break;
}
if (opts?.expandPrimaryContext && PRIMARY_CONTEXT_CUSTOM_TYPES.has(custom.customType)) {
const text = contentToText(custom.content).trim();
if (text) {
lines.push(
`<primary-context kind="${custom.customType}">`,
escapeXmlText(text),
"</primary-context>",
"",
);
}
} else {
lines.push(customOneLiner(custom), "");
}
lastWatchedLabel = undefined;
break;
}
case "branchSummary": {
const branchMsg = msg as BranchSummaryMessage;
lines.push(`[branch] from ${branchMsg.fromId}: ${oneLine(branchMsg.summary)}`, "");
lastWatchedLabel = undefined;
break;
}
case "compactionSummary": {
const compactMsg = msg as CompactionSummaryMessage;
lines.push(`[compaction] ${oneLine(compactMsg.summary)}`, "");
lastWatchedLabel = undefined;
break;
}
case "fileMention": {
const fileMsg = msg as FileMentionMessage;
lines.push(`[file-mention] ${oneLine(fileMsg.files.map(f => f.path).join(", "))}`, "");
lastWatchedLabel = undefined;
break;
}
}
}
if (opts?.watchedRoleState) {
opts.watchedRoleState.lastLabel = lastWatchedLabel;
}
return `${lines.join("\n").trim()}\n`;
}