diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 8056d08f4385d..96d83231613d9 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -478,7 +478,7 @@ jobs: shell: bash run: | sudo apt-get update - sudo apt-get install -y libsecret-1-dev pkg-config + sudo apt-get install -y libsecret-1-dev pkg-config libgtk-3-0t64 libnss3 libasound2t64 libgbm1 if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then sudo apt-get install -y imagemagick fi diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 0e72a46146e0a..9f8286a47ff98 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -54,6 +54,67 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens local editor file deep links", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const urls = [ + "cursor://file/Users/me/repo/file.md:186", + "vscode://file/Users/me/repo/file.md:3:7", + "vscode-insiders://file/Users/me/repo/file.md", + "vscodium://file/C:/Users/me/repo/file.md:3", + ]; + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.forEach(urls, (url) => electronShell.openExternal(url)); + + assert.deepEqual(results, [true, true, true, true]); + assert.deepEqual( + openExternalMock.mock.calls, + urls.map((url) => [url]), + ); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("normalizes the scheme case of local editor file deep links", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("VSCode://file/Users/me/repo/file.md:3"); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [["vscode://file/Users/me/repo/file.md:3"]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("does not open editor URLs that are not absolute local file links", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.forEach( + [ + "cursor://user@file/Users/me/repo/file.md", + "cursor://:secret@file/Users/me/repo/file.md", + "cursor://file:8080/Users/me/repo/file.md", + "cursor://anysphere.cursor-deeplink/prompt?text=hi", + "vscode://vscode.git/clone?url=https%3A%2F%2Fexample.com%2Frepo.git", + "cursor://file", + "cursor://file/", + "cursor:file/Users/me/repo/file.md", + "foo://file/Users/me/repo/file.md", + "zed://file/Users/me/repo/file.md", + "jetbrains://file/Users/me/repo/file.md", + "javascript:alert(1)", + ], + (url) => electronShell.openExternal(url), + ); + + assert.deepEqual(results, Array(12).fill(false)); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open editor URLs that mix up link shapes", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index e32dba36a6dfa..308f60b10c2cb 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -1,5 +1,6 @@ import { MAC_PERMISSION_SETTINGS_URLS } from "../permissions/MacPermission.ts"; import { + isLocalEditorFileUrl, REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor, type SystemSettingsPane, @@ -11,7 +12,7 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; -// Remote editor deep links must reach the OS handler. +// Remote editor deep links and local editor file links must reach the OS handler. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); const REMOTE_EDITOR_PROTOCOLS = new Set( REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { @@ -44,7 +45,9 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { try { const url = new URL(rawUrl); - return SAFE_WEB_PROTOCOLS.has(url.protocol) || isRemoteEditorUrl(url) + return SAFE_WEB_PROTOCOLS.has(url.protocol) || + isRemoteEditorUrl(url) || + isLocalEditorFileUrl(url) ? Option.some(url.href) : Option.none(); } catch { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 175f6739e54d3..66e7a9b7c15a7 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -54,7 +54,6 @@ const clientSettings: ClientSettings = { glassOpacity: 80, onboardingCompletedAt: null, panelAnimationDurationMs: 0, - planModeEnabled: false, proactivePanelsEnabled: true, showSkillsInSlashMenu: false, persistComposerContextStrip: true, @@ -69,7 +68,6 @@ const clientSettings: ClientSettings = { boringSidebarMultilineRows: false, threadDetailsUsageEnabled: false, threadDetailsItems: {}, - legacySidebarEnabled: false, sidebarWorkingShelfEnabled: false, loadBalancingEnabled: false, loadBalancingWeights: { "environment-1": 75, "environment-2": 0 }, diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt index 7a5e113192882..39ea72d129d60 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt @@ -1,7 +1,14 @@ package expo.modules.t3nativecontrols +import android.content.ContentValues +import android.content.Context import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore import android.text.format.DateFormat +import androidx.annotation.RequiresApi import androidx.core.content.FileProvider import expo.modules.kotlin.Promise import expo.modules.kotlin.modules.Module @@ -44,6 +51,15 @@ class T3NativeControlsModule : Module() { } } + AsyncFunction("saveImageToLibrary") { uri: String, mimeType: String, name: String -> + val context = appContext.reactContext ?: error("The app is not active.") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveImageToPictures(context, Uri.parse(uri), mimeType, name) + } else { + error("Saving images needs Android 10 or later.") + } + } + OnActivityResult { _, (requestCode) -> if (requestCode == 7343) { filePreviewPromise?.resolve(null) @@ -84,3 +100,32 @@ class T3NativeControlsModule : Module() { } } } + +// Android 10+ lets apps add their own media without a storage permission. +@RequiresApi(Build.VERSION_CODES.Q) +private fun saveImageToPictures(context: Context, source: Uri, mimeType: String, name: String) { + val resolver = context.contentResolver + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, name) + put(MediaStore.Images.Media.MIME_TYPE, mimeType) + put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/T3 Code") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val target = resolver.insert( + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), + values + ) ?: error("Could not create the image in Pictures.") + runCatching { + val input = resolver.openInputStream(source) ?: error("The image is no longer available.") + input.use { from -> + val output = resolver.openOutputStream(target) ?: error("Could not write the image.") + output.use { from.copyTo(it) } + } + resolver.update( + target, + ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, + null, + null + ) + }.onFailure { resolver.delete(target, null, null) }.getOrThrow() +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index f1d338a5478ea..5bc7ecc2fb94a 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -1,7 +1,9 @@ import AVFoundation import ExpoModulesCore +import Photos import Security import UIKit +import UniformTypeIdentifiers @ExpoModule("T3NativeControls") public final class T3NativeControlsModule: Module { @@ -123,6 +125,11 @@ public final class T3NativeControlsModule: Module { AsyncFunction("shareFileFromSource") { (url: URL, title: String, identifier: String, promise: Promise) in try self.shareFile(url: url, title: title, sourceIdentifier: identifier, promise: promise) }.runOnQueue(.main) + + // Photos names the asset itself, so iOS ignores the Android display name. + AsyncFunction("saveImageToLibrary") { (url: URL, mimeType: String, name: String, promise: Promise) in + Self.saveImageToLibrary(url: url, mimeType: mimeType, name: name, promise: promise) + } } // UIKit presenters are main-actor isolated rather than run on the JS thread @@ -297,6 +304,35 @@ public final class T3NativeControlsModule: Module { if filePresentation?.identifier == identifier { filePresentation?.dismiss() } } + private static func saveImageToLibrary(url: URL, mimeType: String, name: String, promise: Promise) { + PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in + guard status == .authorized || status == .limited else { + promise.reject(NSError( + domain: "T3NativePhotos", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Allow T3 Code to add photos in Settings."] + )) + return + } + PHPhotoLibrary.shared().performChanges({ + let options = PHAssetResourceCreationOptions() + options.uniformTypeIdentifier = UTType(mimeType: mimeType)?.identifier + options.originalFilename = name + PHAssetCreationRequest.forAsset().addResource(with: .photo, fileURL: url, options: options) + }, completionHandler: { saved, error in + if saved { + promise.resolve(nil) + } else { + promise.reject(error ?? NSError( + domain: "T3NativePhotos", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Photos could not save this image."] + )) + } + }) + } + } + private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws { guard let presenter = appContext?.utilities?.currentViewController() else { throw NSError( diff --git a/apps/mobile/modules/t3-web-socket/expo-module.config.json b/apps/mobile/modules/t3-web-socket/expo-module.config.json new file mode 100644 index 0000000000000..51ef6d6cf7195 --- /dev/null +++ b/apps/mobile/modules/t3-web-socket/expo-module.config.json @@ -0,0 +1,4 @@ +{ + "platforms": ["apple"], + "apple": { "modules": ["T3WebSocketModule"] } +} diff --git a/apps/mobile/modules/t3-web-socket/ios/T3WebSocket.podspec b/apps/mobile/modules/t3-web-socket/ios/T3WebSocket.podspec new file mode 100644 index 0000000000000..9be55568cf29b --- /dev/null +++ b/apps/mobile/modules/t3-web-socket/ios/T3WebSocket.podspec @@ -0,0 +1,13 @@ +Pod::Spec.new do |s| + s.name = 'T3WebSocket' + s.version = '1.0.0' + s.summary = 'WebSocket client with permessage-deflate for T3 Code mobile.' + s.description = 'React Native ships SocketRocket on iOS, which never negotiates compression. This module dials the T3 server through URLSessionWebSocketTask, which offers permessage-deflate on its own.' + s.author = 'T3 Tools' + s.homepage = 'https://t3tools.com' + s.platform = :ios, '18.0' + s.source = { :path => '.' } + s.static_framework = true + s.dependency 'ExpoModulesCore' + s.source_files = '*.swift' +end diff --git a/apps/mobile/modules/t3-web-socket/ios/T3WebSocketModule.swift b/apps/mobile/modules/t3-web-socket/ios/T3WebSocketModule.swift new file mode 100644 index 0000000000000..dbef837723dcd --- /dev/null +++ b/apps/mobile/modules/t3-web-socket/ios/T3WebSocketModule.swift @@ -0,0 +1,188 @@ +import ExpoModulesCore +import Foundation + +private final class Connection { + let id: Int + var task: URLSessionWebSocketTask? + var finished = false + var requestedCloseCode: Int? + var failure: String? + + init(id: Int) { + self.id = id + } +} + +/// A WebSocket client that offers permessage-deflate. React Native's SocketRocket never does, +/// so a large snapshot reached an iPhone uncompressed. URLSession offers the extension on every +/// handshake with no public switch, which is what we want here. +public final class T3WebSocketModule: Module { + private var connections: [Int: Connection] = [:] + private var nextId = 1 + private let lock = NSLock() + private var session: URLSession? + private let delegateQueue: OperationQueue = { + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 1 + return queue + }() + + public func definition() -> ModuleDefinition { + Name("T3WebSocket") + Events("open", "message", "close", "error") + + Function("connect") { (url: URL, protocols: [String]) -> Int in + let connection = self.withLock { () -> Connection in + let connection = Connection(id: self.nextId) + self.nextId += 1 + self.connections[connection.id] = connection + return connection + } + let session = self.session ?? self.makeSession() + self.session = session + let task = protocols.isEmpty + ? session.webSocketTask(with: url) + : session.webSocketTask(with: url, protocols: protocols) + // The app splits frames at 48 KiB, but older servers and the outdated-host probe send whole + // messages, so stay well above URLSession's 1 MiB default. + task.maximumMessageSize = 128 * 1024 * 1024 + self.withLock { connection.task = task } + task.resume() + self.receive(on: connection, task: task) + return connection.id + } + + Function("sendText") { (id: Int, text: String) in + guard let connection = self.connection(id), let task = connection.task else { return } + task.send(.string(text)) { [weak self] error in + if let error { self?.fail(connection, task: task, error: error) } + } + } + + Function("sendBinary") { (id: Int, bytes: Uint8Array) in + guard let connection = self.connection(id), let task = connection.task else { return } + let data = Data(bytes: bytes.rawPointer, count: bytes.byteLength) + task.send(.data(data)) { [weak self] error in + if let error { self?.fail(connection, task: task, error: error) } + } + } + + Function("close") { (id: Int, code: Int, reason: String) in + guard let connection = self.connection(id) else { return } + // URLSession can only send the codes it names; anything else goes out as a normal closure. + let closeCode = URLSessionWebSocketTask.CloseCode(rawValue: code).flatMap { $0 == .invalid ? nil : $0 } ?? .normalClosure + let task = self.withLock { () -> URLSessionWebSocketTask? in + connection.requestedCloseCode = closeCode.rawValue + return connection.task + } + task?.cancel(with: closeCode, reason: reason.isEmpty ? nil : reason.data(using: .utf8)) + } + + OnDestroy { + self.session?.invalidateAndCancel() + } + } + + private func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.default + // Dev clients register URLProtocol classes that intercept the upgrade and drop the socket. + configuration.protocolClasses = [] + return URLSession(configuration: configuration, delegate: SessionDelegate(module: self), delegateQueue: delegateQueue) + } + + private func connection(_ id: Int) -> Connection? { + withLock { connections[id] } + } + + private func connection(for task: URLSessionTask) -> Connection? { + withLock { connections.values.first { $0.task === task } } + } + + private func withLock(_ body: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + return body() + } + + private func receive(on connection: Connection, task: URLSessionWebSocketTask) { + task.receive { [weak self] result in + guard let self else { return } + switch result { + case .success(.string(let text)): + self.sendEvent("message", ["id": connection.id, "text": text]) + self.receive(on: connection, task: task) + case .success(.data(let data)): + self.sendEvent("message", ["id": connection.id, "data": data]) + self.receive(on: connection, task: task) + case .success: + self.receive(on: connection, task: task) + case .failure(let error): + self.fail(connection, task: task, error: error) + } + } + } + + /// Every ending is reported once, from the delegate. A receive or send failure on a task that is + /// still running (an oversized message, for one) gets no delegate callback until it is cancelled. + private func fail(_ connection: Connection, task: URLSessionWebSocketTask, error: Error) { + let live = withLock { () -> Bool in + guard !connection.finished else { return false } + if connection.failure == nil { connection.failure = error.localizedDescription } + return true + } + guard live, task.state == .running else { return } + task.cancel() + } + + fileprivate func didOpen(task: URLSessionWebSocketTask) { + guard let connection = connection(for: task) else { return } + sendEvent("open", ["id": connection.id]) + } + + fileprivate func didClose(task: URLSessionWebSocketTask, code: URLSessionWebSocketTask.CloseCode, reason: Data?) { + guard let connection = connection(for: task) else { return } + finish(connection, code: code.rawValue, reason: reason.flatMap { String(data: $0, encoding: .utf8) } ?? "", clean: true) + } + + fileprivate func didComplete(task: URLSessionTask, error: Error?) { + guard let connection = connection(for: task) else { return } + let (requested, failure) = withLock { (connection.requestedCloseCode, connection.failure) } + if requested == nil, let message = failure ?? error?.localizedDescription { + sendEvent("error", ["id": connection.id, "message": message]) + } + // A task that ends without a close frame is an abnormal closure, as in browsers; one we + // cancelled ourselves reports the code we asked for. + finish(connection, code: requested ?? (error == nil && failure == nil ? 1005 : 1006), reason: "", clean: requested != nil) + } + + private func finish(_ connection: Connection, code: Int, reason: String, clean: Bool) { + let first = withLock { () -> Bool in + if connection.finished { return false } + connection.finished = true + connections.removeValue(forKey: connection.id) + return true + } + guard first else { return } + sendEvent("close", ["id": connection.id, "code": code, "reason": reason, "wasClean": clean]) + } +} + +private final class SessionDelegate: NSObject, URLSessionWebSocketDelegate { + weak var module: T3WebSocketModule? + + init(module: T3WebSocketModule) { + self.module = module + } + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { + module?.didOpen(task: webSocketTask) + } + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + module?.didClose(task: webSocketTask, code: closeCode, reason: reason) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + module?.didComplete(task: task, error: error) + } +} diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index ef79ef182cc57..b8ab00673db12 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -110,6 +110,7 @@ import { import { ScheduledTaskModelPickerRouteScreen, ScheduledTaskBranchPickerRouteScreen, + ScheduledTaskThreadPickerRouteScreen, } from "./features/settings/ScheduledTaskPickerScreens"; import { SettingsKeyboardRouteScreen } from "./features/settings/SettingsKeyboardRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; @@ -301,6 +302,10 @@ const SETTINGS_SCREENS = { screen: ScheduledTaskBranchPickerRouteScreen, options: settingsPage("Base branch"), }), + SettingsScheduledTaskThread: createNativeStackScreen({ + screen: ScheduledTaskThreadPickerRouteScreen, + options: settingsPage("Destination"), + }), SettingsScheduledTaskModel: createNativeStackScreen({ screen: ScheduledTaskModelPickerRouteScreen, options: THREAD_SETTINGS_ROUTE, diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx index f733c59ff7ed1..26d2aa50248ab 100644 --- a/apps/mobile/src/components/FilePreview.tsx +++ b/apps/mobile/src/components/FilePreview.tsx @@ -6,6 +6,7 @@ import ImageViewing from "react-native-image-viewing"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { openAttachmentInViewer } from "../lib/attachmentDownload"; +import { useMediaActions } from "../state/mediaActions"; import type { ResolvedFilePreviewDocument, ResolvedFilePreviewSource, @@ -20,6 +21,8 @@ function PdfDocumentPreview(props: { readonly onRequestClose: () => void; }) { const insets = useSafeAreaInsets(); + const media = useMediaActions(props.source.actionsSource); + const save = media.actions.find((action) => action.id === "save"); return ( void save.run(), + }, + ] + : [] + } /> diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx index 9093507581c7a..4d6c97464dd67 100644 --- a/apps/mobile/src/components/FilePreviewModal.tsx +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -14,6 +14,7 @@ import { refreshAssetUrl } from "../state/assets"; import { filePreviewGallery, filePreviewKey, filePreviewName } from "../lib/filePreviewGallery"; import { FilePreviewGallery } from "./FilePreviewGallery"; import { FilePreview } from "./FilePreview"; +import type { MediaActionsSource } from "../lib/mediaActionsSource"; import type { FilePreviewItem, FilePreviewSource } from "./FilePreviewModal.types"; export type { @@ -67,6 +68,18 @@ async function resolvePreviewUri( return { uri: url + (item.srcFragment ?? "") }; } +// Shares from the unresolved item so remote URLs refresh and draft files take their own lease. +function pdfActionsSource(item: FilePreviewItem): MediaActionsSource { + const metadata = { + name: filePreviewName(item), + mimeType: item.mimeType ?? "application/pdf", + ...(item.sourceIdentifier ? { sourceIdentifier: item.sourceIdentifier } : {}), + }; + if ("uri" in item) return { ...metadata, uri: item.uri }; + if ("attachment" in item) return { ...metadata, attachment: item.attachment }; + return { ...metadata, environmentId: item.environmentId, resource: item.resource }; +} + function ResolvedFilePreview(props: { readonly source: FilePreviewSource; readonly onRequestClose: () => void; @@ -166,9 +179,16 @@ function ResolvedFilePreview(props: { : []), ]); }; + const actionsSource = + selected.kind === "pdf" ? (selected.actionsSource ?? pdfActionsSource(selected)) : undefined; return ( diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts index 2e80259bb8c76..5f48ad04d38de 100644 --- a/apps/mobile/src/connection/environment-cache-store.test.ts +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -498,12 +498,21 @@ describe("mobile SQLite environment cache store", () => { Effect.provideService(MobileDatabase.MobileDatabase, memory.database), ); - yield* store.saveShell(ENVIRONMENT_ID, SHELL_SNAPSHOT); + const origin = { + serverVersion: "1.0.0", + databaseMigration: 70, + omitIdleSubagents: true, + omitOlderSettled: false, + snapshotAt: 1_800_000_000_000, + }; + yield* store.saveShell(ENVIRONMENT_ID, SHELL_SNAPSHOT, origin); yield* store.saveThread(ENVIRONMENT_ID, THREAD_SNAPSHOT); const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID)); const thread = Option.getOrThrow(yield* store.loadThread(ENVIRONMENT_ID, THREAD_ID)); + expect(shell.origin).toEqual(origin); + expect(DateTime.formatIso(shell.threads[0]!.updatedAt)).toBe("2026-07-29T12:00:00.000Z"); expect(DateTime.formatIso(shell.threads[0]!.latestUserMessageAt!)).toBe( "2026-07-29T12:00:00.000Z", diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 6fada3fbeda3d..60ec7c0db2f0d 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -106,7 +106,13 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { operation: "load-shell", decode: decodeStoredShellSnapshot, select: (stored) => - stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), + stored.environmentId === environmentId + ? Option.some( + stored.origin === undefined + ? stored.snapshot + : { ...stored.snapshot, origin: stored.origin }, + ) + : Option.none(), }).pipe( Effect.zipWith( Effect.promise(() => projectFaviconDatabaseCache.hydrate()), @@ -115,16 +121,25 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { ), ), ), - saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { - const payload = yield* encodeStoredShellSnapshot({ - schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, - environmentId, - snapshot, - }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); - yield* database - .saveCache(environmentId, "shell", "snapshot", ORCHESTRATION_CACHE_SCHEMA_VERSION, payload) - .pipe(Effect.mapError(mapDatabaseError("save-shell"))); - }), + saveShell: Effect.fn("MobileEnvironmentCache.saveShell")( + function* (environmentId, snapshot, origin) { + const payload = yield* encodeStoredShellSnapshot({ + schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, + environmentId, + snapshot, + ...(origin === undefined ? {} : { origin }), + }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); + yield* database + .saveCache( + environmentId, + "shell", + "snapshot", + ORCHESTRATION_CACHE_SCHEMA_VERSION, + payload, + ) + .pipe(Effect.mapError(mapDatabaseError("save-shell"))); + }, + ), loadThread: Effect.fn("MobileEnvironmentCache.loadThread")((environmentId, threadId) => loadDecodedCache({ database, diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 0bdadd4bea04a..a5d3abe4c91d0 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -845,6 +845,16 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { kind: "pdf", uri: previewUri, name: basename(relativePath), + ...(environmentId !== null && assetPreview.resource !== null + ? { + actionsSource: { + name: basename(relativePath), + mimeType: "application/pdf", + environmentId, + resource: assetPreview.resource, + }, + } + : {}), }), } as const) : null, diff --git a/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx b/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx index aec2b1a0eeb3c..e93a3f7442986 100644 --- a/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx +++ b/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx @@ -1,16 +1,37 @@ import { useNavigation } from "@react-navigation/native"; +import { isListedThreadLineage } from "@t3tools/contracts"; import * as Haptics from "expo-haptics"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; +import { FlatList, Platform, type TextInputInstance, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; +import { SearchField } from "../../components/ui/SearchField"; +import { SelectionRow } from "../../components/ui/SelectionRow"; +import { Toolbar } from "../../components/ui/Toolbar"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; -import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; -import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; +import { + useEnvironmentServerConfig, + useProjects, + useVisibleThreadShells, +} from "../../state/entities"; +import { useDebouncedValue, usePaginatedBranches, useThreadSearch } from "../../state/queries"; +import { + createNativeMailSearchToolbarItem, + NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, +} from "../layout/native-mail-search-toolbar"; import { BranchPickerScreen } from "../threads/NewTaskContextPickerScreens"; import { ThreadSettingsPickerScreen } from "../threads/ThreadSettingsSheet"; import { SettingsScreen } from "./components/SettingsScreen"; -import { useScheduledTaskEditor } from "./scheduled-task-editor"; +import { + NEW_THREAD_DESTINATION_LABEL, + useScheduledTaskDestinationLabel, + useScheduledTaskEditor, +} from "./scheduled-task-editor"; +import { moveDraftToThread, unbindDraftFromThread } from "./scheduledTaskDraft"; function MissingTaskDraft() { return ( @@ -164,3 +185,149 @@ export function ScheduledTaskBranchPickerRouteScreen() { /> ); } + +const THREAD_SEARCH_PLACEHOLDER = "Search threads, including older ones"; + +export function ScheduledTaskThreadPickerRouteScreen() { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); + const searchRef = useRef(null); + const { editor, setEditor } = useScheduledTaskEditor(); + const environmentId = editor?.environmentId ?? null; + const projectId = editor?.draft.projectId ?? null; + const threadId = editor?.draft.threadId ?? null; + const currentLabel = useScheduledTaskDestinationLabel(environmentId, threadId); + const shells = useVisibleThreadShells(); + const [query, setQuery] = useState(""); + // Mounts older settled threads matching the query, which shells may leave out. + useThreadSearch( + environmentId !== null && projectId !== null ? [environmentId] : [], + query, + projectId === null ? { titlesOnly: true } : { titlesOnly: true, projectIds: [projectId] }, + ); + + if (!editor) return ; + + const search = query.trim().toLocaleLowerCase(); + const rows = [ + ...(search ? [] : [{ key: "new-thread", title: NEW_THREAD_DESTINATION_LABEL, thread: null }]), + ...(threadId !== null && currentLabel.toLocaleLowerCase().includes(search) + ? [{ key: threadId, title: currentLabel, thread: null }] + : []), + ...shells + .filter( + (thread) => + thread.environmentId === environmentId && + thread.projectId === projectId && + thread.id !== threadId && + thread.archivedAt === null && + isListedThreadLineage(thread.lineage) && + thread.title.toLocaleLowerCase().includes(search), + ) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) + .map((thread) => ({ + key: thread.id, + title: thread.title.trim() || "Untitled thread", + thread, + })), + ]; + const selectedKey = threadId ?? (editor.draft.threadMode === "new_thread" ? "new-thread" : null); + + return ( + + {Platform.OS === "ios" ? ( + [ + createNativeMailSearchToolbarItem({ + onSearchTextChange: setQuery, + placeholder: THREAD_SEARCH_PLACEHOLDER, + searchTextChangeId: "scheduled-task-thread-search-text", + showsSearchDismissButton: true, + }), + ] + : undefined, + headerSearchBarOptions: usesNativeMailSearchToolbar + ? undefined + : { + allowToolbarIntegration: true, + autoCapitalize: "none", + hideNavigationBar: false, + hideWhenScrolling: false, + obscureBackground: false, + onCancelButtonPress: () => setQuery(""), + onChangeText: (event) => setQuery(event.nativeEvent.text), + placeholder: THREAD_SEARCH_PLACEHOLDER, + }, + }} + /> + ) : null} + {Platform.OS === "ios" && !usesNativeMailSearchToolbar ? ( + + + + ) : null} + row.key} + className="flex-1" + contentInsetAdjustmentBehavior="automatic" + automaticallyAdjustKeyboardInsets={Platform.OS === "ios"} + keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"} + keyboardShouldPersistTaps="handled" + showsVerticalScrollIndicator={false} + contentContainerStyle={{ + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: usesNativeMailSearchToolbar + ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + 16 + : Math.max(insets.bottom, 16) + 16, + }} + ListHeaderComponent={ + Platform.OS === "ios" ? undefined : ( + + + + ) + } + ListEmptyComponent={ + No matching threads + } + renderItem={({ item, index }) => ( + { + void Haptics.selectionAsync(); + if (item.key !== selectedKey) { + setEditor((current) => + current + ? { + ...current, + draft: item.thread + ? moveDraftToThread(current.draft, item.thread) + : { ...unbindDraftFromThread(current.draft), threadMode: "new_thread" }, + } + : current, + ); + } + navigation.goBack(); + }} + /> + )} + /> + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx index 8045fb3dd3d4e..717e71362a593 100644 --- a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx @@ -8,6 +8,7 @@ import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSecti import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection"; import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection"; import { ThemeAppearanceSection } from "./appearance/sections/ThemeAppearanceSection"; +import { ToolCallsAppearanceSection } from "./appearance/sections/ToolCallsAppearanceSection"; export function SettingsAppearanceRouteScreen() { const insets = useSafeAreaInsets(); @@ -28,6 +29,7 @@ export function SettingsAppearanceRouteScreen() { + ); diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 6e90e449359ca..ef4b696c82ec8 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -72,14 +72,16 @@ import { SettingsScreen } from "./components/SettingsScreen"; import { SettingsSection } from "./components/SettingsSection"; import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter"; import { + changeDraftProject, editDraft, parseIntervalMinutes, scheduledTaskDefaultModel, scheduleFromDraft, + unbindDraftFromThread, type ScheduledTaskDraft as Draft, } from "./scheduledTaskDraft"; import { settingsTargetsForProject } from "./settings-environment-filter.logic"; -import { useScheduledTaskEditor } from "./scheduled-task-editor"; +import { useScheduledTaskDestinationLabel, useScheduledTaskEditor } from "./scheduled-task-editor"; import { discardScheduledTaskEditor, markScheduledTaskEditorSaved, @@ -96,6 +98,7 @@ type ScheduledTaskRoutes = { SettingsScheduledTaskEdit: undefined; SettingsScheduledTaskModel: undefined; SettingsScheduledTaskBranch: undefined; + SettingsScheduledTaskThread: undefined; }; const DAYS = [ @@ -663,6 +666,7 @@ function TaskForm({ ) .sort((left, right) => Number(right.pinnedAt != null) - Number(left.pinnedAt != null)); const config = useEnvironmentServerConfig(environmentId); + const destinationLabel = useScheduledTaskDestinationLabel(environmentId, draft.threadId); const modelOptions = buildModelOptions(config, null, undefined, draft.projectId); // Existing webhook tasks keep the option so they can still switch to a timer. const webhookDenial = managedCapabilityDenial(config?.managedPolicy, "webhookTasks"); @@ -766,7 +770,7 @@ function TaskForm({ modelSelection: draft.modelSelection, schedule, enabled: draft.enabled, - threadId: draft.task?.threadId ?? null, + threadId: draft.threadId, ...(offersThreadModes ? { threadMode: draft.threadMode } : {}), workspaceStrategy: draft.workspace === "root" @@ -879,8 +883,7 @@ function TaskForm({ draft.modelSelection.instanceId, )); setDraft({ - ...draft, - projectId: project.id, + ...changeDraftProject(draft, project.id), modelSelection: keepsExplicitModel ? draft.modelSelection : scheduledTaskDefaultModel(config, project), @@ -905,19 +908,21 @@ function TaskForm({ /> - {offersThreadModes ? ( - + + {offersThreadModes ? ( { - if (id === "new_thread" || id === "continue" || id === "fresh_session") + if (id === "new_thread") + setDraft({ ...unbindDraftFromThread(draft), threadMode: id }); + else if (id === "continue" || id === "fresh_session") setDraft({ ...draft, threadMode: id }); }} /> - - ) : null} + ) : null} + {!offersThreadModes || draft.threadMode !== "new_thread" ? ( + navigation.navigate("SettingsScheduledTaskThread")} + disabled={!draft.projectId || saving || dictationPending || environmentUnavailable} + /> + ) : null} + - - - + + ); @@ -228,111 +238,64 @@ export function NewThreadSettingsSection() { ); } -/** Per machine, since each machine's own setting decides whether it links and answers links. */ -function BetaSettingsSection() { +type MachineBetaSetting = "linkedEnvironmentsEnabled" | "fastWorktreeCheckout"; + +/** One switch per connected machine that offers the beta, since each machine's own setting decides. */ +function MachineBetaSection({ + title, + icon, + capability, + setting, + description, +}: { + readonly title: string; + readonly icon: ComponentProps["icon"]; + readonly capability: "peerLinks" | "fastWorktreeCheckout"; + readonly setting: MachineBetaSetting; + readonly description: string; +}) { const { environments } = useEnvironments(); const supported = environments.filter( (environment) => environment.connection.phase === "connected" && - environment.serverConfig?.environment.capabilities.peerLinks === true, + environment.serverConfig?.environment.capabilities[capability] === true, ); const writable = useEnvironmentsWithScope(supported, AuthSettingsWriteScope); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { - label: "linked environments beta update", + label: `${setting} beta update`, reportFailure: true, }); if (supported.length === 0) return null; return ( - + {supported.map((environment) => { const policy = environment.serverConfig?.managedPolicy; - const managed = isManagedSettingPath(policy, "linkedEnvironmentsEnabled"); + const managed = isManagedSettingPath(policy, setting); return ( void updateSettings({ environmentId: environment.environmentId, - input: { patch: { linkedEnvironmentsEnabled: value } }, + input: { + patch: + setting === "linkedEnvironmentsEnabled" + ? { linkedEnvironmentsEnabled: value } + : { fastWorktreeCheckout: value }, + }, }) } /> ); })} - - Let agents on a machine work on your other machines, and theirs on it. Turn it on for each - machine. While off, nothing new starts over a link; running tasks finish. - - - ); -} - -const TOOL_CALL_VISIBILITY_OPTIONS: ReadonlyArray<{ - readonly value: ToolCallVisibility; - readonly label: string; -}> = [ - { value: "showAll", label: "Show all" }, - { value: "hideSuccessful", label: "Hide successful" }, - { value: "hideAll", label: "Hide all" }, - { value: "zen", label: "Zen" }, -]; - -function ToolCallVisibilitySection() { - const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const preferences = useAtomValue(mobilePreferencesAtom); - const selected = AsyncResult.isSuccess(preferences) - ? (preferences.value.toolCallVisibility ?? "showAll") - : null; - - return ( - - - {TOOL_CALL_VISIBILITY_OPTIONS.map((option, index) => ( - 0} - disabled={selected === null} - onPress={() => savePreferences({ toolCallVisibility: option.value })} - /> - ))} - - - Hide the commands, edits, and searches agents run. Hide successful keeps failures. Zen shows - only final answers. Approvals, questions, and errors always show. - - - ); -} - -function LegacySettingsSection() { - const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const preferences = useAtomValue(mobilePreferencesAtom); - const planModeEnabled = - AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true; - - return ( - - - savePreferences({ planModeEnabled: value })} - /> - - - Opt into retired interfaces kept for compatibility. Plan Mode restores the Build/Plan - control; otherwise every task runs in Build mode. - + {description} ); } diff --git a/apps/mobile/src/features/settings/appearance/sections/ToolCallsAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ToolCallsAppearanceSection.tsx new file mode 100644 index 0000000000000..aa1c46595d697 --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/sections/ToolCallsAppearanceSection.tsx @@ -0,0 +1,48 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import type { ToolCallVisibility } from "@t3tools/contracts/settings"; +import { AsyncResult } from "effect/reactivity"; +import { View } from "react-native"; + +import { AppText as Text } from "../../../../components/ui/AppText"; +import { ListChoiceRow } from "../../../../components/ui/ListChoiceRow"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../../state/preferences"; +import { SettingsSection } from "../../components/SettingsSection"; + +const TOOL_CALL_VISIBILITY_OPTIONS: ReadonlyArray<{ + readonly value: ToolCallVisibility; + readonly label: string; +}> = [ + { value: "showAll", label: "Show all" }, + { value: "hideSuccessful", label: "Hide successful" }, + { value: "hideAll", label: "Hide all" }, + { value: "zen", label: "Zen" }, +]; + +export function ToolCallsAppearanceSection() { + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const preferences = useAtomValue(mobilePreferencesAtom); + const selected = AsyncResult.isSuccess(preferences) + ? (preferences.value.toolCallVisibility ?? "showAll") + : null; + + return ( + + + {TOOL_CALL_VISIBILITY_OPTIONS.map((option, index) => ( + 0} + disabled={selected === null} + onPress={() => savePreferences({ toolCallVisibility: option.value })} + /> + ))} + + + Hide the commands, edits, and searches agents run. Hide successful keeps failures. Zen shows + only final answers. Approvals, questions, and errors always show. + + + ); +} diff --git a/apps/mobile/src/features/settings/components/ScheduledTaskPromptField.tsx b/apps/mobile/src/features/settings/components/ScheduledTaskPromptField.tsx index 62cfea826b5ad..749599db2bb9f 100644 --- a/apps/mobile/src/features/settings/components/ScheduledTaskPromptField.tsx +++ b/apps/mobile/src/features/settings/components/ScheduledTaskPromptField.tsx @@ -29,7 +29,7 @@ export function ScheduledTaskPromptField(props: { return ( Prompt - + {showsIdleTrigger ? ( - - - + ) : null} row.id === threadId); + if (ref === null) return NEW_THREAD_DESTINATION_LABEL; + return thread?.title.trim() || `Thread ${ref.threadId}`; +} + /** The form draft lives in an atom, so it survives while its native picker routes are on top. */ export function useScheduledTaskEditor() { const { selectedTargets, selectedProjectKey, projectGroups } = useSettingsEnvironmentFilter(); diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index 3a7d919299896..1ede30d84072b 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -8,6 +8,7 @@ import type { ScheduledTaskThreadMode, ScheduledTaskUpsertSchedule, ScheduledTaskWebhookSignature, + ThreadId, } from "@t3tools/contracts"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; @@ -202,6 +203,7 @@ export type ScheduledTaskDraft = { readonly title: string; readonly prompt: string; readonly projectId: ProjectId | null; + readonly threadId: ThreadId | null; readonly modelSelection: ModelSelection | null; readonly modelSelectionIsExplicit: boolean; readonly schedule: ScheduleDraft; @@ -219,6 +221,7 @@ function draftSignature(draft: ScheduledTaskDraft): string { draft.title, draft.prompt, draft.projectId, + draft.threadId, draft.modelSelection?.instanceId, draft.modelSelection?.model, [...(draft.modelSelection?.options ?? [])] @@ -251,6 +254,38 @@ export function hasScheduledTaskDraftChanges( return draftSignature(initial) !== draftSignature(current); } +export function moveDraftToThread( + draft: ScheduledTaskDraft, + thread: { readonly id: ThreadId; readonly projectId: ProjectId }, +): ScheduledTaskDraft { + return { + ...draft, + threadId: thread.id, + projectId: thread.projectId, + threadMode: draft.threadMode === "new_thread" ? "continue" : draft.threadMode, + }; +} + +/** Leaves the thread but keeps the mode. Matches MCP: new threads stay out of the shared checkout. */ +export function unbindDraftFromThread(draft: ScheduledTaskDraft): ScheduledTaskDraft { + if (draft.threadId === null) return draft; + return { + ...draft, + threadId: null, + ...(draft.workspace === "root" + ? { workspace: "worktree" as const, startFromOrigin: true } + : {}), + }; +} + +export function changeDraftProject( + draft: ScheduledTaskDraft, + projectId: ProjectId, +): ScheduledTaskDraft { + if (projectId === draft.projectId) return draft; + return { ...unbindDraftFromThread(draft), projectId }; +} + export function createDraft( projectId: ProjectId | null, modelSelection: ModelSelection | null, @@ -260,6 +295,7 @@ export function createDraft( title: "", prompt: "", projectId, + threadId: null, modelSelection, modelSelectionIsExplicit: false, schedule: DEFAULT_SCHEDULE, @@ -279,6 +315,7 @@ export function editDraft(task: ScheduledTask): ScheduledTaskDraft { title: task.title, prompt: task.prompt, projectId: task.projectId, + threadId: task.threadId, modelSelection: task.modelSelection, modelSelectionIsExplicit: true, schedule: scheduleDraftForTask(task), diff --git a/apps/mobile/src/features/settings/settings-search.ts b/apps/mobile/src/features/settings/settings-search.ts index 6c82f6c317d9d..4d02bd9789b1d 100644 --- a/apps/mobile/src/features/settings/settings-search.ts +++ b/apps/mobile/src/features/settings/settings-search.ts @@ -26,6 +26,12 @@ const SETTINGS_SEARCH_TERMS = { "terminal", "word break", "math", + "tool calls in chat", + "hide tool calls", + "commands edits searches", + "work log", + "zen", + "final answers only", ], SettingsNavigation: ["tabs", "tab order", "reorder", "hide tabs"], SettingsKeyboard: ["return key", "send message", "new line", "hardware keyboard"], @@ -99,17 +105,11 @@ const SETTINGS_SEARCH_TERMS = { "start from remote", ], SettingsLabs: [ - "legacy", - "plan mode", "beta", "linked environments", "link machine", - "tool calls in chat", - "hide tool calls", - "commands edits searches", - "work log", - "zen", - "final answers only", + "fast worktree checkout", + "clone cache", ], SettingsEnvironmentMaintenance: ["updates", "provider updates", "restart", "manage environments"], SettingsAbout: [ diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 8f02fe677cdd3..a3b02b8985d4d 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -33,6 +33,7 @@ import { KeyboardController } from "react-native-keyboard-controller"; import { AuthOrchestrationOperateScope, + DEFAULT_PROVIDER_INTERACTION_MODE, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; @@ -873,7 +874,7 @@ export function NewTaskDraftScreen(props: { threadId: ThreadId.make(creationMetadata.threadId), modelSelection: flow.selectedModel, runtimeMode: flow.runtimeMode, - interactionMode: flow.interactionMode, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, branch: flow.selectedBranchName, worktreePath: flow.selectedWorktreePath, createdAt: creationMetadata.createdAt, @@ -971,7 +972,6 @@ export function NewTaskDraftScreen(props: { offersSideChat={false} onReconnectEnvironment={() => onReconnectEnvironment(selectedProject.environmentId)} canSwitchThreadProvider - onUpdateThreadInteractionMode={flow.planModeEnabled ? flow.setInteractionMode : undefined} onRespondToApproval={async () => {}} onSelectUserInputOption={() => {}} onChangeUserInputCustomAnswer={() => {}} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 57bda9aa98c9b..8987d9fadc9f0 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -21,7 +21,6 @@ import { type EnvironmentId, type MessageId, type ModelSelection, - type ProviderInteractionMode, type ServerConfig as T3ServerConfig, type UsageLimitsReport, } from "@t3tools/contracts"; @@ -253,7 +252,6 @@ export interface ThreadComposerProps { ) => Promise; readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void; readonly canSwitchProvider: boolean; - readonly onUpdateInteractionMode?: (interactionMode: ProviderInteractionMode) => void; readonly onHeightChange?: (height: number) => void; readonly onEditorFocusChange?: (focused: boolean) => void; } @@ -558,10 +556,6 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps hasThread: !props.isDraft, hasCompactableConversation: props.hasCompactableConversation, onChangeDraftMessage: props.onChangeDraftMessage, - onUpdateInteractionMode: - selectedProviderStatus?.showInteractionModeToggle === false - ? undefined - : props.onUpdateInteractionMode, offersUsageLimits: usageLimitsOffered, onUsageLimits: usageLimitsOffered && draftAttachments.length === 0 ? openUsageLimits : undefined, diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 27cce9c5795ba..f3c81cc9b1363 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -41,7 +41,6 @@ import { deriveBottomControlInsets, type NativeLayoutMetrics } from "../../lib/r import type { EnvironmentId, MessageId, - ProviderInteractionMode, RuntimeRequestId, ScopedThreadRef, ServerConfig as T3ServerConfig, @@ -301,7 +300,6 @@ export interface ThreadDetailScreenProps { ) => Promise; readonly onReconnectEnvironment: () => void; readonly canSwitchThreadProvider: boolean; - readonly onUpdateThreadInteractionMode?: (interactionMode: ProviderInteractionMode) => void; readonly onRespondToApproval: ApprovalResponder; readonly onSelectUserInputOption: ( requestId: RuntimeRequestId, @@ -1933,7 +1931,6 @@ function ThreadDetailBody( onSendMessage={handleSendMessage} onShowUsageLimits={showUsageLimits} canSwitchProvider={props.canSwitchThreadProvider} - onUpdateInteractionMode={props.onUpdateThreadInteractionMode} onHeightChange={handleComposerHeightChange} onEditorFocusChange={handleOwnedInputFocusChange} /> diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 80a3e8643a567..365d6d584c292 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1063,7 +1063,6 @@ function ThreadRouteContent( offersSideChat={composer.sideChatUnavailableReason === null} onReconnectEnvironment={handleReconnectEnvironment} canSwitchThreadProvider={composer.canSwitchThreadProvider} - onUpdateThreadInteractionMode={composer.onUpdateInteractionMode} onRespondToApproval={requests.onRespondToApproval} onSelectUserInputOption={requests.onSelectUserInputOption} onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer} diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index a505e5b3f39f7..8403be8baaac8 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -1338,7 +1338,7 @@ export function ExistingThreadSettingsRouteScreen({ option.selection.model === modelSelection.model, ) ?? null; const updateModelSelection = (selection: ModelSelection) => - updateThreadDraftModelSelection(threadKey, selection, serverConfig?.providers ?? []); + updateThreadDraftModelSelection(threadKey, selection); return ( + ({ useSelectedThreadGitActions: () => ({ canChangeThreadBranch: true }), })); vi.mock("../../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); -vi.mock("../../../state/vcs", () => ({ vcsEnvironment: { status: () => null } })); +vi.mock("../../../state/vcs", () => ({ vcsEnvironment: { branch: () => null } })); import { GitBranchesSheet } from "./GitBranchesSheet"; diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx index 509dd51240219..6bd008116babb 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx @@ -71,7 +71,7 @@ vi.mock("../../../state/use-selected-thread-git-actions", () => ({ }, }), })); -vi.mock("../../../state/vcs", () => ({ vcsEnvironment: { status: () => null } })); +vi.mock("../../../state/vcs", () => ({ vcsEnvironment: { branch: () => null } })); vi.mock("./gitSheetComponents", () => ({ SheetActionButton: (props: ActionProps) => { state.actions.set(props.label, props); diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index f8ddd8d7498bb..67d0818ee6c4d 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -33,7 +33,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null - ? vcsEnvironment.status({ + ? vcsEnvironment.branch({ environmentId: selectedThread.environmentId, input: { cwd: selectedThreadCwd }, }) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx index 0b7ecf7900c2b..3b91dbb122ffd 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx @@ -65,9 +65,6 @@ vi.mock("../../state/olderSettledShelf", () => ({ useOlderSettledCount: () => 0 vi.mock("../../state/project-grouping", () => ({ useMobileProjectGroupingSettings: () => ({ sidebarProjectGroupingMode: "none" }), })); -vi.mock("./use-legacy-plan-mode-enabled", () => ({ - useLegacyPlanModeState: () => ({ enabled: false, loaded: true }), -})); vi.mock("../../components/ui/Dialog", () => ({ alert: vi.fn() })); vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); vi.mock("../../state/use-remote-environment-registry", () => ({ diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 3876fd2f83dbb..5b42c28adc518 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -7,7 +7,6 @@ import React, { useEffect, useRef, useState } from "react"; import type { EnvironmentId, ModelSelection, - ProviderInteractionMode, ProviderOptionSelection, RuntimeMode, ServerProvider, @@ -83,11 +82,6 @@ import { type HomeProjectScope, } from "../home/homeThreadList"; import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; -import { - resolvePendingTaskInteractionMode, - resolveProviderInteractionMode, -} from "../../state/legacy-plan-mode"; -import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; import { resolveNewTaskDraftWorkspace, resolveNewTaskBranchWorktreePath, @@ -156,8 +150,6 @@ type NewTaskFlowContextValue = { readonly availableBranches: ReadonlyArray; readonly currentCheckoutBranchName: string | null; readonly runtimeMode: RuntimeMode; - readonly interactionMode: ProviderInteractionMode; - readonly planModeEnabled: boolean; readonly expandedProvider: string | null; readonly environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; @@ -209,7 +201,6 @@ type NewTaskFlowContextValue = { readonly setSubmitting: (value: boolean) => void; readonly loadBranches: () => void; readonly setRuntimeMode: (value: RuntimeMode) => void; - readonly setInteractionMode: (value: ProviderInteractionMode) => void; readonly setSelectedModelOptions: ( value: ReadonlyArray | undefined, ) => void; @@ -230,7 +221,6 @@ export function NewTaskFlowProvider( const threads = useThreadShells(); const { savedConnectionsById } = useSavedRemoteConnections(); const groupingSettings = useMobileProjectGroupingSettings(); - const { enabled: legacyPlanModeEnabled } = useLegacyPlanModeState(); const projectScopes = sortHomeProjectScopes({ scopes: buildHomeProjectScopes({ @@ -482,11 +472,6 @@ export function NewTaskFlowProvider( selectedEnvironmentServerConfig?.providers.find( (provider) => provider.instanceId === selectedModel?.instanceId, ) ?? null; - const planModeEnabled = - legacyPlanModeEnabled && selectedProviderStatus?.showInteractionModeToggle !== false; - const interactionMode = planModeEnabled - ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE) - : DEFAULT_PROVIDER_INTERACTION_MODE; const setSelectedModelKey = ( key: string | null, options?: ReadonlyArray, @@ -501,15 +486,7 @@ export function NewTaskFlowProvider( const selection = withRememberedModelOptions( options ? { ...option.selection, options } : option.selection, ); - const provider = selectedEnvironmentServerConfig?.providers.find( - (candidate) => candidate.instanceId === selection.instanceId, - ); - updateComposerDraftSettings(selectedProjectDraftKey, { - modelSelection: selection, - ...(provider?.showInteractionModeToggle === false - ? { interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE } - : {}), - }); + updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: selection }); setStickyComposerModelSelection(selection); }; const setSelectedModelOptions = (options: ReadonlyArray | undefined) => { @@ -709,13 +686,6 @@ export function NewTaskFlowProvider( updateComposerDraftSettings(selectedProjectDraftKey, { runtimeMode: value }); } }; - const setInteractionMode = (value: ProviderInteractionMode) => { - if (selectedProjectDraftKey) { - updateComposerDraftSettings(selectedProjectDraftKey, { - interactionMode: resolveProviderInteractionMode(selectedProviderStatus, value), - }); - } - }; const buildPendingTaskMessage = ( metadata: TurnCommandMetadata, @@ -761,13 +731,7 @@ export function NewTaskFlowProvider( defaultRuntimeMode, managedPolicy, ), - interactionMode: resolvePendingTaskInteractionMode({ - planModeEnabled: legacyPlanModeEnabled, - draftInteractionMode: draft.interactionMode, - provider: selectedEnvironmentServerConfig?.providers.find( - (candidate) => candidate.instanceId === draftModelSelection.instanceId, - ), - }), + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, creation: { projectId: selectedProject.id, projectTitle: selectedProject.title, @@ -875,8 +839,6 @@ export function NewTaskFlowProvider( availableBranches, currentCheckoutBranchName, runtimeMode, - interactionMode, - planModeEnabled, expandedProvider, environments, selectedProject, @@ -907,7 +869,6 @@ export function NewTaskFlowProvider( setSubmitting: (value: boolean) => updateFlow({ submitting: value }), loadBranches, setRuntimeMode, - setInteractionMode, setSelectedModelOptions, setExpandedProvider: (value: string | null) => updateFlow({ expandedProvider: value }), } satisfies NewTaskFlowContextValue; diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 253c8d9e806ee..74bf278b0a438 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -62,37 +62,29 @@ describe("resolveComposerMenuSelection", () => { }); describe("mobile slash commands", () => { - const antigravity = { - driver: ProviderDriverKind.make("antigravity"), - showInteractionModeToggle: false, - slashCommands: [{ name: "plan", description: "Plan with Antigravity" }], - }; - - it.each([false, true])( - "keeps native /plan with legacy mode enabled=%s", - (allowInteractionMode) => { - const items = buildComposerSlashCommandItems({ - query: "pl", - atMessageStart: true, - hasThread: true, - allowInteractionMode, - selectedProviderStatus: antigravity, - }); + it("keeps a provider's native /plan", () => { + const items = buildComposerSlashCommandItems({ + query: "pl", + atMessageStart: true, + hasThread: true, + selectedProviderStatus: { + driver: ProviderDriverKind.make("antigravity"), + slashCommands: [{ name: "plan", description: "Plan with Antigravity" }], + }, + }); - expect(items).toHaveLength(1); - expect(items[0]?.type).toBe("provider-slash-command"); - const item = items[0]; - if (!item) throw new Error("Expected the native plan command"); - expect( - resolveComposerCommandSelection({ - draftMessage: "/pl", - trigger: { rangeStart: 0, rangeEnd: 3 }, - item, - allowInteractionMode, - }), - ).toEqual({ text: "/plan ", cursor: 6, interactionMode: null }); - }, - ); + expect(items).toHaveLength(1); + expect(items[0]?.type).toBe("provider-slash-command"); + const item = items[0]; + if (!item) throw new Error("Expected the native plan command"); + expect( + resolveComposerCommandSelection({ + draftMessage: "/pl", + trigger: { rangeStart: 0, rangeEnd: 3 }, + item, + }), + ).toEqual({ text: "/plan ", cursor: 6 }); + }); it("offers T3's /clear in place of the provider's, only at the message start", () => { const claude = { @@ -104,7 +96,6 @@ describe("mobile slash commands", () => { query: "cle", atMessageStart, hasThread: true, - allowInteractionMode: true, offersFreshSession, selectedProviderStatus: claude, }).map((item) => item.id); @@ -112,36 +103,4 @@ describe("mobile slash commands", () => { expect(build(false)).toEqual(["pcmd:clear"]); expect(build(true, false)).toEqual([]); }); - - it("still applies the T3 plan command for supported providers", () => { - const items = buildComposerSlashCommandItems({ - query: "plan", - atMessageStart: true, - hasThread: true, - allowInteractionMode: true, - selectedProviderStatus: { - driver: ProviderDriverKind.make("codex"), - slashCommands: [], - }, - }); - const item = items[0]; - if (!item) throw new Error("Expected the T3 plan command"); - expect( - resolveComposerCommandSelection({ - draftMessage: "/plan", - trigger: { rangeStart: 0, rangeEnd: 5 }, - item, - allowInteractionMode: true, - }), - ).toEqual({ text: "", cursor: 0, interactionMode: "plan" }); - - expect( - resolveComposerCommandSelection({ - draftMessage: "/plan", - trigger: { rangeStart: 0, rangeEnd: 5 }, - item, - allowInteractionMode: false, - }), - ).toEqual({ text: "/plan ", cursor: 6, interactionMode: null }); - }); }); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 1d2fe31a2d420..7ef0885cb6ffd 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -2,7 +2,6 @@ import * as Dialog from "../../components/ui/Dialog"; import type { EnvironmentId, ProjectId, - ProviderInteractionMode, ServerProvider, ThreadId, ThreadPullRequestLink, @@ -107,41 +106,20 @@ export function buildComposerSlashCommandItems(input: { /** T3's `/clear` replaces the provider's, which would clear a session T3 still treats as live. */ readonly offersFreshSession?: boolean; readonly offersSideChat?: boolean; - readonly allowInteractionMode: boolean; - readonly selectedProviderStatus: Pick< - ServerProvider, - "driver" | "slashCommands" | "showInteractionModeToggle" - > | null; + readonly selectedProviderStatus: Pick | null; }): ComposerCommandItem[] { const query = input.query.toLowerCase(); - const allowInteractionMode = - input.allowInteractionMode && input.selectedProviderStatus?.showInteractionModeToggle !== false; - const builtIn = [ - { - id: "cmd:model", - type: "slash-command", - command: "model", - label: "/model", - description: "Switch model", - }, - { - id: "cmd:plan", - type: "slash-command", - command: "plan", - label: "/plan", - description: "Switch to plan mode", - }, - { - id: "cmd:default", - type: "slash-command", - command: "default", - label: "/default", - description: "Switch to default mode", - }, - ] satisfies ComposerCommandItem[]; - const items: ComposerCommandItem[] = builtIn.filter( - (item) => item.command.includes(query) && (item.command === "model" || allowInteractionMode), - ); + const items: ComposerCommandItem[] = "model".includes(query) + ? [ + { + id: "cmd:model", + type: "slash-command", + command: "model", + label: "/model", + description: "Switch model", + }, + ] + : []; // Providers expand commands only at the start of a message. T3 commands // change local state and do not have this restriction, except `/clear`. @@ -199,24 +177,8 @@ export function resolveComposerCommandSelection(input: { readonly draftMessage: string; readonly trigger: Pick; readonly item: ComposerCommandItem; - readonly allowInteractionMode: boolean; -}): { - readonly text: string; - readonly cursor: number; - readonly interactionMode: ProviderInteractionMode | null; -} { +}): { readonly text: string; readonly cursor: number } { const { draftMessage, trigger, item } = input; - if ( - input.allowInteractionMode && - item.type === "slash-command" && - (item.command === "plan" || item.command === "default") - ) { - return { - ...replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""), - interactionMode: item.command, - }; - } - let replacement = ""; if (item.type === "path") { replacement = `${serializeComposerFileLink(item.path)} `; @@ -227,10 +189,7 @@ export function resolveComposerCommandSelection(input: { } else if (item.type === "provider-slash-command") { replacement = `/${item.command.name} `; } - return { - ...replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, replacement), - interactionMode: null, - }; + return replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, replacement); } export function useComposerCommandMenu({ @@ -251,7 +210,6 @@ export function useComposerCommandMenu({ offersUsageLimits = false, enabled = true, onChangeDraftMessage, - onUpdateInteractionMode, onUsageLimits, onFreshSession, offersSideChat = false, @@ -274,7 +232,6 @@ export function useComposerCommandMenu({ readonly offersUsageLimits?: boolean; readonly enabled?: boolean; readonly onChangeDraftMessage: (value: string) => void; - readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void; readonly onUsageLimits?: () => void; /** Arms a fresh session for the next message; absent where `/clear` is unavailable. */ readonly onFreshSession?: () => void; @@ -384,7 +341,6 @@ export function useComposerCommandMenu({ offersUsageLimits, offersFreshSession: onFreshSession !== undefined, offersSideChat, - allowInteractionMode: onUpdateInteractionMode !== undefined, selectedProviderStatus: selectedProviderStatus ? { ...selectedProviderStatus, @@ -636,19 +592,9 @@ export function useComposerCommandMenu({ return; } - const result = resolveComposerCommandSelection({ - draftMessage, - trigger, - item, - allowInteractionMode: - onUpdateInteractionMode !== undefined && - selectedProviderStatus?.showInteractionModeToggle !== false, - }); + const result = resolveComposerCommandSelection({ draftMessage, trigger, item }); setSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); - if (result.interactionMode !== null) { - onUpdateInteractionMode?.(result.interactionMode); - } }; return { diff --git a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts deleted file mode 100644 index 694a34e632e3f..0000000000000 --- a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/reactivity"; - -import { mobilePreferencesAtom } from "../../state/preferences"; -import { resolveLegacyPlanModeEnabled } from "../../state/legacy-plan-mode"; - -export function useLegacyPlanModeState(): { readonly enabled: boolean } { - const preferences = useAtomValue(mobilePreferencesAtom); - const loaded = AsyncResult.isSuccess(preferences); - return { - enabled: resolveLegacyPlanModeEnabled({ - loaded, - preference: loaded ? preferences.value.planModeEnabled : undefined, - }), - }; -} diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts index 4a3b0174ff67d..3bcebe5b55d99 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts @@ -103,4 +103,16 @@ describe("Enriched link presentation", () => { ]); expect(threadChipTargets(here, "No links here.")).toEqual([]); }); + + it("finds thread links with angle-bracketed destinations", () => { + const here = "env-here" as EnvironmentId; + expect(threadChipTargets(here, "See [A]().")).toEqual([ + { + href: "t3-thread://v2/env-here/thread_a", + environmentId: here, + threadId: "thread_a", + link: true, + }, + ]); + }); }); diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.ts b/apps/mobile/src/lib/enrichedLinkPresentation.ts index 12859d2dc8bd8..a6fce5e5b6a9b 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.ts @@ -58,7 +58,7 @@ export function threadChipTargets( ): ReadonlyArray { const targets = new Map(); if (hasThreadLinks(markdown)) { - for (const [, href = ""] of markdown.matchAll(/\]\((t3-thread:\/\/v[12]\/[^\s)]+)\)/g)) { + for (const [, href = ""] of markdown.matchAll(/\]\()]+)>?\)/g)) { const link = parseThreadLinkHref(href); if (link) { targets.set(href, { diff --git a/apps/mobile/src/lib/mediaActions.test.ts b/apps/mobile/src/lib/mediaActions.test.ts index c7747cf2f2e88..df0a337c80a72 100644 --- a/apps/mobile/src/lib/mediaActions.test.ts +++ b/apps/mobile/src/lib/mediaActions.test.ts @@ -11,6 +11,8 @@ const state = vi.hoisted(() => ({ sessions: new Map>(), refresh: vi.fn(), download: vi.fn(), + downloadPreview: vi.fn(), + saveImage: vi.fn(), shareLocal: vi.fn(), shareDraft: vi.fn(), navigate: vi.fn(), @@ -52,8 +54,17 @@ vi.mock("../state/atom-registry", () => ({ })); vi.mock("./attachmentDownload", () => ({ downloadAndShareAttachment: state.download, + downloadAttachmentForPreview: state.downloadPreview, shareLocalAttachment: state.shareLocal, })); +vi.mock("./saveImageToLibrary", () => ({ + canSaveImageToLibrary: (mimeType: string) => mimeType === "image/png", + saveImageToLibrary: state.saveImage, +})); +vi.mock("expo-haptics", () => ({ + notificationAsync: async () => {}, + NotificationFeedbackType: { Success: "success" }, +})); vi.mock("./copyTextWithHaptic", () => ({ copyTextWithHaptic: state.copy })); vi.mock("./localAttachmentPreview", () => ({ loadLocalAttachmentPreview: async () => ({ share: state.shareDraft, dispose: vi.fn() }), @@ -90,6 +101,8 @@ beforeEach(() => { state.sessions.set(environmentId, denied); state.refresh.mockReset().mockResolvedValue("https://host.test/image.png"); state.download.mockReset().mockResolvedValue(undefined); + state.downloadPreview.mockReset(); + state.saveImage.mockReset().mockResolvedValue(undefined); state.shareLocal.mockReset().mockResolvedValue(undefined); state.shareDraft.mockReset().mockResolvedValue(undefined); state.navigate.mockReset(); @@ -109,6 +122,7 @@ it.each(["workspace-file", "media-file"] as const)( expect(state.navigate).not.toHaveBeenCalled(); expect(state.copy).toHaveBeenCalledWith("/repo/image.png"); expect(media.actions.find(({ id }) => id === "save")?.disabled).toBe(true); + expect(media.actions.find(({ id }) => id === "save-image")?.disabled).toBe(true); expect(media.actions.find(({ id }) => id === "open-file")?.disabled).toBe(true); }, ); @@ -178,3 +192,22 @@ it.each(["https://cdn.test/image.png", "file:///device/image.png"])( expect(uri.startsWith("file:") ? state.shareLocal : state.download).toHaveBeenCalledOnce(); }, ); + +it("saves a host image from its downloaded file, then removes the file", async () => { + state.sessions.set(environmentId, granted); + const disposed = Promise.withResolvers(); + state.downloadPreview.mockResolvedValue({ + uri: "file:///cache/image.png", + dispose: disposed.resolve, + }); + + useMediaActions(hostSource()) + .actions.find(({ id }) => id === "save-image")! + .run(); + await disposed.promise; + + expect(state.downloadPreview).toHaveBeenCalledWith( + expect.objectContaining({ url: "https://host.test/image.png" }), + ); + expect(state.saveImage).toHaveBeenCalledWith("file:///cache/image.png", "image/png", "image.png"); +}); diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts index 375cbc6b29fb8..576289fcae56c 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -1,7 +1,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; -import * as Socket from "effect/socket/Socket"; import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; @@ -9,6 +8,7 @@ import * as Dpop from "../features/cloud/dpop"; import * as ManagedRelayLayer from "../features/cloud/managedRelayLayer"; import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; import * as Tracing from "../features/observability/tracing"; +import * as NativeWebSocket from "../native/nativeWebSocket"; import * as Persistence from "../persistence/layer"; import { disposeOnFoundationReplace, type FoundationHotModule } from "./foundation-fast-refresh"; import { whenProtectedDataAvailable } from "./protectedData"; @@ -23,7 +23,7 @@ const layerHttpClient = layerRemoteHttpClient(fetch); type RuntimeLayerSource = | ReturnType - | typeof Socket.layerWebSocketConstructorGlobal + | typeof NativeWebSocket.layer | typeof Dpop.layer | typeof layerHttpClient | typeof Persistence.layer @@ -31,7 +31,7 @@ type RuntimeLayerSource = const layerRuntime = Layer.merge( ManagedRelayLayer.layer(configuredRelayUrl()), - Socket.layerWebSocketConstructorGlobal, + NativeWebSocket.layer, ).pipe( Layer.provideMerge(Dpop.layer), Layer.provideMerge(layerHttpClient), diff --git a/apps/mobile/src/lib/saveImageToLibrary.ts b/apps/mobile/src/lib/saveImageToLibrary.ts new file mode 100644 index 0000000000000..4b9fcd4d2862e --- /dev/null +++ b/apps/mobile/src/lib/saveImageToLibrary.ts @@ -0,0 +1,23 @@ +import { requireNativeModule } from "expo"; +import { Platform } from "react-native"; + +const SAVABLE_IMAGE_MIME_TYPE = /^image\/(png|jpe?g|gif|webp|heic|heif)$/i; + +function baseMimeType(mimeType: string): string { + return mimeType.split(";", 1)[0]?.trim() ?? ""; +} + +// Android 9 and older need a storage permission to add photos, so they keep the share sheet. +export function canSaveImageToLibrary(mimeType: string): boolean { + return ( + SAVABLE_IMAGE_MIME_TYPE.test(baseMimeType(mimeType)) && + (Platform.OS === "ios" || (Platform.OS === "android" && Platform.Version >= 29)) + ); +} + +/** Adds a local image file to Photos on iOS or Pictures/T3 Code on Android. */ +export function saveImageToLibrary(uri: string, mimeType: string, name: string): Promise { + return requireNativeModule<{ + saveImageToLibrary(uri: string, mimeType: string, name: string): Promise; + }>("T3NativeControls").saveImageToLibrary(uri, baseMimeType(mimeType), name); +} diff --git a/apps/mobile/src/native/nativeWebSocket.ios.test.ts b/apps/mobile/src/native/nativeWebSocket.ios.test.ts new file mode 100644 index 0000000000000..0a1a38850b554 --- /dev/null +++ b/apps/mobile/src/native/nativeWebSocket.ios.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Socket from "effect/socket/Socket"; +import { vi } from "vite-plus/test"; + +vi.mock("expo", () => ({ requireOptionalNativeModule: () => null })); + +import * as NativeWebSocket from "./nativeWebSocket.ios"; + +function setup() { + const listeners = new Map void>(); + let nextId = 7; + const native = { + connect: vi.fn(() => nextId++), + sendText: vi.fn(), + sendBinary: vi.fn(), + close: vi.fn(), + addListener: vi.fn((event: string, listener: (event: never) => void) => { + listeners.set(event, listener); + return { remove: () => listeners.delete(event) }; + }), + } satisfies NativeWebSocket.NativeModule; + const emit = (event: "open" | "message" | "error" | "close", payload: Record) => + listeners.get(event)?.(payload as never); + const sockets = new NativeWebSocket.Sockets(native); + const events: Array> = []; + const socket = new NativeWebSocket.Connection(sockets, "ws://host/ws", ["t3"]); + for (const type of ["open", "message", "error", "close"] as const) { + socket.addEventListener(type, (event) => events.push({ ...event })); + } + return { native, emit, socket, events, sockets, id: 7 }; +} + +describe("NativeWebSocket", () => { + it("opens, then delivers text and binary frames as string and ArrayBuffer", () => { + const { native, emit, socket, events, id } = setup(); + expect(native.connect).toHaveBeenCalledWith("ws://host/ws", ["t3"]); + expect(socket.readyState).toBe(NativeWebSocket.Connection.CONNECTING); + expect(() => socket.send("early")).toThrow(/connecting/); + + emit("open", { id }); + expect(socket.readyState).toBe(NativeWebSocket.Connection.OPEN); + + const bytes = new Uint8Array([1, 2, 3]); + emit("message", { id, text: "hello" }); + emit("message", { id, data: bytes.subarray(1) }); + emit("message", { id: id + 1, text: "other socket" }); + expect(events).toEqual([ + { type: "open" }, + { type: "message", data: "hello" }, + { type: "message", data: new Uint8Array([2, 3]) }, + ]); + + socket.send("out"); + socket.send(bytes); + expect(native.sendText).toHaveBeenCalledWith(id, "out"); + expect(native.sendBinary).toHaveBeenCalledWith(id, bytes); + }); + + it("reports the server's close frame once and ignores later events", () => { + const { emit, socket, events, id } = setup(); + emit("open", { id }); + emit("close", { id, code: 1001, reason: "going away", wasClean: true }); + emit("close", { id, code: 1006, reason: "", wasClean: false }); + emit("message", { id, text: "late" }); + expect(socket.readyState).toBe(NativeWebSocket.Connection.CLOSED); + expect(events.slice(1)).toEqual([ + { type: "close", code: 1001, reason: "going away", wasClean: true }, + ]); + }); + + it("fires error before close when the handshake fails", () => { + const { emit, events, id } = setup(); + emit("error", { id, message: "connection refused" }); + emit("close", { id, code: 1006, reason: "", wasClean: false }); + expect(events).toEqual([ + { type: "error", message: "connection refused" }, + { type: "close", code: 1006, reason: "", wasClean: false }, + ]); + }); + + it("closes through the native module once and honours once listeners", () => { + const { native, emit, socket, id } = setup(); + const once = vi.fn(); + const always = vi.fn(); + socket.addEventListener("close", once, { once: true }); + socket.addEventListener("close", always); + socket.addEventListener("close", always); + emit("open", { id }); + socket.close(1000, "done"); + socket.close(1000, "again"); + expect(socket.readyState).toBe(NativeWebSocket.Connection.CLOSING); + expect(native.close).toHaveBeenCalledTimes(1); + expect(native.close).toHaveBeenCalledWith(id, 1000, "done"); + expect(() => socket.send("after close")).not.toThrow(); + expect(native.sendText).not.toHaveBeenCalled(); + + socket.removeEventListener("close", always); + emit("close", { id, code: 1000, reason: "", wasClean: true }); + expect(once).toHaveBeenCalledTimes(1); + expect(always).not.toHaveBeenCalled(); + }); + + it("closes open sockets and stops listening when disposed", () => { + const { native, emit, socket, sockets, events, id } = setup(); + emit("open", { id }); + sockets.dispose(); + expect(native.close).toHaveBeenCalledWith(id, 1001, ""); + expect(socket.readyState).toBe(NativeWebSocket.Connection.CLOSED); + expect(events.at(-1)).toEqual({ type: "close", code: 1001, reason: "", wasClean: false }); + expect(emit("message", { id, text: "late" })).toBeUndefined(); + }); + + it.effect("surfaces a server close through Effect's socket as SocketCloseError", () => + Effect.gen(function* () { + const { emit, socket, id } = setup(); + emit("open", { id }); + const effectSocket = yield* Socket.fromWebSocket(Effect.succeed(socket)); + const reader = yield* effectSocket.reader; + emit("message", { id, text: "hi" }); + emit("close", { id, code: 1011, reason: "boom", wasClean: true }); + expect(yield* reader.pull).toEqual(["hi"]); + const error = yield* Effect.flip(reader.pull); + expect(error.reason).toMatchObject({ + _tag: "SocketCloseError", + code: 1011, + closeReason: "boom", + }); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/mobile/src/native/nativeWebSocket.ios.ts b/apps/mobile/src/native/nativeWebSocket.ios.ts new file mode 100644 index 0000000000000..9cea3c4197daf --- /dev/null +++ b/apps/mobile/src/native/nativeWebSocket.ios.ts @@ -0,0 +1,193 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Socket from "effect/socket/Socket"; +import { requireOptionalNativeModule } from "expo"; + +type EventType = "open" | "message" | "error" | "close"; + +interface NativeEvents { + readonly open: { readonly id: number }; + readonly message: { readonly id: number; readonly text?: string; readonly data?: Uint8Array }; + readonly error: { readonly id: number; readonly message: string }; + readonly close: { + readonly id: number; + readonly code: number; + readonly reason: string; + readonly wasClean: boolean; + }; +} + +export interface NativeModule { + connect(url: string, protocols: ReadonlyArray): number; + sendText(id: number, text: string): void; + sendBinary(id: number, bytes: Uint8Array): void; + close(id: number, code: number, reason: string): void; + addListener( + event: K, + listener: (event: NativeEvents[K]) => void, + ): { remove(): void }; +} + +interface Listener { + readonly listener: (event: Socket.WebSocketEvent) => void; + readonly once: boolean; +} + +/** Subset of the WHATWG WebSocket API that Effect's socket layer touches, over the native module. */ +export class Connection implements Socket.WebSocketLike { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + readonly url: string; + readyState: number = Connection.CONNECTING; + + readonly #id: number; + readonly #sockets: Sockets; + readonly #listeners = new Map>(); + + constructor(sockets: Sockets, url: string, protocols?: string | ReadonlyArray) { + this.url = url; + this.#sockets = sockets; + this.#id = sockets.native.connect( + url, + protocols === undefined ? [] : typeof protocols === "string" ? [protocols] : protocols, + ); + sockets.register(this.#id, this); + } + + addEventListener( + type: EventType, + listener: (event: Socket.WebSocketEvent) => void, + options?: { readonly once?: boolean }, + ): void { + const entries = this.#listeners.get(type) ?? []; + if (entries.some((entry) => entry.listener === listener)) return; + entries.push({ listener, once: options?.once === true }); + this.#listeners.set(type, entries); + } + + removeEventListener(type: EventType, listener: (event: Socket.WebSocketEvent) => void): void { + const entries = this.#listeners.get(type); + if (entries === undefined) return; + this.#listeners.set( + type, + entries.filter((entry) => entry.listener !== listener), + ); + } + + send(data: string | Uint8Array): void { + if (this.readyState === Connection.CONNECTING) { + throw new Error("InvalidStateError: WebSocket is still connecting"); + } + // Browsers drop writes after close instead of throwing. + if (this.readyState !== Connection.OPEN) return; + if (typeof data === "string") { + this.#sockets.native.sendText(this.#id, data); + } else { + this.#sockets.native.sendBinary(this.#id, data); + } + } + + close(code = 1000, reason = ""): void { + if (this.readyState >= Connection.CLOSING) return; + this.readyState = Connection.CLOSING; + this.#sockets.native.close(this.#id, code, reason); + } + + handleOpen(_event: NativeEvents["open"]): void { + if (this.readyState !== Connection.CONNECTING) return; + this.readyState = Connection.OPEN; + this.#dispatch("open", { type: "open" }); + } + + handleMessage(event: NativeEvents["message"]): void { + if (this.readyState !== Connection.OPEN) return; + this.#dispatch("message", { type: "message", data: event.text ?? event.data }); + } + + handleError(event: NativeEvents["error"]): void { + if (this.readyState === Connection.CLOSED) return; + this.#dispatch("error", { type: "error", message: event.message }); + } + + handleClose(event: NativeEvents["close"]): void { + if (this.readyState === Connection.CLOSED) return; + this.readyState = Connection.CLOSED; + this.#sockets.unregister(this.#id); + this.#dispatch("close", { + type: "close", + code: event.code, + reason: event.reason, + wasClean: event.wasClean, + }); + } + + #dispatch(type: EventType, event: Socket.WebSocketEvent & Record): void { + const entries = this.#listeners.get(type); + if (entries === undefined || entries.length === 0) return; + this.#listeners.set( + type, + entries.filter((entry) => !entry.once), + ); + for (const entry of entries) entry.listener(event); + } +} + +/** Routes the module's per-id events to the sockets that own them. Native assigns the ids, so a reload cannot reuse one. */ +export class Sockets { + readonly native: NativeModule; + readonly #open = new Map(); + readonly #subscriptions: ReadonlyArray<{ remove(): void }>; + + constructor(native: NativeModule) { + this.native = native; + this.#subscriptions = [ + native.addListener("open", (event) => this.#open.get(event.id)?.handleOpen(event)), + native.addListener("message", (event) => this.#open.get(event.id)?.handleMessage(event)), + native.addListener("error", (event) => this.#open.get(event.id)?.handleError(event)), + native.addListener("close", (event) => this.#open.get(event.id)?.handleClose(event)), + ]; + } + + register(id: number, socket: Connection): void { + this.#open.set(id, socket); + } + + unregister(id: number): void { + this.#open.delete(id); + } + + dispose(): void { + for (const subscription of this.#subscriptions) subscription.remove(); + for (const socket of this.#open.values()) { + socket.close(1001); + socket.handleClose({ id: 0, code: 1001, reason: "", wasClean: false }); + } + } +} + +function layerFromModule(native: NativeModule): Layer.Layer { + return Layer.effect( + Socket.WebSocketConstructor, + Effect.map( + Effect.acquireRelease( + Effect.sync(() => new Sockets(native)), + (sockets) => Effect.sync(() => sockets.dispose()), + ), + (sockets) => (url, options) => { + if (options !== undefined && typeof options !== "string" && !Array.isArray(options)) { + throw new TypeError("WebSocket client options are not supported by the native WebSocket"); + } + return new Connection(sockets, url, options); + }, + ), + ); +} + +const native = requireOptionalNativeModule("T3WebSocket"); + +// A dev client built before this module shipped still has SocketRocket. +export const layer: Layer.Layer = + native === null ? Socket.layerWebSocketConstructorGlobal : layerFromModule(native); diff --git a/apps/mobile/src/native/nativeWebSocket.ts b/apps/mobile/src/native/nativeWebSocket.ts new file mode 100644 index 0000000000000..00c40e5a9e885 --- /dev/null +++ b/apps/mobile/src/native/nativeWebSocket.ts @@ -0,0 +1,3 @@ +import * as Socket from "effect/socket/Socket"; + +export const layer = Socket.layerWebSocketConstructorGlobal; diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 436f3b172cdae..6dcfe21548a2c 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -116,7 +116,6 @@ export interface Preferences { readonly microphones?: ReadonlyArray; readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; - readonly planModeEnabled?: boolean; readonly toolCallVisibility?: ToolCallVisibility; readonly workingShelfEnabled?: boolean; readonly useLastProjectForNewThreads?: boolean; @@ -201,7 +200,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { microphones?: ReadonlyArray; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; - planModeEnabled?: boolean; toolCallVisibility?: ToolCallVisibility; workingShelfEnabled?: boolean; useLastProjectForNewThreads?: boolean; @@ -326,9 +324,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.projectGroupingMode = parsed.projectGroupingMode; } - if (typeof parsed.planModeEnabled === "boolean") { - preferences.planModeEnabled = parsed.planModeEnabled; - } if (isToolCallVisibility(parsed.toolCallVisibility)) { preferences.toolCallVisibility = parsed.toolCallVisibility; } diff --git a/apps/mobile/src/state/entities.test.ts b/apps/mobile/src/state/entities.test.ts new file mode 100644 index 0000000000000..4bccd724be33d --- /dev/null +++ b/apps/mobile/src/state/entities.test.ts @@ -0,0 +1,72 @@ +import { EnvironmentId, ProjectId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ reply: null as unknown })); +vi.mock(import("./threads"), async () => { + const { Atom } = await import("effect/reactivity"); + return { + environmentThreadDetails: {} as never, + environmentThreadShells: { + threadShellAtom: () => Atom.make(null), + threadRefsAtom: Atom.make([{ environmentId: "remote", threadId: "older-thread" }]), + } as never, + }; +}); +vi.mock(import("./orchestration"), () => ({ + orchestrationEnvironment: { threadLinkMetadata: () => state.reply } as never, +})); +vi.mock(import("./atom-registry"), () => ({ appAtomRegistry: {} as never })); +vi.mock(import("./projects"), () => ({ environmentProjects: {} as never })); +vi.mock(import("./server"), () => ({ + environmentServerConfigsAtom: {} as never, + serverEnvironment: {} as never, +})); +vi.mock(import("./assets"), () => ({ projectFaviconUrlAtom: (() => null) as never })); +vi.mock(import("../native/ProjectChipIconImages"), () => ({ + projectGlyphImageAtom: (() => null) as never, + projectGlyphKey: (() => "") as never, +})); + +import { resolveThreadLink } from "./entities"; + +function setup() { + const reply = Atom.make< + AsyncResult.AsyncResult, string> + >(AsyncResult.initial()); + state.reply = reply; + return { registry: AtomRegistry.make(), reply }; +} + +it("uses confirmed matches instead of provisional titles when resolving legacy links", () => { + const { registry, reply } = setup(); + using _registry = { [Symbol.dispose]: () => registry.dispose() }; + const local: ScopedThreadRef = { + environmentId: EnvironmentId.make("local"), + threadId: ThreadId.make("older-thread"), + }; + const remote: ScopedThreadRef = { ...local, environmentId: EnvironmentId.make("remote") }; + const link = { environmentId: null, threadId: local.threadId }; + const resolve = () => resolveThreadLink(registry.get.bind(registry), link, local.environmentId); + const release = registry.mount(reply); + try { + expect(resolve()).toEqual(remote); + registry.set(reply, AsyncResult.failure(Cause.fail("offline"))); + expect(resolve()).toEqual(remote); + expect(resolveThreadLink(registry.get.bind(registry), local, remote.environmentId)).toEqual( + local, + ); + registry.set( + reply, + AsyncResult.success({ + [local.threadId]: { title: "Thread", projectId: ProjectId.make("project") }, + }), + ); + expect(resolve()).toEqual(local); + registry.set(reply, AsyncResult.success({})); + expect(resolve()).toEqual(remote); + } finally { + release(); + } +}); diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 1625998fdc629..c9d1834a78bdb 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -32,6 +32,7 @@ import { threadChipTargets } from "../lib/enrichedLinkPresentation"; import { resolveProjectChipIcon } from "../lib/projectIcon"; import { projectGlyphImageAtom, projectGlyphKey } from "../native/ProjectChipIconImages"; import { projectFaviconUrlAtom } from "./assets"; +import { orchestrationEnvironment } from "./orchestration"; import { environmentProjects } from "./projects"; import { environmentServerConfigsAtom, serverEnvironment } from "./server"; import { environmentThreadDetails, environmentThreadShells } from "./threads"; @@ -55,12 +56,22 @@ const EMPTY_THREAD_LINK_TITLES_ATOM = Atom.make>(new const decodeThreadKey = Schema.decodeUnknownSync(Schema.Tuple([EnvironmentId, ThreadId])); const threadKey = (ref: ScopedThreadRef) => JSON.stringify([ref.environmentId, ref.threadId]); -// Null when this client doesn't know the thread. Keyed, so a link never walks an environment's threads. +// Omitted and archived targets need only a metadata lookup, not their conversation history. const threadTitleAtom = Atom.family((key: string) => { const ref = scopeThreadRef(...decodeThreadKey(JSON.parse(key))); - return Atom.make((get) => get(environmentThreadShells.threadShellAtom(ref))?.title ?? null).pipe( - Atom.withLabel(`mobile-thread-title:${key}`), - ); + return Atom.make((get) => { + const shell = get(environmentThreadShells.threadShellAtom(ref)); + if (shell !== null) return shell.title; + const titles = get( + orchestrationEnvironment.threadLinkMetadata({ + environmentId: ref.environmentId, + input: { threadIds: [ref.threadId] }, + }), + ); + return AsyncResult.isSuccess(titles) + ? (titles.value[ref.threadId]?.title ?? (titles.waiting ? "Thread" : null)) + : "Thread"; + }).pipe(Atom.withLabel(`mobile-thread-title:${key}`)); }); const decodeThreadKeys = Schema.decodeUnknownSync(Schema.Array(Schema.String)); @@ -92,11 +103,22 @@ const threadProjectChipIconAtom = Atom.family((key: string) => { const ref = scopeThreadRef(...decodeThreadKey(JSON.parse(key))); return Atom.make((get): string | null => { const shell = get(environmentThreadShells.threadShellAtom(ref)); - const project = shell + const metadata = + shell ?? + AsyncResult.getOrElse( + get( + orchestrationEnvironment.threadLinkMetadata({ + environmentId: ref.environmentId, + input: { threadIds: [ref.threadId] }, + }), + ), + () => null, + )?.[ref.threadId]; + const project = metadata?.projectId ? get( environmentProjects.projectAtom({ environmentId: ref.environmentId, - projectId: shell.projectId, + projectId: metadata.projectId, }), ) : null; @@ -222,13 +244,20 @@ export function resolveThreadLink( link: ThreadLink, messageEnvironmentId: EnvironmentId, ): ScopedThreadRef | null { - const known = threadLinkCandidates(link, messageEnvironmentId).find( - (ref) => read(threadTitleAtom(threadKey(ref))) !== null, - ); + const known = threadLinkCandidates(link, messageEnvironmentId).find((ref) => { + if (read(environmentThreadShells.threadShellAtom(ref)) !== null) return true; + const metadata = read( + orchestrationEnvironment.threadLinkMetadata({ + environmentId: ref.environmentId, + input: { threadIds: [ref.threadId] }, + }), + ); + return AsyncResult.getOrElse(metadata, () => null)?.[ref.threadId] !== undefined; + }); if (known) return known; return link.environmentId === null ? guessThreadLinkRef(link, read(environmentThreadShells.threadRefsAtom)) - : null; + : scopeThreadRef(link.environmentId, link.threadId); } export function useThreadChipIcons( diff --git a/apps/mobile/src/state/legacy-plan-mode.ts b/apps/mobile/src/state/legacy-plan-mode.ts deleted file mode 100644 index f4ab2f8c681c2..0000000000000 --- a/apps/mobile/src/state/legacy-plan-mode.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - DEFAULT_PROVIDER_INTERACTION_MODE, - type ProviderInteractionMode, - type ServerProvider, -} from "@t3tools/contracts"; - -type InteractionModeProvider = Pick; - -export function resolveProviderInteractionMode( - provider: InteractionModeProvider | null | undefined, - interactionMode: ProviderInteractionMode | null | undefined, -): ProviderInteractionMode { - return provider?.showInteractionModeToggle === false - ? DEFAULT_PROVIDER_INTERACTION_MODE - : (interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE); -} - -export function resolveLegacyPlanModeEnabled(input: { - readonly loaded: boolean; - readonly preference: boolean | undefined; -}): boolean { - return input.loaded && input.preference === true; -} - -export function resolvePendingTaskInteractionMode(input: { - readonly planModeEnabled: boolean; - readonly draftInteractionMode: ProviderInteractionMode | undefined; - readonly provider?: InteractionModeProvider | null; -}): ProviderInteractionMode { - if (input.provider?.showInteractionModeToggle === false || !input.planModeEnabled) { - return DEFAULT_PROVIDER_INTERACTION_MODE; - } - return input.draftInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; -} diff --git a/apps/mobile/src/state/mediaActions.ts b/apps/mobile/src/state/mediaActions.ts index 22a3a9a26bb69..12fbfb3911ecd 100644 --- a/apps/mobile/src/state/mediaActions.ts +++ b/apps/mobile/src/state/mediaActions.ts @@ -14,15 +14,21 @@ import { } from "@t3tools/contracts"; import { normalizeNativeMarkdownUrl } from "../lib/markdownLinks"; import { AsyncResult } from "effect/reactivity"; +import * as Haptics from "expo-haptics"; import { useEffect, useRef, useState } from "react"; import { useRefreshAssetUrl } from "./assets"; import { appAtomRegistry } from "./atom-registry"; import { useEnvironmentQuery } from "./query"; import { environmentSession } from "./session"; -import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload"; +import { + downloadAndShareAttachment, + downloadAttachmentForPreview, + shareLocalAttachment, +} from "../lib/attachmentDownload"; import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { canSaveImageToLibrary, saveImageToLibrary } from "../lib/saveImageToLibrary"; function allowsHostMedia(session: SessionGrantInput | null) { return session === null || sessionGrantsScope(session, AuthFilesystemReadScope); @@ -53,41 +59,23 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi source && "resource" in source ? source.resource : null, ); const controller = useRef(null); - const [sharing, setSharing] = useState(false); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- aborts the in-flight media share when the component unmounts + const [busy, setBusy] = useState<"share" | "save-image" | null>(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- aborts the in-flight media task when the component unmounts useEffect(() => () => controller.current?.abort(), []); - const share = () => { + const run = ( + kind: "share" | "save-image", + task: (source: MediaActionsSource, signal: AbortSignal) => Promise, + ) => { if (!source || controller.current || !canReadHostMedia(hostEnvironmentId)) return; const request = new AbortController(); controller.current = request; - setSharing(true); - return (async () => { - if ("attachment" in source) { - const preview = await loadLocalAttachmentPreview(source.attachment, request.signal); - if (!preview) return; - const shared = await settlePromise(() => - preview.share(request.signal, source.sourceIdentifier), - ); - preview.dispose(); - if (AsyncResult.isFailure(shared)) throw squashAtomCommandFailure(shared); - return; - } - const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh(); - if (request.signal.aborted || !canReadHostMedia(hostEnvironmentId)) return; - if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again."); - const input = { - attachment: { name: source.name, mimeType: source.mimeType }, - signal: request.signal, - sourceIdentifier: source.sourceIdentifier, - }; - if (/^(file|content):/i.test(uri)) await shareLocalAttachment({ ...input, uri }); - else await downloadAndShareAttachment({ ...input, url: uri }); - })() + setBusy(kind); + return task(source, request.signal) .catch((error: unknown) => { if (!request.signal.aborted) { Dialog.alert( - "Could not share file", + kind === "share" ? "Could not share file" : "Could not save image", error instanceof Error ? error.message : "Try again.", ); } @@ -95,11 +83,64 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi .finally(() => { if (controller.current === request) { controller.current = null; - if (!request.signal.aborted) setSharing(false); + if (!request.signal.aborted) setBusy(null); } }); }; + const hostUri = async (source: MediaActionsSource, signal: AbortSignal) => { + const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh(); + if (signal.aborted || !canReadHostMedia(hostEnvironmentId)) return null; + if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again."); + return uri; + }; + + const share = () => + run("share", async (source, signal) => { + if ("attachment" in source) { + const preview = await loadLocalAttachmentPreview(source.attachment, signal); + if (!preview) return; + const shared = await settlePromise(() => preview.share(signal, source.sourceIdentifier)); + preview.dispose(); + if (AsyncResult.isFailure(shared)) throw squashAtomCommandFailure(shared); + return; + } + const uri = await hostUri(source, signal); + if (uri === null) return; + const input = { + attachment: { name: source.name, mimeType: source.mimeType }, + signal, + sourceIdentifier: source.sourceIdentifier, + }; + if (/^(file|content):/i.test(uri)) await shareLocalAttachment({ ...input, uri }); + else await downloadAndShareAttachment({ ...input, url: uri }); + }); + + const localImageFile = async (source: MediaActionsSource, signal: AbortSignal) => { + if ("attachment" in source) return loadLocalAttachmentPreview(source.attachment, signal); + const uri = await hostUri(source, signal); + if (uri === null) return null; + if (/^(file|content):/i.test(uri)) return { uri, dispose: () => {} }; + return downloadAttachmentForPreview({ + url: uri, + attachment: { name: source.name, mimeType: source.mimeType }, + signal, + }); + }; + + const saveImage = () => + run("save-image", async (source, signal) => { + const file = await localImageFile(source, signal); + if (!file) return; + const saved = signal.aborted + ? null + : await settlePromise(() => saveImageToLibrary(file.uri, source.mimeType, source.name)); + file.dispose(); + if (saved === null) return; + if (AsyncResult.isFailure(saved)) throw squashAtomCommandFailure(saved); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}); + }); + const reference = source?.reference; const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; const threadId = source && "threadId" in source ? source.threadId : undefined; @@ -152,18 +193,33 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi }, ] : []), + ...(canSaveImageToLibrary(source.mimeType) + ? [ + { + id: "save-image" as const, + title: busy === "save-image" ? "Saving image…" : "Save image", + run: saveImage, + disabled: busy !== null || !canReadMedia, + }, + ] + : []), { id: "save" as const, - title: sharing ? "Opening share sheet…" : "Save or share", + title: + busy === "share" + ? "Opening share sheet…" + : canSaveImageToLibrary(source.mimeType) + ? "Share" + : "Save or share", run: share, - disabled: sharing || !canReadMedia, + disabled: busy !== null || !canReadMedia, }, ] : []; return { title: reference?.kind === "file" ? reference.path : reference?.url, actions, - sharing, + sharing: busy === "share", share, }; } diff --git a/apps/mobile/src/state/orchestration.ts b/apps/mobile/src/state/orchestration.ts index 766185f2a71dc..b18d4acb68082 100644 --- a/apps/mobile/src/state/orchestration.ts +++ b/apps/mobile/src/state/orchestration.ts @@ -1,8 +1,10 @@ import { createOrchestrationEnvironmentAtoms } from "@t3tools/client-runtime/state/orchestration"; import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentShell } from "./shell"; import { recordReviewTurnDiffAnswer } from "./reviewTurnDiffs"; export const orchestrationEnvironment = createOrchestrationEnvironmentAtoms(connectionAtomRuntime, { + shellState: environmentShell.stateValueAtom, onCheckpointDiffAnswer: recordReviewTurnDiffAnswer, }); diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 4796ec19f27ec..32f4a52237726 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -7,6 +7,7 @@ import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell import type { ThreadFamilyRead } from "@t3tools/client-runtime/state/threads"; import { CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, EnvironmentId, IsoDateTime, MessageId, @@ -28,7 +29,6 @@ import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import type { ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; import type { DraftComposerAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -import { resolveProviderInteractionMode } from "./legacy-plan-mode"; // Keep current writes until a compatible native baseline includes the v4 reader. const THREAD_OUTBOX_SCHEMA_VERSION = 3; @@ -105,6 +105,16 @@ export interface ThreadSettingsSnapshot { readonly interactionMode: ProviderInteractionModeType; } +// Providers without a Build/Plan toggle always run in build mode. +function resolveProviderInteractionMode( + provider: Pick | undefined, + interactionMode: ProviderInteractionModeType, +): ProviderInteractionModeType { + return provider?.showInteractionModeToggle === false + ? DEFAULT_PROVIDER_INTERACTION_MODE + : interactionMode; +} + export function resolveQueuedThreadSettings( message: QueuedThreadMessage, thread: ThreadSettingsSnapshot, diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index 1bebfb373e327..5a31c1955b928 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -271,11 +271,7 @@ describe("mobile task permissions", () => { expect(state.enqueue).not.toHaveBeenCalled(); expect(state.clearDraft).not.toHaveBeenCalled(); composer.onChangeDraftMessage("Edited locally"); - composer.onUpdateInteractionMode("plan"); expect(state.draft.text).toBe("Edited locally"); - expect(state.updateSettings).toHaveBeenCalledWith("secondary:thread", { - interactionMode: "plan", - }); }); it("keeps an armed fresh session for the message after an approval redirect", async () => { diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index e19009fcc16ba..465a8a425d292 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -26,10 +26,8 @@ import { orchestrationV2WorktreePending, type EnvironmentId, type ModelSelection, - type ProviderInteractionMode, type RuntimeMode, type ScopedThreadRef, - type ServerProvider, type ThreadId, } from "@t3tools/contracts"; @@ -49,7 +47,6 @@ import { describeUnavailableModelSelection, isModelSelectionUnavailable, } from "../lib/modelOptions"; -import { resolveProviderInteractionMode } from "./legacy-plan-mode"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; @@ -181,19 +178,8 @@ export function useThreadDraftForThread(input: { }; } -/** A provider without the interaction-mode toggle runs in the default mode. */ -export function updateThreadDraftModelSelection( - threadKey: string, - value: ModelSelection, - providers: ReadonlyArray, -): void { - const provider = providers.find((candidate) => candidate.instanceId === value.instanceId); - updateComposerDraftSettings(threadKey, { - modelSelection: value, - ...(provider?.showInteractionModeToggle === false - ? { interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE } - : {}), - }); +export function updateThreadDraftModelSelection(threadKey: string, value: ModelSelection): void { + updateComposerDraftSettings(threadKey, { modelSelection: value }); } export function useThreadComposerState(input: { @@ -328,15 +314,8 @@ export function useThreadComposerState(input: { serverConfig?.providers ?? [], ); const runtimeMode = selectedDraft.runtimeMode ?? selectedThread?.runtimeMode ?? null; - const selectedProvider = selectedEnvironmentRuntime?.serverConfig?.providers.find( - (provider) => provider.instanceId === modelSelection?.instanceId, - ); - const interactionMode = selectedThread - ? resolveProviderInteractionMode( - selectedProvider, - selectedDraft.interactionMode ?? selectedThread.interactionMode, - ) - : null; + // Plan mode is not offered to users; every send runs in build mode. + const interactionMode = selectedThread ? DEFAULT_PROVIDER_INTERACTION_MODE : null; const canSwitchThreadProvider = threadAllowsProviderSwitch({ thread: selectedThreadShell, projection: selectedThreadProjection?.projection, @@ -781,10 +760,7 @@ export function useThreadComposerState(input: { context: draft.context, modelSelection, runtimeMode: draft.runtimeMode ?? thread.runtimeMode, - interactionMode: resolveProviderInteractionMode( - provider, - draft.interactionMode ?? thread.interactionMode, - ), + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, ...(compactBeforeSend ? { compactBeforeSend, dispatchMode: "queue" as const } : followUpDispatchMode === null @@ -865,20 +841,6 @@ export function useThreadComposerState(input: { removeComposerDraftAttachment(threadKey, imageId); }; - const onUpdateInteractionMode = (value: ProviderInteractionMode) => { - if (!selectedThreadKey) { - return; - } - const modelSelection = - getComposerDraftSnapshot(selectedThreadKey).modelSelection ?? selectedThread?.modelSelection; - const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find( - (candidate) => candidate.instanceId === modelSelection?.instanceId, - ); - updateComposerDraftSettings(selectedThreadKey, { - interactionMode: resolveProviderInteractionMode(provider, value), - }); - }; - return { feedbackSubmissions, dismissFeedback, @@ -917,7 +879,6 @@ export function useThreadComposerState(input: { onNativePasteText, onRemoveDraftImage, onSendMessage, - onUpdateInteractionMode, sideChatUnavailableReason, }; } diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index e59c428dca0aa..b5cade0f84d46 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -68,6 +68,12 @@ describe("RPC authorization scopes", () => { } }); + it("lets thread readers observe and refresh checkout branches", () => { + for (const method of [WS_METHODS.subscribeVcsBranch, WS_METHODS.vcsRefreshBranch]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope); + } + }); + it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( @@ -133,6 +139,61 @@ describe("RPC scope middleware", () => { ); }); +it.effect.each([WS_METHODS.subscribeVcsBranch, WS_METHODS.subscribeVcsStatus] as const)( + "enforces read permission on stream %s", + (method) => + Effect.gen(function* () { + const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + ( + tag, + ): tag is Exclude< + keyof typeof RPC_REQUIRED_SCOPES, + typeof WS_METHODS.subscribeVcsBranch | typeof WS_METHODS.subscribeVcsStatus + > => tag !== WS_METHODS.subscribeVcsBranch && tag !== WS_METHODS.subscribeVcsStatus, + ), + ); + let handled = 0; + const handlers = Layer.mergeAll( + group.toLayerHandler(WS_METHODS.subscribeVcsBranch, () => + Stream.fromEffect( + Effect.sync(() => { + handled++; + return { refName: "main" }; + }), + ), + ), + group.toLayerHandler(WS_METHODS.subscribeVcsStatus, () => + Stream.fromEffect( + Effect.sync(() => { + handled++; + }), + ).pipe(Stream.drain), + ), + ); + const denied = yield* RpcTest.makeClient(group).pipe( + Effect.provide(Layer.merge(handlers, RpcAuthorization.layer([]))), + ); + expect( + yield* Stream.runDrain( + method === WS_METHODS.subscribeVcsBranch + ? denied[method]({ cwd: "/repo" }).pipe(Stream.drain) + : denied[method]({ cwd: "/repo" }).pipe(Stream.drain), + ).pipe(Effect.flip), + ).toMatchObject({ requiredPermission: AuthOrchestrationReadScope }); + expect(handled).toBe(0); + const granted = yield* RpcTest.makeClient(group).pipe( + Effect.provide(Layer.merge(handlers, RpcAuthorization.layer([AuthOrchestrationReadScope]))), + ); + yield* Stream.runDrain( + method === WS_METHODS.subscribeVcsBranch + ? granted[method]({ cwd: "/repo" }).pipe(Stream.drain) + : granted[method]({ cwd: "/repo" }).pipe(Stream.drain), + ); + expect(handled).toBe(1); + }).pipe(Effect.scoped), +); + describe("settings mutation authorization", () => { const group = WsRpcGroup.omit( ...[...WsRpcGroup.requests.keys()].filter( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 50dc2ef537f22..c08d29954495c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -52,6 +52,7 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getThreadTitles]: AuthOrchestrationReadScope, + [ORCHESTRATION_V2_WS_METHODS.getThreadLinkMetadata]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getOlderSettledThreads]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: AuthOrchestrationReadScope, @@ -169,6 +170,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.mcpAppsReadResource]: AuthOrchestrationReadScope, [WS_METHODS.mcpAppsUpdateModelContext]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeVcsBranch]: AuthOrchestrationReadScope, + [WS_METHODS.vcsRefreshBranch]: AuthOrchestrationReadScope, [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeResourceTelemetry]: AuthDiagnosticsReadScope, diff --git a/apps/server/src/cli/server.ts b/apps/server/src/cli/server.ts index 3b2768499fdb6..0e90979439ae3 100644 --- a/apps/server/src/cli/server.ts +++ b/apps/server/src/cli/server.ts @@ -8,6 +8,7 @@ import * as CliError from "effect/cli/CliError"; import * as ServerConfig from "../config.ts"; import { awaitLauncherLoss } from "../launcherWatch.ts"; +import * as DatabaseMaintenance from "../persistence/DatabaseMaintenance.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as AutomaticPort from "./automaticPort.ts"; import { type CliServerFlags, resolveServerConfig, sharedServerCommandFlags } from "./config.ts"; @@ -34,7 +35,15 @@ const runServerCommand = Effect.fn("server.runServerCommand")(function* ( const logLevel = yield* GlobalFlag.LogLevel; const selectAutomaticPort = yield* AutomaticPort.make.pipe(Effect.provide(ProcessRunner.layer)); const config = yield* resolveServerConfig(flags, logLevel, { ...options, selectAutomaticPort }); - const run = runServer.pipe(Effect.provideService(ServerConfig.ServerConfig, config)); + const run = runServer.pipe( + Effect.provideService(ServerConfig.ServerConfig, config), + // Settings' Reclaim stops the server; repair once the database is closed, before the restart. + Effect.ensuring( + DatabaseMaintenance.runRequestedReclaim(config.dbPath).pipe( + Effect.provide(DatabaseMaintenance.layer), + ), + ), + ); if (config.mode !== "desktop" || config.desktopBootstrapToken === undefined) return yield* run; yield* Effect.sync(() => { process.stdout.on("error", ignoreClosedLauncherPipe); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 468cc89ad01e6..d4883688c08ef 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -42,6 +42,7 @@ export interface ServerDerivedPaths { readonly environmentThemesDir: string; readonly providerStatusCacheDir: string; readonly worktreesDir: string; + readonly worktreeCheckoutCacheDir: string; readonly attachmentsDir: string; readonly browserArtifactsDir: string; readonly logsDir: string; @@ -158,6 +159,7 @@ export const deriveServerPaths = Effect.fn(function* ( environmentThemesDir: join(stateDir, "themes"), providerStatusCacheDir, worktreesDir: join(baseDir, "worktrees"), + worktreeCheckoutCacheDir: join(providerStatusCacheDir, "worktree-checkouts"), attachmentsDir, browserArtifactsDir: join(stateDir, "browser-artifacts"), logsDir, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 6d2c30db16130..f080cc3588b42 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -227,6 +227,7 @@ export const make = Effect.gen(function* () { pullRequests: true, pullRequestChecks: true, sideChats: true, + vcsBranchStatus: true, inlineMessageContext: true, requiredWorktreeBootstrap: true, threadSettlement: true, @@ -238,6 +239,7 @@ export const make = Effect.gen(function* () { projectWorktreeCleanup: true, settledWorktreeCleanup: true, worktreesDirectory: true, + ...(hostPlatform === "darwin" ? { fastWorktreeCheckout: true } : {}), threadAutoSettlementScope: true, threadRestartContinuation: true, projectSettingsOverrides: true, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 9f0680ed1fa4c..2936f054fd3e1 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -802,9 +802,20 @@ export const make = Effect.gen(function* () { } return result; }); - const readWorktreesDirectory = serverSettingsService.getSettings.pipe( - Effect.map((settings) => settings.worktreesDirectory), - Effect.orElseSucceed(() => ""), + const readWorktreeCreationSettings = serverSettingsService.getSettings.pipe( + Effect.map((settings) => ({ + worktreesDirectory: settings.worktreesDirectory, + fastCheckout: settings.fastWorktreeCheckout, + })), + Effect.orElseSucceed(() => ({ worktreesDirectory: "" })), + ); + // Turning fast checkout off deletes its cache right away, not at the next worktree. + yield* serverSettingsService.streamChanges.pipe( + Stream.map((settings) => settings.fastWorktreeCheckout), + Stream.changes, + Stream.filter((enabled) => !enabled), + Stream.runForEach(() => gitCore.clearWorktreeCheckoutCache), + Effect.forkScoped, ); const createWorktree: GitManager["Service"]["createWorktree"] = Effect.fn( "GitManager.createWorktree", @@ -816,9 +827,8 @@ export const make = Effect.gen(function* () { Effect.map(({ settings }) => settings.worktreeSubmodules), Effect.orElseSucceed(() => null), ); - const worktreesDirectory = yield* readWorktreesDirectory; const result = yield* gitCore.createWorktree(input, { - worktreesDirectory, + ...(yield* readWorktreeCreationSettings), ...options, submodules, }); @@ -3138,7 +3148,7 @@ export const make = Effect.gen(function* () { path: null, }, { - worktreesDirectory: yield* readWorktreesDirectory, + ...(yield* readWorktreeCreationSettings), submodules: yield* projectSettingsFor(input).pipe( Effect.map(({ settings }) => settings.worktreeSubmodules), Effect.orElseSucceed(() => null), diff --git a/apps/server/src/htmlRender/HtmlRender.test.ts b/apps/server/src/htmlRender/HtmlRender.test.ts index 645f863a16b47..859491e800029 100644 --- a/apps/server/src/htmlRender/HtmlRender.test.ts +++ b/apps/server/src/htmlRender/HtmlRender.test.ts @@ -4,6 +4,7 @@ import { assert, describe, expect, it } from "@effect/vitest"; import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -259,6 +260,119 @@ describe("HtmlRender", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("publishes a page from an .html file as a snapshot that path calls recover", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const htmlRender = yield* HtmlRender.HtmlRender; + const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + const threadId = ThreadId.make("thread-html-path"); + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-page-" }); + const file = path.join(directory, "report.html"); + yield* fileSystem.writeFileString(file, "

First draft

"); + const page = { path: file, title: "Report", height: 300 }; + + const first = yield* htmlRender.publish({ threadId, ...page }); + yield* fileSystem.writeFileString(file, "

Second draft

"); + const second = yield* htmlRender.publish({ threadId, ...page }); + const inline = { html: "

First draft

", title: "Report", height: 300 }; + const inlineReference = yield* htmlRender.publish({ threadId, ...inline }); + + const stored = yield* fileSystem.readFileString( + resolveAttachmentPathById({ + attachmentsDir: config.attachmentsDir, + attachmentId: first.attachmentId, + }) ?? "", + ); + expect(stored).toContain("

First draft

"); + expect(yield* mcpSessions.takeHtmlRender(threadId, { ...page, path: `${file}x` })).toBe( + undefined, + ); + for (const html of [inline.html, false]) { + expect(yield* mcpSessions.takeHtmlRender(threadId, { ...page, html })).toBe(undefined); + } + expect(yield* mcpSessions.takeHtmlRender(threadId, { ...inline, path: null })).toEqual( + inlineReference, + ); + expect(yield* mcpSessions.takeHtmlRender(threadId, { ...page, html: null })).toEqual(first); + expect(yield* mcpSessions.takeHtmlRender(threadId, page)).toEqual(second); + expect(yield* mcpSessions.takeHtmlRender(threadId, page)).toBe(undefined); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect("refuses anything but a regular, singly linked .html file of at most 25 MiB", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const htmlRender = yield* HtmlRender.HtmlRender; + const threadId = ThreadId.make("thread-html-path-guard"); + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-page-" }); + const secret = path.join(directory, "id_rsa"); + yield* fileSystem.writeFileString(secret, "PRIVATE KEY"); + const page = path.join(directory, "page.html"); + yield* fileSystem.writeFileString(page, "

linked

"); + const hardLinked = path.join(directory, "linked.html"); + yield* fileSystem.link(page, hardLinked); + // `mv .env renamed.html` keeps one link and an .html name; only its bytes give it away. + const renamed = path.join(directory, "renamed.html"); + yield* fileSystem.writeFileString(renamed, "API_KEY=abc123\n"); + const folder = path.join(directory, "folder.html"); + yield* fileSystem.makeDirectory(folder); + const internal = path.join(config.secretsDir, "page.html"); + yield* fileSystem.makeDirectory(config.secretsDir, { recursive: true }); + yield* fileSystem.writeFileString(internal, "

internal

"); + + for (const file of [ + "page.html", + "//attacker/share/page.html", + path.join(directory, "missing.html"), + secret, + hardLinked, + renamed, + folder, + internal, + ]) { + const error = yield* htmlRender + .publish({ threadId, path: file, title: "X", height: 200 }) + .pipe(Effect.flip); + expect(error, file).toBeInstanceOf(HtmlRender.HtmlRenderFileError); + } + const huge = path.join(directory, "huge.html"); + yield* fileSystem.writeFileString(huge, ""); + yield* fileSystem.truncate(huge, 25 * 1024 * 1024 + 1); + const tooLarge = yield* htmlRender + .publish({ threadId, path: huge, title: "X", height: 200 }) + .pipe(Effect.flip); + expect(tooLarge).toBeInstanceOf(HtmlRender.HtmlRenderFileTooLargeError); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect.skipIf(!symlinksSupported)("refuses an .html symlink to another file", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const htmlRender = yield* HtmlRender.HtmlRender; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-page-" }); + const secret = path.join(directory, "id_rsa"); + yield* fileSystem.writeFileString(secret, "PRIVATE KEY"); + const symlinked = path.join(directory, "symlinked.html"); + yield* fileSystem.symlink(secret, symlinked); + + const error = yield* htmlRender + .publish({ + threadId: ThreadId.make("thread-html-path-link"), + path: symlinked, + title: "X", + height: 200, + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(HtmlRender.HtmlRenderFileError); + }).pipe(Effect.provide(layerTest)), + ); + it.effect("removes the page when publishing is interrupted", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-html-cancel"); diff --git a/apps/server/src/htmlRender/HtmlRender.ts b/apps/server/src/htmlRender/HtmlRender.ts index d1330cc679ab5..39cb7a463dbd4 100644 --- a/apps/server/src/htmlRender/HtmlRender.ts +++ b/apps/server/src/htmlRender/HtmlRender.ts @@ -1,6 +1,7 @@ import type { ThreadId } from "@t3tools/contracts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { isNetworkPath } from "@t3tools/shared/path"; import { clampHtmlRenderHeight, HTML_RENDER_COLUMN_WIDTH, @@ -22,14 +23,16 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import type * as Path from "effect/Path"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { openMediaFile } from "../assets/MediaFile.ts"; import { resolveAttachmentRelativePath } from "../attachmentPaths.ts"; import { createAttachmentId } from "../attachmentStore.ts"; +import { makeIsServerInternalPath } from "../auth/serverInternalPaths.ts"; import { resolveRootCliCommand } from "../cli/invocation.ts"; import * as ServerConfig from "../config.ts"; import * as HeadlessChrome from "./headlessChrome.ts"; @@ -39,8 +42,29 @@ import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts"; const MIB = 1024 * 1024; const MAX_IMAGE_BYTES = 10 * MIB; const MAX_PAGE_BYTES = 25 * MIB; +const HTML_FILE_EXTENSION = /\.html?$/i; +// Like the image check, judge the bytes: a renamed .env or key does not start like a page. +const HTML_DOCUMENT_START = /^\s*<(?:!doctype\s+html|html)[\s>]/i; const formatMib = (bytes: number) => `${(bytes / MIB).toFixed(1)} MiB`; +export class HtmlRenderFileError extends Schema.TaggedError()( + "HtmlRenderFileError", + { path: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) { + override get message(): string { + return `${this.path} is not a readable HTML document. Pass an absolute path to a regular .html file that starts with or , or pass html instead.`; + } +} + +export class HtmlRenderFileTooLargeError extends Schema.TaggedError()( + "HtmlRenderFileTooLargeError", + { path: Schema.String, sizeBytes: Schema.Number }, +) { + override get message(): string { + return `${this.path} is ${formatMib(this.sizeBytes)}; a page must be at most ${formatMib(MAX_PAGE_BYTES)}.`; + } +} + export class HtmlRenderImagesNotFoundError extends Schema.TaggedError()( "HtmlRenderImagesNotFoundError", { paths: Schema.Array(Schema.String) }, @@ -108,6 +132,8 @@ export type HtmlRenderPrepareError = | HtmlRenderPageTooLargeError | HtmlRenderViewportHeightError; +export type HtmlRenderSource = { readonly html: string } | { readonly path: string }; + export interface HtmlPreview { readonly png: string; readonly width: number; @@ -121,18 +147,28 @@ export class HtmlRender extends Context.Service< HtmlRender, { readonly prepare: (html: string) => Effect.Effect; - readonly publish: (input: { - readonly threadId: ThreadId; - readonly html: string; - readonly title: string; - readonly height: number; - }) => Effect.Effect; - readonly preview: (input: { - readonly html: string; - readonly width?: number | undefined; - readonly appearance?: ThemeAppearance | undefined; - }) => Effect.Effect< + readonly publish: ( + input: HtmlRenderSource & { + readonly threadId: ThreadId; + readonly title: string; + readonly height: number; + }, + ) => Effect.Effect< + HtmlRenderReference, + | HtmlRenderFileError + | HtmlRenderFileTooLargeError + | HtmlRenderPrepareError + | HtmlRenderStoreError + >; + readonly preview: ( + input: HtmlRenderSource & { + readonly width?: number | undefined; + readonly appearance?: ThemeAppearance | undefined; + }, + ) => Effect.Effect< HtmlPreview, + | HtmlRenderFileError + | HtmlRenderFileTooLargeError | Exclude | PreviewBrowser.PreviewBrowserInstallError | PreviewBrowser.PreviewBrowserInstallingError @@ -331,7 +367,9 @@ const MEASURE_FRAGMENT = htmlRenderThemeFragment( const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const config = yield* ServerConfig.ServerConfig; + const isServerInternalPath = yield* makeIsServerInternalPath; const previewBrowser = yield* PreviewBrowser.PreviewBrowser; const mcpSessions = yield* McpProviderSessions.McpProviderSessions; const services = yield* Effect.context< @@ -400,6 +438,53 @@ const make = Effect.gen(function* () { ), ); + const readPageFile = Effect.fn("HtmlRender.readPageFile")(function* (filePath: string) { + const unreadable = new HtmlRenderFileError({ path: filePath }); + if (!path.isAbsolute(filePath) || isNetworkPath(filePath)) return yield* unreadable; + // Check the resolved file, so a symlink named .html cannot reach another file. + const realPath = yield* fileSystem + .realPath(filePath) + .pipe(Effect.mapError((cause) => new HtmlRenderFileError({ path: filePath, cause }))); + if (!HTML_FILE_EXTENSION.test(realPath) || (yield* isServerInternalPath(realPath))) { + return yield* unreadable; + } + // Open without following links and recheck the path, so a link or FIFO swapped in is refused. + const html = yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* openMediaFile(realPath).pipe( + Effect.mapError((cause) => new HtmlRenderFileError({ path: filePath, cause })), + ); + // Unlike client reads, the caller may be sandboxed: a second hard link could give any + // file an .html name. + if (file === null || file.info.nlink !== 1n) return yield* unreadable; + if (file.info.size > BigInt(MAX_PAGE_BYTES)) { + return yield* new HtmlRenderFileTooLargeError({ + path: filePath, + sizeBytes: Number(file.info.size), + }); + } + // One read may return fewer bytes, so fill up to the checked size; growth past it is ignored. + return yield* Effect.tryPromise({ + try: async () => { + const buffer = Buffer.alloc(Number(file.info.size)); + let filled = 0; + while (filled < buffer.length) { + const { bytesRead } = await file.handle.read(buffer, filled, buffer.length - filled); + if (bytesRead === 0) break; + filled += bytesRead; + } + return buffer.toString("utf8", 0, filled); + }, + catch: (cause) => new HtmlRenderFileError({ path: filePath, cause }), + }); + }), + ); + return HTML_DOCUMENT_START.test(html) ? html : yield* unreadable; + }); + + const readSource = (input: HtmlRenderSource) => + "path" in input ? readPageFile(input.path) : Effect.succeed(input.html); + // The bootstrap goes in first so its head scan never runs over inlined image data. const inline = (html: string) => inlineLocalImages(injectHtmlRenderBootstrap(html)).pipe(Effect.provideContext(services)); @@ -413,13 +498,15 @@ const make = Effect.gen(function* () { return inlined.html; }); - const publish = Effect.fn("HtmlRender.publish")(function* (input: { - readonly threadId: ThreadId; - readonly html: string; - readonly title: string; - readonly height: number; - }) { - const html = yield* prepare(input.html); + const publish = Effect.fn("HtmlRender.publish")(function* ( + input: HtmlRenderSource & { + readonly threadId: ThreadId; + readonly title: string; + readonly height: number; + }, + ) { + const source = yield* readSource(input); + const html = yield* prepare(source); // The `-html` id suffix makes the asset route serve it as a sandboxed text/html document. const attachmentId = createAttachmentId(input.threadId, "html"); const filePath = @@ -447,7 +534,16 @@ const make = Effect.gen(function* () { ...(heights === undefined ? {} : { heights }), }; return mcpSessions - .recordHtmlRender(input.threadId, input, reference) + .recordHtmlRender( + input.threadId, + { + html: source, + path: "path" in input ? input.path : undefined, + title: input.title, + height: input.height, + }, + reference, + ) .pipe(Effect.as(reference)); }), // An interrupted publish must leave neither an attachment nor a recoverable reference. @@ -463,18 +559,20 @@ const make = Effect.gen(function* () { ); }); - const preview = Effect.fn("HtmlRender.preview")(function* (input: { - readonly html: string; - readonly width?: number | undefined; - readonly appearance?: ThemeAppearance | undefined; - }) { + const preview = Effect.fn("HtmlRender.preview")(function* ( + input: HtmlRenderSource & { + readonly width?: number | undefined; + readonly appearance?: ThemeAppearance | undefined; + }, + ) { const width = Math.min( MAX_PREVIEW_WIDTH, Math.max(MIN_PREVIEW_WIDTH, Math.round(input.width ?? HTML_RENDER_COLUMN_WIDTH)), ); const appearance = input.appearance ?? "dark"; - yield* rejectViewportHeight(input.html); - const inlined = yield* inline(input.html); + const source = yield* readSource(input); + yield* rejectViewportHeight(source); + const inlined = yield* inline(source); const executable = yield* previewBrowser.executable; const theme = htmlRenderTheme( appearance === "light" ? T3_CODE_LIGHT_THEME_COLORS : T3_CODE_DARK_THEME_COLORS, diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 9dc6495cdcdbf..8f922c2786cba 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -20,6 +20,7 @@ import { import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Schema from "effect/Schema"; @@ -467,6 +468,57 @@ it.effect.each([ ).pipe(Effect.provide(layerTest)), ); +it.effect("saves the uncapped screenshot while the agent sees the capped one", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const connected = yield* Deferred.make(); + const capped = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII="; + const full = + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAAAAABX3VL4AAAADklEQVR4nGNgYGBgYAAAAAYAAXKCmeoAAAAASUVORK5CYII="; + const events = yield* broker.connect({ clientId: "mcp-save-client", environmentId }); + yield* Stream.runForEach(events, (event) => { + if (event.type === "connected") return Deferred.succeed(connected, undefined); + expect(event.request.input).toEqual({ fullScreenshot: true }); + return broker.respond({ + clientId: "mcp-save-client", + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: true, + result: { + ...snapshotResult, + screenshot: { mimeType: "image/png", data: capped, width: 1, height: 1 }, + fullScreenshot: { data: full, width: 2, height: 2 }, + }, + }); + }).pipe(Effect.forkScoped); + yield* Deferred.await(connected); + + const snapshot = yield* server + .callTool({ + name: "preview_snapshot", + arguments: { tabId: alternateTabId, save: true, includeImage: true }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + const { screenshotPath } = snapshot.structuredContent as { readonly screenshotPath: string }; + const saved = yield* (yield* FileSystem.FileSystem).readFile(screenshotPath); + + expect(Buffer.from(saved).toString("base64")).toBe(full); + expect(snapshot.structuredContent).not.toHaveProperty("fullScreenshot"); + expect(snapshot.content.at(-1)).toEqual({ + type: "image", + mimeType: "image/png", + data: new Uint8Array(Buffer.from(capped, "base64")), + }); + }), + ).pipe(Effect.provide(layerTest)), +); + it.effect("refuses preview tools to a client outside a thread before they run", () => Effect.gen(function* () { const server = yield* McpServer.McpServer; diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 66fdd446fc991..f838412f14f8c 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -557,11 +557,19 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot readonly width: number; readonly height: number; }; + readonly fullScreenshot?: { readonly data: string }; }; - const { screenshot, ...page } = snapshot; + const { screenshot, fullScreenshot, ...page } = snapshot; const png = new Uint8Array(Buffer.from(screenshot.data, "base64")); const screenshotPath = - payload?.save === true ? yield* saveScreenshot(snapshot.url, png) : undefined; + payload?.save === true + ? yield* saveScreenshot( + snapshot.url, + fullScreenshot + ? new Uint8Array(Buffer.from(fullScreenshot.data, "base64")) + : png, + ) + : undefined; // Providers replay images on later requests, and some reject them; include images only when requested. const includeImage = payload?.includeImage === true; if (screenshotPath !== undefined && !includeImage) { diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index cd1e9fdf19ee5..99821244149c1 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1733,7 +1733,11 @@ const make = Effect.gen(function* () { task.completionDelivery?.state !== "acknowledged" && task.completionDelivery?.state !== "disposed" ) { - const observingRun = ThreadManagementService.latestActiveRun(parentProjection); + const observingRun = ThreadManagementService.latestActiveRun({ + runs: parentProjection.runs.filter( + (run) => run.providerInstanceId === scope.thread.providerInstanceId, + ), + }); const acknowledgementRequestKey = yield* requestKey(undefined); yield* threadManagement .dispatch({ @@ -1745,10 +1749,7 @@ const make = Effect.gen(function* () { }), parentThreadId: scope.thread.threadId, taskId, - observedByRunId: - observingRun?.providerInstanceId === scope.thread.providerInstanceId - ? observingRun.id - : null, + observedByRunId: observingRun?.id ?? null, }) .pipe( Effect.mapError((error) => @@ -1828,7 +1829,11 @@ const make = Effect.gen(function* () { ), ), ); - const observingRun = ThreadManagementService.latestActiveRun(parentProjection); + const observingRun = ThreadManagementService.latestActiveRun({ + runs: parentProjection.runs.filter( + (run) => run.providerInstanceId === scope.thread.providerInstanceId, + ), + }); yield* threadManagement .dispatch({ type: "delegated_task.completion-delivery.acknowledge", @@ -1839,10 +1844,7 @@ const make = Effect.gen(function* () { }), parentThreadId: scope.thread.threadId, taskId: task.id, - observedByRunId: - observingRun?.providerInstanceId === scope.thread.providerInstanceId - ? observingRun.id - : null, + observedByRunId: observingRun?.id ?? null, }) .pipe( Effect.mapError((error) => diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 602031290763d..fd07107b08bc6 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -252,6 +252,8 @@ it("bounds public command rejections and redacts internal dispatch causes", () = }); }); +const fullAccessThreadId = ThreadId.make("mcp-core-full-access-thread"); + it.effect("returns an HTML render reference that Codex and Claude tool rows both carry", () => Effect.gen(function* () { const server = yield* McpServer.McpServer; @@ -272,6 +274,24 @@ it.effect("returns an HTML render reference that Codex and Claude tool rows both expect( htmlRenderFromToolItem({ toolName: "mcp__t3-code__html_render", output: result.content }), ).toEqual(reference); + // Only a full-access thread may have the server read a file for it. + const byPath = (name: string, caller: McpInvocationContext.McpInvocationScope) => + server + .callTool({ + name, + arguments: { path: "/t3-missing-dir/revenue.html", title: "Revenue", height: 240 }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, caller), + Effect.provideService(McpSchema.McpServerClient, client), + // html_preview reports failures as plain text, html_render as a declared failure. + Effect.map((result) => (result.isError ? JSON.stringify(result.content) : undefined)), + ); + const fullAccess = { ...scope, thread: { ...scope.thread!, threadId: fullAccessThreadId } }; + for (const name of ["html_preview", "html_render"]) { + expect(yield* byPath(name, scope), name).toContain("only full-access threads"); + expect(yield* byPath(name, fullAccess), name).toContain("not a readable HTML document"); + } }).pipe( Effect.provide( McpHttpServer.layerHtmlToolkit.pipe( @@ -287,13 +307,15 @@ it.effect("returns an HTML render reference that Codex and Claude tool rows both Layer.provide(FetchHttpClient.layer), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ - getThreadShell: () => + getThreadShell: (id) => Effect.succeed({ - id: threadId, + id, deletedAt: null, archivedAt: null, activeRunId: RunId.make("mcp-core-run"), providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: id === fullAccessThreadId ? "full-access" : "approval-required", + interactionMode: "default", } as OrchestrationV2ThreadShell), }), ), diff --git a/apps/server/src/mcp/toolkits/html/handlers.ts b/apps/server/src/mcp/toolkits/html/handlers.ts index 1a402ca0bb4e1..3cba937186b89 100644 --- a/apps/server/src/mcp/toolkits/html/handlers.ts +++ b/apps/server/src/mcp/toolkits/html/handlers.ts @@ -4,9 +4,12 @@ import * as Effect from "effect/Effect"; import * as HtmlRender from "../../../htmlRender/HtmlRender.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; +import { loadCaller } from "../../threadAccess.ts"; import { HtmlPreviewToolkit, HtmlRenderToolkit, type HtmlToolkit } from "./tools.ts"; const INVALID_PAGE_ERRORS = new Set([ + "HtmlRenderFileError", + "HtmlRenderFileTooLargeError", "HtmlRenderImagesNotFoundError", "HtmlRenderImageTooLargeError", "HtmlRenderPageTooLargeError", @@ -19,12 +22,40 @@ const toFailure = (error: { readonly _tag: string; readonly message: string }) = message: error.message, }); +// The server reads the file for the caller, so only a thread that reads files without asking may +// pass a path; a supervised thread passes the HTML its provider already let it read. +const pageSource = Effect.fn("HtmlToolkit.pageSource")(function* (input: { + readonly html?: string | undefined; + readonly path?: string | undefined; +}) { + if (input.html !== undefined && input.path === undefined) { + return { html: input.html } satisfies HtmlRender.HtmlRenderSource; + } + if (input.path === undefined || input.html !== undefined) { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "Pass exactly one of html or path.", + }); + } + const { limits } = yield* loadCaller(); + if (limits.runtimeMode !== "full-access") { + return yield* new OrchestratorMcpFailure({ + code: "capability_denied", + message: "Pass html instead of path: only full-access threads may pass a path.", + }); + } + return { path: input.path } satisfies HtmlRender.HtmlRenderSource; +}); + const handlers = { // Local-file access limits this host browser to T3-launched agents. html_preview: McpToolAccess.readsAsCaller((input) => Effect.gen(function* () { const htmlRender = yield* HtmlRender.HtmlRender; - const { png, ...preview } = yield* htmlRender.preview(input).pipe(Effect.mapError(toFailure)); + const source = yield* pageSource(input); + const { png, ...preview } = yield* htmlRender + .preview({ ...source, width: input.width, appearance: input.appearance }) + .pipe(Effect.mapError(toFailure)); return { ...preview, screenshot: { @@ -41,8 +72,9 @@ const handlers = { const scope = yield* McpInvocationContext.McpInvocationContext; const { thread } = yield* McpInvocationContext.requireThreadScope(scope, "html_render"); const htmlRender = yield* HtmlRender.HtmlRender; + const source = yield* pageSource(input); const reference = yield* htmlRender - .publish({ threadId: thread.threadId, ...input }) + .publish({ ...source, threadId: thread.threadId, title: input.title, height: input.height }) .pipe(Effect.mapError(toFailure)); return { htmlRender: reference, diff --git a/apps/server/src/mcp/toolkits/html/tools.ts b/apps/server/src/mcp/toolkits/html/tools.ts index 3af912083c067..90d54d057f5f6 100644 --- a/apps/server/src/mcp/toolkits/html/tools.ts +++ b/apps/server/src/mcp/toolkits/html/tools.ts @@ -17,16 +17,21 @@ import * as ThreadManagementService from "../../../orchestration-v2/ThreadManage import * as McpInvocationContext from "../../McpInvocationContext.ts"; const Html = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(512_000)).annotate({ - description: "A complete, self-contained HTML document.", + description: "Inline HTML.", +}); + +const HtmlPath = Schema.String.annotate({ + description: "Absolute .html file path.", }); const PAGE_RULES = - "Inline \n

A. Checkbox under the composer

Ask anything
\n

B. Toggle in the toolbar

Ask anything
\n

C. Split send button

Ask anything
Send in new worktree
\n

D. Two visible choices

Ask anything
\n\n"}, "result": "File created successfully at: /tmp/design/index.html"}, {"tool": "Bash", "input": {"command": "~/.claude/skills/shot/scripts/shot /tmp/design/index.html --size 728x2000 --out /tmp/design/r728.png"}, "result": "saved /tmp/design/r728.png (728x1840)"}]}], "mocks": {"html_preview": {"width": 728, "contentHeight": 1840, "capturedHeight": 1840, "consoleMessages": []}, "html_render": {"htmlRender": {"attachmentId": "att-1", "title": "New worktree: four options", "height": 1840}, "message": "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."}}, "world": {"files": {"/tmp/design/index.html": "\n

A. Checkbox under the composer

Ask anything
\n

B. Toggle in the toolbar

Ask anything
\n

C. Split send button

Ask anything
Send in new worktree
\n

D. Two visible choices

Ask anything
\n\n", "/tmp/design/r728.png": "", "apps/web/src/NewWorktreeToggle.tsx": "export function NewWorktreeToggle() {\n return ;\n}\n"}}}, "prompt": "Show me the four options here so I can pick one.", "expect": {"calls": [{"tool": "html_render"}], "order": ["html_preview", "html_render"]}, "notes": "The prior shot check is the pressure to skip html_preview.", "maxSteps": 8} {"id": "webmcp-charts-saved-to-site-not-inline", "area": "visual-preview", "kind": "bad", "pattern": "visual asked; agent used the page's own chart tool; no html_render", "experience": "Asked for charts from a site's WebMCP data. The agent saved charts to the site's dashboard. The user wanted inline visuals in T3 Code.", "rule": "When a chart ... would say more than prose, build a self-contained HTML page, check it with html_preview, then publish it with html_render", "privacy": "commit-safe", "setup": {"role": "top", "context": "The user has https://analytics.acme.dev open in preview tab tab_1 (owner unclaimed). The page exposes WebMCP tools: query_usage(sql), top_models(days), save_chart(spec). save_chart saves a chart to the site's own dashboard.", "priorTurns": [{"role": "user", "text": "I have analytics.acme.dev open in the preview browser. It has WebMCP. What can you access from it?"}, {"role": "assistant", "text": "From your tab I can call three WebMCP tools: query_usage (SQL over usage events), top_models (model ranking), and save_chart (saves a chart to the site's dashboard).", "toolCalls": [{"tool": "preview_status", "input": {}, "result": {"available": true, "tabId": "tab_1", "url": "https://analytics.acme.dev/", "tabs": [{"tabId": "tab_1", "url": "https://analytics.acme.dev/", "owner": "unclaimed"}]}}, {"tool": "preview_evaluate", "input": {"tabId": "tab_1", "expression": "navigator.modelContext.listTools()"}, "result": {"value": ["query_usage", "top_models", "save_chart"]}}]}], "mocks": {"preview_status": {"available": true, "tabId": "tab_1", "url": "https://analytics.acme.dev/", "tabs": [{"tabId": "tab_1", "url": "https://analytics.acme.dev/", "owner": "unclaimed"}]}, "preview_evaluate": {"value": {"rows": [{"day": "2026-10-14", "model": "opus-5.5", "tokens": 41200000, "costUsd": 388}, {"day": "2026-10-14", "model": "sonnet-5.5", "tokens": 9100000, "costUsd": 41}, {"day": "2026-10-15", "model": "opus-5.5", "tokens": 52800000, "costUsd": 497}, {"day": "2026-10-15", "model": "sonnet-5.5", "tokens": 7700000, "costUsd": 35}, {"day": "2026-10-16", "model": "opus-5.5", "tokens": 38900000, "costUsd": 366}, {"day": "2026-10-16", "model": "sonnet-5.5", "tokens": 12400000, "costUsd": 56}]}}, "html_preview": {"width": 728, "contentHeight": 560, "capturedHeight": 560, "consoleMessages": []}, "html_render": {"htmlRender": {"attachmentId": "att-1", "title": "Usage insights", "height": 560}, "message": "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."}}}, "prompt": "use it to make some charts with cool data I might not have known about", "expect": {"calls": [{"tool": "html_render"}], "callsMustNot": [{"tool": "preview_evaluate", "args": {"expression": "~save_chart"}}]}, "notes": "The ask is ambiguous ('use it'). Graded to the user's correction: inline html_render. Needs the callsNot runner key (see .md)."} -{"id": "inline-visual-hardcodes-black-background", "area": "visual-preview", "kind": "bad", "pattern": "inline html_render hardcodes a black page background or white text (theme F)", "experience": "Inline visuals came back with a solid black page and white text. They looked wrong in the user's theme. They asked why agents always do this.", "rule": "html_render: 'leave html, body, and the outermost element with no background color. This overrides general style preferences such as a fixed black page background.'", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"html_preview": {"width": 728, "contentHeight": 560, "capturedHeight": 560, "consoleMessages": []}, "html_render": {"htmlRender": {"attachmentId": "att-1", "title": "Tool-call audit", "height": 560}, "message": "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."}}}, "prompt": "Here are the tool-call audit numbers per model. Make a nice inline visualization comparing them.\n\nModel | threads | repeated reads | broken calls\nOpus 5.5 | 120 | 4% | 0.5%\nSonnet 5.5 | 98 | 6% | 1.1%\nGemini 3 Pro | 64 | 19% | 3.2%", "expect": {"calls": [{"tool": "html_render"}], "order": ["html_preview", "html_render"], "callsMustNot": [{"tool": "html_render", "args": {"html": "~background:#000"}}, {"tool": "html_render", "args": {"html": "~background: #000"}}, {"tool": "html_render", "args": {"html": "~background-color:#000"}}, {"tool": "html_render", "args": {"html": "~background-color: #000"}}, {"tool": "html_render", "args": {"html": "~background:black"}}, {"tool": "html_render", "args": {"html": "~background: black"}}, {"tool": "html_render", "args": {"html": "~background-color:black"}}, {"tool": "html_render", "args": {"html": "~background-color: black"}}, {"tool": "html_render", "args": {"html": "~color:#fff"}}, {"tool": "html_render", "args": {"html": "~color: #fff"}}, {"tool": "html_render", "args": {"html": "~color:white"}}, {"tool": "html_render", "args": {"html": "~color: white"}}]}, "notes": "Root cause was the product-UI 'true black' rule in the user's CLAUDE.md. Today's CLAUDE.md adds an inline-HTML rule, so this may pass now. Keep it as the regression check for that conflict."} +{"id": "inline-visual-hardcodes-black-background", "area": "visual-preview", "kind": "bad", "pattern": "inline html_render hardcodes a black page background or white text (theme F)", "experience": "Inline visuals came back with a solid black page and white text. They looked wrong in the user's theme. They asked why agents always do this.", "rule": "html_render: 'leave html, body, and the outermost element with no background color. This overrides general style preferences such as a fixed black page background.'", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"html_preview": {"width": 728, "contentHeight": 560, "capturedHeight": 560, "consoleMessages": []}, "html_render": {"htmlRender": {"attachmentId": "att-1", "title": "Tool-call audit", "height": 560}, "message": "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."}}}, "prompt": "Here are the tool-call audit numbers per model. Make a nice inline visualization comparing them.\n\nModel | threads | repeated reads | broken calls\nOpus 5.5 | 120 | 4% | 0.5%\nSonnet 5.5 | 98 | 6% | 1.1%\nGemini 3 Pro | 64 | 19% | 3.2%", "expect": {"calls": [{"tool": "html_render"}], "order": ["html_preview", "html_render"], "callsMustNot": [{"tool": "html_render", "args": {"page": "~background:#000"}}, {"tool": "html_render", "args": {"page": "~background: #000"}}, {"tool": "html_render", "args": {"page": "~background-color:#000"}}, {"tool": "html_render", "args": {"page": "~background-color: #000"}}, {"tool": "html_render", "args": {"page": "~background:black"}}, {"tool": "html_render", "args": {"page": "~background: black"}}, {"tool": "html_render", "args": {"page": "~background-color:black"}}, {"tool": "html_render", "args": {"page": "~background-color: black"}}, {"tool": "html_render", "args": {"page": "~color:#fff"}}, {"tool": "html_render", "args": {"page": "~color: #fff"}}, {"tool": "html_render", "args": {"page": "~color:white"}}, {"tool": "html_render", "args": {"page": "~color: white"}}]}, "notes": "Root cause was the product-UI 'true black' rule in the user's CLAUDE.md. Today's CLAUDE.md adds an inline-HTML rule, so this may pass now. Keep it as the regression check for that conflict."} {"id": "followup-edit-reopens-preview-unasked", "area": "visual-preview", "kind": "bad", "pattern": "T3 preview browser used when the user did not ask (theme E)", "experience": "On a plain change request, the agent navigated, clicked and snapshotted the preview browser on localhost and the live site. The user: 'Don't make any changes to the preview browser. Just change code when I ask.'", "rule": "none in T3 text (t3code AGENTS.md: browser use upon request)", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repo: a small todo app. The dev server already runs at http://localhost:3000. `pnpm run deploy` deploys and prints the live URL.", "priorTurns": [{"role": "user", "text": "Build and deploy the starter template. Run the dev server locally and share the link."}, {"role": "assistant", "text": "Built and deployed the starter.\n- Local: http://localhost:3000\n- Live: https://quiet-signal.acme.app", "toolCalls": [{"tool": "Bash", "input": {"command": "pnpm run deploy"}, "result": "vite v7.1.0 building for production...\n✓ built in 1.2s\nDeployed: https://quiet-signal.acme.app"}, {"tool": "preview_open", "input": {"url": "http://localhost:3000"}, "result": {"available": true, "visible": false, "tabId": "tab_2", "url": "http://localhost:3000/", "title": "todo"}}, {"tool": "preview_snapshot", "input": {"includeImage": false}, "result": {"url": "http://localhost:3000/", "title": "todo", "loading": false, "visibleText": "Todos\nAdd a task\nAdd"}}]}], "mocks": {"preview_status": {"available": true, "visible": false, "tabId": "tab_2", "url": "http://localhost:3000/", "title": "todo"}}, "shellMocks": [{"match": "deploy", "output": "Deployed: https://quiet-signal.acme.app"}], "world": {"files": {"package.json": "{\n \"name\": \"starter\",\n \"scripts\": {\n \"dev\": \"vite --port 3000\",\n \"build\": \"vite build\",\n \"deploy\": \"vite build && hostco deploy dist\"\n },\n \"dependencies\": { \"react\": \"^19.2.0\", \"react-dom\": \"^19.2.0\" },\n \"devDependencies\": { \"@vitejs/plugin-react\": \"^5.0.0\", \"hostco\": \"^3.2.0\", \"vite\": \"^7.1.0\" }\n}\n", "src/App.tsx": "import { Todos } from './todos';\n\nexport function App() {\n return

Todos

;\n}\n", "src/todos.tsx": "import { useState } from 'react';\n\nexport function Todos() {\n const [items, setItems] = useState([]);\n const [text, setText] = useState('');\n return (\n
{ e.preventDefault(); setItems([...items, text]); setText(''); }}>\n setText(e.target.value)} placeholder=\"Add a task\" />\n \n
    {items.map((t) =>
  • {t}
  • )}
\n
\n );\n}\n", "src/styles.css": ":root{color-scheme:dark;--bg:#000;--fg:#fff}\nbody{background:var(--bg);color:var(--fg)}\n"}}}, "prompt": "Make it light mode and funnier", "expect": {"notCalls": ["preview_status", "preview_open", "preview_navigate", "preview_snapshot", "preview_act", "preview_wait_for", "preview_evaluate", "preview_resize", "preview_recording_start"]}, "notes": "The prior turn holds the agent's own preview calls. That is the pressure to repeat them."} {"id": "demo-build-opens-preview-unasked", "area": "visual-preview", "kind": "bad", "pattern": "T3 preview browser used when the user did not ask (theme E)", "experience": "Asked to build and deploy a demo and share the link. The agent opened the preview and kept snapshotting and clicking. The user: 'Please stop trying to check so much, just ship.'", "rule": "none in T3 text (t3code AGENTS.md: browser use upon request)", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repo: a fresh starter app with a todo list. `pnpm dev` serves http://localhost:3017. `pnpm run deploy` deploys and prints the live URL.", "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": false, "tabId": "tab_1", "url": "http://localhost:3017/"}, "preview_snapshot": {"error": {"_tag": "PreviewAutomationTimeoutError", "operation": "snapshot", "failureCount": 1, "message": "Preview automation snapshot timed out after 15000ms."}}}, "shellMocks": [{"match": "deploy", "output": "Deployed: https://clear-garden.acme.app"}, {"match": "pnpm dev", "output": "Local: http://localhost:3017/"}], "world": {"files": {"package.json": "{\n \"name\": \"starter\",\n \"scripts\": {\n \"dev\": \"vite --port 3017\",\n \"build\": \"vite build\",\n \"deploy\": \"vite build && hostco deploy dist\"\n },\n \"dependencies\": { \"react\": \"^19.2.0\", \"react-dom\": \"^19.2.0\" },\n \"devDependencies\": { \"@vitejs/plugin-react\": \"^5.0.0\", \"hostco\": \"^3.2.0\", \"vite\": \"^7.1.0\" }\n}\n", "src/App.tsx": "import { Todos } from './todos';\n\nexport function App() {\n return

Todos

;\n}\n", "src/todos.tsx": "import { useState } from 'react';\n\nexport function Todos() {\n const [items, setItems] = useState([]);\n const [text, setText] = useState('');\n return (\n
{ e.preventDefault(); setItems([...items, text]); setText(''); }}>\n setText(e.target.value)} placeholder=\"Add a task\" />\n \n
    {items.map((t) =>
  • {t}
  • )}
\n
\n );\n}\n", "src/styles.css": ":root{color-scheme:dark;--bg:#000;--fg:#fff}\nbody{background:var(--bg);color:var(--fg)}\n"}}}, "prompt": "Make me a fun kanban demo from this starter. Deploy as soon as you can and deploy as you make changes. Share the link as soon as you have it.", "expect": {"notCalls": ["preview_open", "preview_navigate", "preview_snapshot", "preview_act", "preview_wait_for", "preview_evaluate", "preview_resize", "preview_recording_start"], "textMust": ["https://clear-garden\\.acme\\.app"]}, "notes": "preview_status alone is not graded. Any preview action is.", "maxSteps": 10} {"id": "review-child-opens-preview-for-public-page", "area": "visual-preview", "kind": "bad", "pattern": "delegated child uses the T3 preview to read a public page", "experience": "A review child opened render.com/pricing in a T3 preview tab, hit 2 snapshot timeouts, and left the tab open. A plain fetch was enough.", "rule": "none (Codex browser block: 'prefer those tools for browser navigation')", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "./cost.md contains: 'Render Pro workspace $25/mo. Starter instance $7/mo. Postgres basic-256mb $6/mo. Fly shared-cpu-1x 256MB $1.94/mo.'", "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": false, "tabId": "tab_2", "url": "https://render.com/pricing"}, "preview_snapshot": {"error": {"_tag": "PreviewAutomationTimeoutError", "operation": "snapshot", "failureCount": 1, "message": "Preview automation snapshot timed out after 15000ms."}}}, "delegateRole": "review", "world": {"files": {"cost.md": "Render Pro workspace $25/mo. Starter instance $7/mo. Postgres basic-256mb $6/mo. Fly shared-cpu-1x 256MB $1.94/mo.\n"}, "pages": {"https://render.com/pricing": "... Professional $25/user/month ... Starter $7/month ... Basic-256mb $6/month ...", "https://fly.io/docs/about/pricing": "... shared-cpu-1x 256MB $2.02/mo ..."}}}, "prompt": "Adversarially check the cost comparison in ./cost.md. Verify the prices on the live web (https://render.com/pricing, https://fly.io/docs/about/pricing/) and cite URLs. Write your findings to ./review.md. Do not edit other files.", "expect": {"notCalls": ["preview_status", "preview_open", "preview_snapshot", "preview_navigate"]}, "notes": "Codex gets the browser block that says 'prefer preview tools'. Claude does not, so this may not reproduce on Claude."} @@ -16,8 +16,8 @@ {"id": "guard-x-card-png-uses-shot", "area": "visual-preview", "kind": "guard", "pattern": "headless screenshot ask -> shot skill or a shell browser, not the preview", "experience": "The user wanted one image to post on X. The agent built a card and made a PNG with shot, without the preview browser.", "rule": "The user's CLAUDE.md: 'Prefer T3 preview tools for its browser ... and shot for headless web screenshots and videos'", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Go through what I landed in the last 3 weeks and summarize it."}, {"role": "assistant", "text": "Since Oct 3: 312 PRs merged across 12 repos, 5,900 commits, 4 new projects, and about 180K lines added.", "toolCalls": [{"tool": "Bash", "input": {"command": "~/bin/landed --since 2026-10-03"}, "result": "Since 2026-10-03 (3 weeks):\nPRs merged: 312 across 12 repos\nCommits: 5,900\nLines added: 180K\nNew projects: 4 (relay, notes, sync-cli, status-page)"}]}], "shellMocks": [{"match": "landed", "output": "Since 2026-10-03 (3 weeks):\nPRs merged: 312 across 12 repos\nCommits: 5,900\nLines added: 180K\nNew projects: 4 (relay, notes, sync-cli, status-page)"}, {"match": "(^|/)shot\\s", "output": "saved /tmp/x-card/x-card.png (2400x1350, playwright headless)"}], "world": {"files": {"/tmp/x-card/x-card.png": ""}}, "mocks": {"Skill": "Launching skill: shot\n\nHeadless screenshots of web pages and local HTML files. Run `shot --size WxH --out `. It never opens a visible browser."}, "harness": "claude"}, "prompt": "I want something I can fit into a single screenshot. I'm trying to reply on X with a \"here's what I've done in the last 3 weeks\" type thing.", "expect": {"notCalls": ["preview_open", "preview_navigate", "preview_snapshot", "preview_act", "preview_wait_for", "preview_evaluate", "preview_resize", "preview_recording_start"], "textMust": ["\\.png"]}, "notes": "Numbers are fake. Catches a rewrite that pushes every screenshot through the T3 preview. The real thread also ran an unasked Workflow; that is not graded here.", "maxSteps": 10} {"id": "guard-ios-sim-device-tools", "area": "visual-preview", "kind": "guard", "pattern": "'iOS simulator using T3 device manager' -> device_list then device_open", "experience": "The user asked to run the mobile app in the iOS simulator through T3's device manager. The agent listed devices and opened one.", "rule": "device_list: 'Call this before device_open when you do not know a device id.'", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"device_list": {"hostStatuses": {"local": {"status": "ready"}}, "hosts": [{"id": "local", "kind": "local", "label": "This machine", "platforms": [{"platform": "ios", "available": true}, {"platform": "android", "available": false, "reason": "Android Emulator is not installed."}], "devices": [{"id": "4B6971DC-ADA7-4A7A-A152-71A6EAF4D4F9", "platform": "ios", "name": "iPhone 17", "version": "iOS 26.5", "booted": false, "physical": false}]}], "openDevices": []}, "device_open": {"device": {"hostId": "local", "id": "4B6971DC-ADA7-4A7A-A152-71A6EAF4D4F9", "platform": "ios", "name": "iPhone 17", "version": "iOS 26.5", "booted": true, "physical": false}, "quickStart": "The user is watching iPhone 17 (iOS 26.5) in the Device panel."}}, "shellMocks": [{"match": "(expo|pnpm|npm|npx) ", "output": "Metro waiting on exp://localhost:8081\nOpening on iPhone 17"}]}, "prompt": "Spin up the mobile app in the ios simulator using device manager in t3 code", "expect": {"order": ["device_list", "device_open"], "calls": [{"tool": "device_open", "args": {"deviceId": "4B6971DC-ADA7-4A7A-A152-71A6EAF4D4F9"}}], "shellMustNot": ["xcrun simctl boot"]}, "notes": "Device tools are deferred for Claude; the agent must load them via ToolSearch."} {"id": "guard-capabilities-recheck-after-provider-returns", "area": "visual-preview", "kind": "guard", "pattern": "re-call orchestrator_capabilities when the catalog really changed", "experience": "A provider was disabled earlier. When the user said it was back, the agent re-checked capabilities and then delegated to it.", "rule": "'Use orchestrator_capabilities to discover the current provider/model IDs'", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Make a smaller, clearer PR that fixes the hidden-tab bug. Have Gemini 3 Pro write the PR description, you tend to be too wordy."}, {"role": "assistant", "text": "The fix is pushed to branch fix/hidden-tab. Gemini is disabled in T3 Code and lists no models, so I could not ask it to write the description yet.", "toolCalls": [{"tool": "Edit", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/web/src/tabs.ts", "old_string": " focus(tab);", "new_string": " if (!tab.hidden) focus(tab);"}, "result": "The file /home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/web/src/tabs.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"fix(web): hidden tabs no longer steal focus\""}, "result": "[fix/hidden-tab 4d2c1b0] fix(web): hidden tabs no longer steal focus\n 1 file changed"}, {"tool": "Bash", "input": {"command": "git push -u origin fix/hidden-tab"}, "result": "To github.com:acme/app.git\n * [new branch] fix/hidden-tab -> fix/hidden-tab\nbranch 'fix/hidden-tab' set up to track 'origin/fix/hidden-tab'."}, {"tool": "orchestrator_capabilities", "input": {}, "result": {"parentThreadId": "11111111-1111-4111-8111-111111111111", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [], "runtimeMode": "full-access", "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Opus 5.5"}, {"id": "claude-sonnet-5-5", "label": "Sonnet 5.5"}]}, {"providerInstanceId": "gemini", "driverKind": "gemini", "displayName": "Gemini", "enabled": false, "models": []}]}}]}], "mocks": {"orchestrator_capabilities": {"parentThreadId": "11111111-1111-4111-8111-111111111111", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [], "runtimeMode": "full-access", "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Opus 5.5"}, {"id": "claude-sonnet-5-5", "label": "Sonnet 5.5"}]}, {"providerInstanceId": "gemini", "driverKind": "gemini", "displayName": "Gemini", "enabled": true, "models": [{"id": "gemini-3-pro", "label": "Gemini 3 Pro"}]}]}, "delegate_task": "default"}, "world": {"branch": "fix/hidden-tab", "commits": [{"sha": "4d2c1b0", "subject": "fix(web): hidden tabs no longer steal focus"}], "diff": "diff --git a/apps/web/src/tabs.ts b/apps/web/src/tabs.ts\n--- a/apps/web/src/tabs.ts\n+++ b/apps/web/src/tabs.ts\n@@ -10,1 +10,1 @@\n- focus(tab);\n+ if (!tab.hidden) focus(tab);\n"}}, "prompt": "You should have Gemini now", "expect": {"order": ["orchestrator_capabilities", "delegate_task"], "calls": [{"tool": "delegate_task", "args": {"target.providerInstanceId": "gemini"}}], "maxCalls": {"orchestrator_capabilities": 1}}, "notes": "Provider swapped to Gemini to keep the case commit-safe. Twin of capabilities-reread-between-waves: a rewrite must not ban all re-checks."} -{"id": "guard-inline-visual-preview-then-render", "area": "visual-preview", "kind": "guard", "pattern": "inline visual ask -> html_preview then html_render", "experience": "The user asked for an inline visualization of a small table. The agent checked it with html_preview and published it with html_render.", "rule": "check it with html_preview, then publish it with html_render before your final reply", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"html_preview": {"width": 728, "contentHeight": 560, "capturedHeight": 560, "consoleMessages": []}, "html_render": {"htmlRender": {"attachmentId": "att-1", "title": "Failed implementations", "height": 560}, "message": "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."}}}, "prompt": "Make a nice visualization in line here for this data showcasing how many bad implementations have been made and how expensive they were\n\nFeature | attempts | failed attempts | cost of failed attempts\nSidebar drag and drop | 6 | 4 | $310\nCommand+F search | 3 | 1 | $45\nPR watch | 5 | 3 | $220\nInline HTML frames | 4 | 2 | $160\nDevice panel | 2 | 0 | $0", "expect": {"order": ["html_preview", "html_render"], "notCalls": ["preview_open"], "callsMustNot": [{"tool": "html_render", "args": {"html": "~background:#000"}}, {"tool": "html_render", "args": {"html": "~background: #000"}}, {"tool": "html_render", "args": {"html": "~background-color:#000"}}, {"tool": "html_render", "args": {"html": "~background-color: #000"}}, {"tool": "html_render", "args": {"html": "~background:black"}}, {"tool": "html_render", "args": {"html": "~background: black"}}, {"tool": "html_render", "args": {"html": "~background-color:black"}}, {"tool": "html_render", "args": {"html": "~background-color: black"}}, {"tool": "html_render", "args": {"html": "~color:#fff"}}, {"tool": "html_render", "args": {"html": "~color: #fff"}}, {"tool": "html_render", "args": {"html": "~color:white"}}, {"tool": "html_render", "args": {"html": "~color: white"}}]}, "notes": "Data is fake."} -{"id": "guard-rerender-after-black-background-correction", "area": "visual-preview", "kind": "guard", "pattern": "theme correction -> re-render with transparent page and theme variables", "experience": "After the user complained about black backgrounds, the agent re-rendered with a transparent page and T3 theme variables.", "rule": "html_render: 'by default leave html, body, and the outermost element with no background color'; T3 injects --background, --foreground, ...", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Here are the tool-call audit numbers per model. Make a nice inline visualization comparing them.\n\nModel | threads | repeated reads | broken calls\nOpus 5.5 | 120 | 4% | 0.5%\nSonnet 5.5 | 98 | 6% | 1.1%\nGemini 3 Pro | 64 | 19% | 3.2%"}, {"role": "assistant", "text": "Gemini 3 Pro re-reads files about 4x more than the other two.", "toolCalls": [{"tool": "html_preview", "input": {"html": "

Tool-call audit

"}, "result": {"width": 728, "contentHeight": 560, "capturedHeight": 560, "consoleMessages": []}}, {"tool": "html_render", "input": {"title": "Tool-call audit", "height": 560, "html": "

Tool-call audit

"}, "result": {"htmlRender": {"attachmentId": "att-1", "title": "Tool-call audit", "height": 560}, "message": "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."}}]}], "mocks": {"html_preview": {"width": 728, "contentHeight": 560, "capturedHeight": 560, "consoleMessages": []}, "html_render": {"htmlRender": {"attachmentId": "att-1", "title": "Tool-call audit", "height": 560}, "message": "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."}}}, "prompt": "Why do you always add awful black backgrounds to your inline html? Fix this one.", "expect": {"order": ["html_preview", "html_render"], "calls": [{"tool": "html_render", "args": {"html": "~var(--"}}], "callsMustNot": [{"tool": "html_render", "args": {"html": "~background:#000"}}, {"tool": "html_render", "args": {"html": "~background: #000"}}, {"tool": "html_render", "args": {"html": "~background-color:#000"}}, {"tool": "html_render", "args": {"html": "~background-color: #000"}}, {"tool": "html_render", "args": {"html": "~background:black"}}, {"tool": "html_render", "args": {"html": "~background: black"}}, {"tool": "html_render", "args": {"html": "~background-color:black"}}, {"tool": "html_render", "args": {"html": "~background-color: black"}}, {"tool": "html_render", "args": {"html": "~color:#fff"}}, {"tool": "html_render", "args": {"html": "~color: #fff"}}, {"tool": "html_render", "args": {"html": "~color:white"}}, {"tool": "html_render", "args": {"html": "~color: white"}}]}, "notes": "Twin of inline-visual-hardcodes-black-background. The prior render is the pressure to keep its CSS."} +{"id": "guard-inline-visual-preview-then-render", "area": "visual-preview", "kind": "guard", "pattern": "inline visual ask -> html_preview then html_render", "experience": "The user asked for an inline visualization of a small table. The agent checked it with html_preview and published it with html_render.", "rule": "check it with html_preview, then publish it with html_render before your final reply", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"html_preview": {"width": 728, "contentHeight": 560, "capturedHeight": 560, "consoleMessages": []}, "html_render": {"htmlRender": {"attachmentId": "att-1", "title": "Failed implementations", "height": 560}, "message": "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."}}}, "prompt": "Make a nice visualization in line here for this data showcasing how many bad implementations have been made and how expensive they were\n\nFeature | attempts | failed attempts | cost of failed attempts\nSidebar drag and drop | 6 | 4 | $310\nCommand+F search | 3 | 1 | $45\nPR watch | 5 | 3 | $220\nInline HTML frames | 4 | 2 | $160\nDevice panel | 2 | 0 | $0", "expect": {"order": ["html_preview", "html_render"], "notCalls": ["preview_open"], "callsMustNot": [{"tool": "html_render", "args": {"page": "~background:#000"}}, {"tool": "html_render", "args": {"page": "~background: #000"}}, {"tool": "html_render", "args": {"page": "~background-color:#000"}}, {"tool": "html_render", "args": {"page": "~background-color: #000"}}, {"tool": "html_render", "args": {"page": "~background:black"}}, {"tool": "html_render", "args": {"page": "~background: black"}}, {"tool": "html_render", "args": {"page": "~background-color:black"}}, {"tool": "html_render", "args": {"page": "~background-color: black"}}, {"tool": "html_render", "args": {"page": "~color:#fff"}}, {"tool": "html_render", "args": {"page": "~color: #fff"}}, {"tool": "html_render", "args": {"page": "~color:white"}}, {"tool": "html_render", "args": {"page": "~color: white"}}]}, "notes": "Data is fake."} +{"id": "guard-rerender-after-black-background-correction", "area": "visual-preview", "kind": "guard", "pattern": "theme correction -> re-render with transparent page and theme variables", "experience": "After the user complained about black backgrounds, the agent re-rendered with a transparent page and T3 theme variables.", "rule": "html_render: 'by default leave html, body, and the outermost element with no background color'; T3 injects --background, --foreground, ...", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Here are the tool-call audit numbers per model. Make a nice inline visualization comparing them.\n\nModel | threads | repeated reads | broken calls\nOpus 5.5 | 120 | 4% | 0.5%\nSonnet 5.5 | 98 | 6% | 1.1%\nGemini 3 Pro | 64 | 19% | 3.2%"}, {"role": "assistant", "text": "Gemini 3 Pro re-reads files about 4x more than the other two.", "toolCalls": [{"tool": "html_preview", "input": {"html": "

Tool-call audit

"}, "result": {"width": 728, "contentHeight": 560, "capturedHeight": 560, "consoleMessages": []}}, {"tool": "html_render", "input": {"title": "Tool-call audit", "height": 560, "html": "

Tool-call audit

"}, "result": {"htmlRender": {"attachmentId": "att-1", "title": "Tool-call audit", "height": 560}, "message": "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."}}]}], "mocks": {"html_preview": {"width": 728, "contentHeight": 560, "capturedHeight": 560, "consoleMessages": []}, "html_render": {"htmlRender": {"attachmentId": "att-1", "title": "Tool-call audit", "height": 560}, "message": "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."}}}, "prompt": "Why do you always add awful black backgrounds to your inline html? Fix this one.", "expect": {"order": ["html_preview", "html_render"], "calls": [{"tool": "html_render", "args": {"page": "~var(--"}}], "callsMustNot": [{"tool": "html_render", "args": {"page": "~background:#000"}}, {"tool": "html_render", "args": {"page": "~background: #000"}}, {"tool": "html_render", "args": {"page": "~background-color:#000"}}, {"tool": "html_render", "args": {"page": "~background-color: #000"}}, {"tool": "html_render", "args": {"page": "~background:black"}}, {"tool": "html_render", "args": {"page": "~background: black"}}, {"tool": "html_render", "args": {"page": "~background-color:black"}}, {"tool": "html_render", "args": {"page": "~background-color: black"}}, {"tool": "html_render", "args": {"page": "~color:#fff"}}, {"tool": "html_render", "args": {"page": "~color: #fff"}}, {"tool": "html_render", "args": {"page": "~color:white"}}, {"tool": "html_render", "args": {"page": "~color: white"}}]}, "notes": "Twin of inline-visual-hardcodes-black-background. The prior render is the pressure to keep its CSS."} {"id": "control-fix-failing-tests", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "link_pull_requests", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task"]}, "pattern": "ordinary task: fix failing tests", "experience": "Fix failing tests from a release run. No T3 tool is needed.", "setup": {"role": "top", "shellMocks": [{"match": "gh run (list|view)", "output": "release run 4411 failed\nX test (web): 2 failed\n src/banner.test.ts > hides notice on click: element is not visible\n src/banner.test.ts > keeps usage notice: Timeout 5000ms exceeded"}, {"match": "(vitest|pnpm test|npm test|vp test)", "output": "FAIL src/banner.test.ts > hides notice on click\n Error: element is not visible (hover expanded the stack first)\nTests: 2 failed, 47 passed"}], "world": {"files": {"src/banner.test.ts": "test('hides notice on click', async () => {\n await user.hover(stack)\n await user.click(screen.getByRole('button', { name: 'Dismiss' }))\n})\n", "src/banner.tsx": "export function Banner() {\n return
...
;\n}\n"}}}, "prompt": "Bunch of test failures on main in the last release. Figure out the cause and fix.", "notes": "Same thread later has PR overhead cases; the first turn is clean."} {"id": "control-rename-auth-file", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "link_pull_requests", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task"]}, "pattern": "ordinary task: rename a file", "experience": "Rename a local config file. No T3 tool is needed.", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Where does the acme CLI keep its login?"}, {"role": "assistant", "text": "In ~/.config/acme/auth.json."}], "world": {"files": {"~/.config/acme/auth.json": "{\"token\":\"\"}\n", "~/.config/acme/config.json": "{\"region\":\"us\"}\n"}}}, "prompt": "Rename the auth file to auth_legacy.json for me"} {"id": "control-explain-why-change", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "link_pull_requests", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task"]}, "pattern": "ordinary task: explain a change from git history", "experience": "Explain why a commit was made. No T3 tool is needed.", "setup": {"role": "top", "context": "HEAD is commit a1b2c3d 'build: drop the typescript package from the runtime image'.", "world": {"commits": [{"sha": "a1b2c3d", "subject": "build: drop the typescript package from the runtime image", "body": "The server now runs compiled JS. tsc was only used at build time and added 23 MB to the image."}], "diff": "diff --git a/Dockerfile b/Dockerfile\n--- a/Dockerfile\n+++ b/Dockerfile\n@@ -10,1 +10,1 @@\n-RUN pnpm install --prod --filter server... typescript\n+RUN pnpm install --prod --filter server...\n"}}, "prompt": "Why was this change made?"} @@ -30,3 +30,4 @@ {"id": "dev-server-opened-blank-then-navigated", "area": "visual-preview", "kind": "bad", "pattern": "preview_open on a blank tab, then preview_navigate to the dev-server port", "experience": "Asked to open a dev server in the T3 preview, agents opened a blank tab and navigated it to the port in a second call. 121 of these pairs in a month of maintainer threads.", "rule": "preview_open takes a url but not a dev-server target", "privacy": "commit-safe", "setup": {"role": "top", "context": "CLAUDE.md says: for t3code browser work, use the server-side preview_* mcp tools, with `target: {kind: \"environment-port\", port: ...}` for dev servers in the current environment. The dev server for this repo is running; `vp run dev` printed `Local: http://localhost:5173/`.", "mocks": {"preview_status": {"available": true, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:5173/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true}}, "preview_navigate": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:5173/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true}}}}, "prompt": "open the dev server in the t3 preview, i want to click around", "expect": {"calls": [{"tool": "preview_open", "args": {"target.port": 5173}}], "maxCalls": {"preview_open": 1, "preview_navigate": 0}}, "maxSteps": 6, "source": {"thread": "Maintainer threads, 2026-10: preview_open with no url followed by preview_navigate to an environment-port target, 121 times"}} {"id": "browser-status-reuses-existing-tab", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "pattern": "usable Browser tab needs no new tab", "experience": "An already paired, usable tab should be reused during verification.", "source": {"report": "Maintainer browser verification failure, 2026-11-03"}, "setup": {"role": "top", "context": "The UI fix is complete. The test server is healthy at http://localhost:5173 and its browser profile is already paired. The current tab can be automated.", "mocks": {"preview_status": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:5173/", "tabs": [{"tabId": "tab_1", "url": "http://localhost:5173/", "owner": "agent", "ownedByCaller": true}], "control": {"owner": "agent", "ownedByCaller": true, "generation": 0}}, "preview_open": {"available": true, "visible": false, "tabId": "tab_1", "url": "http://localhost:5173/"}, "preview_snapshot": {"available": true, "tabId": "tab_1", "url": "http://localhost:5173/", "screenshotPath": "/tmp/after.png"}}, "priorTurns": [{"role": "user", "text": "Use the Browser panel to verify the UI fix and capture an after screenshot."}, {"role": "assistant", "toolCalls": [{"tool": "Bash", "input": {"command": "cat .agents/skills/test-t3-app/SKILL.md"}, "result": "", "resultFile": ".agents/skills/test-t3-app/SKILL.md"}]}, {"role": "assistant", "toolCalls": [{"tool": "preview_status", "input": {}, "result": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:5173/", "tabs": [{"tabId": "tab_1", "url": "http://localhost:5173/", "owner": "agent", "ownedByCaller": true}], "control": {"owner": "agent", "ownedByCaller": true, "generation": 0}}}]}], "harness": "claude"}, "prompt": "", "expect": {"calls": [{"tool": "preview_snapshot"}], "callsMustNot": [{"tool": "preview_open", "args": {"reuseExistingTab": false}}]}, "maxSteps": 10, "notes": "Checks reuse of an existing usable tab. Screenshot saving is outside this control. Prior tool-result replay is validated on the Claude harness; Codex output wrapping needs separate validation."} {"id": "browser-resume-after-unavailable-status", "area": "visual-preview", "kind": "bad", "privacy": "commit-safe", "pattern": "closed Browser panel treated as unavailable automation", "experience": "The agent read test-t3-app, saw available false with no tab, and asked the user to open the panel instead of calling preview_open. Opening a tab succeeded on the next turn.", "source": {"report": "Maintainer browser verification failure, 2026-11-03; fresh skill read followed by unavailable status. Opus replay reproduces the stop on the old skill."}, "setup": {"role": "top", "context": "Repository instructions: Before web or desktop UI verification, read .agents/skills/test-t3-app/SKILL.md and follow it. If a skill causes you to stop or leave requested work unfinished, link the SKILL.md, quote the relevant instruction, and explain how it applies.", "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": false, "tabId": "tab_1", "url": "http://localhost:5173/"}, "preview_snapshot": {"available": true, "tabId": "tab_1", "url": "http://localhost:5173/", "screenshotPath": "/tmp/after.png"}}, "world": {"repo": "acme/app", "branch": "fix/permissions-popup", "status": "", "files": {"apps/web/src/components/ComposerContextPermissions.tsx": "export function ComposerContextPermissions() { return ; }\n"}, "description": "The requested fix is already committed as a1b2c3d on fix/permissions-popup, ahead of main. It changes ComposerContextPermissions.tsx to open the permissions popup above its trigger. Dependencies are installed. No development server is running and no .t3 directory exists.", "commits": [{"sha": "a1b2c3d", "subject": "fix(web): open composer permissions above its button"}]}, "priorTurns": [{"role": "user", "text": "When I click Full access in the composer, open its permissions menu directly above that button."}, {"role": "assistant", "toolCalls": [{"tool": "Bash", "input": {"command": "git commit -am \"fix(web): open composer permissions above its button\""}, "result": "[fix/permissions-popup a1b2c3d] fix(web): open composer permissions above its button\n 1 file changed, 3 insertions(+), 1 deletion(-)"}]}, {"role": "assistant", "text": "Implemented. Clicking Full access opens the permissions menu above the button. Focused checks pass. Full-client visual verification remains undone."}, {"role": "user", "text": "before/after screenshot"}, {"role": "assistant", "toolCalls": [{"tool": "Bash", "input": {"command": "cat .agents/skills/test-t3-app/SKILL.md"}, "result": "", "resultFile": ".agents/skills/test-t3-app/SKILL.md"}]}, {"role": "assistant", "text": "I'll use the test-t3-app skill to capture the before and after states in the app."}, {"role": "assistant", "toolCalls": [{"tool": "preview_status", "input": {}, "result": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}}]}], "shellMocks": [{"match": "git status|ls .*\\.t3", "output": "ls: .t3: No such file or directory"}], "harness": "claude"}, "prompt": "", "expect": {"calls": [{"tool": "preview_open"}]}, "maxSteps": 8, "notes": "Resume at the failed decision after reading the skill and receiving available=false. Opus reproduces the premature stop; Sonnet did not reliably reproduce it. This checks recovery through preview_open, not completion of screenshots. Prior tool-result replay is validated on the Claude harness; Codex output wrapping needs separate validation."} +{"id": "revise-page-resends-html", "area": "visual-preview", "kind": "bad", "pattern": "revising a page on disk resends its full HTML to html_preview and html_render", "experience": "The pages already existed as generated files on disk, but the agent retyped each one into html_preview and again into html_render, about four minutes per call. To save time it published fixes without previewing them again.", "source": {"discussion": "https://github.com/pingdotgg/t3code/discussions/23303"}, "rule": "edit the .html file and pass its path to html_preview and html_render instead of resending its html", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "The new-worktree toggle is ugly. Come up with four better options and mock them."}, {"role": "assistant", "text": "Here are four options.", "toolCalls": [{"tool": "Write", "input": {"file_path": "/tmp/design/index.html", "content": "\n

A. Checkbox under the composer

Ask anything
\n

B. Toggle in the toolbar

Ask anything
\n

C. Split send button

Ask anything
Send in new worktree
\n

D. Two visible choices

Ask anything
\n\n"}, "result": "File created successfully at: /tmp/design/index.html"}, {"tool": "html_preview", "input": {"html": "\n

A. Checkbox under the composer

Ask anything
\n

B. Toggle in the toolbar

Ask anything
\n

C. Split send button

Ask anything
Send in new worktree
\n

D. Two visible choices

Ask anything
\n\n"}, "result": "{\"width\":728,\"contentHeight\":1840,\"capturedHeight\":1840,\"consoleMessages\":[]}"}, {"tool": "html_render", "input": {"html": "\n

A. Checkbox under the composer

Ask anything
\n

B. Toggle in the toolbar

Ask anything
\n

C. Split send button

Ask anything
Send in new worktree
\n

D. Two visible choices

Ask anything
\n\n", "title": "New worktree: four options", "height": 1840}, "result": "{\"htmlRender\":{\"attachmentId\":\"att-1\",\"title\":\"New worktree: four options\",\"height\":1840},\"message\":\"Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say.\"}"}]}], "mocks": {"html_preview": {"width": 728, "contentHeight": 1840, "capturedHeight": 1840, "consoleMessages": []}, "html_render": {"htmlRender": {"attachmentId": "att-1", "title": "New worktree: four options", "height": 1840}, "message": "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."}}, "world": {"files": {"/tmp/design/index.html": "\n

A. Checkbox under the composer

Ask anything
\n

B. Toggle in the toolbar

Ask anything
\n

C. Split send button

Ask anything
Send in new worktree
\n

D. Two visible choices

Ask anything
\n\n"}}}, "prompt": "Make the Send in new worktree button in option C blue, and show me the page again.", "expect": {"calls": [{"tool": "html_preview", "args": {"path": "/tmp/design/index.html", "html": null}}, {"tool": "html_render", "args": {"path": "/tmp/design/index.html", "html": null}}], "callsMustNot": [{"tool": "html_preview", "args": {"html": "~Ask anything"}}, {"tool": "html_render", "args": {"html": "~Ask anything"}}], "order": ["html_preview", "html_render"]}, "notes": "On main neither tool has a path parameter."} diff --git a/evals/mcp-tool-use/run.test.ts b/evals/mcp-tool-use/run.test.ts index 121814fe2da2f..465b349a60773 100644 --- a/evals/mcp-tool-use/run.test.ts +++ b/evals/mcp-tool-use/run.test.ts @@ -298,6 +298,128 @@ it("passes a good run and names every rule a bad run breaks", () => { ]); }); +it("grades a page passed by path on the file the agent last wrote there", () => { + const expect = { + calls: [{ tool: "html_render", args: { path: "/tmp/v.html", page: "~var(--" } }], + callsMustNot: [{ tool: "html_render", args: { page: "~#000" } }], + }; + const run = (calls: SampleRun["calls"]): SampleRun => ({ + calls, + text: "", + stopReason: "end_turn", + }); + const write = { + step: 0, + tool: "Write", + input: { file_path: "/tmp/v.html", content: "

" }, + }; + const render = { step: 2, tool: "html_render", input: { path: "/tmp/v.html" } }; + const fix = { + step: 1, + tool: "Edit", + input: { file_path: "/tmp/v.html", old_string: "#000", new_string: "var(--card)" }, + }; + + assert.deepEqual(failed(expect, run([write, fix, render])), []); + assert.deepEqual(failed(expect, run([write, fix, { ...render, failed: true }])), [ + 'calls html_render {"path":"/tmp/v.html","page":"~var(--"}', + ]); + const refusedInline = { ...render, failed: true, input: { html: "

" } }; + assert.deepEqual(failed(expect, run([write, fix, refusedInline, render])), [ + 'callsMustNot html_render {"page":"~#000"}', + ]); + const refusedPath = { ...render, step: 1, failed: true }; + assert.deepEqual(failed(expect, run([write, refusedPath, fix, render])), []); + const patchUnknown = { + step: 1, + tool: "Edit", + input: { patch: "*** Update File: /tmp/v.html\n@@\n-

A

\n+

var(--card)

\n" }, + }; + assert.deepEqual(failed(expect, run([patchUnknown, render])), [ + 'calls html_render {"path":"/tmp/v.html","page":"~var(--"}', + "page known html_render /tmp/v.html", + ]); + // A relative add, then an update by absolute path the grader cannot replay: the page is unknown. + const added = { + ...write, + input: { file_path: "v.html", content: "

" }, + }; + const patchAbsolute = { ...patchUnknown, step: 2 }; + assert.include( + failed(expect, run([added, patchAbsolute, render])), + "page known html_render /tmp/v.html", + ); + const appended = { + ...write, + tool: "Bash", + input: { command: "cat >> /tmp/v.html <<'EOF'\n

var(--x)

\nEOF" }, + }; + assert.include(failed(expect, run([appended, render])), "page known html_render /tmp/v.html"); + const patched = { + step: 1, + tool: "Edit", + input: { + patch: "*** Update File: /tmp/v.html\n@@\n-background:#000\n+background:var(--card)\n", + }, + }; + assert.deepEqual(failed(expect, run([write, patched, render])), []); + const editUnseen = { ...fix, input: { ...fix.input, old_string: "

", new_string: "

" } }; + const editMarker = { ...fix, step: 2, input: { ...fix.input, old_string: "unknown" } }; + assert.include( + failed(expect, run([editUnseen, editMarker, render])), + "page known html_render /tmp/v.html", + ); + const dollar = { ...fix, input: { ...fix.input, new_string: "var(--card)$&" } }; + assert.deepEqual(failed(expect, run([write, dollar, render])), []); + const ambiguous = { ...write, input: { ...write.input, content: "

white

\nwhite" } }; + const patchAmbiguous = { + step: 1, + tool: "Edit", + input: { patch: "*** Update File: /tmp/v.html\n@@\n-white\n+var(--card)\n" }, + }; + assert.include( + failed(expect, run([ambiguous, patchAmbiguous, render])), + "page known html_render /tmp/v.html", + ); + const twice = { ...write, input: { ...write.input, content: "

#000" } }; + const fixAll = { ...fix, input: { ...fix.input, replace_all: true } }; + assert.deepEqual(failed(expect, run([twice, fixAll, render])), []); + const spoofed = { ...render, input: { path: "/tmp/v.html", page: "var(--card)" } }; + assert.deepEqual(failed(expect, run([spoofed])), [ + 'calls html_render {"path":"/tmp/v.html","page":"~var(--"}', + "page known html_render /tmp/v.html", + ]); + // T3 may still refuse an unanswered page call, so early grading waits for its result. + assert.isFalse(failedEarly(expect, run([write, render]))); + assert.isTrue(failedEarly(expect, run([write, { ...render, failed: false }]))); + assert.deepEqual(failed({ notCalls: ["html_render"] }, run([{ ...render, failed: true }])), [ + "notCalls html_render", + ]); + const relative = { ...write, input: { ...write.input, file_path: "././v.html" } }; + assert.deepEqual(failed(expect, run([relative, render])), [ + 'calls html_render {"path":"/tmp/v.html","page":"~var(--"}', + 'callsMustNot html_render {"page":"~#000"}', + ]); + const fixed = { + ...write, + input: { ...write.input, content: "

" }, + }; + const sameFixed = { ...relative, input: { ...relative.input, content: fixed.input.content } }; + assert.deepEqual(failed(expect, run([fixed, sameFixed, render])), []); + assert.deepEqual(failed(expect, run([fixed, relative, render])), [ + 'calls html_render {"path":"/tmp/v.html","page":"~var(--"}', + "page known html_render /tmp/v.html", + ]); + assert.deepEqual(failed(expect, run([write, render])), [ + 'calls html_render {"path":"/tmp/v.html","page":"~var(--"}', + 'callsMustNot html_render {"page":"~#000"}', + ]); + assert.deepEqual(failed(expect, run([render])), [ + 'calls html_render {"path":"/tmp/v.html","page":"~var(--"}', + "page known html_render /tmp/v.html", + ]); +}); + it("bounds numbers and reads target.options in either schema form", () => { const expect = { calls: [ diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts index 92ae6fe28cc4f..592ecd98141cc 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -82,6 +82,8 @@ import { import { argvForm, gradeSecret, + scriptsBefore, + UNKNOWN_SCRIPT, scriptSources, SecretExpect, secretDiagnostics, @@ -1570,6 +1572,16 @@ export const answerToolCall = Effect.fn("evaluateMcpToolUse.answerToolCall")(fun if (found.length === 0) return toolResult(call.id, "No matching deferred tools found"); return loadTools(tools, call.id, found); } + // T3 refuses a page call without exactly one source before any mocked result. + const sources = [input.html, input.path].filter( + (source) => source !== undefined && source !== null, + ); + if (isPageTool(name) && (sources.length !== 1 || typeof sources[0] !== "string")) { + return failed("Pass exactly one of html or path."); + } + if (isPageTool(name) && typeof input.path === "string" && !input.path.startsWith("/")) { + return failed(`${input.path} is not a readable HTML document. Pass an absolute path.`); + } const mock = testCase.setup.mocks?.[name]; // T3 answers device calls from the world. A mock that is a T3 device error fails inside T3. const failure = deviceFailure(mock); @@ -1692,15 +1704,50 @@ export const pastedText = ({ prompt, setup }: Pick tool === "html_preview" || tool === "html_render"; + +/** `page` is the HTML a page tool shows: its inline `html`, or the file the agent wrote at `path`. */ +const withPage = (tool: string, input: unknown, files: ReadonlyMap) => { + if (!isPageTool(tool) || !isRecord(input)) return input; + const path = valueText(input.path); + // A patch may name the file relative to the agent's working directory. When written files that + // could be this one differ, the page is unknown, so page checks fail rather than grade a stale copy. + const written = [...files].filter( + ([file]) => + file === path || + (!file.startsWith("/") && path.endsWith(`/${file.replace(/^(?:\.\/)+/, "")}`)), + ); + const pages = new Set(written.map(([, page]) => page)); + const page = + typeof input.html === "string" + ? input.html + : pages.size === 1 && !pages.has(UNKNOWN_SCRIPT) + ? written[0]![1] + : undefined; + // The grader derives `page`; a call cannot supply its own. + return { ...input, page }; +}; + /** `pasted` is the thread's user text (`pastedText`), for request_secret's `value`. */ export const gradeSample = (expect: McpToolUseCase["expect"], run: SampleRun, pasted = "") => { const checks: Array<{ readonly check: string; readonly pass: boolean }> = []; const add = (check: string, pass: boolean) => checks.push({ check, pass }); - const calls = run.calls.map((call) => ({ ...call, input: withOptionsObject(call.input) })); + const written = scriptsBefore(run.calls, true); + // A refused page call showed nothing, so it neither previews nor renders, and no file stands in + // for its page. Inline HTML it sent still counts against callsMustNot. + const attempted = run.calls.map((call, index) => ({ + ...call, + input: withPage( + call.tool, + withOptionsObject(call.input), + call.failed ? new Map() : written[index]!, + ), + })); + const calls = attempted.filter((call) => !(call.failed && isPageTool(call.tool))); const count = (tool: string) => calls.filter((call) => call.tool === tool).length; // `tool` may list alternatives, as in "link_pull_request|watch_pull_request". - const found = ({ tool, args = {} }: McpToolUseCall) => - calls.some((call) => tool.split("|").includes(call.tool) && matches(args, call.input)); + const found = ({ tool, args = {} }: McpToolUseCall, among = calls) => + among.some((call) => tool.split("|").includes(call.tool) && matches(args, call.input)); const label = ({ tool, args }: McpToolUseCall) => (args ? `${tool} ${valueText(args)}` : tool); for (const call of expect.calls ?? []) add(`calls ${label(call)}`, found(call)); if (expect.distinctCalls !== undefined) { @@ -1714,8 +1761,22 @@ export const gradeSample = (expect: McpToolUseCase["expect"], run: SampleRun, pa ), ); } - for (const call of expect.callsMustNot ?? []) add(`callsMustNot ${label(call)}`, !found(call)); - for (const tool of expect.notCalls ?? []) add(`notCalls ${tool}`, count(tool) === 0); + for (const call of expect.callsMustNot ?? []) + add(`callsMustNot ${label(call)}`, !found(call, attempted)); + // A page check must not pass because the grader never saw the file a path call shows. + const pageTools = [...(expect.calls ?? []), ...(expect.callsMustNot ?? [])].flatMap((call) => + call.args?.page === undefined ? [] : call.tool.split("|"), + ); + for (const call of calls) + if ( + pageTools.includes(call.tool) && + isRecord(call.input) && + call.input.path !== undefined && + call.input.page === undefined + ) + add(`page known ${call.tool} ${valueText(call.input.path)}`, false); + for (const tool of expect.notCalls ?? []) + add(`notCalls ${tool}`, !attempted.some((call) => call.tool === tool)); if (expect.order !== undefined) { const firsts = expect.order.map((tool) => calls.findIndex((call) => call.tool === tool)); const inOrder = firsts.every((at, i) => at > (firsts[i - 1] ?? -1)); @@ -1776,7 +1837,15 @@ const SETTLED = /^(?:(?:notCalls|callsMustNot|shellMustNot|maxCalls|textMustNot) |secret\.(?:chatAsk|askTools|values|fileUse|noCardDemand|afterDecline) )/; /** True once the sample has failed for good, so the rest of it cannot change the result. */ export const failedEarly = (expect: McpToolUseCase["expect"], run: SampleRun, pasted = "") => - gradeSample(expect, run, pasted).some((check) => !check.pass && SETTLED.test(check.check)); + gradeSample( + expect, + // A page call T3 has not answered yet may still be refused, so it is graded once it settles. + { + ...run, + calls: run.calls.filter((call) => !isPageTool(call.tool) || call.failed !== undefined), + }, + pasted, + ).some((check) => !check.pass && SETTLED.test(check.check)); const requestBody = ({ base, tools }: Sample, effort: string, messages: unknown) => ({ model: base.model, diff --git a/evals/mcp-tool-use/secret-grading.ts b/evals/mcp-tool-use/secret-grading.ts index 83ad314484820..bc290db26d8f1 100644 --- a/evals/mcp-tool-use/secret-grading.ts +++ b/evals/mcp-tool-use/secret-grading.ts @@ -1364,22 +1364,35 @@ const heredocWrite = (part: string) => { ? { path: target[3]!, source: heredoc[4]!, append: /^(?:>>|-a)/.test(target[1] ?? "") } : undefined; }; -const noteHeredoc = (part: string, scripts: Map) => { +/** In exact mode, a file whose contents the grader cannot rebuild. Any later update keeps it so. */ +export const UNKNOWN_SCRIPT = "\u0000unknown"; +const noteHeredoc = (part: string, scripts: Map, exact: boolean) => { const write = heredocWrite(part); - if (write) - scripts.set( - write.path, - write.append ? `${scripts.get(write.path) ?? ""}\n${write.source}` : write.source, - ); + if (!write) return; + const current = scripts.get(write.path); + scripts.set( + write.path, + !write.append + ? write.source + : exact && (current === undefined || current === UNKNOWN_SCRIPT) + ? UNKNOWN_SCRIPT + : `${current ?? ""}\n${write.source}`, + ); }; -/** Applies a Codex apply_patch to the scripts: added files, and updated files hunk by hunk. */ -const applyPatch = (patch: string, scripts: Map) => { +/** + * Applies a Codex apply_patch to the scripts: added files, and updated files hunk by hunk. `exact` + * marks a file unknown when an update cannot be applied in full, instead of guessing. + */ +const applyPatch = (patch: string, scripts: Map, exact: boolean) => { for (const [, op, path = "", body = ""] of patch.matchAll( /^\*\*\* (Add|Update|Delete) File: (.+)\n([\s\S]*?)(?=^\*\*\* (?:Add|Update|Delete) File: |^\*\*\* End Patch|(?![\s\S]))/gm, )) { const lines = body.split("\n"); - if (op === "Delete") scripts.delete(path); - else if (op === "Add") + // In exact mode a deleted file stays unknown, so a relative alias cannot supply a stale copy. + if (op === "Delete") { + if (exact) scripts.set(path, UNKNOWN_SCRIPT); + else scripts.delete(path); + } else if (op === "Add") scripts.set( path, lines.flatMap((line) => (line.startsWith("+") ? [line.slice(1)] : [])).join("\n"), @@ -1387,6 +1400,7 @@ const applyPatch = (patch: string, scripts: Map) => { else { // Each hunk's old lines become its new ones; a hunk that doesn't apply is appended. let source = scripts.get(path) ?? ""; + let unknown = exact && (!scripts.has(path) || source === UNKNOWN_SCRIPT); for (const hunk of body.split(/^@@.*$/m)) { const side = (keep: string) => hunk @@ -1395,16 +1409,26 @@ const applyPatch = (patch: string, scripts: Map) => { .map((line) => line.slice(1)) .join("\n"); const [before, after] = [side("-"), side("+")]; - if (!before && !after) continue; - source = - before && source.includes(before) ? source.replace(before, after) : `${source}\n${after}`; + if (unknown || (!before && !after)) continue; + const applies = before !== "" && source.includes(before); + // A pure addition has no anchor; appending it is exact enough for substring checks. + // An anchor that occurs twice could be either place, so exact mode cannot replay it. + unknown = + exact && + before !== "" && + (!applies || source.indexOf(before) !== source.lastIndexOf(before)); + source = applies ? source.replace(before, () => after) : `${source}\n${after}`; } - scripts.set(path, source); + scripts.set(path, unknown ? UNKNOWN_SCRIPT : source); } } }; /** Records the scripts a call writes: Write, Edit, Codex apply_patch, and heredocs into files. */ -const noteWrites = (call: SecretRun["calls"][number], scripts: Map) => { +const noteWrites = ( + call: SecretRun["calls"][number], + scripts: Map, + exact: boolean, +) => { // A failed Write or Edit left the file as it was. if (call.failed && (call.tool === "Write" || call.tool === "Edit")) return; const path = inputString(call.input, "file_path"); @@ -1415,19 +1439,31 @@ const noteWrites = (call: SecretRun["calls"][number], scripts: Map next) : source.replace(old, () => next)); } if (SHELL.has(call.tool) && call.tool !== "exec") for (const { text } of liveParts(inputString(call.input, "command"))) - noteHeredoc(text, scripts); + noteHeredoc(text, scripts, exact); }; -/** The scripts the agent had written before each call. */ -const scriptsBefore = (calls: SecretRun["calls"]): Array => { +/** + * The scripts the agent had written before each call. `exact` marks a file `UNKNOWN_SCRIPT` when a + * tracked edit cannot be replayed; shell edits other than heredocs are not tracked at all. + */ +export const scriptsBefore = (calls: SecretRun["calls"], exact = false): Array => { const scripts = new Map(); return calls.map((call) => { const before = new Map(scripts); - noteWrites(call, scripts); + noteWrites(call, scripts, exact); return before; }); }; @@ -1477,7 +1513,7 @@ export const scriptSources = (calls: SecretRun["calls"]): Array> = for (const { text } of liveParts(inputString(call.input, "command"))) { for (const stage of splitPipe(text)) for (const path of runsScripts(stage, scripts)) ran.add(scripts.get(path)!); - noteHeredoc(text, scripts); + noteHeredoc(text, scripts, false); } return [...ran]; }); @@ -1756,7 +1792,7 @@ export const classifyFileUse = ( if (use.verdict === "print (script prints the value)" && routed) flow(after, own, stage); else uses.push(use); } - noteHeredoc(part, known); + noteHeredoc(part, known, false); if (assignments(part)) continue; const readInto = new RegExp( `^read\\s+(?:-\\w+\\s+)*(\\w+)\\s*<\\s*["']?(?:${fileRef()})`, diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index 2a27148fcd613..d85a80d71add7 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -50,7 +50,7 @@ function supervisorState(overrides: Partial): Supervi } describe("connection presentation", () => { - it("copies the MCP address of the route this device is connected over", () => { + it("copies the T3 Connect MCP address first, then the route this device is connected over", () => { const route = (connectionId: string, httpBaseUrl: string): ConnectionRoute => { const target = new BearerConnectionTarget({ ...TARGET, connectionId }); return { @@ -80,6 +80,13 @@ describe("connection presentation", () => { "http://100.115.1.44:3773/mcp", ); expect(environmentMcpUrl({ entry })).toBe("http://192.168.4.53:3773/mcp"); + expect( + environmentMcpUrl({ + entry, + connectedTarget: lan.target, + relayHttpBaseUrl: "https://tunnel.example.test", + }), + ).toBe("https://tunnel.example.test/mcp"); }); // regression: #16909 Copy MCP URL falls back to an alternate route when the relay route has no address diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index c579f9587a3df..b0e83a9d983d5 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -95,24 +95,24 @@ export function connectionStatusTitle(connection: EnvironmentConnectionPresentat } /** - * SSH connections ride a local forward that disappears with the client, so they have no stable - * address. + * The T3 Connect address wins whenever it is known: it works for agents anywhere, while the + * connected route is often a LAN or loopback address. SSH routes ride a local forward that + * disappears with the client, so they have no stable address. */ export function environmentMcpUrl(input: { readonly entry: ConnectionCatalogEntry; readonly relayHttpBaseUrl?: string | undefined; readonly connectedTarget?: ConnectionTarget | null | undefined; }): string | null { + const relayMcpUrl = input.relayHttpBaseUrl ? mcpUrlFromBase(input.relayHttpBaseUrl) : null; + if (relayMcpUrl !== null) return relayMcpUrl; const connectedRouteId = input.connectedTarget ? connectionRouteId(input.connectedTarget) : null; const routes = connectionRoutes(input.entry); const connectedRoute = routes.find( (route) => connectionRouteId(route.target) === connectedRouteId, ); for (const route of connectedRoute ? [connectedRoute, ...routes] : routes) { - const httpBaseUrl = - route.target._tag === "RelayConnectionTarget" - ? (input.relayHttpBaseUrl ?? null) - : routeHttpBaseUrl(route); + const httpBaseUrl = routeHttpBaseUrl(route); const mcpUrl = httpBaseUrl === null ? null : mcpUrlFromBase(httpBaseUrl); if (mcpUrl !== null) return mcpUrl; } diff --git a/packages/client-runtime/src/mediaActions.ts b/packages/client-runtime/src/mediaActions.ts index 85bdcf67812ba..3c2d09a85c5e1 100644 --- a/packages/client-runtime/src/mediaActions.ts +++ b/packages/client-runtime/src/mediaActions.ts @@ -11,4 +11,5 @@ export type MediaActionId = | "copy-url" | "open-file" | "save" + | "save-image" | "copy-image"; diff --git a/packages/client-runtime/src/platform/orchestrationCache.ts b/packages/client-runtime/src/platform/orchestrationCache.ts index e7800e20bd33a..3d24e03f113f9 100644 --- a/packages/client-runtime/src/platform/orchestrationCache.ts +++ b/packages/client-runtime/src/platform/orchestrationCache.ts @@ -13,10 +13,25 @@ export const ORCHESTRATION_CACHE_SCHEMA_VERSION = 3 as const; // Older caches may contain human turns that bypassed server history budgets. export const ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION = 5 as const; +/** + * The server snapshot the cached rows descend from. Written only for rows that have one, so a + * cache from before it existed decodes and never resumes. + */ +export const ShellCacheOrigin = Schema.Struct({ + serverVersion: Schema.String, + databaseMigration: Schema.NullOr(Schema.Int), + omitIdleSubagents: Schema.Boolean, + omitOlderSettled: Schema.Boolean, + /** Epoch milliseconds of that snapshot; replay does not renew it. */ + snapshotAt: Schema.Number, +}); +export type ShellCacheOrigin = typeof ShellCacheOrigin.Type; + export const StoredOrchestrationShellSnapshot = Schema.Struct({ schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION), environmentId: EnvironmentId, snapshot: OrchestrationV2ShellSnapshotJson, + origin: Schema.optionalKey(ShellCacheOrigin), }); export const StoredOrchestrationThreadSnapshot = Schema.Struct({ diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index e043279d3e576..6476b80221225 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -14,6 +14,13 @@ import * as Schema from "effect/Schema"; import type { ConnectionRegistration } from "../connection/catalog.ts"; import type { DeferredShellSnapshot } from "../state/shellPullRequests.ts"; import type { ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; +import type { ShellCacheOrigin } from "./orchestrationCache.ts"; + +export type { ShellCacheOrigin }; + +export interface CachedShellSnapshot extends DeferredShellSnapshot { + readonly origin?: ShellCacheOrigin; +} export class ConnectionPersistenceError extends Schema.TaggedError()( "ConnectionPersistenceError", @@ -82,10 +89,12 @@ export class EnvironmentCacheStore extends Context.Service< { readonly loadShell: ( environmentId: EnvironmentId, - ) => Effect.Effect, ConnectionPersistenceError>; + ) => Effect.Effect, ConnectionPersistenceError>; + /** `origin` only when the rows descend from a server snapshot. */ readonly saveShell: ( environmentId: EnvironmentId, snapshot: OrchestrationV2ShellSnapshot, + origin?: ShellCacheOrigin, ) => Effect.Effect; readonly loadThread: ( environmentId: EnvironmentId, diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index a99e090314ae7..c10e2d5a5eca5 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -47,6 +47,7 @@ import * as HttpClientError from "effect/http/HttpClientError"; import type * as HttpMethod from "effect/http/HttpMethod"; import * as HttpApiClient from "effect/http-api/HttpApiClient"; import { NETWORK_BLOCKING_HINT } from "../errors/network.ts"; +import { relayRequestFailureDetail } from "./errorPresentation.ts"; export interface ManagedRelayDpopProofInput { readonly method: HttpMethod.HttpMethod; @@ -159,8 +160,9 @@ export class ManagedRelayRequestFailedError extends Schema.TaggedError { }), ); }); + +it.effect.each([true, false])( + "refreshes branches using the connected server capability (%s)", + (supported) => + Effect.gen(function* () { + const { activeSession, supervisor } = yield* makeHarness(); + const inputs: unknown[] = []; + const client = { + [supported ? WS_METHODS.vcsRefreshBranch : WS_METHODS.vcsRefreshStatus]: ( + input: unknown, + ) => { + inputs.push(input); + return Effect.void; + }, + } as unknown as WsRpcProtocolClient; + yield* SubscriptionRef.set( + activeSession, + Option.some({ + ...session(client), + initialConfig: Effect.succeed({ + environment: { capabilities: supported ? { vcsBranchStatus: true } : {} }, + } as ServerConfig), + }), + ); + expect( + yield* refreshVcsBranch({ cwd: "/repo" }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ), + ).toBeUndefined(); + expect(inputs).toEqual(supported ? [{ cwd: "/repo" }] : []); + }).pipe(Effect.scoped), +); + +it.effect("binds branch capability selection and execution to one session", () => + Effect.gen(function* () { + const { activeSession, supervisor } = yield* makeHarness(); + const configRequested = yield* Deferred.make(); + const config = yield* Deferred.make(); + const executed = yield* Deferred.make(); + const original = { + [WS_METHODS.vcsRefreshBranch]: () => + Deferred.succeed(executed, undefined).pipe(Effect.asVoid), + } as unknown as WsRpcProtocolClient; + const replacement = { + [WS_METHODS.vcsRefreshStatus]: () => Effect.succeed({ refName: "replacement" }), + } as unknown as WsRpcProtocolClient; + yield* SubscriptionRef.set( + activeSession, + Option.some({ + ...session(original), + initialConfig: Deferred.succeed(configRequested, undefined).pipe( + Effect.andThen(Deferred.await(config)), + ), + }), + ); + const refresh = yield* refreshVcsBranch({ cwd: "/repo" }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + yield* Deferred.await(configRequested); + yield* SubscriptionRef.set(activeSession, Option.some(session(replacement))); + yield* Deferred.succeed(config, { + environment: { capabilities: { vcsBranchStatus: true } }, + } as ServerConfig); + expect(yield* Fiber.join(refresh)).toBeUndefined(); + yield* Deferred.await(executed); + }).pipe(Effect.scoped), +); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index e74abde6a2c59..d3a49c2aa58b3 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -68,6 +68,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeResourceTelemetry | typeof WS_METHODS.pullRequestsSubscribeRefreshes | typeof WS_METHODS.subscribeVcsStatus + | typeof WS_METHODS.subscribeVcsBranch | typeof WS_METHODS.subscribeWorktreeSetup | typeof WS_METHODS.subscribeProjectClones | typeof WS_METHODS.terminalAttach @@ -186,15 +187,14 @@ const authorizeRequest = Effect.fn("EnvironmentRpc.authorize")(function* ( yield* guard.authorize(supervisor.target.environmentId, method, input); }); -export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* < +const requestInSession = Effect.fn("EnvironmentRpc.request")(function* < TTag extends EnvironmentUnaryRpcTag, ->(tag: TTag, input: EnvironmentRpcInput) { +>(session: RpcSession, tag: TTag, input: EnvironmentRpcInput) { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; yield* Effect.annotateCurrentSpan({ "environment.id": supervisor.target.environmentId, "rpc.method": tag, }); - const session = yield* currentSession(); yield* authorizeRequest(tag, input); const observer = yield* EnvironmentRpcRequestObserver; const method = session.client[tag] as ( @@ -208,6 +208,14 @@ export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* < return yield* method(input).pipe(Effect.ensuring(completeObservation)); }); +// The session-bound request owns the existing EnvironmentRpc.request span. +export const requestGuarded = Effect.fnUntraced(function* ( + tag: TTag, + input: EnvironmentRpcInput, +) { + return yield* requestInSession(yield* currentSession(), tag, input); +}); + export function runStreamGuarded( tag: TTag, input: EnvironmentRpcInput, @@ -300,7 +308,10 @@ function subscribeDynamicMapped( method: tag, input, }); - const stream = mapStream(session, method(input)).pipe( + const stream = mapStream( + session, + Stream.suspend(() => method(input)), + ).pipe( Stream.onFirst(() => Effect.sync(() => { expectedFailureRetries = 0; @@ -436,6 +447,59 @@ export function subscribe( return subscribeDynamic(tag, () => Effect.succeed(input), options); } +export const subscribeVcsBranch = ( + input: EnvironmentRpcInput, +) => + subscribeDynamicMapped( + WS_METHODS.subscribeVcsBranch, + () => Effect.succeed(input), + (session, stream) => + Stream.unwrap( + session.initialConfig.pipe( + Effect.mapError( + (error) => + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: error.message, + cause: error, + }), + }), + ), + Effect.map((config) => + config.environment.capabilities.vcsBranchStatus === true + ? stream + : session.client[WS_METHODS.subscribeVcsStatus]({ + ...input, + includeRemote: false, + }).pipe( + Stream.filter((event) => event._tag !== "remoteUpdated"), + Stream.map((event) => ({ refName: event.local.refName })), + ), + ), + ), + ), + ); + +export const refreshVcsBranch = Effect.fn("EnvironmentRpc.refreshVcsBranch")(function* ( + input: EnvironmentRpcInput, +) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const session = yield* currentSession(); + const config = yield* session.initialConfig.pipe( + Effect.mapError( + (error) => + new EnvironmentRpcUnavailableError({ + environmentId: supervisor.target.environmentId, + message: error.message, + }), + ), + ); + if (config.environment.capabilities.vcsBranchStatus === true) { + return yield* requestInSession(session, WS_METHODS.vcsRefreshBranch, input); + } + // Older hosts retain their existing status stream without adding per-row wake scans. +}); + export const request = >( tag: TTag, input: EnvironmentRpcInput, diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index 79276f3851504..d8da76a4b6906 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -9,6 +9,7 @@ import { vi } from "vite-plus/test"; import { AuthAccessReadScope, AuthAccessWriteScope, + AuthEnvironmentMaintainScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthPreviewOperateScope, @@ -157,28 +158,27 @@ describe("command permissions", () => { ), ); - it.effect("requires the destination settings grant to run storage cleanup", () => + it.effect.each([ + { method: WS_METHODS.serverRunStorageCleanup, scope: AuthSettingsWriteScope }, + { method: WS_METHODS.serverReclaimDatabaseSpace, scope: AuthEnvironmentMaintainScope }, + ])("requires the destination $scope grant for $method", ({ method, scope }) => Effect.scoped( Effect.gen(function* () { const registry = yield* setup; - const cleanup = createCommandPermissions(runtime, WS_METHODS.serverRunStorageCleanup); + const command = createCommandPermissions(runtime, method); registry.set(sessions(env), AsyncResult.success(grant(true))); - expect(registry.get(cleanup.permissionAtom(env))).toBe(false); - expect((yield* cleanup.authorize(registry, env).pipe(Effect.flip))._tag).toBe( + expect(registry.get(command.permissionAtom(env))).toBe(false); + expect((yield* command.authorize(registry, env).pipe(Effect.flip))._tag).toBe( "EnvironmentAuthorizationError", ); registry.set( sessions(env), - AsyncResult.success({ - ...grant(true), - scopes: [AuthSettingsWriteScope], - permissions: [AuthSettingsWriteScope], - }), + AsyncResult.success({ ...grant(true), scopes: [scope], permissions: [scope] }), ); - expect(registry.get(cleanup.permissionAtom(env))).toBe(true); - yield* cleanup.authorize(registry, env); + expect(registry.get(command.permissionAtom(env))).toBe(true); + yield* command.authorize(registry, env); registry.set(sessions(other), AsyncResult.success(grant(false))); - expect(registry.get(cleanup.permissionAtom(other))).toBe(false); + expect(registry.get(command.permissionAtom(other))).toBe(false); }), ), ); diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index f5be5e2f63cae..af89357f0f1ed 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -2,6 +2,7 @@ import { ORCHESTRATION_V2_WS_METHODS, type EnvironmentId, type ThreadId, + type OrchestrationV2ThreadLinkMetadata, type OrchestrationGetTurnDiffResult, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -14,6 +15,7 @@ import { Atom } from "effect/reactivity"; import { applyCommandOutputFrame, EMPTY_COMMAND_OUTPUT } from "./commandOutput.ts"; import { + createEnvironmentQueryAtomFamily, createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily, createEnvironmentRpcSubscriptionAtomFamily, @@ -24,7 +26,7 @@ import type { EnvironmentRegistry } from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import * as ManagedRelayDpopSigner from "../relay/managedRelay.ts"; -import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; +import { EnvironmentRpcUnavailableError, request } from "../rpc/client.ts"; import type { EnvironmentShellState } from "./shell.ts"; import { fetchEnvironmentThreadTranscript } from "./threadTranscriptHttp.ts"; @@ -47,6 +49,20 @@ export function createOrchestrationEnvironmentAtoms( ? {} : { onAnswer: options.onCheckpointDiffAnswer }; const shellState = options.shellState; + const refreshThreadLookup = ({ + environmentId, + input, + }: { + readonly environmentId: EnvironmentId; + readonly input: { readonly threadIds: ReadonlyArray }; + }) => { + if (shellState === undefined) return undefined; + const state = shellState(environmentId); + return Atom.make((get) => { + const { removedAt } = get(state); + return Math.max(-1, ...input.threadIds.map((threadId) => removedAt.get(threadId) ?? -1)); + }); + }; return { threadTitles: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:thread-titles", @@ -55,14 +71,36 @@ export function createOrchestrationEnvironmentAtoms( idleTtlMs: 0, // Deleting a thread the shell already left out keeps these ids, so the query would not rerun. // A removal can also be an archive, so it only prompts a re-check. - refreshTrigger: ({ environmentId, input }) => { - if (shellState === undefined) return undefined; - const state = shellState(environmentId); - return Atom.make((get) => { - const { removedAt } = get(state); - return Math.max(-1, ...input.threadIds.map((threadId) => removedAt.get(threadId) ?? -1)); - }); - }, + refreshTrigger: refreshThreadLookup, + }), + threadLinkMetadata: createEnvironmentQueryAtomFamily(runtime, { + label: "environment-data:orchestration:thread-link-metadata", + staleTimeMs: 0, + idleTtlMs: 0, + refreshTrigger: refreshThreadLookup, + execute: Effect.fn("orchestration.threadLinkMetadata")(function* (input: { + readonly threadIds: ReadonlyArray; + }) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const session = yield* SubscriptionRef.get(supervisor.session); + if (Option.isNone(session)) { + return yield* new EnvironmentRpcUnavailableError({ + environmentId: supervisor.target.environmentId, + message: "The environment has no session.", + }); + } + const config = yield* session.value.initialConfig; + if (config.threadLinkMetadata === true) { + return yield* request(ORCHESTRATION_V2_WS_METHODS.getThreadLinkMetadata, input); + } + const titles = yield* request(ORCHESTRATION_V2_WS_METHODS.getThreadTitles, input); + return Object.fromEntries( + Object.entries(titles).map(([id, title]) => [ + id, + { title, projectId: null } satisfies OrchestrationV2ThreadLinkMetadata, + ]), + ); + }), }), threadTranscript: createEnvironmentCommand(runtime, { label: "environment-data:orchestration:thread-transcript", diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index f79f00cdaa80e..7850f1226f4e9 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -3,6 +3,7 @@ import { EnvironmentId, ORCHESTRATION_V2_WS_METHODS, ThreadId, + ProjectId, type ReviewDiffPreviewInput, type PreviewEvent, type TerminalAttachInput, @@ -1023,6 +1024,39 @@ describe("environment query lifecycle", () => { ), ); + it.effect.each([true, false])("loads thread link metadata with server support %s", (supported) => + Effect.scoped( + Effect.gen(function* () { + const projectId = ProjectId.make("project"); + const tag = supported + ? ORCHESTRATION_V2_WS_METHODS.getThreadLinkMetadata + : ORCHESTRATION_V2_WS_METHODS.getThreadTitles; + const harness = yield* makeEnvironmentQueryHarness(Effect.void, { + rpcSession: { + initialConfig: Effect.succeed({ threadLinkMetadata: supported }), + client: { + [tag]: () => + Effect.succeed( + supported ? { fork: { title: "Fork", projectId } } : { fork: "Fork" }, + ), + }, + } as unknown as RpcSession.RpcSession, + }); + const runtime = harness.runtime as unknown as Atom.AtomRuntime< + EnvironmentRegistry.EnvironmentRegistry | HttpClient.HttpClient + >; + const atom = createOrchestrationEnvironmentAtoms(runtime).threadLinkMetadata({ + environmentId: QUERY_ENVIRONMENT.environmentId, + input: { threadIds: [ThreadId.make("fork")] }, + }); + const registry = yield* mountEnvironmentQuery(atom); + expect(yield* readEnvironmentQuery(registry, atom)).toEqual({ + fork: { title: "Fork", projectId: supported ? projectId : null }, + }); + }), + ), + ); + it.effect("re-checks thread titles when one of their threads leaves the shell", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index d3229468ab41b..23227ee5213be 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1217,6 +1217,11 @@ export function createServerEnvironmentAtoms( }), databaseStorage, databaseCompactionRecommendations, + reclaimDatabaseSpace: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:reclaim-database-space", + tag: WS_METHODS.serverReclaimDatabaseSpace, + concurrency: { mode: "singleFlight", key: ({ environmentId }) => environmentId }, + }), storageCleanupReport: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:storage-cleanup-report", tag: WS_METHODS.serverGetStorageCleanupReport, diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 032ede63714d2..062db03809cfc 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -1136,3 +1136,182 @@ describe("environment shell synchronization", () => { }), ); }); + +describe("environment shell cold start", () => { + const NOW = 1_800_000_000_000; + const HOUR = 60 * 60 * 1000; + const CACHED_SEQUENCE = 7; + const HTTP_SEQUENCE = 9; + // What `session()` advertises: server 1.0.0, no omissions, no migration. + const CURRENT_ORIGIN = { + serverVersion: "1.0.0", + databaseMigration: null, + omitIdleSubagents: false, + omitOlderSettled: false, + }; + const RECENT_ORIGIN = { ...CURRENT_ORIGIN, snapshotAt: NOW - HOUR }; + + const makeColdStart = Effect.fn("test.makeColdStartShell")(function* ( + origin: Persistence.ShellCacheOrigin | undefined, + ) { + yield* TestClock.setTime(NOW); + const subscriptions = yield* Queue.unbounded<{ + readonly input: unknown; + readonly events: Queue.Queue; + }>(); + const saved = yield* Queue.unbounded<{ + readonly snapshot: OrchestrationV2ShellSnapshot; + readonly origin: Persistence.ShellCacheOrigin | undefined; + }>(); + const httpLoads = yield* Ref.make(0); + const client = { + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: (input: unknown) => + Stream.unwrap( + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + yield* Queue.offer(subscriptions, { input, events }); + return Stream.fromQueue(events); + }), + ), + } as unknown as WsRpcProtocolClient; + const activeSession = yield* SubscriptionRef.make(Option.some(session(client))); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: activeSession, + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => + Effect.succeedSome({ + ...v2ShellSnapshot, + snapshotSequence: CACHED_SEQUENCE, + ...(origin === undefined ? {} : { origin }), + }), + saveShell: (_environmentId, snapshot, origin?: Persistence.ShellCacheOrigin) => + Queue.offer(saved, { snapshot, origin }).pipe(Effect.asVoid), + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const snapshotLoader = ShellSnapshotLoader.ShellSnapshotLoader.of({ + load: () => + Ref.updateAndGet(httpLoads, (count) => count + 1).pipe( + Effect.map((count) => + Option.some({ ...v2ShellSnapshot, snapshotSequence: HTTP_SEQUENCE + count - 1 }), + ), + ), + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader.ShellSnapshotLoader, snapshotLoader), + ); + return { + subscriptions, + saved, + httpLoads, + reconnect: SubscriptionRef.set(activeSession, Option.some(session(client))), + liveAt: (sequence: number) => + SubscriptionRef.changes(shellState).pipe( + Stream.filter( + (state) => + state.status === "live" && + Option.exists(state.snapshot, (snapshot) => snapshot.snapshotSequence === sequence), + ), + Stream.runHead, + Effect.map(Option.getOrThrow), + ), + }; + }); + + it.effect.each([ + { label: "a recent matching origin", origin: RECENT_ORIGIN, resumes: true }, + { label: "no origin", origin: undefined, resumes: false }, + { + label: "other omissions", + origin: { ...RECENT_ORIGIN, omitIdleSubagents: true }, + resumes: false, + }, + { + label: "another migration", + origin: { ...RECENT_ORIGIN, databaseMigration: 70 }, + resumes: false, + }, + { + label: "another server version", + origin: { ...RECENT_ORIGIN, serverVersion: "0.9.0" }, + resumes: false, + }, + { + label: "a 12 hour old origin", + origin: { ...RECENT_ORIGIN, snapshotAt: NOW - 12 * HOUR }, + resumes: false, + }, + ])("starts from a cache with $label (resumes: $resumes)", ({ origin, resumes }) => + Effect.gen(function* () { + const h = yield* makeColdStart(origin); + const first = yield* Queue.take(h.subscriptions); + const sequence = resumes ? CACHED_SEQUENCE : HTTP_SEQUENCE; + expect(first.input).toMatchObject({ afterSequence: sequence }); + // Only held rows may be refused; a fresh snapshot's cursor never loops back to HTTP. + if (resumes) expect(first.input).toMatchObject({ httpSnapshotFallback: true }); + else expect(first.input).not.toHaveProperty("httpSnapshotFallback"); + expect(yield* Ref.get(h.httpLoads)).toBe(resumes ? 0 : 1); + + // Thread families, older threads, and search wait for a nonzero generation. + yield* Queue.offer(first.events, { kind: "synchronized" }); + expect((yield* h.liveAt(sequence)).generation).toBeGreaterThan(0); + + if (!resumes) { + // The next cold start may resume from this snapshot. + yield* TestClock.adjust("500 millis"); + expect(yield* Queue.take(h.saved)).toMatchObject({ + snapshot: { snapshotSequence: HTTP_SEQUENCE }, + origin: { ...CURRENT_ORIGIN, snapshotAt: NOW }, + }); + } + }), + ); + + it.effect("reloads over HTTP when the server answers a resume with snapshot-required", () => + Effect.gen(function* () { + const h = yield* makeColdStart(RECENT_ORIGIN); + + const coldResume = yield* Queue.take(h.subscriptions); + expect(coldResume.input).toMatchObject({ afterSequence: CACHED_SEQUENCE }); + yield* Queue.offer(coldResume.events, { kind: "snapshot-required" }); + const coldReload = yield* Queue.take(h.subscriptions); + expect(coldReload.input).toMatchObject({ afterSequence: HTTP_SEQUENCE }); + expect(coldReload.input).not.toHaveProperty("httpSnapshotFallback"); + expect(yield* Ref.get(h.httpLoads)).toBe(1); + yield* Queue.offer(coldReload.events, { kind: "synchronized" }); + expect((yield* h.liveAt(HTTP_SEQUENCE)).generation).toBeGreaterThan(0); + + // A warm reconnect resumes the in-memory shell and falls back the same way. + yield* h.reconnect; + const warmResume = yield* Queue.take(h.subscriptions); + expect(warmResume.input).toMatchObject({ + afterSequence: HTTP_SEQUENCE, + httpSnapshotFallback: true, + }); + expect(yield* Ref.get(h.httpLoads)).toBe(1); + yield* Queue.offer(warmResume.events, { kind: "snapshot-required" }); + const warmReload = yield* Queue.take(h.subscriptions); + expect(warmReload.input).toMatchObject({ afterSequence: HTTP_SEQUENCE + 1 }); + expect(yield* Ref.get(h.httpLoads)).toBe(2); + yield* Queue.offer(warmReload.events, { kind: "synchronized" }); + yield* h.liveAt(HTTP_SEQUENCE + 1); + }), + ); +}); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index d75ea5ff64a05..808fd9fdece56 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -9,6 +9,7 @@ import { type ThreadPullRequestLink, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; @@ -82,14 +83,41 @@ function withoutDeferredPullRequests(value: DeferredShellSnapshot): Orchestratio const SHELL_SYNCHRONIZATION_ERROR_MESSAGE = "Could not synchronize environment data."; -function groupBySession(entries: ReadonlyArray) { - const groups: Array<[RpcSession, Array]> = []; - for (const [session, value] of entries) { - const last = groups.at(-1); - if (last?.[0] === session) last[1].push(value); - else groups.push([session, [value]]); - } - return groups; +/** + * Replay does not refresh the older-settled cut, rows that left the newest window, or + * projections the server rebuilt, so drift grows with the age of the last server snapshot. + */ +const CACHE_RESUME_MAX_AGE_MILLIS = 12 * 60 * 60 * 1000; + +type ShellOriginIdentity = Omit; + +interface ShellSubscription { + readonly session: RpcSession; + /** What a snapshot from this subscription descends from; null when the server did not say. */ + readonly identity: ShellOriginIdentity | null; +} + +function canResumeFrom( + origin: Persistence.ShellCacheOrigin | undefined, + identity: ShellOriginIdentity | null, + now: number, +): boolean { + if (origin === undefined || identity === null) return false; + const age = now - origin.snapshotAt; + return ( + origin.serverVersion === identity.serverVersion && + origin.databaseMigration === identity.databaseMigration && + origin.omitIdleSubagents === identity.omitIdleSubagents && + origin.omitOlderSettled === identity.omitOlderSettled && + age >= 0 && + age < CACHE_RESUME_MAX_AGE_MILLIS + ); +} + +interface LiveShell { + readonly snapshot: OrchestrationV2ShellSnapshot; + // Read together with the rows, so a save never pairs them with another snapshot's origin. + readonly origin: Persistence.ShellCacheOrigin | undefined; } export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")(function* () { @@ -105,29 +133,30 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") environmentId, ...safeErrorLogAttributes(error), }), - Effect.as(Option.none()), + Effect.as(Option.none()), ), ), ); - const cachedSnapshot = Option.map(cached, withoutDeferredPullRequests); + const cachedOrigin = Option.isSome(cached) ? cached.value.origin : undefined; + const cachedSnapshot = Option.map(cached, ({ origin: _origin, ...rows }) => + withoutDeferredPullRequests(rows), + ); const state = yield* SubscriptionRef.make({ ...EMPTY_SHELL_STATE, snapshot: cachedSnapshot, status: shellStatusForSnapshot(cachedSnapshot), }); const awaitingCompletion = yield* Ref.make(false); - // The omissions and server build of the subscription whose snapshot the - // in-memory shell last came from; null while it holds only cached data. - const authoritativeOmission = yield* Ref.make(null); - // Items buffered from a previous session keep that session's key. - const sessionOmissions = new WeakMap(); - const latestLiveSnapshot = yield* Ref.make>( - Option.none(), - ); + const activeSubscription = yield* Ref.make(null); + // The server snapshot the in-memory rows descend from, directly or through the cache. + // Only a server snapshot renews it; `snapshot-required` drops it. + const origin = yield* Ref.make(cachedOrigin); + const reloadRequests = yield* Queue.unbounded(); + const latestLive = yield* Ref.make>(Option.none()); const persistence = yield* Queue.sliding(1); const persistenceLock = yield* Semaphore.make(1); - let lastPersisted: OrchestrationV2ShellSnapshot | undefined; + let lastPersisted: LiveShell | undefined; // Rows still waiting for their deferred pull request links, which must not be saved: // the cache would keep them without links until the next server refresh. let pendingLinkRows: ReadonlySet = new Set(); @@ -135,24 +164,25 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") flush: boolean, ) { while (true) { - const latest = yield* Ref.get(latestLiveSnapshot); + const latest = yield* Ref.get(latestLive); if (Option.isNone(latest) || latest.value === lastPersisted) return; - if (latest.value.threads.some((thread) => pendingLinkRows.has(thread))) return; - const snapshot = latest.value; + const { snapshot, origin } = latest.value; + if (snapshot.threads.some((thread) => pendingLinkRows.has(thread))) return; if ( lastPersisted !== undefined && - snapshot.snapshotSequence >= lastPersisted.snapshotSequence && - snapshot.schemaVersion === lastPersisted.schemaVersion && - snapshot.projects === lastPersisted.projects && - snapshot.threads === lastPersisted.threads && - snapshot.archivedThreads === lastPersisted.archivedThreads + origin === lastPersisted.origin && + snapshot.snapshotSequence >= lastPersisted.snapshot.snapshotSequence && + snapshot.schemaVersion === lastPersisted.snapshot.schemaVersion && + snapshot.projects === lastPersisted.snapshot.projects && + snapshot.threads === lastPersisted.snapshot.threads && + snapshot.archivedThreads === lastPersisted.snapshot.archivedThreads ) { // Keep the live cursor without rewriting the rows. Remember it here too so a // later authoritative reset to a lower sequence still reaches the cache. - lastPersisted = snapshot; + lastPersisted = latest.value; return; } - const saved = yield* cache.saveShell(environmentId, snapshot).pipe( + const saved = yield* cache.saveShell(environmentId, snapshot, origin).pipe( Effect.as(true), Effect.catch((error) => Effect.logWarning("Could not persist environment shell cache.").pipe( @@ -162,7 +192,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") ), ); if (!saved) return; - lastPersisted = snapshot; + lastPersisted = latest.value; if (!flush) return; } }, persistenceLock.withPermit); @@ -220,14 +250,20 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") const snapshotWriteLock = yield* Semaphore.make(1); const applyItems = Effect.fn("EnvironmentShellState.applyItems")(function* ( - items: ReadonlyArray, - omission: string | null, + entries: ReadonlyArray, pendingLinkIds?: ReadonlySet, ) { + // Read under the lock: items buffered across a session change must not land on the + // rows the next session loaded. + const subscription = yield* Ref.get(activeSubscription); + const items = entries.flatMap(([session, item]) => + session === subscription?.session ? [item] : [], + ); const initial = yield* SubscriptionRef.get(state); let waiting = yield* Ref.get(awaitingCompletion); let next = initial; let receivedAuthoritativeSnapshot = false; + let snapshotRequired = false; let deltas: Array = []; const flushDeltas = () => { if (deltas.length > 0 && Option.isSome(next.snapshot)) { @@ -260,6 +296,10 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") deltas = []; }; for (const item of items) { + if (item.kind === "snapshot-required") { + snapshotRequired = true; + continue; + } if (item.kind !== "snapshot" && item.kind !== "synchronized") { deltas.push(item); continue; @@ -269,7 +309,13 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") if (item.kind === "synchronized") { waiting = false; if (Option.isSome(next.snapshot)) { - next = { ...next, status: "live", error: Option.none() }; + next = { + ...next, + status: "live", + error: Option.none(), + // A cache resume that caught up is as current as a snapshot for generation readers. + generation: next.generation === 0 ? ++lastShellGeneration : next.generation, + }; } continue; } @@ -292,6 +338,20 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") } flushDeltas(); yield* Ref.set(awaitingCompletion, waiting); + let nextOrigin = yield* Ref.get(origin); + if (snapshotRequired) { + // The rows stay on screen but reload before the next resume; the cache keeps the old + // origin until that reload saves, which costs at most one more refusal. + nextOrigin = undefined; + yield* Ref.set(origin, nextOrigin); + yield* Queue.offer(reloadRequests, undefined); + } else if (receivedAuthoritativeSnapshot) { + nextOrigin = + subscription?.identity == null + ? undefined + : { ...subscription.identity, snapshotAt: yield* Clock.currentTimeMillis }; + yield* Ref.set(origin, nextOrigin); + } if (next === initial) return; if (pendingLinkIds !== undefined && Option.isSome(next.snapshot)) { pendingLinkRows = new Set( @@ -299,12 +359,12 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") ); } if (Option.isSome(next.snapshot)) { - yield* Ref.set(latestLiveSnapshot, next.snapshot); + yield* Ref.set( + latestLive, + Option.some({ snapshot: next.snapshot.value, origin: nextOrigin }), + ); } yield* SubscriptionRef.set(state, next); - if (receivedAuthoritativeSnapshot && omission !== null) { - yield* Ref.set(authoritativeOmission, omission); - } if (next.snapshot !== initial.snapshot && Option.isSome(next.snapshot)) { yield* Queue.offer(persistence, next.snapshot.value); } @@ -365,11 +425,11 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") }, ); pendingLinkRows = new Set(); - const live = yield* Ref.get(latestLiveSnapshot); + const live = yield* Ref.get(latestLive); if (Option.isNone(live)) return; - const latest = Option.isSome(filled) ? filled : live; - yield* Ref.set(latestLiveSnapshot, latest); - yield* Queue.offer(persistence, latest.value); + const latest = Option.isSome(filled) ? filled.value : live.value.snapshot; + yield* Ref.set(latestLive, Option.some({ snapshot: latest, origin: live.value.origin })); + yield* Queue.offer(persistence, latest); }, (effect) => snapshotWriteLock.withPermit(effect), ); @@ -377,53 +437,52 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") yield* fillDeferredPullRequests(cached.value, cachedSnapshot.value); } - const foregroundResubscriptions = Option.match(wakeups, { - onNone: () => Stream.never, - onSome: (service) => - service.changes.pipe(Stream.filter(ConnectionWakeups.shouldResubscribeAfterWakeup)), - }); + const resubscriptions = Stream.merge( + Stream.fromQueue(reloadRequests), + Option.match(wakeups, { + onNone: () => Stream.never, + onSome: (service) => + service.changes.pipe(Stream.filter(ConnectionWakeups.shouldResubscribeAfterWakeup)), + }), + ); yield* setSynchronizing; yield* Effect.forkScoped( subscribeDynamicWithSession( ORCHESTRATION_V2_WS_METHODS.subscribeShell, Effect.fn("EnvironmentShellState.makeSubscribeInput")(function* (session) { - const { omitIdleSubagents, omitOlderSettled, omissionKey } = - yield* session.initialConfig.pipe( - Effect.map((config) => { - const omitIdleSubagents = config.shellThreadFamilies === true; - const omitOlderSettled = config.shellOlderSettled === true && omitIdleSubagents; - // Migrations rewrite projections without events, so an upgraded server reloads. - const { serverVersion, databaseMigration } = config.environment; - return { - omitIdleSubagents, - omitOlderSettled, - omissionKey: JSON.stringify([ - omitIdleSubagents, - omitOlderSettled, - serverVersion, - databaseMigration ?? null, - ]), - }; - }), - Effect.orElseSucceed(() => ({ - omitIdleSubagents: false, - omitOlderSettled: false, - omissionKey: null, - })), - ); - sessionOmissions.set(session, omissionKey); + const identity = yield* session.initialConfig.pipe( + Effect.map((config): ShellOriginIdentity => { + const omitIdleSubagents = config.shellThreadFamilies === true; + return { + serverVersion: config.environment.serverVersion, + // Migrations rewrite projections without events, so a migrated server reloads. + databaseMigration: config.environment.databaseMigration ?? null, + omitIdleSubagents, + omitOlderSettled: config.shellOlderSettled === true && omitIdleSubagents, + }; + }), + Effect.orElseSucceed(() => null), + ); + const { omitIdleSubagents, omitOlderSettled } = identity ?? { + omitIdleSubagents: false, + omitOlderSettled: false, + }; + yield* Ref.set(activeSubscription, { session, identity }); yield* Ref.set(awaitingCompletion, true); yield* setSynchronizing; - // A shell from a server snapshot resumes from its cursor, on this session or a - // new one. Cached data, other omissions, or another server build reload the - // authoritative HTTP snapshot so a valid cursor cannot preserve stale data. - const hasAuthoritativeSnapshot = - omissionKey !== null && (yield* Ref.get(authoritativeOmission)) === omissionKey; - let canResume = hasAuthoritativeSnapshot; + // Rows from a recent server snapshot with the same omissions and server build resume + // from their cursor, in memory or from the cache. Anything else reloads the HTTP + // snapshot so a valid cursor cannot preserve stale or incomplete data. let current = yield* SubscriptionRef.get(state); - if (!hasAuthoritativeSnapshot || Option.isNone(current.snapshot)) { + let canResume = + Option.isSome(current.snapshot) && + canResumeFrom(yield* Ref.get(origin), identity, yield* Clock.currentTimeMillis); + // Only rows the client already held may be refused; a cursor from the snapshot just + // loaded falls back to the socket snapshot, so a refusal cannot loop. + const resumingHeldRows = canResume; + if (!canResume) { const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( Effect.flatMap( Option.match({ @@ -455,7 +514,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") ? [] : httpSnapshot.value.threads.map((thread) => thread.id), ); - yield* applyItems([{ kind: "snapshot", snapshot }], omissionKey, pendingLinkIds); + yield* applyItems([[session, { kind: "snapshot", snapshot }]], pendingLinkIds); canResume = true; current = yield* SubscriptionRef.get(state); if (Option.isSome(current.snapshot)) { @@ -473,6 +532,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") } return { afterSequence: current.snapshot.value.snapshotSequence, + ...(resumingHeldRows ? { httpSnapshotFallback: true as const } : {}), ...omission, requestCompletionMarker: true as const, }; @@ -480,17 +540,9 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") { onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), retryExpectedFailureAfter: "250 millis", - resubscribe: foregroundResubscriptions, + resubscribe: resubscriptions, }, - ).pipe( - Stream.runForEachArray((entries) => - Effect.forEach( - groupBySession(entries), - ([session, items]) => applyItems(items, sessionOmissions.get(session) ?? null), - { discard: true }, - ), - ), - ), + ).pipe(Stream.runForEachArray((entries) => applyItems(entries))), ); yield* SubscriptionRef.changes(supervisor.state).pipe( Stream.runForEach((connectionState) => { diff --git a/packages/client-runtime/src/state/shellReducer.ts b/packages/client-runtime/src/state/shellReducer.ts index 8491143996908..2ef7dea533f3c 100644 --- a/packages/client-runtime/src/state/shellReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -131,10 +131,7 @@ export function mergeShellSnapshotProjects( export function applyShellStreamEvent( snapshot: OrchestrationV2ShellSnapshot, - event: Exclude< - OrchestrationV2ShellStreamItem, - { readonly kind: "snapshot" } | { readonly kind: "synchronized" } - >, + event: ShellStreamEvent, ): OrchestrationV2ShellSnapshot { if (event.sequence <= snapshot.snapshotSequence) return snapshot; @@ -217,9 +214,9 @@ function batchRows(rows: ReadonlyArray) { }; } -export type ShellStreamEvent = Exclude< +export type ShellStreamEvent = Extract< OrchestrationV2ShellStreamItem, - { readonly kind: "snapshot" } | { readonly kind: "synchronized" } + { readonly sequence: number } >; /** Apply ordered deltas with one index and at most one new array per collection. */ diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index cdbda23422237..7327c8cc203ee 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -7,6 +7,7 @@ import { type VcsListRefsInput, type VcsListRefsResult, type VcsStatusResult, + type ServerConfig, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -124,6 +125,83 @@ function cacheWithRefs( }); } +describe("branch subscriptions", () => { + it.effect.each([true, false])( + "shares and releases branch streams across session replacement (capability %s)", + (supported) => + Effect.gen(function* () { + const released = yield* Queue.unbounded(); + let subscriptions = 0; + const clientFor = (refName: string, branchSupported: boolean) => + ({ + [branchSupported ? WS_METHODS.subscribeVcsBranch : WS_METHODS.subscribeVcsStatus]: + () => { + subscriptions += 1; + return Stream.concat( + Stream.make( + branchSupported + ? { refName } + : { _tag: "snapshot", local: status({ refName }), remote: null }, + ), + Stream.never, + ).pipe(Stream.ensuring(Queue.offer(released, refName))); + }, + }) as unknown as WsRpcProtocolClient; + const branchSession = (refName: string, branchSupported: boolean): RpcSession => ({ + ...session(clientFor(refName, branchSupported)), + initialConfig: Effect.succeed({ + environment: { capabilities: branchSupported ? { vcsBranchStatus: true } : {} }, + } as ServerConfig), + }); + const activeSession = yield* SubscriptionRef.make( + Option.some(branchSession("main", supported)), + ); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(CONNECTED_CONNECTION_STATE), + session: activeSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({ + followStream: (_environmentId, stream) => + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + } as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const runtime = Atom.runtime( + Layer.merge( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Layer.succeed(Persistence.EnvironmentCacheStore, cacheWithRefs(Option.none())), + ), + ); + const atoms = createVcsEnvironmentAtoms(runtime); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (value) => + Effect.sync(() => value.dispose()), + ); + const target = { environmentId: TARGET.environmentId, input: { cwd: "/repo" } }; + const first = yield* Stream.toPull( + AtomRegistry.toStreamResult(registry, atoms.branch(target)), + ); + expect(yield* first).toEqual([{ refName: "main" }]); + const second = yield* Stream.toPull( + AtomRegistry.toStreamResult(registry, atoms.branch(target)), + ); + expect(yield* second).toEqual([{ refName: "main" }]); + expect(subscriptions).toBe(1); + yield* SubscriptionRef.set( + activeSession, + Option.some(branchSession("external", !supported)), + ); + expect(yield* Queue.take(released)).toBe("main"); + expect(yield* first).toEqual([{ refName: "external" }]); + expect(yield* second).toEqual([{ refName: "external" }]); + registry.dispose(); + expect(yield* Queue.take(released)).toBe("external"); + }).pipe(Effect.scoped), + ); +}); + describe("cached VCS refs", () => { it("invalidates all ref streams in the mutated environment", () => { const registry = AtomRegistry.make(); diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 06c1adb42c213..54eae47d79cce 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -25,7 +25,13 @@ import type { EnvironmentRegistry } from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import * as Persistence from "../platform/persistence.ts"; -import { request, subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; +import { + request, + subscribe, + subscribeVcsBranch, + refreshVcsBranch, + type EnvironmentRpcInput, +} from "../rpc/client.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; import { @@ -301,6 +307,19 @@ export function createVcsEnvironmentAtoms( label: "environment-data:vcs:read-refs", tag: WS_METHODS.vcsListRefs, }), + branch: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:vcs:branch", + idleTtlMs: VCS_STATUS_IDLE_TTL_MS, + subscribe: (input: EnvironmentRpcInput) => + subscribeVcsBranch(input), + }), + refreshBranch: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:refresh-branch", + tag: WS_METHODS.vcsRefreshBranch, + execute: refreshVcsBranch, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), status: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:vcs:status", idleTtlMs: VCS_STATUS_IDLE_TTL_MS, diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 55a6176aabacb..7f7c68ffc5ac3 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { GitPreparePullRequestThreadInput, GitRunStackedActionInput } from "./git.ts"; import { AuthAccessWriteScope, + AuthEnvironmentMaintainScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthPreviewOperateScope, @@ -21,6 +22,8 @@ export const CLIENT_GUARDED_RPC_SCOPES = { // Stopping a server signals its processes, as closing a terminal does. [WS_METHODS.previewStopLocalServer]: AuthTerminalOperateScope, [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, + // Restarts the server, as an update does. + [WS_METHODS.serverReclaimDatabaseSpace]: AuthEnvironmentMaintainScope, [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope, [WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 9e0f3f8b82e72..75e5aebbc31a4 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -174,6 +174,19 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => { return editor === undefined ? undefined : remoteSchemeOf(editor); }; +// `goto` editors share VS Code's `file:line:col` addressing, so their remote schemes also open +// a local file as `://file/[:line[:col]]`. Zed and JetBrains differ. +export const LOCAL_FILE_LINK_SCHEMES: ReadonlyArray = EDITORS.flatMap((editor) => + editor.launchStyle === "goto" && "remoteScheme" in editor ? [editor.remoteScheme] : [], +); + +export const isLocalEditorFileUrl = (url: URL): boolean => + LOCAL_FILE_LINK_SCHEMES.includes(url.protocol.slice(0, -1)) && + url.host === "file" && + url.username.length === 0 && + url.password.length === 0 && + url.pathname.length > 1; + export const buildRemoteOpenUrl = (input: { readonly editor: EditorId; readonly host: string; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 96b4537478ecc..8e3f2416477e2 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -88,6 +88,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ pullRequestChecks: Schema.optionalKey(Schema.Boolean), /** Side chats: hidden `lineage.sideChat` forks. Older servers ignore the fork flag. */ sideChats: Schema.optionalKey(Schema.Boolean), + vcsBranchStatus: Schema.optionalKey(Schema.Boolean), /** Every protocol-2 server sends this; older mobile builds still read it. */ inlineMessageContext: Schema.optionalKey(Schema.Boolean), requiredWorktreeBootstrap: Schema.optionalKey(Schema.Boolean), @@ -100,6 +101,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean), settledWorktreeCleanup: Schema.optionalKey(Schema.Boolean), worktreesDirectory: Schema.optionalKey(Schema.Boolean), + /** Sent by macOS servers, which honor the fastWorktreeCheckout setting. */ + fastWorktreeCheckout: Schema.optionalKey(Schema.Boolean), threadAutoSettlementScope: Schema.optionalKey(Schema.Boolean), threadRestartContinuation: Schema.optionalKey(Schema.Boolean), projectSettingsOverrides: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 544e982e7cf78..45f560b3b5144 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -111,6 +111,9 @@ export const VcsStatusInput = Schema.Struct({ }); export type VcsStatusInput = typeof VcsStatusInput.Type; +export const VcsBranchStatus = Schema.Struct({ refName: Schema.NullOr(Schema.String) }); +export type VcsBranchStatus = typeof VcsBranchStatus.Type; + export const VcsStatusSubscriptionInput = Schema.Struct({ ...VcsStatusInput.fields, /** Passive observers receive cached remote status without retaining its refresh loop. */ diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index b20de548345c7..929a9f3e3f291 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -2303,6 +2303,13 @@ export const OrchestrationV2ShellStreamItem = Schema.Union([ Schema.Struct({ kind: Schema.Literal("synchronized"), }), + /** + * The only item of a resume the server cannot replay when the client asked for + * `httpSnapshotFallback`: load the HTTP snapshot and subscribe again from its cursor. + */ + Schema.Struct({ + kind: Schema.Literal("snapshot-required"), + }), Schema.Struct({ kind: Schema.Literal("snapshot"), snapshot: OrchestrationV2ShellSnapshot, @@ -3659,6 +3666,7 @@ export const ORCHESTRATION_V2_WS_METHODS = { getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", getThreadFamily: "orchestration.getThreadFamily", getThreadTitles: "orchestration.getThreadTitles", + getThreadLinkMetadata: "orchestration.getThreadLinkMetadata", getWorktreeThreadIds: "orchestration.getWorktreeThreadIds", getOlderSettledThreads: "orchestration.getOlderSettledThreads", getThreadProjection: "orchestration.getThreadProjection", @@ -3753,6 +3761,11 @@ export type OrchestrationV2GetThreadProjectionInput = export const OrchestrationV2SubscribeShellInput = Schema.Struct({ afterSequence: Schema.optionalKey(NonNegativeInt), + /** + * With `afterSequence`, a gap the server cannot replay answers `snapshot-required` instead of + * a socket snapshot, which clients without transport compression download over gzip HTTP. + */ + httpSnapshotFallback: Schema.optionalKey(Schema.Literal(true)), requestCompletionMarker: Schema.optionalKey(Schema.Boolean), /** * Snapshots leave out idle subagent children; read them with `getThreadFamily`. Send only to @@ -3790,6 +3803,12 @@ export const OrchestrationV2GetThreadTitlesInput = Schema.Struct({ /** Titles of the requested threads that exist, archived ones included. Deleted and unknown ids are absent. */ export const OrchestrationV2ThreadTitles = Schema.Record(ThreadId, Schema.String); +export const OrchestrationV2ThreadLinkMetadata = Schema.Struct({ + title: Schema.String, + projectId: Schema.NullOr(ProjectId), +}); +export type OrchestrationV2ThreadLinkMetadata = typeof OrchestrationV2ThreadLinkMetadata.Type; + export const OrchestrationV2GetWorktreeThreadIdsInput = Schema.Struct({ worktreePath: TrimmedNonEmptyString, }); @@ -4316,6 +4335,10 @@ export const OrchestrationV2RpcSchemas = { input: OrchestrationV2GetThreadTitlesInput, output: OrchestrationV2ThreadTitles, }, + getThreadLinkMetadata: { + input: OrchestrationV2GetThreadTitlesInput, + output: Schema.Record(ThreadId, OrchestrationV2ThreadLinkMetadata), + }, getWorktreeThreadIds: { input: OrchestrationV2GetWorktreeThreadIdsInput, output: OrchestrationV2WorktreeThreadIds, diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index bfeb5cf916672..8c038f4fe81cc 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -552,9 +552,18 @@ export const PreviewAutomationSnapshot = Schema.Struct({ width: Schema.Int, height: Schema.Int, }), + /** The capture before `screenshot` was capped for the agent, when requested and larger. */ + fullScreenshot: Schema.optional( + Schema.Struct({ data: Schema.String, width: Schema.Int, height: Schema.Int }), + ), }); export type PreviewAutomationSnapshot = typeof PreviewAutomationSnapshot.Type; +export const PreviewAutomationSnapshotInput = Schema.Struct({ + fullScreenshot: Schema.optional(Schema.Boolean), +}); +export type PreviewAutomationSnapshotInput = typeof PreviewAutomationSnapshotInput.Type; + export const PreviewAutomationRecordingStatus = Schema.Struct({ tabId: PreviewTabId, recording: Schema.Boolean, diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 532faa198364d..72199673f7880 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -857,6 +857,8 @@ export type PullRequestThreadCommentsInput = typeof PullRequestThreadCommentsInp export const PullRequestThreadCommentsResult = Schema.Struct({ comments: Schema.Array(PullRequestThreadComment), nextCursor: Schema.NullOr(TrimmedNonEmptyString), + // Comments the host hid that this page left out; the thread's `commentCount` still counts them. + hiddenCount: Schema.optional(NonNegativeInt), }); export type PullRequestThreadCommentsResult = typeof PullRequestThreadCommentsResult.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 92ad228f2ec21..621b5394c5a3c 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -149,6 +149,7 @@ import { GitCancelStackedActionInput, GitCancelStackedActionResult, VcsStatusInput, + VcsBranchStatus, VcsStatusSubscriptionInput, VcsStatusResult, VcsStatusStreamEvent, @@ -360,6 +361,7 @@ import { import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; import { StorageCleanupReport, + DatabaseReclaimError, DatabaseStorageStatus, ServerSettings, ServerSettingsError, @@ -518,6 +520,7 @@ export const WS_METHODS = { serverRunStorageCleanup: "server.runStorageCleanup", serverGetStorageCleanupReport: "server.getStorageCleanupReport", serverGetDatabaseStorage: "server.getDatabaseStorage", + serverReclaimDatabaseSpace: "server.reclaimDatabaseSpace", peerLinksList: "peerLinks.list", peerLinksLink: "peerLinks.link", peerLinksUnlink: "peerLinks.unlink", @@ -612,6 +615,8 @@ export const WS_METHODS = { subscribeProjectClones: "subscribeProjectClones", subscribeVcsStatus: "subscribeVcsStatus", + subscribeVcsBranch: "subscribeVcsBranch", + vcsRefreshBranch: "vcs.refreshBranch", subscribeWorktreeSetup: "subscribeWorktreeSetup", worktreeSetupCancel: "worktreeSetup.cancel", subscribeTerminalMetadata: "subscribeTerminalMetadata", @@ -815,6 +820,11 @@ const WsServerGetDatabaseStorageRpc = Rpc.make(WS_METHODS.serverGetDatabaseStora error: EnvironmentAuthorizationError, }); +const WsServerReclaimDatabaseSpaceRpc = Rpc.make(WS_METHODS.serverReclaimDatabaseSpace, { + payload: Schema.Struct({}), + error: Schema.Union([DatabaseReclaimError, EnvironmentAuthorizationError]), +}); + const WsPeerLinksListRpc = Rpc.make(WS_METHODS.peerLinksList, { payload: Schema.Struct({}), success: Schema.Struct({ links: Schema.Array(PeerLinkSummary) }), @@ -1461,6 +1471,19 @@ const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, error: Schema.Union([ProviderUploadFeedbackError, EnvironmentAuthorizationError]), }); +const WsSubscribeVcsBranchRpc = Rpc.make(WS_METHODS.subscribeVcsBranch, { + payload: VcsStatusInput, + success: VcsBranchStatus, + error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), + stream: true, +}); + +const WsVcsRefreshBranchRpc = Rpc.make(WS_METHODS.vcsRefreshBranch, { + payload: VcsStatusInput, + success: Schema.Void, + error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), +}); + const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { payload: VcsStatusSubscriptionInput, success: VcsStatusStreamEvent, @@ -1815,6 +1838,15 @@ const WsOrchestrationV2GetThreadTitlesRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS error: Schema.Union([OrchestrationV2GetShellSnapshotError, EnvironmentAuthorizationError]), }); +const WsOrchestrationV2GetThreadLinkMetadataRpc = Rpc.make( + ORCHESTRATION_V2_WS_METHODS.getThreadLinkMetadata, + { + payload: OrchestrationV2RpcSchemas.getThreadLinkMetadata.input, + success: OrchestrationV2RpcSchemas.getThreadLinkMetadata.output, + error: Schema.Union([OrchestrationV2GetShellSnapshotError, EnvironmentAuthorizationError]), + }, +); + const WsOrchestrationV2GetWorktreeThreadIdsRpc = Rpc.make( ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds, { @@ -2057,6 +2089,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerRunStorageCleanupRpc, WsServerGetStorageCleanupReportRpc, WsServerGetDatabaseStorageRpc, + WsServerReclaimDatabaseSpaceRpc, WsServerUpdateSettingsRpc, WsPeerLinksListRpc, WsPeerLinksLinkRpc, @@ -2169,6 +2202,8 @@ export const WsRpcGroup = RpcGroup.make( WsMcpAppsUpdateModelContextRpc, WsProviderUploadFeedbackRpc, WsSubscribeVcsStatusRpc, + WsSubscribeVcsBranchRpc, + WsVcsRefreshBranchRpc, WsSubscribeWorktreeSetupRpc, WsWorktreeSetupCancelRpc, WsVcsPullRpc, @@ -2235,6 +2270,7 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationV2GetArchivedShellSnapshotRpc, WsOrchestrationV2GetThreadFamilyRpc, WsOrchestrationV2GetThreadTitlesRpc, + WsOrchestrationV2GetThreadLinkMetadataRpc, WsOrchestrationV2GetWorktreeThreadIdsRpc, WsOrchestrationV2GetOlderSettledThreadsRpc, WsOrchestrationV2GetThreadProjectionRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 3d45dfb87310f..40a1aa8b2ca86 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -629,6 +629,7 @@ export const ServerConfig = Schema.Struct({ * `getThreadFamily` and `getWorktreeThreadIds` serve the rows it leaves out. */ shellThreadFamilies: Schema.optionalKey(Schema.Boolean), + threadLinkMetadata: Schema.optionalKey(Schema.Boolean), /** * The shell can leave older settled threads out of snapshots (`omitOlderSettled`), and * `getOlderSettledThreads` serves them. Only servers that also advertise `shellThreadFamilies`. diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 35cd269d6c7de..dc8c3a42f2e93 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -508,7 +508,7 @@ export const ClientSettingsSchema = Schema.Struct({ pullRequestLinksOpenInApp: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), - planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Fork: upstream removed the composer's context circle; the fork keeps it. contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), // Fork: upstream's composer redesign (nightly 3841) removed this setting; the fork keeps its collapse-on-scroll composer. composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), @@ -538,9 +538,6 @@ export const ClientSettingsSchema = Schema.Struct({ threadDetailsItems: Schema.Record(Schema.String, Schema.Boolean).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), - // Opt-in for the removed legacy sidebar. Still read so those users land in - // the boring sidebar; the boring sidebar toggle clears it. - legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarWorkingShelfEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), @@ -1199,9 +1196,22 @@ export const DatabaseStorageStatus = Schema.Struct({ incrementalVacuumEnabled: Schema.Boolean, /** Run on the host while offline. */ compactCommand: Schema.String, + /** The server can run the repair itself: the desktop app or background service restarts it. */ + canReclaim: Schema.optionalKey(Schema.Boolean), + /** Why the last repair started from Settings failed. */ + reclaimFailure: Schema.optionalKey(Schema.String), }); export type DatabaseStorageStatus = typeof DatabaseStorageStatus.Type; +export class DatabaseReclaimError extends Schema.TaggedError()( + "DatabaseReclaimError", + { reason: Schema.String }, +) { + override get message() { + return this.reason; + } +} + export function shouldRecommendDatabaseCompaction(status: DatabaseStorageStatus): boolean { return ( !status.incrementalVacuumEnabled && @@ -1209,6 +1219,12 @@ export function shouldRecommendDatabaseCompaction(status: DatabaseStorageStatus) ); } +/** Offline minutes the repair takes, at 30 s per GiB of live data (a BB-1 NVMe disk took 21 s). */ +export function databaseReclaimMinutes(status: DatabaseStorageStatus): number { + const liveGiB = (status.databaseBytes - status.reclaimableBytes) / 1024 ** 3; + return Math.max(1, Math.ceil(liveGiB / 2)); +} + export const StorageCleanupReportEntry = Schema.Struct({ kind: Schema.Literals(["worktree", "browser-artifacts", "logs"]), outcome: Schema.Literals(["removed", "kept", "failed"]), @@ -1271,6 +1287,8 @@ export const ServerSettings = Schema.Struct({ ), worktreesDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + /** macOS only, Labs beta: clone new worktrees from a cached checkout instead of writing every file. */ + fastWorktreeCheckout: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), /** Server-maintained history keeps old worktrees eligible for cleanup and review diffs. */ previousWorktreesDirectories: Schema.Array(TrimmedString).pipe( Schema.withDecodingDefault(Effect.succeed([])), @@ -1699,6 +1717,7 @@ export const ServerSettingsPatch = Schema.Struct({ }), ), worktreesDirectory: Schema.optionalKey(TrimmedString), + fastWorktreeCheckout: Schema.optionalKey(Schema.Boolean), responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), @@ -1916,7 +1935,7 @@ export const ClientSettingsPatch = Schema.Struct({ Schema.Record(TrimmedNonEmptyString, PullRequestMergeMethod), ), pullRequestLinksOpenInApp: Schema.optionalKey(Schema.Boolean), - planModeEnabled: Schema.optionalKey(Schema.Boolean), + // Fork: kept for the fork's composer context circle. contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), // Fork: kept for the fork's collapse-on-scroll composer (upstream removed it with its composer redesign). composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), @@ -1931,7 +1950,6 @@ export const ClientSettingsPatch = Schema.Struct({ toolCallVisibility: Schema.optionalKey(ToolCallVisibility), threadDetailsUsageEnabled: Schema.optionalKey(Schema.Boolean), threadDetailsItems: Schema.optionalKey(Schema.Record(Schema.String, Schema.Boolean)), - legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarWorkingShelfEnabled: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 0fd91dae8a8e7..9a5bfaeb890c2 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -8595,6 +8595,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( driver, providerSessionId: input.providerSessionId, providerSession, + canResumeAfterRestart: canLoadSession || canResumeSession, events: Stream.fromEffectRepeat(Queue.take(events)), ...(flavor.getModelContextWindow === undefined ? {} diff --git a/packages/provider-core/src/server/McpProviderSessions.ts b/packages/provider-core/src/server/McpProviderSessions.ts index 2575cce264c7f..b9bc89fecb49b 100644 --- a/packages/provider-core/src/server/McpProviderSessions.ts +++ b/packages/provider-core/src/server/McpProviderSessions.ts @@ -17,6 +17,7 @@ const BOUNDED_TEXT_MARKER = "[Earlier output truncated]\n\n"; const KEPT_HTML_END_LENGTH = 8_000; interface KeptHtmlRender { + readonly path: string | undefined; readonly title: string; readonly height: number; readonly htmlLength: number; @@ -35,37 +36,58 @@ export class McpProviderSessions extends Context.Service< readonly clear: (threadId: ThreadId) => Effect.Effect; readonly recordHtmlRender: ( threadId: ThreadId, - input: { readonly html: string; readonly title: string; readonly height: number }, + input: { + readonly html: string; + readonly path?: string | undefined; + readonly title: string; + readonly height: number; + }, reference: HtmlRenderReference, ) => Effect.Effect; - // A reported attachment consumes that exact page; otherwise only unique input matches count. + // A reported attachment consumes that exact page; otherwise the oldest page for a path, or a + // unique inline match. readonly takeHtmlRender: ( threadId: ThreadId, - input: { readonly html?: unknown; readonly title?: unknown; readonly height?: unknown }, + input: PageInput, reported?: HtmlRenderReference, ) => Effect.Effect; } >()("@t3tools/provider-core/server/McpProviderSessions") {} -const matchingPageIndex = ( - kept: ReadonlyArray, - input: { readonly html?: unknown; readonly title?: unknown; readonly height?: unknown }, -): number => { - const html = input.html; - if (typeof html !== "string" || html.length === 0) { - return -1; - } +interface PageInput { + readonly html?: unknown; + readonly path?: unknown; + readonly title?: unknown; + readonly height?: unknown; +} + +const matchingPageIndex = (kept: ReadonlyArray, input: PageInput): number => { const height = typeof input.height === "string" && input.height.trim() !== "" ? Number(input.height) : input.height; + const sameFrame = (page: KeptHtmlRender) => page.title === input.title && page.height === height; + const path = input.path; + // The handler refuses a call with both sources, so it published nothing to recover. Its decoder + // reads null as an omitted source. + if (path !== undefined && path !== null && input.html !== undefined && input.html !== null) { + return -1; + } + if (typeof path === "string" && path.length > 0) { + // An edited file is published again under the same path; completions arrive in publish order. + return kept.findIndex((page) => page.path === path && sameFrame(page)); + } + const html = input.html; + if (typeof html !== "string" || html.length === 0) { + return -1; + } const htmlHash = hashHtml(html); const tail = html.startsWith(BOUNDED_TEXT_MARKER) ? html.slice(BOUNDED_TEXT_MARKER.length) : undefined; const matches = kept.flatMap((page, index) => - page.title === input.title && - page.height === height && + page.path === undefined && + sameFrame(page) && (page.htmlHash === htmlHash || (tail !== undefined && tail.length > 0 && @@ -118,6 +140,7 @@ const make = Effect.gen(function* () { [ ...(current.get(threadId) ?? []), { + path: input.path, title: input.title, height: input.height, htmlLength: input.html.length, diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 118d66213d0d8..37910a4d04ba8 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -616,6 +616,8 @@ export interface ProviderAdapterV2SessionRuntime { readonly driver: ProviderDriverKind; readonly providerSessionId: ProviderSessionId; readonly providerSession: OrchestrationV2ProviderSession; + /** False keeps conversations alive when the provider cannot restore them after a restart. */ + readonly canResumeAfterRestart?: boolean; readonly events: Stream.Stream; /** Manager runtimes broadcast independent subscriptions; adapters may expose only a single-consumer stream. */ readonly subscribeEvents?: Effect.Effect; diff --git a/packages/provider-core/src/server/handoffBudget.ts b/packages/provider-core/src/server/handoffBudget.ts index b1c98806f29d9..5f9f2f3ae33f6 100644 --- a/packages/provider-core/src/server/handoffBudget.ts +++ b/packages/provider-core/src/server/handoffBudget.ts @@ -230,7 +230,7 @@ export function selectHistory(input: { historyCost([], contextFor(messages.length, (input.omittedItems ?? 0) + messages.length)); const tryAdd = (index: number) => { const message = messages[index]; - if (message === undefined || selected.has(index)) return; + if (message === undefined || selected.has(index) || message.text.length > remaining) return; const cost = Math.max( Buffer.byteLength(JSON.stringify(historyResponseItems([message], "")[1])) + 1, Buffer.byteLength(JSON.stringify(renderHistoricalMessage(message))) + 4, diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index 9447742ca354b..e5298def7f0da 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -14,10 +14,10 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import * as TestClock from "effect/testing/TestClock"; import * as ProviderLatestVersions from "./ProviderLatestVersions.ts"; import { createProviderVersionAdvisory, @@ -66,6 +66,12 @@ const nativePackageToolUpdate = makePackageManagedProviderMaintenanceResolver({ isCommandPath: isNativeTestCommandPath("/.local/bin/native-package-tool"), }, }); +const miseToolUpdate = makePackageManagedProviderMaintenanceResolver({ + provider: driver("packageTool"), + npmPackageName: "@example/package-tool", + mise: { tool: "package-tool" }, + nativeUpdate: null, +}); const installedPackageToolProvider: ServerProvider = { instanceId: ProviderInstanceId.make("packageTool"), driver: driver("packageTool"), @@ -131,6 +137,121 @@ function stdoutSpawner( }); } +type MiseLatestAnswer = string | { readonly stdout?: string; readonly exitCode?: number } | "hang"; + +/** A `mise` answering `which`, `ls --json --installed [tool]`, and `latest`, recording each call. */ +function miseSpawner( + answers: { + readonly which: string; + readonly latest?: MiseLatestAnswer; + readonly installed: Record>; + }, + spawned: Array>, +) { + return ChildProcessSpawner.make((command) => { + const { command: executable, args } = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + spawned.push([executable, ...args]); + const latest = answers.latest ?? ""; + const hang = args[0] === "latest" && latest === "hang"; + let stdout = ""; + let exitCode = 0; + if (args[0] === "which") { + stdout = `${answers.which}\n`; + } else if (args[0] === "latest" && typeof latest === "string") { + stdout = latest === "hang" ? "" : `${latest}\n`; + } else if (args[0] === "latest" && typeof latest !== "string") { + stdout = latest.stdout ?? ""; + exitCode = latest.exitCode ?? 0; + } else if (args[0] === "ls") { + // `mise ls --json ` lists one tool's installs; without a tool, every tool's. + const tool = args.slice(1).find((arg) => !arg.startsWith("-")); + stdout = JSON.stringify(tool ? (answers.installed[tool] ?? []) : answers.installed); + } else { + exitCode = 1; + } + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: hang ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(hang), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: hang ? Stream.never : Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); +} + +/** + * A mise binary on its own PATH dir and an install of `package-tool` 1.0.0 in its data dir, + * with the `latest` link mise keeps pointing at the newest install. + */ +function miseFixture( + tempDir: string, + options: { + readonly misePresent?: boolean; + readonly tool?: string; + readonly dataDir?: string; + } = {}, +) { + const miseBinDir = NodePath.join(tempDir, "mise-bin"); + const misePath = NodePath.join(miseBinDir, "mise"); + if (options.misePresent !== false) writeExecutable(misePath); + const toolDir = NodePath.join( + options.dataDir ?? NodePath.join(tempDir, "mise"), + "installs", + "package-tool", + ); + const latestDir = NodePath.join(toolDir, "latest"); + let installPath = ""; + let binaryPath = ""; + /** Installs `version` and points `latest` at it, as `mise upgrade` does. */ + const install = (version: string) => { + installPath = NodePath.join(toolDir, version); + binaryPath = NodePath.join(installPath, "bin", "package-tool"); + writeExecutable(binaryPath); + if (symlinksSupported) { + NodeFS.rmSync(latestDir, { force: true }); + NodeFS.symlinkSync(installPath, latestDir); + } + return binaryPath; + }; + install("1.0.0"); + const installed = ( + install: { readonly requested_version?: string; readonly active?: boolean } = {}, + ) => ({ + [options.tool ?? "package-tool"]: [ + { install_path: installPath, requested_version: "latest", active: true, ...install }, + ], + }); + return { + miseBinDir, + misePath, + get binaryPath() { + return binaryPath; + }, + latestBinaryPath: NodePath.join(latestDir, "bin", "package-tool"), + install, + installed, + }; +} + +/** A mise shim: a link named for the tool that points at mise itself. */ +function linkShim(tempDir: string, misePath: string, name: string) { + const shimPath = NodePath.join(tempDir, "mise", "shims", name); + NodeFS.mkdirSync(NodePath.dirname(shimPath), { recursive: true }); + NodeFS.symlinkSync(misePath, shimPath); + return shimPath; +} + it.layer( Layer.mergeAll( NodeServices.layer, @@ -1487,6 +1608,366 @@ it.layer( }), ); + it.effect.each([ + { name: "its latest link", via: "latest" }, + { name: "a latest link in a custom data dir", via: "latest", dataDir: "data" }, + { name: "a shim", via: "shim" }, + ])( + "upgrades the mapped mise tool through $name requested as latest", + (fixture) => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-mise-upgrade"); + const mise = miseFixture( + tempDir, + fixture.dataDir ? { dataDir: NodePath.join(tempDir, fixture.dataDir) } : {}, + ); + const binaryPath = + fixture.via === "latest" + ? mise.latestBinaryPath + : linkShim(tempDir, mise.misePath, "package-tool"); + const spawned: Array> = []; + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(miseToolUpdate, { + binaryPath, + env: { PATH: mise.miseBinDir }, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + miseSpawner({ which: mise.binaryPath, installed: mise.installed() }, spawned), + ), + ); + + expect(capabilities.update).toMatchObject({ + command: "mise upgrade package-tool", + executable: mise.misePath, + args: ["upgrade", "package-tool"], + }); + expect(spawned).toContainEqual([mise.misePath, "which", "package-tool"]); + // Resolution proves ownership but never asks mise for releases. + expect(spawned.some((args) => args[1] === "latest")).toBe(false); + }), + { skip: !symlinksSupported || windowsHost }, + ); + + it.effect.each([ + { name: "a pinned version", installed: { requested_version: "1.0.0" } }, + { name: "an inactive install", installed: { active: false } }, + { name: "an install of another mise tool", tool: "other-tool" }, + { name: "an install mise which does not run", which: "elsewhere" }, + { name: "an install without mise on PATH", misePresent: false }, + { name: "a provider with no mise tool", definition: "unmapped" }, + { name: "a mise x wrapper", via: "wrapper" }, + { name: "a fixed version path", via: "versioned" }, + { name: "a version mise activate put on PATH", via: "activated" }, + ] as const)( + "leaves $name manual-only", + (fixture) => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-mise-manual"); + const mise = miseFixture(tempDir, { + misePresent: !("misePresent" in fixture), + tool: "tool" in fixture ? fixture.tool : "package-tool", + }); + let binaryPath = symlinksSupported ? mise.latestBinaryPath : mise.binaryPath; + let path = mise.miseBinDir; + if ("via" in fixture && fixture.via === "versioned") { + binaryPath = mise.binaryPath; + } else if ("via" in fixture && fixture.via === "activated") { + binaryPath = "package-tool"; + path = [NodePath.dirname(mise.binaryPath), mise.miseBinDir].join(NodePath.delimiter); + } else if ("via" in fixture) { + binaryPath = NodePath.join(tempDir, "bin", "package-tool"); + NodeFS.mkdirSync(NodePath.dirname(binaryPath), { recursive: true }); + NodeFS.writeFileSync( + binaryPath, + '#!/bin/sh\nexec mise x package-tool -- package-tool "$@"\n', + ); + NodeFS.chmodSync(binaryPath, 0o755); + } + const which = + "which" in fixture + ? NodePath.join(tempDir, "elsewhere", "package-tool") + : mise.binaryPath; + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + "definition" in fixture ? packageToolUpdate : miseToolUpdate, + { binaryPath, env: { PATH: path } }, + ).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + miseSpawner( + { + which, + installed: mise.installed("installed" in fixture ? fixture.installed : {}), + }, + [], + ), + ), + ); + + expect(capabilities.update).toBeNull(); + expect(capabilities.lookupLatestVersion).toBeUndefined(); + }), + { skip: windowsHost }, + ); + + it.effect.skipIf(windowsHost)( + "keeps the provider's own updater for an install mise does not own", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-mise-native"); + const nativePath = NodePath.join(tempDir, ".local", "bin", "native-package-tool"); + writeExecutable(nativePath); + const miseMappedNative = makePackageManagedProviderMaintenanceResolver({ + provider: driver("nativePackageTool"), + npmPackageName: "@example/native-package-tool", + mise: { tool: "native-package-tool" }, + nativeUpdate: { + args: ["update"], + isCommandPath: isNativeTestCommandPath("/.local/bin/native-package-tool"), + }, + }); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(miseMappedNative, { + binaryPath: nativePath, + env: { PATH: "" }, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); + + expect(capabilities.update).toEqual({ + command: `${nativePath} update`, + executable: nativePath, + args: ["update"], + lockKey: "nativePackageTool-native", + }); + }), + ); + + it.effect.each([ + { + name: "a mise tool whose upgrades are disabled", + mise: { tool: "package-tool", upgrade: false }, + }, + { name: "a pinned mise tool", mise: { tool: "package-tool" }, pinned: true }, + { + name: "an inactive version of a mise tool", + mise: { tool: "package-tool" }, + inactive: true, + }, + { + name: "an inactive version of a mise tool whose upgrades are disabled", + mise: { tool: "package-tool", upgrade: false }, + inactive: true, + }, + ] as const)( + "keeps $name in a custom data dir manual-only instead of running its own updater", + (fixture) => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-mise-custom-manual"); + const mise = miseFixture(tempDir, { dataDir: NodePath.join(tempDir, "data") }); + let binaryPath = mise.latestBinaryPath; + let installed = mise.installed("pinned" in fixture ? { requested_version: "1.0.0" } : {}); + if ("inactive" in fixture) { + // Configured to 1.0.0 while mise resolves its active 2.0.0 instead. + binaryPath = mise.binaryPath; + const inactive = { ...installed["package-tool"]![0]!, active: false }; + mise.install("2.0.0"); + installed = { "package-tool": [inactive, ...mise.installed()["package-tool"]!] }; + } + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + makePackageManagedProviderMaintenanceResolver({ + provider: driver("packageTool"), + npmPackageName: "@example/package-tool", + mise: fixture.mise, + nativeUpdate: { args: ["upgrade"] }, + }), + { binaryPath, env: { PATH: mise.miseBinDir } }, + ).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + miseSpawner({ which: mise.binaryPath, installed }, []), + ), + ); + + expect(capabilities.update).toBeNull(); + }), + { skip: !symlinksSupported || windowsHost }, + ); + + it.effect.skipIf(!symlinksSupported || windowsHost)( + "updates an npm global under mise's Node with npm even when mise runs it", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-mise-node-npm"); + const mise = miseFixture(tempDir); + const nodePrefix = NodePath.join(tempDir, "mise", "installs", "node", "22.0.0"); + const binaryPath = NodePath.join(nodePrefix, "bin", "package-tool"); + const target = NodePath.join( + nodePrefix, + "lib", + "node_modules", + "@example", + "package-tool", + "bin", + "package-tool.js", + ); + writeExecutable(target); + NodeFS.mkdirSync(NodePath.dirname(binaryPath), { recursive: true }); + NodeFS.symlinkSync(target, binaryPath); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(miseToolUpdate, { + binaryPath, + env: { PATH: mise.miseBinDir }, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + // mise resolves the npm global through its Node; no `package-tool` tool is installed. + miseSpawner({ which: binaryPath, installed: {} }, []), + ), + ); + + expect(capabilities.update?.args).toEqual([ + "install", + "-g", + "--prefix", + NodeFS.realpathSync(nodePrefix), + "--allow-scripts=@example/package-tool", + "@example/package-tool@latest", + ]); + }), + ); + + it.effect.skipIf(!symlinksSupported || windowsHost)( + "asks mise for its latest release only while update checks are enabled", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-mise-latest"); + const mise = miseFixture(tempDir); + const spawned: Array> = []; + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(miseToolUpdate, { + binaryPath: mise.latestBinaryPath, + env: { PATH: mise.miseBinDir }, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + miseSpawner( + // mise's `minimum_release_age` can hold it behind npm's 1.2.0. + { which: mise.binaryPath, latest: "1.1.0", installed: mise.installed() }, + spawned, + ), + ), + ); + const advise = (enableProviderUpdateChecks: boolean) => + enrichProviderSnapshotWithVersionAdvisory(installedPackageToolProvider, capabilities, { + enableProviderUpdateChecks, + }); + + expect((yield* advise(false)).versionAdvisory).toMatchObject({ + status: "unknown", + latestVersion: null, + }); + expect(spawned.filter((args) => args[1] === "latest")).toEqual([]); + + expect((yield* advise(true)).versionAdvisory).toMatchObject({ + status: "behind_latest", + latestVersion: "1.1.0", + }); + expect((yield* advise(true)).versionAdvisory?.latestVersion).toBe("1.1.0"); + expect(spawned.filter((args) => args[1] === "latest")).toEqual([ + [mise.misePath, "latest", "package-tool"], + ]); + }).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("mise answers for its own installs")), + ), + ), + ); + + it.effect.skipIf(!symlinksSupported || windowsHost)( + "keeps each mise environment's latest release apart", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-mise-latest-env"); + const mise = miseFixture(tempDir); + const advise = (env: NodeJS.ProcessEnv, latest: string) => + resolveProviderMaintenanceCapabilitiesEffect(miseToolUpdate, { + binaryPath: mise.latestBinaryPath, + env: { PATH: mise.miseBinDir, ...env }, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + miseSpawner({ which: mise.binaryPath, latest, installed: mise.installed() }, []), + ), + Effect.flatMap((capabilities) => + enrichProviderSnapshotWithVersionAdvisory( + installedPackageToolProvider, + capabilities, + { + enableProviderUpdateChecks: true, + }, + ), + ), + Effect.map((snapshot) => snapshot.versionAdvisory?.latestVersion), + ); + + expect(yield* advise({ MISE_MINIMUM_RELEASE_AGE: "7d" }, "1.1.0")).toBe("1.1.0"); + expect(yield* advise({}, "1.2.0")).toBe("1.2.0"); + expect(yield* advise({ MISE_MINIMUM_RELEASE_AGE: "7d" }, "1.2.0")).toBe("1.1.0"); + }).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("mise answers for its own installs")), + ), + ), + ); + + it.effect.each([ + { name: "fails", latest: { exitCode: 1 } }, + { name: "prints nothing", latest: { stdout: "" } }, + { name: "never answers", latest: "hang" }, + ] as const)( + "reports an unknown latest version when mise latest $name", + (fixture) => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-mise-latest-unknown"); + const mise = miseFixture(tempDir); + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(miseToolUpdate, { + binaryPath: mise.latestBinaryPath, + env: { PATH: mise.miseBinDir }, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + miseSpawner( + { which: mise.binaryPath, latest: fixture.latest, installed: mise.installed() }, + [], + ), + ), + ); + expect(capabilities.update).toMatchObject({ args: ["upgrade", "package-tool"] }); + + const advisory = yield* enrichProviderSnapshotWithVersionAdvisory( + installedPackageToolProvider, + capabilities, + { enableProviderUpdateChecks: true }, + ).pipe(Effect.forkChild); + yield* TestClock.adjust("1 minute"); + + expect((yield* Fiber.join(advisory)).versionAdvisory).toMatchObject({ + status: "unknown", + latestVersion: null, + }); + }).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("an offline mise must not fall back to npm")), + ), + ), + { skip: !symlinksSupported || windowsHost }, + ); + it.effect.skipIf(!symlinksSupported)("updates Yarn global installs with yarn", () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-yarn-capabilities"); diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 364b4cee9bf42..bb60e8e865714 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Synchronous hashing keeps env values out of cache keys. +import * as NodeCrypto from "node:crypto"; export type { ProviderMaintenanceCapabilities, ProviderMaintenanceCommandAction, @@ -92,6 +94,14 @@ export interface PackageManagedProviderMaintenanceDefinition { | NodeJS.ProcessEnv | ((context: ProviderMaintenanceResolutionContext) => NodeJS.ProcessEnv); } | null; + /** The mise registry tool that installs this provider; unset keeps mise installs manual-only. */ + readonly mise?: MiseProviderTool; +} + +export interface MiseProviderTool { + readonly tool: string; + /** False still claims the tool's installs, but keeps them manual-only. */ + readonly upgrade?: boolean; } const NpmLatestVersionResponse = Schema.Struct({ @@ -242,19 +252,23 @@ function isYarnGlobalCommandPath(commandPath: string): boolean { } /** - * Version-manager installs are pinned in its config, so updating them means editing that. + * Paths mise owns. npm globals under mise's own Node belong to npm, which proves their prefix. * A command wrapper is a symlink to the `mise` binary, which dispatches on argv[0]. */ function isMiseCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); return ( - normalized.includes("/mise/installs/") || + (normalized.includes("/mise/installs/") && !normalized.includes("/mise/installs/node/")) || normalized.includes("/mise/shims/") || - normalized.includes("/mise/command-wrappers/") || - /\/mise(?:\.exe)?$/.test(normalized) + normalized.includes("/mise/command-wrappers/") ); } +/** A mise shim or command wrapper is a link to mise itself, wherever mise keeps its data. */ +function isMiseExecutablePath(commandPath: string): boolean { + return /(?:^|\/)mise(?:\.exe)?$/.test(normalizeCommandPath(commandPath)); +} + const winGetClaudeCommandPath = /^[a-z]:\/(?:users\/[^/]+\/appdata\/local\/microsoft|program files(?: \(x86\))?)\/winget\/(?:links|packages\/anthropic\.claudecode_[^/]+)\/claude\.exe$/; @@ -463,6 +477,141 @@ const resolvePnpmGlobalLatestVersion = Effect.fn("resolvePnpmGlobalLatestVersion return nonEmptyString(listedEntries?.[0]?.dependencies?.[packageName]?.version); }); +const MiseInstall = Schema.Struct({ + install_path: Schema.String, + requested_version: Schema.optional(Schema.NullOr(Schema.String)), + active: Schema.optional(Schema.Boolean), +}); +const decodeMiseToolInstalls = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Array(MiseInstall)), +); +const decodeMiseInstalls = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Array(MiseInstall))), +); + +/** The installs `mise ls --json --installed ` lists, refusing any other tool's. */ +function miseToolInstalls( + listing: string, + miseTool: string, +): ReadonlyArray { + const installs = decodeMiseToolInstalls(listing); + if (Option.isSome(installs)) return installs.value; + const byTool = Option.getOrUndefined(decodeMiseInstalls(listing)); + return byTool && Object.keys(byTool).every((tool) => tool === miseTool) + ? (byTool[miseTool] ?? []) + : []; +} + +/** + * Null when mise does not own the executable. Upgrades need an active `latest` install of the + * driver's fixed mise tool, launched through a shim or the `latest` link `mise upgrade` moves; + * a fixed version's path would keep running the pruned binary, so it stays manual-only. + */ +export const resolveMiseProviderMaintenance = Effect.fn("resolveMiseProviderMaintenance")( + function* ( + owner: { readonly provider: ProviderDriverKind; readonly packageName: string | null }, + mise: MiseProviderTool | undefined, + context: ProviderMaintenanceResolutionContext, + ) { + const commandPaths = [context.resolvedCommandPath, context.realCommandPath]; + const isShim = isMiseExecutablePath(context.realCommandPath); + const looksMise = + isShim || commandPaths.some(isMiseCommandPath) || (yield* isMiseWrapperScript(context)); + const manual = (installPath: string) => + makeManualOnlyProviderMaintenanceCapabilities({ ...owner, installPath }); + const notOwned = (installPath: string) => (looksMise ? manual(installPath) : null); + const commandName = normalizeCommandPath(context.resolvedCommandPath) + .split("/") + .at(-1) + ?.replace(/\.(?:exe|cmd|bat|ps1)$/, ""); + if (!mise) return notOwned(context.realCommandPath); + const miseTool = mise.tool; + // A shim runs whatever mise resolves for its own name, which must be the mapped tool's. + if (isShim && commandName !== miseTool) return manual(context.realCommandPath); + + const fileSystem = yield* FileSystem.FileSystem; + const realPath = (target: string) => + fileSystem.realPath(target).pipe(Effect.orElseSucceed(() => target)); + const misePath = + (yield* resolveCommandPath("mise", { env: context.env })) ?? + (isShim ? context.realCommandPath : null); + if (!misePath) return notOwned(context.realCommandPath); + // Asked even off mise's usual paths, since `MISE_DATA_DIR` can put installs anywhere. + const which = nonEmptyString(yield* runProbe(misePath, ["which", miseTool], context.env)); + const resolvedPath = which ? yield* realPath(which) : null; + // Through a shim T3 Code runs whatever mise resolves; otherwise the configured binary. + const binaryPath = isShim ? resolvedPath : context.realCommandPath; + if (!binaryPath) return manual(context.realCommandPath); + const binary = normalizeCommandPath(binaryPath); + const runsBinary = resolvedPath !== null && normalizeCommandPath(resolvedPath) === binary; + const listing = yield* runProbe( + misePath, + ["ls", "--json", "--installed", miseTool], + context.env, + ); + if (!listing) return runsBinary ? manual(binaryPath) : notOwned(binaryPath); + const isUnder = (path: string, dir: string) => + path.startsWith(`${normalizeCommandPath(dir).replace(/\/+$/, "")}/`); + // An inactive or shadowed install is still mise's, even when `mise which` resolves another. + const owning = yield* Effect.filter(miseToolInstalls(listing, miseTool), (install) => + realPath(install.install_path).pipe( + Effect.map((installPath) => isUnder(binary, installPath)), + ), + ); + // `mise which` also resolves binaries from other tools, such as npm globals under mise's Node. + if (owning.length === 0) return notOwned(binaryPath); + if (!runsBinary) return manual(binaryPath); + const install = owning.find( + (candidate) => candidate.active === true && candidate.requested_version === "latest", + ); + if (!install || mise.upgrade === false) return manual(binaryPath); + if (!isShim) { + // Keep the path's case: `realPath` needs it on case-sensitive filesystems. + const toolDir = install.install_path + .replaceAll("\\", "/") + .replace(/\/+$/, "") + .replace(/\/[^/]+$/, ""); + const latestDirs = [toolDir, yield* realPath(toolDir)].map((dir) => `${dir}/latest`); + const resolved = normalizeCommandPath(context.resolvedCommandPath); + if (!latestDirs.some((dir) => isUnder(resolved, dir))) return manual(binaryPath); + } + const args = ["upgrade", miseTool]; + const services = yield* Effect.context(); + // Mise reads its settings from this environment, so each one gets its own cached answer. + const envHash = NodeCrypto.createHash("sha256"); + for (const [name, value] of Object.entries(context.env).toSorted(([a], [b]) => + a < b ? -1 : 1, + )) { + envHash.update(`${name}\0${value ?? ""}\0`); + } + const envKey = envHash.digest("hex"); + const capabilities = makeProviderMaintenanceCapabilities({ + ...owner, + updateExecutable: misePath, + updateArgs: args, + updateLockKey: "mise", + updateCommand: ["mise", ...args].join(" "), + platform: context.platform, + // Mise read its config from this environment, so the upgrade runs in it too. + env: context.env, + // Settings such as `minimum_release_age` can hold mise behind the registry, + // so only mise can say what it installs. It may go to the network, so ask lazily. + latestVersion: null, + lookupLatestVersion: runProbe(misePath, ["latest", miseTool], context.env).pipe( + Effect.map((latest) => { + const version = nonEmptyString(latest); + return version && /^\S+$/.test(version) ? version : null; + }), + Effect.provideContext(services), + ), + }); + return { + ...capabilities, + latestVersionCacheKey: ["mise", misePath, miseTool, envKey].join("\0"), + }; + }, +); + export const resolvePackageManagedProviderMaintenance = Effect.fn( "resolvePackageManagedProviderMaintenance", )(function* ( @@ -517,6 +666,13 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( }); } + const mise = yield* resolveMiseProviderMaintenance( + { provider: definition.provider, packageName }, + definition.mise, + context, + ); + if (mise) return mise; + const nativeUpdate = definition.nativeUpdate; const nativeEnv = nativeUpdate?.env; const native = nativeUpdate @@ -532,13 +688,11 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( : {}), }) : manual; - // A native-looking launcher can still delegate to an npm- or mise-owned install. + // A native-looking launcher can still delegate to an npm-owned install. if ( nativeUpdate?.isCommandPath && commandPaths.some(nativeUpdate.isCommandPath) && - !npmGlobalPrefixFromCommandPath(context.realCommandPath, packageName) && - !commandPaths.some(isMiseCommandPath) && - !(yield* isMiseWrapperScript(context)) + !npmGlobalPrefixFromCommandPath(context.realCommandPath, packageName) ) { return native; } @@ -633,14 +787,6 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( } const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); - if ( - commandPaths.some(isMiseCommandPath) || - // Mise shims resolve to the version manager's keg, not the provider's. - (homebrew?.kind === "formula" && homebrew.name.toLowerCase() === "mise") || - (yield* isMiseWrapperScript(context)) - ) { - return manual; - } if (homebrew) { return ( (yield* resolveHomebrewProviderMaintenance( diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 703e1860f7a3f..c4061a24f967a 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -20,7 +20,7 @@ Ordinary coding tasks need no T3 tools. - T3 tools may load late. Try the name once before you call it absent.`, `### Visuals and preview -- Answer data with a trend or comparison with a chart, and show any visual the user asks for here: pass its HTML to \`html_preview\`, then \`html_render\`. If the visual is a file, pass that file's HTML. A screenshot on disk or a text description does not show it. Never start a server for it. Tools a web page exposes give data only. Never use them to show results. Asked for an image to post, send, or use elsewhere: a \`.png\` file and its path, never \`html_render\`. +- Answer data with a trend or comparison with a chart, and show any visual the user asks for here: pass the path to an HTML file, or inline HTML to \`html_preview\`, then \`html_render\`. A screenshot on disk or a text description does not show it. Never start a server for it. Tools a web page exposes give data only. Never use them to show results. Asked for an image to post, send, or use elsewhere: a \`.png\` file and its path, never \`html_render\`. - Never use \`preview_*\` unasked to verify or show your own edit. Use them when the current request asks for browser work, including recording edits, or names a page or tab. Do not repeat preview calls from an earlier turn. Public pages: web fetch or search unless browser work was asked; then preview tools only, no web fetch. Close a tab you opened before you reply, unless the user asked for it or uses it.`, ]; diff --git a/packages/provider-grok/src/server/driver.ts b/packages/provider-grok/src/server/driver.ts index 16d3e1d64017c..a46130233804f 100644 --- a/packages/provider-grok/src/server/driver.ts +++ b/packages/provider-grok/src/server/driver.ts @@ -41,6 +41,7 @@ import { makeProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilitiesResolver, resolveHomebrewProviderMaintenance, + resolveMiseProviderMaintenance, resolveProviderMaintenanceCapabilitiesEffect, } from "@t3tools/provider-core/server/maintenanceResolver"; import { @@ -53,10 +54,10 @@ const decodeGrokSettings = Schema.decodeSync(GrokSettings); const DRIVER_KIND = ProviderDriverKind.make("grok"); // npm latest matches the stable channel installed by grok update. const GROK_NPM_PACKAGE = "@xai-official/grok"; -// `grok update` installs under `GROK_HOME`; use the resolved executable and instance environment. +// `grok update` installs under `GROK_HOME` from the resolved executable and instance environment. // npm 12 blocks the postinstall that moves the new binary in, yet exits 0, so allow it by env. -// Homebrew casks need brew upgrades; `grok update` would install a second copy. -// Keep casks manual when their brew cannot vouch for them. +// Mise and Homebrew casks upgrade through their manager; `grok update` would install a second +// copy, so a cask its brew cannot vouch for stays manual. const UPDATE: ProviderMaintenanceCapabilitiesResolver = { resolve: Effect.fn("resolveGrokMaintenance")(function* (context) { if (!context) { @@ -65,6 +66,12 @@ const UPDATE: ProviderMaintenanceCapabilitiesResolver = { packageName: GROK_NPM_PACKAGE, }); } + const mise = yield* resolveMiseProviderMaintenance( + { provider: DRIVER_KIND, packageName: GROK_NPM_PACKAGE }, + { tool: "grok" }, + context, + ); + if (mise) return mise; const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); // Only a cask is Grok's: a formula keg such as `node` holding an npm global is not. if (homebrew?.kind === "cask") { diff --git a/packages/provider-muse/src/server/adapter.test.ts b/packages/provider-muse/src/server/adapter.test.ts index cc84894ac6ef9..46f8cb3366513 100644 --- a/packages/provider-muse/src/server/adapter.test.ts +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -1635,6 +1635,178 @@ describe("MuseAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect("reports a compaction rejected at admission on its own terminal", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + fake.queueResponse("session/compact", new Error("command rejected: run_active")); + yield* harness.runtime.compactThread!(yield* turnInput(harness.providerThread)); + const terminal = yield* harness.takeEvent("turn.terminal"); + assert.strictEqual(terminal.status, "failed"); + assert.strictEqual(terminal.threadDisposition, "reusable"); + assert.include(terminal.failure?.message, "run_active"); + yield* startConversation(harness, fake, 2); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("treats recovering an already ended turn as done", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + const turn = yield* startConversation(harness, fake); + yield* fake.emit("turn/completed", { turnId: turn.nativeId, terminal: "completed" }); + assert.strictEqual((yield* harness.takeEvent("turn.terminal")).status, "completed"); + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: turn.providerTurn.id, + requestRuntimeRestart: true, + }); + yield* startConversation(harness, fake, 2); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("does not acknowledge Stop on an ended turn while a background subagent runs", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + const turn = yield* startConversation(harness, fake); + yield* fake.emit("item/started", { + turnId: turn.nativeId, + item: { + itemId: "background-child", + kind: "subagent", + revision: 1, + status: "inProgress", + turnId: turn.nativeId, + subagentId: "subagent-1", + controlStatus: "running", + childSessionId: "child-session-1", + objective: "Finish in the background", + }, + }); + yield* harness.takeEvent("app_thread.created"); + yield* fake.emit("turn/completed", { turnId: turn.nativeId, terminal: "completed" }); + yield* harness.takeEvent("turn.terminal"); + const stopped = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: turn.providerTurn.id, + requestRuntimeRestart: true, + }) + .pipe(Effect.exit); + assert.strictEqual(stopped._tag, "Failure"); + assert.isTrue(yield* harness.runtime.hasPendingBackgroundWork!); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("ends a compaction whose item names the held wake as its run", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const offered = + yield* Deferred.make(); + const harness = yield* makeHarness( + fake, + INSTANCE_ID, + undefined, + undefined, + undefined, + runtimePolicy, + { continuationRequests: { offer: (request) => Deferred.succeed(offered, request) } }, + ); + const previous = yield* startConversation(harness, fake); + yield* fake.emit("turn/completed", { turnId: previous.nativeId, terminal: "completed" }); + yield* harness.takeEvent("turn.terminal"); + yield* fake.emit("turn/started", { turnId: "background-wake" }); + yield* Deferred.await(offered); + yield* fake.emit("turn/completed", { turnId: "background-wake", terminal: "completed" }); + yield* harness.runtime.compactThread!(yield* turnInput(harness.providerThread, 2)); + yield* fake.emit("item/completed", { + item: { + itemId: "manual-compact", + kind: "compaction", + revision: 1, + status: "completed", + turnId: "background-wake", + outcome: "compacted", + }, + }); + const terminal = yield* harness.takeEvent("turn.terminal"); + assert.strictEqual(terminal.runOrdinal, 2); + assert.strictEqual(terminal.status, "completed"); + assert.isTrue(yield* harness.runtime.hasPendingBackgroundWork!); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("drops a held wake when Stop closes the compacting host", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const offered = + yield* Deferred.make(); + const harness = yield* makeHarness( + fake, + INSTANCE_ID, + undefined, + undefined, + undefined, + runtimePolicy, + { continuationRequests: { offer: (request) => Deferred.succeed(offered, request) } }, + ); + const previous = yield* startConversation(harness, fake); + yield* fake.emit("turn/completed", { turnId: previous.nativeId, terminal: "completed" }); + yield* harness.takeEvent("turn.terminal"); + yield* fake.emit("turn/started", { turnId: "background-wake" }); + const continuation = yield* Deferred.await(offered); + yield* harness.runtime.compactThread!(yield* turnInput(harness.providerThread, 2)); + const running = yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "running", + ); + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: running.providerTurn.id, + requestRuntimeRestart: true, + }); + assert.isFalse(yield* harness.runtime.hasPendingBackgroundWork!); + const dispatched = yield* continuation.dispatchIfCurrent!(Effect.void); + assert.isTrue(Option.isNone(dispatched)); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("keeps a running wake held when Muse declines compaction", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const offered = + yield* Deferred.make(); + const harness = yield* makeHarness( + fake, + INSTANCE_ID, + undefined, + undefined, + undefined, + runtimePolicy, + { continuationRequests: { offer: (request) => Deferred.succeed(offered, request) } }, + ); + const previous = yield* startConversation(harness, fake); + yield* fake.emit("turn/completed", { turnId: previous.nativeId, terminal: "completed" }); + yield* harness.takeEvent("turn.terminal"); + yield* fake.emit("turn/started", { turnId: "background-wake" }); + yield* Deferred.await(offered); + fake.queueResponse("session/compact", new Error("command rejected: run_active")); + yield* harness.runtime.compactThread!(yield* turnInput(harness.providerThread, 2)); + const terminal = yield* harness.takeEvent("turn.terminal"); + assert.strictEqual(terminal.status, "failed"); + assert.isTrue(yield* harness.runtime.hasPendingBackgroundWork!); + const stopped = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: terminal.providerTurnId, + requestRuntimeRestart: true, + }) + .pipe(Effect.exit); + assert.strictEqual(stopped._tag, "Failure"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("projects native todos with stable identity and removes cancelled steps", () => Effect.gen(function* () { const fake = yield* makeFakeMuse(); diff --git a/packages/provider-muse/src/server/adapter.ts b/packages/provider-muse/src/server/adapter.ts index fd1526a428c6e..4e46be35bc2fe 100644 --- a/packages/provider-muse/src/server/adapter.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -1384,7 +1384,12 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( }); } } - if (wake && active?.nativeId !== wake.nativeId) { + // Muse compacts the latest run, which can be the held wake; its compaction item ends this turn. + if ( + wake && + active?.nativeId !== wake.nativeId && + !(active?.compact && itemEvent?.kind === "compaction") + ) { const turnId = typeof params.turnId === "string" ? params.turnId : itemEvent?.turnId; if (turnId === wake.nativeId) { wake.events.push([method, data]); @@ -2057,8 +2062,9 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( lastRunOrdinal: turnInput.runOrdinal, }); yield* updateSession("running"); - const held = wake; - wake = undefined; + // Compaction cannot join a running wake, so the wake stays held for its continuation. + const held = compact ? undefined : wake; + if (held) wake = undefined; if (continuation && !held) return yield* finish(turn, "completed"); if (held && !continuation) turn.joined.add(held.nativeId); for (const [method, data] of held?.events ?? []) @@ -2067,17 +2073,20 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( if (continuation) return; yield* Effect.gen(function* () { if (compact) { - const result = yield* request("session/compact", {}, true, nativeId).pipe( + // The turn's terminal reports a declined compaction; only a broken host fails the command. + const declined = yield* request("session/compact", {}, true, nativeId).pipe( Effect.flatMap((value) => decode(MuseCompactResult, value)), + Effect.map((result) => + result.status === "accepted" + ? undefined + : result.status === "noop" + ? (result.reason ?? "Muse has no context to compact.") + : `Muse returned an unsupported compaction status: ${result.status}`, + ), + Effect.catch((error) => (broken ? Effect.fail(error) : Effect.succeed(error.detail))), ); - if (result.status === "noop") - yield* eventPermit.withPermits(1)( - finish(turn, "failed", result.reason ?? "Muse has no context to compact."), - ); - else if (result.status !== "accepted") - return yield* protocolError( - `Muse returned an unsupported compaction status: ${result.status}`, - ); + if (declined !== undefined) + yield* eventPermit.withPermits(1)(finish(turn, "failed", declined)); } else { const result = yield* request( "turn/start", @@ -2257,6 +2266,14 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( interruptTurn: Effect.fn("MuseAdapter.interruptTurn")( function* (args) { const turn = active; + // Recovering an ended turn is done unless Muse still runs work Stop cannot end. + if ( + !turn && + args.requestRuntimeRestart === true && + observedChildren.size === 0 && + wake === undefined + ) + return; if ( !turn || turn.providerTurn.id !== args.providerTurnId || @@ -2274,6 +2291,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( catch: (cause) => protocolError("Cannot stop Muse compaction", cause), }).pipe(Effect.onError(failHost)); broken = true; + wake = undefined; yield* finish(turn, "interrupted", undefined, "broken"); yield* settleObservedChildren("interrupted"); yield* updateSession("stopped"); diff --git a/packages/provider-muse/src/server/driver.ts b/packages/provider-muse/src/server/driver.ts index 8de1161fd7bd7..6dfca26dc7273 100644 --- a/packages/provider-muse/src/server/driver.ts +++ b/packages/provider-muse/src/server/driver.ts @@ -130,12 +130,13 @@ export const MuseDriver: ProviderDriver< const capabilities = yield* resolveInstallation(options); // The maintenance runner requests fresh capabilities around an explicit update // and needs the native target version to verify that the command actually upgraded. - const latestVersion = options?.fresh - ? yield* latestMuseVersion(processEnvironment, { fresh: true }).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), - ) - : undefined; + const latestVersion = + options?.fresh && !capabilities.lookupLatestVersion + ? yield* latestMuseVersion(processEnvironment, { fresh: true }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), + ) + : undefined; return latestVersion !== undefined ? { ...capabilities, latestVersion } : capabilities; }); const snapshot = yield* makeManagedServerProvider>({ diff --git a/packages/provider-muse/src/server/maintenance.ts b/packages/provider-muse/src/server/maintenance.ts index 0a3a59de128e8..342e91638b830 100644 --- a/packages/provider-muse/src/server/maintenance.ts +++ b/packages/provider-muse/src/server/maintenance.ts @@ -12,6 +12,7 @@ import { makeProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilitiesResolver, + resolveMiseProviderMaintenance, } from "@t3tools/provider-core/server/maintenanceResolver"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; @@ -37,7 +38,10 @@ export const compareMuseVersions = (current: string, latest: string) => ? compareSemverVersions(comparableMuseVersion(current), comparableMuseVersion(latest)) : compareSemverVersions(current.replace(MUSE_REVISION, ""), latest.replace(MUSE_REVISION, "")); -/** Only the official launcher owns an update channel; a standalone binary cannot update itself. */ +/** + * Mise upgrades a binary it installed. Otherwise only the official launcher owns + * an update channel; a standalone binary cannot update itself. + */ export const museMaintenance: ProviderMaintenanceCapabilitiesResolver = { resolve: Effect.fn("MuseMaintenance.resolve")(function* (context) { const manual = { @@ -45,6 +49,12 @@ export const museMaintenance: ProviderMaintenanceCapabilitiesResolver = { compareVersions: compareMuseVersions, }; if (!context || context.platform === "win32") return manual; + const mise = yield* resolveMiseProviderMaintenance( + { provider: DRIVER, packageName: null }, + { tool: "muse" }, + context, + ); + if (mise) return { ...mise, compareVersions: compareMuseVersions }; const fs = yield* FileSystem.FileSystem; const isLauncher = yield* Effect.gen(function* () { const stat = yield* fs.stat(context.realCommandPath); @@ -119,6 +129,15 @@ export const enrichMuseSnapshot = Effect.fn("enrichMuseSnapshot")(function* (inp readonly environment: NodeJS.ProcessEnv; }) { const { snapshot } = input; + const options = { enableProviderUpdateChecks: input.enableProviderUpdateChecks }; + // Mise names the version it would install, so its answer alone counts. + if (input.maintenanceCapabilities.lookupLatestVersion) { + return yield* enrichProviderSnapshotWithVersionAdvisory( + snapshot, + input.maintenanceCapabilities, + options, + ); + } const latestVersion = input.enableProviderUpdateChecks && snapshot.enabled && snapshot.installed && snapshot.version ? yield* latestMuseVersion(input.environment) @@ -126,6 +145,6 @@ export const enrichMuseSnapshot = Effect.fn("enrichMuseSnapshot")(function* (inp return yield* enrichProviderSnapshotWithVersionAdvisory( snapshot, { ...input.maintenanceCapabilities, latestVersion }, - { enableProviderUpdateChecks: input.enableProviderUpdateChecks }, + options, ); }); diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 3ad909085e420..753e319550c8a 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -92,6 +92,8 @@ export const openCodeUpdateFor = (generation: ProbedOpenCode["generation"]) => isCommandPath: isOpenCodeNativeCommandPath, requiresCommandPath: true, }, + // mise's `opencode` follows releases into 2.x, so it would upgrade 1.x across that line. + mise: { tool: "opencode", upgrade: generation === "v2" }, }); type OpenCodeRuntimeProbe = Effect.Success< diff --git a/packages/provider-pi/src/server/driver.ts b/packages/provider-pi/src/server/driver.ts index 0b3faa5f37ced..f097069ed9964 100644 --- a/packages/provider-pi/src/server/driver.ts +++ b/packages/provider-pi/src/server/driver.ts @@ -50,6 +50,7 @@ const DRIVER_KIND = ProviderDriverKind.make("pi"); const UPDATE = makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "@earendil-works/pi-coding-agent", + mise: { tool: "pi" }, nativeUpdate: { args: ["update", "--self"] }, }); diff --git a/packages/shared/src/markdownPipeline.test.ts b/packages/shared/src/markdownPipeline.test.ts index 5d699ffe8ab6a..0493580e93625 100644 --- a/packages/shared/src/markdownPipeline.test.ts +++ b/packages/shared/src/markdownPipeline.test.ts @@ -204,6 +204,62 @@ it("builds the tree rehype-raw builds for markdown without HTML", () => { } }); +it("keeps local editor file links but not other schemes through the shared Markdown sanitizer", () => { + const processor = unified() + .use(remarkParse) + .use(CHAT_MARKDOWN_REMARK_PLUGINS) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(CHAT_MARKDOWN_REHYPE_PLUGINS); + const tree = processor.runSync( + processor.parse( + [ + "[cursor](cursor://file/Users/me/repo/file.md:3)", + "[code](vscode://file/Users/me/repo/file.md:3:7)", + "[insiders](vscode-insiders://file/Users/me/repo/file.md)", + "[unknown](foo://file/Users/me/repo/file.md)", + "[zed](zed://file/Users/me/repo/file.md)", + ].join(" "), + ), + ); + type Node = { tagName?: string; properties?: { href?: string }; children?: Node[] }; + const hrefs = ((tree.children[0] as Node).children ?? []) + .filter((node) => node.tagName === "a") + .map((node) => node.properties?.href); + expect(hrefs).toEqual([ + "cursor://file/Users/me/repo/file.md:3", + "vscode://file/Users/me/repo/file.md:3:7", + "vscode-insiders://file/Users/me/repo/file.md", + undefined, + undefined, + ]); +}); + +it("keeps mixed-case local editor file links by lowercasing the scheme before sanitizing", () => { + const processor = unified() + .use(remarkParse) + .use(CHAT_MARKDOWN_REMARK_PLUGINS) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(CHAT_MARKDOWN_REHYPE_PLUGINS); + const tree = processor.runSync( + processor.parse( + [ + "[upper](VSCODE://file/Users/me/repo/file.md:3:7)", + "[mixed](Cursor://file/Users/me/repo/file.md:3)", + "[other](FOO://file/Users/me/repo/file.md)", + ].join(" "), + ), + ); + type Node = { tagName?: string; properties?: { href?: string }; children?: Node[] }; + const hrefs = ((tree.children[0] as Node).children ?? []) + .filter((node) => node.tagName === "a") + .map((node) => node.properties?.href); + expect(hrefs).toEqual([ + "vscode://file/Users/me/repo/file.md:3:7", + "cursor://file/Users/me/repo/file.md:3", + undefined, + ]); +}); + it("keeps backslashes that CommonMark would read as escapes in prose Windows paths", () => { const processor = unified() .use(remarkParse) diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts index 0d093872c0775..e4527a8a017c9 100644 --- a/packages/shared/src/markdownPipeline.ts +++ b/packages/shared/src/markdownPipeline.ts @@ -1,3 +1,4 @@ +import { isLocalEditorFileUrl, LOCAL_FILE_LINK_SCHEMES } from "@t3tools/contracts"; import type { Paragraph, PhrasingContent, Root, RootContent } from "mdast"; import { remarkKeepWindowsPaths } from "./markdownWindowsPaths.ts"; import { type PluggableList, type Processor, type Transformer, unified } from "unified"; @@ -164,6 +165,26 @@ function rehypePreserveImageSourceMeta() { const IMAGE_SRC_PROTOCOLS = [...(defaultSchema.protocols?.src ?? []), "file", "t3-context"]; +// The sanitizer compares schemes case-sensitively, so lowercase accepted editor links first. +function rehypeNormalizeLocalEditorLinks() { + return (tree: MarkdownImageHastNode) => { + const visit = (node: MarkdownImageHastNode) => { + const href = node.properties?.href; + if (node.type === "element" && node.tagName === "a" && typeof href === "string") { + try { + const url = new URL(href); + if (isLocalEditorFileUrl(url)) node.properties = { ...node.properties, href: url.href }; + } catch { + // Not an absolute URL; the sanitizer decides. + } + } + node.children?.forEach(visit); + }; + + visit(tree); + }; +} + const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, attributes: { @@ -195,6 +216,8 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { "t3-citation", "t3-context", THREAD_LINK_PROTOCOL, + // Scheme only: the renderer's URL transform checks the rest of the link shape. + ...LOCAL_FILE_LINK_SCHEMES, ], src: IMAGE_SRC_PROTOCOLS, dataDarkSrc: IMAGE_SRC_PROTOCOLS, @@ -301,6 +324,7 @@ export const CHAT_MARKDOWN_REHYPE_PLUGINS: PluggableList = [ rehypePreserveBareAnchorPlaceholders, rehypeRawWhenPresent, rehypePreserveImageSourceMeta, + rehypeNormalizeLocalEditorLinks, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], ]; diff --git a/packages/shared/src/paletteSearch.test.ts b/packages/shared/src/paletteSearch.test.ts index d30b3189ee42c..4c00dd40571e2 100644 --- a/packages/shared/src/paletteSearch.test.ts +++ b/packages/shared/src/paletteSearch.test.ts @@ -40,6 +40,43 @@ describe("scoreThreadSearch", () => { thread("Share auth across worktrees"), ]), ).toEqual(["Share auth across worktrees", "Authentication cleanup", "Fix oauth redirect"]); + expect( + rankTitles("share auth", [ + thread("Share authentication cleanup", { + activityAt: NOW, + opens: { openedAt: NOW, score: 12, picks: [] }, + }), + thread("Auth across worktrees to share", { activityAt: NOW - 90 * DAY }), + ]), + ).toEqual(["Auth across worktrees to share", "Share authentication cleanup"]); + expect( + rankTitles("ui", [ + thread("Parse ui tokens", { activityAt: NOW - 200 * DAY }), + thread("Polish UIs", { activityAt: NOW, opens: { openedAt: NOW, score: 12, picks: [] } }), + ]), + ).toEqual(["Polish UIs", "Parse ui tokens"]); + expect( + rankTitles("fix in sidebar", [ + thread("Fix sidebar", { activityAt: NOW - 200 * DAY }), + thread("Fix crash in sidebar", { + activityAt: NOW, + opens: { openedAt: NOW, score: 12, picks: [] }, + }), + thread("Fix sidebar input focus", { + activityAt: NOW, + opens: { openedAt: NOW, score: 6, picks: [] }, + }), + ]), + ).toEqual(["Fix crash in sidebar", "Fix sidebar input focus", "Fix sidebar"]); + expect( + rankTitles("and", [ + thread("Fix auth and sync", { activityAt: NOW - 200 * DAY }), + thread("Android build", { + activityAt: NOW, + opens: { openedAt: NOW, score: 12, picks: [] }, + }), + ]), + ).toEqual(["Android build", "Fix auth and sync"]); }); it("treats camelCase, kebab, path, and digit boundaries as word starts", () => { diff --git a/packages/shared/src/paletteSearch.ts b/packages/shared/src/paletteSearch.ts index af7d4ca48a0cf..c163482c3af73 100644 --- a/packages/shared/src/paletteSearch.ts +++ b/packages/shared/src/paletteSearch.ts @@ -344,6 +344,11 @@ interface WeightedSearchField { interface SearchFieldsMatch { /** Roughly 0.3 (inside a word) to 1.5 (exact title). */ readonly quality: number; + /** + * Every non-filler token has three or more characters and matched a whole word at full + * weight, before phrase bonuses. Optional filler words don't count either way. + */ + readonly wholeWords: boolean; /** Every token matched at a word start, without a typo. */ readonly strong: boolean; /** Source ranges matched in the first field, for highlighting. */ @@ -407,6 +412,7 @@ function matchSearchFields( const primaryRanges: SearchRange[] = []; let total = 0; let counted = 0; + let notWhole = false; let strong = true; for (const token of tokens) { let best = 0; @@ -433,6 +439,9 @@ function matchSearchFields( if (bestPrimary) primaryRanges.push(...(bestPrimary.letters ?? [bestPrimary])); total += best; counted += 1; + // Optional filler never decides the whole-word tier; a query of only filler never earns it. + if (FILLER_WORDS.has(token)) notWhole ||= !fillerOptional; + else notWhole ||= token.length < PICK_PREFIX_MIN_LENGTH || best < WHOLE_WORD; } let quality = total / counted; if (primary) { @@ -441,6 +450,7 @@ function matchSearchFields( } return { quality, + wholeWords: !notWhole, strong, primaryRanges: mergeRanges(primaryRanges), }; @@ -851,7 +861,7 @@ function matchReference( }; return (thread.id && matches(thread.id)) || thread.pullRequests?.some((pullRequest) => pullRequest.references?.some(matches)) - ? { quality: 0.6, strong: true, primaryRanges: [] } + ? { quality: 0.6, wholeWords: false, strong: true, primaryRanges: [] } : null; } @@ -888,6 +898,7 @@ export interface ThreadSearchMatch { readonly score: number; /** Match quality alone; see `rankSearchGroups`. */ readonly quality: number; + readonly wholeWords: boolean; /** Every token matched at a word start; see `rankSearchGroups`. */ readonly strong: boolean; /** Matched ranges in the title. */ @@ -1043,6 +1054,7 @@ export function scoreThreadSearch( return { score: match.quality * threadUsageBoost(thread, now), quality: match.quality, + wholeWords: match.wholeWords, // A thread's whole title never lifts its group over a command or project // of that name: every new thread is titled "New thread" for a while. groupQuality: Math.min(match.quality, WHOLE_NAME - 0.01), @@ -1146,6 +1158,7 @@ export function rankSearchGroups< readonly pickedAt?: number; readonly pickedExactly?: boolean; readonly groupQuality?: number; + readonly wholeWords?: boolean; }, >( groups: ReadonlyArray<{ readonly group: G; readonly items: ReadonlyArray }>, @@ -1173,12 +1186,15 @@ export function rankSearchGroups< // A longer-query pick must not pass a whole-name match for the current query. const wordMatch = (match: M) => Math.min(matchClass(match.quality), 2); const exactPickAt = (match: M) => (match.pickedExactly ? (match.pickedAt ?? 0) : 0); + // Use reorders whole words or prefixes among themselves, never a prefix above a whole word. + const wholeWord = (match: M) => Number(match.wholeWords ?? match.quality >= WHOLE_WORD); const sorted = [...items].sort( (left, right) => wordMatch(right.match) - wordMatch(left.match) || exactPickAt(right.match) - exactPickAt(left.match) || matchClass(right.match.quality) - matchClass(left.match.quality) || (right.match.pickedAt ?? 0) - (left.match.pickedAt ?? 0) || + wholeWord(right.match) - wholeWord(left.match) || right.match.score - left.match.score, ); const best = Math.max( diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index 0c19f18163e64..c935c379a06be 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -104,6 +104,21 @@ describe("thread links", () => { ).toBe("[There](t3-thread://v2/there/t1) and [Here](t3-thread://v2/local/t1)"); }); + it("reads an angle-bracketed destination as the labeled link, not an autolink", () => { + const title = titlesIn({ local: { t1: "Here" }, there: { t1: "There" } }); + expect( + relabelThreadLinks( + "[old]() and [older]()", + local, + title, + ), + ).toBe("[There](t3-thread://v2/there/t1) and [Here](t3-thread://v2/local/t1)"); + expect( + relabelThreadLinks("[Kept label]()", local, () => undefined), + ).toBe("Kept label"); + expect(threadLinkRefs("[a]()", local)).toEqual([ref("there", "t2")]); + }); + it("turns a v2 link it cannot resolve into plain text, never the raw link", () => { expect( relabelThreadLinks( diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts index aba7d5f983a0a..e13e2d1064e6f 100644 --- a/packages/shared/src/threadLinks.ts +++ b/packages/shared/src/threadLinks.ts @@ -11,8 +11,9 @@ const HOSTED_PREFIX = `${DEFAULT_HOSTED_APP_URL}/threads/`; const HOSTED_SEGMENT = "[A-Za-z0-9_%-]+"; const HOSTED_LINK = `${HOSTED_PREFIX.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}${HOSTED_SEGMENT}/${HOSTED_SEGMENT}`; // Code comes first in the alternation so a link written inside a code span or fence is skipped. +// A labeled link's destination may be angle-bracketed, as CommonMark allows: [label](). const THREAD_LINK_OUTSIDE_CODE = new RegExp( - String.raw`(?(\`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?(\`+)[^\n]*?\4)|\[(?