mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-07-09 17:28:45 +00:00
fix(coding-agent): add entry renderers for session entries
This commit is contained in:
parent
8c9436407c
commit
ba10b60b51
18 changed files with 507 additions and 158 deletions
|
|
@ -5,9 +5,11 @@
|
|||
### Added
|
||||
|
||||
- Added public SDK exports for CLI-equivalent model and scoped-model resolution ([#6201](https://github.com/earendil-works/pi/issues/6201)).
|
||||
- Added extension entry renderers for persisted display-only session entries that are rendered in interactive mode without being sent to the model context.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed custom session entries appended during assistant streaming to render before the live assistant message, matching persisted session order.
|
||||
- Fixed oversized bash tool timeouts to fail with a clear validation error instead of being clamped to an immediate timeout ([#6181](https://github.com/earendil-works/pi/issues/6181)).
|
||||
|
||||
## [0.80.3] - 2026-06-30
|
||||
|
|
|
|||
|
|
@ -944,9 +944,10 @@ Read-only access to session state. See [Session Format](session-format.md) for t
|
|||
For `tool_call`, this state is synchronized through the current assistant message before handlers run. In parallel tool execution mode it is still not guaranteed to include sibling tool results from the same assistant message.
|
||||
|
||||
```typescript
|
||||
ctx.sessionManager.getEntries() // All entries
|
||||
ctx.sessionManager.getBranch() // Current branch
|
||||
ctx.sessionManager.getLeafId() // Current leaf entry ID
|
||||
ctx.sessionManager.getEntries() // All entries
|
||||
ctx.sessionManager.getBranch() // Current branch
|
||||
ctx.sessionManager.buildContextEntries() // Active branch entries with compaction applied
|
||||
ctx.sessionManager.getLeafId() // Current leaf entry ID
|
||||
```
|
||||
|
||||
### ctx.modelRegistry / ctx.model
|
||||
|
|
@ -1352,7 +1353,7 @@ pi.registerTool({
|
|||
|
||||
### pi.sendMessage(message, options?)
|
||||
|
||||
Inject a custom message into the session.
|
||||
Inject a custom message into the session. Custom messages participate in LLM context. For durable TUI-only content that should not be sent to the LLM, use [`pi.appendEntry()`](#piappendentrycustomtype-data) with [`pi.registerEntryRenderer()`](#piregisterentryrenderercustomtype-renderer).
|
||||
|
||||
```typescript
|
||||
pi.sendMessage({
|
||||
|
|
@ -1403,10 +1404,11 @@ See [send-user-message.ts](../examples/extensions/send-user-message.ts) for a co
|
|||
|
||||
### pi.appendEntry(customType, data?)
|
||||
|
||||
Persist extension state (does NOT participate in LLM context).
|
||||
Persist extension data. Custom entries do NOT participate in LLM context. In interactive mode, they can also render inside the chat transcript when paired with `pi.registerEntryRenderer()`.
|
||||
|
||||
```typescript
|
||||
pi.appendEntry("my-state", { count: 42 });
|
||||
pi.appendEntry("status-card", { title: "Indexed files", count: 17 });
|
||||
|
||||
// Restore on reload
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
|
|
@ -1524,7 +1526,27 @@ mode and would not execute if sent via `prompt`.
|
|||
|
||||
### pi.registerMessageRenderer(customType, renderer)
|
||||
|
||||
Register a custom TUI renderer for messages with your `customType`. See [Custom UI](#custom-ui).
|
||||
Register a custom TUI renderer for custom messages with your `customType`. Custom messages are created with `pi.sendMessage()` and participate in LLM context. See [Custom UI](#custom-ui).
|
||||
|
||||
### pi.registerEntryRenderer(customType, renderer)
|
||||
|
||||
Register a custom TUI renderer for custom entries with your `customType`. Custom entries are created with `pi.appendEntry()` and do not participate in LLM context.
|
||||
|
||||
```typescript
|
||||
import { Box, Text } from "@earendil-works/pi-tui";
|
||||
|
||||
pi.registerEntryRenderer("status-card", (entry, { expanded }, theme) => {
|
||||
const data = entry.data as { title: string; count: number };
|
||||
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
||||
box.addChild(new Text(`${theme.bold(data.title)}: ${data.count}`));
|
||||
if (expanded) {
|
||||
box.addChild(new Text(theme.fg("dim", JSON.stringify(data, null, 2))));
|
||||
}
|
||||
return box;
|
||||
});
|
||||
|
||||
pi.appendEntry("status-card", { title: "Indexed files", count: 17 });
|
||||
```
|
||||
|
||||
### pi.registerShortcut(shortcut, options)
|
||||
|
||||
|
|
@ -2528,9 +2550,9 @@ ctx.ui.setEditorComponent((tui, theme, keybindings) =>
|
|||
|
||||
See [tui.md](tui.md) Pattern 7 for a complete example with mode indicator.
|
||||
|
||||
### Message Rendering
|
||||
### Message and Entry Rendering
|
||||
|
||||
Register a custom renderer for messages with your `customType`:
|
||||
Register a custom renderer for messages with your `customType`. Use message renderers for content that should participate in LLM context:
|
||||
|
||||
```typescript
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
|
|
@ -2559,6 +2581,16 @@ pi.sendMessage({
|
|||
});
|
||||
```
|
||||
|
||||
For TUI-only content that should not be sent to the LLM, render custom entries instead:
|
||||
|
||||
```typescript
|
||||
pi.registerEntryRenderer("my-card", (entry, options, theme) => {
|
||||
return new Text(theme.fg("accent", JSON.stringify(entry.data)));
|
||||
});
|
||||
|
||||
pi.appendEntry("my-card", { status: "done" });
|
||||
```
|
||||
|
||||
### Theme Colors
|
||||
|
||||
All render functions receive a `theme` object. See [themes.md](themes.md) for creating custom themes and the full color palette.
|
||||
|
|
@ -2685,6 +2717,7 @@ All examples in [examples/extensions/](../examples/extensions/).
|
|||
| `custom-provider-gitlab-duo/` | GitLab Duo integration | `registerProvider` with OAuth |
|
||||
| **Messages & Communication** |||
|
||||
| `message-renderer.ts` | Custom message rendering | `registerMessageRenderer`, `sendMessage` |
|
||||
| `entry-renderer.ts` | TUI-only custom entry rendering | `registerEntryRenderer`, `appendEntry` |
|
||||
| `event-bus.ts` | Inter-extension events | `pi.events` |
|
||||
| **Session Metadata** |||
|
||||
| `session-name.ts` | Name sessions for selector | `setSessionName`, `getSessionName` |
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ Extension state persistence. Does NOT participate in LLM context.
|
|||
{"type":"custom","id":"h8i9j0k1","parentId":"g7h8i9j0","timestamp":"2024-12-03T14:20:00.000Z","customType":"my-extension","data":{"count":42}}
|
||||
```
|
||||
|
||||
Use `customType` to identify your extension's entries on reload.
|
||||
Use `customType` to identify your extension's entries on reload. Interactive mode can render custom entries via `pi.registerEntryRenderer(customType, renderer)`, but they still do not participate in LLM context.
|
||||
|
||||
### CustomMessageEntry
|
||||
|
||||
|
|
@ -306,15 +306,24 @@ Entries form a tree:
|
|||
|
||||
## Context Building
|
||||
|
||||
`buildSessionContext()` walks from the current leaf to the root, producing the message list for the LLM:
|
||||
`buildContextEntries()` walks from the current leaf to the root, producing the active entry list while honoring compaction:
|
||||
|
||||
1. Collects all entries on the path
|
||||
2. Extracts current model and thinking level settings
|
||||
3. If a `CompactionEntry` is on the path:
|
||||
- Emits the summary first
|
||||
- Then messages from `firstKeptEntryId` to compaction
|
||||
- Then messages after compaction
|
||||
4. Converts `BranchSummaryEntry` and `CustomMessageEntry` to appropriate message formats
|
||||
2. If a `CompactionEntry` is on the path:
|
||||
- Includes the compaction entry first
|
||||
- Then entries from `firstKeptEntryId` to compaction
|
||||
- Then entries after compaction
|
||||
3. Preserves non-message entries in the selected range so interactive mode can render them
|
||||
|
||||
`buildSessionContext()` builds on that entry list to produce the message list for the LLM:
|
||||
|
||||
1. Extracts current model and thinking level settings from the full path
|
||||
2. Converts selected entries to messages:
|
||||
- `message` -> stored `AgentMessage`
|
||||
- `compaction` -> `compactionSummary`
|
||||
- `branch_summary` -> `branchSummary`
|
||||
- `custom_message` -> `CustomMessage`
|
||||
- `custom` -> no context message
|
||||
|
||||
## Parsing Example
|
||||
|
||||
|
|
@ -401,6 +410,7 @@ Key methods for working with sessions programmatically.
|
|||
- `branchWithSummary(entryId, summary, details?, fromHook?)` - Branch with context summary
|
||||
|
||||
### Instance Methods - Context & Info
|
||||
- `buildContextEntries()` - Get active branch entries with compaction applied
|
||||
- `buildSessionContext()` - Get messages, thinkingLevel, and model for LLM
|
||||
- `getEntries()` - All entries (excluding header)
|
||||
- `getHeader()` - Session header metadata
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
|||
| Extension | Description |
|
||||
|-----------|-------------|
|
||||
| `message-renderer.ts` | Custom message rendering with colors and expandable details via `registerMessageRenderer` |
|
||||
| `entry-renderer.ts` | TUI-only session entry rendering via `appendEntry` and `registerEntryRenderer` |
|
||||
| `event-bus.ts` | Inter-extension communication via `pi.events` |
|
||||
|
||||
### Session Metadata
|
||||
|
|
|
|||
41
packages/coding-agent/examples/extensions/entry-renderer.ts
Normal file
41
packages/coding-agent/examples/extensions/entry-renderer.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Custom entry rendering example.
|
||||
*
|
||||
* Shows how to render durable extension data inside the chat without sending it
|
||||
* to the LLM. Custom entries are stored in the session via pi.appendEntry() and
|
||||
* rendered in interactive mode via pi.registerEntryRenderer().
|
||||
*
|
||||
* Usage: /status-card [message]
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Box, Text } from "@earendil-works/pi-tui";
|
||||
|
||||
interface StatusCardData {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerEntryRenderer<StatusCardData>("status-card", (entry, { expanded }, theme) => {
|
||||
const data = entry.data ?? { message: "No data", timestamp: Date.now() };
|
||||
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
||||
box.addChild(new Text(`${theme.fg("accent", "[status]")} ${data.message}`, 0, 0));
|
||||
|
||||
if (expanded) {
|
||||
box.addChild(new Text(theme.fg("dim", new Date(data.timestamp).toLocaleString()), 0, 0));
|
||||
}
|
||||
|
||||
return box;
|
||||
});
|
||||
|
||||
pi.registerCommand("status-card", {
|
||||
description: "Render a durable status card that is not sent to the LLM",
|
||||
handler: async (args) => {
|
||||
pi.appendEntry<StatusCardData>("status-card", {
|
||||
message: args.trim() || "Status card",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -86,7 +86,7 @@ import type { BashExecutionMessage, CustomMessage } from "./messages.ts";
|
|||
import type { ModelRegistry } from "./model-registry.ts";
|
||||
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts";
|
||||
import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts";
|
||||
import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.ts";
|
||||
import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.ts";
|
||||
import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.ts";
|
||||
import type { SettingsManager } from "./settings-manager.ts";
|
||||
import type { SlashCommandInfo } from "./slash-commands.ts";
|
||||
|
|
@ -137,6 +137,7 @@ export type AgentSessionEvent =
|
|||
followUp: readonly string[];
|
||||
}
|
||||
| { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" }
|
||||
| { type: "entry_appended"; entry: SessionEntry }
|
||||
| { type: "session_info_changed"; name: string | undefined }
|
||||
| { type: "thinking_level_changed"; level: ThinkingLevel }
|
||||
| {
|
||||
|
|
@ -2262,7 +2263,11 @@ export class AgentSession {
|
|||
});
|
||||
},
|
||||
appendEntry: (customType, data) => {
|
||||
this.sessionManager.appendCustomEntry(customType, data);
|
||||
const entryId = this.sessionManager.appendCustomEntry(customType, data);
|
||||
const entry = this.sessionManager.getEntry(entryId);
|
||||
if (entry) {
|
||||
this._emit({ type: "entry_appended", entry });
|
||||
}
|
||||
},
|
||||
setSessionName: (name) => {
|
||||
this.setSessionName(name);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ export type {
|
|||
EditorFactory,
|
||||
EditToolCallEvent,
|
||||
EditToolResultEvent,
|
||||
// Message and Entry Rendering
|
||||
EntryRenderer,
|
||||
EntryRenderOptions,
|
||||
ExecOptions,
|
||||
ExecResult,
|
||||
Extension,
|
||||
|
|
@ -91,7 +94,6 @@ export type {
|
|||
LsToolResultEvent,
|
||||
// Events - Message
|
||||
MessageEndEvent,
|
||||
// Message Rendering
|
||||
MessageRenderer,
|
||||
MessageRenderOptions,
|
||||
MessageStartEvent,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { execCommand } from "../exec.ts";
|
|||
import { createSyntheticSourceInfo } from "../source-info.ts";
|
||||
import { time } from "../timings.ts";
|
||||
import type {
|
||||
EntryRenderer,
|
||||
Extension,
|
||||
ExtensionAPI,
|
||||
ExtensionFactory,
|
||||
|
|
@ -269,6 +270,12 @@ function createExtensionAPI(
|
|||
extension.messageRenderers.set(customType, renderer as MessageRenderer);
|
||||
},
|
||||
|
||||
registerEntryRenderer<T>(customType: string, renderer: EntryRenderer<T>): void {
|
||||
runtime.assertActive();
|
||||
extension.entryRenderers ??= new Map();
|
||||
extension.entryRenderers.set(customType, renderer as EntryRenderer);
|
||||
},
|
||||
|
||||
// Flag access - checks extension registered it, reads from runtime
|
||||
getFlag(name: string): boolean | string | undefined {
|
||||
runtime.assertActive();
|
||||
|
|
@ -415,6 +422,7 @@ function createExtension(extensionPath: string, resolvedPath: string): Extension
|
|||
handlers: new Map(),
|
||||
tools: new Map(),
|
||||
messageRenderers: new Map(),
|
||||
entryRenderers: new Map(),
|
||||
commands: new Map(),
|
||||
flags: new Map(),
|
||||
shortcuts: new Map(),
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type {
|
|||
ContextEvent,
|
||||
ContextEventResult,
|
||||
ContextUsage,
|
||||
EntryRenderer,
|
||||
Extension,
|
||||
ExtensionActions,
|
||||
ExtensionCommandContext,
|
||||
|
|
@ -553,6 +554,16 @@ export class ExtensionRunner {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
getEntryRenderer(customType: string): EntryRenderer | undefined {
|
||||
for (const ext of this.extensions) {
|
||||
const renderer = ext.entryRenderers?.get(customType);
|
||||
if (renderer) {
|
||||
return renderer;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private resolveRegisteredCommands(): ResolvedCommand[] {
|
||||
const commands: RegisteredCommand[] = [];
|
||||
const counts = new Map<string, number>();
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import type { ModelRegistry } from "../model-registry.ts";
|
|||
import type {
|
||||
BranchSummaryEntry,
|
||||
CompactionEntry,
|
||||
CustomEntry,
|
||||
ReadonlySessionManager,
|
||||
SessionEntry,
|
||||
SessionManager,
|
||||
|
|
@ -1093,19 +1094,29 @@ export interface SessionBeforeTreeResult {
|
|||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message Rendering
|
||||
// Message and Entry Rendering
|
||||
// ============================================================================
|
||||
|
||||
export interface MessageRenderOptions {
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export interface EntryRenderOptions {
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export type MessageRenderer<T = unknown> = (
|
||||
message: CustomMessage<T>,
|
||||
options: MessageRenderOptions,
|
||||
theme: Theme,
|
||||
) => Component | undefined;
|
||||
|
||||
export type EntryRenderer<T = unknown> = (
|
||||
entry: CustomEntry<T>,
|
||||
options: EntryRenderOptions,
|
||||
theme: Theme,
|
||||
) => Component | undefined;
|
||||
|
||||
// ============================================================================
|
||||
// Command Registration
|
||||
// ============================================================================
|
||||
|
|
@ -1224,6 +1235,9 @@ export interface ExtensionAPI {
|
|||
/** Register a custom renderer for CustomMessageEntry. */
|
||||
registerMessageRenderer<T = unknown>(customType: string, renderer: MessageRenderer<T>): void;
|
||||
|
||||
/** Register a custom renderer for CustomEntry. Custom entries do not participate in LLM context. */
|
||||
registerEntryRenderer<T = unknown>(customType: string, renderer: EntryRenderer<T>): void;
|
||||
|
||||
// =========================================================================
|
||||
// Actions
|
||||
// =========================================================================
|
||||
|
|
@ -1598,6 +1612,7 @@ export interface Extension {
|
|||
handlers: Map<string, HandlerFn[]>;
|
||||
tools: Map<string, RegisteredTool>;
|
||||
messageRenderers: Map<string, MessageRenderer>;
|
||||
entryRenderers?: Map<string, EntryRenderer>;
|
||||
commands: Map<string, RegisteredCommand>;
|
||||
flags: Map<string, ExtensionFlag>;
|
||||
shortcuts: Map<KeyId, ExtensionShortcut>;
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@ export type ReadonlySessionManager = Pick<
|
|||
| "getEntry"
|
||||
| "getLabel"
|
||||
| "getBranch"
|
||||
| "buildContextEntries"
|
||||
| "getHeader"
|
||||
| "getEntries"
|
||||
| "getTree"
|
||||
|
|
@ -317,6 +318,126 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt
|
|||
return null;
|
||||
}
|
||||
|
||||
function buildEntryIndex(entries: SessionEntry[], byId?: Map<string, SessionEntry>): Map<string, SessionEntry> {
|
||||
if (byId) return byId;
|
||||
const index = new Map<string, SessionEntry>();
|
||||
for (const entry of entries) {
|
||||
index.set(entry.id, entry);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function buildSessionPath(
|
||||
entries: SessionEntry[],
|
||||
leafId?: string | null,
|
||||
byId?: Map<string, SessionEntry>,
|
||||
): SessionEntry[] {
|
||||
const index = buildEntryIndex(entries, byId);
|
||||
let leaf: SessionEntry | undefined;
|
||||
if (leafId === null) {
|
||||
return [];
|
||||
}
|
||||
if (leafId) {
|
||||
leaf = index.get(leafId);
|
||||
}
|
||||
leaf ??= entries[entries.length - 1];
|
||||
if (!leaf) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const path: SessionEntry[] = [];
|
||||
let current: SessionEntry | undefined = leaf;
|
||||
while (current) {
|
||||
path.push(current);
|
||||
current = current.parentId ? index.get(current.parentId) : undefined;
|
||||
}
|
||||
path.reverse();
|
||||
return path;
|
||||
}
|
||||
|
||||
function getSessionContextSettings(path: SessionEntry[]): Pick<SessionContext, "thinkingLevel" | "model"> {
|
||||
let thinkingLevel = "off";
|
||||
let model: { provider: string; modelId: string } | null = null;
|
||||
|
||||
for (const entry of path) {
|
||||
if (entry.type === "thinking_level_change") {
|
||||
thinkingLevel = entry.thinkingLevel;
|
||||
} else if (entry.type === "model_change") {
|
||||
model = { provider: entry.provider, modelId: entry.modelId };
|
||||
} else if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
model = { provider: entry.message.provider, modelId: entry.message.model };
|
||||
}
|
||||
}
|
||||
|
||||
return { thinkingLevel, model };
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one selected session entry into LLM/runtime messages.
|
||||
* Plain custom entries are display/state entries and do not participate in context.
|
||||
*/
|
||||
export function sessionEntryToContextMessages(entry: SessionEntry): AgentMessage[] {
|
||||
if (entry.type === "message") {
|
||||
return [entry.message];
|
||||
}
|
||||
if (entry.type === "custom_message") {
|
||||
return [createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp)];
|
||||
}
|
||||
if (entry.type === "branch_summary" && entry.summary) {
|
||||
return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)];
|
||||
}
|
||||
if (entry.type === "compaction") {
|
||||
return [createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp)];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the active, compaction-aware session entry list.
|
||||
*
|
||||
* This follows the current leaf path. If the path contains compaction entries,
|
||||
* the latest compaction is represented by the compaction entry itself, followed
|
||||
* by the kept entries starting at firstKeptEntryId and all entries after the
|
||||
* compaction entry. Older summarized entries are omitted.
|
||||
*/
|
||||
export function buildContextEntries(
|
||||
entries: SessionEntry[],
|
||||
leafId?: string | null,
|
||||
byId?: Map<string, SessionEntry>,
|
||||
): SessionEntry[] {
|
||||
const path = buildSessionPath(entries, leafId, byId);
|
||||
let compaction: CompactionEntry | null = null;
|
||||
|
||||
for (const entry of path) {
|
||||
if (entry.type === "compaction") {
|
||||
compaction = entry;
|
||||
}
|
||||
}
|
||||
|
||||
if (!compaction) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const compactionIdx = path.findIndex((entry) => entry.id === compaction.id);
|
||||
if (compactionIdx < 0) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const contextEntries: SessionEntry[] = [compaction];
|
||||
let foundFirstKept = false;
|
||||
for (let i = 0; i < compactionIdx; i++) {
|
||||
const entry = path[i];
|
||||
if (entry.id === compaction.firstKeptEntryId) {
|
||||
foundFirstKept = true;
|
||||
}
|
||||
if (foundFirstKept) {
|
||||
contextEntries.push(entry);
|
||||
}
|
||||
}
|
||||
contextEntries.push(...path.slice(compactionIdx + 1));
|
||||
return contextEntries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the session context from entries using tree traversal.
|
||||
* If leafId is provided, walks from that entry to root.
|
||||
|
|
@ -327,108 +448,9 @@ export function buildSessionContext(
|
|||
leafId?: string | null,
|
||||
byId?: Map<string, SessionEntry>,
|
||||
): SessionContext {
|
||||
// Build uuid index if not available
|
||||
if (!byId) {
|
||||
byId = new Map<string, SessionEntry>();
|
||||
for (const entry of entries) {
|
||||
byId.set(entry.id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Find leaf
|
||||
let leaf: SessionEntry | undefined;
|
||||
if (leafId === null) {
|
||||
// Explicitly null - return no messages (navigated to before first entry)
|
||||
return { messages: [], thinkingLevel: "off", model: null };
|
||||
}
|
||||
if (leafId) {
|
||||
leaf = byId.get(leafId);
|
||||
}
|
||||
if (!leaf) {
|
||||
// Fallback to last entry (when leafId is undefined)
|
||||
leaf = entries[entries.length - 1];
|
||||
}
|
||||
|
||||
if (!leaf) {
|
||||
return { messages: [], thinkingLevel: "off", model: null };
|
||||
}
|
||||
|
||||
// Walk from leaf to root, collecting path
|
||||
const path: SessionEntry[] = [];
|
||||
let current: SessionEntry | undefined = leaf;
|
||||
while (current) {
|
||||
path.push(current);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
path.reverse();
|
||||
|
||||
// Extract settings and find compaction
|
||||
let thinkingLevel = "off";
|
||||
let model: { provider: string; modelId: string } | null = null;
|
||||
let compaction: CompactionEntry | null = null;
|
||||
|
||||
for (const entry of path) {
|
||||
if (entry.type === "thinking_level_change") {
|
||||
thinkingLevel = entry.thinkingLevel;
|
||||
} else if (entry.type === "model_change") {
|
||||
model = { provider: entry.provider, modelId: entry.modelId };
|
||||
} else if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
model = { provider: entry.message.provider, modelId: entry.message.model };
|
||||
} else if (entry.type === "compaction") {
|
||||
compaction = entry;
|
||||
}
|
||||
}
|
||||
|
||||
// Build messages and collect corresponding entries
|
||||
// When there's a compaction, we need to:
|
||||
// 1. Emit summary first (entry = compaction)
|
||||
// 2. Emit kept messages (from firstKeptEntryId up to compaction)
|
||||
// 3. Emit messages after compaction
|
||||
const messages: AgentMessage[] = [];
|
||||
|
||||
const appendMessage = (entry: SessionEntry) => {
|
||||
if (entry.type === "message") {
|
||||
messages.push(entry.message);
|
||||
} else if (entry.type === "custom_message") {
|
||||
messages.push(
|
||||
createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),
|
||||
);
|
||||
} else if (entry.type === "branch_summary" && entry.summary) {
|
||||
messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
|
||||
}
|
||||
};
|
||||
|
||||
if (compaction) {
|
||||
// Emit summary first
|
||||
messages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp));
|
||||
|
||||
// Find compaction index in path
|
||||
const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === compaction.id);
|
||||
|
||||
// Emit kept messages (before compaction, starting from firstKeptEntryId)
|
||||
let foundFirstKept = false;
|
||||
for (let i = 0; i < compactionIdx; i++) {
|
||||
const entry = path[i];
|
||||
if (entry.id === compaction.firstKeptEntryId) {
|
||||
foundFirstKept = true;
|
||||
}
|
||||
if (foundFirstKept) {
|
||||
appendMessage(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit messages after compaction
|
||||
for (let i = compactionIdx + 1; i < path.length; i++) {
|
||||
const entry = path[i];
|
||||
appendMessage(entry);
|
||||
}
|
||||
} else {
|
||||
// No compaction - emit all messages, handle branch summaries and custom messages
|
||||
for (const entry of path) {
|
||||
appendMessage(entry);
|
||||
}
|
||||
}
|
||||
|
||||
const path = buildSessionPath(entries, leafId, byId);
|
||||
const { thinkingLevel, model } = getSessionContextSettings(path);
|
||||
const messages = buildContextEntries(entries, leafId, byId).flatMap(sessionEntryToContextMessages);
|
||||
return { messages, thinkingLevel, model };
|
||||
}
|
||||
|
||||
|
|
@ -1164,6 +1186,14 @@ export class SessionManager {
|
|||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the active, compaction-aware entry list for context/rendering.
|
||||
* Uses tree traversal from current leaf.
|
||||
*/
|
||||
buildContextEntries(): SessionEntry[] {
|
||||
return buildContextEntries(this.getEntries(), this.leafId, this.byId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the session context (what gets sent to the LLM).
|
||||
* Uses tree traversal from current leaf.
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ export type {
|
|||
ContextUsage,
|
||||
CustomToolCallEvent,
|
||||
EditToolCallEvent,
|
||||
EntryRenderer,
|
||||
EntryRenderOptions,
|
||||
ExecOptions,
|
||||
ExecResult,
|
||||
Extension,
|
||||
|
|
@ -214,6 +216,7 @@ export {
|
|||
} from "./core/sdk.ts";
|
||||
export {
|
||||
type BranchSummaryEntry,
|
||||
buildContextEntries,
|
||||
buildSessionContext,
|
||||
type CompactionEntry,
|
||||
CURRENT_SESSION_VERSION,
|
||||
|
|
@ -234,6 +237,7 @@ export {
|
|||
SessionManager,
|
||||
type SessionMessageEntry,
|
||||
type SessionTreeNode,
|
||||
sessionEntryToContextMessages,
|
||||
type ThinkingLevelChangeEntry,
|
||||
} from "./core/session-manager.ts";
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import type { Component } from "@earendil-works/pi-tui";
|
||||
import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui";
|
||||
import type { EntryRenderer } from "../../../core/extensions/types.ts";
|
||||
import type { CustomEntry } from "../../../core/session-manager.ts";
|
||||
import { theme } from "../theme/theme.ts";
|
||||
|
||||
/**
|
||||
* Component that renders a custom session entry from extensions.
|
||||
* The host owns transcript spacing; renderer output should provide only its content.
|
||||
*/
|
||||
export class CustomEntryComponent extends Container {
|
||||
private entry: CustomEntry<unknown>;
|
||||
private renderer: EntryRenderer;
|
||||
private customComponent?: Component;
|
||||
private _expanded = false;
|
||||
|
||||
constructor(entry: CustomEntry<unknown>, renderer: EntryRenderer) {
|
||||
super();
|
||||
this.entry = entry;
|
||||
this.renderer = renderer;
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
hasContent(): boolean {
|
||||
return this.customComponent !== undefined;
|
||||
}
|
||||
|
||||
setExpanded(expanded: boolean): void {
|
||||
if (this._expanded !== expanded) {
|
||||
this._expanded = expanded;
|
||||
this.rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
override invalidate(): void {
|
||||
super.invalidate();
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
this.clear();
|
||||
this.customComponent = undefined;
|
||||
|
||||
let component: Component | undefined;
|
||||
try {
|
||||
component = this.renderer(this.entry, { expanded: this._expanded }, theme);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
||||
box.addChild(new Text(theme.fg("error", `[${this.entry.customType}] renderer failed: ${message}`), 0, 0));
|
||||
component = box;
|
||||
}
|
||||
|
||||
if (!component) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.customComponent = component;
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(component);
|
||||
}
|
||||
}
|
||||
|
|
@ -81,7 +81,7 @@ import { DefaultPackageManager } from "../../core/package-manager.ts";
|
|||
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-names.ts";
|
||||
import type { ResourceDiagnostic } from "../../core/resource-loader.ts";
|
||||
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";
|
||||
import { type SessionContext, SessionManager } from "../../core/session-manager.ts";
|
||||
import { type SessionEntry, SessionManager, sessionEntryToContextMessages } from "../../core/session-manager.ts";
|
||||
import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";
|
||||
import type { SourceInfo } from "../../core/source-info.ts";
|
||||
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
|
||||
|
|
@ -103,6 +103,7 @@ import { BorderedLoader } from "./components/bordered-loader.ts";
|
|||
import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts";
|
||||
import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.ts";
|
||||
import { CustomEditor } from "./components/custom-editor.ts";
|
||||
import { CustomEntryComponent } from "./components/custom-entry.ts";
|
||||
import { CustomMessageComponent } from "./components/custom-message.ts";
|
||||
import { DaxnutsComponent } from "./components/daxnuts.ts";
|
||||
import { DynamicBorder } from "./components/dynamic-border.ts";
|
||||
|
|
@ -183,6 +184,12 @@ type CompactionQueuedMessage = {
|
|||
mode: "steer" | "followUp";
|
||||
};
|
||||
|
||||
type RenderSessionItem = AgentMessage | Extract<SessionEntry, { type: "custom" }>;
|
||||
|
||||
function isCustomSessionEntry(item: RenderSessionItem): item is Extract<SessionEntry, { type: "custom" }> {
|
||||
return "type" in item && item.type === "custom";
|
||||
}
|
||||
|
||||
const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN"]);
|
||||
|
||||
function isDeadTerminalError(error: unknown): boolean {
|
||||
|
|
@ -2781,6 +2788,13 @@ export class InteractiveMode {
|
|||
this.ui.requestRender();
|
||||
break;
|
||||
|
||||
case "entry_appended":
|
||||
if (event.entry.type === "custom") {
|
||||
this.addCustomEntryToChat(event.entry);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
break;
|
||||
|
||||
case "session_info_changed":
|
||||
this.updateTerminalTitle();
|
||||
this.footer.invalidate();
|
||||
|
|
@ -3068,6 +3082,28 @@ export class InteractiveMode {
|
|||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
private addCustomEntryToChat(entry: Extract<SessionEntry, { type: "custom" }>): void {
|
||||
const renderer = this.session.extensionRunner.getEntryRenderer(entry.customType);
|
||||
if (!renderer) {
|
||||
return;
|
||||
}
|
||||
const component = new CustomEntryComponent(entry, renderer);
|
||||
component.setExpanded(this.toolOutputExpanded);
|
||||
if (!component.hasContent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.streamingComponent) {
|
||||
const streamingIndex = this.chatContainer.children.indexOf(this.streamingComponent);
|
||||
if (streamingIndex >= 0) {
|
||||
this.chatContainer.children.splice(streamingIndex, 0, component);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.chatContainer.addChild(component);
|
||||
}
|
||||
|
||||
private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void {
|
||||
switch (message.role) {
|
||||
case "bashExecution": {
|
||||
|
|
@ -3167,14 +3203,8 @@ export class InteractiveMode {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render session context to chat. Used for initial load and rebuild after compaction.
|
||||
* @param sessionContext Session context to render
|
||||
* @param options.updateFooter Update footer state
|
||||
* @param options.populateHistory Add user messages to editor history
|
||||
*/
|
||||
private renderSessionContext(
|
||||
sessionContext: SessionContext,
|
||||
private renderSessionItems(
|
||||
items: readonly RenderSessionItem[],
|
||||
options: { updateFooter?: boolean; populateHistory?: boolean } = {},
|
||||
): void {
|
||||
this.pendingTools.clear();
|
||||
|
|
@ -3185,7 +3215,13 @@ export class InteractiveMode {
|
|||
this.updateEditorBorderColor();
|
||||
}
|
||||
|
||||
for (const message of sessionContext.messages) {
|
||||
for (const item of items) {
|
||||
if (isCustomSessionEntry(item)) {
|
||||
this.addCustomEntryToChat(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const message = item;
|
||||
// Assistant messages need special handling for tool calls
|
||||
if (message.role === "assistant") {
|
||||
this.addMessageToChat(message);
|
||||
|
|
@ -3243,10 +3279,28 @@ export class InteractiveMode {
|
|||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render session entries to chat. Used for initial load and rebuild after compaction.
|
||||
* @param entries Compaction-aware session entries to render
|
||||
* @param options.updateFooter Update footer state
|
||||
* @param options.populateHistory Add user messages to editor history
|
||||
*/
|
||||
private renderSessionEntries(
|
||||
entries: SessionEntry[],
|
||||
options: { updateFooter?: boolean; populateHistory?: boolean } = {},
|
||||
): void {
|
||||
const items = entries.flatMap((entry): RenderSessionItem[] => {
|
||||
if (entry.type === "custom") {
|
||||
return [entry];
|
||||
}
|
||||
return sessionEntryToContextMessages(entry);
|
||||
});
|
||||
this.renderSessionItems(items, options);
|
||||
}
|
||||
|
||||
renderInitialMessages(): void {
|
||||
// Get aligned messages and entries from session context
|
||||
const context = this.sessionManager.buildSessionContext();
|
||||
this.renderSessionContext(context, {
|
||||
const entries = this.sessionManager.buildContextEntries();
|
||||
this.renderSessionEntries(entries, {
|
||||
updateFooter: true,
|
||||
populateHistory: true,
|
||||
});
|
||||
|
|
@ -3297,8 +3351,7 @@ export class InteractiveMode {
|
|||
|
||||
private rebuildChatFromMessages(): void {
|
||||
this.chatContainer.clear();
|
||||
const context = this.sessionManager.buildSessionContext();
|
||||
this.renderSessionContext(context);
|
||||
this.renderSessionEntries(this.sessionManager.buildContextEntries());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -335,12 +335,15 @@ describe("extensions discovery", () => {
|
|||
expect(result.extensions[0].tools.has("parse_duration")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers message renderers", async () => {
|
||||
it("registers message and entry renderers", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.registerMessageRenderer("my-custom-type", (message, options, theme) => {
|
||||
return null; // Use default rendering
|
||||
});
|
||||
pi.registerEntryRenderer("my-entry-type", (entry, options, theme) => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "with-renderer.ts"), extCode);
|
||||
|
|
@ -350,6 +353,7 @@ describe("extensions discovery", () => {
|
|||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].messageRenderers.has("my-custom-type")).toBe(true);
|
||||
expect(result.extensions[0].entryRenderers?.has("my-entry-type")).toBe(true);
|
||||
});
|
||||
|
||||
it("reports error when extension throws during initialization", async () => {
|
||||
|
|
|
|||
|
|
@ -560,7 +560,7 @@ describe("ExtensionRunner", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("message renderers", () => {
|
||||
describe("message and entry renderers", () => {
|
||||
it("gets message renderer by type", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
|
|
@ -578,6 +578,21 @@ describe("ExtensionRunner", () => {
|
|||
const missing = runner.getMessageRenderer("not-exists");
|
||||
expect(missing).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gets entry renderer by type", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.registerEntryRenderer("my-entry", (entry, options, theme) => null);
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "entry-renderer.ts"), extCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
|
||||
expect(runner.getEntryRenderer("my-entry")).toBeDefined();
|
||||
expect(runner.getEntryRenderer("not-exists")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("flags", () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type BranchSummaryEntry,
|
||||
buildContextEntries,
|
||||
buildSessionContext,
|
||||
type CompactionEntry,
|
||||
type CustomEntry,
|
||||
type ModelChangeEntry,
|
||||
type SessionEntry,
|
||||
type SessionMessageEntry,
|
||||
|
|
@ -52,6 +54,10 @@ function branchSummary(id: string, parentId: string | null, summary: string, fro
|
|||
return { type: "branch_summary", id, parentId, timestamp: "2025-01-01T00:00:00Z", summary, fromId };
|
||||
}
|
||||
|
||||
function custom(id: string, parentId: string | null, customType: string, data?: unknown): CustomEntry {
|
||||
return { type: "custom", id, parentId, timestamp: "2025-01-01T00:00:00Z", customType, data };
|
||||
}
|
||||
|
||||
function thinkingLevel(id: string, parentId: string | null, level: string): ThinkingLevelChangeEntry {
|
||||
return { type: "thinking_level_change", id, parentId, timestamp: "2025-01-01T00:00:00Z", thinkingLevel: level };
|
||||
}
|
||||
|
|
@ -169,6 +175,37 @@ describe("buildSessionContext", () => {
|
|||
expect(ctx.messages).toHaveLength(4);
|
||||
expect((ctx.messages[0] as any).summary).toContain("Second summary");
|
||||
});
|
||||
|
||||
it("buildContextEntries returns compaction-aware entries including custom entries", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "first"),
|
||||
custom("2", "1", "old-state", { hidden: true }),
|
||||
msg("3", "2", "assistant", "response1"),
|
||||
custom("4", "3", "kept-card", { title: "Kept" }),
|
||||
msg("5", "4", "user", "second"),
|
||||
compaction("6", "5", "Summary", "4"),
|
||||
custom("7", "6", "after-card", { title: "After" }),
|
||||
msg("8", "7", "assistant", "response2"),
|
||||
];
|
||||
|
||||
expect(buildContextEntries(entries).map((entry) => entry.id)).toEqual(["6", "4", "5", "7", "8"]);
|
||||
const ctx = buildSessionContext(entries);
|
||||
expect(ctx.messages.map((message) => message.role)).toEqual(["compactionSummary", "user", "assistant"]);
|
||||
});
|
||||
|
||||
it("keeps settings from the full path after compaction", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "first"),
|
||||
thinkingLevel("2", "1", "high"),
|
||||
msg("3", "2", "assistant", "response1"),
|
||||
msg("4", "3", "user", "second"),
|
||||
compaction("5", "4", "Summary", "4"),
|
||||
];
|
||||
|
||||
const ctx = buildSessionContext(entries);
|
||||
expect(ctx.thinkingLevel).toBe("high");
|
||||
expect(ctx.messages.map((message) => message.role)).toEqual(["compactionSummary", "user"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with branches", () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { AssistantMessage, ToolResultMessage, Usage } from "@earendil-works
|
|||
import { Container, Text, type TUI } from "@earendil-works/pi-tui";
|
||||
import { beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import type { AgentSessionEvent } from "../../../src/core/agent-session.ts";
|
||||
import type { SessionContext } from "../../../src/core/session-manager.ts";
|
||||
import type { SessionEntry } from "../../../src/core/session-manager.ts";
|
||||
import type { ToolExecutionComponent } from "../../../src/modes/interactive/components/tool-execution.ts";
|
||||
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
|
||||
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
|
||||
|
|
@ -27,6 +27,12 @@ const EMPTY_USAGE: Usage = {
|
|||
},
|
||||
};
|
||||
|
||||
type RenderSessionItems = (
|
||||
this: RenderSessionContextThis,
|
||||
items: AgentMessage[],
|
||||
options?: { updateFooter?: boolean; populateHistory?: boolean },
|
||||
) => void;
|
||||
|
||||
type RenderSessionContextThis = {
|
||||
pendingTools: Map<string, ToolExecutionComponent>;
|
||||
chatContainer: Container;
|
||||
|
|
@ -43,11 +49,12 @@ type RenderSessionContextThis = {
|
|||
updateEditorBorderColor(): void;
|
||||
getRegisteredToolDefinition(toolName: string): undefined;
|
||||
addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void;
|
||||
renderSessionItems: RenderSessionItems;
|
||||
};
|
||||
|
||||
type RenderSessionContext = (
|
||||
type RenderSessionEntries = (
|
||||
this: RenderSessionContextThis,
|
||||
sessionContext: SessionContext,
|
||||
entries: SessionEntry[],
|
||||
options?: { updateFooter?: boolean; populateHistory?: boolean },
|
||||
) => void;
|
||||
|
||||
|
|
@ -70,6 +77,8 @@ function createFakeInteractiveModeThis(): RenderSessionContextThis {
|
|||
isInitialized: true,
|
||||
updateEditorBorderColor: vi.fn(),
|
||||
getRegisteredToolDefinition: (_toolName: string) => undefined,
|
||||
renderSessionItems: (InteractiveMode.prototype as unknown as { renderSessionItems: RenderSessionItems })
|
||||
.renderSessionItems,
|
||||
addMessageToChat(message: AgentMessage) {
|
||||
chatContainer.addChild(new Text(message.role, 0, 0));
|
||||
},
|
||||
|
|
@ -107,31 +116,38 @@ function createToolResultMessage(text: string): ToolResultMessage {
|
|||
};
|
||||
}
|
||||
|
||||
function createSessionContext(messages: AgentMessage[]): SessionContext {
|
||||
return {
|
||||
messages,
|
||||
thinkingLevel: "off",
|
||||
model: null,
|
||||
};
|
||||
function createSessionEntries(messages: AgentMessage[]): SessionEntry[] {
|
||||
let parentId: string | null = null;
|
||||
return messages.map((message, index) => {
|
||||
const entry: SessionEntry = {
|
||||
type: "message",
|
||||
id: `entry-${index}`,
|
||||
parentId,
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
};
|
||||
parentId = entry.id;
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
function renderChat(container: Container): string {
|
||||
return stripAnsi(container.render(120).join("\n"));
|
||||
}
|
||||
|
||||
describe("InteractiveMode.renderSessionContext", () => {
|
||||
describe("InteractiveMode.renderSessionEntries", () => {
|
||||
beforeAll(() => {
|
||||
initTheme("dark");
|
||||
});
|
||||
|
||||
test("keeps unresolved rendered tool calls registered for live completion events", async () => {
|
||||
const fakeThis = createFakeInteractiveModeThis();
|
||||
const renderSessionContext = (
|
||||
InteractiveMode.prototype as unknown as { renderSessionContext: RenderSessionContext }
|
||||
).renderSessionContext;
|
||||
const renderSessionEntries = (
|
||||
InteractiveMode.prototype as unknown as { renderSessionEntries: RenderSessionEntries }
|
||||
).renderSessionEntries;
|
||||
const handleEvent = (InteractiveMode.prototype as unknown as { handleEvent: HandleEvent }).handleEvent;
|
||||
|
||||
renderSessionContext.call(fakeThis, createSessionContext([createAssistantToolCallMessage()]));
|
||||
renderSessionEntries.call(fakeThis, createSessionEntries([createAssistantToolCallMessage()]));
|
||||
|
||||
expect(fakeThis.pendingTools.has(TOOL_CALL_ID)).toBe(true);
|
||||
|
||||
|
|
@ -149,13 +165,13 @@ describe("InteractiveMode.renderSessionContext", () => {
|
|||
|
||||
test("does not keep completed historical tool calls registered as pending", () => {
|
||||
const fakeThis = createFakeInteractiveModeThis();
|
||||
const renderSessionContext = (
|
||||
InteractiveMode.prototype as unknown as { renderSessionContext: RenderSessionContext }
|
||||
).renderSessionContext;
|
||||
const renderSessionEntries = (
|
||||
InteractiveMode.prototype as unknown as { renderSessionEntries: RenderSessionEntries }
|
||||
).renderSessionEntries;
|
||||
|
||||
renderSessionContext.call(
|
||||
renderSessionEntries.call(
|
||||
fakeThis,
|
||||
createSessionContext([createAssistantToolCallMessage(), createToolResultMessage("HISTORICAL_RESULT")]),
|
||||
createSessionEntries([createAssistantToolCallMessage(), createToolResultMessage("HISTORICAL_RESULT")]),
|
||||
);
|
||||
|
||||
expect(fakeThis.pendingTools.size).toBe(0);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue