Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
764 lines
22 KiB
TypeScript
764 lines
22 KiB
TypeScript
import type {
|
|
WorkspaceAgentSessionDetailTurn,
|
|
WorkspaceAgentSessionDetailViewModel
|
|
} from "../../workspaceAgentSessionDetailViewModel";
|
|
import { extractImageGenerationPreview } from "../../imageGenerationTool";
|
|
import type { AgentConversationVM } from "../contracts/agentConversationVM";
|
|
import type { AgentGeneratedImageRowVM } from "../contracts/agentGeneratedImageRowVM";
|
|
import type { AgentGoalControlRowVM } from "../contracts/agentGoalControlRowVM";
|
|
import type {
|
|
AgentMessageContentVM,
|
|
AgentMessageRowVM
|
|
} from "../contracts/agentMessageRowVM";
|
|
import type { AgentToolCallVM } from "../contracts/agentToolCallVM";
|
|
import type { AgentTranscriptRowVM } from "../contracts/agentTranscriptRowVM";
|
|
import { projectAgentMessageFinalText } from "./agentMessageFinalTextProjection";
|
|
import { computeAgentToolGroups } from "./agentToolGroupingProjection";
|
|
import { buildAgentTurnSequenceItems } from "./agentTurnSequenceProjection";
|
|
import { projectTurnRows } from "./agentTurnRowProjection";
|
|
import { projectAgentProcessingRow } from "./agentProcessingProjection";
|
|
import { projectAgentTurnSummaryRows } from "./agentTurnSummaryProjection";
|
|
|
|
export interface AgentConversationProjectionOptions {
|
|
avoidGroupingEdits?: boolean;
|
|
}
|
|
|
|
const RENDER_IRRELEVANT_TRANSCRIPT_ROW_FIELDS = new Set(["occurredAtUnixMs"]);
|
|
// Codex has emitted both the older "2%" wording and the current wording
|
|
// without a percentage; the percentage is diagnostic context, not identity.
|
|
const CODEX_SKILLS_CONTEXT_BUDGET_NOTICE_PATTERN =
|
|
/skill descriptions were shortened to fit the(?:\s+\d+(?:\.\d+)?%)?\s+skills context budget/i;
|
|
const CODEX_MODEL_METADATA_FALLBACK_NOTICE_FRAGMENT =
|
|
"defaulting to fallback metadata";
|
|
const MARKDOWN_IMAGE_PATTERN =
|
|
/!\[[^\]]*]\(\s*(?:<([^>]+)>|([^)\s]+))(?:\s+["'][^"']*["'])?\s*\)/g;
|
|
|
|
export function projectAgentConversationVM(
|
|
detail: WorkspaceAgentSessionDetailViewModel,
|
|
options: AgentConversationProjectionOptions = {}
|
|
): AgentConversationVM {
|
|
const rows: AgentTranscriptRowVM[] = [];
|
|
const turns = detail.turns;
|
|
const summariesByTurnId = new Map(
|
|
projectAgentTurnSummaryRows(detail).map((summary) => [
|
|
summary.turnId,
|
|
summary
|
|
])
|
|
);
|
|
|
|
turns.forEach((turn) => {
|
|
rows.push(
|
|
...projectTurnConversationRows(turn, detail.session.workspaceId, {
|
|
agentSessionId: detail.session.agentSessionId,
|
|
avoidGroupingEdits: options.avoidGroupingEdits
|
|
})
|
|
);
|
|
const summary = summariesByTurnId.get(turn.id);
|
|
if (summary) rows.push(summary);
|
|
});
|
|
|
|
const normalizedRows = dropCodexDiagnosticNotices(
|
|
dropRedundantErrorWarningNotices(
|
|
mergeAdjacentAssistantMessageRows(
|
|
dropRedundantCompactFailureEchoRows(
|
|
mergeAdjacentTransportRetryNoticeRows(rows)
|
|
)
|
|
)
|
|
)
|
|
);
|
|
const timelineRows = insertGoalControlRows(normalizedRows, detail);
|
|
const processing = projectAgentProcessingRow(detail, timelineRows);
|
|
const projectedRows = projectAgentMessageFinalText(
|
|
processing ? [...timelineRows, processing] : timelineRows,
|
|
detail
|
|
);
|
|
|
|
return {
|
|
activity: detail.activity,
|
|
workspaceRoot: detail.workspaceRoot,
|
|
sourceDetail: detail,
|
|
rows: projectedRows
|
|
};
|
|
}
|
|
|
|
function insertGoalControlRows(
|
|
rows: readonly AgentTranscriptRowVM[],
|
|
detail: WorkspaceAgentSessionDetailViewModel
|
|
): AgentTranscriptRowVM[] {
|
|
const controls = detail.goalControls ?? [];
|
|
if (controls.length === 0) {
|
|
return [...rows];
|
|
}
|
|
const merged = [...rows];
|
|
for (const control of controls) {
|
|
const row: AgentGoalControlRowVM = {
|
|
kind: "goal-control",
|
|
id: `goal-control:${control.id}`,
|
|
turnId: null,
|
|
action: control.action,
|
|
body: control.body,
|
|
occurredAtUnixMs: control.occurredAtUnixMs ?? null,
|
|
sourceTimelineItems: control.sourceTimelineItems
|
|
};
|
|
const insertionIndex = merged.findIndex(
|
|
(candidate) =>
|
|
row.occurredAtUnixMs !== null &&
|
|
candidate.occurredAtUnixMs !== null &&
|
|
candidate.occurredAtUnixMs > row.occurredAtUnixMs
|
|
);
|
|
if (insertionIndex < 0) {
|
|
merged.push(row);
|
|
} else {
|
|
merged.splice(insertionIndex, 0, row);
|
|
}
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function dropRedundantCompactFailureEchoRows(
|
|
rows: readonly AgentTranscriptRowVM[]
|
|
): AgentTranscriptRowVM[] {
|
|
const filtered: AgentTranscriptRowVM[] = [];
|
|
for (const row of rows) {
|
|
const previous = filtered.at(-1);
|
|
if (
|
|
isSingleCompactFailureNoticeRow(previous) &&
|
|
isSinglePlainAssistantMessageRow(row) &&
|
|
previous.turnId === row.turnId &&
|
|
previous.messages[0]?.systemNotice?.detail?.trim() ===
|
|
row.messages[0]?.body.trim()
|
|
) {
|
|
continue;
|
|
}
|
|
filtered.push(row);
|
|
}
|
|
return filtered;
|
|
}
|
|
|
|
function isSingleCompactFailureNoticeRow(
|
|
row: AgentTranscriptRowVM | undefined
|
|
): row is AgentMessageRowVM {
|
|
if (
|
|
!row ||
|
|
row.kind !== "message" ||
|
|
row.speaker !== "assistant" ||
|
|
row.thinking.length > 0 ||
|
|
row.messages.length !== 1
|
|
) {
|
|
return false;
|
|
}
|
|
const notice = row.messages[0]?.systemNotice;
|
|
return (
|
|
notice?.command === "compact" &&
|
|
notice.commandStatus === "failed" &&
|
|
Boolean(notice.detail?.trim())
|
|
);
|
|
}
|
|
|
|
function isSinglePlainAssistantMessageRow(
|
|
row: AgentTranscriptRowVM
|
|
): row is AgentMessageRowVM {
|
|
return (
|
|
row.kind === "message" &&
|
|
row.speaker === "assistant" &&
|
|
row.thinking.length === 0 &&
|
|
row.messages.length === 1 &&
|
|
!row.messages[0]?.visibleError &&
|
|
!row.messages[0]?.systemNotice
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Latest plain assistant reply in the projected transcript (newest first).
|
|
* Used as the optional consult context; error cards, system notices, plan and
|
|
* collaboration cards are skipped so only ordinary reply text qualifies.
|
|
*/
|
|
export function latestAssistantMessageText(
|
|
conversation: Pick<AgentConversationVM, "rows"> | null | undefined
|
|
): string | null {
|
|
if (!conversation) {
|
|
return null;
|
|
}
|
|
for (let rowIndex = conversation.rows.length - 1; rowIndex >= 0; rowIndex--) {
|
|
const row = conversation.rows[rowIndex];
|
|
if (row?.kind !== "message" || row.speaker !== "assistant") {
|
|
continue;
|
|
}
|
|
for (
|
|
let messageIndex = row.messages.length - 1;
|
|
messageIndex >= 0;
|
|
messageIndex--
|
|
) {
|
|
const message = row.messages[messageIndex];
|
|
if (!message || message.visibleError || message.systemNotice) {
|
|
continue;
|
|
}
|
|
if (message.contentKind && message.contentKind !== "text") {
|
|
continue;
|
|
}
|
|
const body = message.body.trim();
|
|
if (body) {
|
|
return body;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function reconcileProjectedAgentConversationVM(
|
|
previous: AgentConversationVM | null | undefined,
|
|
next: AgentConversationVM
|
|
): AgentConversationVM {
|
|
if (!previous) {
|
|
return next;
|
|
}
|
|
|
|
const previousRowsById = new Map(previous.rows.map((row) => [row.id, row]));
|
|
let reusedRowCount = 0;
|
|
const rows = next.rows.map((row) => {
|
|
const previousRow = previousRowsById.get(row.id);
|
|
if (previousRow && equivalentTranscriptRowForRender(previousRow, row)) {
|
|
reusedRowCount += 1;
|
|
return previousRow;
|
|
}
|
|
return row;
|
|
});
|
|
const rowsArrayReused =
|
|
reusedRowCount === next.rows.length &&
|
|
next.rows.length === previous.rows.length &&
|
|
next.rows.every((_row, index) => rows[index] === previous.rows[index]);
|
|
|
|
return {
|
|
...next,
|
|
rows: rowsArrayReused ? previous.rows : rows
|
|
};
|
|
}
|
|
|
|
function equivalentTranscriptRowForRender(
|
|
previous: AgentTranscriptRowVM,
|
|
next: AgentTranscriptRowVM
|
|
): boolean {
|
|
return equivalentValueIgnoringKeys(
|
|
previous,
|
|
next,
|
|
RENDER_IRRELEVANT_TRANSCRIPT_ROW_FIELDS
|
|
);
|
|
}
|
|
|
|
function equivalentValueIgnoringKeys(
|
|
left: unknown,
|
|
right: unknown,
|
|
ignoredKeys: ReadonlySet<string>
|
|
): boolean {
|
|
if (Object.is(left, right)) {
|
|
return true;
|
|
}
|
|
if (typeof left !== typeof right || left === null || right === null) {
|
|
return false;
|
|
}
|
|
if (Array.isArray(left) || Array.isArray(right)) {
|
|
return (
|
|
Array.isArray(left) &&
|
|
Array.isArray(right) &&
|
|
left.length === right.length &&
|
|
left.every((value, index) =>
|
|
equivalentValueIgnoringKeys(value, right[index], ignoredKeys)
|
|
)
|
|
);
|
|
}
|
|
if (typeof left !== "object" || typeof right !== "object") {
|
|
return false;
|
|
}
|
|
const leftRecord = left as Record<string, unknown>;
|
|
const rightRecord = right as Record<string, unknown>;
|
|
const leftKeys = Object.keys(leftRecord).filter(
|
|
(key) => !ignoredKeys.has(key)
|
|
);
|
|
const rightKeys = Object.keys(rightRecord).filter(
|
|
(key) => !ignoredKeys.has(key)
|
|
);
|
|
return (
|
|
leftKeys.length === rightKeys.length &&
|
|
leftKeys.every(
|
|
(key) =>
|
|
Object.prototype.hasOwnProperty.call(rightRecord, key) &&
|
|
equivalentValueIgnoringKeys(
|
|
leftRecord[key],
|
|
rightRecord[key],
|
|
ignoredKeys
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
function mergeAdjacentAssistantMessageRows(
|
|
rows: readonly AgentTranscriptRowVM[]
|
|
): AgentTranscriptRowVM[] {
|
|
const merged: AgentTranscriptRowVM[] = [];
|
|
for (const row of rows) {
|
|
const previous = merged.at(-1);
|
|
if (
|
|
isMergeableAssistantMessageRow(previous) &&
|
|
canMergeAdjacentAssistantMessageRows(previous, row)
|
|
) {
|
|
const lastMessage = previous.messages.at(-1);
|
|
const nextMessage = row.messages[0];
|
|
if (lastMessage && nextMessage) {
|
|
lastMessage.body = joinAdjacentAssistantMessageBodies(
|
|
lastMessage.body,
|
|
nextMessage.body,
|
|
nextMessage.statusKind
|
|
);
|
|
lastMessage.statusKind =
|
|
nextMessage.statusKind ??
|
|
(lastMessage.statusKind === "working" ||
|
|
lastMessage.statusKind === "waiting"
|
|
? null
|
|
: lastMessage.statusKind) ??
|
|
null;
|
|
lastMessage.occurredAtUnixMs =
|
|
nextMessage.occurredAtUnixMs ?? lastMessage.occurredAtUnixMs;
|
|
}
|
|
if (row.messages.length > 1) {
|
|
previous.messages.push(...row.messages.slice(1));
|
|
}
|
|
previous.occurredAtUnixMs =
|
|
row.occurredAtUnixMs ?? previous.occurredAtUnixMs;
|
|
continue;
|
|
}
|
|
merged.push(row);
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Join adjacent assistant bodies without fusing independent Markdown blocks.
|
|
* Active streaming fragments remain byte-for-byte continuations, while a
|
|
* settled fragment gains a line boundary when neither side provides one.
|
|
*/
|
|
function joinAdjacentAssistantMessageBodies(
|
|
previousBody: string,
|
|
nextBody: string,
|
|
nextStatusKind: AgentMessageContentVM["statusKind"]
|
|
): string {
|
|
if (nextStatusKind === "working" || nextStatusKind === "waiting") {
|
|
return previousBody + nextBody;
|
|
}
|
|
if (
|
|
previousBody.endsWith("\n") ||
|
|
nextBody.startsWith("\n") ||
|
|
nextBody.startsWith("\r")
|
|
) {
|
|
return previousBody + nextBody;
|
|
}
|
|
return `${previousBody}\n${nextBody}`;
|
|
}
|
|
|
|
function dropCodexDiagnosticNotices(
|
|
rows: readonly AgentTranscriptRowVM[]
|
|
): AgentTranscriptRowVM[] {
|
|
const filteredRows: AgentTranscriptRowVM[] = [];
|
|
for (const row of rows) {
|
|
if (row.kind !== "message" || row.speaker !== "assistant") {
|
|
filteredRows.push(row);
|
|
continue;
|
|
}
|
|
const messages = row.messages.filter(
|
|
(message) => !isCodexDiagnosticNotice(message)
|
|
);
|
|
if (messages.length === 0 && row.thinking.length === 0) {
|
|
continue;
|
|
}
|
|
if (messages.length === row.messages.length) {
|
|
filteredRows.push(row);
|
|
continue;
|
|
}
|
|
filteredRows.push({ ...row, messages });
|
|
}
|
|
return filteredRows;
|
|
}
|
|
|
|
function isCodexDiagnosticNotice(message: AgentMessageContentVM): boolean {
|
|
const notice = message.systemNotice;
|
|
if (!notice || notice.noticeKind !== "warning") {
|
|
return false;
|
|
}
|
|
const text = [notice.title, notice.detail, message.body]
|
|
.filter(Boolean)
|
|
.join("\n")
|
|
.toLowerCase();
|
|
if (CODEX_SKILLS_CONTEXT_BUDGET_NOTICE_PATTERN.test(text)) {
|
|
// Persisted skill notices may omit source, but an explicitly non-runtime
|
|
// notice must remain visible even when its text matches this diagnostic.
|
|
return !notice.source || notice.source === "runtime";
|
|
}
|
|
return (
|
|
notice.source === "runtime" &&
|
|
text.includes(CODEX_MODEL_METADATA_FALLBACK_NOTICE_FRAGMENT)
|
|
);
|
|
}
|
|
|
|
function dropRedundantErrorWarningNotices(
|
|
rows: readonly AgentTranscriptRowVM[]
|
|
): AgentTranscriptRowVM[] {
|
|
const turnsWithVisibleError = new Set<string>();
|
|
for (const row of rows) {
|
|
if (row.kind !== "message" || row.speaker !== "assistant") {
|
|
continue;
|
|
}
|
|
for (const message of row.messages) {
|
|
if (message.visibleError) {
|
|
turnsWithVisibleError.add(row.turnId);
|
|
}
|
|
}
|
|
}
|
|
if (turnsWithVisibleError.size === 0) {
|
|
return [...rows];
|
|
}
|
|
|
|
const filteredRows: AgentTranscriptRowVM[] = [];
|
|
for (const row of rows) {
|
|
if (row.kind !== "message" || row.speaker !== "assistant") {
|
|
filteredRows.push(row);
|
|
continue;
|
|
}
|
|
if (!turnsWithVisibleError.has(row.turnId)) {
|
|
filteredRows.push(row);
|
|
continue;
|
|
}
|
|
const messages = row.messages.filter(
|
|
(message) => !isRedundantErrorWarningNotice(message)
|
|
);
|
|
if (messages.length === 0) {
|
|
continue;
|
|
}
|
|
if (messages.length === row.messages.length) {
|
|
filteredRows.push(row);
|
|
continue;
|
|
}
|
|
filteredRows.push({ ...row, messages });
|
|
}
|
|
return filteredRows;
|
|
}
|
|
|
|
function isRedundantErrorWarningNotice(
|
|
message: AgentMessageContentVM
|
|
): boolean {
|
|
const notice = message.systemNotice;
|
|
if (!notice || notice.noticeKind !== "warning") {
|
|
return false;
|
|
}
|
|
const title = notice.title?.trim() ?? "";
|
|
return title === "Codex reported an error.";
|
|
}
|
|
|
|
function mergeAdjacentTransportRetryNoticeRows(
|
|
rows: readonly AgentTranscriptRowVM[]
|
|
): AgentTranscriptRowVM[] {
|
|
const merged: AgentTranscriptRowVM[] = [];
|
|
for (const row of rows) {
|
|
const previous = merged.at(-1);
|
|
if (
|
|
isSingleTransportRetryNoticeRow(previous) &&
|
|
isSingleTransportRetryNoticeRow(row) &&
|
|
previous.turnId === row.turnId
|
|
) {
|
|
const previousMessage = previous.messages[0];
|
|
const nextMessage = row.messages[0];
|
|
if (!previousMessage || !nextMessage) {
|
|
merged.push(row);
|
|
continue;
|
|
}
|
|
const sourceTimelineItems = mergeSourceTimelineItems(
|
|
previousMessage.sourceTimelineItems,
|
|
nextMessage.sourceTimelineItems
|
|
);
|
|
previous.messages[0] = {
|
|
...previousMessage,
|
|
body: nextMessage.body || previousMessage.body,
|
|
systemNotice: nextMessage.systemNotice ?? previousMessage.systemNotice,
|
|
statusKind:
|
|
nextMessage.statusKind ?? previousMessage.statusKind ?? null,
|
|
occurredAtUnixMs:
|
|
nextMessage.occurredAtUnixMs ?? previousMessage.occurredAtUnixMs,
|
|
...(sourceTimelineItems ? { sourceTimelineItems } : {})
|
|
};
|
|
previous.occurredAtUnixMs =
|
|
row.occurredAtUnixMs ?? previous.occurredAtUnixMs;
|
|
continue;
|
|
}
|
|
merged.push(row);
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function isSingleTransportRetryNoticeRow(
|
|
row: AgentTranscriptRowVM | undefined
|
|
): row is AgentMessageRowVM {
|
|
if (
|
|
!row ||
|
|
row.kind !== "message" ||
|
|
row.speaker !== "assistant" ||
|
|
row.thinking.length > 0 ||
|
|
row.messages.length !== 1
|
|
) {
|
|
return false;
|
|
}
|
|
return row.messages[0]?.systemNotice?.noticeKind === "transport_retry";
|
|
}
|
|
|
|
function mergeSourceTimelineItems(
|
|
previous: AgentMessageContentVM["sourceTimelineItems"],
|
|
next: AgentMessageContentVM["sourceTimelineItems"]
|
|
): AgentMessageContentVM["sourceTimelineItems"] {
|
|
if (!previous || previous.length === 0) {
|
|
return next;
|
|
}
|
|
if (!next || next.length === 0) {
|
|
return previous;
|
|
}
|
|
return [...previous, ...next];
|
|
}
|
|
|
|
function isMergeableAssistantMessageRow(
|
|
row: AgentTranscriptRowVM | undefined
|
|
): row is AgentMessageRowVM {
|
|
return Boolean(
|
|
row &&
|
|
row.kind === "message" &&
|
|
row.speaker === "assistant" &&
|
|
row.thinking.length === 0 &&
|
|
!row.messages.some(isSpecialAssistantMessage)
|
|
);
|
|
}
|
|
|
|
function canMergeAdjacentAssistantMessageRows(
|
|
previous: AgentMessageRowVM,
|
|
next: AgentTranscriptRowVM
|
|
): next is AgentMessageRowVM {
|
|
return (
|
|
next.kind === "message" &&
|
|
next.speaker === "assistant" &&
|
|
next.thinking.length === 0 &&
|
|
previous.turnId === next.turnId &&
|
|
!next.messages.some(isSpecialAssistantMessage)
|
|
);
|
|
}
|
|
|
|
function isSpecialAssistantMessage(message: {
|
|
visibleError?: unknown;
|
|
systemNotice?: unknown;
|
|
contentKind?: string;
|
|
}): boolean {
|
|
return Boolean(
|
|
message.visibleError ||
|
|
message.systemNotice ||
|
|
message.contentKind === "plan" ||
|
|
message.contentKind === "collaboration" ||
|
|
message.contentKind === "tutti-plan-issue-link"
|
|
);
|
|
}
|
|
|
|
function projectTurnConversationRows(
|
|
turn: WorkspaceAgentSessionDetailTurn,
|
|
workspaceId: string | null | undefined,
|
|
options: {
|
|
agentSessionId: string;
|
|
avoidGroupingEdits?: boolean;
|
|
}
|
|
): AgentTranscriptRowVM[] {
|
|
const sequence = buildAgentTurnSequenceItems(turn, workspaceId);
|
|
const { groups, groupedIndices, suppressedIndices } = computeAgentToolGroups(
|
|
sequence,
|
|
options
|
|
);
|
|
const skippedIndices = new Set([...groupedIndices, ...suppressedIndices]);
|
|
return promoteGeneratedImageRows(
|
|
projectTurnRows(
|
|
sequence,
|
|
groups,
|
|
skippedIndices,
|
|
turn.id,
|
|
options.agentSessionId
|
|
)
|
|
);
|
|
}
|
|
|
|
function promoteGeneratedImageRows(
|
|
rows: readonly AgentTranscriptRowVM[]
|
|
): AgentTranscriptRowVM[] {
|
|
const promoted: AgentTranscriptRowVM[] = [];
|
|
for (const row of rows) {
|
|
promoted.push(row);
|
|
if (row.kind !== "tool-group") {
|
|
continue;
|
|
}
|
|
for (const call of row.calls) {
|
|
const artifact = projectGeneratedImageRow(call, row.turnId);
|
|
if (artifact) {
|
|
promoted.push(artifact);
|
|
}
|
|
}
|
|
}
|
|
return dropDuplicateGeneratedImageMarkdown(promoted);
|
|
}
|
|
|
|
function dropDuplicateGeneratedImageMarkdown(
|
|
rows: readonly AgentTranscriptRowVM[]
|
|
): AgentTranscriptRowVM[] {
|
|
const generatedImagesByTurn = new Map<string, Set<string>>();
|
|
for (const row of rows) {
|
|
if (row.kind !== "generated-image") {
|
|
continue;
|
|
}
|
|
const reference = normalizeGeneratedImageReference(row.uri);
|
|
if (!reference) {
|
|
continue;
|
|
}
|
|
const references = generatedImagesByTurn.get(row.turnId) ?? new Set();
|
|
references.add(reference);
|
|
generatedImagesByTurn.set(row.turnId, references);
|
|
}
|
|
if (generatedImagesByTurn.size === 0) {
|
|
return [...rows];
|
|
}
|
|
|
|
const filtered: AgentTranscriptRowVM[] = [];
|
|
for (const row of rows) {
|
|
if (row.kind !== "message" || row.speaker !== "assistant") {
|
|
filtered.push(row);
|
|
continue;
|
|
}
|
|
const generatedImages = generatedImagesByTurn.get(row.turnId);
|
|
if (!generatedImages) {
|
|
filtered.push(row);
|
|
continue;
|
|
}
|
|
let changed = false;
|
|
const messages = row.messages.flatMap((message) => {
|
|
const body = removeGeneratedImageMarkdown(message.body, generatedImages);
|
|
if (body === message.body) {
|
|
return [message];
|
|
}
|
|
changed = true;
|
|
return body ? [{ ...message, body }] : [];
|
|
});
|
|
if (messages.length === 0 && row.thinking.length === 0) {
|
|
continue;
|
|
}
|
|
filtered.push(changed ? { ...row, messages } : row);
|
|
}
|
|
return filtered;
|
|
}
|
|
|
|
function removeGeneratedImageMarkdown(
|
|
body: string,
|
|
generatedImages: ReadonlySet<string>
|
|
): string {
|
|
let removed = false;
|
|
const next = body.replace(
|
|
MARKDOWN_IMAGE_PATTERN,
|
|
(
|
|
match,
|
|
angleReference: string | undefined,
|
|
plainReference: string | undefined
|
|
) => {
|
|
const reference = normalizeGeneratedImageReference(
|
|
angleReference ?? plainReference ?? ""
|
|
);
|
|
if (!reference || !generatedImages.has(reference)) {
|
|
return match;
|
|
}
|
|
removed = true;
|
|
return "";
|
|
}
|
|
);
|
|
return removed
|
|
? next
|
|
.replace(/[ \t]+\n/g, "\n")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim()
|
|
: body;
|
|
}
|
|
|
|
function normalizeGeneratedImageReference(reference: string): string | null {
|
|
let normalized = reference.trim();
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
if (/^file:\/\//i.test(normalized)) {
|
|
if (!URL.canParse(normalized)) {
|
|
return null;
|
|
}
|
|
normalized = new URL(normalized).pathname;
|
|
} else if (/^[a-z][a-z\d+.-]*:\/\//i.test(normalized)) {
|
|
return URL.canParse(normalized) ? new URL(normalized).href : normalized;
|
|
}
|
|
normalized = decodePercentEncodedReference(normalized);
|
|
normalized = normalized.replaceAll("\\", "/");
|
|
const prefix = normalized.startsWith("/") ? "/" : "";
|
|
const parts: string[] = [];
|
|
for (const part of normalized.split("/")) {
|
|
if (!part || part === ".") {
|
|
continue;
|
|
}
|
|
if (part === ".." && parts.length > 0) {
|
|
parts.pop();
|
|
continue;
|
|
}
|
|
parts.push(part);
|
|
}
|
|
const collapsed = `${prefix}${parts.join("/")}`;
|
|
return /^[a-z]:\//i.test(collapsed) ? collapsed.toLowerCase() : collapsed;
|
|
}
|
|
|
|
function decodePercentEncodedReference(reference: string): string {
|
|
return reference.replace(/(?:%[a-f\d]{2})+/gi, (encodedRun) => {
|
|
const bytes = encodedRun
|
|
.slice(1)
|
|
.split("%")
|
|
.map((hex) => Number.parseInt(hex, 16));
|
|
return new TextDecoder().decode(Uint8Array.from(bytes));
|
|
});
|
|
}
|
|
|
|
function projectGeneratedImageRow(
|
|
call: AgentToolCallVM,
|
|
turnId: string
|
|
): AgentGeneratedImageRowVM | null {
|
|
if (
|
|
call.rendererKind !== "image-generation" ||
|
|
call.statusKind !== "completed"
|
|
) {
|
|
return null;
|
|
}
|
|
const image = extractImageGenerationPreview({
|
|
toolName: call.toolName,
|
|
displayName: call.name,
|
|
outputSavedPath: call.output?.savedPath,
|
|
outputSavedPaths: call.output?.savedPaths,
|
|
outputMimeType: call.output?.imageMimeType,
|
|
outputText: call.output?.text,
|
|
inputPrompt: call.input?.prompt,
|
|
payloadInputPrompt:
|
|
call.payload?.input &&
|
|
typeof call.payload.input === "object" &&
|
|
!Array.isArray(call.payload.input)
|
|
? (call.payload.input as Record<string, unknown>).prompt
|
|
: null
|
|
});
|
|
if (!image.imageUri) {
|
|
return null;
|
|
}
|
|
return {
|
|
kind: "generated-image",
|
|
id: `generated-image:${call.id}`,
|
|
turnId,
|
|
sourceCallId: call.id,
|
|
uri: image.imageUri,
|
|
mimeType: image.mimeType,
|
|
prompt: image.prompt,
|
|
occurredAtUnixMs: call.occurredAtUnixMs
|
|
};
|
|
}
|