diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 1b40ad50..e873f473 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -10,7 +10,7 @@ body: id: version attributes: label: T4 Code version - placeholder: "0.1.11" + placeholder: "0.1.12" validations: required: true - type: dropdown diff --git a/README.md b/README.md index 152db1f2..f7eaf53e 100644 --- a/README.md +++ b/README.md @@ -4,33 +4,35 @@ T4 Code is a free, open-source (MIT) desktop app for [Oh My Pi](https://github.c ![T4 Code main window](docs/assets/t4-code-main.png) -[**Download v0.1.11**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.11) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source) +[**Download v0.1.12**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.12) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source) ## Requirements -T4 Code needs an OMP build with desktop appserver support. For v0.1.11, use the public integration build below. +T4 Code needs an OMP build with desktop appserver support. For v0.1.12, use the public integration build below. -T4 Code v0.1.11 was verified with OMP 16.5.1 built from [`15527d1f`](https://github.com/lyc-aon/oh-my-pi/commit/15527d1f00bac22705f63f80b29c0c30e67fc5da), tagged [`t4code-16.5.1-appserver-1`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-16.5.1-appserver-1). That public integration is based on the official upstream [`v16.5.1`](https://github.com/can1357/oh-my-pi/tree/v16.5.1) tag at [`14b5da76`](https://github.com/can1357/oh-my-pi/commit/14b5da76a9aece9a469288718d22c3d624daf033). It carries forward bounded replay and terminal events, complete desktop runtime projection, catalog-backed session management, deterministic failed-worker reaping, recoverable crash state, settled close state, ordered remote delivery, cross-client control convergence, and restart-safe session teardown. It also reconciles OMP 16.5.1's RPC disconnect cleanup with T4's persistent session-lock release. The official upstream v16.5.1 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source; T4 Code does not depend on private home-directory files, an auth broker, or a custom Codex CLI fork. T4 Code vendors `@oh-my-pi/app-wire` 0.5.3 from integration commit [`15527d1f`](https://github.com/lyc-aon/oh-my-pi/commit/15527d1f00bac22705f63f80b29c0c30e67fc5da), source tree `4961ea9c522a3bbf9a9900424dd475a48148c729`. +T4 Code v0.1.12 was verified with OMP 16.5.1 built from [`6a87fa64`](https://github.com/lyc-aon/oh-my-pi/commit/6a87fa6407ebff20417b4d52885a6bb3091003ea), tagged [`t4code-16.5.1-appserver-4`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-16.5.1-appserver-4). That public integration is based on the official upstream [`v16.5.1`](https://github.com/can1357/oh-my-pi/tree/v16.5.1) tag at [`14b5da76`](https://github.com/can1357/oh-my-pi/commit/14b5da76a9aece9a469288718d22c3d624daf033). It carries forward T4's appserver and lifecycle integration, adds bounded prompt-image upload and transcript-image readback, and tightens public wire-frame decoding, typed command results, canonical response boundaries, and RPC event bounds. The official upstream v16.5.1 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source; T4 Code does not depend on private home-directory files, an auth broker, or a custom Codex CLI fork. T4 Code vendors `@oh-my-pi/app-wire` 0.5.5 from integration commit [`6a87fa64`](https://github.com/lyc-aon/oh-my-pi/commit/6a87fa6407ebff20417b4d52885a6bb3091003ea), source tree `a2495fe8781c979184fe7fb9a6d37d8f33bad30f`. -| Platform | Arch | Package | -| --- | --- | --- | -| Android | arm64, armv7, x86_64 | `.apk` (**signed**) | -| Linux | x86_64 | `.deb`, AppImage | -| macOS | Apple Silicon (arm64) | `.dmg`, `.zip` (**unsigned, see below**) | +| Platform | Arch | Package | +| -------- | --------------------- | ---------------------------------------- | +| Android | arm64, armv7, x86_64 | `.apk` (**signed**) | +| Linux | x86_64 | `.deb`, AppImage | +| macOS | Apple Silicon (arm64) | `.dmg`, `.zip` (**unsigned, see below**) | -No Windows build and no Intel Mac build in v0.1.11. The iOS TestFlight build is coming soon. +No Windows build and no Intel Mac build in v0.1.12. The iOS TestFlight build is coming soon. -## What changed in v0.1.11 +## What changed in v0.1.12 -- The verified host moves to official OMP 16.5.1. It brings interrupted-turn recovery, organization-scoped Anthropic credentials, complete credential rotation, correct subagent model selection, and bounded transcript retention. -- The integration settles pending extension UI and host requests before it drains RPC work and releases the persistent session lock. app-wire remains at 0.5.3 with the same source tree and packaged bytes. +- Attach up to eight PNG, JPEG, WebP, or GIF images to a prompt, with a 20 MiB limit per image. Drafts remain recoverable when an upload or connection is interrupted. +- Transcript images are read back by verified digest through bounded queues and caches. Animated images expose pause and play controls, and reduced-motion preferences are respected. +- Desktop, browser, and Android connections now time out and retry cleanly when a host accepts a socket but never completes setup. A one-shot compatibility handshake also keeps older hosts usable when they reject the new image feature. +- The matching OMP runtime adds bounded image upload/read commands, strict public frame decoding, and bounded RPC terminal and agent metadata. ## Install ### Android 1. On the Android phone, sign in to Tailscale with an account that can reach the T4 Code host. -2. Download [`T4-Code-0.1.11-android.apk`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.11/T4-Code-0.1.11-android.apk). +2. Download [`T4-Code-0.1.12-android.apk`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.12/T4-Code-0.1.12-android.apk). 3. If Android asks, allow your browser or file manager to install unknown apps, then install the APK. 4. Open T4 Code and enter the host's HTTPS Tailscale address, including its port. @@ -39,8 +41,8 @@ The APK does not contain an appserver or expose one to the public internet. It c ### Linux (Debian/Ubuntu) ```sh -wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.11/T4-Code-0.1.11-linux-amd64.deb -sudo apt install ./T4-Code-0.1.11-linux-amd64.deb +wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.12/T4-Code-0.1.12-linux-amd64.deb +sudo apt install ./T4-Code-0.1.12-linux-amd64.deb ``` Use `apt install` rather than `dpkg -i` so system dependencies resolve automatically. @@ -48,17 +50,17 @@ Use `apt install` rather than `dpkg -i` so system dependencies resolve automatic ### Linux (AppImage) ```sh -wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.11/T4-Code-0.1.11-linux-x86_64.AppImage -chmod +x T4-Code-0.1.11-linux-x86_64.AppImage -./T4-Code-0.1.11-linux-x86_64.AppImage +wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.12/T4-Code-0.1.12-linux-x86_64.AppImage +chmod +x T4-Code-0.1.12-linux-x86_64.AppImage +./T4-Code-0.1.12-linux-x86_64.AppImage ``` ### macOS (Apple Silicon) > [!WARNING] -> **The macOS v0.1.11 build is unsigned and unnotarized.** Apple has not signed or notarized it, so Gatekeeper can report a "damaged" app or an unidentified developer. Only continue if you trust the release from this repository. You can always build from source instead. +> **The macOS v0.1.12 build is unsigned and unnotarized.** Apple has not signed or notarized it, so Gatekeeper can report a "damaged" app or an unidentified developer. Only continue if you trust the release from this repository. You can always build from source instead. -1. Download [`T4-Code-0.1.11-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.11/T4-Code-0.1.11-mac-arm64.dmg) (or [`T4-Code-0.1.11-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.11/T4-Code-0.1.11-mac-arm64.zip)). +1. Download [`T4-Code-0.1.12-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.12/T4-Code-0.1.12-mac-arm64.dmg) (or [`T4-Code-0.1.12-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.12/T4-Code-0.1.12-mac-arm64.zip)). 2. Drag `T4 Code.app` into `/Applications`. 3. If Gatekeeper blocks the app and you choose to proceed, remove the quarantine attributes from the copied app bundle: diff --git a/SECURITY.md b/SECURITY.md index 69bee9b5..fa8d155a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,4 +26,4 @@ We read every report and will reply to tell you what happens next. This is a sma - T4 Code is a desktop client. The OMP runtime is a separate project; runtime vulnerabilities belong at . - Pairing credentials are encrypted with the OS keychain via Electron `safeStorage`. Reports about credential handling, the pairing flow, or the `t4-code://` deep-link handler are especially welcome. -- The macOS v0.1.11 build is unsigned and unnotarized; that is a known, disclosed limitation, not a vulnerability report. Removing `com.apple.quarantine` changes Gatekeeper handling but does not sign, notarize, or verify the app. +- The macOS v0.1.12 build is unsigned and unnotarized; that is a known, disclosed limitation, not a vulnerability report. Removing `com.apple.quarantine` changes Gatekeeper handling but does not sign, notarize, or verify the app. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 7eebcdcb..cd011758 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -6,7 +6,7 @@ T3 Code is selectively referenced for future ports from https://github.com/pingd ## Oh My Pi -Future adaptations of OMP source use the OMP repository under its repository license. OMP remains runtime authority; adapted files retain OMP attribution and the applicable source license. The vendored `@oh-my-pi/app-wire@0.5.3` package is packed from the public `lyc-aon/oh-my-pi` integration commit `15527d1f00bac22705f63f80b29c0c30e67fc5da`, source tree `4961ea9c522a3bbf9a9900424dd475a48148c729`; tarball SHA-256 `5ae8833b963a6a56b65a9e2184358906a8717a6c8f57617875a91d4771791050`; golden corpus SHA-256 `36811f39241c6c491c967a8f969f14c43431366289750538a40893d0dc267324`. Target integration commit is recorded in the Desktop commit history and compatibility matrix. +Future adaptations of OMP source use the OMP repository under its repository license. OMP remains runtime authority; adapted files retain OMP attribution and the applicable source license. The vendored `@oh-my-pi/app-wire@0.5.5` package is packed from the public `lyc-aon/oh-my-pi` integration commit `6a87fa6407ebff20417b4d52885a6bb3091003ea`, source tree `a2495fe8781c979184fe7fb9a6d37d8f33bad30f`; tarball SHA-256 `049d92d47c0c29a019c3844072b96198105116c452a54182163ef78994ef557e`; golden corpus SHA-256 `e92d3d7a4848ab6ea6403cc1c1faa6912f8fdc75d2a6abf663ece0154a6eb7fa`. Target integration commit is recorded in the Desktop commit history and compatibility matrix. ## Oh My Pi icon diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3b2d37a8..942a53a5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/desktop", - "version": "0.1.11", + "version": "0.1.12", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/desktop/src/security-policy.ts b/apps/desktop/src/security-policy.ts index f6517aaa..ed93de33 100644 --- a/apps/desktop/src/security-policy.ts +++ b/apps/desktop/src/security-policy.ts @@ -34,7 +34,7 @@ export function rendererUrl(options: { readonly isPackaged: boolean; readonly de export function contentSecurityPolicy(trusted: TrustedRenderer, development: boolean): string { const script = development ? "'self' 'unsafe-inline'" : "'self'"; const connect = development ? `connect-src ${trusted.origin}` : "connect-src 'none'"; - return ["default-src 'self'", `script-src ${script}`, "style-src 'self' 'unsafe-inline'", "font-src 'self' data:", "img-src 'self' data:", connect, "object-src 'none'", "base-uri 'none'", "frame-src 'none'", "frame-ancestors 'none'", "form-action 'none'"].join("; "); + return ["default-src 'self'", `script-src ${script}`, "style-src 'self' 'unsafe-inline'", "font-src 'self' data:", "img-src 'self' data: blob:", connect, "object-src 'none'", "base-uri 'none'", "frame-src 'none'", "frame-ancestors 'none'", "form-action 'none'"].join("; "); } export function isTrustedNavigation(value: string, trusted: TrustedRenderer): boolean { diff --git a/apps/desktop/src/security.ts b/apps/desktop/src/security.ts index 1c5564df..a590d5a9 100644 --- a/apps/desktop/src/security.ts +++ b/apps/desktop/src/security.ts @@ -79,7 +79,7 @@ export function contentSecurityPolicy(trusted: TrustedRenderer, development: boo `script-src ${script}`, "style-src 'self' 'unsafe-inline'", "font-src 'self' data:", - "img-src 'self' data:", + "img-src 'self' data: blob:", connect, "object-src 'none'", "base-uri 'none'", diff --git a/apps/desktop/src/target-manager.ts b/apps/desktop/src/target-manager.ts index e2209237..e43437a4 100644 --- a/apps/desktop/src/target-manager.ts +++ b/apps/desktop/src/target-manager.ts @@ -7,6 +7,12 @@ import { createLocalTransport, type UnixWebSocketTransport } from "./transport.t import { createRemoteWebSocketTransport, type RemoteWebSocketTransport } from "./remote-runtime/transport.ts"; import { validateRemoteTarget, type CredentialStore, type PublicRemoteTarget, type RemoteTargetRecord, type RemoteTargetRegistry } from "./remote-runtime/registry.ts"; const DEFAULT_CAPABILITIES: readonly DeviceCapability[] = Object.freeze([...DEVICE_CAPABILITIES]); +const REQUESTED_FEATURES: readonly string[] = ADDITIVE_FEATURES; +const COMPATIBILITY_FEATURES: readonly string[] = Object.freeze( + REQUESTED_FEATURES.filter( + (feature) => feature !== "prompt.images" && feature !== "transcript.images", + ), +); export type DesktopTargetState = "disconnected" | "connecting" | "connected" | "pairing-required" | "error"; export interface PublicDesktopTarget { @@ -331,8 +337,9 @@ export class DesktopTargetManager { }), cursorStore: this.cursorStoreFactory(targetId), capabilities: requestedCapabilities, - requestedFeatures: ADDITIVE_FEATURES, - client: { name: "T4 Code", version: "0.1.11", build: "desktop", platform: process.platform }, + requestedFeatures: REQUESTED_FEATURES, + compatibilityRequestedFeatures: COMPATIBILITY_FEATURES, + client: { name: "T4 Code", version: "0.1.12", build: "desktop", platform: process.platform }, reconnect: { attemptCap: 12, baseMs: 250, maxMs: 10_000 }, }; const client = createOmpClient(clientOptions); diff --git a/apps/desktop/src/transport.ts b/apps/desktop/src/transport.ts index 849125bc..2b2cb65d 100644 --- a/apps/desktop/src/transport.ts +++ b/apps/desktop/src/transport.ts @@ -67,22 +67,32 @@ export function ensureMacRuntimeDirectory(path: string): void { export interface UnixWebSocketTransportOptions { readonly socketPath: string; readonly validatePath?: boolean; + readonly handshakeTimeoutMs?: number; } export class UnixWebSocketTransport implements OmpTransport { private readonly socketPath: string; private readonly shouldValidate: boolean; + private readonly handshakeTimeoutMs: number; private socket: WebSocket | undefined; private readonly messages = new Set<(data: string | Uint8Array) => void>(); private readonly closes = new Set<(code?: number, reason?: string) => void>(); private readonly errors = new Set<(error: unknown) => void>(); private closed = false; private openReject: (() => void) | undefined; + private openTimer: ReturnType | undefined; constructor(options: UnixWebSocketTransportOptions) { if (!options.socketPath.startsWith("/")) throw new Error("Unix socket path must be absolute"); + if ( + options.handshakeTimeoutMs !== undefined && + (!Number.isSafeInteger(options.handshakeTimeoutMs) || options.handshakeTimeoutMs <= 0) + ) { + throw new Error("handshake timeout must be a positive safe integer"); + } this.socketPath = options.socketPath; this.shouldValidate = options.validatePath ?? true; + this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? 10_000; } open(): Promise { @@ -92,14 +102,36 @@ export class UnixWebSocketTransport implements OmpTransport { const socket = new WebSocket("ws://omp.local/ws", { perMessageDeflate: false, maxPayload: 1_048_576, + handshakeTimeout: this.handshakeTimeoutMs + 100, createConnection: () => netConnect({ path: socketPath }), }); this.socket = socket; const { promise, resolve, reject } = Promise.withResolvers(); let settled = false; - this.openReject = () => { if (!settled) { settled = true; reject(new Error("local transport closed")); } }; - const succeed = (): void => { if (!settled) { settled = true; this.openReject = undefined; resolve(); } }; - const fail = (): void => { if (!settled) { settled = true; this.openReject = undefined; reject(new Error("local transport unavailable")); } }; + const clearOpenTimer = (): void => { + if (this.openTimer === undefined) return; + clearTimeout(this.openTimer); + this.openTimer = undefined; + }; + const fail = (message = "local transport unavailable"): void => { + if (settled) return; + settled = true; + clearOpenTimer(); + this.openReject = undefined; + reject(new Error(message)); + }; + this.openReject = () => fail("local transport closed"); + const succeed = (): void => { + if (settled) return; + settled = true; + clearOpenTimer(); + this.openReject = undefined; + resolve(); + }; + this.openTimer = setTimeout(() => { + fail("local websocket handshake timed out"); + if (socket.readyState === WebSocket.CONNECTING) socket.terminate(); + }, this.handshakeTimeoutMs); socket.on("open", succeed); socket.on("message", (data, isBinary) => { if (isBinary) return; @@ -128,6 +160,8 @@ export class UnixWebSocketTransport implements OmpTransport { this.closed = true; this.openReject?.(); this.openReject = undefined; + if (this.openTimer !== undefined) clearTimeout(this.openTimer); + this.openTimer = undefined; const socket = this.socket; this.socket = undefined; if (socket !== undefined) { @@ -159,4 +193,3 @@ export function createLocalTransport(): UnixWebSocketTransport { if (process.platform === "darwin") ensureMacRuntimeDirectory(dirname(socketPath)); return new UnixWebSocketTransport({ socketPath }); } - diff --git a/apps/desktop/test/security.test.ts b/apps/desktop/test/security.test.ts index 206b576a..6491c34d 100644 --- a/apps/desktop/test/security.test.ts +++ b/apps/desktop/test/security.test.ts @@ -23,6 +23,7 @@ describe("desktop security boundaries", () => { expect(contentSecurityPolicy(trusted, false)).not.toContain("ws://127.0.0.1:5173"); for (const policy of [development, contentSecurityPolicy(trusted, false)]) { expect(policy).toContain("font-src 'self' data:"); + expect(policy).toContain("img-src 'self' data: blob:"); } const production = contentSecurityPolicy(trusted, false); expect(production).toContain("script-src 'self'"); @@ -33,6 +34,8 @@ describe("desktop security boundaries", () => { const duplicateProduction = duplicateContentSecurityPolicy({ origin: trusted.origin, url: trusted.url }, false); expect(duplicateDevelopment).toContain("font-src 'self' data:"); expect(duplicateProduction).toContain("font-src 'self' data:"); + expect(duplicateDevelopment).toContain("img-src 'self' data: blob:"); + expect(duplicateProduction).toContain("img-src 'self' data: blob:"); expect(duplicateProduction).toContain("script-src 'self'"); expect(duplicateProduction).not.toContain("script-src 'self' 'unsafe-inline'"); expect(duplicateProduction).toContain("connect-src 'none'"); diff --git a/apps/desktop/test/target-manager.test.ts b/apps/desktop/test/target-manager.test.ts index 0a805166..f5e8014b 100644 --- a/apps/desktop/test/target-manager.test.ts +++ b/apps/desktop/test/target-manager.test.ts @@ -42,8 +42,13 @@ function pairingWelcome(host = "host-fixture"): WelcomeFrame { } class Transport implements OmpTransport { private readonly helloFrame: WelcomeFrame; - constructor(helloFrame: WelcomeFrame = welcome()) { + private readonly helloRejection: { readonly code: number; readonly reason: string } | undefined; + constructor( + helloFrame: WelcomeFrame = welcome(), + helloRejection?: { readonly code: number; readonly reason: string }, + ) { this.helloFrame = helloFrame; + this.helloRejection = helloRejection; } readonly sent: string[] = []; closed = false; @@ -55,8 +60,15 @@ class Transport implements OmpTransport { send(data: string): void { this.sent.push(data); for (const listener of this.sentListeners) listener(); - if (JSON.parse(data).type === "hello") - for (const listener of this.messages) listener(JSON.stringify(this.helloFrame)); + if (JSON.parse(data).type === "hello") { + if (this.helloRejection !== undefined) { + for (const listener of this.closes) { + listener(this.helloRejection.code, this.helloRejection.reason); + } + } else { + for (const listener of this.messages) listener(JSON.stringify(this.helloFrame)); + } + } } waitForSent(index: number): Promise> { const existing = this.sent[index]; @@ -153,6 +165,37 @@ function manager( } describe("desktop target manager boundaries", () => { + it("falls back from both image features for a pre-image appserver", async () => { + const transports: Transport[] = []; + const runtime = new DesktopTargetManager({ + cursorStore: new Store(), + transportFactory: () => { + const next = new Transport( + welcome(), + transports.length === 0 ? { code: 1008, reason: "invalid frame" } : undefined, + ); + transports.push(next); + return next as never; + }, + events: { onFrame: () => {}, onState: () => {}, onError: () => {} }, + }); + + await runtime.connect(); + + expect(transports).toHaveLength(2); + const firstHello = JSON.parse(transports[0]?.sent[0] ?? "{}") as { + requestedFeatures?: string[]; + }; + const fallbackHello = JSON.parse(transports[1]?.sent[0] ?? "{}") as { + requestedFeatures?: string[]; + }; + expect(firstHello.requestedFeatures).toContain("prompt.images"); + expect(firstHello.requestedFeatures).toContain("transcript.images"); + expect(fallbackHello.requestedFeatures).not.toContain("prompt.images"); + expect(fallbackHello.requestedFeatures).not.toContain("transcript.images"); + await runtime.close(); + }); + it("serializes concurrent target lifecycle and closes each generation", async () => { const transports: Transport[] = []; const registry = new Registry(); diff --git a/apps/desktop/test/transport.test.ts b/apps/desktop/test/transport.test.ts index f2881e43..4e4cb8a6 100644 --- a/apps/desktop/test/transport.test.ts +++ b/apps/desktop/test/transport.test.ts @@ -1,6 +1,6 @@ import { createServer as createHttpServer } from "node:http"; import { chmodSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { createServer as createNetServer, type Server } from "node:net"; +import { createServer as createNetServer, type Server, type Socket } from "node:net"; import { once } from "node:events"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -80,6 +80,30 @@ describeUnix("Unix socket ownership and resolution", () => { } }); + it("bounds a local WebSocket handshake that never answers", async () => { + const directory = fixtureDirectory(); + const socketPath = join(directory, "stalled.sock"); + const sockets = new Set(); + const server = createNetServer((socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + const transport = new UnixWebSocketTransport({ + socketPath, + validatePath: false, + handshakeTimeoutMs: 25, + }); + try { + await listenUnix(server, socketPath); + await expect(transport.open()).rejects.toThrow("handshake timed out"); + } finally { + transport.close(); + for (const socket of sockets) socket.destroy(); + await closeServer(server); + rmSync(directory, { recursive: true, force: true }); + } + }); + it("rejects absolute, traversal, malformed, and symlink-to-symlink targets", async () => { const directory = fixtureDirectory(); const publicPath = join(directory, "appserver.sock"); diff --git a/apps/mobile/capacitor.config.json b/apps/mobile/capacitor.config.json index 5639c08b..af409d0f 100644 --- a/apps/mobile/capacitor.config.json +++ b/apps/mobile/capacitor.config.json @@ -3,7 +3,7 @@ "appName": "T4 Code", "webDir": "dist", "loggingBehavior": "debug", - "appendUserAgent": " T4CodeMobile/0.1.11", + "appendUserAgent": " T4CodeMobile/0.1.12", "android": { "path": "android", "minWebViewVersion": 60, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 77a63675..9367010b 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/mobile", - "version": "0.1.11", + "version": "0.1.12", "private": true, "type": "module", "scripts": { diff --git a/apps/mobile/scripts/prepare-web.test.mjs b/apps/mobile/scripts/prepare-web.test.mjs index a6c96845..9dab46a7 100644 --- a/apps/mobile/scripts/prepare-web.test.mjs +++ b/apps/mobile/scripts/prepare-web.test.mjs @@ -48,6 +48,7 @@ test("the bundled document restricts connections without constraining the hosted const hostedIndex = await readFile(resolve(mobileRoot, "../web/index.html"), "utf8"); assert.match(prepareScript, /connect-src 'self' wss:\/\/\*\.ts\.net:\*/); + assert.match(prepareScript, /img-src 'self' data: blob:/); assert.match(prepareScript, /http-equiv="Content-Security-Policy"/); assert.doesNotMatch(prepareScript, /connect-src \*/); assert.doesNotMatch(hostedIndex, /http-equiv="Content-Security-Policy"/); diff --git a/apps/site/package.json b/apps/site/package.json index ad4736f6..e02d537e 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/site", - "version": "0.1.11", + "version": "0.1.12", "private": true, "type": "module", "scripts": { diff --git a/apps/site/src/release.ts b/apps/site/src/release.ts index bbc1fafc..0baf7d75 100644 --- a/apps/site/src/release.ts +++ b/apps/site/src/release.ts @@ -6,15 +6,15 @@ export const DOCS_URL = `${SITE_URL}/docs`; export const REPO_URL = "https://github.com/LycaonLLC/t4-code"; export const OMP_URL = "https://github.com/can1357/oh-my-pi"; export const OMP_RUNTIME_VERSION = "16.5.1"; -export const OMP_RUNTIME_COMMIT = "15527d1f00bac22705f63f80b29c0c30e67fc5da"; -export const OMP_RUNTIME_TAG = "t4code-16.5.1-appserver-1"; +export const OMP_RUNTIME_COMMIT = "6a87fa6407ebff20417b4d52885a6bb3091003ea"; +export const OMP_RUNTIME_TAG = "t4code-16.5.1-appserver-4"; export const OMP_RUNTIME_URL = `https://github.com/lyc-aon/oh-my-pi/tree/${OMP_RUNTIME_TAG}`; export const OMP_UPSTREAM_TAG = "v16.5.1"; export const OMP_UPSTREAM_COMMIT = "14b5da76a9aece9a469288718d22c3d624daf033"; export const OMP_UPSTREAM_URL = `${OMP_URL}/tree/${OMP_UPSTREAM_TAG}`; -export const APP_WIRE_VERSION = "0.5.3"; -export const RELEASE_TAG = "v0.1.11"; -export const RELEASE_VERSION = "0.1.11"; +export const APP_WIRE_VERSION = "0.5.5"; +export const RELEASE_TAG = "v0.1.12"; +export const RELEASE_VERSION = "0.1.12"; export const RELEASES_URL = `${REPO_URL}/releases/tag/${RELEASE_TAG}`; export type Platform = "android" | "linux" | "mac"; @@ -48,11 +48,11 @@ function asset( } export const RELEASE_ASSETS: readonly ReleaseAsset[] = [ - asset("android", "apk", "universal", "T4-Code-0.1.11-android.apk", "Android APK"), - asset("linux", "deb", "x86_64", "T4-Code-0.1.11-linux-amd64.deb", "Linux .deb"), - asset("linux", "appimage", "x86_64", "T4-Code-0.1.11-linux-x86_64.AppImage", "Linux AppImage"), - asset("mac", "dmg", "arm64", "T4-Code-0.1.11-mac-arm64.dmg", "macOS .dmg"), - asset("mac", "zip", "arm64", "T4-Code-0.1.11-mac-arm64.zip", "macOS .zip"), + asset("android", "apk", "universal", "T4-Code-0.1.12-android.apk", "Android APK"), + asset("linux", "deb", "x86_64", "T4-Code-0.1.12-linux-amd64.deb", "Linux .deb"), + asset("linux", "appimage", "x86_64", "T4-Code-0.1.12-linux-x86_64.AppImage", "Linux AppImage"), + asset("mac", "dmg", "arm64", "T4-Code-0.1.12-mac-arm64.dmg", "macOS .dmg"), + asset("mac", "zip", "arm64", "T4-Code-0.1.12-mac-arm64.zip", "macOS .zip"), ]; export function assetsFor(platform: Platform): readonly ReleaseAsset[] { diff --git a/apps/site/test/release.test.ts b/apps/site/test/release.test.ts index 2ba6af80..5331f7c7 100644 --- a/apps/site/test/release.test.ts +++ b/apps/site/test/release.test.ts @@ -1,4 +1,4 @@ -// Release contract guard: exact v0.1.11 asset names and URLs, and the +// Release contract guard: exact v0.1.12 asset names and URLs, and the // platform-detection rule the hero download button relies on. import { describe, expect, it } from "vite-plus/test"; import { @@ -19,13 +19,13 @@ import { } from "../src/release.ts"; describe("release assets", () => { - it("carries the five contracted v0.1.11 filenames", () => { + it("carries the five contracted v0.1.12 filenames", () => { expect(RELEASE_ASSETS.map((a) => a.filename)).toEqual([ - "T4-Code-0.1.11-android.apk", - "T4-Code-0.1.11-linux-amd64.deb", - "T4-Code-0.1.11-linux-x86_64.AppImage", - "T4-Code-0.1.11-mac-arm64.dmg", - "T4-Code-0.1.11-mac-arm64.zip", + "T4-Code-0.1.12-android.apk", + "T4-Code-0.1.12-linux-amd64.deb", + "T4-Code-0.1.12-linux-x86_64.AppImage", + "T4-Code-0.1.12-mac-arm64.dmg", + "T4-Code-0.1.12-mac-arm64.zip", ]); }); @@ -37,8 +37,8 @@ describe("release assets", () => { it("targets the public LycaonLLC repo", () => { expect(REPO_URL).toBe("https://github.com/LycaonLLC/t4-code"); - expect(RELEASE_TAG).toBe("v0.1.11"); - expect(RELEASE_VERSION).toBe("0.1.11"); + expect(RELEASE_TAG).toBe("v0.1.12"); + expect(RELEASE_VERSION).toBe("0.1.12"); }); it("splits assets by platform with correct architectures", () => { @@ -59,15 +59,15 @@ describe("release assets", () => { describe("OMP integration contract", () => { it("pins the verified runtime tag, commit, and app-wire package", () => { - expect(OMP_RUNTIME_TAG).toBe("t4code-16.5.1-appserver-1"); - expect(OMP_RUNTIME_COMMIT).toBe("15527d1f00bac22705f63f80b29c0c30e67fc5da"); + expect(OMP_RUNTIME_TAG).toBe("t4code-16.5.1-appserver-4"); + expect(OMP_RUNTIME_COMMIT).toBe("6a87fa6407ebff20417b4d52885a6bb3091003ea"); expect(OMP_RUNTIME_URL).toBe( - "https://github.com/lyc-aon/oh-my-pi/tree/t4code-16.5.1-appserver-1", + "https://github.com/lyc-aon/oh-my-pi/tree/t4code-16.5.1-appserver-4", ); expect(OMP_UPSTREAM_TAG).toBe("v16.5.1"); expect(OMP_UPSTREAM_COMMIT).toBe("14b5da76a9aece9a469288718d22c3d624daf033"); expect(OMP_UPSTREAM_URL).toBe("https://github.com/can1357/oh-my-pi/tree/v16.5.1"); - expect(APP_WIRE_VERSION).toBe("0.5.3"); + expect(APP_WIRE_VERSION).toBe("0.5.5"); }); }); diff --git a/apps/web/package.json b/apps/web/package.json index dccb516d..e7f0c48b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/web", - "version": "0.1.11", + "version": "0.1.12", "private": true, "type": "module", "scripts": { diff --git a/apps/web/public/t4-bootstrap.js b/apps/web/public/t4-bootstrap.js index 7e564cbb..eb856928 100644 --- a/apps/web/public/t4-bootstrap.js +++ b/apps/web/public/t4-bootstrap.js @@ -3,13 +3,27 @@ // background values mirror --background in packages/ui/src/tokens.css // (#ffffff light; color-mix(oklch(0.145 0 0) 95%, #ffffff) = #161616 // dark) — keep them in sync with the token file. +// Android System WebView releases without Object.hasOwn still execute the +// vendored app-wire decoders. Install the standards-equivalent primitive +// before the module graph loads. +if (typeof Object.hasOwn !== "function") { + Object.defineProperty(Object, "hasOwn", { + configurable: true, + writable: true, + value: function hasOwn(object, property) { + return Object.prototype.hasOwnProperty.call(object, property); + }, + }); +} + const doc = document.documentElement; let dark = false; try { const raw = localStorage.getItem("omp:workspace:v1"); const theme = raw ? JSON.parse(raw).theme : "system"; dark = theme === "dark" || (theme !== "light" && matchMedia("(prefers-color-scheme: dark)").matches); -} catch { +} catch (_error) { + void _error; dark = matchMedia("(prefers-color-scheme: dark)").matches; } if (dark) doc.classList.add("dark"); @@ -18,7 +32,8 @@ doc.style.colorScheme = dark ? "dark" : "light"; try { const accent = localStorage.getItem("t4-code:accent:v1"); if (accent) doc.dataset.accent = accent; -} catch { +} catch (_error) { + void _error; // default accent } diff --git a/apps/web/src/components/Rail.tsx b/apps/web/src/components/Rail.tsx index ce566fab..186a49e4 100644 --- a/apps/web/src/components/Rail.tsx +++ b/apps/web/src/components/Rail.tsx @@ -43,6 +43,7 @@ import { import type { SessionListView, WorkspaceSession } from "../lib/workspace-data.ts"; import { formatRelativeTime, type ProjectGroup, type SessionRow } from "../lib/session-tree.ts"; +import { composerStore } from "../features/composer/composer-store.ts"; import { createLiveSession } from "../features/session-runtime/live-create.ts"; import { archiveLiveSession, @@ -135,6 +136,11 @@ function SessionRowItem({ else if (action === "archive") await archiveLiveSession(controller, address); else if (action === "restore") await restoreLiveSession(controller, address); else await deleteLiveSession(controller, address); + // Archive/restore preserves the same draft contract as A to B to A. + // Only confirmed permanent deletion releases its staged blob URLs. + if (action === "delete") { + composerStore.getState().disposeSession(session.id); + } const verb = action === "rename" ? "renamed" diff --git a/apps/web/src/components/SessionScreen.tsx b/apps/web/src/components/SessionScreen.tsx index 9e19d60c..373af3ad 100644 --- a/apps/web/src/components/SessionScreen.tsx +++ b/apps/web/src/components/SessionScreen.tsx @@ -212,7 +212,7 @@ export function SessionScreen({
- +
{!archived && }
diff --git a/apps/web/src/features/composer/Composer.tsx b/apps/web/src/features/composer/Composer.tsx index cf05bc59..505550eb 100644 --- a/apps/web/src/features/composer/Composer.tsx +++ b/apps/web/src/features/composer/Composer.tsx @@ -12,14 +12,22 @@ import { ArrowUp, ListTodo, Paperclip, Square } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { PromptOutcome } from "../session-runtime/controller.ts"; -import type { PromptAttachment, SessionIntent } from "../session-runtime/intents.ts"; +import { + IMAGE_PROMPTS_UNSUPPORTED_REASON, + type SessionIntent, +} from "../session-runtime/intents.ts"; import { thinkingLabel, type ComposerControlsSnapshot, } from "../session-runtime/session-controls.ts"; import { useWorkspace, workspaceStore } from "../../state/store-instance.ts"; import { selectSessionView } from "../../state/workspace-store.ts"; -import { admitAttachments, type AttachmentCandidate } from "./attachments.ts"; +import { + admitAttachments, + toPromptAttachment, + type AttachmentCandidate, + type StagedAttachment, +} from "./attachments.ts"; import { composerStore, useComposer } from "./composer-store.ts"; import { ContextMeter } from "./ContextMeter.tsx"; import { AttachmentChips, RunOptionsMenu } from "./ComposerControls.tsx"; @@ -35,12 +43,21 @@ import { import { createSubmissionGate, type SubmissionIo, + type SubmissionLatch, type SubmissionNotice, type SubmittedPrompt, } from "./submission.ts"; const MAX_TEXTAREA_HEIGHT = 220; -const EMPTY_ATTACHMENTS: readonly PromptAttachment[] = []; +const EMPTY_ATTACHMENTS: readonly StagedAttachment[] = []; +const EMPTY_REJECTIONS: readonly string[] = []; +const IMAGE_REVISION_REASON = "Images cannot be added to a plan revision. Remove them or finish the revision first."; +const IMAGE_ACTIVE_TURN_REASON = + "Images can be sent with the next prompt after the running turn finishes."; + +function filesToCandidates(files: ArrayLike): AttachmentCandidate[] { + return Array.from(files, (file) => ({ file })); +} // --------------------------------------------------------------------------- // Composer @@ -99,14 +116,18 @@ export function Composer({ const attachments = useComposer( (state) => state.attachmentsBySessionId[sessionId] ?? EMPTY_ATTACHMENTS, ); + const notice = useComposer((state) => state.submissionNoticeBySessionId[sessionId] ?? null); + const sending = useComposer( + (state) => state.pendingSubmissionBySessionId[sessionId] !== undefined, + ); + const rejections = useComposer( + (state) => state.attachmentRejectionsBySessionId[sessionId] ?? EMPTY_REJECTIONS, + ); const textareaRef = useRef(null); const fileInputRef = useRef(null); const [caret, setCaret] = useState(0); const [menuIndex, setMenuIndex] = useState(0); const [menuDismissed, setMenuDismissed] = useState(false); - const [rejections, setRejections] = useState([]); - const [notice, setNotice] = useState(null); - const [sending, setSending] = useState(false); const disabled = !canPrompt || readOnlyReason !== null; const disabledReason = @@ -154,6 +175,11 @@ export function Composer({ }, [sessionId], ); + const setRejections = useCallback( + (next: readonly string[]) => + composerStore.getState().setAttachmentRejections(sessionId, next), + [sessionId], + ); const acceptSlash = useCallback( (index: number) => { @@ -173,10 +199,18 @@ export function Composer({ [slashItems, setDraft], ); - // One submission gate per runtime seam: it drops double-submits while a - // send is pending and applies the outcome — clear on accepted, keep the - // exact draft/attachments/caret on rejected or unknown. - const gate = useMemo(() => createSubmissionGate(submitPrompt), [submitPrompt]); + // The latch lives in the session-keyed composer store, so switching away + // and back cannot create an unlocked second gate while this send is live. + const latch = useMemo( + () => ({ + pending: () => composerStore.getState().pendingSubmissionBySessionId[sessionId] !== undefined, + begin: () => composerStore.getState().beginSubmission(sessionId), + current: (token) => composerStore.getState().isSubmissionCurrent(sessionId, token), + end: (token) => composerStore.getState().finishSubmission(sessionId, token), + }), + [sessionId], + ); + const gate = useMemo(() => createSubmissionGate(submitPrompt, latch), [submitPrompt, latch]); const submissionIo = useMemo( () => ({ @@ -189,17 +223,16 @@ export function Composer({ removeAttachments: (ids) => { for (const id of ids) composerStore.getState().removeAttachment(sessionId, id); }, - setNotice, + setNotice: (next: SubmissionNotice) => + composerStore.getState().setSubmissionNotice(sessionId, next), }), - [sessionId, resizeTextarea], + [sessionId, resizeTextarea, setRejections], ); const runSubmission = useCallback( (intent: SessionIntent, submitted: SubmittedPrompt, onAccepted?: () => void) => { if (gate.pending()) return; - setSending(true); void gate.submit(intent, submitted, submissionIo).then((outcome) => { - setSending(false); if (outcome !== null && outcome.kind === "accepted") onAccepted?.(); }); }, @@ -209,7 +242,15 @@ export function Composer({ const submit = useCallback(() => { const text = draft.trim(); if (text === "" && attachments.length === 0) return; + if (attachments.length > 0 && !controls.attachmentsSupported) { + setRejections([IMAGE_PROMPTS_UNSUPPORTED_REASON]); + return; + } if (revisingPlanId !== null) { + if (attachments.length > 0) { + setRejections([IMAGE_REVISION_REASON]); + return; + } // The revision banner stays until the host accepts the note, so a // rejected revision keeps both the draft and the revising context. runSubmission( @@ -220,39 +261,71 @@ export function Composer({ return; } if (turnActive) { + if (attachments.length > 0) { + setRejections([IMAGE_ACTIVE_TURN_REASON]); + return; + } runSubmission({ kind: "steer", text }, { text: draft, attachmentIds: [] }); return; } runSubmission( - { kind: "prompt", text, attachments }, + { kind: "prompt", text, attachments: attachments.map(toPromptAttachment) }, { text: draft, attachmentIds: attachments.map((attachment) => attachment.id) }, ); - }, [draft, attachments, turnActive, revisingPlanId, onCancelRevise, runSubmission]); + }, [ + draft, + attachments, + controls.attachmentsSupported, + turnActive, + revisingPlanId, + onCancelRevise, + runSubmission, + ]); const queueFollowUp = useCallback(() => { const text = draft.trim(); if (text === "") return; + if (attachments.length > 0) { + setRejections([IMAGE_ACTIVE_TURN_REASON]); + return; + } runSubmission({ kind: "followUp", text }, { text: draft, attachmentIds: [] }); - }, [draft, runSubmission]); + }, [draft, attachments.length, runSubmission]); + + const reportUnsupportedImages = useCallback(() => { + setRejections([IMAGE_PROMPTS_UNSUPPORTED_REASON]); + }, []); const intake = useCallback( (candidates: readonly AttachmentCandidate[]) => { - if (!controls.attachmentsSupported) return; - const result = admitAttachments(attachments, candidates); + if (!controls.attachmentsSupported) { + reportUnsupportedImages(); + return; + } + const stagedBySession = composerStore.getState().attachmentsBySessionId; + const existing = stagedBySession[sessionId] ?? []; + let stagedBytes = 0; + let stagedCount = 0; + for (const staged of Object.values(stagedBySession)) { + stagedCount += staged.length; + for (const attachment of staged) stagedBytes += attachment.sizeBytes; + } + const result = admitAttachments(existing, candidates, { stagedBytes, stagedCount }); if (result.accepted.length > 0) { composerStore.getState().addAttachments(sessionId, result.accepted); } setRejections(result.rejections); }, - [attachments, sessionId, controls.attachmentsSupported], + [sessionId, controls.attachmentsSupported, reportUnsupportedImages], ); - const filesToCandidates = (files: ArrayLike): AttachmentCandidate[] => - Array.from(files, (file) => ({ - name: file.name, - mediaType: file.type || "application/octet-stream", - sizeBytes: file.size, - })); + const requestAttachmentPicker = useCallback(() => { + if (!controls.attachmentsSupported) { + reportUnsupportedImages(); + return; + } + fileInputRef.current?.click(); + }, [controls.attachmentsSupported, reportUnsupportedImages]); const handleKeyDown = (event: React.KeyboardEvent) => { const keyInput = { @@ -298,6 +371,7 @@ export function Composer({ const action = resolveComposerKey(keyInput); if (action === "submit") { event.preventDefault(); + if (sending) return; submit(); } // "newline" falls through to the textarea's default behavior. @@ -366,17 +440,12 @@ export function Composer({
{ - if ( - !disabled && - controls.attachmentsSupported && - event.dataTransfer.types.includes("Files") - ) { - event.preventDefault(); - } + if (event.dataTransfer.types.includes("Files")) event.preventDefault(); }} onDrop={(event) => { - if (disabled || !controls.attachmentsSupported) return; + if (event.dataTransfer.files.length === 0) return; event.preventDefault(); + if (disabled) return; intake(filesToCandidates(event.dataTransfer.files)); }} > @@ -413,8 +482,9 @@ export function Composer({ }} onKeyDown={handleKeyDown} onPaste={(event) => { - if (disabled || event.clipboardData.files.length === 0) return; + if (event.clipboardData.files.length === 0) return; event.preventDefault(); + if (disabled) return; intake(filesToCandidates(event.clipboardData.files)); }} onSelect={(event) => setCaret(event.currentTarget.selectionStart)} @@ -433,7 +503,7 @@ export function Composer({ /> {controls.attachmentsSupported && ( { @@ -452,23 +522,27 @@ export function Composer({ onIntent={onIntent} /> - {controls.attachmentsSupported && ( - - fileInputRef.current?.click()} - size="icon-sm" - > - - - } - /> - Attach images or text files - - )} + + + + + } + /> + + {controls.attachmentsSupported + ? "Attach PNG, JPEG, WebP, or GIF images" + : "This host does not support image prompts yet"} + + {turnActive && ( <> @@ -527,16 +601,16 @@ export function Composer({ /> - {controls.attachmentsSupported && ( - fileInputRef.current?.click()} - size="icon-xl" - > - - - )} + + +
void; }) { if (attachments.length === 0) return null; return (
    {attachments.map((attachment) => (
  • - {attachment.kind === "image" ? ( -
+ ); +} + +function TranscriptImage({ + source, + reference, + alt, + motionPreference, +}: { + readonly source: TranscriptImageSource; + readonly reference: TranscriptImageReference; + readonly alt: string; + readonly motionPreference?: MotionPreference; +}) { + const subscribe = useCallback( + (listener: () => void) => source.subscribe(reference, listener), + [reference, source], + ); + const getSnapshot = useCallback( + () => source.getSnapshot(reference), + [reference, source], + ); + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const reducedMotion = useReducedMotion(motionPreference); + const [animationVisible, setAnimationVisible] = useState(() => !reducedMotion); + + useEffect(() => source.retain(reference), [reference, source]); + useEffect(() => { + if (reducedMotion) setAnimationVisible(false); + }, [reducedMotion]); + + if (snapshot.status === "loading") { + return ; + } + if (snapshot.status === "unavailable") { + return ; + } + if (snapshot.status === "error") { + return ; + } + const showImage = !snapshot.animated || animationVisible; + const animationLabel = animationVisible ? "Pause animation" : "Play animation"; + return ( +
+ {showImage ? ( + {alt} source.reportDecodeFailure(reference)} + src={snapshot.url} + /> + ) : ( +
+
+ )} + {snapshot.animated && ( + + )} +
+ ); +} + +/** Ordered evidence strip: horizontal on narrow panes, wrapped at desktop widths. */ +export function TranscriptImages({ + source, + images, + issue, + label, + className, + motionPreference, +}: TranscriptImagesProps) { + if (images.length === 0 && issue === null) return null; + return ( +
+ {issue !== null && ( +
+ +
+ )} + {images.map((reference, index) => ( +
+ +
+ ))} +
+ ); +} diff --git a/apps/web/src/features/transcript/TranscriptRows.tsx b/apps/web/src/features/transcript/TranscriptRows.tsx index ca91aed1..09e5a528 100644 --- a/apps/web/src/features/transcript/TranscriptRows.tsx +++ b/apps/web/src/features/transcript/TranscriptRows.tsx @@ -24,10 +24,12 @@ import { } from "lucide-react"; import { memo, useEffect, useRef, useState } from "react"; +import type { TranscriptImageSource } from "../session-runtime/transcript-images.ts"; import { useAnchoredDisclosure } from "./disclosure-anchor.tsx"; import { CopyButton, Markdown } from "./Markdown.tsx"; import type { ToolCall, TranscriptNotice } from "./projection.ts"; import { formatElapsed, type TranscriptRow } from "./rows.ts"; +import { TranscriptImages } from "./TranscriptImages.tsx"; // --------------------------------------------------------------------------- // Self-ticking elapsed label @@ -97,12 +99,24 @@ function ReasoningDisclosure({ reasoning }: { readonly reasoning: string }) { ); } -function MessageRow({ row }: { readonly row: Extract }) { +function MessageRow({ + row, + imageSource, +}: { + readonly row: Extract; + readonly imageSource: TranscriptImageSource; +}) { if (row.role === "user") { return (
+ @@ -114,6 +128,12 @@ function MessageRow({ row }: { readonly row: Extract {row.reasoning !== "" && } +
["calls"][number]; readonly nowMs: number; + readonly imageSource: TranscriptImageSource; }) { const [open, setOpen] = useState(false); const anchoredToggle = useAnchoredDisclosure(); @@ -301,6 +323,13 @@ const ToolCallRow = memo(function ToolCallRow({ {call.progress.join("\n")} )} + {open && (
@@ -337,14 +366,16 @@ const ToolCallRow = memo(function ToolCallRow({ function ToolGroupRow({ row, nowMs, + imageSource, }: { readonly row: Extract; readonly nowMs: number; + readonly imageSource: TranscriptImageSource; }) { return (
{row.calls.map((call) => ( - + ))}
); @@ -465,16 +496,18 @@ function WorkingRow({ export const TranscriptRowContent = memo(function TranscriptRowContent({ row, nowMs, + imageSource, }: { readonly row: TranscriptRow; /** Elapsed-label time base from the session runtime snapshot. */ readonly nowMs: number; + readonly imageSource: TranscriptImageSource; }) { switch (row.kind) { case "message": - return ; + return ; case "tool-group": - return ; + return ; case "notice": return ; case "unknown-entry": diff --git a/apps/web/src/features/transcript/TranscriptTimeline.tsx b/apps/web/src/features/transcript/TranscriptTimeline.tsx index 6835c824..fdfd643a 100644 --- a/apps/web/src/features/transcript/TranscriptTimeline.tsx +++ b/apps/web/src/features/transcript/TranscriptTimeline.tsx @@ -12,6 +12,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat import { useWorkspace, workspaceStore } from "../../state/store-instance.ts"; import { selectSessionView } from "../../state/workspace-store.ts"; +import type { TranscriptImageSource } from "../session-runtime/transcript-images.ts"; import { createAnchoredToggle, DisclosureAnchorContext } from "./disclosure-anchor.tsx"; import type { TranscriptRow } from "./rows.ts"; import { TranscriptRowContent } from "./TranscriptRows.tsx"; @@ -47,6 +48,7 @@ export interface TranscriptTimelineProps { readonly bottomInset: number; /** Elapsed-label time base from the session runtime snapshot. */ readonly nowMs: number; + readonly imageSource: TranscriptImageSource; } export const TranscriptTimeline = memo(function TranscriptTimeline({ @@ -55,6 +57,7 @@ export const TranscriptTimeline = memo(function TranscriptTimeline({ streaming, bottomInset, nowMs, + imageSource, }: TranscriptTimelineProps) { const listRef = useRef(null); // null anchor = the user was following the tail when they left. @@ -186,10 +189,10 @@ export const TranscriptTimeline = memo(function TranscriptTimeline({ const renderItem = useCallback( ({ item }: { item: TranscriptRow }) => (
- +
), - [nowMs], + [imageSource, nowMs], ); const maintainScrollAtEnd = useMemo( @@ -331,7 +334,7 @@ export const TranscriptTimeline = memo(function TranscriptTimeline({ className="mx-auto w-full max-w-(--transcript-measure) min-w-0 shrink-0 px-4 sm:px-6" key={row.id} > - +
))}
diff --git a/apps/web/src/features/transcript/image-metadata.ts b/apps/web/src/features/transcript/image-metadata.ts new file mode 100644 index 00000000..fc53198d --- /dev/null +++ b/apps/web/src/features/transcript/image-metadata.ts @@ -0,0 +1,82 @@ +// Transcript image metadata stays tiny and inert in the row projection. The +// bytes live behind `session.image.read`; rows carry only the durable entry id, +// digest, and MIME type needed to ask the host for an authorized blob. +import type { DurableEntry } from "./projection.ts"; + +export const TRANSCRIPT_IMAGE_MAX_COUNT = 64; + +export const TRANSCRIPT_IMAGE_MIME_TYPES = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", +] as const; + +export type TranscriptImageMimeType = (typeof TRANSCRIPT_IMAGE_MIME_TYPES)[number]; + +export interface TranscriptImageReference { + readonly entryId: string; + readonly sha256: string; + readonly mimeType: TranscriptImageMimeType; +} + +export interface TranscriptImageMetadataResult { + readonly images: readonly TranscriptImageReference[]; + /** Present only when an entry advertised malformed image metadata. */ + readonly issue: string | null; +} + +export const INVALID_TRANSCRIPT_IMAGE_METADATA = + "This transcript entry contains invalid image metadata."; + +const EMPTY_RESULT: TranscriptImageMetadataResult = Object.freeze({ + images: Object.freeze([]), + issue: null, +}); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, expected: readonly string[]): boolean { + const keys = Object.keys(value); + return ( + keys.length === expected.length && + expected.every((key) => Object.prototype.hasOwnProperty.call(value, key)) + ); +} + +function isMimeType(value: unknown): value is TranscriptImageMimeType { + return ( + typeof value === "string" && + (TRANSCRIPT_IMAGE_MIME_TYPES as readonly string[]).includes(value) + ); +} + +/** + * Mirror app-wire 0.5.5's strict metadata decoder until that generated + * package is vendored. One malformed item rejects the whole ordered list; + * partially trusted metadata must never trigger a host read. + */ +export function transcriptImagesFromEntry(entry: DurableEntry): TranscriptImageMetadataResult { + const raw = entry.data.images; + if (raw === undefined) return EMPTY_RESULT; + if (!Array.isArray(raw) || raw.length > TRANSCRIPT_IMAGE_MAX_COUNT) { + return { images: [], issue: INVALID_TRANSCRIPT_IMAGE_METADATA }; + } + + const images: TranscriptImageReference[] = []; + for (const item of raw) { + if ( + !isRecord(item) || + !hasExactKeys(item, ["sha256", "mimeType"]) || + typeof item.sha256 !== "string" || + !/^[a-f0-9]{64}$/u.test(item.sha256) || + !isMimeType(item.mimeType) + ) { + return { images: [], issue: INVALID_TRANSCRIPT_IMAGE_METADATA }; + } + images.push({ entryId: String(entry.id), sha256: item.sha256, mimeType: item.mimeType }); + } + return { images, issue: null }; +} diff --git a/apps/web/src/features/transcript/rows.ts b/apps/web/src/features/transcript/rows.ts index 51b8fefd..de18d357 100644 --- a/apps/web/src/features/transcript/rows.ts +++ b/apps/web/src/features/transcript/rows.ts @@ -19,6 +19,10 @@ import { type TranscriptNotice, type TranscriptProjection, } from "./projection.ts"; +import { + transcriptImagesFromEntry, + type TranscriptImageReference, +} from "./image-metadata.ts"; /** Pure elapsed formatter: "42s" under a minute, then "3m 7s". */ export function formatElapsed(fromIso: string, nowMs: number): string { @@ -35,6 +39,8 @@ export type TranscriptRow = readonly role: "user" | "assistant"; readonly text: string; readonly reasoning: string; + readonly images: readonly TranscriptImageReference[]; + readonly imageIssue: string | null; /** Live rows stream in place; settled rows come from durable entries. */ readonly live: boolean; readonly startedAt: string; @@ -42,7 +48,7 @@ export type TranscriptRow = | { readonly id: string; readonly kind: "tool-group"; - readonly calls: readonly ToolCall[]; + readonly calls: readonly TranscriptToolCall[]; /** True while any call in the group is still running. */ readonly running: boolean; } @@ -64,6 +70,11 @@ export type TranscriptRow = readonly startedAt: string; }; +export interface TranscriptToolCall extends ToolCall { + readonly images: readonly TranscriptImageReference[]; + readonly imageIssue: string | null; +} + export interface AttentionState { readonly approval: ApprovalRequest | null; readonly ask: AskRequest | null; @@ -92,8 +103,9 @@ function textOf(data: Record, key: string): string { } /** Durable tool entries fold into synthetic settled ToolCalls. */ -function toolCallFromEntry(entry: DurableEntry): ToolCall { +function toolCallFromEntry(entry: DurableEntry): TranscriptToolCall { const data = entry.data; + const transcriptImages = transcriptImagesFromEntry(entry); return { callId: entry.id, tool: textOf(data, "tool") || "tool", @@ -104,12 +116,14 @@ function toolCallFromEntry(entry: DurableEntry): ToolCall { progress: [], result: data.result === undefined || data.result === null ? null : plainRecord(data.result), endedAt: entry.timestamp, + images: transcriptImages.images, + imageIssue: transcriptImages.issue, }; } function rowsFromEntries(entries: readonly DurableEntry[]): TranscriptRow[] { const rows: TranscriptRow[] = []; - let pendingTools: ToolCall[] = []; + let pendingTools: TranscriptToolCall[] = []; let pendingToolGroupId = ""; const flushTools = () => { @@ -129,12 +143,15 @@ function rowsFromEntries(entries: readonly DurableEntry[]): TranscriptRow[] { case "message": { flushTools(); const role = textOf(entry.data, "role") === "user" ? "user" : "assistant"; + const transcriptImages = transcriptImagesFromEntry(entry); rows.push({ id: entry.id, kind: "message", role, text: textOf(entry.data, "text"), reasoning: textOf(entry.data, "reasoning"), + images: transcriptImages.images, + imageIssue: transcriptImages.issue, live: false, startedAt: entry.timestamp, }); @@ -202,7 +219,11 @@ export function deriveTranscriptRows(projection: TranscriptProjection): Transcri const rows = rowsFromEntries(projection.entries); if (projection.toolCalls.size > 0) { - const calls = [...projection.toolCalls.values()]; + const calls: TranscriptToolCall[] = [...projection.toolCalls.values()].map((call) => ({ + ...call, + images: [], + imageIssue: null, + })); rows.push({ id: "live-tools", kind: "tool-group", @@ -218,6 +239,8 @@ export function deriveTranscriptRows(projection: TranscriptProjection): Transcri role: message.role, text: message.text, reasoning: message.reasoning, + images: [], + imageIssue: null, live: true, startedAt: message.startedAt, }); @@ -270,7 +293,29 @@ export interface StableRowsState { readonly result: TranscriptRow[]; } -function toolCallsEqual(a: ToolCall, b: ToolCall): boolean { +function imageReferencesEqual( + left: readonly TranscriptImageReference[], + right: readonly TranscriptImageReference[], +): boolean { + if (left === right) return true; + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + const a = left[index]; + const b = right[index]; + if ( + a === undefined || + b === undefined || + a.entryId !== b.entryId || + a.sha256 !== b.sha256 || + a.mimeType !== b.mimeType + ) { + return false; + } + } + return true; +} + +function toolCallsEqual(a: TranscriptToolCall, b: TranscriptToolCall): boolean { if (a === b) return true; if ( a.callId !== b.callId || @@ -281,6 +326,8 @@ function toolCallsEqual(a: ToolCall, b: ToolCall): boolean { a.endedAt !== b.endedAt || a.args !== b.args || a.result !== b.result || + a.imageIssue !== b.imageIssue || + !imageReferencesEqual(a.images, b.images) || a.progress.length !== b.progress.length ) { return false; @@ -300,6 +347,8 @@ function rowsEqual(a: TranscriptRow, b: TranscriptRow): boolean { a.role === b.role && a.text === b.text && a.reasoning === b.reasoning && + a.imageIssue === b.imageIssue && + imageReferencesEqual(a.images, b.images) && a.live === b.live && a.startedAt === b.startedAt ); diff --git a/apps/web/src/platform/browser-shell-port.ts b/apps/web/src/platform/browser-shell-port.ts index e613d702..26a22c65 100644 --- a/apps/web/src/platform/browser-shell-port.ts +++ b/apps/web/src/platform/browser-shell-port.ts @@ -63,6 +63,11 @@ import { } from "./native-mobile.ts"; const TARGET_ID = "remote"; +const COMPATIBILITY_FEATURES: readonly string[] = Object.freeze( + ADDITIVE_FEATURES.filter( + (feature) => feature !== "prompt.images" && feature !== "transcript.images", + ), +); const MAX_URL_LENGTH = 2048; const MAX_LABEL_LENGTH = 128; @@ -266,6 +271,7 @@ export function createBrowserShellPort( transport: transportFactory, capabilities: DEVICE_CAPABILITIES, requestedFeatures: ADDITIVE_FEATURES, + compatibilityRequestedFeatures: COMPATIBILITY_FEATURES, authentication: () => authentication, privilegedPairResult: async (result) => { await persistNativeMobileCredentials(result); @@ -273,7 +279,7 @@ export function createBrowserShellPort( }, client: { name: "T4 Code", - version: "0.1.11", + version: "0.1.12", build: mobilePlatform ?? "browser", platform: mobilePlatform ?? (platform === "darwin" ? "darwin" : "linux"), }, diff --git a/apps/web/src/platform/browser-transport.ts b/apps/web/src/platform/browser-transport.ts index 16b588f8..d6096402 100644 --- a/apps/web/src/platform/browser-transport.ts +++ b/apps/web/src/platform/browser-transport.ts @@ -8,10 +8,13 @@ export interface BrowserTransportOptions { /** Full WebSocket URL. Only ws: and wss: URLs are accepted. */ readonly url: string; readonly protocols?: string | string[]; + /** Maximum time to wait for the browser WebSocket open event. */ + readonly openTimeoutMs?: number; } export const MAX_BROWSER_URL_LENGTH = 2048; export const MAX_BROWSER_MESSAGE_BYTES = 4 * 1024 * 1024; +export const DEFAULT_BROWSER_OPEN_TIMEOUT_MS = 10_000; const MAX_CLOSE_REASON_LENGTH = 256; function validateUrl(value: string): string { @@ -35,8 +38,10 @@ function byteLength(value: string): number { export class BrowserWebSocketTransport implements OmpTransport { private readonly url: string; private readonly protocols: string | string[] | undefined; + private readonly openTimeoutMs: number; private socket: WebSocket | undefined; private socketCleanup: (() => void) | undefined; + private rejectOpening: ((message?: string) => void) | undefined; private readonly messages = new Set<(data: string | Uint8Array) => void>(); private readonly closes = new Set<(code?: number, reason?: string) => void>(); private readonly errors = new Set<(error: unknown) => void>(); @@ -44,6 +49,10 @@ export class BrowserWebSocketTransport implements OmpTransport { constructor(options: BrowserTransportOptions) { this.url = validateUrl(options.url); this.protocols = options.protocols; + this.openTimeoutMs = options.openTimeoutMs ?? DEFAULT_BROWSER_OPEN_TIMEOUT_MS; + if (!Number.isSafeInteger(this.openTimeoutMs) || this.openTimeoutMs <= 0) { + throw new Error("invalid browser transport open timeout"); + } } open(): Promise { @@ -56,6 +65,7 @@ export class BrowserWebSocketTransport implements OmpTransport { return new Promise((resolve, reject) => { let settled = false; + let openTimer: ReturnType | undefined; let socket: WebSocket; try { socket = new WebSocket(this.url, this.protocols); @@ -66,27 +76,40 @@ export class BrowserWebSocketTransport implements OmpTransport { socket.binaryType = "arraybuffer"; this.socket = socket; + const clearOpenTimer = (): void => { + if (openTimer === undefined) return; + clearTimeout(openTimer); + openTimer = undefined; + }; const cleanup = (): void => { + clearOpenTimer(); socket.removeEventListener("open", onOpen); socket.removeEventListener("error", onError); socket.removeEventListener("message", onMessage); socket.removeEventListener("close", onClose); if (this.socketCleanup === cleanup) this.socketCleanup = undefined; }; - const fail = (): void => { + const fail = (message = "browser transport connection failed"): void => { if (!settled) { settled = true; - reject(new Error("browser transport connection failed")); + clearOpenTimer(); + if (this.rejectOpening === fail) this.rejectOpening = undefined; + reject(new Error(message)); } }; const onOpen = (): void => { if (!settled) { settled = true; + clearOpenTimer(); + if (this.rejectOpening === fail) this.rejectOpening = undefined; resolve(); } }; const onError = (): void => { + cleanup(); + if (this.socket === socket) this.socket = undefined; fail(); + try { socket.close(); } catch { /* best effort */ } for (const listener of this.errors) listener(new Error("browser transport error")); }; const onMessage = (event: MessageEvent): void => { @@ -117,10 +140,18 @@ export class BrowserWebSocketTransport implements OmpTransport { for (const listener of this.closes) listener(event.code, reason); }; this.socketCleanup = cleanup; + this.rejectOpening = fail; socket.addEventListener("open", onOpen); socket.addEventListener("error", onError); socket.addEventListener("message", onMessage); socket.addEventListener("close", onClose); + openTimer = setTimeout(() => { + if (settled) return; + cleanup(); + if (this.socket === socket) this.socket = undefined; + fail("browser transport connection timed out"); + try { socket.close(); } catch { /* best effort */ } + }, this.openTimeoutMs); }); } @@ -133,9 +164,11 @@ export class BrowserWebSocketTransport implements OmpTransport { close(): void { const socket = this.socket; + const rejectOpening = this.rejectOpening; this.socket = undefined; this.socketCleanup?.(); this.socketCleanup = undefined; + rejectOpening?.("browser transport closed while opening"); if (socket !== undefined) { try { socket.close(1000, "client closed"); } catch { /* best effort */ } } diff --git a/apps/web/src/platform/desktop-runtime.ts b/apps/web/src/platform/desktop-runtime.ts index e2835876..9af97691 100644 --- a/apps/web/src/platform/desktop-runtime.ts +++ b/apps/web/src/platform/desktop-runtime.ts @@ -11,12 +11,14 @@ import { } from "@t4-code/client"; import { useSyncExternalStore } from "react"; +import { bindAuthoritativeComposerCleanup } from "../features/composer/authoritative-cleanup.ts"; import { rendererPlatform } from "../state/store-instance.ts"; const RUNTIME_SLOT = Symbol.for("t4-code.web.desktop-runtime"); interface RuntimeSlot { controller: DesktopRuntimeController; + disposeComposerCleanup: () => void; started: boolean; } @@ -34,7 +36,12 @@ export function acquireRuntimeController( ): DesktopRuntimeController { let slot = holder[RUNTIME_SLOT]; if (slot === undefined) { - slot = { controller: createDesktopRuntimeController({ shell }), started: false }; + const controller = createDesktopRuntimeController({ shell }); + slot = { + controller, + disposeComposerCleanup: bindAuthoritativeComposerCleanup(controller), + started: false, + }; holder[RUNTIME_SLOT] = slot; } return slot.controller; @@ -59,6 +66,7 @@ export function startRuntimeController(shell: DesktopShellPort, holder: RuntimeS window.addEventListener( "pagehide", () => { + slot.disposeComposerCleanup(); void controller.stop(); delete holder[RUNTIME_SLOT]; }, diff --git a/apps/web/test/authoritative-composer-cleanup.test.ts b/apps/web/test/authoritative-composer-cleanup.test.ts new file mode 100644 index 00000000..ca83eaa4 --- /dev/null +++ b/apps/web/test/authoritative-composer-cleanup.test.ts @@ -0,0 +1,158 @@ +import { + applyPublicFrame, + createProjectionSnapshot, + type ProjectionFrame, + type ProjectionSnapshot, +} from "@t4-code/client"; +import { + hostId, + projectId, + revision, + sessionId, + type SessionRef, +} from "@t4-code/protocol"; +import { describe, expect, it } from "vite-plus/test"; + +import { admitAttachments, type StagedAttachment } from "../src/features/composer/attachments.ts"; +import { reconcileAuthoritativeSessionDeletion } from "../src/features/composer/authoritative-cleanup.ts"; +import { createComposerStore } from "../src/features/composer/composer-store.ts"; +import { createTranscriptImageSource } from "../src/features/session-runtime/transcript-images.ts"; +import { sessionViewId } from "../src/platform/live-workspace.ts"; + +const HOST = hostId("cleanup-host"); +const V = "omp-app/1" as const; + +function viewId(id: string): string { + return sessionViewId(String(HOST), id); +} + +function ref(id: string): SessionRef { + return { + hostId: HOST, + sessionId: sessionId(id), + project: { projectId: projectId("cleanup-project"), name: "Cleanup" }, + revision: revision(`revision-${id}`), + title: id, + status: "idle", + updatedAt: "2026-07-14T00:00:00Z", + }; +} + +function inventory( + ids: readonly string[], + seq: number, + options: { readonly totalCount?: number; readonly truncated?: boolean } = {}, +): Extract { + return { + v: V, + type: "sessions", + hostId: HOST, + cursor: { epoch: "cleanup-epoch", seq }, + sessions: ids.map(ref), + totalCount: options.totalCount ?? ids.length, + truncated: options.truncated ?? false, + }; +} + +function removeDelta(id: string, seq: number): Extract { + return { + v: V, + type: "session.delta", + hostId: HOST, + sessionId: sessionId(id), + cursor: { epoch: "cleanup-epoch", seq }, + revision: revision(`delta-${seq}`), + remove: sessionId(id), + }; +} + +function staged(id: string): StagedAttachment { + const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], `${id}.png`, { + type: "image/png", + }); + return admitAttachments([], [{ file }], { + createId: () => `attachment-${id}`, + createPreviewUrl: () => `blob:test/${id}`, + }).accepted[0] as StagedAttachment; +} + +function apply(snapshot: ProjectionSnapshot, frame: ProjectionFrame): ProjectionSnapshot { + return applyPublicFrame(snapshot, frame); +} + +describe("authoritative composer cleanup", () => { + it("releases only sessions omitted by a complete authoritative inventory", () => { + const revoked: string[] = []; + const store = createComposerStore({ revokePreviewUrl: (url) => revoked.push(url) }); + store.getState().addAttachments(viewId("deleted"), [staged("deleted")]); + store.getState().addAttachments(viewId("kept"), [staged("kept")]); + const previous = apply(createProjectionSnapshot(), inventory(["deleted", "kept"], 1)); + const frame = inventory(["kept"], 2); + const current = apply(previous, frame); + + reconcileAuthoritativeSessionDeletion(previous, current, frame, store); + + expect(store.getState().attachmentsBySessionId[viewId("deleted")]).toBeUndefined(); + expect(store.getState().attachmentsBySessionId[viewId("kept")]).toHaveLength(1); + expect(revoked).toEqual(["blob:test/deleted"]); + reconcileAuthoritativeSessionDeletion(previous, current, frame, store); + expect(revoked).toEqual(["blob:test/deleted"]); + }); + + it("preserves staged Files for truncated inventories and stale remove deltas", () => { + const revoked: string[] = []; + const store = createComposerStore({ revokePreviewUrl: (url) => revoked.push(url) }); + store.getState().addAttachments(viewId("survivor"), [staged("survivor")]); + let previous = apply(createProjectionSnapshot(), inventory(["survivor", "other"], 1)); + const truncated = inventory(["other"], 2, { totalCount: 2, truncated: true }); + let current = apply(previous, truncated); + reconcileAuthoritativeSessionDeletion(previous, current, truncated, store); + expect(store.getState().attachmentsBySessionId[viewId("survivor")]).toHaveLength(1); + + const cursorSeed = { + v: V, + type: "session.delta", + hostId: HOST, + sessionId: sessionId("survivor"), + cursor: { epoch: "cleanup-epoch", seq: 5 }, + revision: revision("delta-5"), + upsert: ref("survivor"), + } as const satisfies Extract; + previous = apply(current, cursorSeed); + const stale = removeDelta("survivor", 4); + current = apply(previous, stale); + reconcileAuthoritativeSessionDeletion(previous, current, stale, store); + + expect(store.getState().attachmentsBySessionId[viewId("survivor")]).toHaveLength(1); + expect(revoked).toEqual([]); + }); + + it("releases Files after an accepted current remove delta", () => { + const revoked: string[] = []; + const store = createComposerStore({ revokePreviewUrl: (url) => revoked.push(url) }); + store.getState().addAttachments(viewId("deleted"), [staged("deleted")]); + const transcriptImages = createTranscriptImageSource({ + hostId: String(HOST), + sessionId: "deleted", + availability: { available: false, reason: "Waiting for the host." }, + readChunk: async () => ({ accepted: false }), + }); + const transcriptImage = { + entryId: "deleted-entry", + sha256: "a".repeat(64), + mimeType: "image/png" as const, + }; + const previous = apply(createProjectionSnapshot(), inventory(["deleted"], 1)); + const frame = removeDelta("deleted", 2); + const current = apply(previous, frame); + + reconcileAuthoritativeSessionDeletion(previous, current, frame, store); + + expect(store.getState().attachmentsBySessionId[viewId("deleted")]).toBeUndefined(); + expect(revoked).toEqual(["blob:test/deleted"]); + expect(transcriptImages.getSnapshot(transcriptImage)).toEqual({ + status: "unavailable", + reason: "This session was removed from the host.", + }); + }); +}); diff --git a/apps/web/test/browser-platform.test.ts b/apps/web/test/browser-platform.test.ts index c089a7c9..eec77d5f 100644 --- a/apps/web/test/browser-platform.test.ts +++ b/apps/web/test/browser-platform.test.ts @@ -8,6 +8,7 @@ import { BrowserWebSocketTransport } from "../src/platform/browser-transport.ts" const originalDocument = globalThis.document; const originalWindow = globalThis.window; +const originalWebSocket = globalThis.WebSocket; function setBrowserLocation(search: string): void { Object.defineProperty(globalThis, "window", { @@ -26,6 +27,7 @@ function setBackendScript(payload: string): void { afterEach(() => { Object.defineProperty(globalThis, "document", { configurable: true, value: originalDocument }); Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: originalWebSocket }); }); describe("browser platform boundary", () => { @@ -103,6 +105,9 @@ describe("browser platform boundary", () => { expect(() => new BrowserWebSocketTransport({ url: "https://not-websocket" })).toThrow( /invalid browser transport URL/u, ); + expect( + () => new BrowserWebSocketTransport({ url: "wss://omp.example/v1/ws", openTimeoutMs: 0 }), + ).toThrow(/invalid browser transport open timeout/u); const transport = new BrowserWebSocketTransport({ url: "wss://omp.example/v1/ws" }); const unsubscribeMessage = transport.onMessage(() => undefined); const unsubscribeClose = transport.onClose(() => undefined); @@ -113,6 +118,56 @@ describe("browser platform boundary", () => { transport.close(); expect(() => transport.send("{}")).toThrow(/not connected/u); }); + + it("bounds a browser WebSocket that never opens", async () => { + let socketClosed = false; + class StalledWebSocket { + static readonly OPEN = 1; + readonly readyState = 0; + binaryType = "blob"; + closed = false; + + addEventListener(): void {} + removeEventListener(): void {} + send(): void {} + close(): void { + this.closed = true; + socketClosed = true; + } + } + Object.defineProperty(globalThis, "WebSocket", { + configurable: true, + value: StalledWebSocket, + }); + const transport = new BrowserWebSocketTransport({ + url: "wss://omp.example/v1/ws", + openTimeoutMs: 1, + }); + await expect(transport.open()).rejects.toThrow(/connection timed out/u); + expect(socketClosed).toBe(true); + }); + + it("settles an in-flight browser open when the transport closes", async () => { + class StalledWebSocket { + static readonly OPEN = 1; + readonly readyState = 0; + binaryType = "blob"; + + addEventListener(): void {} + removeEventListener(): void {} + send(): void {} + close(): void {} + } + Object.defineProperty(globalThis, "WebSocket", { + configurable: true, + value: StalledWebSocket, + }); + const transport = new BrowserWebSocketTransport({ url: "wss://omp.example/v1/ws" }); + const opening = transport.open(); + transport.close(); + await expect(opening).rejects.toThrow(/closed while opening/u); + }); + it("maps client results, stores pairing auth, and closes client on disconnect", async () => { setBackendScript(JSON.stringify({ wsUrl: "wss://omp.example/v1/ws" })); let capturedOptions: OmpClientOptions | undefined; @@ -173,6 +228,10 @@ describe("browser platform boundary", () => { expect(connectCalls).toBe(0); await shell.connect({ targetId: "remote" }); expect(connectCalls).toBe(1); + expect(capturedOptions?.requestedFeatures).toContain("prompt.images"); + expect(capturedOptions?.requestedFeatures).toContain("transcript.images"); + expect(capturedOptions?.compatibilityRequestedFeatures).not.toContain("prompt.images"); + expect(capturedOptions?.compatibilityRequestedFeatures).not.toContain("transcript.images"); const pair = await shell.pair({ targetId: "remote", code: "123456" }); expect(pair.paired).toBe(true); expect(capturedOptions?.authentication?.()).toEqual({ diff --git a/apps/web/test/composer-runtime.test.ts b/apps/web/test/composer-runtime.test.ts index 3a03b847..fa2431d8 100644 --- a/apps/web/test/composer-runtime.test.ts +++ b/apps/web/test/composer-runtime.test.ts @@ -4,7 +4,14 @@ // offline/cached disable truth. Pure logic plus the fixture controller. import { describe, expect, it } from "vite-plus/test"; -import { admitAttachments } from "../src/features/composer/attachments.ts"; +import { + admitAttachments, + MAX_STAGED_ATTACHMENT_BYTES, + MAX_STAGED_ATTACHMENTS, + toPromptAttachment, + type AttachmentIntakeOptions, + type StagedAttachment, +} from "../src/features/composer/attachments.ts"; import { resolveAskDigit, resolveComposerKey, resolveMenuKey } from "../src/features/composer/keys.ts"; import { activeSlashQuery, @@ -13,9 +20,15 @@ import { } from "../src/features/composer/slash.ts"; import { composerStore, + createComposerStore, LEGACY_COMPOSER_STORAGE_KEY, purgeLegacyComposerPersistence, } from "../src/features/composer/composer-store.ts"; +import { + createSubmissionGate, + type SubmissionIo, + type SubmissionLatch, +} from "../src/features/composer/submission.ts"; import { createFixtureSessionRuntime } from "../src/features/session-runtime/controller.ts"; import { FIXTURE_NOW_MS } from "../src/features/session-runtime/fixtures.ts"; import { createMemoryPersistence } from "../src/state/persistence.ts"; @@ -30,6 +43,18 @@ const KEY = { isComposing: false, } as const; +function imageFile(name: string, sizeBytes = 4, type = "image/png"): File { + return new File([new Uint8Array(sizeBytes)], name, { type, lastModified: 1 }); +} + +function deterministicAttachmentOptions(ids: string[] = []): AttachmentIntakeOptions { + let index = 0; + return { + createId: () => ids[index++] ?? `attachment-test-${index}`, + createPreviewUrl: (file) => `blob:test/${file.name}/${index}`, + }; +} + describe("composer keys (IME-safe)", () => { it("Enter submits; modifiers insert a newline", () => { expect(resolveComposerKey(KEY)).toBe("submit"); @@ -95,41 +120,196 @@ describe("slash commands", () => { }); describe("attachments", () => { - it("accepts images and text files, rejects the rest with reasons", () => { + it("uses crypto-backed collision-resistant ids by default", () => { + const result = admitAttachments( + [], + [{ file: imageFile("first.png") }, { file: imageFile("second.png") }], + { createPreviewUrl: (file) => `blob:test/${file.name}` }, + ); + + expect(result.accepted.map((attachment) => attachment.id)).toEqual([ + expect.stringMatching(/^attachment-[0-9a-f]{32}$/), + expect.stringMatching(/^attachment-[0-9a-f]{32}$/), + ]); + expect(result.accepted[0]?.id).not.toBe(result.accepted[1]?.id); + }); + + it("retains exact image Files, uses unique ids, and rejects non-images", () => { + const first = imageFile("shot.png"); + const sameNameDifferentFile = imageFile("shot.png"); + const notes = new File(["notes"], "notes.md", { type: "text/markdown" }); + const movie = new File(["movie"], "movie.mp4", { type: "video/mp4" }); const result = admitAttachments( [], - [ - { name: "shot.png", mediaType: "image/png", sizeBytes: 1024 }, - { name: "notes.md", mediaType: "text/markdown", sizeBytes: 2048 }, - { name: "movie.mp4", mediaType: "video/mp4", sizeBytes: 1024 }, - ], + [{ file: first }, { file: sameNameDifferentFile }, { file: notes }, { file: movie }], + deterministicAttachmentOptions(["attachment-a", "attachment-a", "attachment-b"]), ); - expect(result.accepted.map((attachment) => attachment.kind)).toEqual(["image", "file"]); - expect(result.rejections).toEqual(["movie.mp4: only images and text files attach here."]); + expect(result.accepted.map((attachment) => attachment.kind)).toEqual(["image", "image"]); + expect(result.accepted[0]?.file).toBe(first); + expect(result.accepted[1]?.file).toBe(sameNameDifferentFile); + expect(result.accepted.map((attachment) => attachment.id)).toEqual([ + "attachment-a", + "attachment-b", + ]); + expect(toPromptAttachment(result.accepted[0] as StagedAttachment)).toEqual({ + id: "attachment-a", + kind: "image", + mediaType: "image/png", + name: "shot.png", + sizeBytes: 4, + file: first, + }); + expect(result.rejections).toEqual([ + "notes.md: attach a PNG, JPEG, WebP, or GIF image.", + "movie.mp4: attach a PNG, JPEG, WebP, or GIF image.", + ]); }); - it("enforces the size cap, the count cap, and duplicate names", () => { + it("stages Android-style image Files with an empty MIME type by extension", () => { + const androidPng = imageFile("content-provider.PNG", 4, ""); + const genericJpeg = imageFile("camera.jpg", 4, "application/octet-stream"); + const legacyJpeg = imageFile("camera-provider", 4, "image/jpg"); + const noExtension = imageFile("content-provider", 4, ""); + const result = admitAttachments( + [], + [{ file: androidPng }, { file: genericJpeg }, { file: legacyJpeg }, { file: noExtension }], + deterministicAttachmentOptions([ + "attachment-android", + "attachment-generic", + "attachment-legacy", + ]), + ); + + expect(result.accepted).toHaveLength(3); + expect(result.accepted[0]).toMatchObject({ + id: "attachment-android", + mediaType: "image/png", + file: androidPng, + }); + expect(result.accepted[1]).toMatchObject({ + id: "attachment-generic", + mediaType: "image/jpeg", + file: genericJpeg, + }); + expect(result.accepted[2]).toMatchObject({ + id: "attachment-legacy", + mediaType: "image/jpeg", + file: legacyJpeg, + }); + expect(result.rejections).toEqual([ + "content-provider: attach a PNG, JPEG, WebP, or GIF image.", + ]); + }); + + it("enforces the size cap, count cap, and exact-File duplicate guard", () => { + const tooLarge = imageFile("big.png", 21 * 1024 * 1024); const oversize = admitAttachments( [], - [{ name: "big.png", mediaType: "image/png", sizeBytes: 11 * 1024 * 1024 }], + [{ file: tooLarge }], + deterministicAttachmentOptions(), ); expect(oversize.accepted.length).toBe(0); - expect(oversize.rejections[0]).toContain("over the 10.0 MB limit"); + expect(oversize.rejections[0]).toContain("over the 20.0 MB limit"); + const files = Array.from({ length: 9 }, (_, index) => imageFile(`f${index}.png`, 10)); const eight = admitAttachments( [], - Array.from({ length: 9 }, (_, i) => ({ - name: `f${i}.png`, - mediaType: "image/png" as const, - sizeBytes: 10, - })), + files.map((file) => ({ file })), + deterministicAttachmentOptions(), ).accepted; expect(eight.length).toBe(8); - const duplicate = admitAttachments(eight, [ - { name: "f0.png", mediaType: "image/png", sizeBytes: 10 }, - ]); + const duplicate = admitAttachments( + eight.slice(0, 7), + [{ file: files[0] as File }], + deterministicAttachmentOptions(), + ); expect(duplicate.accepted.length).toBe(0); + expect(duplicate.rejections).toEqual(["f0.png: already attached."]); + }); + + it("bounds preserved staging across sessions without silently evicting files", () => { + const candidate = imageFile("next.png", 1); + const atByteBudget = admitAttachments( + [], + [{ file: candidate }], + { + ...deterministicAttachmentOptions(), + stagedBytes: MAX_STAGED_ATTACHMENT_BYTES, + stagedCount: 8, + }, + ); + expect(atByteBudget.accepted).toEqual([]); + expect(atByteBudget.rejections[0]).toContain("staged images across sessions would exceed 160.0 MB"); + + const atCountBudget = admitAttachments( + [], + [{ file: candidate }], + { + ...deterministicAttachmentOptions(), + stagedBytes: MAX_STAGED_ATTACHMENTS, + stagedCount: MAX_STAGED_ATTACHMENTS, + }, + ); + expect(atCountBudget.accepted).toEqual([]); + expect(atCountBudget.rejections[0]).toContain(`already has ${MAX_STAGED_ATTACHMENTS} staged images`); + + const empty = admitAttachments( + [], + [{ file: imageFile("empty.png", 0) }], + deterministicAttachmentOptions(), + ); + expect(empty.rejections).toEqual(["empty.png: the image is empty."]); + }); + + it("keeps staged Files and previews through rejection/unknown, then revokes on acceptance", async () => { + const file = imageFile("proof.png"); + const staged = admitAttachments( + [], + [{ file }], + deterministicAttachmentOptions(["attachment-proof"]), + ).accepted[0] as StagedAttachment; + const revoked: string[] = []; + const store = createComposerStore({ revokePreviewUrl: (url) => revoked.push(url) }); + store.getState().addAttachments("session-a", [staged]); + let draft = "ship with proof"; + const io: SubmissionIo = { + getDraft: () => draft, + clearDraft: () => { + draft = ""; + }, + removeAttachments: (ids) => { + for (const id of ids) store.getState().removeAttachment("session-a", id); + }, + setNotice: () => {}, + }; + const intent = { + kind: "prompt", + text: draft, + attachments: [toPromptAttachment(staged)], + } as const; + const submitted = { text: draft, attachmentIds: [staged.id] }; + + await createSubmissionGate(async () => ({ kind: "rejected", reason: "not yet" })).submit( + intent, + submitted, + io, + ); + expect(store.getState().attachmentsBySessionId["session-a"]?.[0]?.file).toBe(file); + expect(revoked).toEqual([]); + expect(draft).toBe("ship with proof"); + + await createSubmissionGate(async () => { + throw new Error("transport gone"); + }).submit(intent, submitted, io); + expect(store.getState().attachmentsBySessionId["session-a"]?.[0]?.file).toBe(file); + expect(revoked).toEqual([]); + expect(draft).toBe("ship with proof"); + + await createSubmissionGate(async () => ({ kind: "accepted" })).submit(intent, submitted, io); + expect(store.getState().attachmentsBySessionId["session-a"]).toEqual([]); + expect(revoked).toEqual([staged.previewUrl]); + expect(draft).toBe(""); }); }); @@ -142,6 +322,100 @@ describe("draft continuity A→B→A", () => { expect(selectSessionView(store.getState(), "A").draft).toBe("half-written to A, continued"); expect(selectSessionView(store.getState(), "B").draft).toBe("note for B"); }); + + it("keeps one session submission locked across recreated composer gates", async () => { + const store = createComposerStore(); + const latch: SubmissionLatch = { + pending: () => store.getState().pendingSubmissionBySessionId.A !== undefined, + begin: () => store.getState().beginSubmission("A"), + current: (token) => store.getState().isSubmissionCurrent("A", token), + end: (token) => store.getState().finishSubmission("A", token), + }; + const deferred = Promise.withResolvers<{ readonly kind: "accepted" }>(); + let submissions = 0; + const submitPrompt = () => { + submissions += 1; + return deferred.promise; + }; + const io: SubmissionIo = { + getDraft: () => "hello A", + clearDraft: () => undefined, + removeAttachments: () => undefined, + setNotice: (notice) => store.getState().setSubmissionNotice("A", notice), + }; + const intent = { kind: "prompt", text: "hello A", attachments: [] } as const; + const submitted = { text: "hello A", attachmentIds: [] }; + + const first = createSubmissionGate(submitPrompt, latch).submit(intent, submitted, io); + expect(store.getState().pendingSubmissionBySessionId.A).toMatch(/^submission-/u); + // A composer recreated after A to B to A shares the same session latch. + await expect( + createSubmissionGate(submitPrompt, latch).submit(intent, submitted, io), + ).resolves.toBeNull(); + expect(submissions).toBe(1); + + deferred.resolve({ kind: "accepted" }); + await expect(first).resolves.toEqual({ kind: "accepted" }); + expect(store.getState().pendingSubmissionBySessionId.A).toBeUndefined(); + }); + + it("ignores an obsolete completion after deletion invalidates its token", async () => { + const revoked: string[] = []; + const store = createComposerStore({ revokePreviewUrl: (url) => revoked.push(url) }); + const staged = admitAttachments( + [], + [{ file: imageFile("delete-me.png") }], + deterministicAttachmentOptions(["attachment-delete"]), + ).accepted[0] as StagedAttachment; + store.getState().addAttachments("A", [staged]); + const latch: SubmissionLatch = { + pending: () => store.getState().pendingSubmissionBySessionId.A !== undefined, + begin: () => store.getState().beginSubmission("A"), + current: (token) => store.getState().isSubmissionCurrent("A", token), + end: (token) => store.getState().finishSubmission("A", token), + }; + const deferred = Promise.withResolvers<{ readonly kind: "accepted" }>(); + let draft = "delete while pending"; + let clears = 0; + const io: SubmissionIo = { + getDraft: () => draft, + clearDraft: () => { + clears += 1; + draft = ""; + }, + removeAttachments: () => { + throw new Error("obsolete completion touched attachments"); + }, + setNotice: (notice) => store.getState().setSubmissionNotice("A", notice), + }; + const pending = createSubmissionGate(() => deferred.promise, latch).submit( + { kind: "prompt", text: draft, attachments: [toPromptAttachment(staged)] }, + { text: draft, attachmentIds: [staged.id] }, + io, + ); + + store.getState().disposeSession("A"); + expect(revoked).toEqual([staged.previewUrl]); + const nextToken = store.getState().beginSubmission("A"); + expect(nextToken).not.toBeNull(); + deferred.resolve({ kind: "accepted" }); + await expect(pending).resolves.toBeNull(); + expect(clears).toBe(0); + expect(draft).toBe("delete while pending"); + expect(store.getState().pendingSubmissionBySessionId.A).toBe(nextToken); + }); + + it("keeps intake warnings scoped to the session that produced them", () => { + const store = createComposerStore(); + store.getState().setAttachmentRejections("A", ["A image is too large"]); + store.getState().setAttachmentRejections("B", ["B image type is unsupported"]); + store.getState().setAttachmentRejections("A", []); + + expect(store.getState().attachmentRejectionsBySessionId.A).toBeUndefined(); + expect(store.getState().attachmentRejectionsBySessionId.B).toEqual([ + "B image type is unsupported", + ]); + }); }); describe("composer control persistence removal", () => { diff --git a/apps/web/test/image-upload.test.ts b/apps/web/test/image-upload.test.ts new file mode 100644 index 00000000..0b48fa16 --- /dev/null +++ b/apps/web/test/image-upload.test.ts @@ -0,0 +1,444 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + canonicalBase64, + IMAGE_PROMPT_UNKNOWN_REASON, + IMAGE_UPLOAD_CHUNK_BYTES, + IMAGE_UPLOAD_PREPARATION_REASON, + IMAGE_UPLOAD_PROTOCOL_REASON, + runImagePromptUpload, + sniffPromptImageMimeType, + type ImagePromptUploadOptions, + type ImageUploadCommand, + type ImageUploadCommandResult, +} from "../src/features/session-runtime/image-upload.ts"; +import type { PromptAttachment } from "../src/features/session-runtime/intents.ts"; + +const IMAGE_IDS = [ + "123e4567-e89b-42d3-a456-426614174000", + "123e4567-e89b-42d3-a456-426614174001", + "123e4567-e89b-42d3-a456-426614174002", +] as const; + +function pngBytes(size = 16): Uint8Array { + const bytes = new Uint8Array(Math.max(size, 8)); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + for (let index = 8; index < bytes.length; index += 1) bytes[index] = index % 251; + return bytes; +} + +function webpBytes(): Uint8Array { + return new Uint8Array([ + 0x52, 0x49, 0x46, 0x46, 0x04, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, + ]); +} + +function attachment( + id: string, + bytes: Uint8Array, + options: { readonly name?: string; readonly type?: string } = {}, +): PromptAttachment { + const file = new File([new Uint8Array(bytes)], options.name ?? `${id}.png`, { + type: options.type ?? "image/png", + }); + return { + id, + name: file.name, + mediaType: file.type, + sizeBytes: file.size, + kind: "image", + file, + }; +} + +function byteLengthOfBase64(value: string): number { + return atob(value).length; +} + +interface UploadHarness { + readonly events: string[]; + readonly chunks: string[]; + readonly command: ImagePromptUploadOptions["command"]; +} + +function successfulCommandHarness(ids: readonly string[] = IMAGE_IDS): UploadHarness { + const events: string[] = []; + const chunks: string[] = []; + let beginIndex = 0; + const sizes = new Map(); + const received = new Map(); + return { + events, + chunks, + async command(command, args) { + if (command === "session.image.begin") { + const imageId = ids[beginIndex++]; + if (imageId === undefined) throw new Error("test image id exhausted"); + sizes.set(imageId, Number(args.size)); + received.set(imageId, 0); + events.push(`begin:${imageId}:${String(args.mimeType)}`); + return { + accepted: true, + result: { imageId, chunkBytes: IMAGE_UPLOAD_CHUNK_BYTES }, + }; + } + const imageId = String(args.imageId); + if (command === "session.image.discard") { + events.push(`discard:${imageId}`); + return { accepted: true, result: { discarded: true } }; + } + const content = String(args.content); + chunks.push(content); + const next = (received.get(imageId) ?? 0) + byteLengthOfBase64(content); + received.set(imageId, next); + events.push(`chunk:${imageId}:${String(args.offset)}:${next}`); + return { + accepted: true, + result: { imageId, received: next, complete: next === sizes.get(imageId) }, + }; + }, + }; +} + +function baseOptions( + attachments: readonly PromptAttachment[], + command: ImagePromptUploadOptions["command"], + sendPrompt: ImagePromptUploadOptions["sendPrompt"], + targetId = "local", +): ImagePromptUploadOptions { + return { + targetId, + attachments, + command, + sendPrompt, + rejectionReason: () => "host rejected image upload", + }; +} + +describe("image preparation", () => { + it("magic-sniffs all supported image types and rejects arbitrary bytes", () => { + expect(sniffPromptImageMimeType(pngBytes())).toBe("image/png"); + expect(sniffPromptImageMimeType(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]))).toBe( + "image/jpeg", + ); + expect(sniffPromptImageMimeType(new TextEncoder().encode("GIF87a"))).toBe("image/gif"); + expect(sniffPromptImageMimeType(new TextEncoder().encode("GIF89a"))).toBe("image/gif"); + expect(sniffPromptImageMimeType(webpBytes())).toBe("image/webp"); + expect(sniffPromptImageMimeType(new TextEncoder().encode("not an image"))).toBeNull(); + }); + + it("produces canonical base64 across the safe conversion-batch boundary", () => { + const bytes = pngBytes(70_001); + const encoded = canonicalBase64(bytes); + const binary = atob(encoded); + expect(binary).toHaveLength(bytes.byteLength); + expect(Uint8Array.from(binary, (character) => character.charCodeAt(0))).toEqual(bytes); + expect(encoded).toMatch(/^[A-Za-z0-9+/]*={0,2}$/u); + }); +}); + +describe("image prompt upload", () => { + it("reads image bytes through FileReader when File.arrayBuffer is unavailable", async () => { + const bytes = pngBytes(); + const legacyAttachment = attachment("legacy-webview", bytes); + Object.defineProperty(legacyAttachment.file, "arrayBuffer", { + configurable: true, + value: undefined, + }); + + const originalFileReader = Object.getOwnPropertyDescriptor(globalThis, "FileReader"); + class LegacyFileReader { + result: string | ArrayBuffer | null = null; + error: DOMException | null = null; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + onabort: (() => void) | null = null; + + readAsArrayBuffer(): void { + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + this.result = buffer; + queueMicrotask(() => this.onload?.()); + } + } + Object.defineProperty(globalThis, "FileReader", { + configurable: true, + value: LegacyFileReader, + }); + + const harness = successfulCommandHarness(); + try { + await expect( + runImagePromptUpload( + baseOptions([legacyAttachment], harness.command, async () => ({ kind: "accepted" })), + ), + ).resolves.toEqual({ kind: "accepted" }); + } finally { + if (originalFileReader === undefined) Reflect.deleteProperty(globalThis, "FileReader"); + else Object.defineProperty(globalThis, "FileReader", originalFileReader); + } + + expect(harness.events).toEqual([ + `begin:${IMAGE_IDS[0]}:image/png`, + `chunk:${IMAGE_IDS[0]}:0:${bytes.byteLength}`, + `discard:${IMAGE_IDS[0]}`, + ]); + }); + + it("hashes and uploads whole files sequentially, normalizes MIME by magic, and prompts with refs only", async () => { + const large = pngBytes(IMAGE_UPLOAD_CHUNK_BYTES + 7); + const mismatched = attachment("webp", webpBytes(), { + name: "provider.png", + type: "image/png", + }); + const first = attachment("large", large); + const harness = successfulCommandHarness(); + let promptCalls = 0; + let promptRefs: readonly Readonly<{ imageId: string }>[] = []; + + const outcome = await runImagePromptUpload( + baseOptions([first, mismatched], harness.command, async (images) => { + promptCalls += 1; + promptRefs = images; + harness.events.push("prompt"); + return { kind: "accepted" }; + }), + ); + + expect(outcome).toEqual({ kind: "accepted" }); + expect(promptCalls).toBe(1); + expect(promptRefs).toEqual([{ imageId: IMAGE_IDS[0] }, { imageId: IMAGE_IDS[1] }]); + expect(harness.events).toEqual([ + `begin:${IMAGE_IDS[0]}:image/png`, + `chunk:${IMAGE_IDS[0]}:0:${IMAGE_UPLOAD_CHUNK_BYTES}`, + `chunk:${IMAGE_IDS[0]}:${IMAGE_UPLOAD_CHUNK_BYTES}:${large.byteLength}`, + `begin:${IMAGE_IDS[1]}:image/webp`, + `chunk:${IMAGE_IDS[1]}:0:${webpBytes().byteLength}`, + "prompt", + `discard:${IMAGE_IDS[0]}`, + `discard:${IMAGE_IDS[1]}`, + ]); + expect(harness.chunks[0]).toBe(canonicalBase64(large.subarray(0, IMAGE_UPLOAD_CHUNK_BYTES))); + expect(harness.chunks[1]).toBe(canonicalBase64(large.subarray(IMAGE_UPLOAD_CHUNK_BYTES))); + }); + + it("discards every begun image when a later chunk is rejected and never sends the prompt", async () => { + const harness = successfulCommandHarness(); + let chunks = 0; + const command = async ( + commandName: ImageUploadCommand, + args: Readonly>, + ): Promise => { + if (commandName === "session.image.chunk") { + chunks += 1; + if (chunks === 2) { + harness.events.push(`rejected:${String(args.imageId)}`); + return { + accepted: false, + error: { code: "image_invalid", message: "test rejection" }, + }; + } + } + return harness.command(commandName, args); + }; + let promptCalls = 0; + + const outcome = await runImagePromptUpload( + baseOptions( + [attachment("first", pngBytes()), attachment("second", pngBytes())], + command, + async () => { + promptCalls += 1; + return { kind: "accepted" }; + }, + ), + ); + + expect(outcome).toEqual({ kind: "rejected", reason: "host rejected image upload" }); + expect(promptCalls).toBe(0); + expect(harness.events.slice(-3)).toEqual([ + `rejected:${IMAGE_IDS[1]}`, + `discard:${IMAGE_IDS[0]}`, + `discard:${IMAGE_IDS[1]}`, + ]); + }); + + it("rejects unreadable image bytes before begin and preserves the prompt boundary", async () => { + const harness = successfulCommandHarness(); + let promptCalls = 0; + const invalid = attachment("invalid", new TextEncoder().encode("plain text")); + + const outcome = await runImagePromptUpload( + baseOptions([invalid], harness.command, async () => { + promptCalls += 1; + return { kind: "accepted" }; + }), + ); + + expect(outcome).toEqual({ kind: "rejected", reason: IMAGE_UPLOAD_PREPARATION_REASON }); + expect(harness.events).toEqual([]); + expect(promptCalls).toBe(0); + }); + + it("treats malformed progress as a protocol failure and cleans its begun spool", async () => { + const harness = successfulCommandHarness(); + const command = async ( + commandName: ImageUploadCommand, + args: Readonly>, + ): Promise => { + if (commandName === "session.image.chunk") { + return { + accepted: true, + result: { imageId: args.imageId, received: 1, complete: true }, + }; + } + return harness.command(commandName, args); + }; + + const outcome = await runImagePromptUpload( + baseOptions([attachment("proof", pngBytes())], command, async () => ({ kind: "accepted" })), + ); + + expect(outcome).toEqual({ kind: "rejected", reason: IMAGE_UPLOAD_PROTOCOL_REASON }); + expect(harness.events.at(-1)).toBe(`discard:${IMAGE_IDS[0]}`); + }); + + it("does not retry an uncertain prompt and still performs best-effort cleanup", async () => { + const harness = successfulCommandHarness(); + let promptCalls = 0; + const outcome = await runImagePromptUpload( + baseOptions([attachment("proof", pngBytes())], harness.command, async () => { + promptCalls += 1; + throw new Error("connection closed after send"); + }), + ); + + expect(outcome).toEqual({ kind: "unknown", reason: IMAGE_PROMPT_UNKNOWN_REASON }); + expect(promptCalls).toBe(1); + expect(harness.events.at(-1)).toBe(`discard:${IMAGE_IDS[0]}`); + }); + + it("keeps an accepted prompt accepted when post-prompt spool cleanup fails", async () => { + const harness = successfulCommandHarness(); + let discardAttempts = 0; + const command = async ( + commandName: ImageUploadCommand, + args: Readonly>, + ): Promise => { + if (commandName === "session.image.discard") { + discardAttempts += 1; + throw new Error("cleanup transport unavailable"); + } + return harness.command(commandName, args); + }; + + const outcome = await runImagePromptUpload( + baseOptions([attachment("proof", pngBytes())], command, async () => ({ kind: "accepted" })), + ); + + expect(outcome).toEqual({ kind: "accepted" }); + expect(discardAttempts).toBe(1); + }); + + it("serializes hash/upload/prompt per target", async () => { + const gate = Promise.withResolvers(); + const events: string[] = []; + const firstHarness = successfulCommandHarness([IMAGE_IDS[0]]); + const secondHarness = successfulCommandHarness([IMAGE_IDS[1]]); + let held = false; + const firstCommand = async ( + commandName: ImageUploadCommand, + args: Readonly>, + ): Promise => { + if (commandName === "session.image.begin" && !held) { + held = true; + events.push("A:begin-held"); + await gate.promise; + } + events.push(`A:${commandName}`); + return firstHarness.command(commandName, args); + }; + const secondCommand = async ( + commandName: ImageUploadCommand, + args: Readonly>, + ): Promise => { + events.push(`B:${commandName}`); + return secondHarness.command(commandName, args); + }; + + const first = runImagePromptUpload( + baseOptions([attachment("A", pngBytes())], firstCommand, async () => { + events.push("A:prompt"); + return { kind: "accepted" }; + }), + ); + const second = runImagePromptUpload( + baseOptions([attachment("B", pngBytes())], secondCommand, async () => { + events.push("B:prompt"); + return { kind: "accepted" }; + }), + ); + await expect.poll(() => events).toEqual(["A:begin-held"]); + + gate.resolve(); + await expect(Promise.all([first, second])).resolves.toEqual([ + { kind: "accepted" }, + { kind: "accepted" }, + ]); + expect(events).toEqual([ + "A:begin-held", + "A:session.image.begin", + "A:session.image.chunk", + "A:prompt", + "A:session.image.discard", + "B:session.image.begin", + "B:session.image.chunk", + "B:prompt", + "B:session.image.discard", + ]); + }); + + it("settles the prompt and advances the target pipeline while post-prompt discards are pending", async () => { + const cleanupGate = Promise.withResolvers(); + const harness = successfulCommandHarness(); + const discardAttempts: string[] = []; + const command = async ( + commandName: ImageUploadCommand, + args: Readonly>, + ): Promise => { + if (commandName === "session.image.discard") { + discardAttempts.push(String(args.imageId)); + await cleanupGate.promise; + return { accepted: true, result: { discarded: true } }; + } + return harness.command(commandName, args); + }; + const promptEvents: string[] = []; + + const first = runImagePromptUpload( + baseOptions( + [attachment("first-a", pngBytes()), attachment("first-b", pngBytes())], + command, + async () => { + promptEvents.push("first"); + return { kind: "accepted" }; + }, + ), + ); + const second = runImagePromptUpload( + baseOptions([attachment("second", pngBytes())], command, async () => { + promptEvents.push("second"); + return { kind: "accepted" }; + }), + ); + + await expect(Promise.all([first, second])).resolves.toEqual([ + { kind: "accepted" }, + { kind: "accepted" }, + ]); + expect(promptEvents).toEqual(["first", "second"]); + expect(discardAttempts).toEqual([...IMAGE_IDS]); + + cleanupGate.resolve(); + }); +}); diff --git a/apps/web/test/interaction-motion.test.ts b/apps/web/test/interaction-motion.test.ts index c245c951..acf5880b 100644 --- a/apps/web/test/interaction-motion.test.ts +++ b/apps/web/test/interaction-motion.test.ts @@ -4,6 +4,7 @@ // can't silently ship as a hard pop again. import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { decodeCommandResult, decodeHello } from "@t4-code/protocol"; import { describe, expect, it } from "vite-plus/test"; const SRC = join(import.meta.dirname, "../src"); @@ -169,4 +170,63 @@ describe("session refresh renders without flicker", () => { expect(bootstrap).toContain("tokens.css"); expect(bootstrap).toContain("omp:workspace:v1"); }); + + it("boots app-wire hello and image decoders without native Object.hasOwn", () => { + const originalHasOwn = Object.getOwnPropertyDescriptor(Object, "hasOwn"); + try { + Object.defineProperty(Object, "hasOwn", { + configurable: true, + writable: true, + value: undefined, + }); + const doc = { + documentElement: { + classList: { add: () => undefined }, + style: {} as Record, + dataset: {} as Record, + }, + }; + const runBootstrap = new Function( + "document", + "localStorage", + "matchMedia", + "location", + "window", + bootstrap, + ); + runBootstrap( + doc, + { getItem: () => null }, + () => ({ matches: false }), + { hash: "" }, + { addEventListener: () => undefined }, + ); + expect(typeof Object.hasOwn).toBe("function"); + expect( + decodeHello({ + v: "omp-app/1", + type: "hello", + protocol: { min: "omp-app/1", max: "omp-app/1" }, + client: { name: "T4 Code", version: "test", build: "test", platform: "android" }, + requestedFeatures: ["transcript.images"], + savedCursors: [], + authentication: { deviceId: "android", deviceToken: "A".repeat(43) }, + }), + ).toMatchObject({ type: "hello", requestedFeatures: ["transcript.images"] }); + expect( + decodeCommandResult("session.image.read", { + sha256: "a".repeat(64), + mimeType: "image/png", + size: 1, + offset: 0, + nextOffset: 1, + complete: true, + content: "AA==", + }), + ).toMatchObject({ mimeType: "image/png", complete: true }); + } finally { + if (originalHasOwn === undefined) delete (Object as { hasOwn?: unknown }).hasOwn; + else Object.defineProperty(Object, "hasOwn", originalHasOwn); + } + }); }); diff --git a/apps/web/test/live-composer.test.ts b/apps/web/test/live-composer.test.ts index cfd90831..bdafd2fb 100644 --- a/apps/web/test/live-composer.test.ts +++ b/apps/web/test/live-composer.test.ts @@ -22,7 +22,11 @@ import { type SessionSnapshotFrame, type SessionsFrame, } from "@t4-code/protocol"; -import type { CommandResultError } from "@t4-code/protocol/desktop-ipc"; +import type { + CommandRequest, + CommandResult, + CommandResultError, +} from "@t4-code/protocol/desktop-ipc"; import { createSubmissionGate, @@ -32,6 +36,8 @@ import { } from "../src/features/composer/submission.ts"; import { createLiveSessionRuntime } from "../src/features/session-runtime/live-runtime.ts"; import type { SessionRuntime } from "../src/features/session-runtime/controller.ts"; +import { IMAGE_PROMPTS_UNSUPPORTED_REASON } from "../src/features/session-runtime/intents.ts"; +import { IMAGE_UPLOAD_CHUNK_BYTES } from "../src/features/session-runtime/image-upload.ts"; import { obtainLiveRuntime } from "../src/features/session-runtime/useSessionRuntime.ts"; import { deriveWorkspaceData, sessionViewId } from "../src/platform/live-workspace.ts"; import { @@ -222,6 +228,7 @@ describe("prompt submission outcomes", () => { expect(shell.commandCount("session.prompt")).toBe(1); const sent = shell.commands.find((request) => request.intent.command === "session.prompt"); expect(sent?.intent.args).toEqual({ message: "ship it" }); + expect(sent?.intent.expectedRevision).toBeUndefined(); }); it("does not send before the first session snapshot establishes a revision", async () => { @@ -245,6 +252,99 @@ describe("prompt submission outcomes", () => { expect(shell.commandCount("session.prompt")).toBe(0); }); + it("rejects image metadata without a negotiated upload protocol instead of dropping it", async () => { + const { shell, runtime } = await startedRuntime(); + + const outcome = await runtime.submitPrompt({ + kind: "prompt", + text: "inspect this", + attachments: [ + { + id: "attachment-proof", + kind: "image", + mediaType: "image/png", + name: "proof.png", + sizeBytes: 12, + }, + ], + }); + + expect(outcome).toEqual({ kind: "rejected", reason: IMAGE_PROMPTS_UNSUPPORTED_REASON }); + expect(shell.commandCount("session.prompt")).toBe(0); + }); + + it("uploads negotiated images and sends only ordered image refs in the prompt", async () => { + const { shell, runtime } = await startedRuntime(["sessions.prompt"], ["prompt.images"]); + const imageId = "123e4567-e89b-42d3-a456-426614174000"; + const bytes = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02, 0x03, + ]); + const file = new File([bytes], "proof-from-android.png", { type: "" }); + let received = 0; + shell.command = async (request: CommandRequest): Promise => { + shell.commands.push(request); + const command = request.intent.command; + let result: unknown; + if (command === "session.image.begin") { + result = { imageId, chunkBytes: IMAGE_UPLOAD_CHUNK_BYTES }; + } else if (command === "session.image.chunk") { + const content = String(request.intent.args?.content); + received += atob(content).length; + result = { imageId, received, complete: received === file.size }; + } else if (command === "session.image.discard") { + result = { discarded: true }; + } else if (command === "session.prompt") { + result = { accepted: true }; + } + return { + targetId: request.targetId, + requestId: `image-req-${shell.commands.length}`, + commandId: `image-cmd-${shell.commands.length}`, + accepted: true, + ...(result === undefined ? {} : { result }), + }; + }; + + expect(runtime.getSnapshot().controls.attachmentsSupported).toBe(true); + const outcome = await runtime.submitPrompt({ + kind: "prompt", + text: "inspect this image", + attachments: [ + { + id: "attachment-proof", + kind: "image", + mediaType: "image/png", + name: file.name, + sizeBytes: file.size, + file, + }, + ], + }); + + expect(outcome).toEqual({ kind: "accepted" }); + expect( + shell.commands + .filter((request) => request.intent.command.startsWith("session.image.")) + .map((request) => request.intent.command), + ).toEqual(["session.image.begin", "session.image.chunk", "session.image.discard"]); + const begin = shell.commands.find( + (request) => request.intent.command === "session.image.begin", + ); + expect(begin?.intent.args).toMatchObject({ + mimeType: "image/png", + size: file.size, + sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), + }); + const prompt = shell.commands.find((request) => request.intent.command === "session.prompt"); + expect(prompt?.intent.args).toEqual({ + message: "inspect this image", + images: [{ imageId }], + }); + expect(JSON.stringify(prompt?.intent.args)).not.toContain(file.name); + expect(JSON.stringify(prompt?.intent.args)).not.toContain("iVBOR"); + expect(shell.commandCount("session.prompt")).toBe(1); + }); + it.each(REJECTION_CASES)( "$label keeps the exact draft and shows actionable copy without replaying", async ({ error, reason }) => { @@ -685,11 +785,13 @@ describe("authoritative live runtime protocol", () => { }); let promptLeaseCalled = false; + let promptLeaseRevision: string | undefined; let controllerLeaseCalled = false; const origPromptLease = controller.commandWithPromptLease; controller.commandWithPromptLease = async function (targetId, intent, leaseRevision) { promptLeaseCalled = true; + promptLeaseRevision = leaseRevision; return origPromptLease.call(this, targetId, intent, leaseRevision); }; @@ -706,6 +808,8 @@ describe("authoritative live runtime protocol", () => { const cmd1 = shell.commands.find((c) => c.intent.command === "session.prompt"); expect(cmd1).toBeDefined(); expect(cmd1?.intent.args).toEqual({ message: "ship it", leaseId: "prompt-lease-fixture" }); + expect(cmd1?.intent.expectedRevision).toBeUndefined(); + expect(promptLeaseRevision).toBe("rev-1"); // 2. steer -> session.steer promptLeaseCalled = false; @@ -740,7 +844,7 @@ describe("authoritative live runtime protocol", () => { expect(cmd4?.intent.expectedRevision).toBeUndefined(); }); - it("keeps the revision on prompts but omits it from active-turn steer and follow-up", async () => { + it("omits volatile revisions from prompt, steer, and follow-up commands", async () => { const { shell, runtime } = await startedRuntime(); await runtime.submitPrompt(PROMPT); @@ -752,7 +856,7 @@ describe("authoritative live runtime protocol", () => { const followUp = shell.commands.find( (request) => request.intent.command === "session.followUp", ); - expect(prompt?.intent.expectedRevision).toBe(revision("rev-1")); + expect(prompt?.intent.expectedRevision).toBeUndefined(); expect(steer?.intent.expectedRevision).toBeUndefined(); expect(followUp?.intent.expectedRevision).toBeUndefined(); }); diff --git a/apps/web/test/live-session-controls.test.ts b/apps/web/test/live-session-controls.test.ts index 8e21b9dc..28bfd633 100644 --- a/apps/web/test/live-session-controls.test.ts +++ b/apps/web/test/live-session-controls.test.ts @@ -19,7 +19,7 @@ import { type SessionsFrame, type SettingsFrame, } from "@t4-code/protocol"; -import { createDesktopRuntimeController } from "@t4-code/client"; +import { createDesktopRuntimeController, type DesktopRuntimeController } from "@t4-code/client"; import { createLiveSessionRuntime } from "../src/features/session-runtime/live-runtime.ts"; import type { SessionRuntime } from "../src/features/session-runtime/controller.ts"; @@ -80,6 +80,7 @@ const CONTROL_COMMANDS = [ interface Setup { readonly shell: FakeShell; + readonly controller: DesktopRuntimeController; readonly runtime: SessionRuntime; } @@ -87,13 +88,19 @@ async function startedRuntime(options?: { readonly items?: CatalogItem[]; readonly settings?: Record; readonly skipCatalog?: boolean; + readonly capabilities?: readonly string[]; + readonly features?: readonly string[]; }): Promise { const shell = new FakeShell(); const controller = createDesktopRuntimeController({ shell }); await controller.start(); shell.emitFrame({ targetId: "local", - frame: makeWelcome(HOST, ["sessions.prompt", "sessions.manage"]), + frame: makeWelcome( + HOST, + options?.capabilities ?? ["sessions.prompt", "sessions.manage"], + options?.features ?? [], + ), }); shell.emitFrame({ targetId: "local", @@ -123,7 +130,7 @@ async function startedRuntime(options?: { hostId: HOST, sessionId: SESSION, }); - return { shell, runtime }; + return { shell, controller, runtime }; } function sessionsUpsert(seq: number, extra: Record): SessionsFrame { @@ -135,7 +142,9 @@ function sessionsUpsert(seq: number, extra: Record): SessionsFr { hostId: hostId(HOST), sessionId: sessionId(SESSION), - project: { projectId: "project-1" as SessionsFrame["sessions"][number]["project"]["projectId"] }, + project: { + projectId: "project-1" as SessionsFrame["sessions"][number]["project"]["projectId"], + }, revision: revision("rev-1"), title: "Session", status: "active", @@ -286,9 +295,15 @@ describe("defaults from live host settings", () => { describe("control commands leave immediately with exact payloads", () => { it("setModel sends session.model.set with role/selector and session persistence", async () => { const { shell, runtime } = await startedRuntime(); - const roleOutcome = await runtime.submitPrompt({ kind: "setModel", selector: null, role: "smol" }); + const roleOutcome = await runtime.submitPrompt({ + kind: "setModel", + selector: null, + role: "smol", + }); expect(roleOutcome.kind).toBe("accepted"); - const roleCommand = shell.commands.find((request) => request.intent.command === "session.model.set"); + const roleCommand = shell.commands.find( + (request) => request.intent.command === "session.model.set", + ); expect(roleCommand?.intent.args).toEqual({ role: "smol", persistence: "session" }); expect(roleCommand?.intent.expectedRevision).toBeDefined(); @@ -296,7 +311,10 @@ describe("control commands leave immediately with exact payloads", () => { const selectorCommand = shell.commands.findLast( (request) => request.intent.command === "session.model.set", ); - expect(selectorCommand?.intent.args).toEqual({ selector: "openai/gpt-6", persistence: "session" }); + expect(selectorCommand?.intent.args).toEqual({ + selector: "openai/gpt-6", + persistence: "session", + }); // The wire takes role XOR selector: a cycle-role pick that also knows // its resolved selector still sends only the role. @@ -314,7 +332,9 @@ describe("control commands leave immediately with exact payloads", () => { it("setThinking sends session.thinking.set with the level", async () => { const { shell, runtime } = await startedRuntime(); await runtime.submitPrompt({ kind: "setThinking", level: "xhigh" }); - const sent = shell.commands.find((request) => request.intent.command === "session.thinking.set"); + const sent = shell.commands.find( + (request) => request.intent.command === "session.thinking.set", + ); expect(sent?.intent.args).toEqual({ level: "xhigh" }); }); @@ -343,9 +363,15 @@ describe("control commands leave immediately with exact payloads", () => { }); it("holds a fast prompt until the accepted model revision reconciles", async () => { - const { shell, runtime } = await startedRuntime(); + const { shell, controller, runtime } = await startedRuntime(); const modelGate = deferred(); const listGate = deferred(); + const leaseRevisions: (string | undefined)[] = []; + const originalPromptLease = controller.commandWithPromptLease; + controller.commandWithPromptLease = async function (targetId, intent, leaseRevision) { + leaseRevisions.push(leaseRevision); + return originalPromptLease.call(this, targetId, intent, leaseRevision); + }; shell.commandBehavior = { kind: "defer", gate: modelGate }; const model = runtime.submitPrompt({ kind: "setModel", selector: null, role: "smol" }); @@ -376,11 +402,14 @@ describe("control commands leave immediately with exact payloads", () => { expect((await prompt).kind).toBe("accepted"); const commands = shell.commands.map((request) => request.intent.command); expect(commands.indexOf("session.model.set")).toBeGreaterThanOrEqual(0); - expect(commands.indexOf("session.prompt")).toBeGreaterThan(commands.indexOf("session.model.set")); + expect(commands.indexOf("session.prompt")).toBeGreaterThan( + commands.indexOf("session.model.set"), + ); expect( shell.commands.find((request) => request.intent.command === "session.prompt")?.intent .expectedRevision, - ).toBe(revision("rev-2")); + ).toBeUndefined(); + expect(leaseRevisions).toContain("rev-2"); expect(runtime.getSnapshot().controls.pendingControl).toBeNull(); }); }); @@ -544,7 +573,11 @@ describe("honest unsupported controls", () => { it("a refused catalog item carries the host's own reason", async () => { const { runtime } = await startedRuntime({ items: [ - { ...commandItem("session.model.set"), supported: false, reason: "Model is pinned by policy" }, + { + ...commandItem("session.model.set"), + supported: false, + reason: "Model is pinned by policy", + }, ], }); const controls = runtime.getSnapshot().controls; @@ -560,4 +593,18 @@ describe("honest unsupported controls", () => { const outcome = await runtime.submitPrompt({ kind: "setMode", mode: "plan" }); expect(outcome.kind).toBe("rejected"); }); + + it("enables attachments only when prompt capability and image feature are both granted", async () => { + const imageHost = await startedRuntime({ + capabilities: ["sessions.prompt"], + features: ["prompt.images"], + }); + expect(imageHost.runtime.getSnapshot().controls.attachmentsSupported).toBe(true); + + const featureOnly = await startedRuntime({ + capabilities: [], + features: ["prompt.images"], + }); + expect(featureOnly.runtime.getSnapshot().controls.attachmentsSupported).toBe(false); + }); }); diff --git a/apps/web/test/mobile-touch-targets.test.tsx b/apps/web/test/mobile-touch-targets.test.tsx index 2c675e9d..df090bf3 100644 --- a/apps/web/test/mobile-touch-targets.test.tsx +++ b/apps/web/test/mobile-touch-targets.test.tsx @@ -67,10 +67,12 @@ describe("phone touch targets", () => { attachments={[ { id: "attachment-1", - kind: "file", - mediaType: "text/plain", - name: "notes.txt", + kind: "image", + mediaType: "image/png", + name: "proof.png", sizeBytes: 12, + file: new File(["proof"], "proof.png", { type: "image/png" }), + previewUrl: "blob:test/proof.png", }, ]} onRemove={() => {}} @@ -80,6 +82,7 @@ describe("phone touch targets", () => { expect(buttonTags(runOptions)[0]).toContain("min-h-11"); expect(buttonTags(context)[0]).toContain("h-11"); expect(buttonTags(attachments)[0]).toContain("size-11"); + expect(attachments).toContain(' { diff --git a/apps/web/test/transcript-image-components.test.tsx b/apps/web/test/transcript-image-components.test.tsx new file mode 100644 index 00000000..b593b5dc --- /dev/null +++ b/apps/web/test/transcript-image-components.test.tsx @@ -0,0 +1,70 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import type { + TranscriptImageSnapshot, + TranscriptImageSource, +} from "../src/features/session-runtime/transcript-images.ts"; +import { TranscriptImages } from "../src/features/transcript/TranscriptImages.tsx"; +import type { TranscriptImageReference } from "../src/features/transcript/image-metadata.ts"; + +const IMAGE: TranscriptImageReference = { + entryId: "animated-entry", + sha256: "a".repeat(64), + mimeType: "image/gif", +}; + +function source(snapshot: TranscriptImageSnapshot): TranscriptImageSource { + return { + getSnapshot: () => snapshot, + subscribe: () => () => undefined, + retain: () => () => undefined, + reportDecodeFailure: () => undefined, + dispose: () => undefined, + }; +} + +describe("transcript image presentation", () => { + it("starts animated evidence paused when reduced motion is requested", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Animated image paused"); + expect(markup).toContain('aria-label="Play animation"'); + expect(markup).toContain('aria-pressed="false"'); + expect(markup).not.toContain('src="blob:animated"'); + }); + + it("does not add animation controls to still evidence", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('src="blob:still"'); + expect(markup).not.toContain("animation"); + }); +}); diff --git a/apps/web/test/transcript-images.test.ts b/apps/web/test/transcript-images.test.ts new file mode 100644 index 00000000..87a485c2 --- /dev/null +++ b/apps/web/test/transcript-images.test.ts @@ -0,0 +1,798 @@ +import { createDesktopRuntimeController } from "@t4-code/client"; +import { + entryId, + hostId, + revision, + sessionId, + type DurableEntry, + type SessionSnapshotFrame, +} from "@t4-code/protocol"; +import type { CommandRequest, CommandResult } from "@t4-code/protocol/desktop-ipc"; +import { describe, expect, it } from "vite-plus/test"; + +import { createFixtureSessionRuntime } from "../src/features/session-runtime/controller.ts"; +import { createLiveSessionRuntime } from "../src/features/session-runtime/live-runtime.ts"; +import { + createTranscriptImageSource, + decodeTranscriptImageChunk, + disposeTranscriptImagesForSession, + isAnimatedTranscriptImage, + TRANSCRIPT_IMAGE_CHUNK_BYTES, + TRANSCRIPT_IMAGE_DECODE_ERROR, + TRANSCRIPT_IMAGE_INTEGRITY_ERROR, + TRANSCRIPT_IMAGE_MAX_CHUNKS, + TRANSCRIPT_IMAGE_PROTOCOL_ERROR, + type TranscriptImageCommandResult, + type TranscriptImageSnapshot, + type TranscriptImageSource, +} from "../src/features/session-runtime/transcript-images.ts"; +import { + INVALID_TRANSCRIPT_IMAGE_METADATA, + transcriptImagesFromEntry, + type TranscriptImageMimeType, + type TranscriptImageReference, +} from "../src/features/transcript/image-metadata.ts"; +import { initialProjection, reduceTranscript } from "../src/features/transcript/projection.ts"; +import { deriveTranscriptRows } from "../src/features/transcript/rows.ts"; +import { deferred, FakeShell, makeWelcome } from "./fake-shell.ts"; + +const HOST = "image-host"; +const SESSION = "image-session"; + +function pngBytes(size = 16, salt = 0): Uint8Array { + const bytes = new Uint8Array(Math.max(8, size)); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + for (let index = 8; index < bytes.length; index += 1) bytes[index] = (index + salt) % 251; + return bytes; +} + +function gifBytes(): Uint8Array { + return new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x02]); +} + +function animatedGifBytes(): Uint8Array { + const header = [ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, + 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + ]; + const frame = [ + 0x2c, + 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x01, 0x00, + 0x00, + 0x02, + 0x02, 0x4c, 0x01, + 0x00, + ]; + return new Uint8Array([...header, ...frame, ...frame, 0x3b]); +} + +function base64(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.byteLength; offset += 32 * 1024) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 32 * 1024)); + } + return btoa(binary); +} + +function hex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function sha256(bytes: Uint8Array): Promise { + const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes.slice().buffer as ArrayBuffer); + return hex(new Uint8Array(digest)); +} + +async function reference( + bytes: Uint8Array, + options: { readonly entryId?: string; readonly mimeType?: TranscriptImageMimeType } = {}, +): Promise { + return { + entryId: options.entryId ?? "entry-image", + sha256: await sha256(bytes), + mimeType: options.mimeType ?? "image/png", + }; +} + +function responseFor( + bytes: Uint8Array, + image: TranscriptImageReference, + offset: number, +): TranscriptImageCommandResult { + const nextOffset = Math.min(offset + TRANSCRIPT_IMAGE_CHUNK_BYTES, bytes.byteLength); + return { + accepted: true, + result: { + sha256: image.sha256, + mimeType: image.mimeType, + size: bytes.byteLength, + offset, + nextOffset, + complete: nextOffset === bytes.byteLength, + content: base64(bytes.subarray(offset, nextOffset)), + }, + }; +} + +async function waitForStatus( + source: TranscriptImageSource, + image: TranscriptImageReference, + status: TranscriptImageSnapshot["status"], +): Promise { + const initial = source.getSnapshot(image); + if (initial.status === status) return initial; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + unsubscribe(); + reject(new Error(`timed out waiting for transcript image ${status}`)); + }, 2_000); + const check = () => { + const snapshot = source.getSnapshot(image); + if (snapshot.status !== status) return; + clearTimeout(timer); + unsubscribe(); + resolve(snapshot); + }; + const unsubscribe = source.subscribe(image, check); + check(); + }); +} + +function durableEntry( + id: string, + kind: string, + data: Record, +): DurableEntry { + return { + id: entryId(id), + parentId: null, + hostId: hostId(HOST), + sessionId: sessionId(SESSION), + kind, + timestamp: "2026-07-14T12:00:00.000Z", + data, + }; +} + +describe("transcript image metadata", () => { + it("preserves exact ordered metadata for message and tool-result rows", () => { + const first = "a".repeat(64); + const second = "b".repeat(64); + const message = durableEntry("message-images", "message", { + role: "user", + text: "look", + images: [ + { sha256: first, mimeType: "image/png" }, + { sha256: second, mimeType: "image/webp" }, + ], + }); + const tool = durableEntry("tool-images", "tool-result", { + tool: "read", + title: "read image", + result: {}, + images: [{ sha256: second, mimeType: "image/webp" }], + }); + const frame: SessionSnapshotFrame = { + v: "omp-app/1", + type: "snapshot", + cursor: { epoch: "images", seq: 1 }, + revision: revision("images-revision"), + hostId: hostId(HOST), + sessionId: sessionId(SESSION), + entries: [message, tool], + }; + const rows = deriveTranscriptRows(reduceTranscript(initialProjection(), frame)); + const messageRow = rows.find((row) => row.kind === "message"); + const toolRow = rows.find((row) => row.kind === "tool-group"); + + expect(messageRow?.kind === "message" && messageRow.images).toEqual([ + { entryId: "message-images", sha256: first, mimeType: "image/png" }, + { entryId: "message-images", sha256: second, mimeType: "image/webp" }, + ]); + expect(toolRow?.kind === "tool-group" && toolRow.calls[0]?.images).toEqual([ + { entryId: "tool-images", sha256: second, mimeType: "image/webp" }, + ]); + expect(JSON.stringify(rows)).not.toContain("content"); + }); + + it("rejects the whole metadata list when any item is malformed", () => { + for (const images of [ + "not-an-array", + [{ sha256: "A".repeat(64), mimeType: "image/png" }], + [{ sha256: "a".repeat(64), mimeType: "image/svg+xml" }], + [{ sha256: "a".repeat(64), mimeType: "image/png", content: "AQ==" }], + Array.from({ length: 65 }, () => ({ sha256: "a".repeat(64), mimeType: "image/png" })), + ]) { + expect(transcriptImagesFromEntry(durableEntry("bad", "message", { images }))).toEqual({ + images: [], + issue: INVALID_TRANSCRIPT_IMAGE_METADATA, + }); + } + }); +}); + +describe("transcript image result decoding", () => { + it("accepts one exact bounded result and rejects structural or sequencing drift", async () => { + const bytes = pngBytes(); + const image = await reference(bytes); + const valid = responseFor(bytes, image, 0).result as Record; + expect(decodeTranscriptImageChunk(valid, image, 0)).toMatchObject({ + size: bytes.byteLength, + offset: 0, + nextOffset: bytes.byteLength, + complete: true, + }); + expect( + decodeTranscriptImageChunk( + { + ...valid, + nextOffset: 8, + complete: false, + content: base64(bytes.subarray(0, 8)), + }, + image, + 0, + ), + ).toMatchObject({ offset: 0, nextOffset: 8, complete: false }); + + for (const invalid of [ + { ...valid, sha256: "f".repeat(64) }, + { ...valid, mimeType: "image/webp" }, + { ...valid, size: 0 }, + { ...valid, offset: 1 }, + { ...valid, nextOffset: bytes.byteLength - 1 }, + { ...valid, complete: false }, + { ...valid, content: "AQJ=" }, + { ...valid, path: "/tmp/image" }, + ]) { + expect(() => decodeTranscriptImageChunk(invalid, image, 0)).toThrow( + TRANSCRIPT_IMAGE_PROTOCOL_ERROR, + ); + } + }); + + it("detects browser-decodable animated formats before autoplay", () => { + expect(isAnimatedTranscriptImage(animatedGifBytes(), "image/gif")).toBe(true); + expect( + isAnimatedTranscriptImage( + new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x00, 0x61, 0x63, 0x54, 0x4c, + 0x00, 0x00, 0x00, 0x00, + ]), + "image/png", + ), + ).toBe(true); + expect( + isAnimatedTranscriptImage( + new Uint8Array([ + 0x52, 0x49, 0x46, 0x46, 0x0c, 0x00, 0x00, 0x00, + 0x57, 0x45, 0x42, 0x50, + 0x41, 0x4e, 0x49, 0x4d, 0x00, 0x00, 0x00, 0x00, + ]), + "image/webp", + ), + ).toBe(true); + expect(isAnimatedTranscriptImage(pngBytes(), "image/png")).toBe(false); + }); +}); + +describe("bounded transcript image source", () => { + it("deduplicates duplicate references, reconstructs sequentially, and revokes on dispose", async () => { + const bytes = pngBytes(TRANSCRIPT_IMAGE_CHUNK_BYTES + 17); + const image = await reference(bytes); + const offsets: number[] = []; + const blobs: Blob[] = []; + const revoked: string[] = []; + const source = createTranscriptImageSource({ + availability: { available: true }, + readChunk: async (nextImage, offset) => { + offsets.push(offset); + return responseFor(bytes, nextImage, offset); + }, + createObjectUrl: (blob) => { + blobs.push(blob); + return "blob:verified"; + }, + revokeObjectUrl: (url) => revoked.push(url), + }); + + const releaseFirst = source.retain(image); + const releaseSecond = source.retain({ ...image }); + const ready = await waitForStatus(source, image, "ready"); + + expect(ready).toEqual({ + status: "ready", + url: "blob:verified", + mimeType: "image/png", + size: bytes.byteLength, + animated: false, + }); + expect(offsets).toEqual([0, TRANSCRIPT_IMAGE_CHUNK_BYTES]); + expect(blobs).toHaveLength(1); + expect(new Uint8Array(await blobs[0]!.arrayBuffer())).toEqual(bytes); + releaseFirst(); + releaseSecond(); + source.dispose(); + expect(revoked).toEqual(["blob:verified"]); + }); + + it("refuses digest and MIME magic mismatches before creating a URL", async () => { + const valid = pngBytes(16, 1); + const different = pngBytes(16, 2); + const digestMismatch = await reference(different, { entryId: "digest-mismatch" }); + const gif = gifBytes(); + const mimeMismatch = await reference(gif, { + entryId: "mime-mismatch", + mimeType: "image/png", + }); + let created = 0; + const source = createTranscriptImageSource({ + availability: { available: true }, + readChunk: async (image, offset) => + responseFor(image.entryId === "digest-mismatch" ? valid : gif, image, offset), + createObjectUrl: () => { + created += 1; + return `blob:${created}`; + }, + }); + + const releaseDigest = source.retain(digestMismatch); + const digestError = await waitForStatus(source, digestMismatch, "error"); + const releaseMime = source.retain(mimeMismatch); + const mimeError = await waitForStatus(source, mimeMismatch, "error"); + + expect(digestError).toEqual({ status: "error", reason: TRANSCRIPT_IMAGE_INTEGRITY_ERROR }); + expect(mimeError).toEqual({ status: "error", reason: TRANSCRIPT_IMAGE_INTEGRITY_ERROR }); + expect(created).toBe(0); + releaseDigest(); + releaseMime(); + source.dispose(); + }); + + it("keeps retained URLs alive and evicts the least-recent unused URL", async () => { + const firstBytes = pngBytes(16, 1); + const secondBytes = pngBytes(16, 2); + const first = await reference(firstBytes, { entryId: "first" }); + const second = await reference(secondBytes, { entryId: "second" }); + const revoked: string[] = []; + let objectUrlSerial = 0; + const source = createTranscriptImageSource({ + availability: { available: true }, + maxCacheBytes: 32, + maxCacheEntries: 1, + readChunk: async (image, offset) => + responseFor(image.entryId === "first" ? firstBytes : secondBytes, image, offset), + createObjectUrl: (blob) => { + objectUrlSerial += 1; + return `blob:${blob.size}:${objectUrlSerial}`; + }, + revokeObjectUrl: (url) => revoked.push(url), + }); + + const unsubscribeFirst = source.subscribe(first, () => undefined); + const releaseFirst = source.retain(first); + const firstReady = await waitForStatus(source, first, "ready"); + const releaseSecond = source.retain(second); + await Promise.resolve(); + await Promise.resolve(); + expect(source.getSnapshot(second)).toEqual({ status: "loading" }); + expect(revoked).toEqual([]); + + releaseFirst(); + await Promise.resolve(); + await Promise.resolve(); + expect(source.getSnapshot(second)).toEqual({ status: "loading" }); + expect(revoked).toEqual([]); + unsubscribeFirst(); + const secondReady = await waitForStatus(source, second, "ready"); + expect(secondReady.status).toBe("ready"); + expect(revoked).toEqual([firstReady.status === "ready" ? firstReady.url : ""]); + releaseSecond(); + source.dispose(); + }); + + it("bounds concurrent reads and drains queued images in order", async () => { + const images = await Promise.all( + Array.from({ length: 5 }, async (_, index) => { + const bytes = pngBytes(16, index + 1); + return { bytes, image: await reference(bytes, { entryId: `queued-${index}` }) }; + }), + ); + const gates = new Map>>(); + for (const { image } of images) gates.set(image.entryId, deferred()); + const started: string[] = []; + let active = 0; + let maximumActive = 0; + const source = createTranscriptImageSource({ + availability: { available: true }, + maxConcurrentLoads: 2, + readChunk: async (image) => { + started.push(image.entryId); + active += 1; + maximumActive = Math.max(maximumActive, active); + try { + return await gates.get(image.entryId)!.promise; + } finally { + active -= 1; + } + }, + createObjectUrl: (blob) => `blob:queued-${blob.size}-${started.length}`, + }); + const releases = images.map(({ image }) => source.retain(image)); + + expect(started).toEqual(["queued-0", "queued-1"]); + for (let index = 0; index < images.length; index += 1) { + const item = images[index]!; + gates.get(item.image.entryId)!.resolve(responseFor(item.bytes, item.image, 0)); + expect((await waitForStatus(source, item.image, "ready")).status).toBe("ready"); + await Promise.resolve(); + } + expect(started).toEqual(images.map(({ image }) => image.entryId)); + expect(maximumActive).toBe(2); + for (const release of releases) release(); + source.dispose(); + }); + + it("aborts an unretained read and wakes the next queued image", async () => { + const firstBytes = pngBytes(16, 11); + const secondBytes = pngBytes(16, 12); + const first = await reference(firstBytes, { entryId: "abort-first" }); + const second = await reference(secondBytes, { entryId: "abort-second" }); + const firstStarted = deferred(); + let aborted = false; + let secondReads = 0; + const source = createTranscriptImageSource({ + availability: { available: true }, + maxConcurrentLoads: 1, + readChunk: (image, offset, signal) => { + if (image.entryId === first.entryId) { + firstStarted.resolve(undefined); + return new Promise((_, reject) => { + signal.onCancel(() => { + aborted = true; + reject(new Error("aborted")); + }); + }); + } + secondReads += 1; + return Promise.resolve(responseFor(secondBytes, image, offset)); + }, + createObjectUrl: () => "blob:after-abort", + }); + + const releaseFirst = source.retain(first); + const releaseSecond = source.retain(second); + await firstStarted.promise; + expect(secondReads).toBe(0); + releaseFirst(); + + expect((await waitForStatus(source, second, "ready")).status).toBe("ready"); + expect(aborted).toBe(true); + expect(secondReads).toBe(1); + releaseSecond(); + source.dispose(); + }); + + it("removes cancelled queue entries while a non-abortable host read is still live", async () => { + const firstBytes = pngBytes(16, 21); + const first = await reference(firstBytes, { entryId: "non-abortable-first" }); + const firstResult = deferred(); + const churn = await Promise.all( + Array.from({ length: 48 }, async (_, index) => { + const bytes = pngBytes(16, index + 22); + return { bytes, image: await reference(bytes, { entryId: `cancelled-${index}` }) }; + }), + ); + const finalBytes = pngBytes(16, 99); + const final = await reference(finalBytes, { entryId: "queue-survivor" }); + const reads: string[] = []; + const source = createTranscriptImageSource({ + availability: { available: true }, + maxConcurrentLoads: 1, + readChunk: async (image, offset) => { + reads.push(image.entryId); + if (image.entryId === first.entryId) { + // Deliberately ignore the cancellation signal, matching the live + // controller command that can only settle when the host responds. + return firstResult.promise; + } + return responseFor(finalBytes, image, offset); + }, + createObjectUrl: (blob) => `blob:non-abortable-${blob.size}-${reads.length}`, + }); + const pendingCount = () => + (source as unknown as { readonly pendingLoads: readonly unknown[] }).pendingLoads.length; + + const releaseFirst = source.retain(first); + expect(reads).toEqual([first.entryId]); + for (const { image } of churn) { + const release = source.retain(image); + expect(pendingCount()).toBe(1); + release(); + expect(pendingCount()).toBe(0); + } + + const releaseFinal = source.retain(final); + expect(pendingCount()).toBe(1); + firstResult.resolve(responseFor(firstBytes, first, 0)); + expect((await waitForStatus(source, first, "ready")).status).toBe("ready"); + expect((await waitForStatus(source, final, "ready")).status).toBe("ready"); + expect(reads).toEqual([first.entryId, final.entryId]); + + releaseFinal(); + releaseFirst(); + source.dispose(); + }); + + it("does not read while unavailable and resumes retained reads when access arrives", async () => { + const bytes = pngBytes(); + const image = await reference(bytes); + let reads = 0; + const source = createTranscriptImageSource({ + availability: { available: false, reason: "Waiting for the host." }, + readChunk: async (nextImage, offset) => { + reads += 1; + return responseFor(bytes, nextImage, offset); + }, + createObjectUrl: () => "blob:connected", + }); + const release = source.retain(image); + expect(source.getSnapshot(image)).toEqual({ + status: "unavailable", + reason: "Waiting for the host.", + }); + expect(reads).toBe(0); + + source.setAvailability({ available: true }); + expect((await waitForStatus(source, image, "ready")).status).toBe("ready"); + expect(reads).toBe(1); + release(); + source.dispose(); + }); + + it("clears transient failure state so a later retain can retry cleanly", async () => { + const bytes = pngBytes(); + const image = await reference(bytes); + let reads = 0; + const source = createTranscriptImageSource({ + availability: { available: true }, + readChunk: async (nextImage, offset) => { + reads += 1; + return reads === 1 + ? { accepted: false, error: { code: "connection_closed", message: "gone" } } + : responseFor(bytes, nextImage, offset); + }, + createObjectUrl: () => "blob:retry", + }); + + const releaseFailed = source.retain(image); + expect((await waitForStatus(source, image, "error")).status).toBe("error"); + releaseFailed(); + const releaseReady = source.retain(image); + expect(await waitForStatus(source, image, "ready")).toMatchObject({ + status: "ready", + url: "blob:retry", + }); + expect(reads).toBe(2); + releaseReady(); + source.dispose(); + }); + + it("bounds pathological tiny-chunk responses", async () => { + const bytes = pngBytes(TRANSCRIPT_IMAGE_MAX_CHUNKS + 1); + const image = await reference(bytes); + let reads = 0; + const source = createTranscriptImageSource({ + availability: { available: true }, + readChunk: async (_nextImage, offset) => { + reads += 1; + const nextOffset = offset + 1; + return { + accepted: true, + result: { + sha256: image.sha256, + mimeType: image.mimeType, + size: bytes.byteLength, + offset, + nextOffset, + complete: nextOffset === bytes.byteLength, + content: base64(bytes.subarray(offset, nextOffset)), + }, + }; + }, + }); + const release = source.retain(image); + + expect(await waitForStatus(source, image, "error")).toEqual({ + status: "error", + reason: TRANSCRIPT_IMAGE_PROTOCOL_ERROR, + }); + expect(reads).toBe(TRANSCRIPT_IMAGE_MAX_CHUNKS); + release(); + source.dispose(); + }); + + it("revokes a just-created URL when disposal wins the final integrity race", async () => { + const bytes = pngBytes(); + const image = await reference(bytes); + const revoked: string[] = []; + let source!: ReturnType; + source = createTranscriptImageSource({ + availability: { available: true }, + readChunk: async (nextImage, offset) => responseFor(bytes, nextImage, offset), + createObjectUrl: () => { + source.dispose("Closed during image finalization."); + return "blob:finalization-race"; + }, + revokeObjectUrl: (url) => revoked.push(url), + }); + + const release = source.retain(image); + expect(await waitForStatus(source, image, "unavailable")).toEqual({ + status: "unavailable", + reason: "Closed during image finalization.", + }); + expect(revoked).toEqual(["blob:finalization-race"]); + expect(source.getSnapshot(image)).toBe(source.getSnapshot(image)); + release(); + source.dispose(); + expect(revoked).toEqual(["blob:finalization-race"]); + }); + + it("revokes and removes a cached URL when the browser rejects its bytes", async () => { + const bytes = pngBytes(); + const image = await reference(bytes); + const revoked: string[] = []; + const source = createTranscriptImageSource({ + availability: { available: true }, + readChunk: async (nextImage, offset) => responseFor(bytes, nextImage, offset), + createObjectUrl: () => "blob:decode-failure", + revokeObjectUrl: (url) => revoked.push(url), + }); + const release = source.retain(image); + await waitForStatus(source, image, "ready"); + + source.reportDecodeFailure(image); + + expect(source.getSnapshot(image)).toEqual({ + status: "error", + reason: TRANSCRIPT_IMAGE_DECODE_ERROR, + }); + expect(revoked).toEqual(["blob:decode-failure"]); + release(); + source.dispose(); + expect(revoked).toEqual(["blob:decode-failure"]); + }); + + it("revokes registered URLs on authoritative session deletion", async () => { + const bytes = pngBytes(); + const image = await reference(bytes); + const revoked: string[] = []; + const source = createTranscriptImageSource({ + hostId: HOST, + sessionId: SESSION, + availability: { available: true }, + readChunk: async (nextImage, offset) => responseFor(bytes, nextImage, offset), + createObjectUrl: () => "blob:authoritative", + revokeObjectUrl: (url) => revoked.push(url), + }); + const release = source.retain(image); + await waitForStatus(source, image, "ready"); + + disposeTranscriptImagesForSession(HOST, SESSION); + + expect(revoked).toEqual(["blob:authoritative"]); + expect(source.getSnapshot(image)).toEqual({ + status: "unavailable", + reason: "This session was removed from the host.", + }); + release(); + }); +}); + +describe("runtime capability gating", () => { + function snapshot(): SessionSnapshotFrame { + return { + v: "omp-app/1", + type: "snapshot", + cursor: { epoch: "runtime-images", seq: 1 }, + revision: revision("runtime-images-revision"), + hostId: hostId(HOST), + sessionId: sessionId(SESSION), + entries: [], + }; + } + + it("keeps fixture/browser transcript images honestly unavailable", async () => { + const image = await reference(pngBytes()); + const runtime = createFixtureSessionRuntime({ sessionKey: "fixture", variant: "default" }); + expect(runtime.transcriptImages.getSnapshot(image)).toEqual({ + status: "unavailable", + reason: "Transcript images are available only from a connected OMP host.", + }); + runtime.dispose(); + }); + + it("requires sessions.read and transcript.images before issuing a read", async () => { + const bytes = pngBytes(); + const image = await reference(bytes); + const shell = new FakeShell(); + const controller = createDesktopRuntimeController({ shell }); + await controller.start(); + shell.emitFrame({ targetId: "local", frame: makeWelcome(HOST, ["sessions.read"], []) }); + shell.emitFrame({ targetId: "local", frame: snapshot() }); + const runtime = createLiveSessionRuntime({ + controller, + targetId: "local", + hostId: HOST, + sessionId: SESSION, + }); + const release = runtime.transcriptImages.retain(image); + + expect(runtime.transcriptImages.getSnapshot(image)).toEqual({ + status: "unavailable", + reason: "This OMP host does not offer transcript image reads.", + }); + expect(shell.commandCount("session.image.read")).toBe(0); + release(); + runtime.dispose(); + await controller.stop(); + }); + + it("reads through the live runtime only after the attach acknowledgement", async () => { + const bytes = pngBytes(); + const image = await reference(bytes); + const shell = new FakeShell(); + const attachGate = deferred(); + shell.command = async (request: CommandRequest): Promise => { + shell.commands.push(request); + if (request.intent.command === "session.attach") await attachGate.promise; + const result = + request.intent.command === "session.image.read" + ? responseFor(bytes, image, Number(request.intent.args?.offset)).result + : { accepted: true }; + return { + targetId: request.targetId, + requestId: `runtime-image-${shell.commands.length}`, + commandId: `runtime-image-command-${shell.commands.length}`, + accepted: true, + result, + }; + }; + const controller = createDesktopRuntimeController({ shell }); + await controller.start(); + shell.emitFrame({ + targetId: "local", + frame: makeWelcome(HOST, ["sessions.read"], ["transcript.images"]), + }); + shell.emitFrame({ targetId: "local", frame: snapshot() }); + const runtime = createLiveSessionRuntime({ + controller, + targetId: "local", + hostId: HOST, + sessionId: SESSION, + }); + + const release = runtime.transcriptImages.retain(image); + expect(runtime.transcriptImages.getSnapshot(image)).toEqual({ + status: "unavailable", + reason: "Waiting for this session to finish connecting.", + }); + expect(shell.commandCount("session.image.read")).toBe(0); + attachGate.resolve(undefined); + expect((await waitForStatus(runtime.transcriptImages, image, "ready")).status).toBe("ready"); + expect( + shell.commands.map((request) => request.intent.command).filter((command) => + command.startsWith("session.image"), + ), + ).toEqual(["session.image.read"]); + expect(shell.commands.find((request) => request.intent.command === "session.image.read")?.intent.args) + .toEqual({ entryId: image.entryId, sha256: image.sha256, offset: 0 }); + + release(); + runtime.dispose(); + await controller.stop(); + }); +}); diff --git a/compat/omp-app-matrix.json b/compat/omp-app-matrix.json index bee1d00a..91bf433a 100644 --- a/compat/omp-app-matrix.json +++ b/compat/omp-app-matrix.json @@ -2,21 +2,21 @@ "appProtocol": "omp-app/1", "appWire": { "package": "@oh-my-pi/app-wire", - "version": "0.5.3", + "version": "0.5.5", "sourceRepository": "https://github.com/lyc-aon/oh-my-pi", - "sourceCommit": "15527d1f00bac22705f63f80b29c0c30e67fc5da", - "sourceTreeHash": "4961ea9c522a3bbf9a9900424dd475a48148c729", - "tarball": "vendor/app-wire/oh-my-pi-app-wire-0.5.3.tgz", - "tarballSha256": "5ae8833b963a6a56b65a9e2184358906a8717a6c8f57617875a91d4771791050", - "goldenCorpusSha256": "36811f39241c6c491c967a8f969f14c43431366289750538a40893d0dc267324" + "sourceCommit": "6a87fa6407ebff20417b4d52885a6bb3091003ea", + "sourceTreeHash": "a2495fe8781c979184fe7fb9a6d37d8f33bad30f", + "tarball": "vendor/app-wire/oh-my-pi-app-wire-0.5.5.tgz", + "tarballSha256": "049d92d47c0c29a019c3844072b96198105116c452a54182163ef78994ef557e", + "goldenCorpusSha256": "e92d3d7a4848ab6ea6403cc1c1faa6912f8fdc75d2a6abf663ece0154a6eb7fa" }, "verifiedRuntime": { "package": "omp", "version": "16.5.1", "sourceRepository": "https://github.com/lyc-aon/oh-my-pi", - "sourceCommit": "15527d1f00bac22705f63f80b29c0c30e67fc5da", - "sourceUrl": "https://github.com/lyc-aon/oh-my-pi/commit/15527d1f00bac22705f63f80b29c0c30e67fc5da", - "sourceTag": "t4code-16.5.1-appserver-1", + "sourceCommit": "6a87fa6407ebff20417b4d52885a6bb3091003ea", + "sourceUrl": "https://github.com/lyc-aon/oh-my-pi/commit/6a87fa6407ebff20417b4d52885a6bb3091003ea", + "sourceTag": "t4code-16.5.1-appserver-4", "upstreamRepository": "https://github.com/can1357/oh-my-pi", "upstreamTag": "v16.5.1", "upstreamCommit": "14b5da76a9aece9a469288718d22c3d624daf033", @@ -32,12 +32,25 @@ "bounded-rpc-terminal-events", "reader-failure-runtime-reaping", "recoverable-runtime-crash-status", - "settled-runtime-close-state" + "settled-runtime-close-state", + "bounded-prompt-image-uploads", + "bounded-transcript-image-reads", + "strict-app-wire-frame-contracts", + "app-wire-metadata-key-separator-folding", + "portable-app-wire-module-specifiers", + "nodenext-app-wire-consumer-support", + "future-app-wire-feature-tolerance", + "public-app-wire-package-hardening", + "bounded-rpc-agent-end-frames", + "bounded-rpc-subagent-end-events", + "bounded-rpc-terminal-metadata", + "typed-command-result-validation", + "canonical-response-boundaries" ], "upstreamTagContainsIntegrationPatches": false }, "desktop": { "package": "@t4-code/protocol", - "version": "0.1.11" + "version": "0.1.12" } } diff --git a/docs/CURRENT_RELEASE_NOTES.md b/docs/CURRENT_RELEASE_NOTES.md index 2363950b..6ed664b0 100644 --- a/docs/CURRENT_RELEASE_NOTES.md +++ b/docs/CURRENT_RELEASE_NOTES.md @@ -1,16 +1,18 @@ -## OMP 16.5.1 compatibility +## Image prompts and transcript images -T4 Code v0.1.11 adopts the official OMP 16.5.1 release. The host runtime now includes upstream fixes for interrupted session turns, organization-scoped Anthropic accounts, credential rotation, subagent model selection, bounded transcript retention, and RPC disconnect cleanup. +T4 Code v0.1.12 can attach up to eight PNG, JPEG, WebP, or GIF images to one prompt, with a 20 MiB limit per image. Drafts remain local until the host accepts them, and interrupted or uncertain sends stay recoverable instead of being silently discarded. -## Desktop runtime +Transcript images are loaded by verified digest through bounded work queues and per-session caches. Animated images include pause and play controls, and T4 follows the device's reduced-motion preference. -The merged RPC shutdown path rejects pending extension UI, host tool, and host URI requests before it drains accepted work. T4's session teardown then releases the persistent session lock and flushes the postmortem before the worker exits. This sequence prevents a dead client from leaving queued work or a stale session lock. +## Connection recovery + +Desktop, browser, and Android transports now stop waiting and reconnect when a host accepts a socket but does not complete setup. T4 first advertises image support and performs one compatibility retry without that feature when an older host rejects the handshake. ## Runtime compatibility -T4 Code v0.1.11 uses app-wire 0.5.3 from integration commit [15527d1f](https://github.com/lyc-aon/oh-my-pi/commit/15527d1f00bac22705f63f80b29c0c30e67fc5da), source tree `4961ea9c522a3bbf9a9900424dd475a48148c729`. The app-wire source tree and packaged artifact are unchanged from v0.1.10; provenance now points at the OMP 16.5.1 integration. +T4 Code v0.1.12 vendors app-wire 0.5.5 from integration commit [6a87fa64](https://github.com/lyc-aon/oh-my-pi/commit/6a87fa6407ebff20417b4d52885a6bb3091003ea), source tree `a2495fe8781c979184fe7fb9a6d37d8f33bad30f`. Image prompts activate only when the host advertises the additive image capability; the compatibility handshake keeps older appservers available. -The matching OMP 16.5.1 runtime is tagged [t4code-16.5.1-appserver-1](https://github.com/lyc-aon/oh-my-pi/tree/t4code-16.5.1-appserver-1). It carries forward bounded replay and terminal events, complete session projection, catalog-backed lifecycle controls, ordered remote delivery, failed-worker reaping, recoverable crash state, settled close state, cross-client convergence, and restart-safe RPC teardown. +The matching OMP 16.5.1 runtime is built from [6a87fa64](https://github.com/lyc-aon/oh-my-pi/commit/6a87fa6407ebff20417b4d52885a6bb3091003ea) and tagged [t4code-16.5.1-appserver-4](https://github.com/lyc-aon/oh-my-pi/tree/t4code-16.5.1-appserver-4). It carries forward T4's appserver and lifecycle integration, adds bounded prompt-image uploads and transcript-image reads, and tightens public wire decoding, typed command results, canonical response boundaries, and RPC event limits. The integration is based on the official upstream [v16.5.1 tag](https://github.com/can1357/oh-my-pi/tree/v16.5.1), commit [14b5da76](https://github.com/can1357/oh-my-pi/commit/14b5da76a9aece9a469288718d22c3d624daf033). Official upstream OMP v16.5.1 has no `appserver` command and cannot host T4 Code. diff --git a/e2e/remote-app.spec.ts b/e2e/remote-app.spec.ts index b0fc455d..9fa61091 100644 --- a/e2e/remote-app.spec.ts +++ b/e2e/remote-app.spec.ts @@ -17,6 +17,8 @@ const JITI = resolve( const FIXTURE_PROCESS = resolve(REPO_ROOT, "e2e/fixture-process.ts"); const SESSION_VIEW_ID = "host-stream/session-stream"; const SESSION_TITLE = "stream-v1 fixture"; +// Chromium can report an exact 44 CSS px box a few floating-point ulps below 44. +const MIN_TOUCH_TARGET_PX = 43.99; const CONNECTED_COPY = "This Tailnet connection is live. Choose a session from the list on the left to inspect it."; @@ -719,8 +721,8 @@ for (const viewport of [ expect(geometry.top).toBeGreaterThanOrEqual(0); expect(geometry.right).toBeLessThanOrEqual(geometry.viewportWidth + 0.5); expect(geometry.bottom).toBeLessThanOrEqual(geometry.viewportHeight + 0.5); - expect(geometry.width).toBeGreaterThanOrEqual(44); - expect(geometry.height).toBeGreaterThanOrEqual(44); + expect(geometry.width).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET_PX); + expect(geometry.height).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET_PX); expect(geometry.documentWidth).toBeLessThanOrEqual(geometry.viewportWidth); expect(geometry.bodyWidth).toBeLessThanOrEqual(geometry.viewportWidth); @@ -757,8 +759,8 @@ for (const viewport of [ expect(actionGeometry.top, name).toBeGreaterThanOrEqual(0); expect(actionGeometry.right, name).toBeLessThanOrEqual(actionGeometry.viewportWidth + 0.5); expect(actionGeometry.bottom, name).toBeLessThanOrEqual(actionGeometry.viewportHeight + 0.5); - expect(actionGeometry.width, name).toBeGreaterThanOrEqual(44); - expect(actionGeometry.height, name).toBeGreaterThanOrEqual(44); + expect(actionGeometry.width, name).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET_PX); + expect(actionGeometry.height, name).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET_PX); } // Stop is a challenged command in real OMP. Exercise the actual // request -> confirmation -> confirm -> original-request response @@ -785,8 +787,8 @@ for (const viewport of [ expect(approvalGeometry.top).toBeGreaterThanOrEqual(0); expect(approvalGeometry.right).toBeLessThanOrEqual(approvalGeometry.viewportWidth + 0.5); expect(approvalGeometry.bottom).toBeLessThanOrEqual(approvalGeometry.viewportHeight + 0.5); - expect(approvalGeometry.width).toBeGreaterThanOrEqual(44); - expect(approvalGeometry.height).toBeGreaterThanOrEqual(44); + expect(approvalGeometry.width).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET_PX); + expect(approvalGeometry.height).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET_PX); await approve.click(); await expect(page.getByText("Approval needed", { exact: true })).toBeHidden(); await fixture.advanceBy(30); @@ -818,8 +820,8 @@ test("manages a session from a phone and converges another live client", async ( const initialActions = rail.getByRole("button", { name: `Actions for ${SESSION_TITLE}` }); const initialActionsBox = await initialActions.boundingBox(); expect(initialActionsBox).not.toBeNull(); - expect(initialActionsBox!.width).toBeGreaterThanOrEqual(44); - expect(initialActionsBox!.height).toBeGreaterThanOrEqual(44); + expect(initialActionsBox!.width).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET_PX); + expect(initialActionsBox!.height).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET_PX); await initialActions.click(); const renameAction = page.getByRole("button", { name: "Rename", exact: true }); await expect(renameAction).toBeVisible(); @@ -862,7 +864,7 @@ test("manages a session from a phone and converges another live client", async ( const archivedFilter = rail.getByRole("button", { name: "Archived · 1", exact: true }); const archivedFilterBox = await archivedFilter.boundingBox(); expect(archivedFilterBox).not.toBeNull(); - expect(archivedFilterBox!.height).toBeGreaterThanOrEqual(44); + expect(archivedFilterBox!.height).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET_PX); await expect(observer.getByText(/Archived · read-only/u).first()).toBeVisible(); await expect(observer.getByRole("textbox", { name: "Message the session" })).toHaveCount(0); diff --git a/e2e/site-mobile-docs.spec.ts b/e2e/site-mobile-docs.spec.ts index 3ddce806..3942bb17 100644 --- a/e2e/site-mobile-docs.spec.ts +++ b/e2e/site-mobile-docs.spec.ts @@ -129,7 +129,7 @@ test("offers the Android APK without hiding desktop downloads", async ({ page }) await expect(androidDownload).toBeVisible(); await expect(androidDownload).toHaveAttribute( "href", - "https://github.com/LycaonLLC/t4-code/releases/download/v0.1.11/T4-Code-0.1.11-android.apk", + "https://github.com/LycaonLLC/t4-code/releases/download/v0.1.12/T4-Code-0.1.12-android.apk", ); await expect(page.getByRole("link", { name: "Download for Linux" }).first()).toBeVisible(); await expect(page.getByRole("link", { name: "macOS build" }).first()).toBeVisible(); diff --git a/package.json b/package.json index 886d0719..b00a3232 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/root", - "version": "0.1.11", + "version": "0.1.12", "private": true, "type": "module", "scripts": { diff --git a/packages/client/package.json b/packages/client/package.json index 4f6b8c9e..e4c85be3 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/client", - "version": "0.1.11", + "version": "0.1.12", "private": true, "type": "module", "exports": { diff --git a/packages/client/src/omp-client-connection.ts b/packages/client/src/omp-client-connection.ts index 2654c856..be8d7edf 100644 --- a/packages/client/src/omp-client-connection.ts +++ b/packages/client/src/omp-client-connection.ts @@ -81,8 +81,8 @@ export class OmpClientConnection { }), ]; this.callbacks.connected(transport, generation); - }).catch(() => { - if (generation === this.generationValue) this.callbacks.close(undefined, "transport unavailable"); + }).catch((error: unknown) => { + if (generation === this.generationValue) this.callbacks.error(error); }); } diff --git a/packages/client/src/omp-client-contracts.ts b/packages/client/src/omp-client-contracts.ts index d87750b9..53ce15cf 100644 --- a/packages/client/src/omp-client-contracts.ts +++ b/packages/client/src/omp-client-contracts.ts @@ -63,7 +63,10 @@ export interface OmpClientOptions { transport: OmpTransportFactory; hostId?: string; expectedHostId?: string; client?: { name: string; version: string; build: string; platform: string }; - requestedFeatures?: readonly string[]; requiredFeatures?: readonly string[]; capabilities?: readonly string[]; + requestedFeatures?: readonly string[]; + /** One-shot fallback for hosts that reject a hello containing newer additive features. */ + compatibilityRequestedFeatures?: readonly string[]; + requiredFeatures?: readonly string[]; capabilities?: readonly string[]; authentication?: () => { deviceId: string; deviceToken: string } | undefined; cursorStore?: CursorStore; projection?: ProjectionStore; clock?: Clock; timers?: TimerScheduler; ids?: IdFactory; random?: () => number; reconnect?: { baseMs?: number; maxMs?: number; attemptCap?: number }; diff --git a/packages/client/src/omp-client-frames.ts b/packages/client/src/omp-client-frames.ts index 08c68767..47e0ca04 100644 --- a/packages/client/src/omp-client-frames.ts +++ b/packages/client/src/omp-client-frames.ts @@ -66,7 +66,7 @@ export function sendClientHello( v: PROTOCOL_VERSION, type: "hello", protocol: { min: PROTOCOL_VERSION, max: PROTOCOL_VERSION }, - client: options.client ?? { name: "t4-code", version: "0.1.11", build: "client", platform: "electron" }, + client: options.client ?? { name: "t4-code", version: "0.1.12", build: "client", platform: "electron" }, requestedFeatures: [...(options.requestedFeatures ?? ["resume"])], savedCursors, ...(options.capabilities === undefined ? {} : { capabilities: { client: [...options.capabilities] } }), diff --git a/packages/client/src/omp-client-runtime.ts b/packages/client/src/omp-client-runtime.ts index f9cc8b19..477fd695 100644 --- a/packages/client/src/omp-client-runtime.ts +++ b/packages/client/src/omp-client-runtime.ts @@ -98,6 +98,7 @@ export class OmpClient { private authenticationValue: "local" | "pairing-required" | "paired" | undefined; private granted = new Set(); private closedByUser = false; + private compatibilityFallbackUsed = false; private connectWaiters: ConnectWaiter[] = []; constructor(options: OmpClientOptions) { @@ -214,7 +215,13 @@ export class OmpClient { if (isTerminalState(this.stateValue)) throw this.error("closed", "client is closed"); const ready = new Promise((resolve, reject) => this.connectWaiters.push({ resolve, reject })); this.closedByUser = false; - if (this.stateValue === "idle") this.connection.begin(); + if (this.stateValue === "idle") { + // The transport factory may itself await a WebSocket/Unix-socket open. + // Publish connecting before that await so failures can legally enter + // reconnect-wait/fatal and concurrent connect() calls share one attempt. + this.transition("connecting"); + this.connection.begin(); + } return ready; } @@ -355,8 +362,15 @@ export class OmpClient { } } private sendHello(): void { + const helloOptions = + this.compatibilityFallbackUsed && this.options.compatibilityRequestedFeatures !== undefined + ? { + ...this.options, + requestedFeatures: this.options.compatibilityRequestedFeatures, + } + : this.options; sendClientHello( - this.options, + helloOptions, [...this.cursorJournal.records.values()], (encoded) => this.connection.send(encoded), (input) => decodeOutgoingFrame(input), @@ -506,8 +520,28 @@ export class OmpClient { if (pending?.kind === "pair") this.pendingRequests.settle(id, undefined, this.error("auth", "pairing request failed", false, { code: frame.code })); } - private handleDisconnect(_code?: number, _reason?: string): void { + private handleDisconnect(code?: number, reason?: string): void { if (this.closedByUser || isTerminalState(this.stateValue)) return; + const helloRejected = + this.stateValue === "handshaking" && code === 1008 && reason?.trim() === "invalid frame"; + if (helloRejected) { + if ( + !this.compatibilityFallbackUsed && + this.options.compatibilityRequestedFeatures !== undefined + ) { + this.compatibilityFallbackUsed = true; + this.emitError( + this.error( + "protocol", + "Host uses an earlier feature set; reconnecting in compatibility mode.", + true, + ), + ); + } else { + this.fatal(this.error("protocol", "Host rejected the protocol hello.")); + return; + } + } this.clearTimer("handshakeTimer"); this.heartbeatNonce = undefined; this.reconnectHealth.clear(); diff --git a/packages/client/test/client-reconnect.test.ts b/packages/client/test/client-reconnect.test.ts index 5147b65f..9edb722c 100644 --- a/packages/client/test/client-reconnect.test.ts +++ b/packages/client/test/client-reconnect.test.ts @@ -104,7 +104,7 @@ class FakeTransport implements OmpTransport { lastClientFrame(): ClientFrame { return decodeClientFrame(this.sent[this.sent.length - 1]!); } } -function responseFor(command: CommandFrame, result: unknown = {}): ServerFrame { +function responseFor(command: CommandFrame, result: Record = {}): ServerFrame { return { v: V, type: "response", @@ -112,8 +112,14 @@ function responseFor(command: CommandFrame, result: unknown = {}): ServerFrame { commandId: command.commandId, hostId: command.hostId, ...(command.sessionId === undefined ? {} : { sessionId: command.sessionId }), + command: command.command, ok: true, - result, + result: { + ...(command.command === "session.attach" + ? { attached: true, cursor: { epoch: "epoch-a", seq: 0 } } + : {}), + ...result, + }, }; } function snapshot(seq = 0, session = SESSION): ServerFrame { diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts index eef2b57b..7e664dd5 100644 --- a/packages/client/test/client.test.ts +++ b/packages/client/test/client.test.ts @@ -144,8 +144,37 @@ class DeferredStore implements CursorStore { } } -function responseFor(command: CommandFrame, result: unknown = {}): ServerFrame { - return { v: V, type: "response", requestId: command.requestId, commandId: command.commandId, hostId: command.hostId, ...(command.sessionId === undefined ? {} : { sessionId: command.sessionId }), ok: true, result }; +function defaultResultFor(command: CommandFrame): Record { + if (command.command === "host.list" || command.command === "session.list") { + return { + cursor: { epoch: "epoch-a", seq: 0 }, + sessions: [], + totalCount: 0, + truncated: false, + }; + } + if (command.command === "session.attach") { + return { attached: true, cursor: { epoch: "epoch-a", seq: 0 } }; + } + if (command.command === "session.cancel") return { cancelled: true }; + return {}; +} +function responseFor(command: CommandFrame, result: Record = {}): ServerFrame { + return { + v: V, + type: "response", + requestId: command.requestId, + commandId: command.commandId, + hostId: command.hostId, + ...(command.sessionId === undefined ? {} : { sessionId: command.sessionId }), + command: command.command, + ok: true, + result: { ...defaultResultFor(command), ...result }, + }; +} + +async function flushMicrotasks(turns = 12): Promise { + for (let turn = 0; turn < turns; turn += 1) await Promise.resolve(); } function confirmationFor(command: CommandFrame): ServerFrame { return { @@ -202,8 +231,8 @@ describe("OmpClient protocol state machine", () => { const commands = transport.sent.slice(-2).map((raw) => decodeClientFrame(raw)).filter((frame): frame is CommandFrame => frame.type === "command"); transport.emit(responseFor(commands[1]!, { n: 2 })); transport.emit(responseFor(commands[0]!, { n: 1 })); - expect((await first).result).toEqual({ n: 1 }); - expect((await second).result).toEqual({ n: 2 }); + expect((await first).result).toMatchObject({ n: 1 }); + expect((await second).result).toMatchObject({ n: 2 }); await client.close(); }); it.each(["approve", "deny"] as const)( @@ -394,6 +423,88 @@ describe("OmpClient protocol state machine", () => { await capabilityClient.close(); }); + it("retries one rejected hello with the configured compatibility feature set", async () => { + const clock = new FakeClock(); + const hellos: string[][] = []; + const first = new FakeTransport({ + onSend: (frame, transport) => { + if (frame.type !== "hello") return; + hellos.push([...frame.requestedFeatures]); + transport.drop(1008, "invalid frame"); + }, + }); + const second = new FakeTransport({ + welcome: welcome(), + onSend: (frame) => { + if (frame.type === "hello") hellos.push([...frame.requestedFeatures]); + }, + }); + const transports = [first, second]; + const errors: string[] = []; + const client = new OmpClient({ + transport: () => transports.shift() ?? new FakeTransport(), + hostId: HOST, + timers: clock, + clock, + random: () => 0, + reconnect: { baseMs: 2, maxMs: 2, attemptCap: 2 }, + requestedFeatures: ["resume", "prompt.images", "transcript.images"], + compatibilityRequestedFeatures: ["resume"], + }); + client.onError((error) => errors.push(error.message)); + + const connecting = client.connect(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(client.state).toBe("reconnect-wait"); + clock.advanceBy(1); + await connecting; + + expect(hellos).toEqual([ + ["resume", "prompt.images", "transcript.images"], + ["resume"], + ]); + expect(errors).toContain( + "Host uses an earlier feature set; reconnecting in compatibility mode.", + ); + expect(client.state).toBe("ready"); + await client.close(); + }); + + it("fails promptly when a host also rejects the compatibility hello", async () => { + const clock = new FakeClock(); + let attempts = 0; + const client = new OmpClient({ + transport: () => { + attempts += 1; + return new FakeTransport({ + onSend: (frame, transport) => { + if (frame.type === "hello") transport.drop(1008, "invalid frame"); + }, + }); + }, + hostId: HOST, + timers: clock, + clock, + random: () => 0, + reconnect: { baseMs: 2, maxMs: 2, attemptCap: 8 }, + requestedFeatures: ["resume", "prompt.images", "transcript.images"], + compatibilityRequestedFeatures: ["resume"], + }); + + const connecting = client.connect(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + clock.advanceBy(1); + + await expect(connecting).rejects.toMatchObject({ code: "protocol" }); + expect(attempts).toBe(2); + expect(client.state).toBe("fatal"); + expect(client.resources().timers).toBe(0); + }); + it("reports bounded decoder diagnostics without echoing frame values", async () => { const transport = new FakeTransport({ welcome: welcome() }); const client = await readyClient(transport); @@ -712,9 +823,50 @@ describe("OmpClient protocol state machine", () => { await client.close(); await expect(first).rejects.toMatchObject({ code: "closed" }); const startup = new OmpClient({ transport: async () => { throw new Error("startup"); }, hostId: HOST, reconnect: { attemptCap: 0 } }); + const startupErrors: string[] = []; + const stopStartupErrors = startup.onError((error) => startupErrors.push(error.message)); await expect(startup.connect()).rejects.toMatchObject({ code: "transport" }); + expect(startupErrors).toContain("transport error"); + expect(startup.state).toBe("fatal"); + stopStartupErrors(); expect(startup.resources()).toEqual({ timers: 0, socket: false, socketHandlers: 0, pending: 0, cursorSaves: 0, listeners: 0 }); }); + + it("shares a pending transport open and recovers when the next factory succeeds", async () => { + const clock = new FakeClock(); + let releaseFirst!: () => void; + const first = new Promise((_resolve, reject) => { + releaseFirst = () => reject(new Error("open timed out")); + }); + const second = new FakeTransport({ welcome: welcome() }); + let factoryCalls = 0; + const client = new OmpClient({ + transport: () => { + factoryCalls += 1; + return factoryCalls === 1 ? first : second; + }, + hostId: HOST, + timers: clock, + clock, + random: () => 0, + reconnect: { baseMs: 2, maxMs: 2, attemptCap: 2 }, + }); + + const firstConnect = client.connect(); + const secondConnect = client.connect(); + await flushMicrotasks(); + expect(client.state).toBe("connecting"); + expect(factoryCalls).toBe(1); + + releaseFirst(); + await flushMicrotasks(); + expect(client.state).toBe("reconnect-wait"); + clock.advanceBy(1); + await Promise.all([firstConnect, secondConnect]); + expect(factoryCalls).toBe(2); + expect(client.state).toBe("ready"); + await client.close(); + }); }); describe("OmpClient live fixture websocket", () => { diff --git a/packages/fixture-server/package.json b/packages/fixture-server/package.json index 66d48e90..052abc0f 100644 --- a/packages/fixture-server/package.json +++ b/packages/fixture-server/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/fixture-server", - "version": "0.1.11", + "version": "0.1.12", "private": true, "type": "module", "exports": { diff --git a/packages/protocol/package.json b/packages/protocol/package.json index b61419b4..62ca1572 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,20 +1,20 @@ { "name": "@t4-code/protocol", - "version": "0.1.11", + "version": "0.1.12", "private": true, "type": "module", "exports": { ".": "./src/index.ts", "./desktop-ipc": "./src/desktop-ipc.ts" }, - "dependencies": { - "@oh-my-pi/app-wire": "file:../../vendor/app-wire/oh-my-pi-app-wire-0.5.3.tgz" - }, "scripts": { "build": "tsgo --noEmit", "typecheck": "tsgo --noEmit", "test": "vp test run --passWithNoTests" }, + "dependencies": { + "@oh-my-pi/app-wire": "file:../../vendor/app-wire/oh-my-pi-app-wire-0.5.5.tgz" + }, "devDependencies": { "@types/node": "catalog:", "vite-plus": "catalog:" diff --git a/packages/protocol/test/distribution.test.ts b/packages/protocol/test/distribution.test.ts index 0a8fd441..4779e59b 100644 --- a/packages/protocol/test/distribution.test.ts +++ b/packages/protocol/test/distribution.test.ts @@ -28,18 +28,75 @@ const expectedTarEntries = [ "package/package.json", "package/LICENSE", "package/README.md", + "package/CHANGELOG.md", ...[ - "agent-progress", "agent", "audit-event", "audit-host", "audit", "bye", "catalog", "command", - "confirmation-challenge", "confirmation", "entry-frame", "entry", "error", "event", "files-diff", - "files", "gap", "hello-auth-bad.invalid", "hello-auth-partial.invalid", "hello-auth", "hello", "host-list", "host-watch", "pair-start", "pairing", "ping", "pong", - "preview-capture", "prompt-lease", "response", "restart", "review", "session-delta", "session-secret.invalid", "sessions", - "snapshot", "terminal-output", "terminal", "welcome", - ].map(name => `package/fixtures/v1/${name}.json`), + "agent-progress", + "agent", + "audit-event", + "audit-host", + "audit", + "bye", + "catalog", + "command", + "confirmation-challenge", + "confirmation", + "entry-frame", + "entry", + "error", + "event", + "files-diff", + "files", + "gap", + "hello-auth-bad.invalid", + "hello-auth-partial.invalid", + "hello-auth", + "hello", + "host-list", + "host-watch", + "pair-start", + "pairing", + "ping", + "pong", + "preview-capture", + "prompt-lease", + "response", + "restart", + "review", + "session-delta", + "session-secret.invalid", + "sessions", + "snapshot", + "terminal-output", + "terminal", + "welcome", + ].map((name) => `package/fixtures/v1/${name}.json`), ...[ - "additive", "agents", "audit", "capabilities", "command", "cursor", "entry", "envelope", "errors", - "event", "files-review", "gap", "guards", "heartbeat", "hello", "ids", "index", "limits", - "pairing-confirm", "result", "session-index", "session-state", "snapshot", "terminal", "user-terminals", - ].map(name => `package/src/${name}.ts`), + "additive", + "agents", + "audit", + "capabilities", + "command", + "cursor", + "entry", + "envelope", + "errors", + "event", + "files-review", + "gap", + "guards", + "heartbeat", + "hello", + "ids", + "index", + "limits", + "pairing-confirm", + "result", + "session-index", + "session-state", + "snapshot", + "terminal", + "user-terminals", + ].map((name) => `package/src/${name}.ts`), ].sort(); function sha256(path: string): string { @@ -72,33 +129,45 @@ describe("vendored app-wire distribution", () => { it("pins the frozen source, protocol, corpus, and tarball checksums", () => { expect(manifest).toMatchObject({ package: "@oh-my-pi/app-wire", - version: "0.5.3", + version: "0.5.5", sourceRepository: "https://github.com/lyc-aon/oh-my-pi", - sourceCommit: "15527d1f00bac22705f63f80b29c0c30e67fc5da", - sourceTreeHash: "4961ea9c522a3bbf9a9900424dd475a48148c729", - tarball: "oh-my-pi-app-wire-0.5.3.tgz", + sourceCommit: "6a87fa6407ebff20417b4d52885a6bb3091003ea", + sourceTreeHash: "a2495fe8781c979184fe7fb9a6d37d8f33bad30f", + tarball: "oh-my-pi-app-wire-0.5.5.tgz", appProtocol: "omp-app/1", - goldenCorpusSha256: "36811f39241c6c491c967a8f969f14c43431366289750538a40893d0dc267324", + goldenCorpusSha256: "e92d3d7a4848ab6ea6403cc1c1faa6912f8fdc75d2a6abf663ece0154a6eb7fa", }); expect(manifest.createdAt).toMatch(/^2026-07-14T\d{2}:\d{2}:\d{2}Z$/u); expect(sha256(tarballPath)).toBe(manifest.tarballSha256); - expect(goldenCorpusSha256(join(installedRoot, "fixtures", "v1"))).toBe(manifest.goldenCorpusSha256); - const installedPackage = JSON.parse(readFileSync(join(installedRoot, "package.json"), "utf8")) as Record; + expect(goldenCorpusSha256(join(installedRoot, "fixtures", "v1"))).toBe( + manifest.goldenCorpusSha256, + ); + const installedPackage = JSON.parse( + readFileSync(join(installedRoot, "package.json"), "utf8"), + ) as Record; expect(installedPackage.name).toBe(manifest.package); expect(installedPackage.version).toBe(manifest.version); expect(installedPackage.dependencies ?? {}).toEqual({}); }); it("keeps the packed surface exact and dependency paths portable", () => { - const entries = execFileSync("tar", ["-tzf", tarballPath], { encoding: "utf8" }).trim().split("\n").sort(); + const entries = execFileSync("tar", ["-tzf", tarballPath], { encoding: "utf8" }) + .trim() + .split("\n") + .sort(); expect(entries).toEqual(expectedTarEntries); - expect(entries).toHaveLength(67); + expect(entries).toHaveLength(68); - const protocolPackage = readFileSync(join(repoRoot, "packages", "protocol", "package.json"), "utf8"); + const protocolPackage = readFileSync( + join(repoRoot, "packages", "protocol", "package.json"), + "utf8", + ); const lockfile = readFileSync(join(repoRoot, "pnpm-lock.yaml"), "utf8"); expect(`${protocolPackage}\n${lockfile}`).not.toContain("/home/"); - expect(protocolPackage).toMatch(/"@oh-my-pi\/app-wire": "file:\.\.\/\.\.\/vendor\/app-wire\/oh-my-pi-app-wire-0\.5\.3\.tgz"/u); - expect(lockfile).toMatch(/version: file:vendor\/app-wire\/oh-my-pi-app-wire-0\.5\.3\.tgz/u); + expect(protocolPackage).toMatch( + /"@oh-my-pi\/app-wire": "file:\.\.\/\.\.\/vendor\/app-wire\/oh-my-pi-app-wire-0\.5\.5\.tgz"/u, + ); + expect(lockfile).toMatch(/version: file:vendor\/app-wire\/oh-my-pi-app-wire-0\.5\.5\.tgz/u); expect(`${protocolPackage}\n${lockfile}`).not.toMatch(/file:\/\//u); }); }); diff --git a/packages/remote/package.json b/packages/remote/package.json index 74c897f0..cf4f50d2 100644 --- a/packages/remote/package.json +++ b/packages/remote/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/remote", - "version": "0.1.11", + "version": "0.1.12", "private": true, "type": "module", "exports": { diff --git a/packages/service-manager/package.json b/packages/service-manager/package.json index 45cfd62e..85c3a2f3 100644 --- a/packages/service-manager/package.json +++ b/packages/service-manager/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/service-manager", - "version": "0.1.11", + "version": "0.1.12", "private": true, "type": "module", "exports": { diff --git a/packages/ui/package.json b/packages/ui/package.json index d555411c..eba49921 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/ui", - "version": "0.1.11", + "version": "0.1.12", "private": true, "type": "module", "exports": { diff --git a/packages/ui/src/motion/useReducedMotion.ts b/packages/ui/src/motion/useReducedMotion.ts index cf91ee03..9af4c892 100644 --- a/packages/ui/src/motion/useReducedMotion.ts +++ b/packages/ui/src/motion/useReducedMotion.ts @@ -21,8 +21,13 @@ export function resolveReducedMotion( // across renders. function subscribe(onChange: () => void): () => void { const mediaQuery = window.matchMedia(REDUCED_MOTION_QUERY); - mediaQuery.addEventListener("change", onChange); - return () => mediaQuery.removeEventListener("change", onChange); + if (typeof mediaQuery.addEventListener === "function") { + mediaQuery.addEventListener("change", onChange); + return () => mediaQuery.removeEventListener("change", onChange); + } + // Android System WebView before MediaQueryList EventTarget support. + mediaQuery.addListener(onChange); + return () => mediaQuery.removeListener(onChange); } function getSnapshot(): boolean { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed75f368..f762d058 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -291,8 +291,8 @@ importers: packages/protocol: dependencies: '@oh-my-pi/app-wire': - specifier: file:../../vendor/app-wire/oh-my-pi-app-wire-0.5.3.tgz - version: file:vendor/app-wire/oh-my-pi-app-wire-0.5.3.tgz + specifier: file:../../vendor/app-wire/oh-my-pi-app-wire-0.5.5.tgz + version: file:vendor/app-wire/oh-my-pi-app-wire-0.5.5.tgz devDependencies: '@types/node': specifier: 'catalog:' @@ -638,9 +638,9 @@ packages: resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} - '@oh-my-pi/app-wire@file:vendor/app-wire/oh-my-pi-app-wire-0.5.3.tgz': - resolution: {integrity: sha512-gYmwAFDw4TVtiR3lRzpmbTA8Uk+YuVDbv100HUjDUNVnnDOI3cXxYfKVv6HJneKmflOux1Yu4s7N97fsCwBdMA==, tarball: file:vendor/app-wire/oh-my-pi-app-wire-0.5.3.tgz} - version: 0.5.3 + '@oh-my-pi/app-wire@file:vendor/app-wire/oh-my-pi-app-wire-0.5.5.tgz': + resolution: {integrity: sha512-1fNE37IRSz3Mp583YlF4Su0mT8xUE8UzAHUTRZB85ahvrmMdPnO8ZBQsaocOQN3A9Yk+bqEKTvP7ow56IvlisA==, tarball: file:vendor/app-wire/oh-my-pi-app-wire-0.5.5.tgz} + version: 0.5.5 engines: {bun: '>=1.3.14'} '@oxc-project/runtime@0.138.0': @@ -3737,7 +3737,7 @@ snapshots: '@noble/hashes@2.2.0': {} - '@oh-my-pi/app-wire@file:vendor/app-wire/oh-my-pi-app-wire-0.5.3.tgz': {} + '@oh-my-pi/app-wire@file:vendor/app-wire/oh-my-pi-app-wire-0.5.5.tgz': {} '@oxc-project/runtime@0.138.0': {} diff --git a/provenance/t3code/imports/f2-transcript-20260711.json b/provenance/t3code/imports/f2-transcript-20260711.json index f5300f4a..8da9722d 100644 --- a/provenance/t3code/imports/f2-transcript-20260711.json +++ b/provenance/t3code/imports/f2-transcript-20260711.json @@ -9,21 +9,21 @@ "sourceBlobSha": "1a4dc6b689558a9f8d834c12d7ecb2bb64e0d7c8", "targetPath": "apps/web/src/features/transcript/TranscriptTimeline.tsx", "classification": "adapted", - "checksum": "sha256:e47a30af1647a6a47a163d2185acfeec06ef947491a8e31c93a7746ae35f5f07" + "checksum": "sha256:7354db68084ca22e3dfbcd46cefceca343a6b86f0a6907e6c37d2a919102d1f8" }, { "sourcePath": "apps/web/src/components/chat/MessagesTimeline.tsx", "sourceBlobSha": "1a4dc6b689558a9f8d834c12d7ecb2bb64e0d7c8", "targetPath": "apps/web/src/features/transcript/rows.ts", "classification": "adapted", - "checksum": "sha256:47759dd83be3db8bf4d4aa7b18709260438577cc345a63c71f6f2f5b87b6ee62" + "checksum": "sha256:3514fcffdbb8f67b901e157c997eeb9a8efb5f1e0b527b87428eec6041a521b4" }, { "sourcePath": "apps/web/src/components/chat/MessagesTimeline.tsx", "sourceBlobSha": "1a4dc6b689558a9f8d834c12d7ecb2bb64e0d7c8", "targetPath": "apps/web/src/features/transcript/TranscriptRows.tsx", "classification": "adapted", - "checksum": "sha256:fd4c62b3bd5d00e28942512337197b0ec62d881188e018c370510da0e607f4bc" + "checksum": "sha256:81f9ad15c5dc90dfd09bc8791ae1e08b10d9b02d3bac8a083b73ee8531ad10b0" }, { "sourcePath": "packages/shared/src/searchRanking.ts;apps/web/src/components/chat/composerSlashCommandSearch.ts", @@ -44,7 +44,7 @@ "sourceBlobSha": "fdb8bfe7b183480b7b71f2213d907cc211be0e64", "targetPath": "apps/web/src/features/composer/composer-store.ts", "classification": "adapted", - "checksum": "sha256:35fdbcbe350ae97a7f4cf105ed413fb17bea6615c8988a3f375210fdd860798a" + "checksum": "sha256:10797b73bb749811230ad227c0c1d3414edfe150207690e94220db93046ad2fc" } ] } diff --git a/scripts/check-release-consistency.test.mjs b/scripts/check-release-consistency.test.mjs index 659f7f36..7b5e6fbd 100644 --- a/scripts/check-release-consistency.test.mjs +++ b/scripts/check-release-consistency.test.mjs @@ -17,28 +17,28 @@ function changed(path, replace) { } test("current source tree has one consistent release version", () => { - assert.deepEqual(collectReleaseConsistencyErrors(files, "v0.1.11"), []); + assert.deepEqual(collectReleaseConsistencyErrors(files, "v0.1.12"), []); }); test("rejects a tag that differs from the package version", () => { assert.ok( collectReleaseConsistencyErrors(files, "v9.9.9").some((error) => - error.includes("release tag v9.9.9 does not match v0.1.11"), + error.includes("release tag v9.9.9 does not match v0.1.12"), ), ); }); test("rejects workspace, site, README, and runtime version drift", () => { const cases = [ - ["apps/web/package.json", (text) => text.replace('"version": "0.1.11"', '"version": "0.1.3"')], + ["apps/web/package.json", (text) => text.replace('"version": "0.1.12"', '"version": "0.1.3"')], [ "apps/site/src/release.ts", - (text) => text.replace('RELEASE_TAG = "v0.1.11"', 'RELEASE_TAG = "v0.1.3"'), + (text) => text.replace('RELEASE_TAG = "v0.1.12"', 'RELEASE_TAG = "v0.1.3"'), ], - ["README.md", (text) => text.replace("Download v0.1.11", "Download v0.1.3")], + ["README.md", (text) => text.replace("Download v0.1.12", "Download v0.1.3")], [ "apps/desktop/src/target-manager.ts", - (text) => text.replace('version: "0.1.11"', 'version: "0.1.3"'), + (text) => text.replace('version: "0.1.12"', 'version: "0.1.3"'), ], [ "apps/site/src/docs/content.ts", @@ -68,7 +68,7 @@ test("rejects version drift in a newly added workspace package", () => { test("rejects app-wire matrix changes until the release surfaces agree", () => { const drifted = changed("compat/omp-app-matrix.json", (text) => - text.replace('"version": "0.5.3"', '"version": "0.5.1"'), + text.replace('"version": "0.5.5"', '"version": "0.5.1"'), ); assert.ok(collectReleaseConsistencyErrors(drifted).length > 0); }); @@ -76,7 +76,7 @@ test("rejects app-wire matrix changes until the release surfaces agree", () => { test("rejects app-wire provenance changes until the release surfaces agree", () => { const drifted = changed("compat/omp-app-matrix.json", (text) => text.replace( - '"sourceCommit": "15527d1f00bac22705f63f80b29c0c30e67fc5da"', + '"sourceCommit": "6a87fa6407ebff20417b4d52885a6bb3091003ea"', '"sourceCommit": "0000000000000000000000000000000000000000"', ), ); @@ -90,7 +90,7 @@ test("rejects app-wire provenance changes until the release surfaces agree", () test("rejects drift between the compatibility matrix and vendored app-wire manifest", () => { const drifted = changed("vendor/app-wire/manifest.json", (text) => text.replace( - '"sourceTreeHash": "4961ea9c522a3bbf9a9900424dd475a48148c729"', + '"sourceTreeHash": "a2495fe8781c979184fe7fb9a6d37d8f33bad30f"', '"sourceTreeHash": "0000000000000000000000000000000000000000"', ), ); @@ -105,10 +105,10 @@ test("rejects drift in verified OMP runtime provenance", () => { const cases = [ (text) => text.replace( - "15527d1f00bac22705f63f80b29c0c30e67fc5da", + "6a87fa6407ebff20417b4d52885a6bb3091003ea", "0000000000000000000000000000000000000000", ), - (text) => text.replace('"sourceTag": "t4code-16.5.1-appserver-1"', '"sourceTag": "wrong-tag"'), + (text) => text.replace('"sourceTag": "t4code-16.5.1-appserver-4"', '"sourceTag": "wrong-tag"'), (text) => text.replace( '"upstreamCommit": "14b5da76a9aece9a469288718d22c3d624daf033"', @@ -133,31 +133,31 @@ test("accepts a coordinated app-wire provenance update without editing the workf "compat/omp-app-matrix.json", coordinated .get("compat/omp-app-matrix.json") - .replace('"version": "0.5.3"', '"version": "0.5.4"') - .replace("oh-my-pi-app-wire-0.5.3.tgz", "oh-my-pi-app-wire-0.5.4.tgz"), + .replace('"version": "0.5.5"', '"version": "0.5.6"') + .replace("oh-my-pi-app-wire-0.5.5.tgz", "oh-my-pi-app-wire-0.5.6.tgz"), ); coordinated.set( "apps/site/src/release.ts", coordinated .get("apps/site/src/release.ts") - .replace('APP_WIRE_VERSION = "0.5.3"', 'APP_WIRE_VERSION = "0.5.4"'), + .replace('APP_WIRE_VERSION = "0.5.5"', 'APP_WIRE_VERSION = "0.5.6"'), ); coordinated.set( "README.md", coordinated .get("README.md") - .replace("`@oh-my-pi/app-wire` 0.5.3", "`@oh-my-pi/app-wire` 0.5.4"), + .replace("`@oh-my-pi/app-wire` 0.5.5", "`@oh-my-pi/app-wire` 0.5.6"), ); coordinated.set( "docs/CURRENT_RELEASE_NOTES.md", - coordinated.get("docs/CURRENT_RELEASE_NOTES.md").replace("app-wire 0.5.3", "app-wire 0.5.4"), + coordinated.get("docs/CURRENT_RELEASE_NOTES.md").replace("app-wire 0.5.5", "app-wire 0.5.6"), ); coordinated.set( "vendor/app-wire/manifest.json", coordinated .get("vendor/app-wire/manifest.json") - .replace('"version": "0.5.3"', '"version": "0.5.4"') - .replace("oh-my-pi-app-wire-0.5.3.tgz", "oh-my-pi-app-wire-0.5.4.tgz"), + .replace('"version": "0.5.5"', '"version": "0.5.6"') + .replace("oh-my-pi-app-wire-0.5.5.tgz", "oh-my-pi-app-wire-0.5.6.tgz"), ); assert.deepEqual(collectReleaseConsistencyErrors(coordinated), []); @@ -173,7 +173,7 @@ test("rejects stale README release URLs while allowing historical prose", () => const staleLink = changed("README.md", (text) => `${text}\n[Old release](${oldReleaseUrl})\n`); assert.ok( collectReleaseConsistencyErrors(staleLink).some((error) => - error.includes("release URL for v0.1.3; expected v0.1.11"), + error.includes("release URL for v0.1.3; expected v0.1.12"), ), ); assert.deepEqual(collectReleaseConsistencyErrors(files), []); diff --git a/scripts/inspect-macos-dmg.test.mjs b/scripts/inspect-macos-dmg.test.mjs index d62c7310..4051b227 100644 --- a/scripts/inspect-macos-dmg.test.mjs +++ b/scripts/inspect-macos-dmg.test.mjs @@ -5,7 +5,7 @@ import { resolve } from "node:path"; import { findMountedApp, inspectMacosDmg } from "./inspect-macos-dmg.mjs"; -const dmgPath = "/tmp/T4-Code-0.1.11-mac-arm64.dmg"; +const dmgPath = "/tmp/T4-Code-0.1.12-mac-arm64.dmg"; const mountPoint = "/tmp/t4-code-dmg-test"; function directory(name) { diff --git a/scripts/tailnet-gateway.mjs b/scripts/tailnet-gateway.mjs index bec56e99..b311012c 100644 --- a/scripts/tailnet-gateway.mjs +++ b/scripts/tailnet-gateway.mjs @@ -141,7 +141,7 @@ function gatewayCsp(allowedOrigin) { "script-src 'self'", "style-src 'self' 'unsafe-inline'", "font-src 'self' data:", - "img-src 'self' data:", + "img-src 'self' data: blob:", `connect-src 'self' ${websocketOrigin}`, "object-src 'none'", "base-uri 'none'", diff --git a/scripts/tailnet-gateway.test.mjs b/scripts/tailnet-gateway.test.mjs index b43afa52..ac01fddf 100644 --- a/scripts/tailnet-gateway.test.mjs +++ b/scripts/tailnet-gateway.test.mjs @@ -174,6 +174,7 @@ test("gateway serves configured app and reports real upstream health", async () contentSecurityPolicy ?? "", /connect-src 'self' wss:\/\/host\.example-tailnet\.ts\.net:8445/u, ); + assert.match(contentSecurityPolicy ?? "", /img-src 'self' data: blob:/u); assert.doesNotMatch(contentSecurityPolicy ?? "", /\*/u); const assetResponse = await fetch(`${running.url}/app.js`); diff --git a/scripts/wait-for-release-assets.test.mjs b/scripts/wait-for-release-assets.test.mjs index 805a5593..d8cbb135 100644 --- a/scripts/wait-for-release-assets.test.mjs +++ b/scripts/wait-for-release-assets.test.mjs @@ -7,13 +7,13 @@ const quiet = { log() {} }; test("builds the five package URLs and checksum URL for one release tag", () => { assert.deepEqual( - releaseAssetUrls("0.1.11").map(({ filename }) => filename), + releaseAssetUrls("0.1.12").map(({ filename }) => filename), [ - "T4-Code-0.1.11-android.apk", - "T4-Code-0.1.11-linux-amd64.deb", - "T4-Code-0.1.11-linux-x86_64.AppImage", - "T4-Code-0.1.11-mac-arm64.dmg", - "T4-Code-0.1.11-mac-arm64.zip", + "T4-Code-0.1.12-android.apk", + "T4-Code-0.1.12-linux-amd64.deb", + "T4-Code-0.1.12-linux-x86_64.AppImage", + "T4-Code-0.1.12-mac-arm64.dmg", + "T4-Code-0.1.12-mac-arm64.zip", "SHA256SUMS.txt", ], ); @@ -21,7 +21,7 @@ test("builds the five package URLs and checksum URL for one release tag", () => test("passes only when every public release file returns HTTP 200", async () => { const result = await waitForReleaseAssets({ - version: "0.1.11", + version: "0.1.12", fetchImpl: async () => ({ status: 200 }), logger: quiet, }); @@ -33,7 +33,7 @@ test("retries unavailable files and stays inside the configured timeout", async let currentTime = 0; let calls = 0; const result = await waitForReleaseAssets({ - version: "0.1.11", + version: "0.1.12", timeoutMs: 100, intervalMs: 20, requestTimeoutMs: 5, @@ -50,7 +50,7 @@ test("fails closed at the timeout and reports filenames without response content let currentTime = 0; await assert.rejects( waitForReleaseAssets({ - version: "0.1.11", + version: "0.1.12", timeoutMs: 40, intervalMs: 25, requestTimeoutMs: 5, diff --git a/vendor/app-wire/manifest.json b/vendor/app-wire/manifest.json index ac1dba1f..ac4addaf 100644 --- a/vendor/app-wire/manifest.json +++ b/vendor/app-wire/manifest.json @@ -1,12 +1,12 @@ { "package": "@oh-my-pi/app-wire", - "version": "0.5.3", + "version": "0.5.5", "sourceRepository": "https://github.com/lyc-aon/oh-my-pi", - "sourceCommit": "15527d1f00bac22705f63f80b29c0c30e67fc5da", - "sourceTreeHash": "4961ea9c522a3bbf9a9900424dd475a48148c729", - "tarball": "oh-my-pi-app-wire-0.5.3.tgz", - "tarballSha256": "5ae8833b963a6a56b65a9e2184358906a8717a6c8f57617875a91d4771791050", + "sourceCommit": "6a87fa6407ebff20417b4d52885a6bb3091003ea", + "sourceTreeHash": "a2495fe8781c979184fe7fb9a6d37d8f33bad30f", + "tarball": "oh-my-pi-app-wire-0.5.5.tgz", + "tarballSha256": "049d92d47c0c29a019c3844072b96198105116c452a54182163ef78994ef557e", "appProtocol": "omp-app/1", - "goldenCorpusSha256": "36811f39241c6c491c967a8f969f14c43431366289750538a40893d0dc267324", - "createdAt": "2026-07-14T18:47:14Z" + "goldenCorpusSha256": "e92d3d7a4848ab6ea6403cc1c1faa6912f8fdc75d2a6abf663ece0154a6eb7fa", + "createdAt": "2026-07-14T21:40:03Z" } diff --git a/vendor/app-wire/oh-my-pi-app-wire-0.5.3.tgz b/vendor/app-wire/oh-my-pi-app-wire-0.5.3.tgz deleted file mode 100644 index d251db61..00000000 Binary files a/vendor/app-wire/oh-my-pi-app-wire-0.5.3.tgz and /dev/null differ diff --git a/vendor/app-wire/oh-my-pi-app-wire-0.5.5.tgz b/vendor/app-wire/oh-my-pi-app-wire-0.5.5.tgz new file mode 100644 index 00000000..fc5500a1 Binary files /dev/null and b/vendor/app-wire/oh-my-pi-app-wire-0.5.5.tgz differ