Separate Console image bodies from session snapshots

This commit is contained in:
2026-10-04 12:56:40 +09:00
parent f7b722246d
commit 2eb9418a4e
20 changed files with 1021 additions and 31 deletions
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -129,12 +129,12 @@ export type SessionMessageRole = "user" | "assistant";
export type SessionContentPart = { "kind": "text", text: string, } | { "kind": "refusal", refusal: string, };
export type SessionToolAttachment = { media_type: string,
export type SessionToolAttachment = {
/**
* Base64-encoded durable attachment body. Public snapshots preserve the
* committed multimodal value instead of replacing it with placeholder text.
* Stable, bounded identity derived from the owning Session entry, attachment
* position, and immutable image bytes. It is a locator, not a bearer secret.
*/
data_base64: string, };
attachment_id: string, media_type: string, byte_len: number, };
export type RunYieldReason = "compaction";
@@ -0,0 +1,97 @@
<script lang="ts">
import type { SessionToolAttachment } from "#lib/generated/protocol.ts";
import {
ConsoleAttachmentFetchError,
loadConsoleAttachment,
} from "./attachment-loader.ts";
type Props = {
attachment: SessionToolAttachment;
url: string | null;
};
let { attachment, url }: Props = $props();
let root: HTMLElement | null = $state(null);
let objectUrl = $state<string | null>(null);
let disposed = false;
let status = $state<"idle" | "loading" | "ready" | "missing" | "expired" | "unauthorized" | "network" | "failed">("idle");
async function load(): Promise<void> {
if (!url || status === "loading" || status === "ready") return;
status = "loading";
try {
const blob = await loadConsoleAttachment(url);
if (disposed) return;
if (objectUrl) URL.revokeObjectURL(objectUrl);
objectUrl = URL.createObjectURL(blob);
status = "ready";
} catch (error) {
if (disposed) return;
status = error instanceof ConsoleAttachmentFetchError ? error.kind : "failed";
}
}
function statusText(): string {
switch (status) {
case "idle": return url ? "Image waiting to load" : "Image Session is unavailable";
case "loading": return "Loading image…";
case "missing": return "Image is missing";
case "expired": return "Image retention has expired";
case "unauthorized": return "Image access was denied";
case "network": return "Image network request failed";
case "failed": return "Image could not be loaded";
default: return "";
}
}
$effect(() => {
if (!root || !url || status !== "idle") return;
if (typeof IntersectionObserver === "undefined") {
void load();
return;
}
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect();
void load();
}
}, { rootMargin: "160px" });
observer.observe(root);
return () => observer.disconnect();
});
$effect(() => () => {
disposed = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
});
</script>
<div class="console-attachment" bind:this={root} data-attachment-id={attachment.attachment_id}>
{#if status === "ready" && objectUrl}
<img src={objectUrl} alt="Tool output attachment" />
{:else}
<span>{statusText()}</span>
{#if url && ["missing", "expired", "unauthorized", "network", "failed"].includes(status)}
<button type="button" onclick={() => void load()}>Retry</button>
{/if}
{/if}
</div>
<style>
.console-attachment {
margin-top: var(--space-2);
color: var(--text-muted);
font-size: var(--font-size-compact);
}
img {
display: block;
max-width: min(100%, 52rem);
max-height: 42rem;
object-fit: contain;
border: 1px solid var(--line);
border-radius: 0.5rem;
}
button {
margin-left: var(--space-2);
}
</style>
@@ -1,13 +1,16 @@
<script lang="ts">
import AnsiText from '#lib/workspace/console/AnsiText.svelte';
import ConsoleAttachmentImage from '#lib/workspace/console/ConsoleAttachmentImage.svelte';
import RichMarkdown from '#lib/workspace/console/RichMarkdown.svelte';
import type { SessionToolAttachment } from '#lib/generated/protocol.ts';
import type { ConsoleLine } from '#lib/workspace/console/model.ts';
type Props = {
item: ConsoleLine;
attachmentUrl?: (attachment: SessionToolAttachment) => string | null;
};
let { item }: Props = $props();
let { item, attachmentUrl = () => null }: Props = $props();
let detailOpen = $state(false);
let nowMs = $state(Date.now());
@@ -140,6 +143,13 @@
{:else}
<p class="console-plain-text">{item.body || '—'}</p>
{/if}
{#if item.attachments?.length}
<div class="console-attachments">
{#each item.attachments as attachment (attachment.attachment_id)}
<ConsoleAttachmentImage {attachment} url={attachmentUrl(attachment)} />
{/each}
</div>
{/if}
{#if item.diff}
<div class="console-diff" role="group" aria-label="Edit diff">
{#each item.diff as diffLine}
@@ -0,0 +1,72 @@
import {
clearConsoleAttachmentCache,
ConsoleAttachmentFetchError,
loadConsoleAttachment,
} from "./attachment-loader.ts";
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
function assertEquals(actual: unknown, expected: unknown): void {
if (actual !== expected) {
throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
}
}
function assertStrictEquals(actual: unknown, expected: unknown): void {
if (actual !== expected) {
throw new Error("expected values to be strictly identical");
}
}
Deno.test("console attachment loads are lazy-callable and deduplicate in-flight and successful fetches", async () => {
clearConsoleAttachmentCache();
let calls = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => release = resolve);
const fetcher = (async () => {
calls += 1;
await gate;
return new Response(new Uint8Array([1, 2, 3]), {
headers: { "content-type": "image/png" },
});
}) as typeof fetch;
assertEquals(calls, 0);
const first = loadConsoleAttachment("/attachment/a", fetcher);
const second = loadConsoleAttachment("/attachment/a", fetcher);
assertStrictEquals(first, second);
assertEquals(calls, 1);
release();
const blob = await first;
assertEquals(blob.size, 3);
assertStrictEquals(
await loadConsoleAttachment("/attachment/a", fetcher),
blob,
);
assertEquals(calls, 1);
});
Deno.test("console attachment failures stay local and classify retention and authorization", async () => {
clearConsoleAttachmentCache();
for (
const [status, kind] of [[404, "missing"], [410, "expired"], [
403,
"unauthorized",
]] as const
) {
try {
await loadConsoleAttachment(
`/attachment/${status}`,
(async () => new Response(null, { status })) as typeof fetch,
);
throw new Error("expected failure");
} catch (error) {
assertEquals(
error instanceof ConsoleAttachmentFetchError && error.kind,
kind,
);
}
}
});
@@ -0,0 +1,61 @@
export type ConsoleAttachmentFetchErrorKind =
| "missing"
| "expired"
| "unauthorized"
| "network"
| "failed";
export class ConsoleAttachmentFetchError extends Error {
constructor(
public readonly kind: ConsoleAttachmentFetchErrorKind,
message: string,
) {
super(message);
this.name = "ConsoleAttachmentFetchError";
}
}
const successful = new Map<string, Blob>();
const inFlight = new Map<string, Promise<Blob>>();
export function loadConsoleAttachment(
url: string,
fetcher: typeof fetch = fetch,
): Promise<Blob> {
const cached = successful.get(url);
if (cached) return Promise.resolve(cached);
const pending = inFlight.get(url);
if (pending) return pending;
const request = (async () => {
let response: Response;
try {
response = await fetcher(url, { credentials: "same-origin" });
} catch (error) {
throw new ConsoleAttachmentFetchError(
"network",
error instanceof Error ? error.message : "Network request failed",
);
}
if (!response.ok) {
const kind: ConsoleAttachmentFetchErrorKind = response.status === 404
? "missing"
: response.status === 410
? "expired"
: response.status === 401 || response.status === 403
? "unauthorized"
: "failed";
throw new ConsoleAttachmentFetchError(kind, `HTTP ${response.status}`);
}
const blob = await response.blob();
successful.set(url, blob);
return blob;
})().finally(() => inFlight.delete(url));
inFlight.set(url, request);
return request;
}
export function clearConsoleAttachmentCache(): void {
successful.clear();
inFlight.clear();
}
@@ -3741,6 +3741,11 @@ Deno.test("committed tool identities reconcile one live call block", () => {
summary: "read",
content: "contents",
is_error: false,
attachments: [{
attachment_id: "attachment-1",
media_type: "image/png",
byte_len: 5_735_340,
}],
},
},
},
@@ -3753,6 +3758,15 @@ Deno.test("committed tool identities reconcile one live call block", () => {
);
assertEquals(lines[0].entryId, "tool-result-entry");
assertEquals(lines[0].toolCall?.state, "done");
assertEquals(lines[0].attachments, [{
attachment_id: "attachment-1",
media_type: "image/png",
byte_len: 5_735_340,
}]);
assertEquals(
projectConsoleLines(lines, "overview")[0].attachments,
lines[0].attachments,
);
});
function readHistory(ids: string[], summary: string): ConsoleLine[] {
@@ -14,6 +14,7 @@ import type {
ReasoningConfig,
Segment,
SessionSnapshotEntry,
SessionToolAttachment,
WorkerState,
WorkerStateSnapshot,
WorkerStatus,
@@ -63,6 +64,7 @@ type ToolCallView = {
isError?: boolean;
cwd?: string | null;
command?: CommandSnapshot;
attachments?: SessionToolAttachment[];
};
export type ConsoleDiffLine = {
@@ -99,6 +101,7 @@ export type ConsoleLine = {
streaming?: boolean;
error?: boolean;
toolCall?: ToolCallView;
attachments?: SessionToolAttachment[];
/** Number of calls represented by a lower-level aggregate line. */
toolCallCount?: number;
/** Original Read rows, retained so history can reconcile before aggregation. */
@@ -593,7 +596,12 @@ export function projectOverviewLines(lines: ConsoleLine[]): ConsoleLine[] {
if (line.systemItemKind === "task_reminder") continue;
if (isOverviewThinkingLine(line)) continue;
if (line.kind === "tool" && line.toolCall) {
toolGroup.push(line);
if (line.attachments?.length) {
flushTools();
overview.push(line);
} else {
toolGroup.push(line);
}
continue;
}
flushTools();
@@ -1634,7 +1642,7 @@ function attachToolResult(
projection: ConsoleProjection,
eventId: string,
id: string,
result: Pick<ToolCallView, "summary" | "output" | "isError">,
result: Pick<ToolCallView, "summary" | "output" | "isError" | "attachments">,
): void {
const index = findToolCallLineIndex(projection, id);
if (index < 0) {
@@ -1711,6 +1719,7 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
toolStatus: toolCallStatus(toolCall),
detail: toolCallDetail(toolCall),
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
attachments: toolCall.attachments,
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal,
error: toolCall.state === "error" || commandError,
};
@@ -1816,6 +1825,7 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
const hasError = calls.some((call) => call.state === "error");
const paths = calls.map(readPath);
const visiblePaths = inProgress ? paths.slice(-3) : paths;
const attachments = group.flatMap((line) => line.attachments ?? []);
const body = compactLines([
visiblePaths.map((path) => ` ${path}`).join("\n"),
inProgress && paths.length > visiblePaths.length
@@ -1835,8 +1845,10 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
source: "event",
streaming: inProgress,
error: hasError,
attachments,
toolCall: {
...calls[0]!,
attachments,
state: hasError ? "error" : inProgress ? "running" : "done",
isError: hasError,
},
@@ -2361,6 +2373,7 @@ function applySessionEntry(
summary: value["summary"],
content: value["content"],
is_error: value["is_error"],
attachments: value["attachments"],
});
break;
case "system_item": {
@@ -2509,6 +2522,10 @@ function applyLoggedItem(
summary: stringField(item, "summary") ?? "",
output: stringField(item, "content"),
isError: item["is_error"] === true,
attachments: arrayField(
item,
"attachments",
) as SessionToolAttachment[],
},
);
break;
@@ -44,6 +44,7 @@
RewindTarget,
Segment,
SessionHistoryPage,
SessionToolAttachment,
} from "#lib/generated/protocol.ts";
import {
MAX_FILES_PER_SUBMISSION,
@@ -219,6 +220,17 @@
const selectedHistory = $derived(
historyByView[selectedHistoryKey] ?? emptyConsoleHistoryState(),
);
const selectedAttachmentSessionId = $derived(
selectedWorkerView.sessionId ?? selectedHistory.sessionId,
);
function attachmentUrl(attachment: SessionToolAttachment): string | null {
if (!runtimeId || !workerId || !selectedAttachmentSessionId) return null;
return workerApiPath(
`runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}` +
`/sessions/${encodeURIComponent(selectedAttachmentSessionId)}` +
`/attachments/${encodeURIComponent(attachment.attachment_id)}`,
);
}
const committedHistoryLines = $derived(
projectSessionHistoryEntries(
historyEntries(selectedHistory),
@@ -1755,7 +1767,7 @@
{#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0}
<ol class="console-log">
{#each lines as item (item.id)}
<ConsoleLineItem {item} />
<ConsoleLineItem {item} {attachmentUrl} />
{/each}
</ol>
{/if}