# Files changed · PR \#23510 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23510/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(clients\): later image reads show updated file contents](https://git.cafe/pingdotgg/t3code/pull/23510.md)

Version: 12

Head commit: bf4c90b14702fa8dc5370645aa02eed37c76f76e

Base commit: ff525478e236ec0df4c4920eb03ac3961ea714a2

Comparison base: 5ffe04593166870d3ada2a83eb5fe55f0d192fc7

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/features/threads/ThreadFeed.tsx

Status: modified; +5; −2

```diff
@@ -217,6 +217,7 @@
   ThreadWorkGroupToggle,
   ThreadThinkingRow,
   ThreadWorkLog,
+  type ViewedImageRenderer,
   WORK_GROUP_TOGGLE_HEIGHT,
 } from "./thread-work-log";
 import { appendPendingThreadMessages, type PendingThreadFeedEntry } from "./pending-thread-feed";
@@ -1146,7 +1147,7 @@
     readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
     readonly markdownLinkHandlers: MarkdownLinkHandlers;
     readonly renderMarkdownImage: MarkdownImageRenderer;
-    readonly renderViewedImage: MarkdownImageRenderer;
+    readonly renderViewedImage: ViewedImageRenderer;
     readonly renderReasoning: (text: string) => ReactNode;
     readonly iconSubtleColor: string | import("react-native").ColorValue;
     readonly screenColor: string;
@@ -2355,10 +2356,11 @@
       />
     );
   };
+  const renderViewedImage: ViewedImageRenderer = (image, itemId) => {
-  const renderViewedImage: MarkdownImageRenderer = (image) => {
     const viewedImage = resolveViewedImageAsset(image.href, {
       threadId: props.threadId,
       workspaceRoot: props.workspaceRoot,
+      itemId,
     });
     const media = viewedImage
       ? resolveMarkdownMediaPreview(image.href, {
@@ -2375,6 +2377,7 @@
         resource={viewedImage.resource}
         alt={viewedImage.alt}
         srcFragment={viewedImage.srcFragment}
+        signingKey={viewedImage.signingKey}
         actionsSource={
           actionsSource && "resource" in actionsSource
             ? { ...actionsSource, resource: viewedImage.resource }
```

## apps/mobile/src/features/threads/ThreadMarkdownImage.tsx

Status: modified; +2; −1

```diff
@@ -244,11 +244,12 @@
   >;
   readonly alt: string | null;
   readonly srcFragment?: string;
+  readonly signingKey?: string;
   readonly actionsSource?: MediaActionsSource;
   readonly onPressPreview: (source: FilePreviewSource) => void;
 }) {
   const withGallery = useFilePreviewGallery();
+  const assetUrl = useAssetUrlState(props.environmentId, props.resource, props.signingKey);
-  const assetUrl = useAssetUrlState(props.environmentId, props.resource);
 
   return (
     <ThreadMarkdownImageView
```

## apps/mobile/src/features/threads/thread-work-log.tsx

Status: modified; +9; −3

```diff
@@ -90,7 +90,7 @@
 } from "@t3tools/client-runtime/work-log/presentation";
 import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
 import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution";
-import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText";
+import type { MarkdownImageRequest } from "../../native/SelectableMarkdownText";
 import type { FilePreviewSource } from "../../components/FilePreviewModal";
 import { FilePreviewGallery } from "../../components/FilePreviewGallery";
 import { ThreadMarkdownImage } from "./ThreadMarkdownImage";
@@ -495,11 +495,14 @@
   readonly onCopyRow: (rowId: string, value: string) => void;
   /** `groupId` is the group a lone call stands in for; it opens and closes with the row. */
   readonly onToggleRow: (rowId: string, anchorKey: string, groupId?: string) => void;
-  readonly renderImage: MarkdownImageRenderer;
+  readonly renderImage: ViewedImageRenderer;
   readonly renderReasoning: (text: string) => ReactNode;
   readonly onPressPreview: (source: FilePreviewSource) => void;
 }
 
+/** `itemId` separates each Read's signing cache entry from other Reads of the same path. */
+export type ViewedImageRenderer = (image: MarkdownImageRequest, itemId: string) => ReactNode;
+
 export function ThreadWorkLog(props: ThreadWorkLogProps) {
   const renderRow = (row: ThreadFeedActivity) => (
     <ThreadWorkLogRow
@@ -1190,7 +1193,10 @@
           ) : null}
           {viewedImagePath ? (
             <View className="pb-1.5">
+              {props.renderImage(
-              {props.renderImage({ href: viewedImagePath, alt: null, title: null })}
+                { href: viewedImagePath, alt: null, title: null },
+                row.workEntry.id,
+              )}
             </View>
           ) : null}
           <FilePreviewGallery
```

## apps/mobile/src/state/assets.ts

Status: modified; +5; −1

```diff
@@ -33,6 +33,7 @@
 export function useAssetUrlState(
   environmentId: EnvironmentId | null,
   resource: AssetResource | null,
+  signingKey?: string,
 ): AssetUrlState {
   const fileAccessSession = useEnvironmentQuery(
     environmentId === null ? null : environmentSession.sessionStateAtom(environmentId),
@@ -50,7 +51,10 @@
   const result = useAtomValue(
     blocked !== null || environmentId === null || resource === null
       ? EMPTY_ASSET_URL_ATOM
+      : assetEnvironment.createUrl({
-      : assetEnvironment.createUrl({ environmentId, input: { resource } }),
+          environmentId,
+          input: signingKey === undefined ? { resource } : { resource, signingKey },
+        }),
   );
   const shared =
     blocked ??
```

## apps/web/src/assets/assetUrls.ts

Status: modified; +8; −2

```diff
@@ -22,13 +22,14 @@
 export function useAssetUrlState(
   environmentId: EnvironmentId | null,
   resource: AssetResource | null,
+  signingKey?: string,
 ): AssetUrlState {
   const blocked = blockedAssetUrlState(resource, useFilesystemReadAccess(environmentId));
   const preparedConnection = usePreparedConnection(environmentId);
   const result = useAtomValue(
     blocked !== null || environmentId === null || resource === null
       ? EMPTY_ASSET_URL_ATOM
+      : assetEnvironment.createUrl({ environmentId, input: assetUrlInput(resource, signingKey) }),
-      : assetEnvironment.createUrl({ environmentId, input: { resource } }),
   );
   if (blocked !== null) return blocked;
   return {
@@ -43,6 +44,7 @@
 export function useAssetUrlRefresh(
   environmentId: EnvironmentId | null,
   resource: AssetResource | null,
+  signingKey?: string,
 ): () => Promise<string | null> {
   const connection = usePreparedConnection(environmentId);
   const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null;
@@ -52,7 +54,7 @@
   });
   return async () => {
     if (environmentId === null || resource === null || httpBaseUrl === null) return null;
-    const result = await refresh({ environmentId, input: { resource } });
+    const result = await refresh({ environmentId, input: assetUrlInput(resource, signingKey) });
     if (result._tag === "Failure") throw squashAtomCommandFailure(result);
     return resolveAssetUrl(httpBaseUrl, result.value.relativeUrl);
   };
@@ -92,6 +94,10 @@
   return urls;
 }
 
+function assetUrlInput(resource: AssetResource, signingKey: string | undefined) {
+  return signingKey === undefined ? { resource } : { resource, signingKey };
+}
+
 function isFileResource(resource: AssetResource): boolean {
   return (
     resource._tag === "workspace-file" ||
```

## apps/web/src/components/chat/ChatMarkdownMedia.tsx

Status: modified; +3; −2

```diff
@@ -320,6 +320,7 @@
   readonly alt: string;
   readonly copyMarkdown?: string;
   readonly srcFragment?: string;
+  readonly signingKey?: string | undefined;
   readonly standalone?: boolean | undefined;
   readonly maxHeightRem?: number | undefined;
   readonly style?: CSSProperties | undefined;
@@ -334,8 +335,8 @@
   readonly workspaceMutationId?: string | null | undefined;
   readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
 }) {
+  const assetUrl = useAssetUrlState(props.environmentId, props.resource, props.signingKey);
+  const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource, props.signingKey);
-  const assetUrl = useAssetUrlState(props.environmentId, props.resource);
-  const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource);
   const connectionReady = usePreparedConnection(props.environmentId)._tag === "Some";
   const resource = props.resource;
   const refreshToken = JSON.stringify([props.environmentId, resource, props.workspaceMutationId]);
```

## apps/web/src/components/chat/TimelineWorkEntryRow.tsx

Status: modified; +2; −0

```diff
@@ -302,6 +302,7 @@
       ? resolveViewedImageAsset(viewedImagePath, {
           threadId: threadRef.threadId,
           workspaceRoot,
+          itemId: workEntry.id,
         })
       : null;
   const canExpand =
@@ -506,6 +507,7 @@
             resource={viewedImage.resource}
             alt={viewedImage.alt}
             srcFragment={viewedImage.srcFragment}
+            signingKey={viewedImage.signingKey}
             workspaceRoot={workspaceRoot}
             maxHeightRem={16}
             onImageExpand={onImageExpand}
```

## knip.jsonc

Status: modified; +2; −1

```diff
@@ -30,7 +30,8 @@
       ],
       "ignoreDependencies": ["esbuild"],
       // The `t3` npm package's files: copied into the package, never imported.
+      // The Read cache fixture is pasted into a dev browser, never imported.
+      "ignore": ["npm-launcher/**", "fixtures/read-cache-web-states.js"],
-      "ignore": ["npm-launcher/**"],
     },
     "apps/server": {
       // Vite+ pack entries and the launcher used by installed background services.
```

## packages/client-runtime/src/state/assets.test.ts

Status: modified; +105; −2

```diff
@@ -1,11 +1,114 @@
 import { describe, expect, it } from "@effect/vitest";
+import {
+  type AssetCreateUrlInput,
+  type AssetCreateUrlResult,
+  AssetWorkspaceAssetNotFoundError,
+  EnvironmentId,
+  ThreadId,
-import { type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts";
+  WS_METHODS,
+} from "@t3tools/contracts";
 import * as Cause from "effect/Cause";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
 import * as Option from "effect/Option";
+import * as Stream from "effect/Stream";
+import * as SubscriptionRef from "effect/SubscriptionRef";
 import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
 
+import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts";
+import * as EnvironmentRegistry from "../connection/registry.ts";
+import * as EnvironmentSupervisor from "../connection/supervisor.ts";
+import type { RpcSession } from "../rpc/session.ts";
+import { createAssetEnvironmentAtoms, createProjectFaviconUrlAtomFamily } from "./assets.ts";
+
+const REMOTE = EnvironmentId.make("remote");
+const LOCAL = EnvironmentId.make("local");
+
+describe("asset URL signing", () => {
+  it.effect("signs once per Read key and never sends the key to either server", () =>
+    Effect.gen(function* () {
+      const sent: Array<readonly [EnvironmentId, AssetCreateUrlInput]> = [];
+      const makeSupervisor = (environmentId: EnvironmentId) =>
+        Effect.gen(function* () {
+          const client = {
+            [WS_METHODS.assetsCreateUrl]: (input: AssetCreateUrlInput) =>
+              Effect.gen(function* () {
+                sent.push([environmentId, input]);
+                // The remote host cannot see a local screenshot, so every
+                // request also exercises the local media fallback.
+                if (environmentId === REMOTE)
+                  return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
+                return { relativeUrl: `/api/assets/${sent.length}/square.png`, expiresAt: 1 };
+              }),
+          };
+          return {
+            target: { environmentId },
+            session: yield* SubscriptionRef.make(Option.some({ client } as unknown as RpcSession)),
+            state: yield* SubscriptionRef.make({
+              ...AVAILABLE_CONNECTION_STATE,
+              phase: "connected" as const,
+            }),
+          } as unknown as EnvironmentSupervisor.EnvironmentSupervisor["Service"];
+        });
+      const supervisors = {
+        [REMOTE]: yield* makeSupervisor(REMOTE),
+        [LOCAL]: yield* makeSupervisor(LOCAL),
+      };
+      const environmentRegistry = {
+        run: (environmentId, effect) =>
+          Effect.provideService(
+            effect,
+            EnvironmentSupervisor.EnvironmentSupervisor,
+            supervisors[environmentId]!,
+          ),
+        followStream: (environmentId, stream) =>
+          Stream.provideService(
+            stream,
+            EnvironmentSupervisor.EnvironmentSupervisor,
+            supervisors[environmentId]!,
+          ),
+      } as EnvironmentRegistry.EnvironmentRegistry["Service"];
+      const atoms = createAssetEnvironmentAtoms(
+        Atom.runtime(Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry)),
+        Atom.make({ environmentId: LOCAL, httpBaseUrl: "http://local.test" }),
+      );
+      const registry = AtomRegistry.make();
+      yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
+      const resource = {
+        _tag: "media-file",
+        threadId: ThreadId.make("thread"),
+        path: "/tmp/color-test/square.png",
+      } as const;
+      const read = (signingKey?: string) =>
+        AtomRegistry.getResult(
+          registry,
+          atoms.createUrl({
+            environmentId: REMOTE,
+            input: signingKey === undefined ? { resource } : { resource, signingKey },
+          }),
+          { suspendOnWaiting: true },
+        ).pipe(
+          Effect.map((asset) => asset.relativeUrl),
+          Effect.tap(() => Effect.yieldNow),
-import { createProjectFaviconUrlAtomFamily } from "./assets.ts";
+        );
 
+      const blueRead = yield* read("read-blue");
+      expect(yield* read("read-blue")).toBe(blueRead);
+      const redRead = yield* read("read-red");
+      const unkeyed = yield* read();
+
+      expect(new Set([blueRead, redRead, unkeyed]).size).toBe(3);
+      expect(redRead).toMatch(/^http:\/\/local\.test\/api\/assets\//);
+      expect(sent).toEqual(
+        [1, 2, 3].flatMap(() => [
+          [REMOTE, { resource }],
+          [LOCAL, { resource }],
+        ]),
+      );
+    }).pipe(Effect.scoped),
+  );
+});
+
 describe("project favicon URL cache", () => {
   it("does not reuse another environment, workspace, or selected icon's cached URL", () => {
     const registry = AtomRegistry.make();
```

## packages/client-runtime/src/state/assets.ts

Status: modified; +8; −1

```diff
@@ -129,6 +129,8 @@
   };
 }
 
+export type AssetUrlQueryInput = AssetCreateUrlInput & { readonly signingKey?: string };
+
 export function createAssetEnvironmentAtoms<R, E>(
   runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, E>,
   localMediaEnvironment?: Atom.Atom<{
@@ -136,7 +138,12 @@
     readonly httpBaseUrl: string;
   } | null>,
 ) {
+  // `signingKey` only splits the client cache: holders of one key share a
+  // signed URL, and a new key mints a fresh one.
+  const execute = Effect.fn("assets.createUrl")(function* ({
-  const execute = Effect.fn("assets.createUrl")(function* (input: AssetCreateUrlInput) {
+    signingKey: _signingKey,
+    ...input
+  }: AssetUrlQueryInput) {
     const result = yield* request(WS_METHODS.assetsCreateUrl, input).pipe(Effect.result);
     if (Result.isSuccess(result)) return result.success;
     const error = result.failure;
```

## packages/client-runtime/src/work-log/presentation.test.ts

Status: modified; +20; −0

```diff
@@ -6,6 +6,7 @@
 import {
   commandDetailRepeatsCommand,
   extractCommandOutputText,
+  resolveViewedImageAsset,
   resolveWorkEntryToolPresentation,
   toolItemForDisplay,
   type WorkLogPresentationEntry,
@@ -244,3 +245,22 @@
     ).toBe(false);
   });
 });
+
+describe("resolveViewedImageAsset", () => {
+  it("gives a later Read of an overwritten image its own signed URL", () => {
+    const threadId = ThreadId.make("thread");
+    const read = (itemId: string) => {
+      const input = { threadId, workspaceRoot: "/workspace", itemId: TurnItemId.make(itemId) };
+      return resolveViewedImageAsset("/tmp/color-test/square.png", input);
+    };
+
+    const blueRead = read("read-blue");
+    const redRead = read("read-red");
+
+    expect(blueRead).not.toBeNull();
+    expect(read("read-red")).toEqual(redRead);
+    // Clients key the signed-URL cache on this asset, so sharing it with the
+    // earlier Read shows that Read's bytes after the file is overwritten.
+    expect(redRead).not.toEqual(blueRead);
+  });
+});
```

## packages/client-runtime/src/work-log/presentation.ts

Status: modified; +9; −1

```diff
@@ -504,6 +504,8 @@
   readonly resource: Extract<AssetResource, { readonly _tag: "media-file" }>;
   readonly alt: string;
   readonly srcFragment: string;
+  /** A file can be overwritten between Reads, so each Read signs its own URL. */
+  readonly signingKey: string;
 }
 
 export function resolveViewedImageAsset(
@@ -511,6 +513,7 @@
   input: {
     readonly threadId: ThreadId;
     readonly workspaceRoot?: string | null | undefined;
+    readonly itemId: string;
   },
 ): ViewedImageAsset | null {
   const imageSource = classifyMarkdownImageSource(source, input.workspaceRoot ?? ".");
@@ -526,7 +529,12 @@
     resolvedFilePath,
   });
   if (media === null || media.access !== "environment") return null;
-  return { resource: media.resource, alt: media.name, srcFragment: media.srcFragment };
+  return {
+    resource: media.resource,
+    alt: media.name,
+    srcFragment: media.srcFragment,
+    signingKey: input.itemId,
+  };
 }
 
 function toolGroupActionCount(
```

## scripts/fixtures/read-cache-web-states.js

Status: added; +78; −0

```diff
@@ -1,0 +1,78 @@
+/* Static appearance fixture: production image view; bypasses asset hooks and signing. */
+(async () => {
+  const { default: React } = await import("/node_modules/.vite/deps/react.js");
+  const {
+    default: { createRoot },
+  } = await import("/node_modules/.vite/deps/react-dom_client.js");
+  const { AppAtomRegistryProvider } = await import("/src/rpc/atomRegistry.ts");
+  const { ChatMarkdownImage, authoredImageSizeStyle } =
+    await import("/src/components/chat/ChatMarkdownMedia.tsx");
+  const h = React.createElement;
+  const wide =
+    "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAlgAAABkCAIAAADVI9l0AAABqklEQVR4nO3OQQ0AMBAEofVvupUxjyNBANseABzWDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDUDwAg1A8AINQPACDzAaN9g0jl22GoAAAAAElFTkSuQmCC";
+  document.getElementById("read-cache-conditional-fixture")?.remove();
+  const host = document.createElement("div");
+  host.id = "read-cache-conditional-fixture";
+  Object.assign(host.style, {
+    position: "fixed",
+    inset: "0",
+    zIndex: "2147483000",
+    overflow: "auto",
+    background: "white",
+    color: "#111",
+    padding: "16px",
+    font: "14px system-ui",
+  });
+  document.body.appendChild(host);
+  const states = [
+    ["Loading (preset)", { src: null }],
+    ["Missing file (preset)", { src: null, sourceFailed: true }],
+    [
+      "Missing read access (preset)",
+      {
+        src: null,
+        sourceFailed: true,
+        unavailableDetail: "This connection cannot read host files.",
+      },
+    ],
+    ["Known size: 600×100 PNG", { src: wide }],
+    ["No dimensions: same PNG", { src: wide, style: undefined }],
+  ];
+  createRoot(host).render(
+    h(
+      AppAtomRegistryProvider,
+      null,
+      h(
+        "main",
+        null,
+        h("h1", { style: { fontSize: "18px", fontWeight: "bold" } }, "Web static image states"),
+        h(
+          "p",
+          null,
+          "Simulated asset results • production ChatMarkdownImage. Signing hooks, Read rows and transport bypassed.",
+        ),
+        ...states.map(([label, extra]) =>
+          h(
+            "section",
+            { key: label, style: { marginTop: "20px" } },
+            h("h2", { style: { fontSize: "14px", marginBottom: "6px" } }, label),
+            h(ChatMarkdownImage, {
+              src: null,
+              alt: label,
+              copyMarkdown: undefined,
+              standalone: true,
+              actionsSource: { kind: "image", name: label, src: extra.src },
+              style: authoredImageSizeStyle(600, 100),
+              ...extra,
+            }),
+          ),
+        ),
+      ),
+    ),
+  );
+  return {
+    fixture: "production ChatMarkdownImage",
+    bypassed: ["asset hooks", "Read rows", "transport"],
+    viewport: [innerWidth, innerHeight, devicePixelRatio],
+  };
+})();
```

## scripts/fixtures/read-image-cache.json

Status: added; +237; −0

```diff
@@ -1,0 +1,237 @@
+{
+  "clock": "2026-10-22T07:00:00.000Z",
+  "environmentId": "13138000-0000-4000-8000-000000000003",
+  "project": {
+    "project_id": "13138000-0000-4000-8000-000000000002",
+    "title": "Image cache proof",
+    "workspace_root": "/tmp/issue-13138-fixture/workspace",
+    "scripts_json": "[]",
+    "created_at": "2026-10-22T07:00:00.000Z",
+    "updated_at": "2026-10-22T07:00:00.000Z"
+  },
+  "thread": {
+    "createdBy": "user",
+    "creationSource": "web",
+    "id": "13138000-0000-4000-8000-000000000001",
+    "projectId": "13138000-0000-4000-8000-000000000002",
+    "title": "Read image cache fixture",
+    "providerInstanceId": "claudeAgent",
+    "modelSelection": {
+      "instanceId": "claudeAgent",
+      "model": "claude-sonnet-5-5"
+    },
+    "runtimeMode": "full-access",
+    "interactionMode": "default",
+    "branch": null,
+    "worktreePath": null,
+    "pullRequests": [],
+    "activeProviderThreadId": null,
+    "lineage": {
+      "parentThreadId": null,
+      "relationshipToParent": null,
+      "rootThreadId": "13138000-0000-4000-8000-000000000001"
+    },
+    "forkedFrom": null,
+    "createdAt": "2026-10-22T07:00:00.000Z",
+    "updatedAt": "2026-10-22T07:00:00.000Z",
+    "archivedAt": null,
+    "settledOverride": null,
+    "settledAt": null,
+    "snoozedUntil": null,
+    "snoozedAt": null,
+    "lastVisitedAt": "2026-10-22T07:00:00.000Z",
+    "titleRegeneration": null,
+    "deletedAt": null
+  },
+  "run": {
+    "id": "issue-13138-run",
+    "threadId": "13138000-0000-4000-8000-000000000001",
+    "ordinal": 1,
+    "providerInstanceId": "claudeAgent",
+    "modelSelection": {
+      "instanceId": "claudeAgent",
+      "model": "claude-sonnet-5-5"
+    },
+    "providerThreadId": null,
+    "userMessageId": "issue-13138-user",
+    "rootNodeId": "issue-13138-root",
+    "activeAttemptId": null,
+    "status": "completed",
+    "queuePosition": null,
+    "requestedAt": "2026-10-22T07:00:00.000Z",
+    "startedAt": "2026-10-22T07:00:00.000Z",
+    "completedAt": "2026-10-22T07:00:00.000Z",
+    "checkpointId": null,
+    "contextHandoffId": null,
+    "workspacePreparation": {
+      "type": "root"
+    }
+  },
+  "node": {
+    "id": "issue-13138-root",
+    "threadId": "13138000-0000-4000-8000-000000000001",
+    "runId": "issue-13138-run",
+    "parentNodeId": null,
+    "rootNodeId": "issue-13138-root",
+    "kind": "root_turn",
+    "status": "completed",
+    "countsForRun": true,
+    "providerThreadId": null,
+    "providerTurnId": null,
+    "nativeItemRef": null,
+    "runtimeRequestId": null,
+    "checkpointScopeId": null,
+    "startedAt": "2026-10-22T07:00:00.000Z",
+    "completedAt": "2026-10-22T07:00:00.000Z"
+  },
+  "items": [
+    {
+      "id": "issue-13138-read-1",
+      "threadId": "13138000-0000-4000-8000-000000000001",
+      "runId": "issue-13138-run",
+      "nodeId": "issue-13138-root",
+      "providerThreadId": null,
+      "providerTurnId": null,
+      "nativeItemRef": null,
+      "parentItemId": null,
+      "ordinal": 2,
+      "status": "completed",
+      "title": "Read square.png",
+      "startedAt": "2026-10-22T07:00:00.000Z",
+      "completedAt": "2026-10-22T07:00:00.000Z",
+      "updatedAt": "2026-10-22T07:00:00.000Z",
+      "type": "dynamic_tool",
+      "toolName": "Read",
+      "viewedImagePath": "/tmp/issue-13138-fixture/outside/square.png",
+      "input": {
+        "file_path": "/tmp/issue-13138-fixture/outside/square.png"
+      },
+      "output": {
+        "type": "image",
+        "file": {
+          "type": "image/png",
+          "originalSize": 334,
+          "dimensions": {
+            "originalWidth": 100,
+            "originalHeight": 100,
+            "displayWidth": 100,
+            "displayHeight": 100
+          },
+          "sizeBytes": 334
+        }
+      }
+    },
+    {
+      "threadId": "13138000-0000-4000-8000-000000000001",
+      "runId": "issue-13138-run",
+      "nodeId": "issue-13138-root",
+      "providerThreadId": null,
+      "providerTurnId": null,
+      "nativeItemRef": null,
+      "parentItemId": null,
+      "status": "completed",
+      "startedAt": "2026-10-22T07:00:00.000Z",
+      "completedAt": "2026-10-22T07:00:00.000Z",
+      "updatedAt": "2026-10-22T07:00:00.000Z",
+      "id": "issue-13138-label-1",
+      "ordinal": 3,
+      "type": "assistant_message",
+      "title": null,
+      "messageId": "issue-13138-label-1",
+      "text": "Next Read: green",
+      "streaming": false
+    },
+    {
+      "id": "issue-13138-read-2",
+      "threadId": "13138000-0000-4000-8000-000000000001",
+      "runId": "issue-13138-run",
+      "nodeId": "issue-13138-root",
+      "providerThreadId": null,
+      "providerTurnId": null,
+      "nativeItemRef": null,
+      "parentItemId": null,
+      "ordinal": 4,
+      "status": "completed",
+      "title": "Read square.png",
+      "startedAt": "2026-10-22T07:00:00.000Z",
+      "completedAt": "2026-10-22T07:00:00.000Z",
+      "updatedAt": "2026-10-22T07:00:00.000Z",
+      "type": "dynamic_tool",
+      "toolName": "Read",
+      "viewedImagePath": "/tmp/issue-13138-fixture/outside/square.png",
+      "input": {
+        "file_path": "/tmp/issue-13138-fixture/outside/square.png"
+      },
+      "output": {
+        "type": "image",
+        "file": {
+          "type": "image/png",
+          "originalSize": 334,
+          "dimensions": {
+            "originalWidth": 100,
+            "originalHeight": 100,
+            "displayWidth": 100,
+            "displayHeight": 100
+          },
+          "sizeBytes": 334
+        }
+      }
+    },
+    {
+      "threadId": "13138000-0000-4000-8000-000000000001",
+      "runId": "issue-13138-run",
+      "nodeId": "issue-13138-root",
+      "providerThreadId": null,
+      "providerTurnId": null,
+      "nativeItemRef": null,
+      "parentItemId": null,
+      "status": "completed",
+      "startedAt": "2026-10-22T07:00:00.000Z",
+      "completedAt": "2026-10-22T07:00:00.000Z",
+      "updatedAt": "2026-10-22T07:00:00.000Z",
+      "id": "issue-13138-label-2",
+      "ordinal": 5,
+      "type": "assistant_message",
+      "title": null,
+      "messageId": "issue-13138-label-2",
+      "text": "Next Read: blue",
+      "streaming": false
+    },
+    {
+      "id": "issue-13138-read-3",
+      "threadId": "13138000-0000-4000-8000-000000000001",
+      "runId": "issue-13138-run",
+      "nodeId": "issue-13138-root",
+      "providerThreadId": null,
+      "providerTurnId": null,
+      "nativeItemRef": null,
+      "parentItemId": null,
+      "ordinal": 6,
+      "status": "completed",
+      "title": "Read square.png",
+      "startedAt": "2026-10-22T07:00:00.000Z",
+      "completedAt": "2026-10-22T07:00:00.000Z",
+      "updatedAt": "2026-10-22T07:00:00.000Z",
+      "type": "dynamic_tool",
+      "toolName": "Read",
+      "viewedImagePath": "/tmp/issue-13138-fixture/outside/square.png",
+      "input": {
+        "file_path": "/tmp/issue-13138-fixture/outside/square.png"
+      },
+      "output": {
+        "type": "image",
+        "file": {
+          "type": "image/png",
+          "originalSize": 334,
+          "dimensions": {
+            "originalWidth": 100,
+            "originalHeight": 100,
+            "displayWidth": 100,
+            "displayHeight": 100
+          },
+          "sizeBytes": 334
+        }
+      }
+    }
+  ]
+}
```

## scripts/seed-read-image-cache.py

Status: added; +69; −0

```diff
@@ -1,0 +1,69 @@
+#!/usr/bin/env python3
+"""Seed stopped, isolated V2 visual state; or write a sequence image.
+
+Seed: --template <migrated isolated sqlite> --home <empty isolated home>.
+Write: --color red|green|blue [--atomic] [--path <sandbox PNG>].
+The three completed Read rows are preseeded. Expand them in order after each
+write; this exercises production signing/cache/rendering, not event ingestion.
+Never use this script with a running target server or the live T3 home.
+"""
+import argparse
+import json
+from pathlib import Path
+import sqlite3
+import struct
+import zlib
+
+p = argparse.ArgumentParser(description=__doc__)
+p.add_argument('--template', type=Path)
+p.add_argument('--home', type=Path)
+p.add_argument('--color', choices=['red', 'green', 'blue'])
+p.add_argument('--atomic', action='store_true')
+p.add_argument('--resource-path', default='/tmp/issue-13138-fixture/outside/square.png', help='Read resource used when seeding')
+p.add_argument('--path', type=Path, default=Path('/tmp/issue-13138-fixture/outside/square.png'))
+a = p.parse_args()
+if a.color:
+    rgb = {'red': (255, 0, 0), 'green': (0, 255, 0), 'blue': (0, 0, 255)}[a.color]
+    def chunk(tag, data):
+        return struct.pack('>I', len(data)) + tag + data + struct.pack('>I', zlib.crc32(tag + data) & 0xffffffff)
+    png = b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', 100, 100, 8, 2, 0, 0, 0)) + chunk(b'IDAT', zlib.compress((b'\0' + bytes(rgb) * 100) * 100)) + chunk(b'IEND', b'')
+    a.path.parent.mkdir(parents=True, exist_ok=True)
+    target = a.path.with_suffix('.replacement.png') if a.atomic else a.path
+    target.write_bytes(png)
+    if a.atomic:
+        target.replace(a.path)
+    print(json.dumps({'color': a.color, 'atomic': a.atomic, 'inode': a.path.stat().st_ino, 'bytes': len(png)}))
+else:
+    assert a.home and a.template, '--home and --template required'
+    assert '.t3/userdata' not in str(a.home.resolve()), 'Use a disposable evidence home'
+    dest = a.home / 'userdata/statev2.sqlite'
+    assert not dest.exists(), 'Refusing to replace an existing database'
+    # Escape the path for its place inside JSON strings; parse before creating dest.
+    f = json.loads((Path(__file__).parent / 'fixtures/read-image-cache.json').read_text().replace('/tmp/issue-13138-fixture/outside/square.png', json.dumps(a.resource_path)[1:-1]))
+    dest.parent.mkdir(parents=True, exist_ok=True)
+    src = sqlite3.connect(f'file:{a.template.resolve()}?mode=ro', uri=True)
+    db = sqlite3.connect(dest)
+    for (sql,) in src.execute("SELECT sql FROM sqlite_master WHERE sql IS NOT NULL AND type='table' AND name NOT LIKE 'sqlite_%'"):
+        db.execute(sql)
+    for row in src.execute('SELECT * FROM effect_sql_migrations'):
+        db.execute('INSERT INTO effect_sql_migrations VALUES (' + ','.join('?' for _ in row) + ')', row)
+    def put(table, values):
+        db.execute(f'INSERT INTO {table} (' + ','.join(values) + ') VALUES (' + ','.join('?' for _ in values) + ')', list(values.values()))
+    put('projection_projects', f['project'])
+    for name, key, ident, extra in [
+        ('threads', 'thread', 'thread_id', {'project_id': f['project']['project_id'], 'title': f['thread']['title'], 'default_provider': 'claudeAgent', 'runtime_mode': 'full-access', 'interaction_mode': 'default', 'created_at': f['clock'], 'updated_at': f['clock'], 'provider_instance_id': 'claudeAgent'}),
+        ('runs', 'run', 'run_id', {'thread_id': f['thread']['id'], 'ordinal': 1, 'provider': 'claudeAgent', 'status': 'completed', 'requested_at': f['clock'], 'completed_at': f['clock'], 'provider_instance_id': 'claudeAgent'}),
+        ('nodes', 'node', 'node_id', {'thread_id': f['thread']['id'], 'run_id': f['run']['id'], 'root_node_id': f['node']['id'], 'kind': 'root_turn', 'status': 'completed', 'started_at': f['clock'], 'completed_at': f['clock']}),
+    ]:
+        put('orchestration_v2_projection_' + name, {ident: f[key]['id'], **extra, 'payload_json': json.dumps(f[key])})
+    for item in f['items']:
+        put('orchestration_v2_projection_turn_items', {'turn_item_id': item['id'], 'thread_id': item['threadId'], 'run_id': item['runId'], 'node_id': item['nodeId'], 'ordinal': item['ordinal'], 'type': item['type'], 'status': item['status'], 'updated_at': item['updatedAt'], 'payload_json': json.dumps(item)})
+        if item['type'] == 'assistant_message':
+            put('orchestration_v2_projection_messages', {'message_id': item['messageId'], 'thread_id': item['threadId'], 'run_id': item['runId'], 'node_id': item['nodeId'], 'role': 'assistant', 'streaming': 0, 'created_at': item['startedAt'], 'updated_at': item['updatedAt'], 'payload_json': json.dumps({'createdBy': 'agent', 'creationSource': 'provider', 'id': item['messageId'], 'threadId': item['threadId'], 'runId': item['runId'], 'nodeId': item['nodeId'], 'role': 'assistant', 'text': item['text'], 'attachments': [], 'streaming': False, 'createdAt': item['startedAt'], 'updatedAt': item['updatedAt']})})
+    for (sql,) in src.execute("SELECT sql FROM sqlite_master WHERE sql IS NOT NULL AND type='index'"):
+        db.execute(sql)
+    db.commit()
+    db.close()
+    (dest.parent / 'environment-id').write_text(f['environmentId'])
+    Path(f['project']['workspace_root']).mkdir(parents=True, exist_ok=True)
+    print('Seeded isolated V2 rendering fixture:', dest)
```
