Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
181 lines
7.2 KiB
TypeScript
181 lines
7.2 KiB
TypeScript
import type { ChatMessage, ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types";
|
|
import { isTerminalSelectionAttachment } from "../../application/state/terminalSelectionAttachment";
|
|
import { formatVaultNoteReferences, isVaultNoteAttachment } from "../../application/state/vaultNoteAttachment";
|
|
import { redactSecretsForModel } from "../../infrastructure/ai/harness/modelSecretRedaction";
|
|
|
|
const MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS = 120;
|
|
const MAX_TOOL_COMMAND_CHARS = 220;
|
|
const MAX_HISTORICAL_TERMINAL_OUTPUT_CHARS = 4_000;
|
|
const HISTORICAL_TERMINAL_OUTPUT_HEAD_CHARS = 2_800;
|
|
|
|
function truncateInline(value: string, maxChars: number): string {
|
|
const normalized = value.replace(/\s+/g, " ").trim();
|
|
if (normalized.length <= maxChars) return normalized;
|
|
return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
|
|
}
|
|
|
|
function describeAttachmentSize(attachment: ChatMessageAttachment): string {
|
|
return `${attachment.base64Data.length} base64 chars`;
|
|
}
|
|
|
|
function formatTerminalSelectionPlaceholder(
|
|
attachment: ChatMessageAttachment,
|
|
index: number,
|
|
): string {
|
|
const details = [
|
|
`filename=${attachment.filename || `terminal-selection-${index + 1}.log`}`,
|
|
attachment.lineCount != null ? `lines=${attachment.lineCount}` : undefined,
|
|
attachment.previewText ? `preview=${truncateInline(attachment.previewText, MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS)}` : undefined,
|
|
describeAttachmentSize(attachment),
|
|
].filter(Boolean).join(", ");
|
|
|
|
return `[Historical terminal selection omitted from replay: ${details}]`;
|
|
}
|
|
|
|
function formatAttachmentPlaceholder(
|
|
attachment: ChatMessageAttachment,
|
|
index: number,
|
|
): string {
|
|
const label = attachment.mediaType.startsWith("image/") ? "image" : "file";
|
|
const details = [
|
|
attachment.filename ? `filename=${attachment.filename}` : undefined,
|
|
attachment.filePath ? `path=${attachment.filePath}` : undefined,
|
|
`mediaType=${attachment.mediaType}`,
|
|
describeAttachmentSize(attachment),
|
|
].filter(Boolean).join(", ");
|
|
|
|
return `[Historical ${label} attachment omitted from replay: ${details || `attachment-${index + 1}`}]`;
|
|
}
|
|
|
|
export function buildHistoricalUserReplayContent(
|
|
content: string,
|
|
attachments: ChatMessageAttachment[] = [],
|
|
): string {
|
|
if (!attachments.length) return content;
|
|
// Keep note identities ahead of prose so bounded external history retains them.
|
|
const notes = attachments.filter(isVaultNoteAttachment);
|
|
const placeholders = attachments.filter((attachment) => !isVaultNoteAttachment(attachment))
|
|
.map((attachment, index) => (
|
|
isTerminalSelectionAttachment(attachment)
|
|
? formatTerminalSelectionPlaceholder(attachment, index)
|
|
: formatAttachmentPlaceholder(attachment, index)
|
|
));
|
|
|
|
const attachmentBlock = placeholders.map((line) => `\n\n${line}`).join("");
|
|
const body = content.trim() ? `${content}${attachmentBlock}` : placeholders.join("\n\n");
|
|
return notes.length ? [formatVaultNoteReferences(notes), body].filter(Boolean).join("\n\n") : body;
|
|
}
|
|
|
|
function getToolCommand(toolCall?: ToolCall): string | undefined {
|
|
const args = toolCall?.arguments ?? {};
|
|
if (typeof args.command === "string") return args.command;
|
|
const serialized = JSON.stringify(args);
|
|
return serialized && serialized !== "{}" ? serialized : undefined;
|
|
}
|
|
|
|
function fitHistoricalTerminalOutput(content: string): string {
|
|
const safeContent = redactSecretsForModel(content);
|
|
if (safeContent.length <= MAX_HISTORICAL_TERMINAL_OUTPUT_CHARS) return safeContent;
|
|
const marker = "\n\n[... historical terminal output shortened for replay ...]\n\n";
|
|
const tailChars = Math.max(
|
|
0,
|
|
MAX_HISTORICAL_TERMINAL_OUTPUT_CHARS - HISTORICAL_TERMINAL_OUTPUT_HEAD_CHARS - marker.length,
|
|
);
|
|
return `${safeContent.slice(0, HISTORICAL_TERMINAL_OUTPUT_HEAD_CHARS)}${marker}${safeContent.slice(-tailChars)}`;
|
|
}
|
|
|
|
function findOutputHandleId(content: string): string | undefined {
|
|
return content.match(/\bhandleId=(tool-output-[A-Za-z0-9-]+)/)?.[1];
|
|
}
|
|
|
|
export function buildHistoricalToolReplayMaps(messages: ChatMessage[]): {
|
|
resolvedToolCallsByAssistant: Map<ChatMessage, Set<ToolCall>>;
|
|
toolCallByToolResult: Map<ToolResult, ToolCall>;
|
|
} {
|
|
const resolvedToolCallsByAssistant = new Map<ChatMessage, Set<ToolCall>>();
|
|
const toolCallByToolResult = new Map<ToolResult, ToolCall>();
|
|
const pendingToolCalls: Array<{ message: ChatMessage; toolCall: ToolCall }> = [];
|
|
|
|
for (const message of messages) {
|
|
if (message.role === "assistant" && message.toolCalls?.length) {
|
|
for (const toolCall of message.toolCalls) {
|
|
pendingToolCalls.push({ message, toolCall });
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (message.role !== "tool" || !message.toolResults?.length) continue;
|
|
|
|
for (const result of message.toolResults) {
|
|
const pendingIndex = findLastIndex(
|
|
pendingToolCalls,
|
|
({ toolCall }) => toolCall.id === result.toolCallId,
|
|
);
|
|
if (pendingIndex < 0) continue;
|
|
|
|
const [paired] = pendingToolCalls.splice(pendingIndex, 1);
|
|
toolCallByToolResult.set(result, paired.toolCall);
|
|
|
|
const resolved = resolvedToolCallsByAssistant.get(paired.message) ?? new Set<ToolCall>();
|
|
resolved.add(paired.toolCall);
|
|
resolvedToolCallsByAssistant.set(paired.message, resolved);
|
|
}
|
|
}
|
|
|
|
return { resolvedToolCallsByAssistant, toolCallByToolResult };
|
|
}
|
|
|
|
function findLastIndex<T>(items: T[], predicate: (item: T) => boolean): number {
|
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
if (predicate(items[index])) return index;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
export function buildHistoricalToolResultReplayText(
|
|
result: ToolResult,
|
|
toolCall?: ToolCall,
|
|
{
|
|
preserveTerminalOutput = false,
|
|
}: {
|
|
preserveTerminalOutput?: boolean;
|
|
} = {},
|
|
): string {
|
|
const toolName = toolCall?.name ?? "unknown";
|
|
if (!isTerminalToolName(toolName) || preserveTerminalOutput) {
|
|
return result.content;
|
|
}
|
|
|
|
const details = [
|
|
`toolCallId=${result.toolCallId}`,
|
|
getToolCommand(toolCall) ? `command=${truncateInline(redactSecretsForModel(getToolCommand(toolCall) ?? ""), MAX_TOOL_COMMAND_CHARS)}` : undefined,
|
|
`outputChars=${result.content.length}`,
|
|
result.isError ? "status=error" : "status=success",
|
|
].filter(Boolean).join(", ");
|
|
|
|
const fittedOutput = fitHistoricalTerminalOutput(result.content);
|
|
const handleId = findOutputHandleId(fittedOutput);
|
|
const jobId = typeof toolCall?.arguments?.jobId === "string"
|
|
? toolCall.arguments.jobId
|
|
: undefined;
|
|
const recovery = handleId
|
|
? `Use tool_output_read with handleId=${handleId} for omitted details.`
|
|
: jobId
|
|
? `Poll the existing jobId=${jobId} if it is still running and more detail is needed.`
|
|
: "Only the bounded historical output below is available.";
|
|
|
|
return [
|
|
`[Historical terminal output omitted from replay beyond the bounded evidence below: ${details}. This tool call already completed; do not execute the command again. ${recovery}]`,
|
|
fittedOutput || "[No terminal output was returned.]",
|
|
].join("\n");
|
|
}
|
|
|
|
function isTerminalToolName(toolName: string): boolean {
|
|
return toolName === "terminal"
|
|
|| toolName === "terminal_exec"
|
|
|| toolName === "terminal_execute"
|
|
|| toolName === "terminal_start"
|
|
|| toolName === "terminal_poll"
|
|
|| toolName === "terminal_read_context";
|
|
}
|