mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +02:00
feat(hot-state): section-aware greedy renderer, accounting types, fix docs drift
Replace blunt .slice(0, maxRenderedChars) truncation with a greedy line accumulator that fits whole lines and suppresses header-only sections. Add accountHotSessionStateRender() returning prompt + omitted[] accounting. Export HotStateSection, HotStateOmissionReason, HotStateOmittedItem, HotSessionStateRenderAccounting types for v2 evidence extension. Fix configuration.md ranking formula (ACTION_WEIGHT + count*3, weights 50/45/30/20). Clarify README injection order. Add 9 unit tests.
This commit is contained in:
@@ -85,11 +85,13 @@ OpenCode Working Memory adds durable memory without making extra LLM/API calls.
|
|||||||
▼
|
▼
|
||||||
┌──────────────────────────────────────┐
|
┌──────────────────────────────────────┐
|
||||||
│ ⚡ Prompt Context │
|
│ ⚡ Prompt Context │
|
||||||
│ system[1]: frozen workspace memory │
|
│ system[1]*: frozen workspace memory │
|
||||||
│ system[2+]: hot session state │
|
│ system[2+]*: hot session state │
|
||||||
└──────────────────────────────────────┘
|
└──────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
\* Conceptually, workspace memory is pushed first when it is non-empty, and hot session state is pushed after workspace memory. If workspace memory is empty, hot state may be the first plugin-added system message. Actual `system[]` indices also depend on OpenCode and other plugins, so `system[1]` / `system[2+]` is a simplified model.
|
||||||
|
|
||||||
**Zero extra API calls:** OpenCode Working Memory does not call the model on its own. Memory extraction is folded into OpenCode's built-in compaction request.
|
**Zero extra API calls:** OpenCode Working Memory does not call the model on its own. Memory extraction is folded into OpenCode's built-in compaction request.
|
||||||
|
|
||||||
**Cache-friendly layout:** durable workspace memory is rendered as a stable frozen snapshot for the session, while fast-changing hot session state is appended separately. Compaction starts a new cache epoch, refreshing the workspace snapshot after pending memories are promoted.
|
**Cache-friendly layout:** durable workspace memory is rendered as a stable frozen snapshot for the session, while fast-changing hot session state is appended separately. Compaction starts a new cache epoch, refreshing the workspace snapshot after pending memories are promoted.
|
||||||
|
|||||||
+23
-6
@@ -87,16 +87,33 @@ const HOT_STATE_LIMITS = {
|
|||||||
|
|
||||||
## Active File Scoring
|
## Active File Scoring
|
||||||
|
|
||||||
Files are ranked by action type:
|
Files are ranked by action type plus repeated access count:
|
||||||
|
|
||||||
| Action | Weight | Description |
|
| Action | Weight | Description |
|
||||||
|--------|--------|-------------|
|
|--------|--------|-------------|
|
||||||
| `write` | 4 | File created/overwritten |
|
| `edit` | 50 | File modified |
|
||||||
| `edit` | 3 | File modified |
|
| `write` | 45 | File created/overwritten |
|
||||||
| `read` | 2 | File read |
|
| `grep` | 30 | Grep searched in file |
|
||||||
| `grep` | 1 | Grep searched in file |
|
| `read` | 20 | File read |
|
||||||
|
|
||||||
Score formula: `count * action_weight * recency_decay`
|
Score formula: `ACTION_WEIGHT[action] + count * 3`
|
||||||
|
|
||||||
|
When scores tie, the most recent `lastSeen` timestamp sorts first. There is no recency decay factor in the score itself.
|
||||||
|
|
||||||
|
## Hot Session State Truncation
|
||||||
|
|
||||||
|
Hot state rendering applies section caps before the character budget:
|
||||||
|
|
||||||
|
| Section | Rendered cap |
|
||||||
|
|---------|--------------|
|
||||||
|
| `active_files` | 8 |
|
||||||
|
| `open_errors` | 3 |
|
||||||
|
| `recent_decisions` | 8 |
|
||||||
|
| `pending_memories` | 6 |
|
||||||
|
|
||||||
|
After section caps, the renderer applies the 700-character budget with whole-line inclusion only. The prefix line is counted against the budget, and a section heading is emitted only when at least one entry from that section fits; header-only sections are suppressed.
|
||||||
|
|
||||||
|
`accountHotSessionStateRender()` returns prompt and omission accounting for future diagnostics. Evidence event integration for hot-state render omissions is planned for v2.
|
||||||
|
|
||||||
## Error Categories
|
## Error Categories
|
||||||
|
|
||||||
|
|||||||
+135
-34
@@ -189,49 +189,150 @@ export function clearErrorsForSuccessfulCommand(state: SessionState, command: st
|
|||||||
state.updatedAt = new Date().toISOString();
|
state.updatedAt = new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HotStateSection = "active_files" | "open_errors" | "recent_decisions" | "pending_memories";
|
||||||
|
|
||||||
|
export type HotStateOmissionReason = "section_cap" | "char_budget";
|
||||||
|
|
||||||
|
export type HotStateOmittedItem = {
|
||||||
|
section: HotStateSection;
|
||||||
|
reason: HotStateOmissionReason;
|
||||||
|
text: string;
|
||||||
|
memoryId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HotSessionStateRenderAccounting = {
|
||||||
|
prompt: string;
|
||||||
|
omitted: HotStateOmittedItem[];
|
||||||
|
maxRenderedChars: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HotStateRenderItem = {
|
||||||
|
section: HotStateSection;
|
||||||
|
line: string;
|
||||||
|
memoryId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HotStateRenderSection = {
|
||||||
|
heading: `${HotStateSection}:`;
|
||||||
|
items: HotStateRenderItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const HOT_STATE_PREFIX = "Hot session state (current session):";
|
||||||
|
|
||||||
|
export function accountHotSessionStateRender(state: SessionState, workspaceRoot: string): HotSessionStateRenderAccounting {
|
||||||
|
const maxRenderedChars = HOT_STATE_LIMITS.maxRenderedChars;
|
||||||
|
const omitted: HotStateOmittedItem[] = [];
|
||||||
|
const sections = buildHotStateRenderSections(state, workspaceRoot, omitted);
|
||||||
|
|
||||||
|
if (sections.every(section => section.items.length === 0)) {
|
||||||
|
return { prompt: "", omitted, maxRenderedChars };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [HOT_STATE_PREFIX];
|
||||||
|
let renderedEntryCount = 0;
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
let headingRendered = false;
|
||||||
|
|
||||||
|
for (const item of section.items) {
|
||||||
|
if (headingRendered) {
|
||||||
|
if (wouldFit(lines, item.line, maxRenderedChars)) {
|
||||||
|
lines.push(item.line);
|
||||||
|
renderedEntryCount += 1;
|
||||||
|
} else {
|
||||||
|
omitted.push(omitHotStateItem(item, "char_budget"));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wouldFit(lines, section.heading, maxRenderedChars)
|
||||||
|
&& wouldFit([...lines, section.heading], item.line, maxRenderedChars)) {
|
||||||
|
lines.push(section.heading, item.line);
|
||||||
|
headingRendered = true;
|
||||||
|
renderedEntryCount += 1;
|
||||||
|
} else {
|
||||||
|
omitted.push(omitHotStateItem(item, "char_budget"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderedEntryCount === 0) return { prompt: "", omitted, maxRenderedChars };
|
||||||
|
|
||||||
|
return { prompt: lines.join("\n"), omitted, maxRenderedChars };
|
||||||
|
}
|
||||||
|
|
||||||
export function renderHotSessionState(state: SessionState, workspaceRoot: string): string {
|
export function renderHotSessionState(state: SessionState, workspaceRoot: string): string {
|
||||||
const activeFiles = rankActiveFiles(state.activeFiles).slice(0, HOT_STATE_LIMITS.maxActiveFilesRendered);
|
return accountHotSessionStateRender(state, workspaceRoot).prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHotStateRenderSections(
|
||||||
|
state: SessionState,
|
||||||
|
workspaceRoot: string,
|
||||||
|
omitted: HotStateOmittedItem[],
|
||||||
|
): HotStateRenderSection[] {
|
||||||
|
const activeFiles = rankActiveFiles(state.activeFiles).map(item => ({
|
||||||
|
section: "active_files" as const,
|
||||||
|
line: `- ${displayPath(workspaceRoot, item.path)} (${item.action}, ${item.count}x)`,
|
||||||
|
}));
|
||||||
const openErrors = [...state.openErrors]
|
const openErrors = [...state.openErrors]
|
||||||
.sort((a, b) => b.lastSeen - a.lastSeen)
|
.sort((a, b) => b.lastSeen - a.lastSeen)
|
||||||
.slice(0, HOT_STATE_LIMITS.maxOpenErrorsRendered);
|
.map(err => ({
|
||||||
const decisions = state.recentDecisions.slice(-HOT_STATE_LIMITS.maxRecentDecisionsStored);
|
section: "open_errors" as const,
|
||||||
const pendingMemories = dedupePendingMemories(state.pendingMemories)
|
line: `- [${err.category}] ${err.summary}`,
|
||||||
.slice(-HOT_STATE_LIMITS.maxPendingMemoriesRendered);
|
}));
|
||||||
|
const decisions = state.recentDecisions.map(item => ({
|
||||||
|
section: "recent_decisions" as const,
|
||||||
|
line: `- ${item.text}`,
|
||||||
|
}));
|
||||||
|
const pendingMemories = dedupePendingMemories(state.pendingMemories).map(item => ({
|
||||||
|
section: "pending_memories" as const,
|
||||||
|
line: `- [${item.type}] ${item.text}`,
|
||||||
|
memoryId: item.id,
|
||||||
|
}));
|
||||||
|
|
||||||
if (activeFiles.length === 0 && openErrors.length === 0 && decisions.length === 0 && pendingMemories.length === 0) return "";
|
const renderedActiveFiles = capHotStateItems(activeFiles, HOT_STATE_LIMITS.maxActiveFilesRendered, "start", omitted);
|
||||||
|
const renderedOpenErrors = capHotStateItems(openErrors, HOT_STATE_LIMITS.maxOpenErrorsRendered, "start", omitted);
|
||||||
|
const renderedDecisions = capHotStateItems(decisions, HOT_STATE_LIMITS.maxRecentDecisionsStored, "end", omitted);
|
||||||
|
const renderedPendingMemories = capHotStateItems(
|
||||||
|
pendingMemories,
|
||||||
|
HOT_STATE_LIMITS.maxPendingMemoriesRendered,
|
||||||
|
"end",
|
||||||
|
omitted,
|
||||||
|
);
|
||||||
|
|
||||||
const lines: string[] = ["Hot session state (current session):"];
|
return [
|
||||||
|
{ heading: "active_files:", items: renderedActiveFiles },
|
||||||
|
{ heading: "open_errors:", items: renderedOpenErrors },
|
||||||
|
{ heading: "recent_decisions:", items: renderedDecisions },
|
||||||
|
{ heading: "pending_memories:", items: renderedPendingMemories },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (activeFiles.length > 0) {
|
function capHotStateItems(
|
||||||
lines.push("active_files:");
|
items: HotStateRenderItem[],
|
||||||
for (const item of activeFiles) {
|
cap: number,
|
||||||
const viewPath = displayPath(workspaceRoot, item.path);
|
keep: "start" | "end",
|
||||||
lines.push(`- ${viewPath} (${item.action}, ${item.count}x)`);
|
omitted: HotStateOmittedItem[],
|
||||||
}
|
): HotStateRenderItem[] {
|
||||||
}
|
if (items.length <= cap) return items;
|
||||||
|
|
||||||
if (openErrors.length > 0) {
|
const renderedItems = keep === "start" ? items.slice(0, cap) : items.slice(-cap);
|
||||||
lines.push("open_errors:");
|
const omittedItems = keep === "start" ? items.slice(cap) : items.slice(0, items.length - cap);
|
||||||
for (const err of openErrors) {
|
omitted.push(...omittedItems.map(item => omitHotStateItem(item, "section_cap")));
|
||||||
lines.push(`- [${err.category}] ${err.summary}`);
|
return renderedItems;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (decisions.length > 0) {
|
function omitHotStateItem(item: HotStateRenderItem, reason: HotStateOmissionReason): HotStateOmittedItem {
|
||||||
lines.push("recent_decisions:");
|
return {
|
||||||
for (const decision of decisions) {
|
section: item.section,
|
||||||
lines.push(`- ${decision.text}`);
|
reason,
|
||||||
}
|
text: item.line,
|
||||||
}
|
...(item.memoryId ? { memoryId: item.memoryId } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (pendingMemories.length > 0) {
|
function wouldFit(lines: string[], nextLine: string, maxRenderedChars: number): boolean {
|
||||||
lines.push("pending_memories:");
|
return [...lines, nextLine].join("\n").length <= maxRenderedChars;
|
||||||
for (const memory of pendingMemories) {
|
|
||||||
lines.push(`- [${memory.type}] ${memory.text}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n").slice(0, HOT_STATE_LIMITS.maxRenderedChars);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rankActiveFiles(activeFiles: ActiveFile[]): ActiveFile[] {
|
function rankActiveFiles(activeFiles: ActiveFile[]): ActiveFile[] {
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import * as sessionStateModule from "../src/session-state.ts";
|
||||||
|
import type { HotSessionStateRenderAccounting } from "../src/session-state.ts";
|
||||||
|
import type { ActiveFile, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "../src/types.ts";
|
||||||
|
import { HOT_STATE_LIMITS } from "../src/types.ts";
|
||||||
|
|
||||||
|
type AccountHotSessionStateRender = (state: SessionState, workspaceRoot: string) => HotSessionStateRenderAccounting;
|
||||||
|
|
||||||
|
const accountHotSessionStateRender = (
|
||||||
|
sessionStateModule as typeof sessionStateModule & { accountHotSessionStateRender: AccountHotSessionStateRender }
|
||||||
|
).accountHotSessionStateRender;
|
||||||
|
|
||||||
|
const { createEmptySessionState, renderHotSessionState } = sessionStateModule;
|
||||||
|
|
||||||
|
const root = "/repo";
|
||||||
|
|
||||||
|
function state(overrides: Partial<SessionState> = {}): SessionState {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
sessionID: "session-state-test",
|
||||||
|
turn: 0,
|
||||||
|
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||||
|
activeFiles: [],
|
||||||
|
openErrors: [],
|
||||||
|
recentDecisions: [],
|
||||||
|
pendingMemories: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeFile(path: string, action: ActiveFile["action"], count: number, lastSeen: number): ActiveFile {
|
||||||
|
return { path, action, count, lastSeen };
|
||||||
|
}
|
||||||
|
|
||||||
|
function openError(id: string, summary: string, lastSeen: number): OpenError {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
category: "test",
|
||||||
|
summary,
|
||||||
|
fingerprint: `fingerprint-${id}`,
|
||||||
|
status: "open",
|
||||||
|
firstSeen: lastSeen - 1,
|
||||||
|
lastSeen,
|
||||||
|
seenCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decision(id: string, text: string, createdAt: number): SessionDecision {
|
||||||
|
return { id, text, source: "assistant", createdAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function memory(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
text,
|
||||||
|
source: "compaction",
|
||||||
|
confidence: 0.75,
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-05-05T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("accountHotSessionStateRender returns empty prompt and no omissions for empty state", () => {
|
||||||
|
const accounting = accountHotSessionStateRender(createEmptySessionState("empty-session"), root);
|
||||||
|
|
||||||
|
assert.equal(accounting.prompt, "");
|
||||||
|
assert.deepEqual(accounting.omitted, []);
|
||||||
|
assert.equal(accounting.maxRenderedChars, HOT_STATE_LIMITS.maxRenderedChars);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accountHotSessionStateRender renders hot-state sections in stable order", () => {
|
||||||
|
const accounting = accountHotSessionStateRender(state({
|
||||||
|
activeFiles: [activeFile("/repo/src/a.ts", "read", 1, 1)],
|
||||||
|
openErrors: [openError("err-1", "one failing test", 2)],
|
||||||
|
recentDecisions: [decision("dec-1", "Keep renderer simple", 3)],
|
||||||
|
pendingMemories: [memory("mem-1", "Promote useful fact")],
|
||||||
|
}), root);
|
||||||
|
|
||||||
|
assert.ok(accounting.prompt.startsWith("Hot session state (current session):"));
|
||||||
|
assert.ok(accounting.prompt.indexOf("active_files:") < accounting.prompt.indexOf("open_errors:"));
|
||||||
|
assert.ok(accounting.prompt.indexOf("open_errors:") < accounting.prompt.indexOf("recent_decisions:"));
|
||||||
|
assert.ok(accounting.prompt.indexOf("recent_decisions:") < accounting.prompt.indexOf("pending_memories:"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accountHotSessionStateRender ranks active files by score then lastSeen descending", () => {
|
||||||
|
const accounting = accountHotSessionStateRender(state({
|
||||||
|
activeFiles: [
|
||||||
|
activeFile("/repo/src/edit.ts", "edit", 1, 400),
|
||||||
|
activeFile("/repo/src/write.ts", "write", 5, 200),
|
||||||
|
activeFile("/repo/src/grep.ts", "grep", 10, 300),
|
||||||
|
activeFile("/repo/src/read.ts", "read", 12, 100),
|
||||||
|
],
|
||||||
|
}), root);
|
||||||
|
|
||||||
|
const lines = accounting.prompt.split("\n");
|
||||||
|
const activeLines = lines.filter(line => line.startsWith("- src/"));
|
||||||
|
assert.deepEqual(activeLines, [
|
||||||
|
"- src/grep.ts (grep, 10x)",
|
||||||
|
"- src/write.ts (write, 5x)",
|
||||||
|
"- src/read.ts (read, 12x)",
|
||||||
|
"- src/edit.ts (edit, 1x)",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accountHotSessionStateRender reports section-cap omissions for every capped section", () => {
|
||||||
|
const accounting = accountHotSessionStateRender(state({
|
||||||
|
activeFiles: Array.from({ length: 9 }, (_, index) => activeFile(`/repo/a${index}.ts`, "read", 1, 100 - index)),
|
||||||
|
openErrors: Array.from({ length: 4 }, (_, index) => openError(`err-${index}`, `e${index}`, 100 - index)),
|
||||||
|
recentDecisions: Array.from({ length: 9 }, (_, index) => decision(`dec-${index}`, `d${index}`, index)),
|
||||||
|
pendingMemories: Array.from({ length: 7 }, (_, index) => memory(`mem-${index}`, `m${index}`)),
|
||||||
|
}), root);
|
||||||
|
|
||||||
|
const sectionCapOmissions = accounting.omitted.filter(item => item.reason === "section_cap");
|
||||||
|
assert.deepEqual(
|
||||||
|
sectionCapOmissions.map(item => item.section).sort(),
|
||||||
|
["active_files", "open_errors", "pending_memories", "recent_decisions"].sort(),
|
||||||
|
);
|
||||||
|
assert.equal(sectionCapOmissions.find(item => item.section === "pending_memories")?.memoryId, "mem-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accountHotSessionStateRender omits over-budget entries without cutting rendered lines", () => {
|
||||||
|
const longPath = `/repo/${"x".repeat(650)}.ts`;
|
||||||
|
const accounting = accountHotSessionStateRender(state({
|
||||||
|
activeFiles: [
|
||||||
|
activeFile("/repo/src/short.ts", "read", 1, 20),
|
||||||
|
activeFile(longPath, "read", 1, 10),
|
||||||
|
],
|
||||||
|
}), root);
|
||||||
|
|
||||||
|
assert.equal(accounting.prompt, [
|
||||||
|
"Hot session state (current session):",
|
||||||
|
"active_files:",
|
||||||
|
"- src/short.ts (read, 1x)",
|
||||||
|
].join("\n"));
|
||||||
|
assert.equal(accounting.omitted.length, 1);
|
||||||
|
assert.equal(accounting.omitted[0]?.reason, "char_budget");
|
||||||
|
assert.equal(accounting.omitted[0]?.section, "active_files");
|
||||||
|
assert.ok(!accounting.prompt.includes("x".repeat(20)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accountHotSessionStateRender includes exact 700-char prompt but omits one additional character", () => {
|
||||||
|
const fixedPrompt = [
|
||||||
|
"Hot session state (current session):",
|
||||||
|
"pending_memories:",
|
||||||
|
"- [decision] ",
|
||||||
|
].join("\n");
|
||||||
|
const exactText = "x".repeat(HOT_STATE_LIMITS.maxRenderedChars - fixedPrompt.length);
|
||||||
|
const exactAccounting = accountHotSessionStateRender(state({
|
||||||
|
pendingMemories: [memory("mem-exact", exactText)],
|
||||||
|
}), root);
|
||||||
|
|
||||||
|
assert.equal(exactAccounting.prompt.length, HOT_STATE_LIMITS.maxRenderedChars);
|
||||||
|
assert.equal(exactAccounting.omitted.length, 0);
|
||||||
|
|
||||||
|
const overAccounting = accountHotSessionStateRender(state({
|
||||||
|
pendingMemories: [memory("mem-over", `${exactText}y`)],
|
||||||
|
}), root);
|
||||||
|
|
||||||
|
assert.equal(overAccounting.prompt, "");
|
||||||
|
assert.equal(overAccounting.omitted.length, 1);
|
||||||
|
assert.equal(overAccounting.omitted[0]?.reason, "char_budget");
|
||||||
|
assert.equal(overAccounting.omitted[0]?.memoryId, "mem-over");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accountHotSessionStateRender suppresses header-only sections when no entries fit", () => {
|
||||||
|
const accounting = accountHotSessionStateRender(state({
|
||||||
|
activeFiles: [activeFile(`/repo/${"z".repeat(720)}.ts`, "read", 1, 1)],
|
||||||
|
}), root);
|
||||||
|
|
||||||
|
assert.equal(accounting.prompt, "");
|
||||||
|
assert.equal(accounting.omitted.length, 1);
|
||||||
|
assert.equal(accounting.omitted[0]?.reason, "char_budget");
|
||||||
|
assert.ok(!accounting.prompt.includes("active_files:"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderHotSessionState delegates to accounted renderer prompt for empty and seeded states", () => {
|
||||||
|
const empty = createEmptySessionState("compat-empty");
|
||||||
|
const seeded = state({
|
||||||
|
activeFiles: [activeFile("/repo/src/a.ts", "edit", 2, 1)],
|
||||||
|
pendingMemories: [memory("mem-compat", "Compatibility prompt")],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(renderHotSessionState(empty, root), accountHotSessionStateRender(empty, root).prompt);
|
||||||
|
assert.equal(renderHotSessionState(seeded, root), accountHotSessionStateRender(seeded, root).prompt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accountHotSessionStateRender counts newline separators in the 700-char budget", () => {
|
||||||
|
const fixedPrompt = [
|
||||||
|
"Hot session state (current session):",
|
||||||
|
"recent_decisions:",
|
||||||
|
"- ",
|
||||||
|
].join("\n");
|
||||||
|
const exactText = "n".repeat(HOT_STATE_LIMITS.maxRenderedChars - fixedPrompt.length);
|
||||||
|
|
||||||
|
const exactAccounting = accountHotSessionStateRender(state({
|
||||||
|
recentDecisions: [decision("dec-exact", exactText, 1)],
|
||||||
|
}), root);
|
||||||
|
assert.equal(exactAccounting.prompt.length, HOT_STATE_LIMITS.maxRenderedChars);
|
||||||
|
assert.equal(exactAccounting.omitted.length, 0);
|
||||||
|
|
||||||
|
const overAccounting = accountHotSessionStateRender(state({
|
||||||
|
recentDecisions: [decision("dec-over", `${exactText}!`, 1)],
|
||||||
|
}), root);
|
||||||
|
assert.equal(overAccounting.prompt, "");
|
||||||
|
assert.equal(overAccounting.omitted.length, 1);
|
||||||
|
assert.equal(overAccounting.omitted[0]?.reason, "char_budget");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user