Files
locode/src/persistence/replayHistory.ts
T
kim 6fe98887d5 v0.6.0: complete all 12 local-model upgrades + raise maxIterations to 100
Upgrade candidates (all 12 done):
- #1 parallel read-only tool execution (runToolBatch, 4 loop sites)
- #2 configurable retry policy (maxRetries + exponential backoff via SDK)
- #3 script-aware token estimation (CJK/symbol/structure-aware heuristic)
- #4 head+tail output capping (truncate.ts), auto-applies to bash/git/etc
- #5 partial-history compaction (preserve recent tail, summarize older prefix)
- #6 dynamic max_tokens (resolveMaxTokens)
- #7 richer tool descriptions with "use when" guidance
- #8 MCP reconnect retry + /mcp reconnect command + session toolset refresh
- #9 context-window cache TTL (cachedAt timestamp, default 7 days)
- #10 edit_file similar-match suggestion on old_string miss (bounded Levenshtein)
- #11 git_status output head+tail (resolved via #4)
- #12 auto-accept now approves all mutating tools; auto-edit stays edit-only

Other:
- DEFAULT_MAX_ITERATIONS 50 -> 100 (local models issue one tool call per step)
- MaxIterationsError message guides resume + config override
- plus prior known-issues work (grep -e/--, session id sanitization, MCP content
  types, 12 hook events, plugin collisions, skill references, git ops expansion,
  configurable autoCompactThreshold, bashGuard, pathGuard, FilePanel, replay)
2026-08-20 16:52:03 +09:00

109 lines
4.6 KiB
TypeScript

import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import { parseFallbackToolCalls } from "../toolcalling/fallbackParser.js";
import { nextId, type HistoryItem, type NewHistoryItem } from "../ui/ink/types.js";
import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js";
const TOOL_CALL_BLOCK_RE = /```tool_call\s*[\s\S]*?```/g;
const TOOL_RESULT_BLOCK_RE = /^```tool_result\n([\s\S]*?)\n```$/;
/** A message's `content` can be a plain string or an array of content parts (text/image_url) —
* see pushToolResultMessage in agent/loop.ts. Only the text part matters for replay display. */
function textContent(content: ChatCompletionMessageParam["content"]): string | null {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
const part = content.find((p): p is { type: "text"; text: string } => (p as { type?: string }).type === "text");
return part?.text ?? null;
}
return null;
}
function isErrorResult(result: unknown): boolean {
return !!(result && typeof result === "object" && "error" in (result as object));
}
/** Rebuilds the tool_call/tool_result HistoryItems a resumed session's transcript is otherwise
* missing (see App.tsx initSessionFromRecord) — the persisted record (SessionRecord.messages) is
* the raw OpenAI-shape history, which carries everything needed (tool name, arguments, result)
* even though it was never saved as a pre-rendered display label. Handles both native mode
* (assistant `tool_calls` + matching `tool`-role messages, correlated by `tool_call_id`) and
* fallback mode (` ```tool_call``` ` blocks embedded in assistant text, ` ```tool_result``` `
* blocks embedded in user text — see fallbackParser.ts and pushToolResultMessage). */
export function buildReplayHistory(messages: ChatCompletionMessageParam[]): HistoryItem[] {
const items: HistoryItem[] = [];
// Native mode: `tool` messages only carry a tool_call_id, not the tool's name — remember the
// name from the assistant message that made the call so its later result can be labeled.
const pendingCallNames = new Map<string, string>();
const push = (item: NewHistoryItem) => items.push({ id: nextId(), ...item } as HistoryItem);
for (const m of messages) {
if (m.role === "user") {
const text = textContent(m.content);
if (text === null) continue;
const fallbackResult = TOOL_RESULT_BLOCK_RE.exec(text.trim());
if (fallbackResult) {
try {
const { name, result } = JSON.parse(fallbackResult[1]!) as { name: string; result: unknown };
push({ kind: "tool_result", summary: summarizeToolResult(name, result), isError: isErrorResult(result) });
} catch {
// Malformed persisted block (shouldn't happen since we wrote it) — drop rather than
// show the raw JSON to the user.
}
continue;
}
if (text) push({ kind: "user", text });
continue;
}
if (m.role === "assistant") {
const toolCalls = m.tool_calls;
if (toolCalls?.length) {
const text = textContent(m.content);
if (text) push({ kind: "assistant", text });
for (const call of toolCalls) {
if (call.type !== "function") continue;
let args: unknown = {};
try {
args = JSON.parse(call.function.arguments);
} catch {
// Leave args as {} — formatCallLabel degrades gracefully for a missing field.
}
pendingCallNames.set(call.id, call.function.name);
push({ kind: "tool_call", label: formatCallLabel(call.function.name, args) });
}
continue;
}
const text = textContent(m.content);
if (!text) continue;
const parsed = parseFallbackToolCalls(text);
if (parsed.calls.length) {
const stripped = text.replace(TOOL_CALL_BLOCK_RE, "").trim();
if (stripped) push({ kind: "assistant", text: stripped });
for (const call of parsed.calls) {
push({ kind: "tool_call", label: formatCallLabel(call.name, call.arguments) });
}
} else {
push({ kind: "assistant", text });
}
continue;
}
if (m.role === "tool") {
const name = pendingCallNames.get(m.tool_call_id) ?? "unknown";
const text = textContent(m.content);
let result: unknown = text;
if (text) {
try {
result = JSON.parse(text);
} catch {
result = text;
}
}
push({ kind: "tool_result", summary: summarizeToolResult(name, result), isError: isErrorResult(result) });
}
}
return items;
}