diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 365363881f5b..08318bcd8da5 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -19,6 +19,7 @@ import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; @@ -32,6 +33,7 @@ import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts"; import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; +import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; const DEFAULT_DESKTOP_BACKEND_PORT = 3773; const MAX_TCP_PORT = 65_535; @@ -179,6 +181,10 @@ const bootstrap = Effect.gen(function* () { }); yield* installDesktopIpcHandlers(); yield* logBootstrapInfo("bootstrap ipc handlers registered"); + // Before any window: the preload merges these items before the app reads storage. + yield* (yield* DesktopLegacyLocalStorage.DesktopLegacyLocalStorage).load( + yield* (yield* DesktopAppIdentity.DesktopAppIdentity).resolveUserDataPath, + ); yield* snapShot.initialize; @@ -337,6 +343,7 @@ const scopedProgram = Effect.scoped( yield* Effect.annotateCurrentSpan({ scope: "desktop", runId }); const shutdown = yield* DesktopShutdown.DesktopShutdown; + const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; yield* Effect.addFinalizer(() => // Stop every backend in the pool, not just the primary. The @@ -344,7 +351,10 @@ const scopedProgram = Effect.scoped( // cascade, so leaving the WSL instance for its parent scope // finalizer means it gets hard-killed by the OS instead of // receiving SIGTERM + grace. - stopAllPoolInstances().pipe(Effect.ensuring(shutdown.markComplete)), + stopAllPoolInstances().pipe( + Effect.ensuring(rendererHistory.shutdown), + Effect.ensuring(shutdown.markComplete), + ), ); yield* startup; diff --git a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts new file mode 100644 index 000000000000..b29cfb3b7c63 --- /dev/null +++ b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts @@ -0,0 +1,117 @@ +import * as Context from "effect/Context"; +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 * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; + +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import { readChromiumLocalStorage } from "./chromiumLocalStorage.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +/** + * Carries the renderer's localStorage (prompt stash, unsent drafts, layout, + * theme) over from the V1 desktop profile, which V2 replaced with its own + * `t3code-v2` profile. The V1 profile is only read, never opened by Chromium, + * so this works while V1 is still running. + * + * `load` runs before the window opens; the preload takes the items once, + * merges them into the new profile, and calls `complete`, which writes a + * marker so later launches skip the read. A failed read or merge, or a window + * that never completes, leaves no marker and retries next launch. + */ +export class DesktopLegacyLocalStorage extends Context.Service< + DesktopLegacyLocalStorage, + { + readonly load: (userDataPath: string) => Effect.Effect; + readonly take: Effect.Effect>>>; + readonly complete: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopLegacyLocalStorage") {} + +const MARKER_FILE_NAME = "v1-local-storage-imported"; +// V1 used "T3 Code (Alpha)" when that folder existed and "t3code" otherwise. +const V1_PROFILE_NAMES = ["T3 Code (Alpha)", "t3code"]; + +const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const items = yield* Ref.make(Option.none>>()); + const markerPath = yield* Ref.make(Option.none()); + + /** Newest file mtime in a directory; appending to a log leaves the directory's own mtime alone. */ + const newestFileMtime = (directory: string) => + Effect.gen(function* () { + let newest = 0; + for (const name of yield* fs.readDirectory(directory)) { + const info = yield* fs.stat(path.join(directory, name)).pipe(Effect.option); + if (Option.isNone(info)) continue; + const mtime = Option.match(info.value.mtime, { + onNone: () => 0, + onSome: (date) => date.getTime(), + }); + newest = Math.max(newest, mtime); + } + return newest; + }).pipe(Effect.option); + + /** The V1 profile whose Local Storage was written most recently, if any. */ + const findV1LocalStorage = Effect.gen(function* () { + let newest: { readonly directory: string; readonly mtime: number } | null = null; + for (const name of V1_PROFILE_NAMES) { + const directory = path.join(environment.appDataDirectory, name, "Local Storage", "leveldb"); + const mtime = yield* newestFileMtime(directory); + if (Option.isNone(mtime)) continue; + if (newest === null || mtime.value > newest.mtime) newest = { directory, mtime: mtime.value }; + } + return newest?.directory ?? null; + }); + + const writeMarker = Effect.gen(function* () { + const marker = yield* Ref.get(markerPath); + if (Option.isNone(marker)) return; + yield* fs + .writeFileString(marker.value, "") + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not record the V1 Local Storage import", error), + ), + ); + }); + + const load = Effect.fn("desktop.legacyLocalStorage.load")(function* (userDataPath: string) { + // Development already shares its profile between versions. + if (environment.isDevelopment) return; + const marker = path.join(userDataPath, MARKER_FILE_NAME); + if (yield* fs.exists(marker).pipe(Effect.orElseSucceed(() => true))) return; + yield* Ref.set(markerPath, Option.some(marker)); + const directory = yield* findV1LocalStorage; + if (directory === null) return yield* writeMarker; + const origin = ElectronProtocol.getDesktopUrl(false).replace(/\/$/, ""); + const read = yield* readChromiumLocalStorage(directory, origin).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.tapError((error) => + Effect.logWarning("Could not read V1 Local Storage; will retry next launch", error), + ), + Effect.option, + ); + if (Option.isNone(read)) return; + yield* Effect.logInfo("V1 Local Storage ready to import", { + directory, + keys: read.value.size, + }); + yield* Ref.set(items, Option.some(Object.fromEntries(read.value))); + }); + + return DesktopLegacyLocalStorage.of({ + load, + // Taking clears the items, so a reload before `complete` cannot merge twice. + take: Ref.getAndSet(items, Option.none()), + complete: writeMarker, + }); +}); + +export const layer = Layer.effect(DesktopLegacyLocalStorage, make); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index ce8233e56612..55d8074ab7f0 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -227,7 +227,7 @@ const refreshFileSize = ( Effect.orElseSucceed(() => 0), ); -const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { +export const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { readonly filePath: string; readonly maxBytes?: number; readonly maxFiles?: number; diff --git a/apps/desktop/src/app/chromiumLocalStorage.ts b/apps/desktop/src/app/chromiumLocalStorage.ts new file mode 100644 index 000000000000..d5847b814db6 --- /dev/null +++ b/apps/desktop/src/app/chromiumLocalStorage.ts @@ -0,0 +1,460 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; + +/** + * Read-only decoder for a Chromium profile's `Local Storage/leveldb`. + * + * It reads the database files directly and never takes LevelDB's lock, so it + * works while another app version has the profile open. Live files come from + * the MANIFEST that CURRENT names, and the newest sequence number wins, which + * is how LevelDB itself resolves overwrites and deletions. + */ + +const LOG_BLOCK_SIZE = 32 * 1024; +const LOG_HEADER_SIZE = 7; +const TABLE_FOOTER_SIZE = 48; +const BLOCK_TRAILER_SIZE = 5; + +class LevelDbFormatError extends Schema.TaggedError()("LevelDbFormatError", { + detail: Schema.String, +}) {} + +const isLevelDbFormatError = Schema.is(LevelDbFormatError); + +const fail = (detail: string): never => { + throw new LevelDbFormatError({ detail }); +}; + +// ignoreBOM keeps a leading U+FEFF that is part of the stored string. +const textDecoderUtf16 = new TextDecoder("utf-16le", { ignoreBOM: true }); +const textDecoderUtf8 = new TextDecoder(); + +/** True ISO-8859-1. WHATWG's "latin1" label is windows-1252, which remaps 0x80-0x9F. */ +function decodeLatin1(bytes: Uint8Array): string { + let result = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + result += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return result; +} + +class Reader { + readonly bytes: Uint8Array; + offset: number; + constructor(bytes: Uint8Array, offset = 0) { + this.bytes = bytes; + this.offset = offset; + } + get done() { + return this.offset >= this.bytes.length; + } + byte(): number { + if (this.offset >= this.bytes.length) fail("unexpected end of data"); + return this.bytes[this.offset++]!; + } + varint(): number { + let result = 0; + let multiplier = 1; + for (let shift = 0; shift < 64; shift += 7) { + const byte = this.byte(); + result += (byte & 0x7f) * multiplier; + if ((byte & 0x80) === 0) return result; + multiplier *= 128; + } + return fail("varint too long"); + } + take(length: number): Uint8Array { + if (length < 0 || this.offset + length > this.bytes.length) fail("length out of range"); + const slice = this.bytes.subarray(this.offset, this.offset + length); + this.offset += length; + return slice; + } + lengthPrefixed(): Uint8Array { + return this.take(this.varint()); + } +} + +const readUint32 = (bytes: Uint8Array, offset: number) => + (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16)) + + bytes[offset + 3]! * 0x1000000; + +const CRC32C_TABLE = (() => { + const table = new Uint32Array(256); + for (let index = 0; index < 256; index++) { + let crc = index; + for (let bit = 0; bit < 8; bit++) crc = crc & 1 ? (crc >>> 1) ^ 0x82f63b78 : crc >>> 1; + table[index] = crc >>> 0; + } + return table; +})(); + +function crc32c(bytes: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of bytes) crc = CRC32C_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +/** LevelDB stores CRCs masked so a CRC of data containing CRCs stays well distributed. */ +const unmaskCrc = (masked: number) => { + const rotated = (masked - 0xa282ead8) >>> 0; + return ((rotated >>> 17) | (rotated << 15)) >>> 0; +}; + +function decompressSnappy(input: Uint8Array): Uint8Array { + const reader = new Reader(input); + const output = new Uint8Array(reader.varint()); + let written = 0; + while (!reader.done) { + const tag = reader.byte(); + const kind = tag & 3; + if (kind === 0) { + let length = tag >>> 2; + if (length >= 60) { + const extraBytes = length - 59; + length = 0; + for (let index = 0; index < extraBytes; index++) length += reader.byte() * 256 ** index; + } + length += 1; + if (written + length > output.length) fail("snappy literal overflows output"); + output.set(reader.take(length), written); + written += length; + continue; + } + let length: number; + let offset: number; + if (kind === 1) { + length = 4 + ((tag >>> 2) & 7); + offset = ((tag >>> 5) << 8) | reader.byte(); + } else if (kind === 2) { + length = (tag >>> 2) + 1; + offset = reader.byte() | (reader.byte() << 8); + } else { + length = (tag >>> 2) + 1; + offset = readUint32(reader.take(4), 0); + } + if (offset === 0 || offset > written || written + length > output.length) { + fail("snappy copy out of range"); + } + // Copies may overlap their own output, so they go byte by byte. + for (let index = 0; index < length; index++) { + output[written] = output[written - offset]!; + written++; + } + } + if (written !== output.length) fail("snappy output length mismatch"); + return output; +} + +/** + * Splits a LevelDB log (write-ahead log or MANIFEST) into its records. Like + * LevelDB's own reader, a corrupt record or zero padding skips the rest of its + * block, since a reused log keeps appending after a torn write, and a record + * cut off by the end of the file ends the log. + */ +function readLogRecords(bytes: Uint8Array): Uint8Array[] { + const records: Uint8Array[] = []; + let pending: Uint8Array[] | null = null; + let offset = 0; + while (offset + LOG_HEADER_SIZE <= bytes.length) { + const blockRemaining = LOG_BLOCK_SIZE - (offset % LOG_BLOCK_SIZE); + if (blockRemaining < LOG_HEADER_SIZE) { + offset += blockRemaining; + continue; + } + const length = bytes[offset + 4]! | (bytes[offset + 5]! << 8); + const type = bytes[offset + 6]!; + const end = offset + LOG_HEADER_SIZE + length; + const fitsBlock = LOG_HEADER_SIZE + length <= blockRemaining; + if (fitsBlock && end > bytes.length) break; + const typeAndPayload = bytes.subarray(offset + 6, end); + if ( + type === 0 || + !fitsBlock || + crc32c(typeAndPayload) !== unmaskCrc(readUint32(bytes, offset)) + ) { + offset += blockRemaining; + pending = null; + continue; + } + const payload = typeAndPayload.subarray(1); + offset = end; + if (type === 1) { + records.push(payload); + pending = null; + } else if (type === 2) { + pending = [payload]; + } else if (type === 3 && pending) { + pending.push(payload); + } else if (type === 4 && pending) { + pending.push(payload); + const total = pending.reduce((sum, part) => sum + part.length, 0); + const record = new Uint8Array(total); + let position = 0; + for (const part of pending) { + record.set(part, position); + position += part.length; + } + records.push(record); + pending = null; + } else { + pending = null; + } + } + return records; +} + +interface LiveFiles { + readonly tables: ReadonlyArray; + readonly logNumber: number; + readonly previousLogNumber: number; +} + +/** Replays MANIFEST version edits to find the live table files and log. */ +function readManifest(bytes: Uint8Array): LiveFiles { + const tables = new Set(); + let logNumber = 0; + let previousLogNumber = 0; + // LevelDB refuses to open a MANIFEST missing any of these, and so do we. + const required = new Set([2, 3, 4]); + for (const record of readLogRecords(bytes)) { + const reader = new Reader(record); + while (!reader.done) { + const tag = reader.varint(); + required.delete(tag); + switch (tag) { + case 1: // comparator + reader.lengthPrefixed(); + break; + case 2: + logNumber = reader.varint(); + break; + case 3: // next file number + case 4: // last sequence + reader.varint(); + break; + case 5: // compaction pointer + reader.varint(); + reader.lengthPrefixed(); + break; + case 6: // deleted file + reader.varint(); + tables.delete(reader.varint()); + break; + case 7: { + reader.varint(); + tables.add(reader.varint()); + reader.varint(); + reader.lengthPrefixed(); + reader.lengthPrefixed(); + break; + } + case 9: + previousLogNumber = reader.varint(); + break; + default: + fail(`unknown manifest tag ${tag}`); + } + } + } + if (required.size > 0) fail("incomplete manifest"); + return { tables: [...tables], logNumber, previousLogNumber }; +} + +interface VersionedEntry { + readonly sequence: number; + /** `null` marks a deletion. */ + readonly value: Uint8Array | null; +} + +type EntryMap = Map; + +const keyId = decodeLatin1; + +function record(entries: EntryMap, key: Uint8Array, entry: VersionedEntry) { + const id = keyId(key); + const existing = entries.get(id); + if (!existing || existing.entry.sequence < entry.sequence) entries.set(id, { key, entry }); +} + +/** Applies one write-ahead log record (a WriteBatch). */ +function readWriteBatch(entries: EntryMap, batch: Uint8Array) { + if (batch.length < 12) fail("write batch too short"); + // The sequence is a little-endian uint64; real values stay far below 2^53. + const sequence = readUint32(batch, 0) + readUint32(batch, 4) * 0x100000000; + const count = readUint32(batch, 8); + const reader = new Reader(batch, 12); + for (let index = 0; index < count; index++) { + const type = reader.byte(); + const key = reader.lengthPrefixed(); + if (type === 1) { + record(entries, key, { sequence: sequence + index, value: reader.lengthPrefixed() }); + } else if (type === 0) { + record(entries, key, { sequence: sequence + index, value: null }); + } else { + fail(`unknown write batch record type ${type}`); + } + } +} + +function readBlock(table: Uint8Array, reader: Reader): Uint8Array { + const offset = reader.varint(); + const size = reader.varint(); + if (offset + size + BLOCK_TRAILER_SIZE > table.length) fail("block handle out of range"); + const contents = table.subarray(offset, offset + size); + const compression = table[offset + size]!; + const checksum = unmaskCrc(readUint32(table, offset + size + 1)); + if (crc32c(table.subarray(offset, offset + size + 1)) !== checksum) fail("block checksum"); + if (compression === 0) return contents; + if (compression === 1) return decompressSnappy(contents); + return fail(`unsupported block compression ${compression}`); +} + +function* blockEntries(block: Uint8Array): Generator<[Uint8Array, Uint8Array]> { + if (block.length < 4) fail("block too short"); + const restartCount = readUint32(block, block.length - 4); + const limit = block.length - 4 - restartCount * 4; + if (limit < 0) fail("restart array out of range"); + const reader = new Reader(block.subarray(0, limit)); + let key = new Uint8Array(0); + while (!reader.done) { + const shared = reader.varint(); + const unshared = reader.varint(); + const valueLength = reader.varint(); + if (shared > key.length) fail("block key prefix out of range"); + const nextKey = new Uint8Array(shared + unshared); + nextKey.set(key.subarray(0, shared)); + nextKey.set(reader.take(unshared), shared); + key = nextKey; + yield [key, reader.take(valueLength)]; + } +} + +/** Applies every entry in one sorted table (`.ldb`/`.sst`) file. */ +function readTable(entries: EntryMap, table: Uint8Array) { + if (table.length < TABLE_FOOTER_SIZE) fail("table too short"); + const footer = new Reader(table, table.length - TABLE_FOOTER_SIZE); + footer.varint(); + footer.varint(); + const index = readBlock(table, footer); + for (const [, handle] of blockEntries(index)) { + const block = readBlock(table, new Reader(handle)); + for (const [internalKey, value] of blockEntries(block)) { + if (internalKey.length < 8) fail("internal key too short"); + const trailerOffset = internalKey.length - 8; + const trailer = + readUint32(internalKey, trailerOffset) + + readUint32(internalKey, trailerOffset + 4) * 0x100000000; + const type = trailer % 256; + const sequence = Math.floor(trailer / 256); + const key = internalKey.subarray(0, trailerOffset); + record(entries, key, { sequence, value: type === 1 ? value : null }); + } + } +} + +/** Chromium prefixes each stored string with 0 (UTF-16LE) or 1 (Latin-1). */ +function decodeChromiumString(bytes: Uint8Array): string | null { + if (bytes.length === 0) return null; + const body = bytes.subarray(1); + if (bytes[0] === 1) return decodeLatin1(body); + if (bytes[0] === 0 && body.length % 2 === 0) return textDecoderUtf16.decode(body); + return null; +} + +export class ChromiumLocalStorageReadError extends Schema.TaggedError()( + "ChromiumLocalStorageReadError", + { + directory: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Could not read Chromium Local Storage at ${this.directory}.`; + } +} + +/** + * Returns the localStorage items one origin (for example `t3code://app`) had + * in the profile, or an empty map when the profile never stored any. + */ +export const readChromiumLocalStorage = Effect.fn("desktop.chromiumLocalStorage.read")(function* ( + directory: string, + origin: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const readFile = (name: string) => fs.readFile(path.join(directory, name)); + const wrap = Effect.mapError( + (cause: PlatformError.PlatformError | LevelDbFormatError) => + new ChromiumLocalStorageReadError({ directory, cause }), + ); + const decode = (run: () => A) => + Effect.try({ + try: run, + catch: (cause) => + isLevelDbFormatError(cause) ? cause : new LevelDbFormatError({ detail: String(cause) }), + }); + + const current = textDecoderUtf8.decode(yield* readFile("CURRENT").pipe(wrap)).trim(); + if (!/^MANIFEST-\d+$/.test(current)) { + return yield* new ChromiumLocalStorageReadError({ + directory, + cause: new LevelDbFormatError({ detail: "bad CURRENT" }), + }); + } + const manifest = yield* readFile(current).pipe(wrap); + const live = yield* decode(() => readManifest(manifest)).pipe(wrap); + const logs = (yield* fs.readDirectory(directory).pipe(wrap)) + .map((name) => /^(\d+)\.log$/.exec(name)) + // The logs LevelDB's own recovery replays. + .filter((match) => { + const number = Number(match?.[1]); + return number >= live.logNumber || number === live.previousLogNumber; + }) + .map((match) => match![0]); + + const entries: EntryMap = new Map(); + for (const number of live.tables) { + const name = String(number).padStart(6, "0"); + // Newer LevelDB names tables .ldb; older databases may still hold .sst files. + const bytes = yield* readFile(`${name}.ldb`).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => readFile(`${name}.sst`), + ), + wrap, + ); + yield* decode(() => readTable(entries, bytes)).pipe(wrap); + } + for (const name of logs) { + const bytes = yield* readFile(name).pipe(wrap); + yield* decode(() => { + for (const batch of readLogRecords(bytes)) readWriteBatch(entries, batch); + }).pipe(wrap); + } + + // A flush while another process has the database open can move entries + // from a log we read into a table our MANIFEST copy predates. Fail so the + // caller retries rather than import a snapshot missing those entries. + const currentAfter = textDecoderUtf8.decode(yield* readFile("CURRENT").pipe(wrap)).trim(); + const manifestAfter = yield* fs.stat(path.join(directory, current)).pipe(wrap); + if (currentAfter !== current || Number(manifestAfter.size) !== manifest.length) { + return yield* new ChromiumLocalStorageReadError({ + directory, + cause: new LevelDbFormatError({ detail: "database changed while reading" }), + }); + } + + const prefix = `_${origin}\u0000`; + const items = new Map(); + for (const [id, { key: rawKey, entry }] of entries) { + if (!id.startsWith(prefix) || entry.value === null) continue; + const key = decodeChromiumString(rawKey.subarray(prefix.length)); + const value = decodeChromiumString(entry.value); + if (key !== null && value !== null) items.set(key, value); + } + return items; +}); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 6d53c81f3f1d..ed83b91904e8 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -77,6 +77,10 @@ import { pickBackgroundFolder, readBackgroundFolderImage, } from "./methods/backgroundFolder.ts"; +import { + completeLegacyLocalStorage, + takeLegacyLocalStorage, +} from "./methods/legacyLocalStorage.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { @@ -88,6 +92,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(AppActivationIpc.setReady); yield* ipc.handle(AppActivationIpc.complete); + yield* ipc.handleSync(takeLegacyLocalStorage); + yield* ipc.handle(completeLegacyLocalStorage); yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 1120942ec8af..fa781f6989ac 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -127,3 +127,5 @@ export const PREVIEW_RECORDING_INPUT_CHANNEL = "desktop:preview-recording-input" export const RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:receive-provider-auth-callback"; export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-auth-callback"; +export const TAKE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:take-legacy-local-storage"; +export const COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:complete-legacy-local-storage"; diff --git a/apps/desktop/src/ipc/methods/legacyLocalStorage.ts b/apps/desktop/src/ipc/methods/legacyLocalStorage.ts new file mode 100644 index 000000000000..13f657e7812f --- /dev/null +++ b/apps/desktop/src/ipc/methods/legacyLocalStorage.ts @@ -0,0 +1,30 @@ +import * as Electron from "electron"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopLegacyLocalStorage from "../../app/DesktopLegacyLocalStorage.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; + +export const takeLegacyLocalStorage = makeSyncIpcMethod({ + channel: IpcChannels.TAKE_LEGACY_LOCAL_STORAGE_CHANNEL, + result: Schema.NullOr(Schema.Record(Schema.String, Schema.String)), + handler: Effect.fn("desktop.ipc.legacyLocalStorage.take")(function* () { + const legacy = yield* DesktopLegacyLocalStorage.DesktopLegacyLocalStorage; + return Option.getOrNull(yield* legacy.take); + }), +}); + +export const completeLegacyLocalStorage = makeIpcMethod({ + channel: IpcChannels.COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.legacyLocalStorage.complete")(function* (_, event) { + const legacy = yield* DesktopLegacyLocalStorage.DesktopLegacyLocalStorage; + // Chromium writes localStorage to disk lazily; persist the merge before + // the marker stops later launches from retrying it. + if (event) Electron.webContents.fromId(event.sender.id)?.session.flushStorageData(); + yield* legacy.complete; + }), +}); diff --git a/apps/desktop/src/legacyLocalStorageMerge.test.ts b/apps/desktop/src/legacyLocalStorageMerge.test.ts new file mode 100644 index 000000000000..fd10c1d02019 --- /dev/null +++ b/apps/desktop/src/legacyLocalStorageMerge.test.ts @@ -0,0 +1,102 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { mergeLegacyLocalStorage } from "./legacyLocalStorageMerge.ts"; + +const memoryStorage = (initial: Record = {}) => { + const items = new Map(Object.entries(initial)); + return { + items, + getItem: (key: string) => items.get(key) ?? null, + setItem: (key: string, value: string) => void items.set(key, value), + }; +}; + +const stash = (...ids: string[]) => + JSON.stringify({ + version: 2, + state: { entries: ids.map((id) => ({ id, prompt: `prompt ${id}` })) }, + }); + +const drafts = (version: number, threadKey: string, prompt: string) => + JSON.stringify({ + version, + state: { + draftsByThreadKey: { [threadKey]: { prompt } }, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + }, + }); + +describe("mergeLegacyLocalStorage", () => { + it("copies V1 keys into an empty profile, except the per-install client id", () => { + const storage = memoryStorage(); + mergeLegacyLocalStorage(storage, { + "t3code:theme": "dark", + "t3code:prompt-stash:v2": stash("a"), + "t3.backgroundActivity.clientId": "v1-client", + }); + assert.equal(storage.getItem("t3code:theme"), "dark"); + assert.equal(storage.getItem("t3code:prompt-stash:v2"), stash("a")); + assert.equal(storage.getItem("t3.backgroundActivity.clientId"), null); + }); + + it("keeps V2 values for plain keys", () => { + const storage = memoryStorage({ "t3code:theme": "light" }); + mergeLegacyLocalStorage(storage, { "t3code:theme": "dark" }); + assert.equal(storage.getItem("t3code:theme"), "light"); + }); + + it("appends V1 stash entries after V2's without duplicating ids", () => { + const storage = memoryStorage({ "t3code:prompt-stash:v2": stash("new", "shared") }); + mergeLegacyLocalStorage(storage, { "t3code:prompt-stash:v2": stash("shared", "old") }); + const ids = JSON.parse(storage.getItem("t3code:prompt-stash:v2")!).state.entries.map( + (entry: { id: string }) => entry.id, + ); + assert.deepStrictEqual(ids, ["new", "shared", "old"]); + }); + + it("adds V1 drafts for other threads and keeps V2's draft for the same thread", () => { + const storage = memoryStorage({ + "t3code:composer-drafts:v1": drafts(9, "env:shared", "v2 text"), + }); + const legacy = JSON.parse(drafts(9, "env:shared", "v1 text")); + legacy.state.draftsByThreadKey["env:old"] = { prompt: "v1 only" }; + mergeLegacyLocalStorage(storage, { "t3code:composer-drafts:v1": JSON.stringify(legacy) }); + const merged = JSON.parse(storage.getItem("t3code:composer-drafts:v1")!); + assert.equal(merged.state.draftsByThreadKey["env:shared"].prompt, "v2 text"); + assert.equal(merged.state.draftsByThreadKey["env:old"].prompt, "v1 only"); + }); + + it("leaves V2 drafts alone when the stored shapes differ in version", () => { + const current = drafts(10, "env:a", "v2"); + const storage = memoryStorage({ "t3code:composer-drafts:v1": current }); + mergeLegacyLocalStorage(storage, { "t3code:composer-drafts:v1": drafts(9, "env:b", "v1") }); + assert.equal(storage.getItem("t3code:composer-drafts:v1"), current); + }); + + it("caps the merged stash at the store's entry limit, dropping the oldest V1 entries", () => { + const v2Ids = Array.from({ length: 15 }, (_, index) => `v2-${index}`); + const v1Ids = Array.from({ length: 10 }, (_, index) => `v1-${index}`); + const storage = memoryStorage({ "t3code:prompt-stash:v2": stash(...v2Ids) }); + mergeLegacyLocalStorage(storage, { "t3code:prompt-stash:v2": stash(...v1Ids) }); + const ids = JSON.parse(storage.getItem("t3code:prompt-stash:v2")!).state.entries.map( + (entry: { id: string }) => entry.id, + ); + assert.deepStrictEqual(ids, [...v2Ids, ...v1Ids.slice(0, 5)]); + }); + + it("keeps importing other keys after one write fails, and reports the failure", () => { + const storage = memoryStorage(); + const setItem = storage.setItem; + storage.setItem = (key, value) => { + if (key === "t3code:theme") throw new DOMException("full", "QuotaExceededError"); + setItem(key, value); + }; + const complete = mergeLegacyLocalStorage(storage, { + "t3code:theme": "dark", + "t3code:last-editor": "zed", + }); + assert.equal(storage.getItem("t3code:last-editor"), "zed"); + assert.isFalse(complete); + }); +}); diff --git a/apps/desktop/src/legacyLocalStorageMerge.ts b/apps/desktop/src/legacyLocalStorageMerge.ts new file mode 100644 index 000000000000..5a048e2edd18 --- /dev/null +++ b/apps/desktop/src/legacyLocalStorageMerge.ts @@ -0,0 +1,113 @@ +/** + * Merges V1 profile localStorage items into the V2 profile. Runs in the preload + * before the app reads storage, and never overwrites what V2 already holds: a + * key V2 has written keeps V2's value, except that stashed prompts and unsent + * drafts are merged entry by entry so neither side's are lost. + */ + +interface MergeStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +const PROMPT_STASH_KEY = "t3code:prompt-stash:v2"; +const COMPOSER_DRAFTS_KEY = "t3code:composer-drafts:v1"; +const NOT_IMPORTED_KEYS = new Set([ + // Identifies one running install to the server; two apps must not share it. + "t3.backgroundActivity.clientId", + // Resumes a permission setup V1 left mid-flow; V2 would redirect to it on boot. + "t3code:snap-shot-setup-resume:v1", +]); +/** Matches MAX_STASH_ENTRIES in apps/web/src/promptStashStore.ts. */ +const MAX_STASH_ENTRIES = 20; +const DRAFT_RECORD_FIELDS = [ + "draftsByThreadKey", + "draftThreadsByThreadKey", + "logicalProjectDraftThreadKeyByLogicalProjectKey", +] as const; + +type JsonObject = Record; + +const isObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); + +function parseObject(raw: string): JsonObject | null { + try { + const parsed: unknown = JSON.parse(raw); + return isObject(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** V2 entries first, then V1 entries V2 does not already have, up to the stash cap. */ +function mergePromptStash(current: JsonObject, legacy: JsonObject): JsonObject | null { + const currentEntries = isObject(current.state) ? current.state.entries : null; + const legacyEntries = isObject(legacy.state) ? legacy.state.entries : null; + if (!Array.isArray(currentEntries) || !Array.isArray(legacyEntries)) return null; + if (current.version !== legacy.version) return null; + const ids = new Set(currentEntries.map((entry) => (isObject(entry) ? entry.id : undefined))); + const added = legacyEntries.filter((entry) => isObject(entry) && !ids.has(entry.id)); + const state = isObject(current.state) ? current.state : {}; + return { + ...current, + state: { ...state, entries: [...currentEntries, ...added].slice(0, MAX_STASH_ENTRIES) }, + }; +} + +/** Adds V1 drafts for threads V2 has no draft for. Shapes must share a version. */ +function mergeComposerDrafts(current: JsonObject, legacy: JsonObject): JsonObject | null { + if (current.version !== legacy.version) return null; + if (!isObject(current.state) || !isObject(legacy.state)) return null; + const state: JsonObject = { ...current.state }; + for (const field of DRAFT_RECORD_FIELDS) { + const currentRecord = state[field]; + const legacyRecord = legacy.state[field]; + if (!isObject(currentRecord) || !isObject(legacyRecord)) return null; + state[field] = { ...legacyRecord, ...currentRecord }; + } + return { ...current, state }; +} + +const isMergedKey = (key: string) => key === PROMPT_STASH_KEY || key === COMPOSER_DRAFTS_KEY; + +function mergeValue(key: string, current: string, legacy: string): string | null { + const merge = + key === PROMPT_STASH_KEY + ? mergePromptStash + : key === COMPOSER_DRAFTS_KEY + ? mergeComposerDrafts + : null; + if (merge === null) return null; + const currentObject = parseObject(current); + const legacyObject = parseObject(legacy); + if (currentObject === null || legacyObject === null) return null; + const merged = merge(currentObject, legacyObject); + return merged === null ? null : JSON.stringify(merged); +} + +/** Returns false when a write failed, so the caller can retry on a later launch. */ +export function mergeLegacyLocalStorage( + storage: MergeStorage, + legacyItems: Readonly>, +): boolean { + let complete = true; + // Stash and drafts first, so a full quota costs layout state rather than prompts. + const keys = Object.keys(legacyItems).sort( + (left, right) => Number(isMergedKey(right)) - Number(isMergedKey(left)), + ); + for (const key of keys) { + if (NOT_IMPORTED_KEYS.has(key)) continue; + const legacy = legacyItems[key]!; + const current = storage.getItem(key); + const next = current === null ? legacy : mergeValue(key, current, legacy); + if (next === null || next === current) continue; + try { + storage.setItem(key, next); + } catch { + // Over quota: skip this key rather than abandon the rest. + complete = false; + } + } + return complete; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index bd79327e8fc9..9f961df45c44 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -62,7 +62,9 @@ import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; +import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts"; import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; +import * as DesktopRendererHistory from "./telemetry/DesktopRendererHistory.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts"; @@ -142,6 +144,7 @@ const desktopFoundationLayer = Layer.mergeAll( MacPermissions.layer, DesktopState.layer, DesktopShutdown.layer, + DesktopLegacyLocalStorage.layer, DesktopAppSettings.layer, DesktopClientSettings.layer, DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), @@ -149,6 +152,7 @@ const desktopFoundationLayer = Layer.mergeAll( DesktopDictationKey.layer, MacFnKeySetup.layer, DesktopObservability.layer, + DesktopRendererHistory.layer, ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); const desktopSshLayer = desktopSshEnvironmentLayer.pipe( diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 0705504971c4..9fdb2892c077 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -10,6 +10,7 @@ import { exposeClerkBridge } from "@clerk/electron/preload"; import { contextBridge, ipcRenderer, webFrame, webUtils } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; +import { mergeLegacyLocalStorage } from "./legacyLocalStorageMerge.ts"; const SNAP_SHOT_EVENT_TYPES = new Set([ "requested", @@ -30,6 +31,18 @@ function isSnapShotEvent(value: unknown): value is DesktopSnapShotEvent { exposeClerkBridge({ passkeys: true }); +// Runs before any app script reads localStorage. See DesktopLegacyLocalStorage. +try { + const legacyItems: unknown = ipcRenderer.sendSync(IpcChannels.TAKE_LEGACY_LOCAL_STORAGE_CHANNEL); + if (typeof legacyItems === "object" && legacyItems !== null) { + if (mergeLegacyLocalStorage(window.localStorage, legacyItems as Record)) { + void ipcRenderer.invoke(IpcChannels.COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL); + } + } +} catch { + // Best effort: the app still starts on the V2 profile's own storage. +} + // oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process. const clientPlatform = process.platform; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 9adaeaaa448b..0a949d39b5a4 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -22,6 +22,7 @@ import { TestClock } from "effect/testing"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as BrowserSession from "./BrowserSession.ts"; import * as PreviewManager from "./Manager.ts"; @@ -267,6 +268,13 @@ const fileSystemLayer = FileSystem.layerNoop({ }); const layer = PreviewManager.layer.pipe( + Layer.provideMerge( + Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, { + register: () => Effect.void, + recordMetrics: () => Effect.void, + shutdown: Effect.void, + }), + ), Layer.provideMerge(browserSessionLayer), Layer.provideMerge(environmentLayer), Layer.provideMerge(fileSystemLayer), @@ -4613,6 +4621,88 @@ describe("PreviewManager", () => { ), ); + effectIt.effect( + "releases evaluation objects after success, exceptions, takeover, and cancellation", + () => + withManager((manager) => + Effect.gen(function* () { + const retained = new Map(); + const pendingEvaluation = yield* Deferred.make(); + const lateRelease = yield* Deferred.make(); + let finishEvaluation: (() => void) | undefined; + let cancelledGroup: string | undefined; + let humanInput: ((event: unknown, signal: unknown) => void) | undefined; + const wc = makeTestPreviewWebContents(vi.fn()); + Object.assign(wc, { isDevToolsOpened: () => false }); + Object.assign(wc.ipc, { + on: vi.fn((channel: string, listener: typeof humanInput) => { + if (channel === "preview:human-input") humanInput = listener; + }), + }); + Object.assign(wc.debugger, { + sendCommand: vi.fn(async (method: string, params?: Record) => { + const group = String(params?.objectGroup ?? ""); + if (method === "Runtime.releaseObjectGroup") { + retained.delete(group); + if (group === cancelledGroup) Deferred.doneUnsafe(lateRelease, Effect.void); + } + if (method !== "Runtime.evaluate") return undefined; + if (params?.expression === "cancelled") { + cancelledGroup = group; + Deferred.doneUnsafe(pendingEvaluation, Effect.void); + await new Promise((resolve) => { + finishEvaluation = resolve; + }); + } + retained.set(group, 2); + if (params?.expression === "exception") { + return { + result: { objectId: "exception-result" }, + exceptionDetails: { + text: "failure", + exception: { objectId: "exception-detail" }, + }, + }; + } + if (params?.expression === "takeover") { + humanInput?.({}, { kind: "pointer", x: 1, y: 2, button: 0 }); + } + return { result: { value: 42 } }; + }), + }); + fromId.mockReturnValue(wc); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + + expect(yield* manager.automationEvaluate("tab_1", { expression: "success" })).toBe(42); + expect(retained.size).toBe(0); + const failure = yield* Effect.exit( + manager.automationEvaluate("tab_1", { expression: "exception" }), + ); + expect(Exit.isFailure(failure)).toBe(true); + expect(retained.size).toBe(0); + const takeover = yield* Effect.exit( + manager.automationEvaluate("tab_1", { expression: "takeover" }), + ); + expect(Exit.isFailure(takeover)).toBe(true); + if (Exit.isFailure(takeover)) { + expect(Option.getOrThrow(Cause.findErrorOption(takeover.cause))).toMatchObject({ + _tag: "PreviewAutomationControlInterruptedError", + }); + } + expect(retained.size).toBe(0); + const cancelled = yield* manager + .automationEvaluate("tab_1", { expression: "cancelled" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(pendingEvaluation); + yield* Fiber.interrupt(cancelled); + finishEvaluation?.(); + yield* Deferred.await(lateRelease); + expect(retained.size).toBe(0); + }), + ), + ); + effectIt.effect("derives evaluation detail kind and length from the same non-empty source", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index f7b5046a7fed..3593ae5f5df6 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -58,6 +58,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -66,6 +67,7 @@ import * as Scope from "effect/Scope"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; import * as BrowserSession from "./BrowserSession.ts"; import { @@ -638,6 +640,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function pictureInPicturePreloadPath: string, ) { const fileSystem = yield* FileSystem.FileSystem; + const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; const hostPlatform = yield* HostProcessPlatform; const path = yield* Path.Path; const parentScope = yield* Scope.Scope; @@ -1332,6 +1335,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const semaphore = yield* Semaphore.make(1); const scope = yield* Scope.fork(parentScope, "sequential"); const wcDebugger = wc.debugger; + const consoleReleases = yield* Queue.sliding(1); + // Console message eviction does not release the debugger's strong + // object handles. We keep text only, so release the whole group, + // including its object-id bookkeeping. Coalesce bursts behind one + // command rather than queueing a command for every logged object. + yield* Effect.forkIn( + Effect.forever( + Queue.take(consoleReleases).pipe( + Effect.andThen( + attemptPromise({ operation: "releaseConsoleObjects", webContentsId: wc.id }, () => + wcDebugger.sendCommand("Runtime.releaseObjectGroup", { + objectGroup: "console", + }), + ).pipe(Effect.ignore), + ), + ), + ), + scope, + ); const handleDebuggerMessage = Effect.fnUntraced(function* ( method: string, params: Record, @@ -1378,6 +1400,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } } yield* captureDiagnosticMessage(wc.id, method, params); + if (method === "Runtime.consoleAPICalled" || method === "Runtime.exceptionThrown") { + yield* Queue.offer(consoleReleases, undefined); + } }); const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { runFork(handleDebuggerMessage(method, params)); @@ -1540,10 +1565,31 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* checkControl; const result = yield* attemptPromise( { operation: `${action}.${method}`, tabId, webContentsId: wc.id }, - () => - sessionId === undefined - ? control.debugger.sendCommand(method, commandParams) - : control.debugger.sendCommand(method, commandParams, sessionId), + async () => { + try { + return await (sessionId === undefined + ? control.debugger.sendCommand(method, commandParams) + : control.debugger.sendCommand(method, commandParams, sessionId)); + } finally { + const objectGroup = + method === "Runtime.evaluate" ? commandParams?.["objectGroup"] : undefined; + // Cancelling an Effect does not cancel the CDP promise. Release + // after it settles, even if the caller has already left, so a + // late result cannot recreate handles after an early cleanup. + if (typeof objectGroup === "string" && objectGroup.startsWith("t3-evaluation-")) { + const params = { objectGroup }; + await ( + sessionId === undefined + ? control.debugger.sendCommand("Runtime.releaseObjectGroup", params) + : control.debugger.sendCommand( + "Runtime.releaseObjectGroup", + params, + sessionId, + ) + ).catch(() => undefined); + } + } + }, ); yield* checkControl; return result; @@ -1611,28 +1657,32 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function returnByValue: boolean, awaitPromise = true, ): Effect.Effect => - send("Runtime.evaluate", { - expression, - awaitPromise, - returnByValue, - userGesture: true, - }).pipe( - Effect.flatMap((rawResponse) => { - const response = rawResponse as CdpEvaluationResult; - if (!response.exceptionDetails) { - return Effect.succeed(response.result?.value as A); - } - const detail = previewAutomationEvaluationDetail(response.exceptionDetails); - return Effect.fail( - new PreviewAutomationEvaluationError({ - tabId, - detailKind: detail.detailKind, - detailLength: detail.detail?.length ?? 0, - cause: response.exceptionDetails, - }), - ); - }), - ); + Effect.suspend(() => { + const objectGroup = `t3-evaluation-${NodeCrypto.randomUUID()}`; + return send("Runtime.evaluate", { + expression, + awaitPromise, + returnByValue, + userGesture: true, + objectGroup, + }).pipe( + Effect.flatMap((rawResponse) => { + const response = rawResponse as CdpEvaluationResult; + if (!response.exceptionDetails) { + return Effect.succeed(response.result?.value as A); + } + const detail = previewAutomationEvaluationDetail(response.exceptionDetails); + return Effect.fail( + new PreviewAutomationEvaluationError({ + tabId, + detailKind: detail.detailKind, + detailLength: detail.detail?.length ?? 0, + cause: response.exceptionDetails, + }), + ); + }), + ); + }); const automationLocator = (input: { readonly selector?: string | undefined; @@ -2281,6 +2331,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId }); } + yield* rendererHistory.register(wc, { surface: "preview", tabId }); const attached = yield* Ref.get(attachedRef); const annotationTheme = yield* Ref.get(annotationThemeRef); const currentAttachment = attached.get(webContentsId); @@ -2435,6 +2486,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* ( wc: Electron.WebContents, ) { + yield* rendererHistory.register(wc, { surface: "preview" }); const webContentsId = wc.id; // A guest destroyed before any tab claims it has no other cleanup path. wc.once("destroyed", () => { @@ -3314,6 +3366,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }, }), ); + yield* rendererHistory.register(pictureInPictureWindow.webContents, { + surface: "picture-in-picture", + tabId, + }); const initializationScope = yield* Scope.fork(parentScope, "sequential"); const ready = yield* Deferred.make(); const session: PictureInPictureSession = { diff --git a/apps/desktop/src/telemetry/DesktopRendererHistory.ts b/apps/desktop/src/telemetry/DesktopRendererHistory.ts new file mode 100644 index 000000000000..1d65d5c56f8d --- /dev/null +++ b/apps/desktop/src/telemetry/DesktopRendererHistory.ts @@ -0,0 +1,283 @@ +import type * as Electron from "electron"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { makeRotatingLogFileWriter } from "../app/DesktopObservability.ts"; + +export interface RendererIdentity { + readonly surface: "main" | "preview" | "splash" | "picture-in-picture" | "devtools"; + readonly tabId?: string; +} + +export class DesktopRendererHistory extends Context.Service< + DesktopRendererHistory, + { + readonly register: ( + webContents: Electron.WebContents, + identity: RendererIdentity, + ) => Effect.Effect; + readonly recordMetrics: (metrics: ReadonlyArray) => Effect.Effect; + readonly shutdown: Effect.Effect; + } +>()("@t3tools/desktop/telemetry/DesktopRendererHistory") {} + +interface RendererMemory { + readonly sampledAtUnixMs: number; + // Electron reports process-wide working sets in KiB, not JavaScript heaps. + readonly kind: "electron-process-working-set"; + readonly workingSetBytes: number; + readonly peakWorkingSetBytes: number; +} + +interface RendererRecord { + readonly version: 1; + readonly mainPid: number; + readonly mainSessionStartedAtUnixMs: number; + readonly timestampUnixMs: number; + readonly event: + | "created" + | "identified" + | "dom-ready" + | "sample" + | "render-process-gone" + | "destroyed"; + readonly webContentsId: number; + readonly surface: RendererIdentity["surface"]; + readonly tabId?: string; + readonly rendererPid: number | null; + readonly rendererPidSource: "current" | "last-known" | "unavailable"; + readonly rendererCreationTimeMs: number | null; + readonly memory: RendererMemory | null; + readonly reason?: Electron.RenderProcessGoneDetails["reason"]; + readonly exitCode?: number; +} + +interface TrackedRenderer { + readonly webContents: Electron.WebContents; + identity: RendererIdentity; + pid: number | null; + creationTimeMs: number | null; + memory: RendererMemory | null; + gone: boolean; + readonly removeListeners: () => void; +} + +const nonnegative = (value: number): number | null => + Number.isFinite(value) && value >= 0 ? Math.round(value) : null; + +/** Small restart-surviving PID history; never reads URLs or page content. */ +const make = Effect.fn("DesktopRendererHistory.make")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const path = yield* Path.Path; + const clock = yield* Clock.Clock; + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + const mainSessionStartedAtUnixMs = clock.currentTimeMillisUnsafe(); + const records = yield* Queue.sliding(256); + const active = new Map(); + let accepting = true; + const writer = yield* makeRotatingLogFileWriter({ + filePath: path.join(environment.logDir, "renderer-history.ndjson"), + maxBytes: 256 * 1024, + maxFiles: 2, + }).pipe( + Effect.catch(() => + Effect.logWarning("Renderer incident history could not be opened.").pipe( + Effect.as({ writeText: (_chunk: string) => Effect.void }), + ), + ), + ); + + const writeRecord = (record: RendererRecord) => + writer.writeText(`${JSON.stringify(record)}\n`).pipe(Effect.ignore); + const writerFiber = yield* Effect.forever( + Effect.uninterruptibleMask((restore) => + restore(Queue.take(records)).pipe(Effect.flatMap(writeRecord)), + ).pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + () => Effect.void, + ), + ), + ).pipe( + Effect.ensuring( + Effect.gen(function* () { + while (Queue.sizeUnsafe(records) > 0) { + const pending = yield* Queue.takeAll(records); + yield* Effect.forEach(pending, writeRecord, { discard: true }); + } + }), + ), + Effect.forkScoped, + ); + + const readPid = (renderer: TrackedRenderer): RendererRecord["rendererPidSource"] => { + try { + if (!renderer.gone && !renderer.webContents.isDestroyed()) { + const pid = renderer.webContents.getOSProcessId(); + if (Number.isInteger(pid) && pid > 0) { + if (pid !== renderer.pid) { + renderer.pid = pid; + renderer.creationTimeMs = null; + renderer.memory = null; + } + return "current"; + } + } + } catch { + // Electron can remove a guest before its destruction callback runs. + } + return renderer.pid === null ? "unavailable" : "last-known"; + }; + const record = ( + renderer: TrackedRenderer, + event: RendererRecord["event"], + details?: Electron.RenderProcessGoneDetails, + ): void => { + const rendererPidSource = readPid(renderer); + Queue.offerUnsafe(records, { + version: 1, + mainPid: process.pid, + mainSessionStartedAtUnixMs, + timestampUnixMs: clock.currentTimeMillisUnsafe(), + event, + webContentsId: renderer.webContents.id, + ...renderer.identity, + rendererPid: renderer.pid, + rendererPidSource, + rendererCreationTimeMs: renderer.creationTimeMs, + memory: renderer.memory, + ...(details === undefined + ? {} + : { + reason: details.reason, + ...(Number.isFinite(details.exitCode) + ? { exitCode: Math.round(details.exitCode) } + : {}), + }), + }); + }; + + const register = (webContents: Electron.WebContents, identity: RendererIdentity) => + Effect.sync(() => { + if (!accepting || webContents.isDestroyed()) return; + const boundedIdentity: RendererIdentity = { + surface: identity.surface, + ...(identity.tabId === undefined ? {} : { tabId: identity.tabId.slice(0, 128) }), + }; + const existing = active.get(webContents.id); + if (existing !== undefined) { + if ( + existing.identity.surface !== boundedIdentity.surface || + existing.identity.tabId !== boundedIdentity.tabId + ) { + existing.identity = boundedIdentity; + record(existing, "identified"); + } + return; + } + const onDomReady = () => { + if (renderer.gone) { + renderer.pid = null; + renderer.creationTimeMs = null; + renderer.memory = null; + renderer.gone = false; + } + record(renderer, "dom-ready"); + }; + const onDevToolsOpened = () => { + const tools = webContents.devToolsWebContents; + if (tools) + runFork( + register(tools, { + surface: "devtools", + ...(renderer.identity.tabId === undefined ? {} : { tabId: renderer.identity.tabId }), + }), + ); + }; + const onGone = (_event: Electron.Event, details: Electron.RenderProcessGoneDetails) => { + readPid(renderer); + renderer.gone = true; + record(renderer, "render-process-gone", details); + }; + const onDestroyed = () => { + record(renderer, "destroyed"); + active.delete(webContents.id); + renderer.removeListeners(); + }; + const renderer: TrackedRenderer = { + webContents, + identity: boundedIdentity, + pid: null, + creationTimeMs: null, + memory: null, + gone: false, + removeListeners: () => { + webContents.removeListener("dom-ready", onDomReady); + webContents.removeListener("devtools-opened", onDevToolsOpened); + webContents.removeListener("render-process-gone", onGone); + webContents.removeListener("destroyed", onDestroyed); + }, + }; + active.set(webContents.id, renderer); + webContents.on("dom-ready", onDomReady); + webContents.on("devtools-opened", onDevToolsOpened); + webContents.on("render-process-gone", onGone); + webContents.once("destroyed", onDestroyed); + record(renderer, "created"); + }).pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + () => Effect.void, + ), + ); + + let lastSampledAtUnixMs = -Infinity; + const recordMetrics = (metrics: ReadonlyArray) => + Effect.sync(() => { + if (!accepting) return; + const sampledAtUnixMs = clock.currentTimeMillisUnsafe(); + if (sampledAtUnixMs - lastSampledAtUnixMs < 30_000) return; + lastSampledAtUnixMs = sampledAtUnixMs; + const byPid = new Map(metrics.map((metric) => [metric.pid, metric])); + for (const renderer of active.values()) { + const pidSource = readPid(renderer); + // A last-known PID may already have been reused by another process. + const metric = + pidSource !== "current" || renderer.pid === null ? undefined : byPid.get(renderer.pid); + if (metric !== undefined) { + renderer.creationTimeMs = nonnegative(metric.creationTime); + renderer.memory = { + sampledAtUnixMs, + kind: "electron-process-working-set", + workingSetBytes: nonnegative(metric.memory.workingSetSize * 1024) ?? 0, + peakWorkingSetBytes: nonnegative(metric.memory.peakWorkingSetSize * 1024) ?? 0, + }; + } + // A process can host multiple contents; these rows must not be summed. + record(renderer, "sample"); + } + }).pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + () => Effect.void, + ), + ); + const shutdown = Effect.sync(() => { + accepting = false; + for (const renderer of active.values()) renderer.removeListeners(); + active.clear(); + }).pipe(Effect.andThen(Fiber.interrupt(writerFiber)), Effect.asVoid); + yield* Effect.addFinalizer(() => shutdown); + + return DesktopRendererHistory.of({ register, recordMetrics, shutdown }); +}); + +export const layer = Layer.effect(DesktopRendererHistory, make()); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index cef90b29874b..14403f8c6097 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -1,10 +1,12 @@ import { DesktopHostTelemetryMessage } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -14,10 +16,19 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import type * as Electron from "electron"; +import * as NodeEvents from "node:events"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts"; import * as DesktopTelemetryPublisher from "./DesktopTelemetryPublisher.ts"; +import * as DesktopRendererHistory from "./DesktopRendererHistory.ts"; + +const historyLayer = Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, { + register: () => Effect.void, + recordMetrics: () => Effect.void, + shutdown: Effect.void, +}); function makeElectronAppLayer( metrics: ReadonlyArray, @@ -70,7 +81,7 @@ describe("DesktopTelemetryPublisher", () => { }), ); const layer = DesktopTelemetryPublisher.layer.pipe( - Layer.provide(Layer.mergeAll(makeElectronAppLayer([]), powerLayer)), + Layer.provide(Layer.mergeAll(makeElectronAppLayer([]), powerLayer, historyLayer)), ); const scope = yield* Scope.make(); @@ -91,6 +102,7 @@ describe("DesktopTelemetryPublisher", () => { const systemIdleState = yield* Ref.make("active"); let beforeSystemIdleState: Effect.Effect = Effect.void; let metricsReadCount = 0; + const recordedMetrics: ReadonlyArray[] = []; const simpleListeners = new Map void>(); let thermalListener: ((state: ElectronPowerMonitor.ElectronThermalState) => void) | null = null; @@ -141,6 +153,14 @@ describe("DesktopTelemetryPublisher", () => { metricsReadCount += 1; }), powerLayer, + Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, { + register: () => Effect.void, + shutdown: Effect.void, + recordMetrics: (sample) => + Effect.sync(() => { + recordedMetrics.push(sample); + }), + }), ), ), ); @@ -167,7 +187,8 @@ describe("DesktopTelemetryPublisher", () => { } assert.deepEqual(initialSnapshot.electronProcesses, []); assert.equal(initialSnapshot.electronPid, process.pid); - assert.equal(metricsReadCount, 0); + assert.equal(metricsReadCount, 1); + assert.deepEqual(recordedMetrics, [metrics]); const nextSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(Effect.forkChild); yield* Effect.yieldNow; @@ -181,7 +202,8 @@ describe("DesktopTelemetryPublisher", () => { assert.equal(demandedSnapshot.electronProcesses[0]?.creationTimeMs, 1_001); assert.equal(demandedSnapshot.electronProcesses[0]?.cpuPercent, 12.5); assert.equal(demandedSnapshot.electronProcesses[0]?.workingSetBytes, 2_048 * 1_024); - assert.equal(metricsReadCount, 1); + assert.equal(metricsReadCount, 2); + assert.deepEqual(recordedMetrics, [metrics, metrics]); yield* publisher.handleControlForSource("secondary-backend", { version: 1, type: "setDiagnosticsDemand", @@ -322,13 +344,13 @@ describe("DesktopTelemetryPublisher", () => { (yield* publisher.latest).pipe(Option.getOrThrow).sequence, configuredSequence, ); - assert.equal(metricsReadCount, metricsAfterStopping); + assert.equal(metricsReadCount, metricsAfterStopping + 1); yield* TestClock.adjust(Duration.millis(1)); assert.equal( (yield* publisher.latest).pipe(Option.getOrThrow).sequence, configuredSequence + 1, ); - assert.equal(metricsReadCount, metricsAfterStopping); + assert.equal(metricsReadCount, metricsAfterStopping + 2); yield* Ref.set(systemIdleState, "locked"); yield* TestClock.adjust(Duration.seconds(7)); @@ -404,7 +426,7 @@ describe("DesktopTelemetryPublisher", () => { }), ); const layer = DesktopTelemetryPublisher.layer.pipe( - Layer.provide(Layer.mergeAll(makeElectronAppLayer([]), powerLayer)), + Layer.provide(Layer.mergeAll(makeElectronAppLayer([]), powerLayer, historyLayer)), ); yield* Effect.gen(function* () { @@ -466,3 +488,303 @@ describe("DesktopTelemetryPublisher", () => { }), ); }); + +describe("DesktopRendererHistory", () => { + it.effect( + "persists surface identity and memory through renderer replacement and destruction", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-renderer-history-", + }); + let previewPid = 7_002; + let previewDestroyed = false; + let urlReads = 0; + const main = Object.assign(new NodeEvents.EventEmitter(), { + id: 1, + getOSProcessId: () => 7_001, + isDestroyed: () => false, + getURL: () => { + urlReads += 1; + return "https://private.invalid/main?secret=private"; + }, + }); + const preview = Object.assign(new NodeEvents.EventEmitter(), { + id: 2, + getOSProcessId: () => previewPid, + isDestroyed: () => previewDestroyed, + getURL: () => { + urlReads += 1; + return "https://private.invalid/preview?secret=private"; + }, + }); + const metrics = [7_001, 7_002].map( + (pid) => + ({ + pid, + type: "Tab", + creationTime: pid * 100, + memory: { workingSetSize: 2_048, peakWorkingSetSize: 4_096 }, + cpu: { percentCPUUsage: 0, cumulativeCPUUsage: 0, idleWakeupsPerSecond: 0 }, + }) satisfies Electron.ProcessMetric, + ); + const recordSchema = Schema.fromJsonString( + Schema.Struct({ + event: Schema.String, + mainPid: Schema.Number, + mainSessionStartedAtUnixMs: Schema.Number, + timestampUnixMs: Schema.Number, + webContentsId: Schema.Number, + surface: Schema.String, + tabId: Schema.optional(Schema.String), + rendererPid: Schema.NullOr(Schema.Number), + rendererPidSource: Schema.String, + rendererCreationTimeMs: Schema.NullOr(Schema.Number), + memory: Schema.NullOr( + Schema.Struct({ + kind: Schema.String, + sampledAtUnixMs: Schema.Number, + workingSetBytes: Schema.Number, + peakWorkingSetBytes: Schema.Number, + }), + ), + reason: Schema.optional(Schema.String), + exitCode: Schema.optional(Schema.Number), + }), + ); + const decodeRecord = Schema.decodeUnknownEffect(recordSchema); + type Record = typeof recordSchema.Type; + const persisted: Record[] = []; + let milestone: + | { + readonly matches: (record: Record) => boolean; + readonly written: Deferred.Deferred; + } + | undefined; + const fileLayer = Layer.succeed(FileSystem.FileSystem, { + ...fileSystem, + writeFile: (filePath, bytes, options) => + fileSystem.writeFile(filePath, bytes, options).pipe( + Effect.tap(() => + decodeRecord(new TextDecoder().decode(bytes).trim()).pipe( + Effect.orDie, + Effect.flatMap((record) => + Effect.sync(() => { + persisted.push(record); + if (milestone?.matches(record)) { + Deferred.doneUnsafe(milestone.written, Effect.succeed(record)); + } + }), + ), + ), + ), + ), + }); + const layer = DesktopRendererHistory.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of({ + logDir: directory, + } as DesktopEnvironment.DesktopEnvironment["Service"]), + ), + fileLayer, + ), + ), + ); + + yield* Effect.gen(function* () { + const history = yield* DesktopRendererHistory.DesktopRendererHistory; + yield* history.register(main as unknown as Electron.WebContents, { surface: "main" }); + yield* history.register(preview as unknown as Electron.WebContents, { + surface: "preview", + }); + yield* history.register(preview as unknown as Electron.WebContents, { + surface: "preview", + tabId: "preview-tab", + }); + const sampled = yield* Deferred.make(); + milestone = { + matches: (row) => row.event === "sample" && row.webContentsId === 2, + written: sampled, + }; + yield* TestClock.adjust(Duration.seconds(30)); + yield* history.recordMetrics(metrics); + const sample = yield* Deferred.await(sampled); + assert.equal(sample.rendererPid, 7_002); + assert.equal(sample.rendererCreationTimeMs, 700_200); + assert.equal(sample.tabId, "preview-tab"); + assert.equal(sample.mainPid, process.pid); + assert.equal(sample.memory?.kind, "electron-process-working-set"); + assert.equal(sample.memory?.workingSetBytes, 2_048 * 1_024); + assert.equal(sample.memory?.peakWorkingSetBytes, 4_096 * 1_024); + assert.equal(sample.memory?.sampledAtUnixMs, sample.timestampUnixMs); + assert.isTrue( + persisted.some( + (row) => + row.event === "created" && row.surface === "main" && row.rendererPid === 7_001, + ), + ); + assert.isTrue( + persisted.some((row) => row.event === "identified" && row.tabId === "preview-tab"), + ); + + const replaced = yield* Deferred.make(); + milestone = { matches: (row) => row.event === "dom-ready", written: replaced }; + previewPid = 7_003; + preview.emit("dom-ready"); + const replacement = yield* Deferred.await(replaced); + assert.equal(replacement.rendererPid, 7_003); + assert.isNull(replacement.rendererCreationTimeMs); + assert.isNull(replacement.memory); + metrics[1] = { ...metrics[1]!, pid: 7_003, creationTime: 700_300 }; + const replacementSampled = yield* Deferred.make(); + milestone = { + matches: (row) => row.event === "sample" && row.webContentsId === 2, + written: replacementSampled, + }; + yield* TestClock.adjust(Duration.seconds(30)); + yield* history.recordMetrics(metrics); + assert.equal((yield* Deferred.await(replacementSampled)).rendererCreationTimeMs, 700_300); + + const crashed = yield* Deferred.make(); + milestone = { matches: (row) => row.event === "render-process-gone", written: crashed }; + previewPid = 0; + preview.emit("render-process-gone", {}, { reason: "oom", exitCode: -7 }); + const crash = yield* Deferred.await(crashed); + assert.equal(crash.rendererPid, 7_003); + assert.equal(crash.rendererPidSource, "last-known"); + assert.equal(crash.reason, "oom"); + assert.equal(crash.exitCode, -7); + // A restarted renderer can reuse the same numeric PID. + const restarted = yield* Deferred.make(); + milestone = { matches: (row) => row.event === "dom-ready", written: restarted }; + previewPid = 7_003; + preview.emit("dom-ready"); + const restart = yield* Deferred.await(restarted); + assert.equal(restart.rendererPid, 7_003); + assert.equal(restart.rendererPidSource, "current"); + assert.isNull(restart.rendererCreationTimeMs); + assert.isNull(restart.memory); + const destroyed = yield* Deferred.make(); + milestone = { matches: (row) => row.event === "destroyed", written: destroyed }; + previewDestroyed = true; + preview.emit("destroyed"); + const destruction = yield* Deferred.await(destroyed); + assert.equal(destruction.rendererPid, 7_003); + assert.isNull(destruction.rendererCreationTimeMs); + assert.equal(destruction.tabId, "preview-tab"); + assert.equal(preview.eventNames().length, 0); + const afterDestroy = yield* Deferred.make(); + milestone = { matches: (row) => row.event === "sample", written: afterDestroy }; + const previousCount = persisted.length; + yield* TestClock.adjust(Duration.seconds(30)); + yield* history.recordMetrics(metrics); + assert.equal((yield* Deferred.await(afterDestroy)).webContentsId, 1); + assert.isFalse(persisted.slice(previousCount).some((row) => row.webContentsId === 2)); + // The app quit handshake drains before the history layer closes. + for (let index = 0; index < 12; index++) main.emit("dom-ready"); + yield* history.shutdown; + assert.equal( + persisted.filter((row) => row.webContentsId === 1 && row.event === "dom-ready").length, + 12, + ); + const afterShutdown = persisted.length; + yield* history.register(main as unknown as Electron.WebContents, { surface: "main" }); + yield* history.recordMetrics(metrics); + assert.equal(persisted.length, afterShutdown); + assert.equal(main.eventNames().length, 0); + yield* history.shutdown; + }).pipe(Effect.provide(layer)); + + assert.equal(main.eventNames().length, 0); + assert.equal( + persisted.filter((row) => row.webContentsId === 1 && row.event === "dom-ready").length, + 12, + ); + assert.equal(urlReads, 0); + const contents = yield* fileSystem.readFileString(`${directory}/renderer-history.ndjson`); + assert.isFalse(contents.includes("private.invalid")); + assert.isFalse(contents.includes("secret")); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("rotates retained incident history within three 256 kib files", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-renderer-rotation-", + }); + const filePath = `${directory}/renderer-history.ndjson`; + const maxBytes = 256 * 1_024; + const encodeSeed = Schema.encodeEffect( + Schema.fromJsonString(Schema.Struct({ seed: Schema.String })), + ); + for (const [suffix, seed] of [ + ["", "current"], + [".1", "previous"], + [".2", "expired"], + [".3", "overflow"], + ] as const) { + const encodedSeed = yield* encodeSeed({ seed }); + yield* fileSystem.writeFileString( + `${filePath}${suffix}`, + `${encodedSeed.padEnd(maxBytes - 1, " ")}\n`, + ); + } + const written = yield* Deferred.make(); + const fileLayer = Layer.succeed(FileSystem.FileSystem, { + ...fileSystem, + writeFile: (target, bytes, options) => + fileSystem + .writeFile(target, bytes, options) + .pipe(Effect.tap(() => Deferred.succeed(written, undefined))), + }); + const layer = DesktopRendererHistory.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of({ + logDir: directory, + } as DesktopEnvironment.DesktopEnvironment["Service"]), + ), + fileLayer, + ), + ), + ); + const renderer = Object.assign(new NodeEvents.EventEmitter(), { + id: 7, + getOSProcessId: () => 9_001, + isDestroyed: () => false, + }); + yield* Effect.gen(function* () { + const history = yield* DesktopRendererHistory.DesktopRendererHistory; + yield* history.register(renderer as unknown as Electron.WebContents, { + surface: "preview", + tabId: "t".repeat(1_000), + }); + yield* Deferred.await(written); + }).pipe(Effect.provide(layer)); + assert.deepEqual((yield* fileSystem.readDirectory(directory)).sort(), [ + "renderer-history.ndjson", + "renderer-history.ndjson.1", + "renderer-history.ndjson.2", + ]); + let totalBytes = 0; + for (const suffix of ["", ".1", ".2"]) { + const size = Number((yield* fileSystem.stat(`${filePath}${suffix}`)).size); + assert.isAtMost(size, maxBytes); + totalBytes += size; + } + assert.isAtMost(totalBytes, 3 * maxBytes); + assert.include(yield* fileSystem.readFileString(`${filePath}.1`), '"seed":"current"'); + assert.include(yield* fileSystem.readFileString(`${filePath}.2`), '"seed":"previous"'); + const current = yield* fileSystem.readFileString(filePath); + assert.include(current, `"tabId":"${"t".repeat(128)}"`); + assert.notInclude(current, "t".repeat(129)); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index a191a0d586ae..c0369e702fa1 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -23,6 +23,7 @@ import * as Stream from "effect/Stream"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts"; +import * as DesktopRendererHistory from "./DesktopRendererHistory.ts"; const LIVE_SAMPLE_INTERVAL = Duration.seconds(1); const BATTERY_SAMPLE_INTERVAL = Duration.seconds(5); @@ -148,6 +149,7 @@ function sampleInterval( export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { const electronApp = yield* ElectronApp.ElectronApp; const powerMonitor = yield* ElectronPowerMonitor.ElectronPowerMonitor; + const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; yield* electronApp.whenReady; const initialPowerState: PowerState = { @@ -212,10 +214,13 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { powerMonitor.getSystemIdleTime, powerMonitor.getSystemIdleState(IDLE_THRESHOLD_SECONDS), powerMonitor.isOnBatteryPower, - demand ? electronApp.getAppMetrics : Effect.succeed([]), + electronApp.getAppMetrics, ], { concurrency: "unbounded" }, ); + // One Electron sampler preserves the shared CPU measurement interval. + // History follows the existing power-aware cadence, even without demand. + yield* rendererHistory.recordMetrics(metrics); const polledLocked = systemIdleState === "unknown" ? currentPower.locked @@ -270,7 +275,7 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { updatedAt: sampledAt, }, speedLimitPercent: observedPower.speedLimitPercent, - electronProcesses: metrics.map((metric) => ({ + electronProcesses: (demand ? metrics : []).map((metric) => ({ pid: metric.pid, creationTimeMs: Math.max(0, Math.round(metric.creationTime)), type: metric.type, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 1345c37bf687..ecf1e386f1ef 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -42,6 +42,7 @@ import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -286,6 +287,11 @@ function makeTestLayer(input: { Layer.provide( Layer.mergeAll( desktopAssetsLayer, + Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, { + register: () => Effect.void, + recordMetrics: () => Effect.void, + shutdown: Effect.void, + }), desktopEnvironmentLayer, desktopAppSettingsLayer, desktopClientSettingsLayer, @@ -401,6 +407,11 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n Layer.provide( Layer.mergeAll( desktopAssetsLayer, + Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, { + register: () => Effect.void, + recordMetrics: () => Effect.void, + shutdown: Effect.void, + }), desktopEnvironmentLayer, DesktopAppSettings.layerTest(), desktopClientSettingsLayer, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index aa5aa085a3ce..4e8fb0969ad3 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -29,6 +29,7 @@ import * as PreviewManager from "../preview/Manager.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; import { makeQuitShortcutHandler } from "./QuitHold.ts"; const TITLEBAR_HEIGHT = 40; @@ -84,6 +85,7 @@ type DesktopWindowRuntimeServices = | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme | ElectronWindow.ElectronWindow + | DesktopRendererHistory.DesktopRendererHistory | PreviewManager.PreviewManager; export type DesktopWindowError = @@ -322,6 +324,7 @@ export const make = Effect.gen(function* () { const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; const electronApp = yield* ElectronApp.ElectronApp; + const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; // Window-side latch for the primary backend's readiness. Set by // handleBackendReady (driven by the pool's onReady callback), cleared // by handleBackendNotReady (driven by onShutdown). Only consumed by @@ -420,6 +423,7 @@ export const make = Effect.gen(function* () { }, }); + yield* rendererHistory.register(window.webContents, { surface: "main" }); if (environment.platform === "darwin") { window.setAutoHideCursor(false); } @@ -905,6 +909,7 @@ export const make = Effect.gen(function* () { sandbox: true, }, }); + yield* rendererHistory.register(splash.webContents, { surface: "splash" }); yield* Ref.set(splashWindowRef, Option.some(splash)); splash.once("closed", () => { void runPromise(Ref.set(splashWindowRef, Option.none())); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9461f373d33a..0fe1ab40ec2c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -130,7 +130,7 @@ "react-native-worklets": "0.13.0", "shiki": "4.2.0", "tailwind-merge": "^3.5.0", - "uniwind": "1.11.0" + "uniwind": "1.12.1" }, "devDependencies": { "@effect/vitest": "catalog:", diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 41eaa569a5d0..1b9c70d13539 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -108,6 +108,7 @@ import IconTypography from "@tabler/icons-react-native/IconTypography"; import IconUpload from "@tabler/icons-react-native/IconUpload"; import IconUserCircle from "@tabler/icons-react-native/IconUserCircle"; import IconUsers from "@tabler/icons-react-native/IconUsers"; +import IconWifi from "@tabler/icons-react-native/IconWifi"; import IconWifiOff from "@tabler/icons-react-native/IconWifiOff"; import IconWorld from "@tabler/icons-react-native/IconWorld"; import IconX from "@tabler/icons-react-native/IconX"; @@ -226,6 +227,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { "textformat.size.smaller": IconTextDecrease, "tray.and.arrow.up": IconUpload, trash: IconTrash, + wifi: IconWifi, "wifi.slash": IconWifiOff, xmark: IconX, "xmark.circle.fill": IconCircleXFilled, diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts index 6ca84b2a5126..8e4ce3e62114 100644 --- a/apps/mobile/src/connection/environment-cache-store.test.ts +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -388,16 +388,105 @@ describe("cooperative shell cache encoding", () => { }), ); - it.effect("still rejects invalid rows after an encoding batch yields", () => + it.effect("preserves optional fields and dates across chunk boundaries and archived rows", () => Effect.gen(function* () { - const invalid = { + const base = SHELL_SNAPSHOT.threads[0]!; + const { titleRegeneration: _, unsettledAt: __, ...withoutOptional } = base; + const row = (index: number) => + index % 3 === 0 + ? { ...withoutOptional, id: ThreadId.make(`row-${index}`) } + : { + ...base, + id: ThreadId.make(`row-${index}`), + snoozedUntil: DateTime.add(NOW, { minutes: index }), + pinnedAt: index % 2 === 0 ? null : NOW, + }; + const mixed = { ...stored, snapshot: { ...stored.snapshot, - threads: [...stored.snapshot.threads, { ...stored.snapshot.threads[0]!, itemCount: -1 }], + threads: Array.from({ length: 65 }, (_, index) => row(index)), + archivedThreads: Array.from({ length: 33 }, (_, index) => ({ + ...row(index + 100), + archivedAt: DateTime.add(NOW, { hours: index }), + })), }, }; - expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalid))).toBe(true); + const actual = yield* encodeStoredShellSnapshot(mixed); + expect(actual).toBe(yield* encodeOriginal(mixed)); + const parsed = JSON.parse(actual); + expect(parsed.snapshot.threads).toHaveLength(65); + expect(parsed.snapshot.threads[63]).not.toHaveProperty("titleRegeneration"); + expect(parsed.snapshot.threads[63]).not.toHaveProperty("unsettledAt"); + expect(parsed.snapshot.threads[64].snoozedUntil).toBe( + DateTime.formatIso(DateTime.add(NOW, { minutes: 64 })), + ); + expect(parsed.snapshot.archivedThreads).toHaveLength(33); + expect(parsed.snapshot.archivedThreads[32].archivedAt).toBe( + DateTime.formatIso(DateTime.add(NOW, { hours: 32 })), + ); + }), + ); + + it.effect("yields to a host timer between every 32-row chunk", () => + Effect.gen(function* () { + const setTimer = vi.spyOn(globalThis, "setTimeout"); + try { + yield* encodeStoredShellSnapshot({ + ...stored, + snapshot: { + ...stored.snapshot, + threads: Array.from({ length: 65 }, () => stored.snapshot.threads[0]!), + archivedThreads: Array.from({ length: 33 }, () => stored.snapshot.archivedThreads[0]!), + }, + }); + // Five row chunks (3 active + 2 archived): four yields between them, one before the envelope. + expect(setTimer).toHaveBeenCalledTimes(5); + } finally { + vi.restoreAllMocks(); + } + }), + ); + + it.effect("still rejects invalid active and archived rows after an encoding chunk yields", () => + Effect.gen(function* () { + const invalidRow = { ...stored.snapshot.threads[0]!, itemCount: -1 }; + const invalidActive = { + ...stored, + snapshot: { ...stored.snapshot, threads: [...stored.snapshot.threads, invalidRow] }, + }; + const invalidArchived = { + ...stored, + snapshot: { ...stored.snapshot, archivedThreads: [invalidRow] }, + }; + const invalidEnvelope = { ...stored, snapshot: { ...stored.snapshot, snapshotSequence: -1 } }; + expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidActive))).toBe(true); + expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidArchived))).toBe(true); + expect(yield* Effect.isFailure(encodeOriginal(invalidEnvelope))).toBe(true); + expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidEnvelope))).toBe(true); + }), + ); + + it.effect("keeps concurrent saves independent", () => + Effect.gen(function* () { + const other = { + ...stored, + environmentId: EnvironmentId.make("environment-2"), + snapshot: { + ...stored.snapshot, + threads: stored.snapshot.threads.slice(0, 40).map((thread) => ({ + ...thread, + title: `Other ${thread.id}`, + })), + archivedThreads: [], + }, + }; + const [first, second] = yield* Effect.all( + [encodeStoredShellSnapshot(stored), encodeStoredShellSnapshot(other)], + { concurrency: "unbounded" }, + ); + expect(first).toBe(yield* encodeOriginal(stored)); + expect(second).toBe(yield* encodeOriginal(other)); }), ); diff --git a/apps/mobile/src/connection/shell-cache-encoding.ts b/apps/mobile/src/connection/shell-cache-encoding.ts index d06630cfd2fa..23dfb8d510a3 100644 --- a/apps/mobile/src/connection/shell-cache-encoding.ts +++ b/apps/mobile/src/connection/shell-cache-encoding.ts @@ -3,36 +3,60 @@ import { OrchestrationV2ThreadShellJson } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +const ROWS_PER_CHUNK = 32; + // Effect's cooperative yield resumes through microtasks on React Native. A host -// timer lets input and rendering run between batches of shell rows instead. +// timer lets input and rendering run between chunks of shell rows instead. const yieldToHost = Effect.callback((resume) => { const timer = setTimeout(() => resume(Effect.void), 0); return Effect.sync(() => clearTimeout(timer)); }); -// Share the row budget across saves and environments; this bounds batches, not elapsed time. -let encodedRows = 0; -const CooperativeThreadShell = OrchestrationV2ThreadShellJson.pipe( - Schema.middlewareEncoding((effect) => - Effect.flatMap(effect, (value) => { - encodedRows++; - if (encodedRows < 32) return Effect.succeed(value); - encodedRows = 0; - return Effect.as(yieldToHost, value); - }), +// Rows go through the canonical row codec in chunks. The envelope codec then +// validates and encodes everything else, passing the encoded rows through as-is. +const encodeThreadChunk = Schema.encodeEffect(Schema.Array(OrchestrationV2ThreadShellJson)); +const EncodedRows = Schema.Array(Schema.Unknown); +const encodeEnvelope = Schema.encodeEffect( + Schema.fromJsonString( + StoredOrchestrationShellSnapshot.mapFields((fields) => ({ + ...fields, + snapshot: fields.snapshot.mapFields((snapshotFields) => ({ + ...snapshotFields, + threads: EncodedRows, + archivedThreads: EncodedRows, + })), + })), ), ); -const CooperativeStoredShellSnapshot = StoredOrchestrationShellSnapshot.mapFields((fields) => ({ - ...fields, - snapshot: fields.snapshot.mapFields((snapshotFields) => ({ - ...snapshotFields, - threads: Schema.Array(CooperativeThreadShell), - archivedThreads: Schema.Array(CooperativeThreadShell), - })), -})); +/** + * Encode the shell cache payload exactly like `fromJsonString(StoredOrchestrationShellSnapshot)`, + * yielding to the host between bounded chunks of thread rows. The final envelope encode and + * JSON stringify still run synchronously over the whole payload. + */ +export const encodeStoredShellSnapshot = Effect.fnUntraced(function* ( + stored: typeof StoredOrchestrationShellSnapshot.Type, +) { + let hasWorked = false; + const yieldBetweenChunks = Effect.suspend(() => { + if (hasWorked) return yieldToHost; + hasWorked = true; + return Effect.void; + }); + const encodeRows = (rows: ReadonlyArray) => + Effect.gen(function* () { + const encoded: Array = []; + for (let start = 0; start < rows.length; start += ROWS_PER_CHUNK) { + yield* yieldBetweenChunks; + const chunk = yield* encodeThreadChunk(rows.slice(start, start + ROWS_PER_CHUNK)); + for (const row of chunk) encoded.push(row); + } + return encoded; + }); -/** Preserve the cache codec's validation and transforms while yielding during large saves. */ -export const encodeStoredShellSnapshot = Schema.encodeEffect( - Schema.fromJsonString(CooperativeStoredShellSnapshot), -); + const { snapshot } = stored; + const threads = yield* encodeRows(snapshot.threads); + const archivedThreads = yield* encodeRows(snapshot.archivedThreads); + yield* yieldBetweenChunks; + return yield* encodeEnvelope({ ...stored, snapshot: { ...snapshot, threads, archivedThreads } }); +}); diff --git a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx index c95306c90132..7e5021153a61 100644 --- a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx +++ b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx @@ -1,8 +1,10 @@ import { useAtomValue } from "@effect/atom-react"; import { type ConnectionRoute, + type ConnectionRouteKind, connectionRouteAddress, connectionRouteId, + connectionRouteKind, connectionRouteLabel, connectionRoutes, isLearned, @@ -10,18 +12,32 @@ import { import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { useEffect, useMemo, useRef, useState } from "react"; -import { Alert, Pressable, View } from "react-native"; +import { Alert, Platform, Pressable, View } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated"; -import { SymbolView } from "../../components/AppSymbol"; +import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; +import { StatusPill } from "../../components/StatusPill"; import { environmentCatalog } from "../../connection/catalog"; +import { cn } from "../../lib/cn"; import { environmentSession } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; +import { connectionTone } from "../connection/connectionTone"; +import { SettingsActionRow } from "./components/SettingsActionRow"; import { SettingsSection } from "./components/SettingsSection"; -const ROW_HEIGHT = 64; +const ICON_SIZE = Platform.OS === "android" ? 24 : 22; +const REMOVE_SIZE = 20; + +const ROUTE_ICONS: Record = { + relay: "cloud", + loopback: "desktopcomputer", + lan: "wifi", + tailnet: "point.3.connected.trianglepath.dotted", + public: "globe", + ssh: "terminal", +}; /** * The ways this device reaches an environment, preferred first. The first @@ -56,6 +72,12 @@ export function EnvironmentRoutesSection({ const [drag, setDrag] = useState<{ readonly id: string; readonly translation: number } | null>( null, ); + // Rows size to their content, so a drag measures how far it has moved past each one. + const [heights, setHeights] = useState>(() => new Map()); + // Each drop remounts the rows so the new order and the cleared drag offsets + // land in one frame. Kept rows would show their old offsets in their new + // slots until the animated style catches up, and the card flashes empty. + const [drops, setDrops] = useState(0); if (entry === undefined) return null; const byId = new Map(saved.map((route) => [connectionRouteId(route.target), route])); @@ -76,8 +98,19 @@ export function EnvironmentRoutesSection({ next.splice(to, 0, moved!); commit(next); }; - const dropIndex = (from: number, translation: number) => - Math.max(0, Math.min(order.length - 1, from + Math.round(translation / ROW_HEIGHT))); + // A lifted row takes a neighbour's slot once it has moved past half of that row. + const dropIndex = (from: number, translation: number) => { + const step = translation > 0 ? 1 : -1; + let to = from; + let remaining = Math.abs(translation); + while (to + step >= 0 && to + step < order.length) { + const next = heights.get(order[to + step]!) ?? 0; + if (next === 0 || remaining < next / 2) break; + remaining -= next; + to += step; + } + return to; + }; const confirmRemove = (route: ConnectionRoute) => Alert.alert( `Remove ${connectionRouteLabel(route)}?`, @@ -106,7 +139,7 @@ export function EnvironmentRoutesSection({ onPress={() => setEditing((value) => !value)} className="px-2 py-1 active:opacity-70" > - + {editing ? "Done" : "Edit"} @@ -116,17 +149,18 @@ export function EnvironmentRoutesSection({ {routes.map((route, index) => { const id = connectionRouteId(route.target); // Rows between the lifted row and its drop slot shift to make room. + const lifted = drag === null ? 0 : (heights.get(drag.id) ?? 0); const shift = dragFrom === -1 || index === dragFrom ? 0 : dragFrom < dragTo && index > dragFrom && index <= dragTo - ? -ROW_HEIGHT + ? -lifted : dragFrom > dragTo && index < dragFrom && index >= dragTo - ? ROW_HEIGHT + ? lifted : 0; return ( + setHeights((current) => + current.get(id) === height ? current : new Map(current).set(id, height), + ) + } onDragStart={() => setDrag({ id, translation: 0 })} onDragMove={(translation) => setDrag({ id, translation })} onDragEnd={(translation, cancelled) => { setDrag(null); + setDrops((count) => count + 1); if (!cancelled) move(index, dropIndex(index, translation)); }} onStep={(direction) => move(index, direction === "up" ? index - 1 : index + 1)} @@ -149,14 +189,7 @@ export function EnvironmentRoutesSection({ /> ); })} - - - Add route - + ); } @@ -169,6 +202,7 @@ function RouteRow(props: { readonly editing: boolean; readonly offset: number; readonly lifted: boolean; + readonly onHeight: (height: number) => void; readonly onDragStart: () => void; readonly onDragMove: (translation: number) => void; readonly onDragEnd: (translation: number, cancelled: boolean) => void; @@ -190,25 +224,27 @@ function RouteRow(props: { })); return ( props.onHeight(event.nativeEvent.layout.height)} + className={cn("flex-row items-center gap-4 pl-4", lifted && "bg-grouped-card shadow-md")} > {props.editing && props.onRemove ? ( + ) : props.editing ? ( + ) : null} part !== null) .join(", ")} - className="min-w-0 flex-1 gap-0.5 px-4" + // Matches the padding of the settings rows around it. + className={cn( + "min-w-0 flex-1 flex-row items-center gap-4 py-4 android:py-3", + address !== null ? "android:min-h-18" : "android:min-h-14", + !props.editing && "pr-4", + )} > - - {label} - {props.inUse ? ( - In use + + + + + {label} + + {props.inUse ? ( + + ) : null} + + {address !== null ? ( + + {isLearned(route) ? `${address} · found automatically` : address} + ) : null} - {address !== null ? ( - - {isLearned(route) ? `${address} · found automatically` : address} - - ) : null} {props.editing ? ( { if (notifiedSubagentThreadId !== undefined) { - navigation.navigate("Thread", { - environmentId: String(props.environmentId), - threadId: String(notifiedSubagentThreadId), - }); + // Push, not navigate: navigate reuses this Thread route, so back + // would skip the parent thread and land on Home (matches #15068). + navigation.dispatch( + StackActions.push("Thread", { + environmentId: String(props.environmentId), + threadId: String(notifiedSubagentThreadId), + }), + ); return; } if (canExpand) { diff --git a/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx b/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx index 8b8b4770bf5d..dc34349a848c 100644 --- a/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx +++ b/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx @@ -1,9 +1,10 @@ import { + AudioModule, RecordingPresets, requestRecordingPermissionsAsync, setAudioModeAsync, setIsAudioActiveAsync, - useAudioRecorder, + type RecorderState, type RecordingStatus, } from "expo-audio"; import { File } from "expo-file-system"; @@ -18,7 +19,7 @@ import { useState, type ReactNode, } from "react"; -import { AppState } from "react-native"; +import { AppState, Platform } from "react-native"; import { useSharedValue } from "react-native-reanimated"; import { getLocalVoiceTranscriber } from "../../native/voiceTranscription"; @@ -28,14 +29,23 @@ import { voiceInputBlocksSubmission, type VoiceInputState, } from "@t3tools/client-runtime/voice-input"; +import { createLazyVoiceRecorder, type LazyVoiceRecorder } from "./lazyVoiceRecorder"; import { normalizeVoiceInputDecibels, VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering"; import { VoiceInputSession } from "./voiceInputSession"; const INITIAL_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null }; const VOICE_METERING_INTERVAL_MS = 80; +// The native constructor takes platform-flattened options, as `useAudioRecorder` +// builds them with expo-audio's internal `createRecordingOptions`. +const { ios: IOS_RECORDING_OPTIONS, android: ANDROID_RECORDING_OPTIONS } = + RecordingPresets.HIGH_QUALITY; const VOICE_RECORDING_OPTIONS = { - ...RecordingPresets.HIGH_QUALITY, + extension: RecordingPresets.HIGH_QUALITY.extension, + sampleRate: RecordingPresets.HIGH_QUALITY.sampleRate, + numberOfChannels: RecordingPresets.HIGH_QUALITY.numberOfChannels, + bitRate: RecordingPresets.HIGH_QUALITY.bitRate, isMeteringEnabled: true, + ...(Platform.OS === "ios" ? IOS_RECORDING_OPTIONS : ANDROID_RECORDING_OPTIONS), }; async function releaseVoiceRecordingAudio(): Promise { @@ -102,18 +112,23 @@ function useVoiceInputRuntime() { const audioLevelsRef = useRef(Array(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0)); const audioLevels = useSharedValue(audioLevelsRef.current); const sessionRef = useRef(null); - const handleRecorderStatus = useCallback((status: RecordingStatus) => { - sessionRef.current?.controller.handleRecorderStatus({ - isFinished: status.isFinished, - hasError: status.hasError || status.mediaServicesDidReset === true, - error: status.error, - url: status.url, - }); - }, []); const permissionRequestPendingRef = useRef(false); - const recorder = useAudioRecorder(VOICE_RECORDING_OPTIONS, handleRecorderStatus); - - if (!sessionRef.current) { + const recorderRef = useRef | null>(null); + + if (!sessionRef.current || !recorderRef.current) { + // The native recorder is created when dictation starts, not on app launch. + const recorder = createLazyVoiceRecorder({ + create: () => new AudioModule.AudioRecorder(VOICE_RECORDING_OPTIONS), + onStatus: (status: RecordingStatus) => { + sessionRef.current?.controller.handleRecorderStatus({ + isFinished: status.isFinished, + hasError: status.hasError || status.mediaServicesDidReset === true, + error: status.error, + url: status.url, + }); + }, + }); + recorderRef.current = recorder; sessionRef.current = new VoiceInputSession({ recorder, getTranscriber: getLocalVoiceTranscriber, @@ -140,6 +155,7 @@ function useVoiceInputRuntime() { const session = sessionRef.current; const controller = session.controller; + const recorder = recorderRef.current; useEffect(() => { const subscription = AppState.addEventListener("change", (nextState) => { @@ -153,7 +169,14 @@ function useVoiceInputRuntime() { return () => subscription.remove(); }, [controller]); - useEffect(() => () => void controller.dispose(), [controller]); + useEffect( + () => () => { + // Dispose first so an active recording is stopped before the recorder is released. + void controller.dispose(); + recorder.release(); + }, + [controller, recorder], + ); useEffect(() => { if (state.phase !== "recording") return; @@ -183,7 +206,7 @@ function useVoiceInputRuntime() { const sampleRecording = () => { if (controller.currentState.phase !== "recording") return; const status = recorder.getStatus(); - if (!status.isRecording) return; + if (!status?.isRecording) return; const level = normalizeVoiceInputDecibels(status.metering); const history = audioLevelsRef.current; diff --git a/apps/mobile/src/features/voice-input/lazyVoiceRecorder.test.ts b/apps/mobile/src/features/voice-input/lazyVoiceRecorder.test.ts new file mode 100644 index 000000000000..ffba8faefffd --- /dev/null +++ b/apps/mobile/src/features/voice-input/lazyVoiceRecorder.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { VoiceInputController } from "@t3tools/client-runtime/voice-input"; + +import { createLazyVoiceRecorder } from "./lazyVoiceRecorder"; + +type Status = { readonly isFinished: boolean }; +type State = { readonly isRecording: boolean }; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function createNativeRecorder(uri: string) { + const listeners = new Set<(status: Status) => void>(); + return { + uri: null as string | null, + prepared: uri, + prepareToRecordAsync: vi.fn(async function (this: { uri: string | null; prepared: string }) { + this.uri = this.prepared; + }), + record: vi.fn(), + stop: vi.fn(async (): Promise => undefined), + getStatus: vi.fn((): State => ({ isRecording: true })), + addListener: vi.fn((_event: "recordingStatusUpdate", listener: (status: Status) => void) => { + listeners.add(listener); + return { remove: () => listeners.delete(listener) }; + }), + release: vi.fn(), + emit: (status: Status) => listeners.forEach((listener) => listener(status)), + }; +} + +function createHarness() { + const created: Array> = []; + const statuses: Status[] = []; + const recorder = createLazyVoiceRecorder({ + create: () => { + const native = createNativeRecorder(`file:///voice-${created.length}.m4a`); + created.push(native); + return native; + }, + onStatus: (status) => statuses.push(status), + }); + return { recorder, created, statuses }; +} + +describe("createLazyVoiceRecorder", () => { + it("does not create a native recorder until a recording prepares", async () => { + const { recorder, created } = createHarness(); + + expect(recorder.uri).toBeNull(); + expect(recorder.getStatus()).toBeNull(); + await recorder.stop(); + recorder.release(); + expect(created).toHaveLength(0); + + await recorder.prepareToRecordAsync(); + recorder.record({ forDuration: 300 }); + + expect(created).toHaveLength(1); + expect(created[0]!.record).toHaveBeenCalledWith({ forDuration: 300 }); + expect(recorder.uri).toBe("file:///voice-0.m4a"); + expect(recorder.getStatus()).toEqual({ isRecording: true }); + }); + + it("reuses one native recorder across recordings", async () => { + const { recorder, created } = createHarness(); + + await recorder.prepareToRecordAsync(); + await recorder.stop(); + await recorder.prepareToRecordAsync(); + + expect(created).toHaveLength(1); + expect(created[0]!.prepareToRecordAsync).toHaveBeenCalledTimes(2); + }); + + it("forwards status events until the recorder is released", async () => { + const { recorder, created, statuses } = createHarness(); + await recorder.prepareToRecordAsync(); + const native = created[0]!; + + native.emit({ isFinished: true }); + recorder.release(); + native.emit({ isFinished: false }); + + expect(statuses).toEqual([{ isFinished: true }]); + }); + + it("releases only after a pending stop settles and keeps the final uri", async () => { + const { recorder, created } = createHarness(); + await recorder.prepareToRecordAsync(); + const native = created[0]!; + const stopped = deferred(); + native.stop.mockReturnValueOnce(stopped.promise); + + const stopping = recorder.stop(); + recorder.release(); + await Promise.resolve(); + expect(native.release).not.toHaveBeenCalled(); + expect(recorder.uri).toBe("file:///voice-0.m4a"); + + stopped.resolve(); + await stopping; + await vi.waitFor(() => expect(native.release).toHaveBeenCalledTimes(1)); + expect(recorder.uri).toBe("file:///voice-0.m4a"); + expect(recorder.getStatus()).toBeNull(); + }); + + it("releases after a failed stop and creates a fresh recorder for the next start", async () => { + const { recorder, created } = createHarness(); + await recorder.prepareToRecordAsync(); + const first = created[0]!; + first.stop.mockRejectedValueOnce(new Error("not recording")); + + await expect(recorder.stop()).rejects.toThrow("not recording"); + recorder.release(); + await vi.waitFor(() => expect(first.release).toHaveBeenCalledTimes(1)); + expect(() => recorder.record({ forDuration: 1 })).toThrow("not prepared"); + + await recorder.prepareToRecordAsync(); + expect(created).toHaveLength(2); + expect(recorder.uri).toBe("file:///voice-1.m4a"); + }); + + describe("with the voice input controller", () => { + function createController(granted: boolean) { + const harness = createHarness(); + const deleted: string[] = []; + const controller = new VoiceInputController({ + recorder: harness.recorder, + getTranscriber: () => ({ + prepare: async () => ({ locale: "en-US", transcribe: async () => "text" }), + }), + requestPermission: async () => ({ granted, canAskAgain: true }), + configureRecording: async () => undefined, + releaseRecording: async () => undefined, + deleteRecording: (uri) => deleted.push(uri), + readDraft: () => ({ + ownerKey: "thread", + text: "", + selection: { start: 0, end: 0 }, + revision: 0, + }), + commitDraft: () => undefined, + onStateChange: () => undefined, + }); + return { ...harness, controller, deleted }; + } + + it("does not create a native recorder when microphone permission is denied", async () => { + const { controller, created } = createController(false); + + await controller.start(); + + expect(controller.currentState.phase).toBe("error"); + expect(created).toHaveLength(0); + }); + + it("stops, deletes, and releases the recorder created by a cancelled recording", async () => { + const { controller, recorder, created, deleted } = createController(true); + + await controller.start(); + expect(controller.currentState.phase).toBe("recording"); + expect(created).toHaveLength(1); + const native = created[0]!; + + controller.cancel(); + recorder.release(); + await vi.waitFor(() => expect(native.release).toHaveBeenCalledTimes(1)); + + expect(native.stop).toHaveBeenCalledTimes(1); + expect(deleted).toEqual(["file:///voice-0.m4a"]); + expect(controller.currentState.phase).toBe("idle"); + }); + }); +}); diff --git a/apps/mobile/src/features/voice-input/lazyVoiceRecorder.ts b/apps/mobile/src/features/voice-input/lazyVoiceRecorder.ts new file mode 100644 index 000000000000..d6deb2163c63 --- /dev/null +++ b/apps/mobile/src/features/voice-input/lazyVoiceRecorder.ts @@ -0,0 +1,88 @@ +import type { VoiceRecorder } from "@t3tools/client-runtime/voice-input"; + +type Subscription = { remove(): void }; + +// The subset of expo-audio's native `AudioRecorder` the voice input hook uses. +export interface NativeVoiceRecorder { + readonly uri: string | null; + prepareToRecordAsync(): Promise; + record(options: { readonly forDuration: number }): void; + stop(): Promise; + getStatus(): TRecorderState; + addListener( + eventName: "recordingStatusUpdate", + listener: (status: TStatus) => void, + ): Subscription; + release(): void; +} + +export interface LazyVoiceRecorder extends VoiceRecorder { + /** Status of the native recorder, or null before a recording has prepared one. */ + getStatus(): TRecorderState | null; + /** + * Releases the native recorder after its pending prepare and stop calls settle. + * A later recording creates a new one, so replayed effects keep working. + */ + release(): void; +} + +/** + * Creates expo-audio's native `AudioRecorder` when a recording first prepares, + * instead of on composer render. Construction blocks the JS thread for tens of + * milliseconds on a cold composer, and most composer mounts never record. + */ +export function createLazyVoiceRecorder(input: { + readonly create: () => NativeVoiceRecorder; + readonly onStatus: (status: TStatus) => void; +}): LazyVoiceRecorder { + let recorder: NativeVoiceRecorder | null = null; + let subscription: Subscription | null = null; + // A released recorder still answers `uri` so the controller can delete its file. + let retired: NativeVoiceRecorder | null = null; + let retiredUri: string | null = null; + let pending: Promise = Promise.resolve(); + + const track = (promise: Promise): Promise => { + pending = Promise.allSettled([pending, promise]).then(() => undefined); + return promise; + }; + + return { + get uri() { + if (recorder) return recorder.uri; + return retired ? retired.uri : retiredUri; + }, + prepareToRecordAsync() { + if (!recorder) { + recorder = input.create(); + subscription = recorder.addListener("recordingStatusUpdate", input.onStatus); + } + return track(recorder.prepareToRecordAsync()); + }, + record(options) { + if (!recorder) throw new Error("Voice recorder is not prepared."); + recorder.record(options); + }, + stop() { + return recorder ? track(recorder.stop()) : Promise.resolve(); + }, + getStatus() { + return recorder ? recorder.getStatus() : null; + }, + release() { + const released = recorder; + if (!released) return; + recorder = null; + subscription?.remove(); + subscription = null; + retired = released; + void pending.then(() => { + if (retired === released) { + retiredUri = released.uri; + retired = null; + } + released.release(); + }); + }, + }; +} diff --git a/apps/mobile/src/lib/uniwind-platform-variants.test.ts b/apps/mobile/src/lib/uniwind-platform-variants.test.ts index 12fa86b33c08..32e0323e7a19 100644 --- a/apps/mobile/src/lib/uniwind-platform-variants.test.ts +++ b/apps/mobile/src/lib/uniwind-platform-variants.test.ts @@ -10,7 +10,7 @@ import { beforeAll, describe, expect, it } from "vite-plus/test"; // `@media ios { ... }` block, and uniwind's CSS processor used to drop the // block's media queries after its first nested rule. Everything past the first // utility compiled unguarded and shipped to both platforms. These tests run -// the installed (patched) uniwind compiler over real Tailwind output, per +// the installed uniwind compiler over real Tailwind output, per // platform, and assert what each bundle receives. The compiler itself runs in // a plain Node child process (uniwind-platform-variants.fixture.cjs) so no // test-runner module transforms sit between the test and the shipped code. @@ -215,11 +215,9 @@ describe("uniwind keeps media rules nested inside class rules attached to the cl }); }); -// The patch file carries three independent uniwind fixes (state/data selector -// variants, the Metro native-styles fingerprint, and this media-query scoping -// fix). Regenerating it for one fix must not silently drop the others — this -// suite exercises the shipped transformer and compiler for the other two. -describe("uniwind patch keeps pre-existing selector and transformer behavior", () => { +// Keep guarding selector variants and the Metro native-styles fingerprint +// alongside media-query scoping when upgrading the installed Uniwind package. +describe("uniwind keeps selector and transformer behavior", () => { let output: FixtureOutput; beforeAll(() => { diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 2829502c8cbd..04433f26e533 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -35,6 +35,8 @@ import { type SourceControlProviderKind, type SourceControlWritingStyleSettings, type ThreadId, + type VcsCreateWorktreeInput, + type VcsCreateWorktreeResult, } from "@t3tools/contracts"; import { hasProjectSettingsOverrides, @@ -103,6 +105,10 @@ interface SourceControlTextGenerationSettings { export class GitManager extends Context.Service< GitManager, { + readonly createWorktree: ( + input: VcsCreateWorktreeInput, + options?: GitVcsDriver.CreateWorktreeOptions, + ) => Effect.Effect; readonly status: ( input: VcsStatusInput, ) => Effect.Effect; @@ -727,6 +733,19 @@ export const make = Effect.gen(function* () { ); return resolveProjectSettings(settings, projectId).settings; }); + const createWorktree: GitManager["Service"]["createWorktree"] = Effect.fn( + "GitManager.createWorktree", + )(function* (input, options) { + const submodules = + options?.submodules !== undefined + ? options.submodules + : yield* projectSettingsFor(input).pipe( + Effect.map((settings) => settings.worktreeSubmodules), + Effect.orElseSucceed(() => null), + ); + return yield* gitCore.createWorktree(input, { ...options, submodules }); + }); + const readRepositoryInstructions = (cwd: string, fileName: string) => Effect.gen(function* () { const root = yield* fileSystem.realPath(cwd); @@ -2849,6 +2868,7 @@ export const make = Effect.gen(function* () { ); return GitManager.of({ + createWorktree, localStatus, remoteStatus, status, diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 912e0c1f4620..bc7f2d9fb2ea 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -347,7 +347,7 @@ export const make = Effect.gen(function* () { ), createWorktree: (input, options) => ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( - Effect.andThen(git.createWorktree(input, options)), + Effect.andThen(gitManager.createWorktree(input, options)), ), listLocalBranchNames: (cwd) => ensureGitCommand("GitWorkflowService.listLocalBranchNames", cwd).pipe( diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 3a9307314c39..f5aba005f9a8 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -19,9 +19,13 @@ const environmentId = EnvironmentId.make("environment-device-test"); const threadId = ThreadId.make("thread-device-test"); const invocation = (capabilities: ReadonlyArray) => ({ environmentId, - threadId, - providerSessionId: "provider-session-device-test", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session-device-test", + thread: { + threadId, + providerSessionId: "provider-session-device-test", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(capabilities), issuedAt: 1, }); diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 69801ee06100..96a0de29f3d0 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -27,9 +27,13 @@ const alternateTabId = PreviewTabId.make("tab-mcp-alternate"); const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const invocation = { environmentId, - threadId, - providerSessionId: "provider-session-mcp-test", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session-mcp-test", + thread: { + threadId, + providerSessionId: "provider-session-mcp-test", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(["preview"] as const), issuedAt: 1, }; diff --git a/apps/server/src/mcp/McpInvocationContext.test.ts b/apps/server/src/mcp/McpInvocationContext.test.ts index 4314c82e6ddc..1daa9740f019 100644 --- a/apps/server/src/mcp/McpInvocationContext.test.ts +++ b/apps/server/src/mcp/McpInvocationContext.test.ts @@ -13,9 +13,13 @@ import * as McpInvocationContext from "./McpInvocationContext.ts"; it.effect("reports the scoped credential context when preview capability is unavailable", () => { const invocation: McpInvocationContext.McpInvocationScope = { environmentId: EnvironmentId.make("environment-1"), - threadId: ThreadId.make("thread-1"), - providerSessionId: "provider-session-1", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session-1", + thread: { + threadId: ThreadId.make("thread-1"), + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(), issuedAt: 1, }; @@ -30,9 +34,9 @@ it.effect("reports the scoped credential context when preview capability is unav expect(error).toMatchObject({ capability: "preview", environmentId: invocation.environmentId, - threadId: invocation.threadId, - providerSessionId: invocation.providerSessionId, - providerInstanceId: invocation.providerInstanceId, + threadId: invocation.thread?.threadId, + providerSessionId: invocation.thread?.providerSessionId, + providerInstanceId: invocation.thread?.providerInstanceId, }); expect(error.message).toContain("MCP credential does not grant the preview capability"); expect(error.message).toContain("use a headless browser from the shell"); @@ -42,9 +46,13 @@ it.effect("reports the scoped credential context when preview capability is unav it.effect("reports other missing capabilities with the neutral error", () => { const invocation: McpInvocationContext.McpInvocationScope = { environmentId: EnvironmentId.make("environment-1"), - threadId: ThreadId.make("thread-1"), - providerSessionId: "provider-session-1", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session-1", + thread: { + threadId: ThreadId.make("thread-1"), + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(["preview"]), issuedAt: 1, }; @@ -56,7 +64,10 @@ it.effect("reports other missing capabilities with the neutral error", () => { ); expect(error).toBeInstanceOf(McpCapabilityUnavailableError); - expect(error).toMatchObject({ capability: "pull-requests", threadId: invocation.threadId }); + expect(error).toMatchObject({ + capability: "pull-requests", + threadId: invocation.thread?.threadId, + }); const scope = yield* McpInvocationContext.requireMcpCapability("preview").pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), @@ -64,3 +75,24 @@ it.effect("reports other missing capabilities with the neutral error", () => { expect(scope).toBe(invocation); }); }); + +it.effect("refuses thread-owned capabilities to a caller signed in from outside a thread", () => { + const invocation: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment-1"), + requestNamespace: "client:session-1", + thread: undefined, + client: { sessionId: "session-1", label: "Claude Code", runtimeModeCeiling: "auto" }, + capabilities: new Set(["preview", "orchestration"]), + issuedAt: 1, + }; + + return Effect.gen(function* () { + const error = yield* McpInvocationContext.requireThreadMcpCapability("preview").pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.flip, + ); + expect(error).toBeInstanceOf(PreviewAutomationUnavailableError); + expect(error).toMatchObject({ capability: "preview", environmentId: "environment-1" }); + expect(error.threadId).toBeUndefined(); + }); +}); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index eff1d01cba29..01906c1b44f6 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -1,8 +1,10 @@ import { type EnvironmentId, McpCapabilityUnavailableError, + OrchestratorMcpFailure, PreviewAutomationUnavailableError, type ProviderInstanceId, + type RuntimeMode, type ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -17,13 +19,35 @@ const ALL_MCP_CAPABILITIES = [ ] as const; export type McpCapability = (typeof ALL_MCP_CAPABILITIES)[number]; -export interface McpInvocationScope { - readonly environmentId: EnvironmentId; +/** A provider session T3 Code launched for one thread. */ +export interface McpThreadCaller { readonly threadId: ThreadId; readonly providerSessionId: string; readonly providerInstanceId: ProviderInstanceId; +} + +/** An agent T3 Code did not launch, signed in through MCP OAuth. */ +export interface McpClientCaller { + readonly sessionId: string; + readonly label: string; + readonly runtimeModeCeiling: RuntimeMode; +} + +/** + * Who is calling and what they may do. Tool parameters choose the target + * (thread, project); the caller sets the limits. A thread caller's omitted + * target falls back to its own thread; a client caller has no own thread, so + * tools that act as the caller (delegate_task, preview, worktree handoff) + * need `thread`. + */ +export interface McpInvocationScope { + readonly environmentId: EnvironmentId; readonly capabilities: ReadonlySet; readonly issuedAt: number; + /** Namespaces idempotency keys so two callers reusing a clientRequestId cannot collide. */ + readonly requestNamespace: string; + readonly thread: McpThreadCaller | undefined; + readonly client: McpClientCaller | undefined; } export class McpInvocationContext extends Context.Service< @@ -42,9 +66,13 @@ const missingCapability = ( ): PreviewAutomationUnavailableError | McpCapabilityUnavailableError => { const fields = { environmentId: invocation.environmentId, - threadId: invocation.threadId, - providerSessionId: invocation.providerSessionId, - providerInstanceId: invocation.providerInstanceId, + ...(invocation.thread === undefined + ? {} + : { + threadId: invocation.thread.threadId, + providerSessionId: invocation.thread.providerSessionId, + providerInstanceId: invocation.thread.providerInstanceId, + }), }; return capability === "preview" ? new PreviewAutomationUnavailableError({ capability, ...fields }) @@ -62,3 +90,34 @@ export const requireMcpCapability = ( ), Effect.withSpan("mcp.requireCapability"), ); + +/** + * Preview tabs and device sessions belong to the calling thread, so their + * capabilities are only ever granted to thread callers. A scope that carries + * one without a thread is refused the same way as a missing capability. + */ +export const requireThreadMcpCapability = ( + capability: C, +): Effect.Effect, McpInvocationContext> => + McpInvocationContext.pipe( + Effect.filterOrFail( + (invocation): invocation is McpThreadInvocationScope => + invocation.capabilities.has(capability) && invocation.thread !== undefined, + (invocation) => missingCapability(invocation, capability) as McpCapabilityError, + ), + Effect.withSpan("mcp.requireCapability"), + ); + +const threadCallerRequired = (operation: string) => + new OrchestratorMcpFailure({ + code: "thread_credential_required", + message: `${operation} acts as the calling T3 thread, so it needs an agent running inside T3 Code. This MCP client signed in from outside a thread.`, + }); + +/** A scope with a thread caller, for tools whose whole surface acts as the caller. */ +export type McpThreadInvocationScope = McpInvocationScope & { readonly thread: McpThreadCaller }; + +export const requireThreadScope = (scope: McpInvocationScope, operation: string) => + scope.thread === undefined + ? Effect.fail(threadCallerRequired(operation)) + : Effect.succeed(scope as McpThreadInvocationScope); diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index ba3c20968d10..719bed1ed4e7 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -47,7 +47,7 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t expect(token.length).toBeGreaterThan(20); const resolved = yield* registry.resolve(token); - expect(resolved?.threadId).toBe(threadId); + expect(resolved?.thread.threadId).toBe(threadId); expect(resolved?.capabilities).toEqual( new Set(["preview", "orchestration", "worktree", "pull-requests"]), ); @@ -158,7 +158,7 @@ it.effect("keeps a credential alive across turns that never touch an MCP tool", yield* registry.touch(threadId); } - expect((yield* registry.resolve(token))?.threadId).toBe(threadId); + expect((yield* registry.resolve(token))?.thread.threadId).toBe(threadId); }), ); diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index c62c3ca5043c..10b933d4f45b 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -32,7 +32,7 @@ export interface McpSessionRegistryShape { readonly issue: (request: McpCredentialRequest) => Effect.Effect; readonly resolve: ( rawToken: string, - ) => Effect.Effect; + ) => Effect.Effect; /** * Records a sign of life for every credential bound to `threadId`. Provider * turns call this so that a session which is plainly alive keeps its @@ -49,9 +49,10 @@ export class McpSessionRegistry extends Context.Service< McpSessionRegistryShape >()("t3/mcp/McpSessionRegistry") {} +/** Registry credentials always belong to a provider session, so their scope has a thread. */ interface CredentialRecord { readonly tokenHash: string; - readonly scope: McpInvocationContext.McpInvocationScope; + readonly scope: McpInvocationContext.McpThreadInvocationScope; readonly lastAliveAt: number; } @@ -127,11 +128,15 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie); const tokenHash = yield* hashToken(rawToken); const browserToolsAvailable = request.browserToolsAvailable ?? true; - const scope: McpInvocationContext.McpInvocationScope = { + const scope: McpInvocationContext.McpThreadInvocationScope = { environmentId, - threadId: ThreadId.make(request.threadId), - providerSessionId, - providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), + requestNamespace: providerSessionId, + thread: { + threadId: ThreadId.make(request.threadId), + providerSessionId, + providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), + }, + client: undefined, capabilities: new Set([ "orchestration", "worktree", @@ -148,9 +153,9 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( return { config: { environmentId, - threadId: scope.threadId, + threadId: scope.thread.threadId, providerSessionId, - providerInstanceId: scope.providerInstanceId, + providerInstanceId: scope.thread.providerInstanceId, endpoint, authorizationHeader: `Bearer ${rawToken}`, browserToolsAvailable: scope.capabilities.has("preview"), @@ -183,7 +188,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( const current = pruneDead(records, timestamp); const next = new Map(current); for (const [tokenHash, record] of current) { - if (record.scope.threadId === threadId) { + if (record.scope.thread.threadId === threadId) { next.set(tokenHash, { ...record, lastAliveAt: timestamp }); } } @@ -203,11 +208,11 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( touch, revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")( function* (providerSessionId) { - yield* revokeWhere((record) => record.scope.providerSessionId === providerSessionId); + yield* revokeWhere((record) => record.scope.thread.providerSessionId === providerSessionId); }, ), revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) { - yield* revokeWhere((record) => record.scope.threadId === threadId); + yield* revokeWhere((record) => record.scope.thread.threadId === threadId); }), revokeAll: SynchronizedRef.set(state, { records: new Map() }), }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 06eff1e6f8b1..2433eb8a704a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -2,10 +2,12 @@ import { EnvironmentId, NodeId, type OrchestrationV2ThreadProjection, + type OrchestrationV2ThreadShell, ProjectId, ProviderDriverKind, ProviderInstanceId, RunId, + ScheduledTaskId, ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -16,6 +18,7 @@ import { expect, it } from "vite-plus/test"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import type * as McpInvocationContext from "./McpInvocationContext.ts"; @@ -37,9 +40,13 @@ const parentInstanceId = ProviderInstanceId.make("codex"); const makeScope = (): McpInvocationContext.McpInvocationScope => ({ environmentId, - threadId: parentThreadId, - providerSessionId: "provider-session-mcp-orchestrator-detail", - providerInstanceId: parentInstanceId, + requestNamespace: "provider-session-mcp-orchestrator-detail", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session-mcp-orchestrator-detail", + providerInstanceId: parentInstanceId, + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }); @@ -127,10 +134,21 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a threadId === parentThreadId ? Effect.succeed(projection) : Effect.die(`unexpected thread ${threadId}`), + getThreadShell: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? (projection.thread as unknown as OrchestrationV2ThreadShell) + : null, + ), + getProjectThreadRecords: (input) => + input.threadId === parentThreadId + ? Effect.succeed(projection) + : Effect.die(`unexpected thread ${input.threadId}`), } satisfies Partial), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), } satisfies Partial), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({ list: () => Effect.succeed({ tasks: [] }), } satisfies Partial), @@ -180,10 +198,21 @@ it("readThread prefers waiting activity status over a newer cancelled queued run threadId === parentThreadId ? Effect.succeed(projection) : Effect.die(`unexpected thread ${threadId}`), + getThreadShell: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? (projection.thread as unknown as OrchestrationV2ThreadShell) + : null, + ), + getProjectThreadRecords: (input) => + input.threadId === parentThreadId + ? Effect.succeed(projection) + : Effect.die(`unexpected thread ${input.threadId}`), } satisfies Partial), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), } satisfies Partial), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({ list: () => Effect.succeed({ tasks: [] }), } satisfies Partial), @@ -295,6 +324,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), } satisfies Partial), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({ list: () => Effect.succeed({ tasks: [] }), } satisfies Partial), @@ -317,34 +347,12 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy }).pipe(Effect.provide(layer), Effect.runPromise); }); -it("readThread reaches a thread the user attached as context, but not one an agent attached", async () => { +it("readThread and sendToThread reach threads in other projects", async () => { + let parentRuns: ReadonlyArray = [ + makeRun({ id: RunId.make("run-parent-live"), ordinal: 1, status: "running" }), + ]; const foreignProjectId = ProjectId.make("project-mcp-orchestrator-foreign"); const foreignThreadId = ThreadId.make("thread-mcp-orchestrator-foreign"); - const agentOnlyThreadId = ThreadId.make("thread-mcp-orchestrator-agent-only"); - const threadRecord = (threadId: ThreadId) => ({ - version: 1, - kind: "thread", - contextId: `thread_${threadId}`, - label: "Attached", - environmentId, - threadId, - title: "Attached", - }); - const message = (input: { createdBy: "user" | "agent"; threadId: ThreadId }) => ({ - id: `message-${input.threadId}`, - threadId: parentThreadId, - runId: null, - nodeId: null, - role: "user", - text: `[Attached](t3-context://v1/thread/thread_${input.threadId})`, - context: { version: 1, records: [threadRecord(input.threadId)] }, - attachments: [], - streaming: false, - createdBy: input.createdBy, - creationSource: input.createdBy === "user" ? "user" : "mcp", - createdAt: now, - updatedAt: now, - }); const parentProjection = { thread: baseThread({ threadId: parentThreadId, @@ -352,13 +360,12 @@ it("readThread reaches a thread the user attached as context, but not one an age instanceId: parentInstanceId, model: "gpt-5.4", }), - runs: [], + get runs() { + return parentRuns; + }, visibleTurnItems: [], runtimeRequests: [], - messages: [ - message({ createdBy: "user", threadId: foreignThreadId }), - message({ createdBy: "agent", threadId: agentOnlyThreadId }), - ], + messages: [], contextTransfers: [], subagents: [], updatedAt: now, @@ -406,11 +413,15 @@ it("readThread reaches a thread the user attached as context, but not one an age Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => { if (threadId === parentThreadId) return Effect.succeed(parentProjection); - if (threadId === foreignThreadId || threadId === agentOnlyThreadId) { - return Effect.succeed(foreignProjection(threadId)); - } + if (threadId === foreignThreadId) return Effect.succeed(foreignProjection(threadId)); return Effect.die(`unexpected thread ${threadId}`); }, + getThreadShell: (threadId) => + Effect.succeed( + threadId === foreignThreadId + ? (foreignProjection(threadId).thread as unknown as OrchestrationV2ThreadShell) + : null, + ), getTimelinePage: (threadId) => Effect.succeed({ items: foreignProjection(threadId).visibleTurnItems, @@ -418,18 +429,36 @@ it("readThread reaches a thread the user attached as context, but not one an age hasMore: false, }), getProjectThreadRecords: (input) => - Effect.fail( - new ThreadManagementService.ThreadManagementThreadNotFoundError({ - projectId: input.projectId, - threadId: input.threadId, - }), - ), + input.threadId === foreignThreadId && input.projectId === foreignProjectId + ? Effect.succeed(foreignProjection(input.threadId)) + : Effect.fail( + new ThreadManagementService.ThreadManagementThreadNotFoundError({ + projectId: input.projectId, + threadId: input.threadId, + }), + ), + sendToThread: () => + Effect.succeed({ + run: { id: "run-foreign", status: "queued" }, + delivery: "started", + } as unknown as ThreadManagementService.ThreadManagementSendResult), } satisfies Partial), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), } satisfies Partial), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({ - list: () => Effect.succeed({ tasks: [] }), + list: () => + Effect.succeed({ + tasks: [ + { + id: "task-foreign", + projectId: foreignProjectId, + runtimeMode: "approval-required", + interactionMode: "default", + } as never, + ], + }), } satisfies Partial), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), @@ -441,18 +470,47 @@ it("readThread reaches a thread the user attached as context, but not one an age await Effect.gen(function* () { const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const attached = yield* service.readThread(makeScope(), { threadId: foreignThreadId }); - expect(attached.thread.threadId).toBe(foreignThreadId); - expect(attached.items.map((item) => item.text)).toEqual(["Foreign thread said hello"]); + const foreign = yield* service.readThread(makeScope(), { threadId: foreignThreadId }); + expect(foreign.thread.threadId).toBe(foreignThreadId); + expect(foreign.thread.projectId).toBe(foreignProjectId); + expect(foreign.items.map((item) => item.text)).toEqual(["Foreign thread said hello"]); - const denied = yield* service - .readThread(makeScope(), { threadId: agentOnlyThreadId }) - .pipe(Effect.flip); - expect(denied.code).toBe("thread_not_found"); + const sent = yield* service.sendToThread(makeScope(), { + threadId: foreignThreadId, + message: "hi", + }); + expect(sent.threadId).toBe(foreignThreadId); - const write = yield* service - .sendToThread(makeScope(), { threadId: foreignThreadId, message: "hi" }) + // Once the caller's run ends, it can still read other threads but no longer write to them. + parentRuns = []; + yield* service.readThread(makeScope(), { threadId: foreignThreadId }); + const stale = yield* service + .sendToThread(makeScope(), { threadId: foreignThreadId, message: "hi again" }) + .pipe(Effect.flip); + expect(stale.code).toBe("parent_not_active"); + const staleInterrupt = yield* service + .interruptThread(makeScope(), { threadId: foreignThreadId }) + .pipe(Effect.flip); + expect(staleInterrupt.code).toBe("parent_not_active"); + // Nor create, change or remove scheduled work in another project. + const staleSchedule = yield* service + .scheduleTask(makeScope(), { + projectId: foreignProjectId, + prompt: "check in later", + schedule: { type: "interval", everyMs: 3_600_000 }, + }) + .pipe(Effect.flip); + expect(staleSchedule.code).toBe("parent_not_active"); + const staleUpdate = yield* service + .updateScheduledTask(makeScope(), { + scheduledTaskId: ScheduledTaskId.make("task-foreign"), + enabled: false, + }) + .pipe(Effect.flip); + expect(staleUpdate.code).toBe("parent_not_active"); + const staleDelete = yield* service + .deleteScheduledTask(makeScope(), { scheduledTaskId: ScheduledTaskId.make("task-foreign") }) .pipe(Effect.flip); - expect(write.code).toBe("thread_not_found"); + expect(staleDelete.code).toBe("parent_not_active"); }).pipe(Effect.provide(layer), Effect.runPromise); }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index eae4ffa8c0b1..5d8025c5fe12 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -22,6 +22,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts"; +import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; @@ -118,13 +119,18 @@ describe("OrchestratorMcpService", () => { Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), }), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-ack"), - threadId: parentThreadId, - providerSessionId: "provider-session:mcp-ack", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session:mcp-ack", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-ack", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }; @@ -202,13 +208,18 @@ describe("OrchestratorMcpService", () => { Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), }), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-restart"), - threadId: parentThreadId, - providerSessionId: "provider-session:mcp-restart", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session:mcp-restart", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-restart", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }; @@ -278,13 +289,18 @@ describe("OrchestratorMcpService", () => { Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), }), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-cancel"), - threadId: parentThreadId, - providerSessionId: "provider-session:mcp-cancel", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session:mcp-cancel", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-cancel", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }; @@ -346,13 +362,18 @@ describe("OrchestratorMcpService", () => { Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), }), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-cancel-failed"), - threadId: parentThreadId, - providerSessionId: "provider-session:mcp-cancel-failed", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session:mcp-cancel-failed", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-cancel-failed", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }; @@ -421,13 +442,18 @@ describe("OrchestratorMcpService", () => { Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), }), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-cancel-dispose-failed"), - threadId: parentThreadId, - providerSessionId: "provider-session:mcp-cancel-dispose-failed", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session:mcp-cancel-dispose-failed", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-cancel-dispose-failed", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }; @@ -460,9 +486,13 @@ describe("OrchestratorMcpService provider resolution", () => { const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-providers"), - threadId: parentThreadId, - providerSessionId: "provider-session:mcp-providers", - providerInstanceId: codexInstanceId, + requestNamespace: "provider-session:mcp-providers", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-providers", + providerInstanceId: codexInstanceId, + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }; @@ -613,6 +643,7 @@ describe("OrchestratorMcpService provider resolution", () => { antigravityInstanceId, disabledAntigravityInstanceId, ]), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); @@ -756,6 +787,7 @@ describe("OrchestratorMcpService provider resolution", () => { ]), }), adapterRegistryLayer([codexInstanceId, antigravityInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); @@ -849,6 +881,7 @@ describe("OrchestratorMcpService provider resolution", () => { ]), }), adapterRegistryLayer([codexInstanceId, antigravityInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); @@ -897,6 +930,7 @@ describe("OrchestratorMcpService provider resolution", () => { ]), }), adapterRegistryLayer([codexInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); @@ -1060,6 +1094,7 @@ describe("OrchestratorMcpService provider resolution", () => { ]), }), adapterRegistryLayer([codexInstanceId, codexAltInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 83a7a4cbfde6..097ce0356264 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -29,6 +29,8 @@ import { type OrchestratorMcpTaskCancelInput, type OrchestratorMcpTaskCancelResult, type OrchestratorMcpUpdateScheduledTaskInput, + type OrchestratorMcpListScheduledTasksInput, + type ProjectId, type OrchestratorMcpThreadDetail, type OrchestratorMcpThreadInterruptInput, type OrchestratorMcpThreadInterruptResult, @@ -68,9 +70,14 @@ import { delegatedTaskProgress, } from "../orchestration-v2/SubagentProjection.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; -import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import { + type McpInvocationScope, + type McpThreadInvocationScope, + requireThreadScope, +} from "./McpInvocationContext.ts"; const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000; const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000; @@ -115,6 +122,7 @@ export interface OrchestratorMcpServiceShape { ) => Effect.Effect; readonly listScheduledTasks: ( scope: McpInvocationScope, + input: OrchestratorMcpListScheduledTasksInput, ) => Effect.Effect; readonly updateScheduledTask: ( scope: McpInvocationScope, @@ -486,7 +494,7 @@ function stableCommandId(input: { [ "command", "mcp", - stablePart(input.scope.providerSessionId), + stablePart(input.scope.requestNamespace), stablePart(input.operation), stablePart(input.requestKey), ...(input.index === undefined ? [] : [String(input.index)]), @@ -503,7 +511,7 @@ function stableThreadId(input: { [ "thread", "mcp", - stablePart(input.scope.providerSessionId), + stablePart(input.scope.requestNamespace), stablePart(input.requestKey), String(input.index), ].join(":"), @@ -519,7 +527,7 @@ function stableMessageId(input: { [ "message", "mcp", - stablePart(input.scope.providerSessionId), + stablePart(input.scope.requestNamespace), stablePart(input.requestKey), String(input.index), ].join(":"), @@ -535,7 +543,7 @@ function stableOperationMessageId(input: { [ "message", "mcp", - stablePart(input.scope.providerSessionId), + stablePart(input.scope.requestNamespace), stablePart(input.operation), stablePart(input.requestKey), ].join(":"), @@ -760,6 +768,35 @@ const make = Effect.gen(function* () { const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerAdapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2; const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService; + const projects = yield* ProjectService.ProjectService; + + /** A caller-named project, which must exist before anything is recorded against it. */ + const requireProject = (projectId: ProjectId) => + projects.getById(projectId).pipe( + Effect.mapError((error) => + failure("orchestration_error", `Unable to read project ${projectId}: ${error.message}`), + ), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(failure("invalid_request", `Project ${projectId} was not found.`)), + onSome: Effect.succeed, + }), + ), + ); + + /** A client has no thread to inherit a model from, so it falls back to the project default. */ + const projectDefaultModelSelection = ( + project: Effect.Success>, + ) => + project.defaultModelSelection === null + ? Effect.fail( + failure( + "invalid_request", + `Project ${project.id} has no default model, so this caller cannot pick one for it.`, + ), + ) + : Effect.succeed(project.defaultModelSelection); const requireCapability = (scope: McpInvocationScope) => scope.capabilities.has("orchestration") @@ -817,61 +854,133 @@ const make = Effect.gen(function* () { ) .pipe(Effect.mapError(threadManagementFailure)); - const loadScopedThread = (scope: McpInvocationScope, threadId: ThreadId) => + /** + * The caller's own thread and the most it may hand to threads it targets. A + * thread caller is capped by its own modes; an OAuth client by the ceiling + * chosen when it was approved. + */ + const loadCaller = (scope: McpInvocationScope) => Effect.gen(function* () { yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); + if (scope.thread === undefined) { + return { + parent: undefined, + limits: { + runtimeMode: scope.client?.runtimeModeCeiling ?? "approval-required", + interactionMode: "default", + } satisfies { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode }, + } as const; + } + const parent = yield* loadProjection(scope.thread.threadId); + return { + parent, + limits: { + runtimeMode: parent.thread.runtimeMode, + interactionMode: parent.thread.interactionMode, + }, + } as const; + }); + + /** The caller's own thread, for operations that act as the caller. */ + const loadThreadCaller = (scope: McpInvocationScope, operation: string) => + Effect.gen(function* () { + yield* requireCapability(scope); + const threadScope = yield* requireThreadScope(scope, operation); + const parent = yield* loadProjection(threadScope.thread.threadId); + return { scope: threadScope, parent } as const; + }); + + /** A target project: the one passed, else the calling thread's. */ + const resolveProjectTarget = ( + parent: Pick | undefined, + projectId: ProjectId | undefined, + ) => + projectId !== undefined + ? Effect.succeed(projectId) + : parent !== undefined + ? Effect.succeed(parent.thread.projectId) + : Effect.fail( + failure( + "target_required", + "Pass projectId: this MCP client is not running inside a T3 thread.", + ), + ); + + /** Any live thread in the environment. */ + const loadTargetThread = (threadId: ThreadId) => + Effect.gen(function* () { + const shell = yield* threadManagement + .getThreadShell(threadId) + .pipe(Effect.mapError(threadManagementFailure)); + if (shell === null || shell.deletedAt !== null) { + return yield* failure("thread_not_found", `Thread ${threadId} was not found.`); + } + return yield* loadProjectThread(shell.projectId, threadId); + }); + + const loadScopedThread = (scope: McpInvocationScope, threadId: ThreadId) => + Effect.gen(function* () { + const caller = yield* loadCaller(scope); const target = - threadId === scope.threadId - ? parent - : yield* loadProjectThread(parent.thread.projectId, threadId); - return { parent, target } as const; + caller.parent !== undefined && threadId === caller.parent.thread.id + ? caller.parent + : yield* loadTargetThread(threadId); + return { ...caller, target } as const; }); /** - * A thread the user attached as context (a `thread` record on one of their own messages) - * is readable even outside the calling project. Only records the user authored count: - * an agent cannot widen its own reach by writing a record. + * A thread caller writes to another thread only while its own run is live, + * so a credential that outlived its session cannot reach across threads. */ - const userAttachedThreadIds = ( - parent: Pick, - ): Set => { - const ids = new Set(); - for (const message of parent.messages) { - if (message.role !== "user" || message.createdBy !== "user") continue; - for (const record of message.context?.records ?? []) { - if (record.kind === "thread" && "threadId" in record) ids.add(record.threadId); - } - } - return ids; + const assertLiveCallerForOtherThread = ( + scope: McpInvocationScope, + parent: Pick | undefined, + target: Pick, + ) => + parent === undefined || target.thread.id === parent.thread.id + ? Effect.void + : assertLiveCaller(scope, parent); + + /** Scheduled work outside the caller's own project needs the same live run. */ + const assertLiveCallerForOtherProject = ( + scope: McpInvocationScope, + parent: Pick | undefined, + projectId: ProjectId, + ) => + parent === undefined || projectId === parent.thread.projectId + ? Effect.void + : assertLiveCaller(scope, parent); + + const assertLiveCaller = ( + scope: McpInvocationScope, + parent: Pick, + ) => { + const activeRun = ThreadManagementService.latestActiveRun(parent); + return parent.thread.archivedAt !== null || + activeRun === undefined || + activeRun.providerInstanceId !== scope.thread?.providerInstanceId + ? Effect.fail( + failure("parent_not_active", "The calling provider no longer owns an active thread run."), + ) + : Effect.void; }; const loadReadableThread = (scope: McpInvocationScope, threadId: ThreadId) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); - const loadTarget = () => - threadManagement - .getThreadRecords(threadId, ["runs", "runtimeRequests", "contextTransfers"]) - .pipe(Effect.mapError(threadManagementFailure)); - if (threadId === scope.threadId) return { parent, target: yield* loadTarget() } as const; + const { parent } = yield* loadCaller(scope); + const shell = yield* threadManagement + .getThreadShell(threadId) + .pipe(Effect.mapError(threadManagementFailure)); + if (shell === null || shell.deletedAt !== null) { + return yield* failure("thread_not_found", `Thread ${threadId} is no longer available.`); + } const target = yield* threadManagement - .getProjectThreadRecords({ projectId: parent.thread.projectId, threadId }, [ + .getProjectThreadRecords({ projectId: shell.projectId, threadId }, [ "runs", "runtimeRequests", "contextTransfers", ]) - .pipe( - Effect.mapError(threadManagementFailure), - Effect.catchIf( - (error) => - error.code === "thread_not_found" && userAttachedThreadIds(parent).has(threadId), - loadTarget, - ), - ); - if (target.thread.deletedAt !== null) { - return yield* failure("thread_not_found", `Thread ${threadId} is no longer available.`); - } + .pipe(Effect.mapError(threadManagementFailure)); return { parent, target } as const; }); @@ -1010,7 +1119,7 @@ const make = Effect.gen(function* () { : Effect.succeed(clientRequestId); const readTask = ( - scope: McpInvocationScope, + scope: McpThreadInvocationScope, taskId: NodeId, waitTimedOut = false, acknowledgeTerminal = false, @@ -1018,17 +1127,17 @@ const make = Effect.gen(function* () { ): Effect.Effect => Effect.gen(function* () { yield* requireCapability(scope); - const parentProjection = yield* loadProjection(scope.threadId); + const parentProjection = yield* loadProjection(scope.thread.threadId); const task = parentProjection.subagents.find( (candidate) => candidate.id === taskId && candidate.origin === "app_owned" && - candidate.threadId === scope.threadId, + candidate.threadId === scope.thread.threadId, ); if (task === undefined || task.childThreadId === null) { return yield* failure( "task_not_found", - `Delegated task ${taskId} does not belong to thread ${scope.threadId}.`, + `Delegated task ${taskId} does not belong to thread ${scope.thread.threadId}.`, ); } const childControls = yield* threadManagement @@ -1094,7 +1203,7 @@ const make = Effect.gen(function* () { (transfer) => transfer.type === "subagent_result" && transfer.sourceThreadId === task.childThreadId && - transfer.targetThreadId === scope.threadId, + transfer.targetThreadId === scope.thread.threadId, ); const resultTransferForRun = (run: OrchestrationV2Run | undefined) => !canExposeTaskRunResult(run) @@ -1145,10 +1254,10 @@ const make = Effect.gen(function* () { requestKey: acknowledgementRequestKey, operation: acknowledgementOperation, }), - parentThreadId: scope.threadId, + parentThreadId: scope.thread.threadId, taskId, observedByRunId: - observingRun?.providerInstanceId === scope.providerInstanceId + observingRun?.providerInstanceId === scope.thread.providerInstanceId ? observingRun.id : null, }) @@ -1164,7 +1273,7 @@ const make = Effect.gen(function* () { return response; }); - const waitForTask = (scope: McpInvocationScope, taskId: NodeId, timeoutMs: number) => + const waitForTask = (scope: McpThreadInvocationScope, taskId: NodeId, timeoutMs: number) => Effect.gen(function* () { while (true) { const result = yield* readTask(scope, taskId, false, true); @@ -1173,11 +1282,17 @@ const make = Effect.gen(function* () { } }).pipe(Effect.timeoutOption(Duration.millis(timeoutMs))); - // Load a single scheduled task and enforce that it belongs to the calling - // thread's project, so agents can only read/mutate tasks in their own scope. - const loadScopedScheduledTask = ( - projectId: ScheduledTask["projectId"], + /** + * A scheduled task the caller may change: one whose modes are no broader + * than the caller's own, so editing its prompt cannot run work above the + * caller's limits. + */ + const loadScheduledTask = ( scheduledTaskId: ScheduledTask["id"], + limits: { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + }, ): Effect.Effect => Effect.gen(function* () { const { tasks } = yield* scheduledTasks @@ -1188,21 +1303,38 @@ const make = Effect.gen(function* () { ), ); const task = tasks.find((candidate) => candidate.id === scheduledTaskId); - if (task === undefined || task.projectId !== projectId) { - return yield* failure( - "task_not_found", - `Scheduled task ${scheduledTaskId} was not found in the calling project.`, - ); + if (task === undefined) { + return yield* failure("task_not_found", `Scheduled task ${scheduledTaskId} was not found.`); } + yield* resolveRuntimeMode(limits.runtimeMode, task.runtimeMode); + yield* resolveInteractionMode(limits.interactionMode, task.interactionMode); return task; }); return OrchestratorMcpService.of({ scheduleTask: (scope, input) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); - const bindToCurrentThread = input.bindToCurrentThread ?? true; + const { parent, limits } = yield* loadCaller(scope); + const projectId = yield* resolveProjectTarget(parent, input.projectId); + yield* assertLiveCallerForOtherProject(scope, parent, projectId); + const project = yield* requireProject(projectId); + // Binding means "wake this thread", which only a thread caller in that project has. + const bindToCurrentThread = + input.bindToCurrentThread ?? + (parent !== undefined && parent.thread.projectId === projectId); + if ( + bindToCurrentThread && + (parent === undefined || parent.thread.projectId !== projectId) + ) { + return yield* failure( + "invalid_request", + parent === undefined + ? "bindToCurrentThread needs an agent running inside a T3 thread." + : "bindToCurrentThread binds to this thread, which belongs to a different project.", + ); + } + const modelSelection = + parent?.thread.modelSelection ?? (yield* projectDefaultModelSelection(project)); const derivedTitle = input.prompt.split("\n")[0]?.trim() ?? ""; const title = input.title ?? (derivedTitle.length > 0 ? derivedTitle.slice(0, 80) : "Scheduled task"); @@ -1211,12 +1343,12 @@ const make = Effect.gen(function* () { prompt: input.prompt, enabled: input.enabled ?? true, schedule: input.schedule, - projectId: parent.thread.projectId, - threadId: bindToCurrentThread ? scope.threadId : null, + projectId, + threadId: bindToCurrentThread && parent !== undefined ? parent.thread.id : null, workspaceStrategy: scheduledTaskWorkspaceStrategy(bindToCurrentThread), - modelSelection: parent.thread.modelSelection, - runtimeMode: parent.thread.runtimeMode, - interactionMode: parent.thread.interactionMode, + modelSelection, + runtimeMode: limits.runtimeMode, + interactionMode: limits.interactionMode, createdBy: "agent", creationSource: "mcp", // Scope the idempotency key by provider session so two callers @@ -1240,10 +1372,10 @@ const make = Effect.gen(function* () { ); return scheduledTaskSummary(task); }), - listScheduledTasks: (scope) => + listScheduledTasks: (scope, input) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); + const { parent } = yield* loadCaller(scope); + const projectId = input.projectId ?? parent?.thread.projectId; const { tasks } = yield* scheduledTasks .list() .pipe( @@ -1251,26 +1383,33 @@ const make = Effect.gen(function* () { failure("orchestration_error", `Could not list scheduled tasks: ${error.message}`), ), ); - // Only expose tasks belonging to the calling thread's project. return { tasks: tasks - .filter((task) => task.projectId === parent.thread.projectId) + .filter((task) => projectId === undefined || task.projectId === projectId) .map(scheduledTaskSummary), }; }), updateScheduledTask: (scope, input) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); - const existing = yield* loadScopedScheduledTask( - parent.thread.projectId, - input.scheduledTaskId, - ); + const { parent, limits } = yield* loadCaller(scope); + const existing = yield* loadScheduledTask(input.scheduledTaskId, limits); + yield* assertLiveCallerForOtherProject(scope, parent, existing.projectId); + if ( + input.bindToCurrentThread === true && + (parent === undefined || parent.thread.projectId !== existing.projectId) + ) { + return yield* failure( + "invalid_request", + parent === undefined + ? "bindToCurrentThread needs an agent running inside a T3 thread." + : "bindToCurrentThread binds to this thread, which belongs to a different project.", + ); + } const threadId = input.bindToCurrentThread === undefined ? existing.threadId - : input.bindToCurrentThread - ? scope.threadId + : input.bindToCurrentThread && parent !== undefined + ? parent.thread.id : null; // Rebinding changes where runs execute, so the workspace strategy must // follow: unbinding a root-strategy task would otherwise run loose @@ -1305,12 +1444,9 @@ const make = Effect.gen(function* () { }), deleteScheduledTask: (scope, input) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); - const existing = yield* loadScopedScheduledTask( - parent.thread.projectId, - input.scheduledTaskId, - ); + const { parent, limits } = yield* loadCaller(scope); + const existing = yield* loadScheduledTask(input.scheduledTaskId, limits); + yield* assertLiveCallerForOtherProject(scope, parent, existing.projectId); yield* scheduledTasks .delete({ id: existing.id }) .pipe( @@ -1322,16 +1458,15 @@ const make = Effect.gen(function* () { }), capabilities: (scope) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); + const { parent, limits } = yield* loadCaller(scope); const providers = yield* loadProviders; const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds(); return { - parentThreadId: scope.threadId, - inheritedProviderInstanceId: parent.thread.modelSelection.instanceId, - inheritedModel: parent.thread.modelSelection.model, - runtimeMode: parent.thread.runtimeMode, - interactionMode: parent.thread.interactionMode, + parentThreadId: parent?.thread.id ?? null, + inheritedProviderInstanceId: parent?.thread.modelSelection.instanceId ?? null, + inheritedModel: parent?.thread.modelSelection.model ?? null, + runtimeMode: limits.runtimeMode, + interactionMode: limits.interactionMode, providers: providers.map((provider) => { const constraints = providerConstraints( provider, @@ -1366,17 +1501,16 @@ const make = Effect.gen(function* () { }, }; }), - delegateTask: (scope, input) => + delegateTask: (callerScope, input) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); + const { scope, parent } = yield* loadThreadCaller(callerScope, "delegate_task"); const parentRun = parent.runs .filter(ThreadManagementService.isActiveRun) .toSorted((left, right) => right.ordinal - left.ordinal)[0]; if ( parentRun === undefined || parentRun.rootNodeId === null || - parentRun.providerInstanceId !== scope.providerInstanceId + parentRun.providerInstanceId !== scope.thread.providerInstanceId ) { return yield* failure( "parent_not_active", @@ -1406,7 +1540,7 @@ const make = Effect.gen(function* () { createdBy: "agent", creationSource: "mcp", commandId, - parentThreadId: scope.threadId, + parentThreadId: scope.thread.threadId, parentRunId: parentRun.id, parentNodeId: parentRun.rootNodeId, task: taskPrompt(input), @@ -1462,7 +1596,7 @@ const make = Effect.gen(function* () { requestKey: key, operation: "delegate-task-wake-policy", }), - parentThreadId: scope.threadId, + parentThreadId: scope.thread.threadId, taskId, completionWake: "always", }) @@ -1491,12 +1625,17 @@ const make = Effect.gen(function* () { ); return yield* readTask(scope, taskId, true, true); }), - taskStatus: (scope, taskId) => readTask(scope, taskId, false, true), - cancelTask: (scope, input) => + taskStatus: (callerScope, taskId) => Effect.gen(function* () { + const scope = yield* requireThreadScope(callerScope, "task_status"); + return yield* readTask(scope, taskId, false, true); + }), + cancelTask: (callerScope, input) => + Effect.gen(function* () { + const scope = yield* requireThreadScope(callerScope, "task_cancel"); const current = yield* readTask(scope, input.taskId); const key = yield* requestKey(input.clientRequestId); - const parentProjection = yield* loadProjection(scope.threadId); + const parentProjection = yield* loadProjection(scope.thread.threadId); const parentTask = parentProjection.subagents.find( (task) => task.id === input.taskId && task.origin === "app_owned", ); @@ -1511,7 +1650,7 @@ const make = Effect.gen(function* () { requestKey: key, operation: "cancel-task-completion-delivery", }), - parentThreadId: scope.threadId, + parentThreadId: scope.thread.threadId, taskId: input.taskId, }) .pipe( @@ -1573,15 +1712,14 @@ const make = Effect.gen(function* () { status: "cancel_requested", }; }), - createThreads: (scope, input) => + createThreads: (callerScope, input) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); + const { scope, parent } = yield* loadThreadCaller(callerScope, "create_threads"); const parentRun = ThreadManagementService.latestActiveRun(parent); if ( parentRun === undefined || parentRun.rootNodeId === null || - parentRun.providerInstanceId !== scope.providerInstanceId + parentRun.providerInstanceId !== scope.thread.providerInstanceId ) { return yield* failure( "parent_not_active", @@ -1660,7 +1798,7 @@ const make = Effect.gen(function* () { index, }), threadId, - senderThreadId: scope.threadId, + senderThreadId: scope.thread.threadId, messageId: stableMessageId({ scope, requestKey: key, @@ -1691,7 +1829,7 @@ const make = Effect.gen(function* () { operation: "record-created-thread", index, }), - parentThreadId: scope.threadId, + parentThreadId: scope.thread.threadId, parentRunId: parentRun.id, parentNodeId, targetThreadId: threadId, @@ -1722,11 +1860,11 @@ const make = Effect.gen(function* () { }), listThreads: (scope, input) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); + const { parent } = yield* loadCaller(scope); + const projectId = yield* resolveProjectTarget(parent, input.projectId); const projectThreads = yield* threadManagement .listProjectThreads({ - projectId: parent.thread.projectId, + projectId, includeSubagents: input.includeSubagents !== false, }) .pipe( @@ -1755,8 +1893,8 @@ const make = Effect.gen(function* () { const page = filtered.slice(cursor, cursor + limit); const nextCursor = cursor + page.length < filtered.length ? cursor + page.length : null; return { - projectId: parent.thread.projectId, - currentThreadId: scope.threadId, + projectId, + currentThreadId: parent?.thread.id ?? null, threads: page.map(listItemFromShell), nextCursor, total: filtered.length, @@ -1795,8 +1933,13 @@ const make = Effect.gen(function* () { { concurrency: 1 }, ); const messagesByThreadId = new Map(sourceMessages); - const task = directAppOwnedChildTask(parent, target); - if (task !== undefined && (input.textOffset ?? 0) === 0) { + const task = parent === undefined ? undefined : directAppOwnedChildTask(parent, target); + if ( + parent !== undefined && + scope.thread !== undefined && + task !== undefined && + (input.textOffset ?? 0) === 0 + ) { const transfer = parent.contextTransfers.find( (transfer) => transfer.type === "subagent_result" && @@ -1822,7 +1965,13 @@ const make = Effect.gen(function* () { maxChars, }) ) { - yield* readTask(scope, task.id, false, true, "thread-read-acknowledge"); + yield* readTask( + scope as McpThreadInvocationScope, + task.id, + false, + true, + "thread-read-acknowledge", + ); } } return { @@ -1845,9 +1994,10 @@ const make = Effect.gen(function* () { }), sendToThread: (scope, input) => Effect.gen(function* () { - const { parent, target } = yield* loadScopedThread(scope, input.threadId); - yield* resolveRuntimeMode(parent.thread.runtimeMode, target.thread.runtimeMode); - yield* resolveInteractionMode(parent.thread.interactionMode, target.thread.interactionMode); + const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId); + yield* assertLiveCallerForOtherThread(scope, parent, target); + yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); + yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); const mode = input.mode ?? "auto"; const key = yield* requestKey(input.clientRequestId); @@ -1858,14 +2008,14 @@ const make = Effect.gen(function* () { }); const result = yield* threadManagement .sendToThread({ - projectId: parent.thread.projectId, + projectId: target.thread.projectId, commandId: stableCommandId({ scope, requestKey: key, operation: "thread-send", }), threadId: input.threadId, - senderThreadId: scope.threadId, + ...(parent === undefined ? {} : { senderThreadId: parent.thread.id }), messageId, text: input.message, attachments: [], @@ -1893,10 +2043,10 @@ const make = Effect.gen(function* () { }), waitForThread: (scope, input) => Effect.gen(function* () { - const { parent } = yield* loadScopedThread(scope, input.threadId); + const { target } = yield* loadScopedThread(scope, input.threadId); const result = yield* threadManagement .waitForThread({ - projectId: parent.thread.projectId, + projectId: target.thread.projectId, threadId: input.threadId, ...(input.runId === undefined ? {} : { runId: input.runId }), timeoutMs: Math.min( @@ -1914,11 +2064,15 @@ const make = Effect.gen(function* () { }), interruptThread: (scope, input) => Effect.gen(function* () { - const { parent } = yield* loadScopedThread(scope, input.threadId); + const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId); + yield* assertLiveCallerForOtherThread(scope, parent, target); + // Stopping another thread's work is a write: it must run within the caller's modes. + yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); + yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); const key = yield* requestKey(input.clientRequestId); const result = yield* threadManagement .interruptThread({ - projectId: parent.thread.projectId, + projectId: target.thread.projectId, commandId: stableCommandId({ scope, requestKey: key, @@ -1962,4 +2116,5 @@ export const layer: Layer.Layer< | ProviderRegistry.ProviderRegistry | ProviderAdapterRegistry.ProviderAdapterRegistryV2 | ScheduledTaskService.ScheduledTaskService + | ProjectService.ProjectService > = Layer.effect(OrchestratorMcpService, make); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 142e0cc55f12..132b2d3f635f 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -71,6 +71,7 @@ import { materializeReplayTranscriptWorkspace, } from "../orchestration-v2/testkit/ReplayTranscriptNdjson.ts"; import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; +import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; @@ -631,6 +632,16 @@ describe("orchestrator MCP toolkit", () => { Layer.provide(registryLayer), Layer.provide(providerRegistryLayer), Layer.provide(scheduledTaskStubLayer), + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: (id) => + Effect.succeed( + id === projectId + ? Option.some({ id, defaultModelSelection: null } as never) + : Option.none(), + ), + }), + ), Layer.provide(NodeServices.layer), ); @@ -676,9 +687,13 @@ describe("orchestrator MCP toolkit", () => { const invocation: McpInvocationContext.McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-orchestrator"), - threadId: parentThreadId, - providerSessionId: "mcp-provider-session-parent", - providerInstanceId: codexInstanceId, + requestNamespace: "mcp-provider-session-parent", + thread: { + threadId: parentThreadId, + providerSessionId: "mcp-provider-session-parent", + providerInstanceId: codexInstanceId, + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }; @@ -2326,31 +2341,38 @@ describe("orchestrator MCP toolkit", () => { branch: null, worktreePath: cwd, }); + // Targets reach the whole environment; the caller's modes still cap writes. const foreignOrganizeCall = yield* invoke("t3_thread_organize", { threadId: foreignThreadId, action: "pin", }); - expect(foreignOrganizeCall.structuredContent).toMatchObject({ - code: "thread_not_found", - }); - expect((yield* orchestrator.getThreadShell(foreignThreadId))?.pinnedAt).toBeNull(); + expect(foreignOrganizeCall.isError).toBe(false); + expect((yield* orchestrator.getThreadShell(foreignThreadId))?.pinnedAt).not.toBeNull(); const foreignReadCall = yield* invoke("t3_thread_read", { threadId: foreignThreadId, }); expect(foreignReadCall.structuredContent).toMatchObject({ - _tag: "OrchestratorMcpFailure", - code: "thread_not_found", + thread: { threadId: foreignThreadId, projectId: "project:mcp-foreign" }, }); const foreignUpdateCall = yield* invoke("t3_thread_update", { threadId: foreignThreadId, action: "rename", - title: "Should stay foreign", + title: "Renamed from another project", }); expect(foreignUpdateCall.structuredContent).toMatchObject({ - _tag: "OrchestratorMcpFailure", - code: "thread_not_found", + threadId: foreignThreadId, + title: "Renamed from another project", + }); + const foreignListCall = yield* invoke("t3_thread_list", { + projectId: "project:mcp-foreign", }); + const foreignListed = yield* decodeThreadListResult( + foreignListCall.structuredContent, + ).pipe(Effect.orDie); + expect(foreignListed.threads.map((thread) => thread.threadId)).toEqual([ + foreignThreadId, + ]); const listCall = yield* invoke("t3_thread_list", { includeSubagents: false, limit: 100, @@ -3548,6 +3570,7 @@ describe("orchestrator MCP toolkit", () => { ), Layer.provide(providerRegistryLayer), Layer.provide(unusedScheduledTaskStubLayer), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(NodeServices.layer), ); @@ -3602,9 +3625,13 @@ describe("orchestrator MCP toolkit", () => { const invocation: McpInvocationContext.McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-replay"), - threadId: parentThreadId, - providerSessionId: "mcp-provider-session-replay-parent", - providerInstanceId: codexInstanceId, + requestNamespace: "mcp-provider-session-replay-parent", + thread: { + threadId: parentThreadId, + providerSessionId: "mcp-provider-session-replay-parent", + providerInstanceId: codexInstanceId, + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }; diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index adf55cad5e91..fe08b0875ed1 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -36,9 +36,13 @@ const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices const scope = { environmentId: EnvironmentId.make("environment-1"), - threadId: ThreadId.make("thread-1"), - providerSessionId: "provider-session-1", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session-1", + thread: { + threadId: ThreadId.make("thread-1"), + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(["preview"] as const), issuedAt: 1, }; @@ -366,9 +370,9 @@ it.effect("preserves bounded request and remote selector diagnostics", () => { expect(error).toMatchObject({ operation: "click", environmentId: scope.environmentId, - threadId: scope.threadId, - providerSessionId: scope.providerSessionId, - providerInstanceId: scope.providerInstanceId, + threadId: scope.thread.threadId, + providerSessionId: scope.thread.providerSessionId, + providerInstanceId: scope.thread.providerInstanceId, clientId: "client-1", requestId: "preview-0", tabId: "tab-1", @@ -468,7 +472,7 @@ it.effect.each([ .pipe(Effect.flip); expect(error).toMatchObject({ _tag: tag, - threadId: scope.threadId, + threadId: scope.thread.threadId, }); expect(error.cause).toBe(remoteError); expect(error.message).toContain("remains on the desktop"); @@ -500,9 +504,9 @@ it.effect("distinguishes malformed remote failures", () => expect(error).toMatchObject({ operation: "status", environmentId: scope.environmentId, - threadId: scope.threadId, - providerSessionId: scope.providerSessionId, - providerInstanceId: scope.providerInstanceId, + threadId: scope.thread.threadId, + providerSessionId: scope.thread.providerSessionId, + providerInstanceId: scope.thread.providerInstanceId, clientId: "client-1", requestId: "preview-0", timeoutMs: 2_000, @@ -522,9 +526,9 @@ it.effect("rejects calls when no connected host exists", () => expect(error).toMatchObject({ operation: "status", environmentId: scope.environmentId, - threadId: scope.threadId, - providerSessionId: scope.providerSessionId, - providerInstanceId: scope.providerInstanceId, + threadId: scope.thread.threadId, + providerSessionId: scope.thread.providerSessionId, + providerInstanceId: scope.thread.providerInstanceId, }); }), ); @@ -590,8 +594,11 @@ it.effect("routes requests for background threads through an environment-level h const result = yield* broker.invoke({ scope: { ...scope, - threadId: backgroundThreadId, - providerSessionId: "provider-session-background", + thread: { + ...scope.thread, + threadId: backgroundThreadId, + providerSessionId: "provider-session-background", + }, }, operation: "status", input: {}, @@ -688,7 +695,7 @@ it.effect("pins a provider session to its initial host despite later focus chang environmentId: scope.environmentId, connectionId: "connection-stale", focused: true, - liveTabs: [{ threadId: scope.threadId, tabId: PreviewTabId.make("stale-tab") }], + liveTabs: [{ threadId: scope.thread.threadId, tabId: PreviewTabId.make("stale-tab") }], }); expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( "second", @@ -702,7 +709,7 @@ it.effect("pins a provider session to its initial host despite later focus chang const firstPinnedScope = { ...scope, - providerSessionId: "provider-session-first-pinned", + thread: { ...scope.thread, providerSessionId: "provider-session-first-pinned" }, }; expect( yield* broker.invoke({ scope: firstPinnedScope, operation: "status", input: {} }), @@ -720,7 +727,10 @@ it.effect("pins a provider session to its initial host despite later focus chang ).toBe("first"); expect( yield* broker.invoke({ - scope: { ...scope, providerSessionId: "provider-session-second-pinned" }, + scope: { + ...scope, + thread: { ...scope.thread, providerSessionId: "provider-session-second-pinned" }, + }, operation: "status", input: {}, }), @@ -756,7 +766,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l connectionId: connections.get("owner")!, focused: false, liveTabs: [ - { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: true }, + { threadId: scope.thread.threadId, tabId: PreviewTabId.make("signed-in"), visible: true }, ], }); yield* broker.focusHost({ @@ -765,7 +775,11 @@ it.effect("prefers the live tab owner for new sessions without moving existing l connectionId: connections.get("other")!, focused: true, liveTabs: [ - { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: false }, + { + threadId: scope.thread.threadId, + tabId: PreviewTabId.make("signed-in"), + visible: false, + }, { threadId: ThreadId.make("another-thread"), tabId: PreviewTabId.make("different-tab"), @@ -778,7 +792,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l ); expect( yield* broker.invoke({ - scope: { ...scope, providerSessionId: "explicit-owner" }, + scope: { ...scope, thread: { ...scope.thread, providerSessionId: "explicit-owner" } }, tabId: PreviewTabId.make("signed-in"), operation: "snapshot", input: {}, @@ -786,7 +800,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l ).toBe("owner"); expect( yield* broker.invoke({ - scope: { ...scope, providerSessionId: "other-tab" }, + scope: { ...scope, thread: { ...scope.thread, providerSessionId: "other-tab" } }, tabId: PreviewTabId.make("different-tab"), operation: "evaluate", input: {}, @@ -805,7 +819,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l ); expect( yield* broker.invoke({ - scope: { ...scope, providerSessionId: "after-tab-closed" }, + scope: { ...scope, thread: { ...scope.thread, providerSessionId: "after-tab-closed" } }, operation: "evaluate", input: {}, }), @@ -1049,7 +1063,7 @@ it.effect("fails over a pinned provider session only after its host disconnects" environmentId: scope.environmentId, connectionId: firstConnectionId, focused: true, - liveTabs: [{ threadId: scope.threadId, tabId: firstTabId }], + liveTabs: [{ threadId: scope.thread.threadId, tabId: firstTabId }], }); expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ host: "first", @@ -1193,9 +1207,9 @@ it.effect("fails requests assigned to the stream that is replaced", () => expect(error).toMatchObject({ operation: "status", environmentId: scope.environmentId, - threadId: scope.threadId, - providerSessionId: scope.providerSessionId, - providerInstanceId: scope.providerInstanceId, + threadId: scope.thread.threadId, + providerSessionId: scope.thread.providerSessionId, + providerInstanceId: scope.thread.providerInstanceId, clientId: "client-1", requestId: "preview-0", timeoutMs: 15_000, diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 65e3064f49a5..2e0534fec25a 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -40,7 +40,8 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as McpInvocationContext from "./McpInvocationContext.ts"; export interface PreviewAutomationInvokeInput { - readonly scope: McpInvocationContext.McpInvocationScope; + /** Preview tabs belong to a thread, so only thread callers reach the broker. */ + readonly scope: McpInvocationContext.McpThreadInvocationScope; readonly operation: PreviewAutomationOperation; readonly input: unknown; readonly tabId?: PreviewTabId; @@ -103,9 +104,9 @@ interface HostAssignment { interface PreviewAutomationRequestErrorContext { readonly operation: PreviewAutomationOperation; readonly environmentId: McpInvocationContext.McpInvocationScope["environmentId"]; - readonly threadId: McpInvocationContext.McpInvocationScope["threadId"]; + readonly threadId: McpInvocationContext.McpThreadCaller["threadId"]; readonly providerSessionId: string; - readonly providerInstanceId: McpInvocationContext.McpInvocationScope["providerInstanceId"]; + readonly providerInstanceId: McpInvocationContext.McpThreadCaller["providerInstanceId"]; readonly clientId: string; readonly connectionId: ClientConnection["connectionId"]; readonly requestId: string; @@ -160,8 +161,8 @@ const selectorDiagnosticsFromInput = ( return {}; }; -const hostAssignmentKey = (scope: McpInvocationContext.McpInvocationScope): string => - `${scope.environmentId}\u0000${scope.providerSessionId}`; +const hostAssignmentKey = (scope: McpInvocationContext.McpThreadInvocationScope): string => + `${scope.environmentId}\u0000${scope.thread.providerSessionId}`; const isPreviewTabId = Schema.is(PreviewTabId); @@ -498,7 +499,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const ownsTargetTab = (host: ClientConnection, visibleOnly = false) => host.liveTabs.some( (tab) => - tab.threadId === input.scope.threadId && + tab.threadId === input.scope.thread.threadId && (!visibleOnly || tab.visible === true) && (input.tabId === undefined || tab.tabId === input.tabId), ); @@ -545,9 +546,9 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const context: PreviewAutomationRequestErrorContext = { operation: input.operation, environmentId: input.scope.environmentId, - threadId: input.scope.threadId, - providerSessionId: input.scope.providerSessionId, - providerInstanceId: input.scope.providerInstanceId, + threadId: input.scope.thread.threadId, + providerSessionId: input.scope.thread.providerSessionId, + providerInstanceId: input.scope.thread.providerInstanceId, clientId: connection.clientId, connectionId: connection.connectionId, requestId, @@ -566,9 +567,9 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { return yield* new PreviewAutomationNoAvailableHostError({ operation: input.operation, environmentId: input.scope.environmentId, - threadId: input.scope.threadId, - providerSessionId: input.scope.providerSessionId, - providerInstanceId: input.scope.providerInstanceId, + threadId: input.scope.thread.threadId, + providerSessionId: input.scope.thread.providerSessionId, + providerInstanceId: input.scope.thread.providerInstanceId, }); } const { connection, requestId, requestContext, requestSequence } = route; @@ -594,7 +595,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { connectionId: connection.connectionId, request: { requestId, - threadId: input.scope.threadId, + threadId: input.scope.thread.threadId, tabId: requestContext.tabId, tabIdExplicit: input.tabId !== undefined, operation: input.operation, diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.test.ts b/apps/server/src/mcp/ThreadMetadataMcpService.test.ts index 2f6f8a8d2134..65ec00372167 100644 --- a/apps/server/src/mcp/ThreadMetadataMcpService.test.ts +++ b/apps/server/src/mcp/ThreadMetadataMcpService.test.ts @@ -12,9 +12,13 @@ import * as ThreadMetadataMcp from "./ThreadMetadataMcpService.ts"; const threadId = ThreadId.make("thread:metadata-caller"); const scope: McpInvocationContext.McpInvocationScope = { environmentId: EnvironmentId.make("environment:metadata-test"), - threadId, - providerSessionId: "provider-session:metadata-test", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session:metadata-test", + thread: { + threadId, + providerSessionId: "provider-session:metadata-test", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }; @@ -74,3 +78,91 @@ it.effect("keeps calling-thread storage failures as orchestration errors", () => expect(error.code).toBe("orchestration_error"); }), ); + +it.effect("refuses to change a thread that runs above the caller's modes", () => + Effect.gen(function* () { + const fullAccessThread = ThreadId.make("thread:metadata-full-access"); + const shells = new Map([ + [ + threadId, + { + id: threadId, + projectId: "project", + runtimeMode: "auto", + interactionMode: "default", + activeRunId: "run-live", + archivedAt: null, + providerInstanceId: "codex", + deletedAt: null, + }, + ], + [ + fullAccessThread, + { + id: fullAccessThread, + projectId: "project", + runtimeMode: "full-access", + interactionMode: "default", + deletedAt: null, + }, + ], + ]); + const error = yield* Effect.gen(function* () { + const service = yield* ThreadMetadataMcp.ThreadMetadataMcpService; + return yield* service.update(scope, { + threadId: fullAccessThread, + action: "rename", + title: "Renamed from a narrower thread", + }); + }).pipe( + Effect.provide(serviceLayer((id) => Effect.succeed((shells.get(id) ?? null) as never))), + Effect.flip, + ); + + expect(error.code).toBe("runtime_mode_escalation_denied"); + }), +); + +it.effect("refuses another thread's metadata to a thread caller whose run has ended", () => + Effect.gen(function* () { + const otherThread = ThreadId.make("thread:metadata-other"); + const shells = new Map([ + [ + threadId, + { + id: threadId, + projectId: "project", + runtimeMode: "full-access", + interactionMode: "default", + activeRunId: null, + archivedAt: null, + providerInstanceId: "codex", + deletedAt: null, + }, + ], + [ + otherThread, + { + id: otherThread, + projectId: "project", + runtimeMode: "approval-required", + interactionMode: "default", + deletedAt: null, + }, + ], + ]); + const error = yield* Effect.gen(function* () { + const service = yield* ThreadMetadataMcp.ThreadMetadataMcpService; + return yield* service.update(scope, { + threadId: otherThread, + action: "rename", + title: "Renamed after the run ended", + }); + }).pipe( + Effect.provide(serviceLayer((id) => Effect.succeed((shells.get(id) ?? null) as never))), + Effect.flip, + ); + + expect(error.code).toBe("parent_not_active"); + }), +); diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.ts b/apps/server/src/mcp/ThreadMetadataMcpService.ts index d5f029626b12..6c552511e722 100644 --- a/apps/server/src/mcp/ThreadMetadataMcpService.ts +++ b/apps/server/src/mcp/ThreadMetadataMcpService.ts @@ -16,6 +16,7 @@ import * as Layer from "effect/Layer"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import { assertTargetWithinLimits } from "./threadAccess.ts"; export class ThreadMetadataMcpService extends Context.Service< ThreadMetadataMcpService, @@ -57,7 +58,7 @@ function commandId(input: { [ "command", "mcp", - stablePart(input.scope.providerSessionId), + stablePart(input.scope.requestNamespace), "thread-update", stablePart(input.threadId), stablePart(input.action), @@ -146,36 +147,65 @@ const make = Effect.gen(function* () { ); } - const parentShell = yield* threadManagement - .getThreadShell(scope.threadId) - .pipe( - Effect.mapError((error) => - failure( - "orchestration_error", - `Unable to locate calling thread ${scope.threadId}: ${errorMessage(error)}`, - ), - ), + const threadId = input.threadId ?? scope.thread?.threadId; + if (threadId === undefined) { + return yield* failure( + "target_required", + "Pass threadId: this MCP client is not running inside a T3 thread.", ); - if (parentShell === null) { - return yield* failure("thread_not_found", `Calling thread ${scope.threadId} was not found.`); } - const parent = yield* threadManagement - .getThreadRecords(scope.threadId, []) + const shell = yield* threadManagement + .getThreadShell(threadId) .pipe( Effect.mapError((error) => failure( "orchestration_error", - `Unable to read calling thread ${scope.threadId}: ${errorMessage(error)}`, + `Unable to locate thread ${threadId}: ${errorMessage(error)}`, ), ), ); - const threadId = input.threadId ?? scope.threadId; - const target = - threadId === scope.threadId - ? parent - : yield* threadManagement - .getProjectThreadRecords({ projectId: parent.thread.projectId, threadId }, []) - .pipe(Effect.mapError(threadLookupFailure)); + if (shell === null || shell.deletedAt !== null) { + return yield* failure("thread_not_found", `Thread ${threadId} was not found.`); + } + // Another thread may only be changed if it runs within the caller's own modes. + if (threadId !== scope.thread?.threadId) { + const limits = + scope.thread === undefined + ? { + runtimeMode: scope.client?.runtimeModeCeiling ?? ("approval-required" as const), + interactionMode: "default" as const, + } + : yield* threadManagement.getThreadShell(scope.thread.threadId).pipe( + Effect.mapError((error) => + failure( + "orchestration_error", + `Unable to locate calling thread: ${errorMessage(error)}`, + ), + ), + Effect.flatMap((caller) => + caller === null + ? Effect.fail(failure("thread_not_found", "The calling thread was not found.")) + : // Like every other cross-thread write, a thread caller needs its live run. + caller.archivedAt !== null || + caller.activeRunId === null || + caller.providerInstanceId !== scope.thread?.providerInstanceId + ? Effect.fail( + failure( + "parent_not_active", + "The calling provider no longer owns an active thread run.", + ), + ) + : Effect.succeed({ + runtimeMode: caller.runtimeMode, + interactionMode: caller.interactionMode, + }), + ), + ); + yield* assertTargetWithinLimits(limits, shell); + } + const target = yield* threadManagement + .getProjectThreadRecords({ projectId: shell.projectId, threadId }, []) + .pipe(Effect.mapError(threadLookupFailure)); const requestKey = input.clientRequestId === undefined ? yield* crypto.randomUUIDv4.pipe(Effect.orDie) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 06e997076647..6105e787d585 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -42,9 +42,13 @@ const makeScope = ( capabilities: ReadonlySet, ): McpInvocationContext.McpInvocationScope => ({ environmentId, - threadId, - providerSessionId: "provider-session-worktree-test", - providerInstanceId: ProviderInstanceId.make("claudeAgent"), + requestNamespace: "provider-session-worktree-test", + thread: { + threadId, + providerSessionId: "provider-session-worktree-test", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + }, + client: undefined, capabilities, issuedAt: 1, }); diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index fb0bd85d70d3..06eb43f41e6c 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -23,7 +23,7 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; -import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import type { McpInvocationScope, McpThreadInvocationScope } from "./McpInvocationContext.ts"; export class WorktreeMcpService extends Context.Service< WorktreeMcpService, @@ -70,6 +70,17 @@ const make = Effect.gen(function* () { // one untracked on disk. const handoffThreadsInFlight = new Set(); + // Worktree tools act on the calling thread's own checkout binding. + const requireThreadScope = (scope: McpInvocationScope) => + scope.thread === undefined + ? Effect.fail( + failure( + "thread_credential_required", + "Worktree handoff and status act as the calling T3 thread, so they need an agent running inside T3 Code.", + ), + ) + : Effect.succeed(scope as McpThreadInvocationScope); + const requireCapability = (scope: McpInvocationScope) => scope.capabilities.has("worktree") ? Effect.void @@ -77,23 +88,23 @@ const make = Effect.gen(function* () { failure("capability_denied", "This MCP credential does not grant worktree capabilities."), ); - const loadThread = (scope: McpInvocationScope) => - threadManagement.getThreadRecords(scope.threadId, []).pipe( + const loadThread = (scope: McpThreadInvocationScope) => + threadManagement.getThreadRecords(scope.thread.threadId, []).pipe( Effect.mapError((error) => error._tag === "OrchestratorProjectionError" - ? failure("thread_not_found", `Thread '${scope.threadId}' was not found.`) + ? failure("thread_not_found", `Thread '${scope.thread.threadId}' was not found.`) : failure( "operation_failed", - `Unable to read thread ${scope.threadId}: ${errorMessage(error)}`, + `Unable to read thread ${scope.thread.threadId}: ${errorMessage(error)}`, ), ), Effect.filterOrFail( (projection) => projection.thread.deletedAt === null, - () => failure("thread_not_found", `Thread '${scope.threadId}' was not found.`), + () => failure("thread_not_found", `Thread '${scope.thread.threadId}' was not found.`), ), ); - const loadProject = (scope: McpInvocationScope, projectId: ProjectId) => + const loadProject = (scope: McpThreadInvocationScope, projectId: ProjectId) => projects.getById(projectId).pipe( asOperationFailed(`Unable to read project ${projectId}`), Effect.flatMap( @@ -102,7 +113,7 @@ const make = Effect.gen(function* () { Effect.fail( failure( "project_not_found", - `Project '${projectId}' was not found for thread '${scope.threadId}'.`, + `Project '${projectId}' was not found for thread '${scope.thread.threadId}'.`, ), ), onSome: Effect.succeed, @@ -115,11 +126,13 @@ const make = Effect.gen(function* () { asOperationFailed("Unable to read server settings"), ); - const handoffIds = (scope: McpInvocationScope) => + const handoffIds = (scope: McpThreadInvocationScope) => crypto.randomUUIDv4.pipe( Effect.map((uuid) => { const part = (kind: string, operation: string) => - [kind, "mcp", encodeURIComponent(scope.providerSessionId), operation, uuid].join(":"); + [kind, "mcp", encodeURIComponent(scope.thread.providerSessionId), operation, uuid].join( + ":", + ); return { commandId: CommandId.make(part("command", "worktree-handoff")), continuationCommandId: CommandId.make(part("command", "worktree-continuation")), @@ -130,13 +143,13 @@ const make = Effect.gen(function* () { ); const performHandoff = Effect.fn("WorktreeMcpService.performHandoff")(function* ( - scope: McpInvocationScope, + scope: McpThreadInvocationScope, input: WorktreeMcpHandoffInput, ) { const alreadyInWorktree = (worktreePath: string) => failure( "already_in_worktree", - `Thread '${scope.threadId}' is already attached to worktree '${worktreePath}'.`, + `Thread '${scope.thread.threadId}' is already attached to worktree '${worktreePath}'.`, ); const projection = yield* loadThread(scope); @@ -148,7 +161,7 @@ const make = Effect.gen(function* () { if (projection.thread.archivedAt !== null) { return yield* failure( "invalid_request", - `Thread '${scope.threadId}' is archived and cannot be handed off to a worktree.`, + `Thread '${scope.thread.threadId}' is archived and cannot be handed off to a worktree.`, ); } @@ -263,7 +276,7 @@ const make = Effect.gen(function* () { Effect.catchCause((cause: Cause.Cause) => { const detail = errorMessage(Cause.squash(cause)); return Effect.logWarning(logMessage, { - threadId: scope.threadId, + threadId: scope.thread.threadId, worktreePath, detail, }).pipe(Effect.as({ status: "failed", detail } as const)); @@ -301,14 +314,14 @@ const make = Effect.gen(function* () { if (recheck.thread.archivedAt !== null) { return yield* failure( "invalid_request", - `Thread '${scope.threadId}' was archived while the worktree was being created; the handoff was rolled back.`, + `Thread '${scope.thread.threadId}' was archived while the worktree was being created; the handoff was rolled back.`, ); } yield* threadManagement .dispatch({ type: "thread.metadata.update", commandId: ids.commandId, - threadId: scope.threadId, + threadId: scope.thread.threadId, branch: worktree.worktree.refName, worktreePath, expectedWorktreePath: null, @@ -357,7 +370,7 @@ const make = Effect.gen(function* () { .sendToThread({ projectId: projection.thread.projectId, commandId: ids.continuationCommandId, - threadId: scope.threadId, + threadId: scope.thread.threadId, messageId: ids.continuationMessageId, text: input.continuationPrompt, attachments: [], @@ -386,7 +399,7 @@ const make = Effect.gen(function* () { if (input.runSetupScript ?? true) { setupScript = yield* setupScriptRunner .runForThread({ - threadId: scope.threadId, + threadId: scope.thread.threadId, projectId: projection.thread.projectId, projectCwd, worktreePath, @@ -430,8 +443,9 @@ const make = Effect.gen(function* () { }); const handoff: WorktreeMcpService["Service"]["handoff"] = Effect.fn("WorktreeMcpService.handoff")( - function* (scope, input) { - yield* requireCapability(scope); + function* (callerScope, input) { + yield* requireCapability(callerScope); + const scope = yield* requireThreadScope(callerScope); // uninterruptibleMask: the guard acquisition and the registration of the // releasing finalizer happen with no interruptible gap in between. An // interrupt landing between a bare add() and the start of an ensured @@ -439,17 +453,19 @@ const make = Effect.gen(function* () { // handoff for this thread until restart. return yield* Effect.uninterruptibleMask((restore) => Effect.suspend(() => { - if (handoffThreadsInFlight.has(scope.threadId)) { + if (handoffThreadsInFlight.has(scope.thread.threadId)) { return Effect.fail( failure( "handoff_in_progress", - `A worktree handoff is already in progress for thread '${scope.threadId}'.`, + `A worktree handoff is already in progress for thread '${scope.thread.threadId}'.`, ), ); } - handoffThreadsInFlight.add(scope.threadId); + handoffThreadsInFlight.add(scope.thread.threadId); return restore(performHandoff(scope, input)).pipe( - Effect.ensuring(Effect.sync(() => handoffThreadsInFlight.delete(scope.threadId))), + Effect.ensuring( + Effect.sync(() => handoffThreadsInFlight.delete(scope.thread.threadId)), + ), ); }), ); @@ -457,8 +473,9 @@ const make = Effect.gen(function* () { ); const status: WorktreeMcpService["Service"]["status"] = Effect.fn("WorktreeMcpService.status")( - function* (scope) { - yield* requireCapability(scope); + function* (callerScope) { + yield* requireCapability(callerScope); + const scope = yield* requireThreadScope(callerScope); const projection = yield* loadThread(scope); const project = yield* loadProject(scope, projection.thread.projectId); diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index bd3a4de60010..a965b0823629 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -2,6 +2,9 @@ import type { ProjectionRecordField } from "../orchestration-v2/ProjectionStore. import { CommandId, OrchestratorMcpFailure, + type ProjectId, + type ProviderInteractionMode, + type RuntimeMode, type ThreadId, type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; @@ -18,6 +21,24 @@ export const unavailable = () => message: "The operation could not be completed.", }); +/** + * The most a caller may hand to the threads it targets. A thread caller is + * capped by its own thread's modes; an OAuth client by the ceiling chosen + * when it was approved. + */ +export interface CallerLimits { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +} + +export interface Caller { + readonly scope: McpInvocationContext.McpInvocationScope; + readonly threads: ThreadManagement.ThreadManagementService["Service"]; + /** The calling thread, absent for a client signed in from outside T3. */ + readonly caller: OrchestrationV2ThreadShell | undefined; + readonly limits: CallerLimits; +} + export const readCaller = Effect.fn("mcp.readCaller")(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; if (!scope.capabilities.has("orchestration")) { @@ -27,26 +48,54 @@ export const readCaller = Effect.fn("mcp.readCaller")(function* () { }); } const threads = yield* ThreadManagement.ThreadManagementService; - const caller = yield* threads.getThreadShell(scope.threadId).pipe(Effect.mapError(unavailable)); + if (scope.thread === undefined) { + return { + scope, + threads, + caller: undefined, + limits: { + runtimeMode: scope.client?.runtimeModeCeiling ?? "approval-required", + interactionMode: "default", + }, + } satisfies Caller; + } + const caller = yield* threads + .getThreadShell(scope.thread.threadId) + .pipe(Effect.mapError(unavailable)); if (caller === null || caller.deletedAt !== null) { return yield* new OrchestratorMcpFailure({ code: "thread_not_found", message: "The calling thread was not found.", }); } - return { scope, threads, caller }; + return { + scope, + threads, + caller, + limits: { runtimeMode: caller.runtimeMode, interactionMode: caller.interactionMode }, + } satisfies Caller; }); -function assertLiveCaller({ - caller, - scope, -}: { - caller: OrchestrationV2ThreadShell; - scope: McpInvocationContext.McpInvocationScope; -}) { +/** + * A caller may change another thread only if that thread runs within the + * caller's own modes. Its own thread is always within them. + */ +export const assertTargetWithinLimits = ( + limits: CallerLimits, + target: { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode }, +) => + OrchestrationMcp.resolveRuntimeMode(limits.runtimeMode, target.runtimeMode).pipe( + Effect.andThen( + OrchestrationMcp.resolveInteractionMode(limits.interactionMode, target.interactionMode), + ), + Effect.asVoid, + ); + +function assertLiveCaller({ caller, scope }: Caller) { + if (caller === undefined) return Effect.void; return caller.archivedAt !== null || caller.activeRunId === null || - caller.providerInstanceId !== scope.providerInstanceId + caller.providerInstanceId !== scope.thread?.providerInstanceId ? Effect.fail( new OrchestratorMcpFailure({ code: "parent_not_active", @@ -55,34 +104,91 @@ function assertLiveCaller({ ) : Effect.void; } + +/** + * Mutations from a thread need that thread's live run, so an agent whose turn + * ended cannot keep acting. An OAuth client has no run; its session and + * ceiling are its authority. + */ export const readMutationCaller = Effect.fn("mcp.readMutationCaller")(function* () { const context = yield* readCaller(); yield* assertLiveCaller(context); return context; }); -/** Resolve the credential's project before looking up a caller-supplied thread. */ +/** + * Actions that change the environment itself (projects, preferences, launching + * outside a project) need full access: a thread caller in full-access/default + * mode, or a client approved with a full-access ceiling. + */ +export const readFullAccessCaller = Effect.fn("mcp.readFullAccessCaller")(function* ( + message: string, +) { + const context = yield* readMutationCaller(); + if ( + (context.caller !== undefined && context.caller.archivedAt !== null) || + context.limits.runtimeMode !== "full-access" || + context.limits.interactionMode !== "default" + ) { + return yield* new OrchestratorMcpFailure({ code: "capability_denied", message }); + } + return context; +}); + +/** A target project: the one passed, else the calling thread's. */ +export const resolveProjectId = (context: Caller, projectId: ProjectId | undefined) => + projectId !== undefined + ? Effect.succeed(projectId) + : context.caller !== undefined + ? Effect.succeed(context.caller.projectId) + : Effect.fail( + new OrchestratorMcpFailure({ + code: "target_required", + message: "Pass projectId: this MCP client is not running inside a T3 thread.", + }), + ); + +/** A target thread: the one passed, else the calling thread. */ +const resolveThreadId = (context: Caller, threadId: ThreadId | undefined) => + threadId !== undefined + ? Effect.succeed(threadId) + : context.caller !== undefined + ? Effect.succeed(context.caller.id) + : Effect.fail( + new OrchestratorMcpFailure({ + code: "target_required", + message: "Pass threadId: this MCP client is not running inside a T3 thread.", + }), + ); + +/** Load a target thread anywhere in the environment; an omitted id means the calling thread. */ export const readThread = Effect.fn("mcp.readThread")(function* < K extends ProjectionRecordField = never, >(threadId?: ThreadId, fields: ReadonlyArray = []) { - const { scope, threads, caller } = yield* readCaller(); - const projection = yield* threads - .getProjectThreadRecords( - { projectId: caller.projectId, threadId: threadId ?? caller.id }, - fields, - { turnItemTypes: ["user_input_request"] }, - ) + const context = yield* readCaller(); + const targetId = yield* resolveThreadId(context, threadId); + const shell = yield* context.threads.getThreadShell(targetId).pipe(Effect.mapError(unavailable)); + if (shell === null || shell.deletedAt !== null) { + return yield* new OrchestratorMcpFailure({ + code: "thread_not_found", + message: "The thread was not found.", + }); + } + const projection = yield* context.threads + .getProjectThreadRecords({ projectId: shell.projectId, threadId: targetId }, fields, { + turnItemTypes: ["user_input_request"], + }) .pipe( Effect.mapError((error) => error._tag === "ThreadManagementThreadNotFoundError" ? new OrchestratorMcpFailure({ code: "thread_not_found", - message: "The thread was not found in the calling project.", + message: "The thread was not found.", }) : unavailable(), ), ); - return { scope, threads, caller, projection }; + return { ...context, projection }; }); export const readWritableThread = Effect.fn("mcp.readWritableThread")(function* < @@ -90,14 +196,7 @@ export const readWritableThread = Effect.fn("mcp.readWritableThread")(function* >(threadId?: ThreadId, fields: ReadonlyArray = []) { const context = yield* readThread(threadId, fields); yield* assertLiveCaller(context); - yield* OrchestrationMcp.resolveRuntimeMode( - context.caller.runtimeMode, - context.projection.thread.runtimeMode, - ); - yield* OrchestrationMcp.resolveInteractionMode( - context.caller.interactionMode, - context.projection.thread.interactionMode, - ); + yield* assertTargetWithinLimits(context.limits, context.projection.thread); return context; }); diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index 42866c8db872..e02110b63dea 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -47,7 +47,7 @@ export const AttachmentHandlersLive = AttachmentToolkit.toLayer({ }), t3_thread_send_attachments: (input) => Effect.gen(function* () { - const { caller, projection, scope } = yield* readWritableThread(input.threadId, ["messages"]); + const { caller, projection } = yield* readWritableThread(input.threadId, ["messages"]); if (projection.thread.archivedAt !== null) return yield* new OrchestratorMcpFailure({ code: "invalid_request", @@ -60,11 +60,11 @@ export const AttachmentHandlersLive = AttachmentToolkit.toLayer({ const commandId = yield* newCommandId(); const messageId = MessageId.make(commandId); const result = yield* ThreadMessageIntake.sendToThread({ - projectId: caller.projectId, + projectId: projection.thread.projectId, threadId: projection.thread.id, commandId, messageId, - senderThreadId: scope.threadId, + ...(caller === undefined ? {} : { senderThreadId: caller.id }), text: input.message ?? "", attachments, mode: "auto", diff --git a/apps/server/src/mcp/toolkits/attachment/tools.ts b/apps/server/src/mcp/toolkits/attachment/tools.ts index 55820f035c17..f93bb06729e8 100644 --- a/apps/server/src/mcp/toolkits/attachment/tools.ts +++ b/apps/server/src/mcp/toolkits/attachment/tools.ts @@ -47,7 +47,7 @@ const AttachmentDiscardTool = Tool.make("t3_attachment_discard", { const AttachmentSendTool = Tool.make("t3_thread_send_attachments", { ...shared, description: - "Send uploaded attachments to this thread or another thread in the calling project. Each call is a new message, without a retry key. Acceptance does not mean the provider can consume the attachment or has finished the turn. The target cannot have broader permission modes than the caller; failures retain claimed files when dispatch outcome is uncertain.", + "Send uploaded attachments to this thread or any other thread in this environment. Each call is a new message, without a retry key. Acceptance does not mean the provider can consume the attachment or has finished the turn. The target cannot have broader permission modes than the caller; failures retain claimed files when dispatch outcome is uncertain.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId), message: Schema.optional(Schema.String.check(Schema.isMaxLength(120000))), diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 339e5eb9b2a8..99834a5ce5b4 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -14,7 +14,11 @@ import { McpAttachmentInput } from "./attachment/input.ts"; import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator.ts"; +import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../../project/ProjectService.ts"; +import * as ProviderRegistry from "../../provider/Services/ProviderRegistry.ts"; +import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts"; import * as McpHttpServer from "../McpHttpServer.ts"; import * as McpInvocationContext from "../McpInvocationContext.ts"; import { OrchestratorToolkit } from "./orchestrator/tools.ts"; @@ -78,9 +82,13 @@ it("publishes unique tool names with reference-free object-root inputs", () => { const threadId = ThreadId.make("mcp-core-thread"); const scope: McpInvocationContext.McpInvocationScope = { environmentId: EnvironmentId.make("mcp-core-environment"), - threadId, - providerSessionId: "mcp-core-session", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "mcp-core-session", + thread: { + threadId, + providerSessionId: "mcp-core-session", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, issuedAt: 0, capabilities: new Set(["orchestration"]), }; @@ -207,3 +215,236 @@ it.effect("resolves reused attachment references from stored metadata", () => expect(failure.code).toBe("invalid_request"); }), ); + +const clientScope = ( + runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access", +): McpInvocationContext.McpInvocationScope => ({ + environmentId: EnvironmentId.make("mcp-core-environment"), + requestNamespace: "client:session-1", + thread: undefined, + client: { sessionId: "session-1", label: "Claude Code", runtimeModeCeiling }, + issuedAt: 0, + capabilities: new Set(["orchestration", "worktree", "pull-requests"]), +}); + +it.effect("a client caller targets any thread within its ceiling and cannot act as a thread", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const call = ( + name: string, + args: Record, + invocation: McpInvocationContext.McpInvocationScope, + ) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + const untargeted = yield* call("t3_thread_organize", { action: "pin" }, clientScope("auto")); + expect(untargeted.structuredContent).toMatchObject({ code: "target_required" }); + + const pinned = yield* call( + "t3_thread_organize", + { action: "pin", threadId: "other-project-thread" }, + clientScope("auto"), + ); + expect(pinned.isError).toBe(false); + expect(pinned.structuredContent).toMatchObject({ sequence: 7 }); + + const aboveCeiling = yield* call( + "t3_thread_organize", + { action: "pin", threadId: "other-project-thread" }, + clientScope("approval-required"), + ); + expect(aboveCeiling.structuredContent).toMatchObject({ + code: "runtime_mode_escalation_denied", + }); + + const forked = yield* call( + "t3_thread_fork", + { sourcePoint: { type: "latest_stable" } }, + clientScope("auto"), + ); + expect(forked.structuredContent).toMatchObject({ code: "target_required" }); + }).pipe( + Effect.provide( + McpHttpServer.ThreadToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => + Effect.succeed({ + id: ThreadId.make("other-project-thread"), + projectId: "other-project", + deletedAt: null, + } as never), + getProjectThreadRecords: () => + Effect.succeed({ + thread: { + id: ThreadId.make("other-project-thread"), + projectId: "other-project", + runtimeMode: "auto", + interactionMode: "default", + deletedAt: null, + }, + } as never), + dispatch: () => Effect.succeed({ sequence: 7 } as never), + }), + ), + ), + ), + ), +); + +it.effect("refuses act-as-caller tools to a client caller", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ + name: "delegate_task", + arguments: { task: "Review", mode: "async" }, + }) + .pipe( + Effect.provideService( + McpInvocationContext.McpInvocationContext, + clientScope("full-access"), + ), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(result.structuredContent).toMatchObject({ code: "thread_credential_required" }); + }).pipe( + Effect.provide( + McpHttpServer.OrchestratorToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + ), + ), + ), +); + +it.effect("a caller cannot rewrite a scheduled task that runs above its own modes", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const call = (name: string, args: Record) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), + Effect.provideService(McpSchema.McpServerClient, client), + ); + const update = yield* call("update_scheduled_task", { + scheduledTaskId: "task-full-access", + prompt: "Run something else", + }); + expect(update.structuredContent).toMatchObject({ code: "runtime_mode_escalation_denied" }); + const remove = yield* call("delete_scheduled_task", { scheduledTaskId: "task-full-access" }); + expect(remove.structuredContent).toMatchObject({ code: "runtime_mode_escalation_denied" }); + const allowed = yield* call("update_scheduled_task", { + scheduledTaskId: "task-auto", + enabled: false, + }); + expect(allowed.isError).toBe(false); + }).pipe( + Effect.provide( + McpHttpServer.OrchestratorToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide( + Layer.mock(ScheduledTaskService.ScheduledTaskService)({ + list: () => + Effect.succeed({ + tasks: [ + scheduledTask("task-full-access", "full-access"), + scheduledTask("task-auto", "auto"), + ], + }), + upsert: (input) => + Effect.succeed({ + task: { ...(scheduledTask(input.id ?? "task-auto", "auto") as object), ...input }, + } as never), + }), + ), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + ), + ), + ), +); + +function scheduledTask(id: string, runtimeMode: "auto" | "full-access"): never { + return { + id, + title: id, + prompt: "Check the build", + enabled: true, + projectId: "project-a", + threadId: null, + schedule: { type: "interval", everyMs: 3_600_000 }, + workspaceStrategy: { type: "worktree", baseRef: "main", startFromOrigin: true }, + modelSelection: { instanceId: "codex", model: "gpt-5" }, + runtimeMode, + interactionMode: "default", + createdBy: "user", + creationSource: "web", + nextRunAt: null, + lastRunStatus: "never", + lastRunAt: null, + lastRunThreadId: null, + lastRunError: null, + runCount: 0, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + } as never; +} + +it.effect("a caller cannot interrupt a thread that runs above its own modes", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ name: "t3_thread_interrupt", arguments: { threadId: "full-access-thread" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(result.structuredContent).toMatchObject({ code: "runtime_mode_escalation_denied" }); + }).pipe( + Effect.provide( + McpHttpServer.OrchestratorToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => + Effect.succeed({ projectId: "project-a", deletedAt: null } as never), + getProjectThreadRecords: () => + Effect.succeed({ + thread: { + id: ThreadId.make("full-access-thread"), + projectId: "project-a", + runtimeMode: "full-access", + interactionMode: "default", + deletedAt: null, + }, + runs: [], + } as never), + interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"), + }), + ), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + ), + ), + ), +); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 2a8172a63411..6c6850673a39 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -66,7 +66,7 @@ export function agentDeviceQuickStart( ].join("\n"); } -const requireDeviceAccess = McpInvocationContext.requireMcpCapability("device").pipe( +const requireDeviceAccess = McpInvocationContext.requireThreadMcpCapability("device").pipe( Effect.mapError( () => new DeviceToolUnavailableError({ @@ -132,7 +132,7 @@ const handlers = { } const hostId = input?.hostId; const open = state.sessions - .filter((session) => session.threadId === scope.threadId) + .filter((session) => session.threadId === scope.thread.threadId) .map((session) => ({ hostId: session.hostId, deviceId: session.deviceId })); return { hostStatuses: Object.fromEntries( @@ -159,12 +159,12 @@ const handlers = { const target = yield* pickDevice(state.devices, input); // Resolve consent and agent connectivity before booting or registering a session. const agentArgs = yield* devices.agentTarget({ - threadId: scope.threadId, + threadId: scope.thread.threadId, hostId: target.hostId, deviceId: target.id, }); const session = yield* devices.open({ - threadId: scope.threadId, + threadId: scope.thread.threadId, hostId: target.hostId, deviceId: target.id, platform: target.platform, @@ -207,7 +207,7 @@ const handlers = { Effect.gen(function* () { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; - const sessions = yield* devices.sessionsForThread(scope.threadId); + const sessions = yield* devices.sessionsForThread(scope.thread.threadId); const target = input.deviceId !== undefined ? { hostId: input.hostId ?? LOCAL_DEVICE_HOST_ID, deviceId: input.deviceId } @@ -234,7 +234,7 @@ const handlers = { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; yield* devices.close({ - threadId: scope.threadId, + threadId: scope.thread.threadId, ...(input.hostId === undefined ? {} : { hostId: input.hostId }), ...(input.deviceId === undefined ? {} : { deviceId: input.deviceId }), ...(input.shutdown === undefined ? {} : { shutdown: input.shutdown }), diff --git a/apps/server/src/mcp/toolkits/environment/handlers.ts b/apps/server/src/mcp/toolkits/environment/handlers.ts index 2c01841611dc..f95ac72bbdb9 100644 --- a/apps/server/src/mcp/toolkits/environment/handlers.ts +++ b/apps/server/src/mcp/toolkits/environment/handlers.ts @@ -4,7 +4,7 @@ import * as Environment from "../../../environment/ServerEnvironment.ts"; import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; import * as Settings from "../../../serverSettings.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; -import { readCaller, readMutationCaller, unavailable } from "../../threadAccess.ts"; +import { readCaller, readFullAccessCaller, unavailable } from "../../threadAccess.ts"; import { EnvironmentToolkit } from "./tools.ts"; export function preferences(settings: ServerSettings) { @@ -30,7 +30,11 @@ export function preferences(settings: ServerSettings) { } const access = (writable = false) => Effect.gen(function* () { - const context = yield* writable ? readMutationCaller() : readCaller(); + const context = yield* writable + ? readFullAccessCaller( + "Preference updates require a live full-access/default thread or a full-access client.", + ) + : readCaller(); const environment = yield* Environment.ServerEnvironment; const descriptor = yield* environment.getDescriptor; if (descriptor.environmentId !== context.scope.environmentId) @@ -57,23 +61,15 @@ export const EnvironmentHandlersLive = EnvironmentToolkit.toLayer({ Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; - return yield* executor.withLock( - scope.threadId, - Effect.gen(function* () { - const { caller, settings } = yield* access(true); - if ( - caller.archivedAt !== null || - caller.runtimeMode !== "full-access" || - caller.interactionMode !== "default" - ) - return yield* new OrchestratorMcpFailure({ - code: "capability_denied", - message: "Preference updates require a live full-access/default thread.", - }); - return preferences( - yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)), - ); - }), - ); + const update = Effect.gen(function* () { + const { settings } = yield* access(true); + return preferences( + yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)), + ); + }); + // A thread caller serializes with its own turn; a client has no thread to lock. + return yield* scope.thread === undefined + ? update + : executor.withLock(scope.thread.threadId, update); }), }); diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 7dbbd21cb624..d45197b22c9c 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -36,11 +36,11 @@ const handlers = { const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.scheduleTask(scope, input); }), - list_scheduled_tasks: () => + list_scheduled_tasks: (input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.listScheduledTasks(scope); + return yield* service.listScheduledTasks(scope, input); }), update_scheduled_task: (input) => Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index a86f1ce214c5..540de4e1ca06 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -7,6 +7,7 @@ import { OrchestratorMcpDeleteScheduledTaskInput, OrchestratorMcpDeleteScheduledTaskResult, OrchestratorMcpFailure, + OrchestratorMcpListScheduledTasksInput, OrchestratorMcpListScheduledTasksResult, OrchestratorMcpScheduleTaskInput, OrchestratorMcpScheduleTaskResult, @@ -44,7 +45,7 @@ const threadMetadataDependencies = [ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { description: - "List the V2 provider instances and their current models from the same live catalog as the composer, including configured custom models, inherited runtime settings, and app-owned orchestration features available to this T3 thread. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.", + "List the V2 provider instances and their current models from the same live catalog as the composer, including configured custom models, inherited runtime settings, and app-owned orchestration features available to this caller. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.", success: OrchestratorMcpCapabilitiesResult, failure: OrchestratorMcpFailure, failureMode: "return", @@ -57,7 +58,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { export const DelegateTaskTool = Tool.make("delegate_task", { description: - "Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", + "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", parameters: OrchestratorMcpDelegateTaskInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, @@ -70,7 +71,7 @@ export const DelegateTaskTool = Tool.make("delegate_task", { const TaskStatusTool = Tool.make("task_status", { description: - "Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task or extend task_cancel to those turns. latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.", + "Needs an agent running inside a T3 thread. Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task or extend task_cancel to those turns. latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.", parameters: OrchestratorMcpTaskStatusInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, @@ -84,7 +85,7 @@ const TaskStatusTool = Tool.make("task_status", { const TaskCancelTool = Tool.make("task_cancel", { description: - "Request interruption of an active T3-owned delegated task and dispose its automatic parent delivery. For a terminal task, return its existing status and dispose delivery without interrupting later child-thread runs, even when task_status reports hasPendingChildRuns=true. Published task results remain available. Use t3_thread_interrupt for a later active run.", + "Needs an agent running inside a T3 thread. Request interruption of an active T3-owned delegated task and dispose its automatic parent delivery. For a terminal task, return its existing status and dispose delivery without interrupting later child-thread runs, even when task_status reports hasPendingChildRuns=true. Published task results remain available. Use t3_thread_interrupt for a later active run.", parameters: OrchestratorMcpTaskCancelInput, success: OrchestratorMcpTaskCancelResult, failure: OrchestratorMcpFailure, @@ -96,7 +97,7 @@ const TaskCancelTool = Tool.make("task_cancel", { export const ScheduleTaskTool = Tool.make("schedule_task", { description: - "Create persistent recurring work in the app scheduler, which runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text: {type:'interval', everyMs:3600000} means hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} means weekday mornings. By default (bindToCurrentThread=true) each run posts into THIS thread; use false only when the user wants a fresh top-level thread per run. Provider, model, and runtime settings inherit from this thread. Report the returned schedule and nextRunAt after success.", + "Create persistent recurring work in the app scheduler, which runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text: {type:'interval', everyMs:3600000} means hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} means weekday mornings. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true); use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread. Report the returned schedule and nextRunAt after success.", parameters: OrchestratorMcpScheduleTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, @@ -108,8 +109,9 @@ export const ScheduleTaskTool = Tool.make("schedule_task", { .annotate(Tool.OpenWorld, true); const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", { + parameters: OrchestratorMcpListScheduledTasksInput, description: - "List the recurring scheduled tasks in the calling thread's project, including their id, schedule, prompt, enabled state, bound thread, next run time, and last run status. Use the returned scheduledTaskId with update_scheduled_task or delete_scheduled_task.", + "List recurring scheduled tasks in a project (omit projectId for the calling thread's project, or every project when there is no calling thread), including their id, schedule, prompt, enabled state, bound thread, next run time, and last run status. Use the returned scheduledTaskId with update_scheduled_task or delete_scheduled_task.", success: OrchestratorMcpListScheduledTasksResult, failure: OrchestratorMcpFailure, failureMode: "return", @@ -146,7 +148,7 @@ const DeleteScheduledTaskTool = Tool.make("delete_scheduled_task", { export const CreateThreadsTool = Tool.make("create_threads", { description: - "Create one or more ORDINARY TOP-LEVEL T3 conversations. This is not delegation and does not create child agents/subagents. For delegated work, choose models from orchestrator_capabilities. Prefer native subagents only when they support the chosen model; otherwise call delegate_task, including for same-provider work. Use create_threads for a batch of separate top-level threads sharing this checkout. Prefer t3_thread_launch for a single thread. Both require the user to request separate/new/top-level threads or conversations. Each entry may override provider, model, options, runtime mode, and interaction mode; omitted settings inherit. Project, branch, and worktree always inherit and cannot be overridden here. For independent implementation or a PR stack in its own worktree, use t3_thread_launch with workspaceStrategy instead of asking the agent to create a worktree in its prompt.", + "Needs an agent running inside a T3 thread. Create one or more ORDINARY TOP-LEVEL T3 conversations. This is not delegation and does not create child agents/subagents. For delegated work, choose models from orchestrator_capabilities. Prefer native subagents only when they support the chosen model; otherwise call delegate_task, including for same-provider work. Use create_threads for a batch of separate top-level threads sharing this checkout. Prefer t3_thread_launch for a single thread. Both require the user to request separate/new/top-level threads or conversations. Each entry may override provider, model, options, runtime mode, and interaction mode; omitted settings inherit. Project, branch, and worktree always inherit and cannot be overridden here. For independent implementation or a PR stack in its own worktree, use t3_thread_launch with workspaceStrategy instead of asking the agent to create a worktree in its prompt.", parameters: OrchestratorMcpCreateThreadsInput, success: OrchestratorMcpCreateThreadsResult, failure: OrchestratorMcpFailure, @@ -159,7 +161,7 @@ export const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "List T3 threads in the calling thread's project, newest first. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list) and paginate with the returned cursor. Threads from other projects are never exposed.", + "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list) and paginate with the returned cursor.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, @@ -173,7 +175,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { const ThreadReadTool = Tool.make("t3_thread_read", { description: - "Read durable state and a paginated timeline from a T3 thread in the calling project, or from a thread the user attached to this conversation as context. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units.", + "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units.", parameters: OrchestratorMcpThreadReadInput, success: OrchestratorMcpThreadReadResult, failure: OrchestratorMcpFailure, @@ -187,7 +189,7 @@ const ThreadReadTool = Tool.make("t3_thread_read", { export const ThreadUpdateTool = Tool.make("t3_thread_update", { description: - "Update metadata for a thread in the calling project. Omit threadId to update this thread. Use action='rename' with title, action='regenerate_title' with no extra field, action='link_pull_request' with pullRequest, or action='unlink_pull_request'. Workspace and branch changes are intentionally not supported. clientRequestId makes retries idempotent.", + "Update metadata for a thread. Omit threadId to update this thread. Use action='rename' with title, action='regenerate_title' with no extra field, action='link_pull_request' with pullRequest, or action='unlink_pull_request'. Workspace and branch changes are intentionally not supported. clientRequestId makes retries idempotent.", parameters: ThreadMetadataMcpUpdateInput, success: ThreadMetadataMcpUpdateResult, failure: OrchestratorMcpFailure, @@ -200,7 +202,7 @@ export const ThreadUpdateTool = Tool.make("t3_thread_update", { const ThreadSendTool = Tool.make("t3_thread_send", { description: - "Send a message to a T3 thread in the calling project. Do not use a delegated task's childThreadId to start another review round here; use delegate_task with the full review context and a new clientRequestId for that round. Thread messages do not create a new delegated task or reopen a completed task. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.", + "Send a message to any T3 thread in this environment. The target cannot have broader permission modes than the caller. Do not use a delegated task's childThreadId to start another review round here; use delegate_task with the full review context and a new clientRequestId for that round. Thread messages do not create a new delegated task or reopen a completed task. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.", parameters: OrchestratorMcpThreadSendInput, success: OrchestratorMcpThreadSendResult, failure: OrchestratorMcpFailure, @@ -227,7 +229,7 @@ const ThreadWaitTool = Tool.make("t3_thread_wait", { const ThreadInterruptTool = Tool.make("t3_thread_interrupt", { description: - "Request interruption of a running turn in a T3 thread in the calling project. Without runId, the newest interruptible run is selected. Terminal runs and threads without an active turn return without another side effect. clientRequestId makes retries idempotent.", + "Request interruption of a running turn in any T3 thread in this environment. Without runId, the newest interruptible run is selected. Terminal runs and threads without an active turn return without another side effect. clientRequestId makes retries idempotent.", parameters: OrchestratorMcpThreadInterruptInput, success: OrchestratorMcpThreadInterruptResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index b70e68862683..9bbbc13a7109 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -60,7 +60,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* ( import("@t3tools/contracts").PreviewAutomationError, McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker > { - const scope = yield* McpInvocationContext.requireMcpCapability("preview"); + const scope = yield* McpInvocationContext.requireThreadMcpCapability("preview"); const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; let targetTabId = tabId; const result = yield* broker.invoke({ @@ -218,7 +218,7 @@ const handlers = { invokeTargeted("recordingStart", input ?? {}), preview_recording_stop: (input) => Effect.gen(function* () { - const scope = yield* McpInvocationContext.requireMcpCapability("preview"); + const scope = yield* McpInvocationContext.requireThreadMcpCapability("preview"); const { tabId, ...operationInput } = input; const response = yield* invoke( "recordingStop", @@ -226,7 +226,7 @@ const handlers = { PREVIEW_RECORDING_STOP_TIMEOUT_MS, tabId, ); - const artifact = yield* claimPreviewRecording(scope.threadId, response.result); + const artifact = yield* claimPreviewRecording(scope.thread.threadId, response.result); return { ...artifact, ...(response.toolIcon ? { toolIcon: response.toolIcon } : {}) }; }), } satisfies Parameters[0]; diff --git a/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts b/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts index 8764dc55fd21..700eeb808154 100644 --- a/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts @@ -35,9 +35,13 @@ it.effect.each([ const effective = resolveProjectSettings(settings, projectId).settings; const scope: McpInvocationContext.McpInvocationScope = { environmentId: EnvironmentId.make("preview-controls-environment"), - threadId, - providerSessionId: "preview-controls-provider-session", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "preview-controls-provider-session", + thread: { + threadId, + providerSessionId: "preview-controls-provider-session", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(effective.enableAgentBrowserAccess ? ["preview"] : []), issuedAt: 0, }; diff --git a/apps/server/src/mcp/toolkits/previewControls/handlers.ts b/apps/server/src/mcp/toolkits/previewControls/handlers.ts index 213a7437353a..16f2449aad35 100644 --- a/apps/server/src/mcp/toolkits/previewControls/handlers.ts +++ b/apps/server/src/mcp/toolkits/previewControls/handlers.ts @@ -1,19 +1,19 @@ import * as Effect from "effect/Effect"; import * as Preview from "../../../preview/Manager.ts"; -import { requireMcpCapability } from "../../McpInvocationContext.ts"; +import { requireThreadMcpCapability } from "../../McpInvocationContext.ts"; import { unavailable } from "../../threadAccess.ts"; import { PreviewControlsToolkit } from "./tools.ts"; const access = Effect.gen(function* () { // The preview capability already reflects the calling project's access setting. - const scope = yield* requireMcpCapability("preview"); + const scope = yield* requireThreadMcpCapability("preview"); return { scope, manager: yield* Preview.PreviewManager }; }); export const PreviewControlsHandlersLive = PreviewControlsToolkit.toLayer({ t3_preview_list: (input) => Effect.gen(function* () { const { scope, manager } = yield* access; - const result = yield* manager.list({ threadId: scope.threadId }); + const result = yield* manager.list({ threadId: scope.thread.threadId }); const start = input.cursor ?? 0; const end = start + (input.limit ?? 20); return { @@ -26,7 +26,7 @@ export const PreviewControlsHandlersLive = PreviewControlsToolkit.toLayer({ Effect.gen(function* () { const { scope, manager } = yield* access; yield* manager - .close({ threadId: scope.threadId, tabId: input.tabId }) + .close({ threadId: scope.thread.threadId, tabId: input.tabId }) .pipe(Effect.mapError(unavailable)); return {}; }), diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 68dc746d2499..fa3569906739 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -5,6 +5,7 @@ import { ProviderInstanceId, ThreadId, type OrchestrationV2ThreadShell, + type Project as ProjectRecord, } from "@t3tools/contracts"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -44,9 +45,13 @@ it.effect("attributes a launched thread's first message to the calling thread", NodeCrypto.layer, Layer.succeed(McpInvocationContext.McpInvocationContext, { environmentId: EnvironmentId.make("environment"), - threadId: sourceThreadId, - providerSessionId: "session", - providerInstanceId, + requestNamespace: "session", + thread: { + threadId: sourceThreadId, + providerSessionId: "session", + providerInstanceId, + }, + client: undefined, issuedAt: 0, capabilities: new Set(["orchestration" as const]), }), @@ -106,9 +111,13 @@ it.effect("launches a scratch thread into the Scratch project", () => NodeCrypto.layer, Layer.succeed(McpInvocationContext.McpInvocationContext, { environmentId: EnvironmentId.make("environment"), - threadId: sourceThreadId, - providerSessionId: "session", - providerInstanceId, + requestNamespace: "session", + thread: { + threadId: sourceThreadId, + providerSessionId: "session", + providerInstanceId, + }, + client: undefined, issuedAt: 0, capabilities: new Set(["orchestration" as const]), }), @@ -194,9 +203,13 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", () NodeCrypto.layer, Layer.succeed(McpInvocationContext.McpInvocationContext, { environmentId: EnvironmentId.make("environment"), - threadId: sourceThreadId, - providerSessionId: "session", - providerInstanceId, + requestNamespace: "session", + thread: { + threadId: sourceThreadId, + providerSessionId: "session", + providerInstanceId, + }, + client: undefined, issuedAt: 0, capabilities: new Set(["orchestration" as const]), }), @@ -263,3 +276,87 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", () expect(named).toEqual(["Pinball Stats"]); }), ); + +const clientLaunchHarness = (input: { + readonly runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access"; + readonly launched: Array; +}) => { + const projectId = ProjectId.make("project:client-target"); + const modelSelection = { instanceId: ProviderInstanceId.make("claude"), model: "claude-opus" }; + const dependencies = Layer.mergeAll( + NodeCrypto.layer, + Layer.succeed(McpInvocationContext.McpInvocationContext, { + environmentId: EnvironmentId.make("environment"), + requestNamespace: "client:session-1", + thread: undefined, + client: { + sessionId: "session-1", + label: "Claude Code", + runtimeModeCeiling: input.runtimeModeCeiling, + }, + issuedAt: 0, + capabilities: new Set(["orchestration" as const]), + }), + Layer.mock(ThreadManagement.ThreadManagementService)({}), + Layer.mock(ThreadLaunch.ThreadLaunchService)({ + launch: (launch) => { + input.launched.push(launch); + return Effect.succeed({ + threadId: launch.threadId, + projection: { + thread: { + id: launch.threadId, + projectId: launch.projectId, + modelSelection: launch.modelSelection, + }, + runs: [], + }, + resumed: false, + } as unknown as ThreadLaunch.ThreadLaunchResult); + }, + }), + Layer.mock(Project.ProjectService)({ + getById: (id) => + Effect.succeed( + id === projectId + ? Option.some({ id, defaultModelSelection: modelSelection } as unknown as ProjectRecord) + : Option.none(), + ), + }), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), + NodeServices.layer, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-client-launch-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ); + return { projectId, modelSelection, dependencies }; +}; + +it.effect("a client launches at its ceiling with the project's default model", () => + Effect.gen(function* () { + const launched: Array = []; + const { projectId, modelSelection, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto-accept-edits", + launched, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide(ProjectHandlersLive.pipe(Layer.provide(dependencies))), + ); + const handle = (params: Parameters>[1]) => + toolkit + .handle("t3_thread_launch", params) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + + const result = yield* handle({ title: "Fix", projectId, message: "Fix the bug" }); + expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection }); + expect(launched[0]?.runtimeMode).toBe("auto-accept-edits"); + expect(launched[0]?.initialMessage?.senderThreadId).toBeUndefined(); + + const escalated = yield* handle({ title: "Fix", projectId, runtimeMode: "full-access" }); + expect(escalated.at(-1)?.result).toMatchObject({ code: "runtime_mode_escalation_denied" }); + + const untargeted = yield* handle({ title: "Fix" }); + expect(untargeted.at(-1)?.result).toMatchObject({ code: "target_required" }); + expect(launched).toHaveLength(1); + }), +); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 346e884f3032..e97119f3132d 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -6,7 +6,15 @@ import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; import * as Project from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; -import { newCommandId, readCaller, readMutationCaller, unavailable } from "../../threadAccess.ts"; +import { resolveRuntimeMode } from "../../OrchestratorMcpService.ts"; +import { + newCommandId, + readCaller, + readFullAccessCaller, + readMutationCaller, + resolveProjectId, + unavailable, +} from "../../threadAccess.ts"; import { ProjectToolkit } from "./tools.ts"; function projectFailure(error: Project.ProjectServiceError) { @@ -25,27 +33,30 @@ const access = Effect.gen(function* () { return yield* Project.ProjectService; }); const mutation = Effect.gen(function* () { - const { caller } = yield* readMutationCaller(); - if ( - caller.archivedAt !== null || - caller.runtimeMode !== "full-access" || - caller.interactionMode !== "default" - ) - return yield* new OrchestratorMcpFailure({ - code: "capability_denied", - message: "Project changes require a live full-access/default calling thread.", - }); + yield* readFullAccessCaller( + "Project changes require a live full-access/default calling thread or a full-access client.", + ); return yield* Project.ProjectService; }); export const ProjectHandlersLive = ProjectToolkit.toLayer({ t3_thread_launch: (input) => Effect.gen(function* () { - const { caller, scope } = yield* readMutationCaller(); - if (caller.runtimeMode !== "full-access" || caller.interactionMode !== "default") + const context = yield* readMutationCaller(); + const { caller, limits } = context; + // A thread caller launches only as itself (full-access/default), as before. A client + // launches anything up to its ceiling. + if ( + caller !== undefined && + (caller.runtimeMode !== "full-access" || caller.interactionMode !== "default") + ) return yield* new OrchestratorMcpFailure({ code: "capability_denied", message: "Project launches require a full-access/default calling thread.", }); + const runtimeMode = yield* resolveRuntimeMode( + limits.runtimeMode, + input.runtimeMode ?? caller?.runtimeMode, + ); const commandId = yield* newCommandId(); const threadId = ThreadId.make(commandId); const messageId = MessageId.make(commandId); @@ -76,22 +87,38 @@ export const ProjectHandlersLive = ProjectToolkit.toLayer({ }), ), )).projectId - : (input.projectId ?? caller.projectId); + : yield* resolveProjectId(context, input.projectId); + const modelSelection = + input.modelSelection ?? + caller?.modelSelection ?? + (yield* Project.ProjectService.pipe( + Effect.flatMap((projects) => projects.getById(projectId)), + Effect.mapError(unavailable), + Effect.map((project) => + Option.getOrUndefined(Option.flatMapNullishOr(project, (p) => p.defaultModelSelection)), + ), + )); + if (modelSelection === undefined) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: + "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.", + }); const result = yield* ThreadMessageIntake.launchThread({ commandId, threadId, projectId, title: input.title, - modelSelection: input.modelSelection ?? caller.modelSelection, - runtimeMode: input.runtimeMode ?? caller.runtimeMode, - interactionMode: input.interactionMode ?? caller.interactionMode, + modelSelection, + runtimeMode, + interactionMode: input.interactionMode ?? caller?.interactionMode ?? "default", workspaceStrategy: input.workspaceStrategy ?? { type: "root" }, ...(input.message === undefined && attachments.length === 0 ? {} : { initialMessage: { messageId, - senderThreadId: scope.threadId, + ...(caller === undefined ? {} : { senderThreadId: caller.id }), text: input.message ?? "", attachments, }, diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 693a76854bb6..e42b0012dd69 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -102,7 +102,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. Requires a full-access/default caller.', + 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. Requires a full-access/default calling thread; a caller outside a T3 thread launches up to its approved permission mode.', parameters: Schema.Struct({ projectId: Schema.optional(ProjectId), scratch: Schema.optional( diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index e329ade90d9e..93485a41175c 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -38,9 +38,13 @@ const invocation = ( capabilities: ReadonlyArray, ): McpInvocationContext.McpInvocationScope => ({ environmentId: EnvironmentId.make("environment-1"), - threadId: THREAD_ID, - providerSessionId: "provider-session-1", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session-1", + thread: { + threadId: THREAD_ID, + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(capabilities), issuedAt: 1, }); diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 97af65e1a7bc..e43208256ea9 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -26,6 +26,7 @@ import * as Option from "effect/Option"; import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { assertTargetWithinLimits } from "../../threadAccess.ts"; import { type ListThreadPullRequestsResult, PullRequestLinkFailedError, @@ -38,6 +39,8 @@ import { type PullRequestTargetInput, PullRequestWatchFailedError, PullRequestThreadNotFoundError, + PullRequestThreadAboveLimitsError, + PullRequestThreadRequiredError, PullRequestsToolkit, type ThreadPullRequestEntry, } from "./tools.ts"; @@ -168,18 +171,64 @@ const make = Effect.gen(function* () { | typeof PullRequestUnlinkFailedError | typeof PullRequestListFailedError | typeof PullRequestWatchFailedError, + requested: ThreadId | undefined, ) { const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests"); + const threadId = requested ?? scope.thread?.threadId; + if (threadId === undefined) { + return yield* new PullRequestThreadRequiredError(); + } const thread = yield* engine - .getThreadShell(scope.threadId) + .getThreadShell(threadId) .pipe(Effect.map(Option.fromNullishOr)) .pipe(Effect.mapError((cause) => new Failure({ cause }))); - if (Option.isNone(thread)) { - return yield* new PullRequestThreadNotFoundError({ threadId: scope.threadId }); + if (Option.isNone(thread) || thread.value.deletedAt !== null) { + return yield* new PullRequestThreadNotFoundError({ threadId }); } return thread.value; }); + /** + * A thread whose pull requests the caller may change: its own, or one that + * runs within the caller's modes. + */ + const requireWritableThread = Effect.fn("PullRequestsToolkit.requireWritableThread")(function* ( + Failure: + | typeof PullRequestLinkFailedError + | typeof PullRequestUnlinkFailedError + | typeof PullRequestWatchFailedError, + requested: ThreadId | undefined, + ) { + const thread = yield* requireThread(Failure, requested); + const scope = yield* McpInvocationContext.McpInvocationContext; + if (thread.id === scope.thread?.threadId) return thread; + const limits = + scope.thread === undefined + ? { + runtimeMode: scope.client?.runtimeModeCeiling ?? ("approval-required" as const), + interactionMode: "default" as const, + } + : yield* engine.getThreadShell(scope.thread.threadId).pipe( + Effect.mapError((cause) => new Failure({ cause })), + Effect.map((caller) => + // A thread caller changes other threads only while its own run is live. + caller === null || + caller.archivedAt !== null || + caller.activeRunId === null || + caller.providerInstanceId !== scope.thread?.providerInstanceId + ? undefined + : { runtimeMode: caller.runtimeMode, interactionMode: caller.interactionMode }, + ), + ); + if (limits === undefined) { + return yield* new PullRequestThreadAboveLimitsError({ threadId: thread.id }); + } + yield* assertTargetWithinLimits(limits, thread).pipe( + Effect.mapError(() => new PullRequestThreadAboveLimitsError({ threadId: thread.id })), + ); + return thread; + }); + const projectOf = ( thread: OrchestrationV2ThreadShell, Failure: @@ -217,7 +266,7 @@ const make = Effect.gen(function* () { input: PullRequestTargetInput, watching: boolean, ) { - const thread = yield* requireThread(PullRequestWatchFailedError); + const thread = yield* requireWritableThread(PullRequestWatchFailedError, input.threadId); const project = yield* projectOf(thread, PullRequestWatchFailedError); const target = yield* resolveTarget(input, project); const watchedLink = (shell: OrchestrationV2ThreadShell) => @@ -241,7 +290,7 @@ const make = Effect.gen(function* () { ...(watching ? { link: { url: target.url, source: "agent" as const } } : {}), }) .pipe(Effect.catchCause(dispatchFailure(PullRequestWatchFailedError))); - const after = yield* requireThread(PullRequestWatchFailedError); + const after = yield* requireThread(PullRequestWatchFailedError, thread.id); return { host: target.host, repository: target.repository, @@ -255,7 +304,7 @@ const make = Effect.gen(function* () { return PullRequestsToolkit.of({ link_pull_request: (input) => Effect.gen(function* () { - const thread = yield* requireThread(PullRequestLinkFailedError); + const thread = yield* requireWritableThread(PullRequestLinkFailedError, input.threadId); const project = yield* projectOf(thread, PullRequestLinkFailedError); const target = yield* resolveTarget(input, project); const existing = threadPullRequestsOf(thread).find((link) => @@ -285,7 +334,7 @@ const make = Effect.gen(function* () { }), unlink_pull_request: (input) => Effect.gen(function* () { - const thread = yield* requireThread(PullRequestUnlinkFailedError); + const thread = yield* requireWritableThread(PullRequestUnlinkFailedError, input.threadId); const project = yield* projectOf(thread, PullRequestUnlinkFailedError); const target = yield* resolveTarget(input, project); if (!threadPullRequestsOf(thread).some((link) => threadPullRequestKeysEqual(link, target))) @@ -316,8 +365,10 @@ const make = Effect.gen(function* () { wasLinked, }; }), - list_thread_pull_requests: () => - requireThread(PullRequestListFailedError).pipe(Effect.map(listThreadPullRequests)), + list_thread_pull_requests: (input) => + requireThread(PullRequestListFailedError, input.threadId).pipe( + Effect.map(listThreadPullRequests), + ), watch_pull_request: (input) => setWatching(input, true), unwatch_pull_request: (input) => setWatching(input, false), }); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 52ac473a3d50..6dd8f7a795a8 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -4,6 +4,7 @@ import { PullRequestState, ThreadPullRequestLinkSource, TrimmedNonEmptyString, + ThreadId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Tool from "effect/unstable/ai/Tool"; @@ -28,6 +29,11 @@ const REGISTER_EVERY_PR = * the host CLI handed back. */ export const PullRequestTargetInput = Schema.Struct({ + threadId: Schema.optional( + ThreadId.annotate({ + description: "Thread to act on. Omit for this thread.", + }), + ), url: Schema.optional( TrimmedNonEmptyString.annotate({ description: @@ -81,6 +87,24 @@ export class PullRequestHostRequiredError extends Schema.TaggedError()( + "PullRequestThreadRequiredError", + {}, +) { + override get message(): string { + return "Pass threadId: this MCP client is not running inside a T3 thread."; + } +} + +export class PullRequestThreadAboveLimitsError extends Schema.TaggedError()( + "PullRequestThreadAboveLimitsError", + { threadId: Schema.String }, +) { + override get message(): string { + return `Thread ${this.threadId} cannot be changed from here: it runs with broader permissions than this caller, or the calling thread has no active run.`; + } +} + export class PullRequestThreadNotFoundError extends Schema.TaggedError()( "PullRequestThreadNotFoundError", { threadId: Schema.String }, @@ -140,6 +164,8 @@ export const PullRequestToolError = Schema.Union([ PullRequestUrlInvalidError, PullRequestTargetIncompleteError, PullRequestHostRequiredError, + PullRequestThreadRequiredError, + PullRequestThreadAboveLimitsError, PullRequestThreadNotFoundError, PullRequestLinkFailedError, PullRequestUnlinkFailedError, @@ -245,7 +271,12 @@ const UnlinkPullRequestTool = Tool.make("unlink_pull_request", { .annotate(Tool.OpenWorld, false); const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { - description: `List the pull requests linked to this thread with their last known host state, and how they chain into stacks (bottom to top). ${REGISTER_EVERY_PR}`, + description: `List the pull requests linked to a thread (omit threadId for this thread) with their last known host state, and how they chain into stacks (bottom to top). ${REGISTER_EVERY_PR}`, + parameters: Schema.Struct({ + threadId: Schema.optional( + ThreadId.annotate({ description: "Thread to list. Omit for this thread." }), + ), + }), success: ListThreadPullRequestsResult, failure: PullRequestToolError, dependencies, @@ -258,7 +289,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { const WatchPullRequestTool = Tool.make("watch_pull_request", { description: - "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every minute and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. Watching ends when the pull request merges or closes, when T3 Code cannot read it for 15 minutes, or when you call unwatch_pull_request.", + "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every minute and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. Watching ends when the pull request merges or closes, when its thread settles, when T3 Code cannot read it for 15 minutes, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch.", parameters: PullRequestTargetInput, success: WatchPullRequestResult, failure: PullRequestToolError, diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index c0f5d135b6cc..b3c3b75eb32e 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -13,7 +13,7 @@ import { modelSelectionCommandType } from "@t3tools/shared/model"; import { newCommandId, readCaller, - readMutationCaller, + readFullAccessCaller, readThread, readWritableThread, unavailable, @@ -78,22 +78,15 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* ( export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ run_scheduled_task_now: (input) => Effect.gen(function* () { - const { caller } = yield* readMutationCaller(); - if ( - caller.archivedAt !== null || - caller.runtimeMode !== "full-access" || - caller.interactionMode !== "default" - ) - return yield* new OrchestratorMcpFailure({ - code: "capability_denied", - message: "Running a scheduled task requires a live full-access/default thread.", - }); + yield* readFullAccessCaller( + "Running a scheduled task requires a live full-access/default thread or a full-access client.", + ); const scheduler = yield* ScheduledTasks.ScheduledTaskService; const { tasks } = yield* scheduler.list().pipe(Effect.mapError(unavailable)); - if (!tasks.some((task) => task.id === input.taskId && task.projectId === caller.projectId)) + if (!tasks.some((task) => task.id === input.taskId)) return yield* new OrchestratorMcpFailure({ code: "invalid_request", - message: "The task was not found in the calling project.", + message: "The scheduled task was not found.", }); const { task } = yield* scheduler .runNow({ id: input.taskId }) @@ -109,13 +102,22 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ t3_thread_search: (input) => Effect.gen(function* () { const { caller } = yield* readCaller(); + const { projectId: requested, ...query } = input; + // Like the other project tools, an omitted project means the caller's own; a client + // outside a thread searches every project. + const projectId = requested ?? caller?.projectId; const threadSearch = yield* ThreadSearch.ThreadSearch; - const result = yield* threadSearch.search(input).pipe(Effect.mapError(unavailable)); - return { matches: result.matches.filter((match) => match.projectId === caller.projectId) }; + const result = yield* threadSearch.search(query).pipe(Effect.mapError(unavailable)); + return { + matches: + projectId === undefined + ? result.matches + : result.matches.filter((match) => match.projectId === projectId), + }; }), t3_thread_fork: (input) => Effect.gen(function* () { - const { threads, projection } = yield* readWritableThread(); + const { threads, projection } = yield* readWritableThread(input.threadId); const commandId = yield* newCommandId(); const targetThreadId = ThreadId.make(`${commandId}:fork`); const result = yield* threads @@ -134,12 +136,13 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ }), t3_thread_merge_back: (input) => Effect.gen(function* () { - const { threads, caller } = yield* readWritableThread(input.targetThreadId); - const result = yield* threads + const context = yield* readWritableThread(input.targetThreadId); + const source = yield* readWritableThread(input.sourceThreadId); + const result = yield* context.threads .dispatch({ type: "thread.merge_back", commandId: yield* newCommandId(), - sourceThreadId: caller.id, + sourceThreadId: source.projection.thread.id, targetThreadId: input.targetThreadId, sourcePoint: input.sourcePoint, createdBy: "agent", @@ -179,7 +182,7 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ const { threads, projection: { thread }, - } = yield* readWritableThread(); + } = yield* readWritableThread(input.threadId); const type = modelSelectionCommandType(thread.providerInstanceId, input.modelSelection); const result = yield* threads .dispatch({ diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 1517763b936e..bc2188037246 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -17,6 +17,7 @@ import { ThreadId, RunId, NonNegativeInt, + ProjectId, } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Schema from "effect/Schema"; @@ -29,7 +30,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; const ThreadOrganizeTool = Tool.make("t3_thread_organize", { description: - "Pin, snooze, settle, archive, or mark a thread unread in the calling project. Omit threadId for this thread. snooze requires snoozedUntil. Existing thread lifecycle rules apply; this does not schedule a future action.", + "Pin, snooze, settle, archive, or mark a thread unread. Omit threadId for this thread. snooze requires snoozedUntil. Existing thread lifecycle rules apply; this does not schedule a future action.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId), action: Schema.Literals([ @@ -91,7 +92,7 @@ const QueueListTool = Tool.make("t3_queue_list", { .annotate(Tool.Destructive, false); const QueueReadTool = Tool.make("t3_queue_read", { ...commandTool, - description: "Read up to 16,000 characters of a queued message in the calling project.", + description: "Read up to 16,000 characters of a queued message. Omit threadId for this thread.", parameters: Schema.Struct(queueTarget), success: queueEntry, }) @@ -146,7 +147,7 @@ const pendingRequest = Schema.Struct({ const PendingRequestListTool = Tool.make("t3_pending_request_list", { ...commandTool, description: - "List pending user questions in a thread in the calling project. Approval requests are not included.", + "List pending user questions in a thread. Omit threadId for this thread. Approval requests are not included.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), success: Schema.Struct({ requestIds: Schema.Array(RuntimeRequestId) }), }) @@ -173,7 +174,7 @@ const PendingRequestRespondTool = Tool.make("t3_pending_request_respond", { const ThreadConfigurationTool = Tool.make("t3_thread_configuration", { ...commandTool, description: - "Read a thread's provider/model selection and modes in the calling project. orchestrator_capabilities lists available providers and models.", + "Read a thread's provider/model selection and modes. Omit threadId for this thread. orchestrator_capabilities lists available providers and models.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), success: Schema.Struct({ threadId: ThreadId, @@ -187,16 +188,20 @@ const ThreadConfigurationTool = Tool.make("t3_thread_configuration", { const ThreadConfigureTool = Tool.make("t3_thread_configure", { ...commandTool, description: - "Set this calling thread's provider, model and options with the existing selection command. This does not change permission modes or other threads. Use orchestrator_capabilities to choose a selection.", - parameters: Schema.Struct({ modelSelection: ModelSelection }), + "Set a thread's provider, model and options with the existing selection command. Omit threadId for this thread. This does not change permission modes. Use orchestrator_capabilities to choose a selection.", + parameters: Schema.Struct({ + threadId: Schema.optional(ThreadId), + modelSelection: ModelSelection, + }), }).annotate(Tool.Destructive, true); const transferResult = Schema.Struct({ sequence: NonNegativeInt, targetThreadId: ThreadId }); const ThreadForkTool = Tool.make("t3_thread_fork", { ...commandTool, description: - "Fork this thread from a stable run or checkpoint using the existing fork command. The fork inherits the source configuration. Acceptance does not mean a provider turn has completed.", + "Fork a thread from a stable run or checkpoint using the existing fork command. Omit threadId to fork this thread. The fork inherits the source configuration. Acceptance does not mean a provider turn has completed.", parameters: Schema.Struct({ + threadId: Schema.optional(ThreadId), sourcePoint: OrchestrationV2ThreadForkSourcePoint, title: Schema.optional(TrimmedNonEmptyString), }), @@ -205,8 +210,9 @@ const ThreadForkTool = Tool.make("t3_thread_fork", { const ThreadMergeBackTool = Tool.make("t3_thread_merge_back", { ...commandTool, description: - "Merge context from this thread back to a related thread in the same project. Existing lineage and transfer rules apply.", + "Merge context from a thread back to a related thread in the same project. Omit sourceThreadId to merge from this thread. Existing lineage and transfer rules apply.", parameters: Schema.Struct({ + sourceThreadId: Schema.optional(ThreadId), targetThreadId: ThreadId, sourcePoint: OrchestrationV2ThreadForkSourcePoint, }), @@ -214,7 +220,7 @@ const ThreadMergeBackTool = Tool.make("t3_thread_merge_back", { }).annotate(Tool.Destructive, true); const ThreadTransfersTool = Tool.make("t3_thread_transfers", { ...commandTool, - description: "Read context transfer status for a thread in the calling project.", + description: "Read context transfer status for a thread. Omit threadId for this thread.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), success: Schema.Struct({ transfers: Schema.Array( @@ -233,8 +239,11 @@ const ThreadTransfersTool = Tool.make("t3_thread_transfers", { const ThreadSearchTool = Tool.make("t3_thread_search", { ...commandTool, description: - "Search active thread titles and content with the app's existing bounded search. Returns matches in the calling project from the global top matches; other-project matches are omitted, so this may return fewer than limit. No pagination or exhaustive-result guarantee.", - parameters: OrchestrationSearchThreadsInput, + "Search active thread titles and content with the app's existing bounded search. Matches are limited to one project (projectId, else the calling thread's project) out of the global top matches, so this may return fewer than limit. A caller outside a T3 thread that omits projectId searches every project. No pagination or exhaustive-result guarantee.", + parameters: Schema.Struct({ + ...OrchestrationSearchThreadsInput.fields, + projectId: Schema.optional(ProjectId), + }), success: OrchestrationSearchThreadsResult, dependencies: [...commandTool.dependencies, ThreadSearch.ThreadSearch], }) @@ -244,7 +253,7 @@ const ThreadSearchTool = Tool.make("t3_thread_search", { const ScheduledTaskRunTool = Tool.make("run_scheduled_task_now", { ...commandTool, description: - "Run a scheduled task in the calling project now through the existing scheduler. Requires a full-access/default caller. Each call is a new manual run; completion means dispatch/bookkeeping completed, not that the provider turn finished.", + "Run a scheduled task now through the existing scheduler. Requires a full-access/default caller. Each call is a new manual run; completion means dispatch/bookkeeping completed, not that the provider turn finished.", parameters: Schema.Struct({ taskId: ScheduledTaskId }), success: Schema.Struct({ taskId: ScheduledTaskId, diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index 0ecabdaecb97..dbb7c59f4fb7 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -2,7 +2,7 @@ import { OrchestratorMcpFailure } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as GitWorkflow from "../../../git/GitWorkflowService.ts"; import * as Project from "../../../project/ProjectService.ts"; -import { readCaller, unavailable } from "../../threadAccess.ts"; +import { readThread, unavailable } from "../../threadAccess.ts"; import * as Effect from "effect/Effect"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -18,9 +18,12 @@ const handlers = { code: "capability_denied", message: "This credential cannot inspect worktrees.", }); - const { caller } = yield* readCaller(); + const { threadId, ...refs } = input; + const { + projection: { thread }, + } = yield* readThread(threadId); const projects = yield* Project.ProjectService; - const project = yield* projects.getById(caller.projectId).pipe(Effect.mapError(unavailable)); + const project = yield* projects.getById(thread.projectId).pipe(Effect.mapError(unavailable)); if (Option.isNone(project)) return yield* new OrchestratorMcpFailure({ code: "invalid_request", @@ -28,7 +31,7 @@ const handlers = { }); const git = yield* GitWorkflow.GitWorkflowService; return yield* git - .listRefs({ ...input, cwd: caller.worktreePath ?? project.value.workspaceRoot }) + .listRefs({ ...refs, cwd: thread.worktreePath ?? project.value.workspaceRoot }) .pipe(Effect.mapError(unavailable)); }), t3_worktree_handoff: (input) => diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index d4cff97dbf75..373be5d3e46d 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -6,6 +6,7 @@ import { WorktreeMcpHandoffInput, WorktreeMcpHandoffResult, WorktreeMcpStatusResult, + ThreadId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as GitWorkflowService from "../../../git/GitWorkflowService.ts"; @@ -23,7 +24,7 @@ const dependencies = [ const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", { description: - "Move this agent thread into a new git worktree. To launch a separate agent already bound to a new or existing worktree, use t3_thread_launch with workspaceStrategy instead. Creates the worktree branch (optionally from origin), re-points the thread at the worktree, and by default runs the project's setup script there. Changing the workspace detaches the live provider session, so the current turn ends shortly after the handoff is recorded; call this as the last action of the turn. To keep working after the handoff, pass continuationPrompt with the remaining work: it is queued as the thread's next message and starts a new turn inside the worktree with the conversation preserved. Without it the thread stays idle until the next message. The worktree is not removed automatically when the thread is deleted. Fails if the thread is already attached to a worktree.", + "Needs an agent running inside a T3 thread. Move this agent thread into a new git worktree. To launch a separate agent already bound to a new or existing worktree, use t3_thread_launch with workspaceStrategy instead. Creates the worktree branch (optionally from origin), re-points the thread at the worktree, and by default runs the project's setup script there. Changing the workspace detaches the live provider session, so the current turn ends shortly after the handoff is recorded; call this as the last action of the turn. To keep working after the handoff, pass continuationPrompt with the remaining work: it is queued as the thread's next message and starts a new turn inside the worktree with the conversation preserved. Without it the thread stays idle until the next message. The worktree is not removed automatically when the thread is deleted. Fails if the thread is already attached to a worktree.", parameters: WorktreeMcpHandoffInput, success: WorktreeMcpHandoffResult, failure: WorktreeMcpFailure, @@ -38,7 +39,7 @@ const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", { const WorktreeStatusTool = Tool.make("t3_worktree_status", { description: - "Report this agent thread's worktree binding: whether it is attached to a git worktree, the worktree path and branch, the project's main workspace root, and the server default for t3_worktree_handoff's startFromOrigin. Call this before t3_worktree_handoff to check whether a handoff is possible or has already happened.", + "Needs an agent running inside a T3 thread. Report this agent thread's worktree binding: whether it is attached to a git worktree, the worktree path and branch, the project's main workspace root, and the server default for t3_worktree_handoff's startFromOrigin. Call this before t3_worktree_handoff to check whether a handoff is possible or has already happened.", // No `parameters`: Tool.make defaults to Tool.EmptyParams, which serializes // to a top-level `type: "object"` JSON Schema. An explicit empty // Schema.Struct({}) serializes to `anyOf: [object, array]`, which is not a @@ -56,8 +57,9 @@ const WorktreeStatusTool = Tool.make("t3_worktree_status", { const WorktreeListTool = Tool.make("t3_worktree_list", { description: - "List branch refs and their associated checkout paths for this thread's workspace using the app's ref inventory. Detached worktrees without a branch are not included. Use t3_worktree_status for the thread binding and t3_worktree_handoff to create a new worktree.", + "List branch refs and their associated checkout paths for a thread's workspace (omit threadId for this thread) using the app's ref inventory. Detached worktrees without a branch are not included. Use t3_worktree_status for the thread binding and t3_worktree_handoff to create a new worktree.", parameters: Schema.Struct({ + threadId: Schema.optional(ThreadId), query: VcsListRefsInput.fields.query, cursor: VcsListRefsInput.fields.cursor, limit: VcsListRefsInput.fields.limit, diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index e822ea4efd4d..907d0794111a 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -15,6 +15,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as EffectAcpErrors from "effect-acp/errors"; import * as ServerConfig from "../../config.ts"; +import * as ServerSettings from "../../serverSettings.ts"; import type { AcpRegistryAvailableCommands, AcpRegistryLiveConfiguration, @@ -87,6 +88,7 @@ const testLayer = Layer.mergeAll( IdAllocator.layer, serverConfigLayer, registryLayer, + ServerSettings.layerTest(), ); describe("AcpRegistryAdapterV2", () => { @@ -121,6 +123,8 @@ describe("AcpRegistryAdapterV2", () => { assert.isTrue(BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2.has(ACP_REGISTRY_PROVIDER)); assert.equal(AcpRegistryAdapterV2Driver.driverKind, ACP_REGISTRY_PROVIDER); assert.deepEqual(AcpRegistryAdapterV2Driver.defaultConfig(), { + source: "registry", + commandArgs: [], enabled: true, agentId: "", commandPath: "", diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts index 76326b274528..af9530a043ae 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts @@ -183,14 +183,17 @@ function makeAcpRegistryRuntime(options: AcpRegistryAdapterV2Options) { export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { const runtimeCoordinator = options.runtimeCoordinator; - const isDevin = options.settings.agentId === "devin"; + const registryAgentId = options.settings.source === "local" ? "" : options.settings.agentId; + const startupKey = + options.settings.source === "local" ? `local:${options.instanceId}` : registryAgentId; + const isDevin = registryAgentId === "devin"; const flavor: AcpAdapterV2Flavor = { driver: ACP_REGISTRY_PROVIDER, capabilities: AcpProviderCapabilitiesV2, - promptFailure: (cause) => acpRegistryPromptFailure(options.settings.agentId, cause), + promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause), // Per-agent exceptions (Mistral Vibe, Devin): see the note above // registerMistralVibeAcpExtensions before adding any more. - ...(options.settings.agentId === "mistral-vibe" + ...(registryAgentId === "mistral-vibe" ? { registerExtensions: registerMistralVibeAcpExtensions } : {}), ...(isDevin @@ -230,7 +233,7 @@ export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { }); }, withRuntimeStartup: (effect: Effect.Effect) => - runtimeCoordinator.withForegroundStartup(options.settings.agentId, effect), + runtimeCoordinator.withForegroundStartup(startupKey, effect), }), ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), }; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 21b398cdc7ad..f8b635a9fa9c 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -3917,7 +3917,9 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - const backgroundStopCases = [true, false, "still_running"] as const; + // "thread_unloaded": the thread was settled, so T3 unsubscribed and Codex + // unloaded it (killing its terminals) before Stop arrived. + const backgroundStopCases = [true, false, "still_running", "thread_unloaded"] as const; const makeBackgroundStopTranscript = (terminated: (typeof backgroundStopCases)[number]) => { const stillRunning = terminated === "still_running"; return makeCodexReplayTranscript({ @@ -3936,9 +3938,15 @@ describe("CodexAdapterV2 post-settle continuation", () => { { type: "emit_inbound", label: "terminate-background-command", - frame: { id: 4, result: { terminated: terminated === true } }, + frame: + terminated === "thread_unloaded" + ? { + id: 4, + error: { code: -32600, message: `thread not found: ${BG_NATIVE_THREAD}` }, + } + : { id: 4, result: { terminated: terminated === true } }, }, - ...(terminated !== true + ...(terminated === false || stillRunning ? [ { type: "expect_outbound" as const, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 2256f4ff9e99..8dad9de3929f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -2054,6 +2054,13 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi Effect.catchTags({ CodexAppServerProcessExitedError: () => Effect.succeed({ terminated: true }), CodexAppServerInputStreamEndedError: () => Effect.succeed({ terminated: true }), + // The thread is unloaded, as Codex does a minute after a + // settle or archive unsubscribes it. Unloading kills the + // thread's terminals, so nothing is left to stop. + CodexAppServerRequestError: (error) => + error.code === -32600 && error.errorMessage.startsWith("thread not found:") + ? Effect.succeed({ terminated: true }) + : Effect.fail(error), }), ); const result = yield* decodeCodexBackgroundTerminalTerminateResponse(response); diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index ad41ae6ffde6..1a02b71982fe 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -534,3 +534,93 @@ it.effect("settles only the stopped run's background work, once", () => ]); }).pipe(Effect.provide(testLayer)), ); + +it.effect("keeps delegated child pull-request links independent of the parent", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const parentThreadId = ThreadId.make("thread:parent-pr"); + const projectId = ProjectId.make("project:parent-pr"); + const parentPullRequest = { + projectId, + repository: "pingdotgg/t3code", + number: 123, + url: "https://github.com/pingdotgg/t3code/pull/123", + }; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-parent-pr"), + threadId: parentThreadId, + projectId, + title: "Parent with a linked PR", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/parent-pr", + worktreePath: "/repo-worktree", + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("link-parent-pr"), + threadId: parentThreadId, + linkedPullRequest: parentPullRequest, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("start-parent-pr"), + threadId: parentThreadId, + messageId: MessageId.make("message:parent-pr"), + text: "Delegate a review", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const parent = yield* projections.getThreadProjection(parentThreadId); + const parentRun = parent.runs[0]!; + yield* orchestrator.dispatch({ + type: "delegated_task.request", + commandId: CommandId.make("delegate-parent-pr"), + parentThreadId, + parentRunId: parentRun.id, + parentNodeId: parentRun.rootNodeId!, + task: "Review the changes", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "agent", + creationSource: "mcp", + }); + const updatedParent = yield* projections.getThreadProjection(parentThreadId); + const childThreadId = updatedParent.subagents[0]!.childThreadId!; + const child = yield* projections.getThreadProjection(childThreadId); + assert.isNull(child.thread.linkedPullRequest); + assert.deepEqual(child.thread.pullRequests, []); + assert.equal(child.thread.branch, parent.thread.branch); + assert.equal(child.thread.worktreePath, parent.thread.worktreePath); + assert.equal(child.thread.lineage.parentThreadId, parentThreadId); + + const childPullRequest = { + ...parentPullRequest, + number: 456, + url: "https://github.com/pingdotgg/t3code/pull/456", + }; + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("link-child-pr"), + threadId: childThreadId, + linkedPullRequest: childPullRequest, + }); + const linkedChild = yield* projections.getThreadProjection(childThreadId); + assert.deepEqual(linkedChild.thread.linkedPullRequest, childPullRequest); + assert.deepEqual( + linkedChild.thread.pullRequests?.map((link) => link.number), + [456], + ); + const parentAfterChildLink = yield* projections.getThreadProjection(parentThreadId); + assert.deepEqual(parentAfterChildLink.thread.linkedPullRequest, parentPullRequest); + assert.deepEqual(parentAfterChildLink.thread.pullRequests, parent.thread.pullRequests); + }).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index fab0276e55ef..913abee22048 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2368,6 +2368,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio threadId: command.threadId, }); } + if ( + command.type === "thread.pull-request.watch" && + command.watching && + (thread.settledOverride === "settled" || thread.settledAt !== null) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} is settled and cannot watch pull requests.`, + }); + } if ( command.type === "thread.metadata.update" && command.expectedWorktreePath !== undefined && @@ -2685,6 +2696,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...thread, settledOverride: "settled", settledAt: alreadySettled ? thread.settledAt : (command.settledAt ?? now), + pullRequests: thread.pullRequests?.map((link) => withPullRequestWatch(link, undefined)), unsettledAt: null, pinnedAt: null, pinOrderKey: null, diff --git a/apps/server/src/orchestration-v2/ProjectStore.ts b/apps/server/src/orchestration-v2/ProjectStore.ts index e576849bc062..ba4cdd2aaeff 100644 --- a/apps/server/src/orchestration-v2/ProjectStore.ts +++ b/apps/server/src/orchestration-v2/ProjectStore.ts @@ -3,7 +3,7 @@ import { IsoDateTime, ModelSelection, type OrchestrationProjectShell, - ProjectIconOverride, + StoredProjectIcon, ProjectId, ProjectScript, ThreadEnvMode, @@ -37,7 +37,7 @@ export const ProjectRow = Schema.Struct({ defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), autoPull: Schema.Boolean, faviconPath: Schema.NullOr(Schema.String), - projectIcon: Schema.NullOr(ProjectIconOverride), + projectIcon: Schema.NullOr(StoredProjectIcon), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -49,7 +49,7 @@ const ProjectDbRow = Schema.Struct({ ...ProjectRow.fields, defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), autoPull: Schema.BooleanFromBit, - projectIcon: Schema.NullOr(Schema.fromJsonString(ProjectIconOverride)), + projectIcon: Schema.NullOr(Schema.fromJsonString(StoredProjectIcon)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 696a40d8d176..0d44bac4c054 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -1082,7 +1082,7 @@ it.effect( const token = captured?.authorizationHeader.replace(/^Bearer\s+/, ""); assert.isDefined(token); const resolved = yield* registry.resolve(token!); - assert.equal(resolved?.threadId, threadId); + assert.equal(resolved?.thread.threadId, threadId); assert.deepEqual( resolved?.capabilities, new Set(["preview", "orchestration", "worktree", "pull-requests"]), @@ -1317,7 +1317,7 @@ it.effect("ProviderSessionManagerV2 duplicate detach preserves replacement MCP c McpProviderSession.readMcpProviderSession(threadId)?.providerSessionId, replacement?.providerSessionId, ); - assert.equal((yield* registry.resolve(replacementToken!))?.threadId, threadId); + assert.equal((yield* registry.resolve(replacementToken!))?.thread.threadId, threadId); }); yield* effect.pipe( @@ -1393,7 +1393,7 @@ it.effect( McpProviderSession.readMcpProviderSession(threadId)?.providerSessionId, replacement?.providerSessionId, ); - assert.equal((yield* registry.resolve(replacementToken!))?.threadId, threadId); + assert.equal((yield* registry.resolve(replacementToken!))?.thread.threadId, threadId); }); yield* effect.pipe( @@ -1449,7 +1449,7 @@ it.effect( // process's MCP client keeps using the credential it was started with. yield* manager.detach({ providerSessionId, threadId, detail: "Workspace changed." }); assert.equal( - (yield* registry.resolve(originalToken!))?.threadId, + (yield* registry.resolve(originalToken!))?.thread.threadId, threadId, "detach must not revoke the credential the live provider process still holds", ); @@ -1468,7 +1468,7 @@ it.effect( original?.providerSessionId, "re-attach must reuse the existing credential, not rotate it", ); - assert.equal((yield* registry.resolve(originalToken!))?.threadId, threadId); + assert.equal((yield* registry.resolve(originalToken!))?.thread.threadId, threadId); // Releasing the session (provider process gone) still revokes. yield* manager.close(providerSessionId); @@ -1592,7 +1592,7 @@ it.effect( "the credential the adapter was configured with must remain current", ); assert.equal( - (yield* registry.resolve(originalToken!))?.threadId, + (yield* registry.resolve(originalToken!))?.thread.threadId, threadId, "the predecessor release must not revoke a credential reserved by an in-flight open", ); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 55278b810e39..307bbb708b18 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -468,8 +468,8 @@ export const layerWithOptions = ( const resolved = yield* mcpSessionRegistry.resolve(rawToken); if ( resolved !== undefined && - resolved.threadId === threadId && - resolved.providerInstanceId === providerInstanceId && + resolved.thread.threadId === threadId && + resolved.thread.providerInstanceId === providerInstanceId && // A flipped browser-access setting must not survive through // credential reuse: rotate so the new scope reflects it. resolved.capabilities.has("preview") === browserToolsAvailable && diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 6e0046c5e59a..f668bbd3f10d 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -64,8 +64,8 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc /** * Wakes a thread's agent when a pull request it watches (`watch_pull_request`) needs a look: * checks finished on the head commit, someone else commented, or the branch started to - * conflict. One pass a minute reads each watched pull request; settled threads wait until - * they are active again, and a merged or closed pull request ends its watch. + * conflict. One pass a minute reads each watched pull request; settling a thread ends its + * watches, and a merged or closed pull request ends its watch. */ export class PullRequestWatchReactor extends Context.Service< PullRequestWatchReactor, diff --git a/apps/server/src/orchestration-v2/SubagentProjection.ts b/apps/server/src/orchestration-v2/SubagentProjection.ts index fa912dfda0ba..f28dc1aa4be5 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.ts @@ -57,6 +57,8 @@ export function makeSubagentChildThread(input: { creationSource: input.creationSource, id: input.childThreadId, title: input.title, + linkedPullRequest: null, + pullRequests: [], historyOrigin: undefined, providerInstanceId: input.providerInstanceId, modelSelection: input.modelSelection, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 5e53250915f8..284d6d4b8e33 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -329,7 +329,10 @@ it.effect.each( assert.equal(wire.messages[0]?.text, task.prompt); assert.equal(wire.messages[0]?.scheduledTaskId, task.id); assert.equal(wire.messages[0]?.createdBy, createdBy); - const turnItem = wire.turnItems.find((item) => item.type === "user_message"); + const turnItem = wire.turnItems.find( + (item): item is Extract => + item.type === "user_message", + ); assert.equal(turnItem?.text, task.prompt); assert.equal(turnItem?.scheduledTaskId, task.id); }).pipe(Effect.provide(Layer.mergeAll(harness.layer, scheduledTasks))); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index c2f0024b0542..a9f68a75b7d4 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2250,6 +2250,108 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each(["manual", "automatic"])("settling ends every pull request watch: %s", (mode) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const threadId = ThreadId.make(`pr-watch-settle-${mode}`); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`pr-watch-settle-create-${mode}`), + threadId, + projectId: ProjectId.make("pr-watch-settle-project"), + title: "Settle watched PRs", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "github.com", repository: "pingdotgg/t3code" }; + for (const number of [7, 8]) { + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-settle-start-${mode}-${number}`), + threadId, + ...key, + number, + watching: true, + link: { url: `https://github.com/pingdotgg/t3code/pull/${number}`, source: "agent" }, + }); + } + const before = yield* orchestrator.getThreadShell(threadId); + const started = before?.pullRequests?.[0]?.watch; + assert.isDefined(started); + if (started === undefined || before === null) return; + yield* orchestrator.dispatch({ + ...(mode === "manual" + ? { type: "thread.settle" as const } + : { type: "thread.auto-settle" as const, snapshotAt: before.updatedAt }), + commandId: CommandId.make(`pr-watch-settle-${mode}`), + threadId, + }); + const expected = before.pullRequests?.map(({ watch: _watch, ...link }) => link); + assert.deepEqual((yield* orchestrator.getThreadShell(threadId))?.pullRequests, expected); + assert.isTrue((yield* maintenance.rebuild).valid); + assert.deepEqual((yield* orchestrator.getThreadShell(threadId))?.pullRequests, expected); + + for (const wake of [ + undefined, + { + messageId: MessageId.make(`pr-watch-settle-late-wake-${mode}`), + text: "Update", + notification: { + source: { kind: "monitor" as const }, + outcome: "updated" as const, + summary: "#7", + }, + }, + ]) { + const late = yield* orchestrator + .dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make(`pr-watch-settle-late-${mode}-${wake !== undefined}`), + threadId, + ...key, + number: 7, + startedAt: started.startedAt, + watch: { ...started, headSha: "late", wakes: 1 }, + ...(wake === undefined ? {} : { wake }), + }) + .pipe(Effect.flip); + assert.equal(late._tag, "OrchestratorDispatchError"); + } + const restart = { + type: "thread.pull-request.watch" as const, + threadId, + ...key, + number: 7, + watching: true, + }; + const blocked = yield* orchestrator + .dispatch({ + ...restart, + commandId: CommandId.make(`pr-watch-settle-blocked-${mode}`), + }) + .pipe(Effect.flip); + assert.equal(blocked._tag, "OrchestratorDispatchError"); + assert.deepEqual((yield* orchestrator.getThreadRecords(threadId, ["messages"])).messages, []); + yield* orchestrator.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make(`pr-watch-settle-unsettle-${mode}`), + threadId, + reason: "user", + }); + yield* orchestrator.dispatch({ + ...restart, + commandId: CommandId.make(`pr-watch-settle-restart-${mode}`), + }); + assert.isDefined((yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch); + }), + ); + it.effect("ends a watch it cannot read, and tells the agent", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index f947d2b74c66..26eca3c7d855 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -9,7 +9,7 @@ import { OrchestrationV2DomainEventJson, OrchestrationV2StoredEvent, ProjectId, - ProjectIconOverride, + StoredProjectIcon, ThreadId, type OrchestrationV2DomainEvent, } from "@t3tools/contracts"; @@ -31,7 +31,7 @@ import { } from "../Errors.ts"; import * as OrchestrationEventStore from "../Services/OrchestrationEventStore.ts"; -const encodeProjectIcon = Schema.encodeSync(ProjectIconOverride); +const encodeProjectIcon = Schema.encodeSync(StoredProjectIcon); const decodeProjectEvent = Schema.decodeUnknownEffect(ApplicationProjectEvent); const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); const EventMetadataFromJsonString = Schema.fromJsonString(ApplicationEventMetadata); diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index 0d709820408d..63363b0d5213 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -25,7 +25,10 @@ import * as NodeCrypto from "node:crypto"; import * as AntigravityInstallation from "./AntigravityInstallation.ts"; import { ANTIGRAVITY_AUTH_BROWSER_MARKER } from "./antigravityAuthSupport.ts"; -import type { AntigravityReleaseAsset } from "./antigravityRelease.ts"; +import { + resolveAntigravityReleaseAsset, + type AntigravityReleaseAsset, +} from "./antigravityRelease.ts"; import antigravityInitialize from "../../../../packages/effect-acp/test/fixtures/antigravity-initialize.json" with { type: "json" }; @@ -945,4 +948,28 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { expect(requests).toEqual([]); }), ); + + it("resolves all supported platform release assets including Intel Mac", () => { + const supportedPlatforms: Array<{ readonly platform: NodeJS.Platform; readonly arch: string }> = + [ + { platform: "darwin", arch: "arm64" }, + { platform: "darwin", arch: "x64" }, + { platform: "linux", arch: "x64" }, + { platform: "linux", arch: "arm64" }, + { platform: "win32", arch: "x64" }, + { platform: "win32", arch: "arm64" }, + ]; + + for (const { platform, arch } of supportedPlatforms) { + const asset = resolveAntigravityReleaseAsset(platform, arch); + expect(asset).not.toBeNull(); + expect(asset?.version).toBe("1.3.0"); + expect(asset?.url).toContain("1.3.0"); + expect(asset?.archiveBytes).toBeGreaterThan(0); + expect(asset?.executable.bytes).toBeGreaterThan(0); + expect(asset?.harness.bytes).toBeGreaterThan(0); + } + + expect(resolveAntigravityReleaseAsset("freebsd", "x64")).toBeNull(); + }); }); diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts index 289039cd0f2c..ceb9c695a590 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts @@ -201,6 +201,19 @@ describe("acpRegistrySnapshotReadiness", () => { }), ).toMatchObject({ installed: false, version: "1.2.3", status: "error" }); + expect( + acpRegistrySnapshotReadiness({ + status: "missing_runner", + version: null, + distribution: "local", + }), + ).toEqual({ + installed: false, + version: null, + status: "error", + message: "Local ACP executable is not available on this environment's PATH.", + }); + expect( acpRegistrySnapshotReadiness({ status: "unprepared", @@ -312,49 +325,57 @@ describe("acpRegistrySnapshotReadiness", () => { expect(snapshot.models[0]?.isDefault).toBe(true); }); - it("reports failed authentication without hiding successful local inspection", () => { - const snapshot = buildCheckedAcpRegistrySnapshot({ - ...identity, - settings: decodeSettings({ agentId: "test-agent", authMethodId: "grok-login" }), - checkedAt: "2026-08-13T10:00:00.000Z", - inspection: { - status: "ready", - agentId: "test-agent", - version: "1.0.0", - distribution: "binary", - }, - probeError: new AcpRegistryOperationError({ - reason: "authentication_failed", - message: "Login required.", - authMethods: [ - { - id: "api-key", - name: "API key", - description: null, - type: "env_var", - }, - { - id: "grok-login", - name: "Log in with Grok", - description: null, - type: "agent", - }, - ], - }), - }); + it.each(["registry", "local"] as const)( + "reports failed authentication after successful %s inspection", + (source) => { + const snapshot = buildCheckedAcpRegistrySnapshot({ + ...identity, + settings: decodeSettings({ + source, + ...(source === "local" ? { commandPath: "test-agent" } : { agentId: "test-agent" }), + authMethodId: "grok-login", + }), + checkedAt: "2026-08-13T10:00:00.000Z", + inspection: { + status: "ready", + agentId: "test-agent", + version: source === "local" ? null : "1.0.0", + distribution: source === "local" ? "local" : "binary", + }, + probeError: new AcpRegistryOperationError({ + reason: "authentication_failed", + message: "Login required.", + authMethods: [ + { + id: "api-key", + name: "API key", + description: null, + type: "env_var", + }, + { + id: "grok-login", + name: "Log in with Grok", + description: null, + type: "agent", + }, + ], + }), + }); - expect(snapshot).toMatchObject({ - installed: true, - version: "1.0.0", - status: "warning", - auth: { - status: "unauthenticated", - type: "agent", - label: "Log in with Grok", - }, - message: 'Sign in in provider settings using "Log in with Grok".', - }); - }); + expect(snapshot).toMatchObject({ + installed: true, + version: source === "local" ? null : "1.0.0", + status: "warning", + setup: { canAuthenticate: true }, + auth: { + status: "unauthenticated", + type: "agent", + label: "Log in with Grok", + }, + message: 'Sign in in provider settings using "Log in with Grok".', + }); + }, + ); it.effect("runs the disposable probe only after local inspection is ready", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts index 68bf797fa084..897dbec5d7fc 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -138,7 +138,8 @@ export function acpRegistrySnapshotReadiness( installed: false, version: null, status: "warning", - message: "Select an ACP Registry agent before starting a thread.", + message: + "Select an ACP Registry agent or configure a local ACP executable before starting a thread.", }; case "not_found": return { @@ -159,7 +160,10 @@ export function acpRegistrySnapshotReadiness( installed: false, version: inspection.version, status: "error", - message: `ACP Registry agent '${inspection.agentId}' requires '${inspection.runner}' on this environment's PATH.`, + message: + inspection.distribution === "local" + ? "Local ACP executable is not available on this environment's PATH." + : `ACP executable '${inspection.runner}' is not available on this environment's PATH.`, }; case "unprepared": return { @@ -196,11 +200,14 @@ function baseSnapshot( readonly documentationUrl?: string; readonly message?: string; readonly probe?: AcpRegistryConfigurationProbeResult; + readonly probeError?: AcpRegistryOperationError; }, ): ServerProvider { const iconUrl = - resolveOfficialAcpRegistryIconUrl(input.probe?.probe.icon) ?? - officialAcpRegistryIconUrlForAgentId(input.settings.agentId); + input.settings.source === "local" + ? null + : (resolveOfficialAcpRegistryIconUrl(input.probe?.probe.icon) ?? + officialAcpRegistryIconUrlForAgentId(input.settings.agentId)); return { instanceId: input.instanceId, driver: DRIVER_KIND, @@ -225,7 +232,9 @@ function baseSnapshot( input.installed && (input.probe ? input.probe.probe.authMethods.length > 0 - : input.settings.agentId.length > 0), + : input.settings.source === "local" + ? (input.probeError?.authMethods?.length ?? 0) > 0 + : input.settings.agentId.length > 0), }, ...(input.message ? { message: input.message } : {}), models: modelsFromDiscovery(input.probe?.probe, input.settings.customModels), @@ -633,7 +642,9 @@ export const AcpRegistryDriver: ProviderDriver Effect.die("unused"), }; -const makeHarness = (method: AcpSchema.AuthMethod, failVerification = false) => +const makeHarness = ( + method: AcpSchema.AuthMethod, + failVerification = false, + settings = decodeSettings({ agentId: "test-agent" }), + providerInstanceId = instanceId, +) => Effect.gen(function* () { const verify = yield* Deferred.make(); const changed: boolean[] = []; @@ -74,8 +79,8 @@ const makeHarness = (method: AcpSchema.AuthMethod, failVerification = false) => const written: string[] = []; const sizes: number[][] = []; const controller = yield* makeAcpRegistryAuth({ - instanceId, - settings: decodeSettings({ agentId: "test-agent" }), + instanceId: providerInstanceId, + settings, cwd: "/workspace", environment: { PATH: "/tools", OVERRIDE: "base" }, onChanged: (value) => @@ -211,6 +216,26 @@ const makeHarness = (method: AcpSchema.AuthMethod, failVerification = false) => }; }).pipe(Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog)); +it.effect("keeps local credentials separate from a matching registry agent ID", () => + Effect.gen(function* () { + const registry = yield* makeHarness(browserMethod); + const local = yield* makeHarness( + browserMethod, + false, + decodeSettings({ source: "local", commandPath: "/different/agent" }), + ProviderInstanceId.make("test-agent"), + ); + assert.deepEqual(registry.controller.credentialBinding, { + owner: "provider", + key: "acp:test-agent", + }); + assert.deepEqual(local.controller.credentialBinding, { + owner: "provider", + key: "acp:local:test-agent", + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect( "discovers methods without signing in, then waits for browser consent and session verification", () => diff --git a/apps/server/src/provider/acp/AcpRegistryAuth.ts b/apps/server/src/provider/acp/AcpRegistryAuth.ts index 4befdb1cec40..6c8a0d9f12c1 100644 --- a/apps/server/src/provider/acp/AcpRegistryAuth.ts +++ b/apps/server/src/provider/acp/AcpRegistryAuth.ts @@ -45,6 +45,8 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o const coordinator = yield* Effect.serviceOption( AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, ); + const agentKey = + options.settings.source === "local" ? `local:${options.instanceId}` : options.settings.agentId; const failure = (operation: string, detail: string, cause?: unknown) => new ProviderSetupError({ instanceId: options.instanceId, operation, detail, cause }); const resolve = catalog @@ -139,7 +141,7 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o const methods = Option.isSome(coordinator) ? coordinator.value - .runBackgroundProbe(options.settings.agentId, discoverMethods) + .runBackgroundProbe(agentKey, discoverMethods) .pipe( Effect.flatMap((result) => Option.isSome(result) @@ -327,7 +329,7 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o }), ); yield* Option.isSome(coordinator) - ? coordinator.value.withForegroundStartup(options.settings.agentId, login) + ? coordinator.value.withForegroundStartup(agentKey, login) : login; yield* options.onChanged(true); }); @@ -353,15 +355,13 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o }), ); const signOut = ( - Option.isSome(coordinator) - ? coordinator.value.withForegroundStartup(options.settings.agentId, logout) - : logout + Option.isSome(coordinator) ? coordinator.value.withForegroundStartup(agentKey, logout) : logout ).pipe(Effect.andThen(options.onChanged(false))); return yield* ProviderAuthFlow.make({ instanceId: options.instanceId, // ACP doesn't advertise its credential scope. Conservatively treat all // instances of the same agent on this environment as sharing credentials. - credentialBinding: { owner: "provider", key: `acp:${options.settings.agentId}` }, + credentialBinding: { owner: "provider", key: `acp:${agentKey}` }, methods, ...(options.settings.authMethodId ? { defaultMethodId: options.settings.authMethodId } : {}), authenticate, diff --git a/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts b/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts index 1f4799e65c24..b38fcfef54e3 100644 --- a/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts +++ b/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts @@ -34,6 +34,9 @@ export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuth // Cosmetic settings and model discovery can rebuild the driver without // changing the account. Credential overrides and profile paths cannot. const binding = hash({ + ...(input.settings.source === "local" + ? { source: "local", commandArgs: input.settings.commandArgs } + : {}), agentId: input.settings.agentId, commandPath: input.settings.commandPath, distribution: input.settings.distribution, diff --git a/apps/server/src/provider/acp/AcpRegistryProbe.test.ts b/apps/server/src/provider/acp/AcpRegistryProbe.test.ts index 4f7422ff783c..9d5288c957ee 100644 --- a/apps/server/src/provider/acp/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/acp/AcpRegistryProbe.test.ts @@ -222,6 +222,75 @@ describe("ACP Registry probe", () => { expect(result.currentModelId).toBeNull(); }); + it("preserves grouped model values and advertises reasoning choices", () => { + const codexModel = '["codex","gpt-6.1-sol"]'; + const goModel = '["opencode-go","deepseek-v4.1-flash"]'; + const result = acpRegistryProbeResult(instanceId, { + sessionId: "probe-session", + initializeResult: { protocolVersion: 1 }, + sessionSetupResult: { + sessionId: "probe-session", + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: goModel, + options: [ + { + groupId: "codex", + name: "Codex", + options: [{ value: codexModel, name: "GPT-6.1 Sol" }], + }, + { + groupId: "opencode-go", + name: "OpenCode Go", + options: [{ value: goModel, name: "DeepSeek V4.1 Flash" }], + }, + ], + }, + { + id: "reasoning", + name: "Reasoning", + category: "thought_level", + type: "select", + currentValue: "high", + options: [ + { + groupId: "effort", + name: "Effort", + options: [ + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + ], + }, + ], + }, + ], + }, + modelConfigId: "model", + } satisfies AcpSessionRuntimeStartResult); + + expect(result.models).toEqual([ + { id: codexModel, name: "GPT-6.1 Sol", description: null }, + { id: goModel, name: "DeepSeek V4.1 Flash", description: null }, + ]); + expect(result.currentModelId).toBe(goModel); + expect(result.configOptions).toEqual([ + { + id: "reasoning", + label: "Reasoning", + type: "select", + currentValue: "high", + options: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + }, + ]); + }); + it("omits a current model that falls outside the bounded model catalog", () => { const result = acpRegistryProbeResult(instanceId, { sessionId: "probe-session", diff --git a/apps/server/src/provider/acp/AcpRegistryProbe.ts b/apps/server/src/provider/acp/AcpRegistryProbe.ts index 588a04e97629..76d1ca0effde 100644 --- a/apps/server/src/provider/acp/AcpRegistryProbe.ts +++ b/apps/server/src/provider/acp/AcpRegistryProbe.ts @@ -440,7 +440,7 @@ export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeCo probe: acpRegistryProbeResult( input.instanceId, result.started, - result.resolved.agent.icon ?? null, + result.resolved.agent?.icon ?? null, { command: result.resolved.spawn.command, args: result.resolved.spawn.args, diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.test.ts b/apps/server/src/provider/acp/AcpRegistrySupport.test.ts index b12b13e3a4ef..dd29f89a2549 100644 --- a/apps/server/src/provider/acp/AcpRegistrySupport.test.ts +++ b/apps/server/src/provider/acp/AcpRegistrySupport.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; -import { AcpRegistrySettings } from "@t3tools/contracts"; +import { AcpRegistrySettings, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; import { HostProcessArchitecture, HostProcessEnvironment, @@ -13,8 +13,6 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as TestClock from "effect/testing/TestClock"; @@ -22,6 +20,7 @@ import * as NodeCrypto from "node:crypto"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; +import * as ServerSettings from "../../serverSettings.ts"; const registryUrl = "https://registry.test/registry.json"; const archiveUrl = "https://registry.test/example-agent.bin"; @@ -57,6 +56,7 @@ function resolverLayer( ) { return Layer.mergeAll( NodeServices.layer, + ServerSettings.layerTest(), Layer.succeed(HostProcessPlatform, "linux"), Layer.succeed(HostProcessArchitecture, "x64"), Layer.succeed(HostProcessEnvironment, environment), @@ -209,6 +209,154 @@ describe("AcpRegistrySupport", () => { ).toBeUndefined(); }); + it.effect("resolves a local command on the selected environment without the registry", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-local-" }); + const commandPath = `${cacheDir}/dsh`; + yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(commandPath, 0o755); + const hostEnvironment = { PATH: "/missing-host-bin", INHERITED: "host", OVERRIDE: "host" }; + const environment = { ...hostEnvironment, PATH: cacheDir, OVERRIDE: "provider" }; + const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }).pipe(Effect.provideService(HostProcessEnvironment, hostEnvironment)); + const localSettings = decodeAcpRegistrySettings({ + source: "local", + commandPath: "dsh", + commandArgs: ["--profile", "acp", "", " spaced ", "$(touch injected); $VALUE"], + }); + + expect(yield* resolver.inspect(localSettings)).toMatchObject({ status: "missing_runner" }); + expect(yield* resolver.inspect(localSettings, environment)).toEqual({ + status: "ready", + agentId: "dsh", + version: null, + distribution: "local", + }); + const resolved = yield* resolver.resolve(localSettings, "/workspace", environment); + expect(resolved).toEqual({ + distribution: "local", + spawn: { + command: commandPath, + args: ["--profile", "acp", "", " spaced ", "$(touch injected); $VALUE"], + cwd: "/workspace", + env: environment, + shell: false, + }, + }); + expect(requests).toEqual([]); + expect(yield* fileSystem.exists(`${cacheDir}/tools`)).toBe(false); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => { + requests.push(request.url); + return Effect.die("unexpected registry request for a local provider"); + }), + ), + ); + }); + + it.effect("resolves a local executable path and inherits the host environment", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-local-path-" }); + const commandPath = `${cacheDir}/dsh wrapper`; + yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(commandPath, 0o755); + const environment = { PATH: "/unused", INHERITED: "host" }; + const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }).pipe(Effect.provideService(HostProcessEnvironment, environment)); + const resolved = yield* resolver.resolve( + decodeAcpRegistrySettings({ source: "local", commandPath }), + "/workspace", + ); + + expect(resolved.spawn).toEqual({ + command: commandPath, + args: [], + cwd: "/workspace", + env: environment, + shell: false, + }); + expect(resolved.agent).toBeUndefined(); + + for (const extension of [".cmd", ".BAT"]) { + const windowsResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(SpawnExecutableResolution, () => `C:\\bin\\dsh${extension}`), + ); + const localSettings = decodeAcpRegistrySettings({ source: "local", commandPath: "dsh" }); + for (const failure of [ + yield* windowsResolver.inspect(localSettings).pipe(Effect.flip), + yield* windowsResolver.resolve(localSettings, "/workspace").pipe(Effect.flip), + ]) { + expect(failure).toMatchObject({ + reason: "runner_unavailable", + detail: expect.stringContaining("node.exe"), + }); + } + } + }).pipe( + Effect.scoped, + Effect.provide(resolverLayer(() => Effect.die("unexpected registry request"))), + ), + ); + + it.effect("reports missing or non-executable local commands without registry fallback", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-local-missing-", + }); + const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const environment = { PATH: cacheDir }; + const unconfigured = decodeAcpRegistrySettings({ source: "local" }); + expect(yield* resolver.inspect(unconfigured, environment)).toEqual({ + status: "unconfigured", + }); + expect( + yield* resolver.resolve(unconfigured, "/workspace", environment).pipe(Effect.flip), + ).toMatchObject({ reason: "agent_not_configured" }); + + const nonExecutable = `${cacheDir}/non-executable`; + yield* fileSystem.writeFileString(nonExecutable, "#!/bin/sh\n"); + yield* fileSystem.chmod(nonExecutable, 0o644); + for (const commandPath of ["dsh", `${cacheDir}/missing`, cacheDir, nonExecutable]) { + const localSettings = decodeAcpRegistrySettings({ source: "local", commandPath }); + expect(yield* resolver.inspect(localSettings, environment)).toEqual({ + status: "missing_runner", + version: null, + distribution: "local", + }); + expect( + yield* resolver.resolve(localSettings, "/workspace", environment).pipe(Effect.flip), + ).toMatchObject({ + reason: "runner_unavailable", + detail: "Local ACP executable is not available on this environment's PATH.", + }); + } + }).pipe( + Effect.scoped, + Effect.provide(resolverLayer(() => Effect.die("unexpected registry request"))), + ), + ); + it.effect("resolves command overrides while preserving registry args and environment", () => { const agent = makeAgent({ binary: { @@ -619,7 +767,7 @@ describe("AcpRegistrySupport", () => { ); }).pipe( Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, ServerSettings.layerTest())), Effect.provideService(HostProcessPlatform, "linux"), Effect.provideService(HostProcessArchitecture, "x64"), ); @@ -1188,42 +1336,72 @@ describe("AcpRegistrySupport", () => { ); }); - it.effect("uninstalls only the T3-managed binary tree and is idempotent", () => { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-acp-registry-uninstall-", - }); - const agentRoot = `${cacheDir}/tools/example-agent`; - const runnerCache = `${cacheDir}/external-npx-cache/example-agent/package.json`; - yield* fileSystem.makeDirectory(`${agentRoot}/1.2.3/linux-x86_64`, { recursive: true }); - yield* fileSystem.writeFileString(`${agentRoot}/1.2.3/linux-x86_64/agent`, "binary"); - yield* fileSystem.makeDirectory(`${cacheDir}/external-npx-cache/example-agent`, { - recursive: true, - }); - yield* fileSystem.writeFileString(runnerCache, "{}"); - - const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ - cacheDir, - toolsDir: `${cacheDir}/tools`, - registryUrl, - }); - const first = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); - const second = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); + it.effect.each([ + { name: "legacy registry", source: undefined, agentId: "example-agent", referenced: true }, + { name: "registry", source: "registry", agentId: "example-agent", referenced: true }, + { name: "padded registry", source: "registry", agentId: " example-agent ", referenced: true }, + { name: "local command", source: "local", agentId: "example-agent", referenced: false }, + ] as const)( + "checks $name references before uninstalling only managed binaries", + ({ source, agentId, referenced }) => { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-uninstall-", + }); + const agentRoot = `${cacheDir}/tools/example-agent`; + const runnerCache = `${cacheDir}/external-npx-cache/example-agent/package.json`; + yield* fileSystem.makeDirectory(`${agentRoot}/1.2.3/linux-x86_64`, { recursive: true }); + yield* fileSystem.writeFileString(`${agentRoot}/1.2.3/linux-x86_64/agent`, "binary"); + yield* fileSystem.makeDirectory(`${cacheDir}/external-npx-cache/example-agent`, { + recursive: true, + }); + yield* fileSystem.writeFileString(runnerCache, "{}"); - expect(first).toEqual({ agentId: "example-agent", removed: true }); - expect(second).toEqual({ agentId: "example-agent", removed: false }); - expect(yield* fileSystem.exists(agentRoot)).toBe(false); - expect(yield* fileSystem.exists(runnerCache)).toBe(true); - }).pipe( - Effect.scoped, - Effect.provide( - resolverLayer((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused"))), + const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const serverSettings = yield* ServerSettings.ServerSettingsService; + const instanceId = ProviderInstanceId.make("uninstall-reference"); + yield* serverSettings.updateProviderInstance({ + operation: "upsert", + instanceId, + instance: { + driver: ProviderDriverKind.make("acpRegistry"), + displayName: "Uninstall reference", + enabled: true, + config: { + agentId, + commandPath: "/local/agent", + ...(source ? { source } : {}), + }, + }, + }); + const first = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); + expect(first).toEqual({ agentId: "example-agent", removed: !referenced }); + expect(yield* fileSystem.exists(agentRoot)).toBe(referenced); + yield* serverSettings.updateProviderInstance({ operation: "remove", instanceId }); + const second = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); + + expect(second).toEqual({ agentId: "example-agent", removed: referenced }); + expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({ + agentId: "example-agent", + removed: false, + }); + expect(yield* fileSystem.exists(agentRoot)).toBe(false); + expect(yield* fileSystem.exists(runnerCache)).toBe(true); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused"))), + ), ), - ), - ); - }); + ); + }, + ); it.effect("keeps managed package installs when removing the same agent's binaries", () => Effect.gen(function* () { @@ -1272,8 +1450,6 @@ describe("AcpRegistrySupport", () => { return Effect.gen(function* () { const downloadStarted = yield* Deferred.make(); const releaseDownload = yield* Deferred.make(); - const referenceChecked = yield* Deferred.make(); - const isReferenced = yield* Ref.make(false); return yield* Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -1290,16 +1466,10 @@ describe("AcpRegistrySupport", () => { .pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(downloadStarted); const uninstallFiber = yield* resolver - .uninstallManagedBinary( - { agentId: agent.id }, - Deferred.succeed(referenceChecked, undefined).pipe( - Effect.andThen(Ref.get(isReferenced)), - ), - ) + .uninstallManagedBinary({ agentId: agent.id }) .pipe(Effect.forkChild({ startImmediately: true })); - expect(Option.isNone(yield* Deferred.poll(referenceChecked))).toBe(true); - yield* Ref.set(isReferenced, true); + expect(uninstallFiber.pollUnsafe()).toBeUndefined(); yield* Deferred.succeed(releaseDownload, undefined); expect(yield* Fiber.join(prepareFiber)).toMatchObject({ prepared: true }); @@ -1310,13 +1480,31 @@ describe("AcpRegistrySupport", () => { const agentRoot = `${cacheDir}/tools/${agent.id}`; expect(yield* fileSystem.exists(agentRoot)).toBe(true); - yield* Ref.set(isReferenced, false); - expect( - yield* resolver.uninstallManagedBinary({ agentId: agent.id }, Ref.get(isReferenced)), - ).toEqual({ agentId: agent.id, removed: true }); - expect( - yield* resolver.uninstallManagedBinary({ agentId: agent.id }, Ref.get(isReferenced)), - ).toEqual({ agentId: agent.id, removed: false }); + const serverSettings = yield* ServerSettings.ServerSettingsService; + const instanceId = ProviderInstanceId.make("registry-reference"); + yield* serverSettings.updateProviderInstance({ + operation: "upsert", + instanceId, + instance: { + driver: ProviderDriverKind.make("acpRegistry"), + displayName: "Registry reference", + enabled: true, + config: { agentId: agent.id }, + }, + }); + expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ + agentId: agent.id, + removed: false, + }); + yield* serverSettings.updateProviderInstance({ operation: "remove", instanceId }); + expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ + agentId: agent.id, + removed: true, + }); + expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ + agentId: agent.id, + removed: false, + }); }).pipe( Effect.scoped, Effect.provide( diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.ts b/apps/server/src/provider/acp/AcpRegistrySupport.ts index 21c34e6dcf69..600ba3a6aaaa 100644 --- a/apps/server/src/provider/acp/AcpRegistrySupport.ts +++ b/apps/server/src/provider/acp/AcpRegistrySupport.ts @@ -40,6 +40,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as NodeCrypto from "node:crypto"; import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts"; +import * as ServerSettings from "../../serverSettings.ts"; import type { AcpSpawnInput } from "./AcpSessionRuntime.ts"; const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; @@ -209,7 +210,7 @@ export function toAcpRegistryOperationError(error: AcpRegistryError): AcpRegistr }); } -export const isAcpRegistryError = Schema.is(AcpRegistryError); +const isAcpRegistryError = Schema.is(AcpRegistryError); export type AcpRegistryPlatformTarget = | "darwin-aarch64" @@ -439,8 +440,8 @@ export function resolveAcpRegistryDistribution(input: { } export interface ResolvedAcpRegistryAgent { - readonly agent: AcpRegistryAgent; - readonly distribution: AcpRegistryDistributionKind; + readonly agent?: AcpRegistryAgent; + readonly distribution: AcpRegistryDistributionKind | "local"; readonly spawn: AcpSpawnInput; } @@ -459,6 +460,11 @@ export type AcpRegistryInspection = readonly distribution: AcpRegistryDistributionKind; readonly runner: string; } + | { + readonly status: "missing_runner"; + readonly version: null; + readonly distribution: "local"; + } | { readonly status: "unprepared"; readonly agentId: string; @@ -469,7 +475,7 @@ export type AcpRegistryInspection = readonly status: "ready"; readonly agentId: string; readonly version: string | null; - readonly distribution: AcpRegistryDistributionKind; + readonly distribution: AcpRegistryDistributionKind | "local"; readonly documentationUrl?: string; }; @@ -493,7 +499,6 @@ export class AcpRegistryCatalog extends Context.Service< ) => Effect.Effect; readonly uninstallManagedBinary: ( input: AcpRegistryManagedBinaryUninstallInput, - isReferenced?: Effect.Effect, ) => Effect.Effect; } >()("t3/provider/acp/AcpRegistrySupport/AcpRegistryCatalog") { @@ -595,11 +600,13 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path + | ServerSettings.ServerSettingsService > { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverSettings = yield* ServerSettings.ServerSettingsService; const platform = yield* HostProcessPlatform; const architecture = yield* HostProcessArchitecture; const hostEnvironment = yield* HostProcessEnvironment; @@ -1610,8 +1617,32 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct } satisfies AcpRegistryPrepareResult; }); + const validateLocalExecutable = (command: string) => + platform === "win32" && /\.(?:cmd|bat)$/iu.test(command) + ? Effect.fail( + new AcpRegistryError({ + reason: "runner_unavailable", + detail: + "Local ACP commands launch without a shell. For a Windows batch wrapper, configure the underlying executable (for example node.exe) and pass the script path as an argument.", + }), + ) + : Effect.void; + const inspect: AcpRegistryCatalog["Service"]["inspect"] = (settings, environment) => Effect.gen(function* () { + if (settings.source === "local") { + const command = settings.commandPath.trim(); + if (!command) return { status: "unconfigured" } as const; + const executable = resolveExecutable(command, platform, environment ?? hostEnvironment); + if (executable !== undefined) yield* validateLocalExecutable(executable); + return executable === undefined + ? ({ + status: "missing_runner", + version: null, + distribution: "local", + } as const) + : ({ status: "ready", agentId: command, version: null, distribution: "local" } as const); + } const agentId = settings.agentId.trim(); if (agentId.length === 0) return { status: "unconfigured" } as const; const registry = yield* loadCachedRegistry(); @@ -1713,6 +1744,28 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct const resolve: AcpRegistryCatalog["Service"]["resolve"] = (settings, cwd, environment) => Effect.gen(function* () { + if (settings.source === "local") { + const executable = settings.commandPath.trim(); + if (!executable) { + return yield* new AcpRegistryError({ + reason: "agent_not_configured", + detail: "Local ACP provider requires an executable.", + }); + } + const env = environment ?? hostEnvironment; + const command = resolveExecutable(executable, platform, env); + if (command === undefined) { + return yield* new AcpRegistryError({ + reason: "runner_unavailable", + detail: "Local ACP executable is not available on this environment's PATH.", + }); + } + yield* validateLocalExecutable(command); + return { + distribution: "local", + spawn: { command, args: settings.commandArgs, cwd, env, shell: false }, + } satisfies ResolvedAcpRegistryAgent; + } const agentId = settings.agentId.trim(); if (agentId.length === 0) { return yield* new AcpRegistryError({ @@ -1782,72 +1835,98 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct } satisfies ResolvedAcpRegistryAgent; }); - const uninstallManagedBinary: AcpRegistryCatalog["Service"]["uninstallManagedBinary"] = ( - input, - isReferenced = Effect.succeed(false), - ) => - installSemaphore.withPermits(1)( - Effect.gen(function* () { - const safeAgentId = yield* decodeBoundedAgentId(input.agentId).pipe( - Effect.mapError( - (cause) => - new AcpRegistryError({ - reason: "install_failed", - detail: "ACP Registry managed binary uninstall received an invalid agent ID.", - cause, - }), - ), - ); - if (yield* isReferenced) { - yield* consumePreparedBinaryReservation(safeAgentId); - return { agentId: safeAgentId, removed: false }; - } - if (yield* hasPreparedBinaryReservation(safeAgentId)) { - return { agentId: safeAgentId, removed: false }; - } - const agentRoot = path.join(installsDirectory, safeAgentId); - const existed = yield* fileSystem.exists(agentRoot).pipe( - Effect.mapError( - (cause) => - new AcpRegistryError({ - reason: "install_failed", - detail: `Could not inspect the managed binary cache for ACP Registry agent ${safeAgentId}.`, - cause, - }), - ), - ); - if (!existed) return { agentId: safeAgentId, removed: false }; - - let removed = false; - yield* Effect.gen(function* () { - for (const version of yield* fileSystem.readDirectory(agentRoot)) { - const versionRoot = path.join(agentRoot, version); - for (const entry of yield* fileSystem.readDirectory(versionRoot)) { - if (!/^(?:darwin|linux|windows)-(?:aarch64|x86_64)$/u.test(entry)) continue; - yield* fileSystem.remove(path.join(versionRoot, entry), { - recursive: true, - force: true, - }); - removed = true; + const uninstallManagedBinary: AcpRegistryCatalog["Service"]["uninstallManagedBinary"] = (input) => + serverSettings + .withSettingsSnapshot((settings) => + installSemaphore.withPermits(1)( + Effect.gen(function* () { + const safeAgentId = yield* decodeBoundedAgentId(input.agentId).pipe( + Effect.mapError( + (cause) => + new AcpRegistryError({ + reason: "install_failed", + detail: "ACP Registry managed binary uninstall received an invalid agent ID.", + cause, + }), + ), + ); + const isReferenced = Object.values(settings.providerInstances).some((instance) => { + if ( + instance.driver !== "acpRegistry" || + instance.config === null || + typeof instance.config !== "object" + ) + return false; + const config = instance.config as Record; + return ( + config.source !== "local" && + typeof config.agentId === "string" && + config.agentId.trim() === safeAgentId + ); + }); + if (isReferenced) { + yield* consumePreparedBinaryReservation(safeAgentId); + return { agentId: safeAgentId, removed: false }; } - if ((yield* fileSystem.readDirectory(versionRoot)).length === 0) - yield* fileSystem.remove(versionRoot, { recursive: true }); - } - if ((yield* fileSystem.readDirectory(agentRoot)).length === 0) - yield* fileSystem.remove(agentRoot, { recursive: true }); - }).pipe( - Effect.mapError( - (cause) => - new AcpRegistryError({ - reason: "install_failed", - detail: `Could not remove managed binaries for ACP Registry agent ${safeAgentId}.`, - cause, - }), - ), - ); - return { agentId: safeAgentId, removed }; - }), - ); + if (yield* hasPreparedBinaryReservation(safeAgentId)) { + return { agentId: safeAgentId, removed: false }; + } + const agentRoot = path.join(installsDirectory, safeAgentId); + const existed = yield* fileSystem.exists(agentRoot).pipe( + Effect.mapError( + (cause) => + new AcpRegistryError({ + reason: "install_failed", + detail: `Could not inspect the managed binary cache for ACP Registry agent ${safeAgentId}.`, + cause, + }), + ), + ); + if (!existed) return { agentId: safeAgentId, removed: false }; + + let removed = false; + yield* Effect.gen(function* () { + for (const version of yield* fileSystem.readDirectory(agentRoot)) { + const versionRoot = path.join(agentRoot, version); + for (const entry of yield* fileSystem.readDirectory(versionRoot)) { + if (!/^(?:darwin|linux|windows)-(?:aarch64|x86_64)$/u.test(entry)) continue; + yield* fileSystem.remove(path.join(versionRoot, entry), { + recursive: true, + force: true, + }); + removed = true; + } + if ((yield* fileSystem.readDirectory(versionRoot)).length === 0) + yield* fileSystem.remove(versionRoot, { recursive: true }); + } + if ((yield* fileSystem.readDirectory(agentRoot)).length === 0) + yield* fileSystem.remove(agentRoot, { recursive: true }); + }).pipe( + Effect.mapError( + (cause) => + new AcpRegistryError({ + reason: "install_failed", + detail: `Could not remove managed binaries for ACP Registry agent ${safeAgentId}.`, + cause, + }), + ), + ); + return { agentId: safeAgentId, removed }; + }), + ), + ) + .pipe( + Effect.catchTag( + "ServerSettingsError", + (cause) => + new AcpRegistryError({ + reason: "install_failed", + detail: + "Could not read provider settings while checking managed ACP binary references.", + cause, + }), + ), + ); return AcpRegistryCatalog.of({ search, diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index dbb78eec8031..eb7129cf9482 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -87,6 +87,7 @@ export interface AcpSpawnInput { readonly cwd?: string; readonly env?: NodeJS.ProcessEnv; readonly extendEnv?: boolean; + readonly shell?: false; } export interface AcpSessionRuntimeOptions { @@ -1516,10 +1517,13 @@ export const make = ( ), ); - const spawnCommand = yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, { - ...(options.spawn.env ? { env: options.spawn.env } : {}), - extendEnv: options.spawn.extendEnv ?? true, - }); + const spawnCommand = + options.spawn.shell === false + ? { command: options.spawn.command, args: options.spawn.args, shell: false } + : yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, { + ...(options.spawn.env ? { env: options.spawn.env } : {}), + extendEnv: options.spawn.extendEnv ?? true, + }); const linuxCgroupLease = options.ownDescendantProcessGroups === true && options.processGroupPlatform === "linux" ? yield* Effect.sync(() => { diff --git a/apps/server/src/provider/antigravityRelease.ts b/apps/server/src/provider/antigravityRelease.ts index 90ba309c10c6..bcce891a463e 100644 --- a/apps/server/src/provider/antigravityRelease.ts +++ b/apps/server/src/provider/antigravityRelease.ts @@ -1,4 +1,4 @@ -const ANTIGRAVITY_RELEASE_VERSION = "agy_acp_server_1.1.1"; +const ANTIGRAVITY_RELEASE_VERSION = "1.3.0"; export interface AntigravityReleaseAsset { readonly version: string; @@ -15,62 +15,73 @@ export interface AntigravityReleaseAsset { }; } -// URLs come from the official registry. Hashes and sizes were checked on 2026-09-03. -// https://github.com/agentclientprotocol/registry/blob/81bf71b55e15f630c4fb8a86d20d3088071d2071/antigravity-acp/agent.json +// URLs come from the official registry. Hashes and sizes were checked on 2026-10-05. +// https://github.com/agentclientprotocol/registry/blob/dc55a34900fdd60e5e97c1cbd7825c5a1df673fc/antigravity-acp/agent.json const releaseAssets = new Map([ [ "darwin-arm64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-agy_acp_server_1.1.1-darwin-arm64.zip", - sha256: "fdfa915652cdb7ba8085cc8fffed072cbe009251aa2c951aabdda07a8c28a189", - archiveBytes: 316_014_828, - executable: { name: "agy_acp_server.par", bytes: 802_163_856 }, - harness: { name: "localharness_external", bytes: 116_766_704 }, + url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-1.3.0-darwin-arm64.zip", + sha256: "7cd97045f7b4fe81175a107cdf16f9c51484e3c78a5162cae415338bb6aa5b88", + archiveBytes: 111_456_962, + executable: { name: "agy_acp_server.par", bytes: 278_535_456 }, + harness: { name: "localharness_external", bytes: 118_611_392 }, + }, + ], + [ + "darwin-x64", + { + version: ANTIGRAVITY_RELEASE_VERSION, + url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-1.3.0-darwin-x86_64.zip", + sha256: "bb23956b89984bf5d354af2c3725e6c57f0cc1b7228e77a0e91c9c2bc1d47646", + archiveBytes: 117_245_544, + executable: { name: "agy_acp_server.par", bytes: 282_840_688 }, + harness: { name: "localharness_external", bytes: 124_175_392 }, }, ], [ "linux-x64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-agy_acp_server_1.1.1-linux-x86_64.zip", - sha256: "38f62d01b32deb0907b3d39a71ec301fd36369f6ffd1cf262d4af385177f79df", - archiveBytes: 681_969_407, - executable: { name: "agy_acp_server.par", bytes: 1_880_360_328 }, - harness: { name: "localharness_external", bytes: 128_966_920 }, + url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-1.3.0-linux-x86_64.zip", + sha256: "9fb60956af0a9d76220a4db91ca9ac88e2a2372ad68f985ab5fceace6b825b96", + archiveBytes: 333_727_150, + executable: { name: "agy_acp_server.par", bytes: 926_533_965 }, + harness: { name: "localharness_external", bytes: 130_388_040 }, }, ], [ "linux-arm64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-agy_acp_server_1.1.1-linux-arm64.zip", - sha256: "ed69e64b308fcb123ab54bf3277bf9cb0d651064f885ea5aab0ff520c7175398", - archiveBytes: 656_572_786, - executable: { name: "agy_acp_server.par", bytes: 1_862_073_131 }, - harness: { name: "localharness_external", bytes: 122_158_704 }, + url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-1.3.0-linux-arm64.zip", + sha256: "500b0bc0fb858e88f4df404d4cedf80bf9298c178291e39e383d6c50b111cbdf", + archiveBytes: 321_690_363, + executable: { name: "agy_acp_server.par", bytes: 930_848_992 }, + harness: { name: "localharness_external", bytes: 123_224_968 }, }, ], [ "win32-x64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-agy_acp_server_1.1.1-windows-x86_64.zip", - sha256: "47cb50eef14f0a4655d78cfcfda869bcea7aaee5f9787e936bc2935ea612c3b8", - archiveBytes: 468_238_392, - executable: { name: "agy_acp_server.exe", bytes: 430_801_616 }, - harness: { name: "localharness_external.exe", bytes: 130_971_800 }, + url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-1.3.0-windows-x86_64.zip", + sha256: "65215e0688681fa3116e048a9eab27ef53af1bbd6f3da3f1c52bd4911d8b17f9", + archiveBytes: 124_509_787, + executable: { name: "agy_acp_server.exe", bytes: 81_437_336 }, + harness: { name: "localharness_external.exe", bytes: 145_548_952 }, }, ], [ "win32-arm64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-agy_acp_server_1.1.1-windows-arm64.zip", - sha256: "35f4b1f47ba6a3fea7b0a3e30010df5ea73a64b4f0e7cf991cddc673ddfbcafc", - archiveBytes: 468_521_191, - executable: { name: "agy_acp_server.exe", bytes: 435_075_816 }, - harness: { name: "localharness_external.exe", bytes: 122_455_704 }, + url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-1.3.0-windows-arm64.zip", + sha256: "4a0f469720e9beb9438a979f543fdbfad5022ebe0992c052c590bd78b3144ca3", + archiveBytes: 124_654_803, + executable: { name: "agy_acp_server.exe", bytes: 85_893_472 }, + harness: { name: "localharness_external.exe", bytes: 135_640_216 }, }, ], ]); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index d248d51d9855..14785b3f8e6b 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2,9 +2,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; import { assert, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -3348,6 +3350,72 @@ it.effect("answers a repeated listing from cache, and concurrent readers share o }), ); +it.effect.each(["list", "listStats"] as const)( + "retries a replacement %s read that joins a canceled lookup", + (operation) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const stopping = yield* Deferred.make(); + const finishShutdown = yield* Deferred.make(); + let hostCalls = 0; + const lookup = Effect.gen(function* () { + if (++hostCalls !== 1) return; + yield* Deferred.succeed(started, undefined); + return yield* Effect.never.pipe( + Effect.onInterrupt(() => + Deferred.succeed(stopping, undefined).pipe( + Effect.andThen(Deferred.await(finishShutdown)), + ), + ), + ); + }); + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + lookup.pipe( + Effect.as({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: false, + }), + ), + listChangeRequestStats: () => + lookup.pipe( + Effect.as([{ repository: "acme/web", number: 1, additions: 3, deletions: 1 }]), + ), + }), + ], + }); + const read = + operation === "list" + ? service + .list({ state: "open" }) + .pipe(Effect.map((result) => result.entries.map((entry) => entry.number))) + : service + .listStats({ + refs: [{ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }], + }) + .pipe(Effect.map((result) => result.stats.map((stat) => stat.number))); + const original = yield* Effect.forkScoped(read); + yield* Deferred.await(started); + const cancellation = yield* Effect.forkScoped(Fiber.interrupt(original)); + yield* Deferred.await(stopping); + // Join the old entry while its lookup is still finishing cancellation. + const replacement = yield* Effect.forkScoped(read, { startImmediately: true }); + yield* Deferred.succeed(finishShutdown, undefined); + yield* Fiber.join(cancellation); + const canceled = yield* Fiber.await(original); + assert.isTrue(Exit.isFailure(canceled)); + if (Exit.isFailure(canceled)) assert.isTrue(Cause.hasInterruptsOnly(canceled.cause)); + assert.deepStrictEqual(yield* Fiber.join(replacement), [1]); + assert.strictEqual(hostCalls, 2); + }), +); + it.effect("shares one cold viewer lookup across distinct concurrent lists", () => Effect.gen(function* () { let viewerCalls = 0; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index afe2d9683cad..a5a1a5b01d62 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -5,6 +5,7 @@ import { } from "@t3tools/shared/sourceControl"; import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -2887,7 +2888,12 @@ export const make = Effect.gen(function* () { ? null : Object.entries(input.cursors).toSorted(([left], [right]) => left.localeCompare(right)), ]); - return Cache.get(listCache, key); + // A replacement reader can join a lookup still finishing its previous reader's cancellation. + return Cache.get(listCache, key).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) ? Cache.get(listCache, key) : Effect.failCause(cause), + ), + ); }; const checksCache = yield* Cache.makeWith( @@ -3134,7 +3140,11 @@ export const make = Effect.gen(function* () { } if (missing.size === 0) return { stats: held }; const key = statsBatchKey(missing.values()); - const { result, at } = yield* Cache.get(listStatsCache, key); + const { result, at } = yield* Cache.get(listStatsCache, key).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) ? Cache.get(listStatsCache, key) : Effect.failCause(cause), + ), + ); for (const [key, ref] of missing) { const stat = result.stats.find( (stat) => diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 03ec85da863e..11c5801f6866 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -588,7 +588,7 @@ const RuntimeCoreDependenciesLive = RuntimeCoreDependenciesBaseLive.pipe( Layer.provideMerge(PtyAdapterLive), // Search, prepare, status inspection, and turn launch share one registry // cache so every client and provider instance sees the same prepared agents. - Layer.provideMerge(AcpRegistryCatalogLive), + Layer.provideMerge(AcpRegistryCatalogLive.pipe(Layer.provide(ServerSettingsLayerLive))), // Shared native/canonical NDJSON writers used by both the per-instance // V2 drivers and the orchestration runtime. Provide resource attribution so // the rewritten telemetry pipeline can account for logical NDJSON writes. diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 07f6c68c0e4b..5dadf9a8a8dc 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2118,36 +2118,9 @@ const makeWsRpcLayer = ( [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: (input) => observeRpcEffect( WS_METHODS.serverUninstallAcpRegistryManagedBinary, - serverSettings - .withSettingsSnapshot((settings) => - acpRegistryCatalog.uninstallManagedBinary( - input, - Effect.succeed( - Object.values(settings.providerInstances).some((instance) => { - if ( - instance.driver !== "acpRegistry" || - instance.config === null || - typeof instance.config !== "object" - ) { - return false; - } - return (instance.config as Record).agentId === input.agentId; - }), - ), - ), - ) - .pipe( - Effect.mapError((cause) => - AcpRegistrySupport.isAcpRegistryError(cause) - ? cause - : new AcpRegistrySupport.AcpRegistryError({ - reason: "install_failed", - detail: `Could not read provider settings while checking references for ACP Registry agent ${input.agentId}.`, - cause, - }), - ), - Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError), - ), + acpRegistryCatalog + .uninstallManagedBinary(input) + .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)), { "rpc.aggregate": "server", "acp_registry.agent_id": input.agentId, diff --git a/apps/web/package.json b/apps/web/package.json index 6e6a22d917cf..14ce3b42e988 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -62,6 +62,7 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", + "unbash": "^5.0.0", "zustand": "^5.0.11" }, "devDependencies": { diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 4c62f5f385ef..f47bd48678ab 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -2,13 +2,6 @@ import React, { type SVGProps, useId } from "react"; import { cn } from "~/lib/utils"; export type Icon = React.FC>; -export const UltrafastIcon: Icon = (props) => ( - - - - -); - export const FinderIcon: Icon = (props) => ( diff --git a/apps/web/src/components/Sidebar.pointer.test.ts b/apps/web/src/components/Sidebar.pointer.test.ts index 3a54498c2304..0c171313612e 100644 --- a/apps/web/src/components/Sidebar.pointer.test.ts +++ b/apps/web/src/components/Sidebar.pointer.test.ts @@ -166,9 +166,9 @@ describe("sidebar pointer lifecycle", () => { expect(drag.onCancel).not.toHaveBeenCalled(); }); - it("suppresses a delayed release click after cancellation, then accepts the next click", () => { + it.each([false, true])("suppresses a cancelled release click, started=%s", (started) => { const drag = gesture(); - document.dispatchEvent(pointer("pointermove", { clientY: 20 })); + if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 })); drag.sensor.cancel(); vi.advanceTimersByTime(1000); const releaseClick = new Event("click"); @@ -182,9 +182,9 @@ describe("sidebar pointer lifecycle", () => { expect(nextPropagation).not.toHaveBeenCalled(); }); - it("allows the next click when the cancelled release happened outside the document", () => { + it.each([false, true])("allows a fresh click after an outside release, started=%s", (started) => { const drag = gesture(); - document.dispatchEvent(pointer("pointermove", { clientY: 20 })); + if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 })); drag.sensor.cancel(); document.dispatchEvent(pointer("pointerdown")); const click = new Event("click"); diff --git a/apps/web/src/components/Sidebar.pointer.ts b/apps/web/src/components/Sidebar.pointer.ts index 5ae83901ed1e..23f95481485d 100644 --- a/apps/web/src/components/Sidebar.pointer.ts +++ b/apps/web/src/components/Sidebar.pointer.ts @@ -137,7 +137,8 @@ export class SidebarPointerSensor { // Cancellation can precede release by an arbitrary amount of time. Consume // that release click, or let a fresh pointerdown end suppression if release // happened outside the document. Ordinary clicks never install this guard. - if (!aborted) { + if (!aborted || cancelled) { + this.document.addEventListener("click", this.suppressClick, { capture: true }); this.document.addEventListener("pointerdown", this.clearClickSuppression, { capture: true }); } try { diff --git a/apps/web/src/components/chat/HighlightedTokens.test.ts b/apps/web/src/components/chat/HighlightedTokens.test.ts new file mode 100644 index 000000000000..ef4bf456585f --- /dev/null +++ b/apps/web/src/components/chat/HighlightedTokens.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { embeddedScripts } from "../../lib/embeddedScripts"; +import { keyedLines, withEmbeddedScripts, wordsOf } from "./HighlightedTokens"; + +function tokensOf(...contents: string[]) { + let offset = 0; + return contents.map((content) => { + const token = { content, offset }; + offset += content.length; + return token; + }); +} + +function shape(parts: ReturnType) { + return parts.map((part) => + typeof part === "string" ? part : part.pieces.map((piece) => piece.text).join(""), + ); +} + +describe("wordsOf", () => { + it.each([ + // Shiki splits these shell words into several tokens. + [["echo", " ", '"', "$HOME", "/${", "PATH", "%%:*", '}"'], 'echo "$HOME/${PATH%%:*}"'], + [["rsync", " ", "-a", " ", "--exclude", " ", "build"], "rsync -a --exclude build"], + // A token can carry whitespace on either side or inside a string. + [["git", " && ", "echo", " ", '"two words"', "; "], 'git && echo "two words"; '], + [[" ", "indented"], " indented"], + [[], ""], + ])("wraps the same words as the plain-text fallback: %j", (contents, line) => { + expect(shape(wordsOf(tokensOf(...contents)))).toEqual( + line.split(/(\s+)/u).filter((part) => part !== ""), + ); + }); + + it("keeps each piece's own token so colors survive regrouping", () => { + const [word] = wordsOf(tokensOf('"', "$HOME", '"')); + expect( + typeof word === "string" ? null : word?.pieces.map((piece) => piece.token.content), + ).toEqual(['"', "$HOME", '"']); + }); +}); + +describe("keyedLines", () => { + it.each([ + "git status\ngit diff", + "Get-ChildItem\r\nWrite-Output done\r\n", + "mixed\r\nendings\n\nlone \r stays", + ])("renders the same text as the source: %j", (code) => { + // Shiki splits lines on \r?\n and drops the ending from the tokens. + const lines = code.split(/\r?\n/u).map((line) => tokensOf(line)); + const rendered = keyedLines(code, lines) + .map(({ tokens, ending }) => tokens.map((token) => token.content).join("") + ending) + .join(""); + expect(rendered).toBe(code); + }); + + it("keys empty lines apart", () => { + const code = "a\r\n\r\n\r\nb"; + const keys = keyedLines( + code, + code.split(/\r?\n/u).map((line) => tokensOf(line)), + ).map((line) => line.key); + expect(new Set(keys).size).toBe(keys.length); + }); +}); + +describe("withEmbeddedScripts", () => { + // A stand-in grammar: each word is one token colored with the language name, + // and lines split the way Shiki splits them. + function tokenize(text: string, language: string) { + if (language === "unknown") throw new Error("Language not found"); + let lineStart = 0; + return text.split(/\r?\n/u).map((line) => { + const tokens = [...line.matchAll(/\S+|\s+/gu)].map((match) => ({ + content: match[0], + offset: lineStart + match.index, + color: language, + })); + lineStart += + line.length + (text.slice(lineStart + line.length).match(/^\r?\n/u)?.[0].length ?? 0); + return tokens; + }); + } + + function colorsOf(code: string, scripts = embeddedScripts(code)) { + const lines = withEmbeddedScripts(code, tokenize(code, "shellscript"), scripts, tokenize); + expect(lines.map((line) => line.map((token) => token.content).join("")).join("\n")).toBe( + code.replaceAll("\r\n", "\n"), + ); + return lines.flat().map((token) => [token.content, token.color]); + } + + it("colors each nested script with its own grammar, keeping the text", () => { + expect(colorsOf(`bash -lc "python3 -c 'print(1)'"`)).toEqual([ + [`bash -lc "python3 -c '`, "shellscript"], + ["print(1)", "python"], + [`'"`, "shellscript"], + ]); + }); + + it("colors an escape sequence like the character it spells", () => { + expect(colorsOf(`bash -c "python3 -c 'print(\\"x\\")'"`)).toContainEqual([ + `print(\\"x\\")`, + "python", + ]); + }); + + it("keeps the surrounding colors where a grammar is missing", () => { + const code = "cat > x.ts <<'EOF'\r\nbody\r\nEOF"; + const [script] = embeddedScripts(code); + // Compare per character: untouched lines keep their grammar's token boundaries. + const characterColors = (scripts: ReturnType) => + colorsOf(code, scripts).flatMap(([content, color]) => [...content!].map(() => color)); + expect(characterColors([{ ...script!, language: "unknown" }])).toEqual(characterColors([])); + }); +}); diff --git a/apps/web/src/components/chat/HighlightedTokens.tsx b/apps/web/src/components/chat/HighlightedTokens.tsx new file mode 100644 index 000000000000..1d7f1522383f --- /dev/null +++ b/apps/web/src/components/chat/HighlightedTokens.tsx @@ -0,0 +1,204 @@ +import { use, useMemo, type CSSProperties } from "react"; + +import { resolveDiffThemeName } from "../../lib/diffRendering"; +import type { EmbeddedScript } from "../../lib/embeddedScripts"; +import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting"; + +interface SyntaxToken { + readonly content: string; + readonly offset: number; + readonly color?: string; + readonly fontStyle?: number; +} + +type TokenLines = ReadonlyArray>; + +const NO_EMBEDDED_SCRIPTS: ReadonlyArray = []; + +function syntaxTokenStyle(token: SyntaxToken): CSSProperties { + const fontStyle = token.fontStyle ?? 0; + return { + ...(token.color ? { color: token.color } : {}), + ...(fontStyle & 1 ? { fontStyle: "italic" } : {}), + ...(fontStyle & 2 ? { fontWeight: 700 } : {}), + ...(fontStyle & 4 ? { textDecoration: "underline" } : {}), + }; +} + +/** + * Colors `code` inside the caller's `
` without changing its text, so
+ * selection and copy match the plain version. Suspends while the grammar
+ * loads; wrap it in Suspense with the plain text as the fallback.
+ * `wordClassName` goes on each whitespace-separated word. `embedded`
+ * scripts are colored with their own grammar inside `code`.
+ */
+export function HighlightedTokens({
+  code,
+  language,
+  embedded = NO_EMBEDDED_SCRIPTS,
+  theme,
+  wordClassName,
+}: {
+  code: string;
+  language: string;
+  embedded?: ReadonlyArray;
+  theme: "light" | "dark";
+  wordClassName?: string;
+}) {
+  const highlighter = use(getSyntaxHighlighterPromise(language));
+  // Every grammar loads into the same shared highlighter.
+  for (const script of embedded) use(getSyntaxHighlighterPromise(script.language));
+  const lines = useMemo(() => {
+    const themeName = resolveDiffThemeName(theme);
+    const tokenize = (text: string, lang: string) =>
+      highlighter.codeToTokens(text, { lang, theme: themeName }).tokens;
+    return keyedLines(
+      code,
+      withEmbeddedScripts(code, tokenize(code, language), embedded, tokenize),
+    );
+  }, [code, embedded, highlighter, language, theme]);
+
+  return lines.map(({ key, tokens, ending }) => (
+    
+      {wordClassName
+        ? wordsOf(tokens).map((part) =>
+            typeof part === "string" ? (
+              part
+            ) : (
+              
+                {part.pieces.map((piece) => (
+                  
+                    {piece.text}
+                  
+                ))}
+              
+            ),
+          )
+        : tokens.map((token) => (
+            
+              {token.content}
+            
+          ))}
+      {ending}
+    
+  ));
+}
+
+/**
+ * Recolors each embedded script's span of `code` with its own grammar,
+ * outermost first so nested scripts win. A script whose grammar is missing
+ * keeps the colors around it.
+ */
+export function withEmbeddedScripts(
+  code: string,
+  lines: TokenLines,
+  embedded: ReadonlyArray,
+  tokenize: (text: string, language: string) => TokenLines,
+): TokenLines {
+  if (embedded.length === 0) return lines;
+  const styles = Array.from({ length: code.length });
+  for (const token of lines.flat()) {
+    styles.fill(token, token.offset, token.offset + token.content.length);
+  }
+  for (const script of embedded) {
+    let scriptLines: TokenLines;
+    try {
+      scriptLines = tokenize(script.text, script.language);
+    } catch {
+      continue;
+    }
+    for (const token of scriptLines.flat()) {
+      if (token.content === "") continue;
+      const last = token.offset + token.content.length - 1;
+      styles.fill(token, script.starts[token.offset], script.ends[last]);
+    }
+  }
+  return restyledLines(code, styles);
+}
+
+/** Splits `code` into lines of tokens, one per run of characters sharing a style. */
+function restyledLines(
+  code: string,
+  styles: ReadonlyArray,
+): SyntaxToken[][] {
+  const lines: SyntaxToken[][] = [];
+  let start = 0;
+  for (const lineBreak of [...code.matchAll(/\r?\n/gu), undefined]) {
+    const end = lineBreak?.index ?? code.length;
+    const tokens: SyntaxToken[] = [];
+    for (let index = start; index < end;) {
+      const style = styles[index];
+      let next = index + 1;
+      while (
+        next < end &&
+        styles[next]?.color === style?.color &&
+        styles[next]?.fontStyle === style?.fontStyle
+      ) {
+        next += 1;
+      }
+      tokens.push({
+        content: code.slice(index, next),
+        offset: index,
+        ...(style?.color ? { color: style.color } : {}),
+        ...(style?.fontStyle ? { fontStyle: style.fontStyle } : {}),
+      });
+      index = next;
+    }
+    lines.push(tokens);
+    if (lineBreak) start = lineBreak.index + lineBreak[0].length;
+  }
+  return lines;
+}
+
+/**
+ * Keys each line by its start offset, which stays unique when several lines
+ * are empty, and pairs it with the line ending `code` has after it. Shiki drops
+ * `\n` and `\r\n` from tokens alike, so CRLF has to come back from the source.
+ */
+export function keyedLines(
+  code: string,
+  lines: ReadonlyArray>,
+) {
+  const endings = code.match(/\r?\n/gu) ?? [];
+  let lineStart = 0;
+  return lines.map((tokens, index) => {
+    const text = tokens.map((token) => token.content).join("");
+    const key = `${lineStart}:${text}`;
+    const ending = endings[index] ?? "";
+    lineStart += text.length + ending.length;
+    return { key, tokens, ending };
+  });
+}
+
+interface Word {
+  readonly key: string;
+  readonly pieces: Array<{ key: string; text: string; token: SyntaxToken }>;
+}
+
+/**
+ * Regroups a line's tokens into whitespace runs and whole words, matching
+ * `text.split(/(\s+)/)`, so a word made of several tokens (`"$HOME/x"`) wraps
+ * exactly like its plain-text fallback.
+ */
+export function wordsOf(tokens: ReadonlyArray): Array {
+  const parts: Array = [];
+  let word: Word | null = null;
+  for (const token of tokens) {
+    let offset = token.offset;
+    for (const text of token.content.split(/(\s+)/u)) {
+      if (text === "") continue;
+      if (/^\s/u.test(text)) {
+        parts.push(text);
+        word = null;
+      } else {
+        if (!word) {
+          word = { key: String(offset), pieces: [] };
+          parts.push(word);
+        }
+        word.pieces.push({ key: String(offset), text, token });
+      }
+      offset += text.length;
+    }
+  }
+  return parts;
+}
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts
index 280cc3672103..81b4af628630 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts
@@ -121,7 +121,7 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string
   }
   const toolPresentation = resolveWorkEntryToolPresentation(entry);
   if (toolPresentation) return toolPresentation.displayName;
-  if (entry.command) return commandDisplayText(entry.command);
+  if (entry.command?.trim()) return commandDisplayText(entry.command);
   const action = toolGroupAction(entry);
   if (action === "code-search" || action === "search") {
     // Adapters title file searches with their target; the item keeps only the pattern.
diff --git a/apps/web/src/components/chat/ShellCommandBlock.tsx b/apps/web/src/components/chat/ShellCommandBlock.tsx
new file mode 100644
index 000000000000..cd264eaaa903
--- /dev/null
+++ b/apps/web/src/components/chat/ShellCommandBlock.tsx
@@ -0,0 +1,76 @@
+import {
+  commandHighlightLanguage,
+  withVisibleControlCharacters,
+} from "@t3tools/client-runtime/work-log/command-label";
+import { Suspense, use, useMemo } from "react";
+
+import { useTheme } from "../../hooks/useTheme";
+import { RenderErrorBoundary } from "../RenderErrorBoundary";
+import { HighlightedTokens } from "./HighlightedTokens";
+
+// Shell words wrap as a unit, so `--exclude` or a quoted string is not split
+// at a hyphen; a word longer than the line still breaks anywhere.
+const WORD_CLASS_NAME = "inline-block max-w-full [overflow-wrap:anywhere]";
+
+// The shell parser loads with the first expanded command, not with the timeline.
+let embeddedScriptsModule: Promise | undefined;
+function loadEmbeddedScripts() {
+  embeddedScriptsModule ??= import("../../lib/embeddedScripts");
+  return embeddedScriptsModule;
+}
+
+/** Same layout as the highlighted version, so the grammar arriving never reflows the block. */
+function PlainWords({ code }: { code: string }) {
+  // split with a capture group alternates words (even) and whitespace (odd).
+  return code.split(/(\s+)/u).map((part, index) =>
+    index % 2 === 1 || part === "" ? (
+      part
+    ) : (
+      // Positions are stable for a given string, which is all this renders.
+      // oxlint-disable-next-line react/no-array-index-key
+      
+        {part}
+      
+    ),
+  );
+}
+
+function HighlightedCommand({ code, theme }: { code: string; theme: "light" | "dark" }) {
+  const { embeddedScripts } = use(loadEmbeddedScripts());
+  const language = commandHighlightLanguage(code);
+  // Only shell syntax nests scripts; PowerShell's own grammar colors its strings.
+  const embedded = useMemo(
+    () => (language === "shellscript" ? embeddedScripts(code) : []),
+    [code, embeddedScripts, language],
+  );
+  return (
+    
+  );
+}
+
+/**
+ * The command a command_execution item ran, syntax highlighted. Scripts inside
+ * it, such as a `bash -lc` script or a Python heredoc, get their own grammar.
+ */
+export function ShellCommandBlock({ command }: { command: string }) {
+  const { resolvedTheme } = useTheme();
+  const code = withVisibleControlCharacters(command.trim());
+  if (!code) return null;
+  const plain = ;
+  return (
+    // The tool body sets the monospace, pre-wrapped text this sits in.
+    
+ + + + + +
+ ); +} diff --git a/apps/web/src/components/chat/SnapShotAttachmentDetails.tsx b/apps/web/src/components/chat/SnapShotAttachmentDetails.tsx index 793da952c6ef..ca6e366fe829 100644 --- a/apps/web/src/components/chat/SnapShotAttachmentDetails.tsx +++ b/apps/web/src/components/chat/SnapShotAttachmentDetails.tsx @@ -1,15 +1,14 @@ import type { SnapShotSource } from "@t3tools/contracts"; import { ImageIcon, TextIcon } from "lucide-react"; -import { Suspense, use, useMemo, type CSSProperties } from "react"; +import { Suspense } from "react"; import { useTheme } from "../../hooks/useTheme"; -import { resolveDiffThemeName } from "../../lib/diffRendering"; -import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting"; import { cn } from "../../lib/utils"; import { RenderErrorBoundary } from "../RenderErrorBoundary"; import { Button } from "../ui/button"; import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { HighlightedTokens } from "./HighlightedTokens"; export const SNAP_SHOT_ATTACHMENT_FRAME_CLASS = "relative h-28 w-52 max-w-full overflow-hidden rounded-lg border border-border/80"; @@ -19,59 +18,6 @@ export interface SnapShotAccessibilityDetails { format: "json" | "text"; } -interface SyntaxToken { - readonly content: string; - readonly offset: number; - readonly color?: string; - readonly fontStyle?: number; -} - -function syntaxTokenStyle(token: SyntaxToken): CSSProperties { - const fontStyle = token.fontStyle ?? 0; - return { - ...(token.color ? { color: token.color } : {}), - ...(fontStyle & 1 ? { fontStyle: "italic" } : {}), - ...(fontStyle & 2 ? { fontWeight: 700 } : {}), - ...(fontStyle & 4 ? { textDecoration: "underline" } : {}), - }; -} - -function HighlightedAccessibilityJson({ - content, - theme, -}: { - content: string; - theme: "light" | "dark"; -}) { - const highlighter = use(getSyntaxHighlighterPromise("json")); - const lines = useMemo( - () => - highlighter.codeToTokens(content, { - lang: "json", - theme: resolveDiffThemeName(theme), - }).tokens, - [content, highlighter, theme], - ); - - let lineOffset = 0; - return lines.map((line) => { - const lineContent = line.map((token) => token.content).join(""); - const lineKey = `${lineOffset}:${lineContent}`; - const hasNextLine = lineOffset + lineContent.length < content.length; - lineOffset += lineContent.length + 1; - return ( - - {line.map((token) => ( - - {token.content} - - ))} - {hasNextLine ? "\n" : null} - - ); - }); -} - export function SnapShotAccessibilityData({ details, className, @@ -84,7 +30,7 @@ export function SnapShotAccessibilityData({ details.format === "json" ? ( - + ) : ( diff --git a/apps/web/src/components/chat/TraitsPicker.test.ts b/apps/web/src/components/chat/TraitsPicker.test.ts index 885a1bd9d83d..ab688bf5a043 100644 --- a/apps/web/src/components/chat/TraitsPicker.test.ts +++ b/apps/web/src/components/chat/TraitsPicker.test.ts @@ -69,32 +69,108 @@ describe("buildTraitsTriggerDisplay", () => { it("omits fast mode from the label entirely when it is off", () => { expect(display([EFFORT, fastModeDescriptor(false), CONTEXT_WINDOW])).toEqual({ label: "High · 1M", - speedIcon: null, }); }); - it("shows the bolt instead of a text label when fast mode is on", () => { + it("pairs fast mode with reasoning before the context window", () => { expect(display([EFFORT, fastModeDescriptor(true), CONTEXT_WINDOW])).toEqual({ - label: "High · 1M", - speedIcon: "fast", + label: "High Fast · 1M", + }); + expect(display([EFFORT, CONTEXT_WINDOW, fastModeDescriptor(true)])).toEqual({ + label: "High Fast · 1M", }); }); - it("treats Codex standard and fast service tiers as fast mode states", () => { - expect(display([EFFORT, serviceTierDescriptor("default")])).toEqual({ - label: "High", - speedIcon: null, - }); - expect(display([EFFORT, serviceTierDescriptor("priority")])).toEqual({ - label: "High", - speedIcon: "fast", + it.each(["reasoningEffort", "reasoning", "effort", "variant", "thinking"])( + "pairs Fast with %s even when context is the primary select", + (id) => { + for (const speed of [fastModeDescriptor(true), serviceTierDescriptor("priority")]) { + expect( + buildTraitsTriggerDisplay({ + provider: CODEX, + descriptors: [ + selectDescriptor("profile", [{ id: "balanced", label: "Balanced" }], "balanced"), + CONTEXT_WINDOW, + { ...EFFORT, id }, + speed, + ], + primarySelectDescriptorId: "profile", + ultrathinkPromptControlled: false, + }), + ).toEqual({ label: "Balanced · 1M · High Fast" }); + } + expect( + buildTraitsTriggerDisplay({ + provider: CODEX, + descriptors: [CONTEXT_WINDOW, { ...EFFORT, id }, serviceTierDescriptor("ultrafast")], + primarySelectDescriptorId: CONTEXT_WINDOW.id, + ultrathinkPromptControlled: false, + }), + ).toEqual({ label: "1M · High Ultrafast" }); + }, + ); + + it("pairs Cursor Fast with reasoning rather than the thinking toggle", () => { + expect( + buildTraitsTriggerDisplay({ + provider: ProviderDriverKind.make("cursor"), + descriptors: [ + { ...EFFORT, id: "reasoning" }, + fastModeDescriptor(true), + { id: "thinking", label: "Thinking", type: "boolean", currentValue: true }, + ], + primarySelectDescriptorId: "reasoning", + ultrathinkPromptControlled: false, + }), + ).toEqual({ label: "High Fast · Thinking On" }); + }); + + it.each([true, false])("keeps boolean thinking %s independent of Fast", (currentValue) => { + const thinking = { + id: "thinking", + label: "Thinking", + type: "boolean" as const, + currentValue, + }; + const label = `Thinking ${currentValue ? "On" : "Off"}`; + expect(display([thinking, fastModeDescriptor(true)])).toEqual({ label: `${label} · Fast` }); + expect(display([thinking, { ...EFFORT, id: "reasoning" }, fastModeDescriptor(true)])).toEqual({ + label: `${label} · High Fast`, }); + expect(display([thinking, fastModeDescriptor(false)])).toEqual({ label }); + }); + + it("keeps speed separate when there is no reasoning descriptor", () => { + expect(display([CONTEXT_WINDOW, fastModeDescriptor(true)])).toEqual({ label: "1M · Fast" }); }); - it("uses a distinct double bolt for Codex Ultrafast", () => { + it.each(["Low", "Medium", "High", "Extra High", "Max", "Ultra"])( + "preserves %s reasoning with Fast across harnesses", + (label) => { + for (const provider of ["codex", "claudeAgent", "cursor"]) { + expect( + buildTraitsTriggerDisplay({ + provider: ProviderDriverKind.make(provider), + descriptors: [ + { ...EFFORT, options: [{ id: "effort", label }], currentValue: "effort" }, + fastModeDescriptor(true), + ], + primarySelectDescriptorId: EFFORT.id, + ultrathinkPromptControlled: false, + }), + ).toEqual({ label: `${label} Fast` }); + } + }, + ); + + it("treats Codex standard and fast service tiers as fast mode states", () => { + expect(display([EFFORT, serviceTierDescriptor("default")])).toEqual({ label: "High" }); + expect(display([EFFORT, serviceTierDescriptor("priority")])).toEqual({ label: "High Fast" }); + }); + + it("keeps Codex Ultrafast distinct from Fast", () => { expect(display([EFFORT, serviceTierDescriptor("ultrafast")])).toEqual({ - label: "High", - speedIcon: "ultrafast", + label: "High Ultrafast", }); }); @@ -105,17 +181,11 @@ describe("buildTraitsTriggerDisplay", () => { EFFORT, { ...descriptor, options: descriptor.options.filter(({ id }) => id !== "priority") }, ]), - ).toEqual({ - label: "High", - speedIcon: "ultrafast", - }); + ).toEqual({ label: "High Ultrafast" }); }); it("keeps other Codex service tiers in the label", () => { - expect(display([EFFORT, serviceTierDescriptor("flex")])).toEqual({ - label: "High · Flex", - speedIcon: null, - }); + expect(display([EFFORT, serviceTierDescriptor("flex")])).toEqual({ label: "High · Flex" }); }); it("keeps Standard as text for models without speed tiers", () => { @@ -124,32 +194,17 @@ describe("buildTraitsTriggerDisplay", () => { ...descriptor, options: descriptor.options.filter(({ id }) => id === "default" || id === "flex"), }; - expect(display([EFFORT, nonSpeedDescriptor])).toEqual({ - label: "High · Standard", - speedIcon: null, - }); - expect(display([nonSpeedDescriptor])).toEqual({ - label: "Standard", - speedIcon: null, - }); + expect(display([EFFORT, nonSpeedDescriptor])).toEqual({ label: "High · Standard" }); + expect(display([nonSpeedDescriptor])).toEqual({ label: "Standard" }); }); it("keeps the Codex service tier readable when it is the only trait", () => { - expect(display([serviceTierDescriptor("default")])).toEqual({ - label: "Standard", - speedIcon: null, - }); - expect(display([serviceTierDescriptor("priority")])).toEqual({ - label: "Fast", - speedIcon: null, - }); + expect(display([serviceTierDescriptor("default")])).toEqual({ label: "Standard" }); + expect(display([serviceTierDescriptor("priority")])).toEqual({ label: "Fast" }); }); it("keeps Ultrafast readable when it is the only trait", () => { - expect(display([serviceTierDescriptor("ultrafast")])).toEqual({ - label: "Ultrafast", - speedIcon: null, - }); + expect(display([serviceTierDescriptor("ultrafast")])).toEqual({ label: "Ultrafast" }); }); it("keeps non-fastMode booleans as text labels", () => { @@ -159,21 +214,16 @@ describe("buildTraitsTriggerDisplay", () => { type: "boolean", currentValue: true, }; - expect(display([EFFORT, thinking])).toEqual({ - label: "High · Thinking On", - speedIcon: null, - }); + expect(display([EFFORT, thinking])).toEqual({ label: "High · Thinking On" }); }); it("falls back to a text label when fast mode is the only trait", () => { - expect(display([fastModeDescriptor(true)])).toEqual({ - label: "Fast", - speedIcon: null, - }); - expect(display([fastModeDescriptor(false)])).toEqual({ - label: "Normal", - speedIcon: null, - }); + expect(display([fastModeDescriptor(true)])).toEqual({ label: "Fast" }); + expect(display([fastModeDescriptor(false)])).toEqual({ label: "Normal" }); + }); + + it("does not add Fast to a model without a speed option", () => { + expect(display([EFFORT, CONTEXT_WINDOW])).toEqual({ label: "High · 1M" }); }); it("stays blank when descriptors resolve to no label and there is no fast mode", () => { @@ -189,10 +239,10 @@ describe("buildTraitsTriggerDisplay", () => { { id: "high", label: "High" }, ], }; - expect(display([unresolved])).toEqual({ label: "", speedIcon: null }); + expect(display([unresolved])).toEqual({ label: "" }); }); - it("still renders the prompt-controlled ultrathink label alongside the bolt", () => { + it("still renders prompt-controlled ultrathink with Fast", () => { expect( buildTraitsTriggerDisplay({ provider: CODEX, @@ -200,7 +250,7 @@ describe("buildTraitsTriggerDisplay", () => { primarySelectDescriptorId: "reasoningEffort", ultrathinkPromptControlled: true, }), - ).toEqual({ label: "Ultrathink", speedIcon: "fast" }); + ).toEqual({ label: "Ultrathink Fast" }); }); }); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 2debe0ba0c70..ff42d096b37a 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -17,8 +17,7 @@ import { normalizeModelSlug, } from "@t3tools/shared/model"; import { memo, useCallback } from "react"; -import { BrainIcon, ZapIcon } from "lucide-react"; -import { UltrafastIcon } from "../Icons"; +import { BrainIcon } from "lucide-react"; import { Menu, MenuGroup, @@ -485,10 +484,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ); }); -/** - * Fast mode uses one bolt; Codex Ultrafast uses two. Keep a text label when - * speed is the only trait so the trigger remains readable. - */ +/** Pair speed with reasoning while keeping other traits separated. */ export function buildTraitsTriggerDisplay(input: { provider: ProviderDriverKind; descriptors: ReadonlyArray; @@ -496,14 +492,15 @@ export function buildTraitsTriggerDisplay(input: { ultrathinkPromptControlled: boolean; modelSelection?: ModelSelection | null; reportedModelSelection?: ModelSelection | null | undefined; -}): { label: string; speedIcon: "fast" | "ultrafast" | null } { +}): { label: string } { let fastModeFallbackLabel: string | null = null; - let speedIcon: "fast" | "ultrafast" | null = null; + let speedLabel: string | null = null; + let reasoningLabelIndex = -1; const labels: Array = []; for (const descriptor of input.descriptors) { if (descriptor.id === "fastMode" && descriptor.type === "boolean") { - speedIcon = descriptor.currentValue === true ? "fast" : null; - fastModeFallbackLabel = speedIcon ? "Fast" : "Normal"; + speedLabel = descriptor.currentValue === true ? "Fast" : null; + fastModeFallbackLabel = speedLabel ?? "Normal"; continue; } if ( @@ -519,11 +516,11 @@ export function buildTraitsTriggerDisplay(input: { (fastTier && currentValue === fastTier.id) || (ultrafastTier && currentValue === ultrafastTier.id) ) { - speedIcon = + speedLabel = ultrafastTier && currentValue === ultrafastTier.id - ? "ultrafast" + ? "Ultrafast" : fastTier && currentValue === fastTier.id - ? "fast" + ? "Fast" : null; fastModeFallbackLabel = descriptor.options.find(({ id }) => id === currentValue)?.label ?? "Normal"; @@ -541,6 +538,14 @@ export function buildTraitsTriggerDisplay(input: { input.reportedModelSelection, ); if (typeof label === "string" && label.length > 0) { + // Custom models retain descriptor order, so the primary select can be context. + if ( + reasoningLabelIndex === -1 && + descriptor.type === "select" && + ["reasoningEffort", "reasoning", "effort", "variant", "thinking"].includes(descriptor.id) + ) { + reasoningLabelIndex = labels.length; + } labels.push(label); } } @@ -549,9 +554,16 @@ export function buildTraitsTriggerDisplay(input: { // off an empty label list alone would also catch descriptors that resolved to // no label at all, printing a bogus "Normal" for a model without fast mode. if (labels.length === 0 && fastModeFallbackLabel !== null) { - return { label: fastModeFallbackLabel, speedIcon: null }; + return { label: fastModeFallbackLabel }; + } + if (speedLabel) { + if (reasoningLabelIndex >= 0) { + labels[reasoningLabelIndex] = `${labels[reasoningLabelIndex]} ${speedLabel}`; + } else { + labels.push(speedLabel); + } } - return { label: labels.join(" · "), speedIcon }; + return { label: labels.join(" · ") }; } export const TraitsPicker = memo(function TraitsPicker({ @@ -601,7 +613,7 @@ export const TraitsPicker = memo(function TraitsPicker({ return null; } - const { label: triggerLabel, speedIcon } = buildTraitsTriggerDisplay({ + const { label: triggerLabel } = buildTraitsTriggerDisplay({ provider, descriptors, primarySelectDescriptorId: primarySelectDescriptor?.id ?? null, @@ -609,26 +621,6 @@ export const TraitsPicker = memo(function TraitsPicker({ modelSelection: instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null, reportedModelSelection, }); - const speedLabel = speedIcon === "ultrafast" ? "Ultrafast mode on" : "Fast mode on"; - const accessibleLabel = speedIcon ? `${triggerLabel}, ${speedLabel}` : triggerLabel; - const fastModeIcon = speedIcon ? ( - <> - - {speedLabel} - - ) : null; - const isCodexStyle = provider === "codex"; return ( @@ -644,7 +636,7 @@ export const TraitsPicker = memo(function TraitsPicker({ - {fastModeIcon ?? ( - - - - )} + {triggerLabel} @@ -682,20 +667,13 @@ export const TraitsPicker = memo(function TraitsPicker({ ) : ( <> - {fastModeIcon ?? ( - - - - )} + {triggerLabel} )} - {accessibleLabel} + {triggerLabel} - {call.command ?
{call.command}
: null} + {call.command ? : null} {call.args ? (
{call.args.map(([key, value]) => ( diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 1f4de90f55b8..29bfa3702d9d 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -91,6 +91,9 @@ function PullRequestRowImpl({ onSelect, speedMode, onActed, + closing = false, + sweeping = false, + onCloseSweepStart, }: { entry: EnvironmentPullRequestEntry; selected: boolean; @@ -110,12 +113,16 @@ function PullRequestRowImpl({ onSelect: (entry: PullRequestRowTarget) => void; speedMode: boolean; onActed: (result: PullRequestSpeedActionResult) => void; + closing?: boolean; + sweeping?: boolean; + onCloseSweepStart?: (entry: EnvironmentPullRequestEntry, event: PointerEvent) => void; }) { const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); return (
{entry.state !== "merged" && entry.provider === "github" ? ( - + ) : null}
); diff --git a/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx index 613928733d37..ee1bc5dcc02c 100644 --- a/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx @@ -1,8 +1,4 @@ import type { PullRequestAction } from "@t3tools/contracts"; -import { Effect } from "effect"; -import { AtomRegistry } from "effect/unstable/reactivity"; -import { appAtomRegistry } from "~/rpc/atomRegistry"; -import { pullRequestEnvironment, pullRequestStackAtom } from "~/state/pullRequests"; import { useUiStateStore } from "~/uiStateStore"; import { Button } from "../ui/button"; import { Spinner } from "../ui/spinner"; @@ -25,10 +21,16 @@ export function PullRequestSpeedActions({ entry, visible, onActed, + closing = false, + sweeping = false, + onCloseSweepStart, }: { entry: EnvironmentPullRequestEntry; visible: boolean; onActed: (result: PullRequestSpeedActionResult) => void; + closing?: boolean; + sweeping?: boolean; + onCloseSweepStart?: (entry: EnvironmentPullRequestEntry, event: PointerEvent) => void; }) { const resolveProjectDefault = usePullRequestDefaultMergeMethodResolver( entry.environmentId, @@ -44,32 +46,7 @@ export function PullRequestSpeedActions({ environmentId: entry.environmentId, reference, onSuccess: (action) => onActed({ entry, action }), - resolveMergeMethod: async () => { - const target = { environmentId: entry.environmentId, input: reference }; - const detailAtom = pullRequestEnvironment.detail({ - ...target, - input: { ...reference, allowStale: false }, - }); - appAtomRegistry.refresh(detailAtom); - const detail = await Effect.runPromise( - AtomRegistry.getResult(appAtomRegistry, detailAtom, { suspendOnWaiting: true }), - ); - if ( - detail.state !== "open" || - detail.isDraft || - !detail.capabilities.actions.includes("merge") || - !detail.viewerPermissions.actions.includes("merge") - ) { - throw new Error("This pull request cannot be merged."); - } - if (detail.capabilities.stackActions) { - const stackAtom = pullRequestStackAtom(target); - appAtomRegistry.refresh(stackAtom); - const stack = await Effect.runPromise( - AtomRegistry.getResult(appAtomRegistry, stackAtom, { suspendOnWaiting: true }), - ); - if (stack !== null) throw new Error("Open this pull request to merge its stack."); - } + resolveMergeMethod: (detail) => { const allowed = detail.capabilities.mergeMethods.filter( (method) => detail.mergeCapabilities[method], ); @@ -89,12 +66,14 @@ export function PullRequestSpeedActions({ : entry.isDraft ? (["close", "ready"] as const) : (["close", "merge"] as const); + const busy = actionPending || closing || sweeping; return (
{actions.map((action) => { const label = ACTIONS[action].label; @@ -106,19 +85,26 @@ export function PullRequestSpeedActions({ + {onLocalConfiguration ? ( + + ) : null}
diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx index 1a0f9276c5af..d112af1fed03 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx @@ -239,6 +239,74 @@ describe("AddProviderInstanceDialog environment routing", () => { ).not.toBeNull(); }); + it("creates a local command in the selected environment without a sign-in step", async () => { + const onOpenChange = vi.fn(); + let tree = render(onOpenChange); + const search = visitElements( + tree, + (element) => + typeof element.type === "function" && element.type.name === "AcpRegistrySearchStep", + ); + (search!.props.onLocalConfiguration as () => void)(); + tree = render(onOpenChange); + (findByChildren(tree, "Next").props.onClick as () => void)(); + tree = render(onOpenChange); + expect(findByChildren(tree, "Executable is required.")).not.toBeNull(); + expect(settingsHooks.mutate).not.toHaveBeenCalled(); + + const configuration = visitElements( + tree, + (element) => element.props.idPrefix === "add-provider-acpRegistry-manual", + ); + const commandArgs = ["--profile", "acp", " literal $(value) ; ", ""]; + (configuration!.props.onChange as (value: Record) => void)({ + source: "local", + commandPath: "dsh", + commandArgs, + }); + const environmentEditor = visitElements( + tree, + (element) => + typeof element.type === "function" && element.type.name === "ProviderEnvironmentSection", + ); + const environment = [{ name: "DSH_PROFILE", value: "work", sensitive: false }]; + (environmentEditor!.props.onChange as (value: typeof environment) => void)(environment); + tree = render(onOpenChange); + (findByChildren(tree, "Next").props.onClick as () => void)(); + tree = render(onOpenChange); + const label = visitElements(tree, (element) => element.props.id === "add-provider-label"); + (label!.props.onChange as (event: { target: { value: string } }) => void)({ + target: { value: "Deepseek Harness" }, + }); + tree = render(onOpenChange); + (findByChildren(tree, "Add instance").props.onClick as () => void)(); + await Promise.resolve(); + await Promise.resolve(); + + expect(settingsHooks.useMutation).toHaveBeenCalledWith(remoteEnvironmentId); + expect(settingsHooks.mutate).toHaveBeenCalledWith({ + operation: "create", + instanceId: "acpRegistry_deepseek_harness", + instance: { + driver: "acpRegistry", + enabled: true, + displayName: "Deepseek Harness", + config: { source: "local", commandPath: "dsh", commandArgs }, + environment, + }, + }); + expect(onOpenChange).toHaveBeenCalledWith(false); + tree = render(onOpenChange); + expect( + visitElements( + tree, + (element) => + typeof element.type === "function" && + element.type.name === "ProviderWizardAuthenticationStep", + ), + ).toBeNull(); + }); + it("keeps the dialog open when the atomic upsert fails", async () => { settingsHooks.mutate.mockResolvedValueOnce({ _tag: "Failure", cause: new Error("Conflict") }); const onOpenChange = vi.fn(); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts index 20d575c1cc58..aecb2c723d05 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts @@ -7,6 +7,7 @@ const ACP_REGISTRY_IDENTITY_STEP = 1; export const ADD_PROVIDER_WIZARD_STEPS = ["Provider", "Identity", "Config"] as const; export const ACP_REGISTRY_WIZARD_STEPS = ["Provider", "Identity", "Sign in"] as const; +export const LOCAL_ACP_WIZARD_STEPS = ["Provider", "Identity"] as const; export interface ProviderIdentityDraft { readonly label: string; @@ -65,7 +66,8 @@ export function isConfiguredAcpRegistryAgent( ) { return false; } - return (instance.config as Record).agentId === agentId; + const config = instance.config as Record; + return config.source !== "local" && config.agentId === agentId; }); } diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 1288d990b991..0d137c89dec3 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -13,6 +13,7 @@ import { ProviderDriverKind, type EnvironmentId, type ProviderInstanceConfig, + type ProviderInstanceEnvironmentVariable, } from "@t3tools/contracts"; import { @@ -39,6 +40,7 @@ import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wiza import { ADD_PROVIDER_WIZARD_STEPS, ACP_REGISTRY_WIZARD_STEPS, + LOCAL_ACP_WIZARD_STEPS, deriveAvailableInstanceId, resolveAcpRegistryWizardNavigation, resolveWizardNavigation, @@ -51,6 +53,7 @@ import { AcpRegistrySearchStep } from "./AcpRegistrySearchStep"; import { ProviderWizardAuthenticationStep } from "./ProviderWizardAuthenticationStep"; import { resolveOfficialAcpRegistryIconUrl } from "./AcpRegistryIcon"; import { AddManagedCodexAccountDialog } from "./CodexSetupSection"; +import { ProviderEnvironmentSection } from "./ProviderInstanceCard"; /** * Normalize a user-provided label into a slug suffix for the instance id. @@ -118,6 +121,9 @@ export function AddProviderInstanceDialog({ {}, ); const [selectedAcp, setSelectedAcp] = useState(null); + const [localEnvironment, setLocalEnvironment] = useState< + ReadonlyArray + >([]); const [isManualAcpConfiguration, setIsManualAcpConfiguration] = useState(false); const [isRegistryLoading, setIsRegistryLoading] = useState(false); const [isPreparingRegistryAgent, setIsPreparingRegistryAgent] = useState(false); @@ -170,13 +176,23 @@ export function AddProviderInstanceDialog({ const previewLabel = label.trim() || `${driverOption.label} Workspace`; const configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT; + const isLocalAcp = isAcpRegistry && isManualAcpConfiguration && configDraft.source === "local"; + const localCommandPath = + typeof configDraft.commandPath === "string" ? configDraft.commandPath.trim() : ""; const manualAgentId = typeof configDraft.agentId === "string" ? configDraft.agentId.trim() : ""; - const acpSelectionError = - selectedAcp !== null || (isManualAcpConfiguration && manualAgentId.length > 0) + const acpSelectionError = isLocalAcp + ? localCommandPath.length > 0 + ? null + : "Executable is required." + : selectedAcp !== null || (isManualAcpConfiguration && manualAgentId.length > 0) ? null : "Select an ACP or configure one manually."; const wizardStepSummaries = isAcpRegistry - ? ([selectedAcp?.name ?? (manualAgentId || null), previewLabel, null] as const) + ? ([ + isLocalAcp ? "Local ACP command" : (selectedAcp?.name ?? (manualAgentId || null)), + previewLabel, + null, + ] as const) : ([driverOption.label, previewLabel, null] as const); const setConfigDraft = (config: Record | undefined) => { setConfigByDriver((existing) => { @@ -201,7 +217,7 @@ export function AddProviderInstanceDialog({ if (navigation.kind === "blocked") { setHasAttemptedSubmit(true); } - if (isAcpRegistry && navigation.kind === "navigate" && navigation.step === 2) { + if (isAcpRegistry && !isLocalAcp && navigation.kind === "navigate" && navigation.step === 2) { void handleSave(); return; } @@ -211,10 +227,15 @@ export function AddProviderInstanceDialog({ const navigateToStep = (requestedStep: number) => { applyWizardNavigation( isAcpRegistry - ? resolveAcpRegistryWizardNavigation(wizardStep, requestedStep, { - instanceIdError, - selectionError: acpSelectionError, - }) + ? isLocalAcp + ? resolveWizardNavigation(wizardStep, requestedStep, LOCAL_ACP_WIZARD_STEPS.length, { + instanceIdError, + prerequisite: { step: 0, error: acpSelectionError }, + }) + : resolveAcpRegistryWizardNavigation(wizardStep, requestedStep, { + instanceIdError, + selectionError: acpSelectionError, + }) : resolveWizardNavigation(wizardStep, requestedStep, ADD_PROVIDER_WIZARD_STEPS.length, { instanceIdError, }), @@ -268,6 +289,24 @@ export function AddProviderInstanceDialog({ setHasAttemptedSubmit(false); }; + const handleLocalAcpConfiguration = () => { + setDriver(ACP_REGISTRY_DRIVER_KIND); + setSelectedAcp(null); + setIsManualAcpConfiguration(true); + setConfigByDriver((existing) => ({ + ...existing, + [ACP_REGISTRY_DRIVER_KIND]: { source: "local", commandArgs: [] }, + })); + setIdentityByDriver((existing) => + updateProviderIdentityDraft(existing, ACP_REGISTRY_DRIVER_KIND, { + label: "Local ACP", + instanceIdOverride: null, + }), + ); + setLocalEnvironment([]); + setHasAttemptedSubmit(false); + }; + const handleSave = async () => { if (isSaving || createdInstanceId) return; setHasAttemptedSubmit(true); @@ -286,6 +325,7 @@ export function AddProviderInstanceDialog({ ...(label.trim().length > 0 ? { displayName: label.trim() } : {}), ...(normalizedAccentColor ? { accentColor: normalizedAccentColor } : {}), ...(hasConfig ? { config } : {}), + ...(isLocalAcp && localEnvironment.length > 0 ? { environment: localEnvironment } : {}), }; // `ProviderInstanceId.make` revalidates the slug; we've already checked // it via `validateInstanceId`, but going through the brand constructor @@ -309,7 +349,7 @@ export function AddProviderInstanceDialog({ return; } onCreated?.(brandedId); - if (isAcpRegistry) { + if (isAcpRegistry && !isLocalAcp) { setCreatedInstanceId(brandedId); setIsSaving(false); setWizardStep(2); @@ -344,7 +384,7 @@ export function AddProviderInstanceDialog({ currentStep={wizardStep} summaries={wizardStepSummaries} instanceIdError={instanceIdError} - steps={ACP_REGISTRY_WIZARD_STEPS} + steps={isLocalAcp ? LOCAL_ACP_WIZARD_STEPS : ACP_REGISTRY_WIZARD_STEPS} disabled={isSaving || isPreparingRegistryAgent || createdInstanceId !== null} identityStep={1} prerequisite={{ step: 0, error: acpSelectionError }} @@ -434,9 +474,13 @@ export function AddProviderInstanceDialog({
-

Enter manually

+

+ {isLocalAcp ? "Local ACP command" : "Enter manually"} +

- Enter an official registry ID and any local executable or auth override. + {isLocalAcp + ? `Run an installed ACP executable on ${environmentLabel}.` + : "Enter an official registry ID and any local executable or auth override."}

) : ( )} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 415ebb97c029..9965925ad27e 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -296,7 +296,7 @@ function ProviderEnvironmentFieldRow(props: { ); } -function ProviderEnvironmentSection(props: { +export function ProviderEnvironmentSection(props: { readonly environment: ReadonlyArray; readonly onChange: (environment: ReadonlyArray) => void; }) { @@ -717,8 +717,16 @@ export function ProviderInstanceCard({ driverKind={driverKind ?? instance.driver} displayName={displayName} accentColor={accentColor} - acpRegistryAgentId={readConfigString(instance.config, "agentId") ?? undefined} - acpRegistryIconUrl={readConfigString(instance.config, "registryIconUrl") ?? undefined} + acpRegistryAgentId={ + readConfigString(instance.config, "source") === "local" + ? undefined + : (readConfigString(instance.config, "agentId") ?? undefined) + } + acpRegistryIconUrl={ + readConfigString(instance.config, "source") === "local" + ? undefined + : (readConfigString(instance.config, "registryIconUrl") ?? undefined) + } showBadge={Boolean(accentColor)} className="size-5" iconClassName="size-4 text-foreground/80" diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index 5737e47f2a13..6edeedb30740 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -57,12 +57,20 @@ describe("ProviderSettingsForm helpers", () => { expect(acpRegistry).toBeDefined(); expect(acpRegistry?.hasDefaultInstance).toBe(false); expect(deriveProviderSettingsFields(acpRegistry!).map((field) => field.key)).toEqual([ + "source", "agentId", "commandPath", "authMethodId", ]); }); + it("shows the local executable without registry identity or authentication fields", () => { + const acpRegistry = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("acpRegistry")]; + expect( + deriveProviderSettingsFields(acpRegistry!, { source: "local" }).map((field) => field.key), + ).toEqual(["source", "commandPath"]); + }); + it("derives a select control with its choices for the Antigravity sign-in method", () => { const antigravity = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("antigravity")]; expect(antigravity).toBeDefined(); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index 94372b97c868..dfb35facbd60 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import type { @@ -9,8 +9,10 @@ import type { ProviderSettingsFormOption, ProviderSettingsFormSchemaAnnotation, } from "@t3tools/contracts"; +import { PlusIcon, XIcon } from "lucide-react"; import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; @@ -76,7 +78,10 @@ function readFieldBooleanDefault( export function deriveProviderSettingsFields( definition: ProviderClientDefinition, + value?: unknown, ): ReadonlyArray { + const isLocalAcp = + definition.value === "acpRegistry" && readProviderConfigString(value, "source") === "local"; const schemaAnnotation = readProviderSettingsFormSchemaAnnotation(definition); const orderedKeys = new Map( (schemaAnnotation.order ?? []).map((key, index) => [key, index] as const), @@ -95,6 +100,7 @@ export function deriveProviderSettingsFields( const fieldSchema = definition.settingsSchema.fields[key]!; const formAnnotation = readProviderSettingsFormAnnotation(fieldSchema); if (formAnnotation.hidden) return []; + if (isLocalAcp && key !== "source" && key !== "commandPath") return []; const annotatedTitle = readFieldAnnotationString(fieldSchema, "title"); const annotatedDescription = readFieldAnnotationString(fieldSchema, "description"); @@ -102,11 +108,20 @@ export function deriveProviderSettingsFields( { key, control: formAnnotation.control ?? "text", - label: annotatedTitle ?? titleizeFieldKey(key), - ...(annotatedDescription !== undefined ? { description: annotatedDescription } : {}), - ...(formAnnotation.placeholder !== undefined - ? { placeholder: formAnnotation.placeholder } - : {}), + label: + isLocalAcp && key === "commandPath" + ? "Executable" + : (annotatedTitle ?? titleizeFieldKey(key)), + ...(isLocalAcp && key === "commandPath" + ? { description: "Executable name or path on this environment." } + : annotatedDescription !== undefined + ? { description: annotatedDescription } + : {}), + ...(isLocalAcp && key === "commandPath" + ? { placeholder: "e.g. dsh" } + : formAnnotation.placeholder !== undefined + ? { placeholder: formAnnotation.placeholder } + : {}), clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit", ...(formAnnotation.control === "switch" ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) } @@ -119,6 +134,112 @@ export function deriveProviderSettingsFields( }); } +let commandArgumentDraftId = 0; +const makeCommandArgumentDraftRow = (value: string) => ({ + id: `provider-argument-${commandArgumentDraftId++}`, + value, +}); + +function commandArgumentsEqual(left: ReadonlyArray, right: ReadonlyArray) { + return left.length === right.length && left.every((argument, index) => argument === right[index]); +} + +function ProviderCommandArguments({ + value, + onChange, +}: Pick) { + const args = useMemo(() => { + const configured = + value !== null && typeof value === "object" + ? (value as Record).commandArgs + : undefined; + return Array.isArray(configured) + ? configured.filter((argument): argument is string => typeof argument === "string") + : []; + }, [value]); + const [rows, setRows] = useState(() => args.map(makeCommandArgumentDraftRow)); + const rowsRef = useRef(rows); + const previousArgsRef = useRef(args); + const lastPublishedArgsRef = useRef | undefined>(undefined); + + useEffect(() => { + const previousArgs = previousArgsRef.current; + const lastPublishedArgs = lastPublishedArgsRef.current; + previousArgsRef.current = args; + lastPublishedArgsRef.current = undefined; + if ( + commandArgumentsEqual(previousArgs, args) || + (lastPublishedArgs !== undefined && commandArgumentsEqual(lastPublishedArgs, args)) + ) + return; + const nextRows = args.map(makeCommandArgumentDraftRow); + rowsRef.current = nextRows; + setRows(nextRows); + }, [args]); + + const updateArguments = (nextRows: typeof rows) => { + rowsRef.current = nextRows; + setRows(nextRows); + const next = nextRows.map((row) => row.value); + lastPublishedArgsRef.current = next; + const config = + value !== null && typeof value === "object" ? { ...(value as Record) } : {}; + onChange({ ...config, commandArgs: next }); + }; + + return ( + updateArguments([...rowsRef.current, makeCommandArgumentDraftRow("")])} + > + + Add argument + + } + > + {rows.length > 0 ? ( +
+ {rows.map((argument, index) => ( +
+ + updateArguments( + rowsRef.current.map((current) => + current.id === argument.id ? { ...current, value: next } : current, + ), + ) + } + aria-label={`Argument ${index + 1}`} + spellCheck={false} + /> + +
+ ))} +
+ ) : null} +
+ ); +} + function readProviderConfigString(config: unknown, key: string): string { if (config === null || typeof config !== "object") return ""; const value = (config as Record)[key]; @@ -411,7 +532,12 @@ export function ProviderSettingsForm({ variant, onChange, }: ProviderSettingsFormProps) { - const fields = useMemo(() => deriveProviderSettingsFields(definition), [definition]); + const fields = useMemo( + () => deriveProviderSettingsFields(definition, value), + [definition, value], + ); + const isLocalAcp = + definition.value === "acpRegistry" && readProviderConfigString(value, "source") === "local"; if (fields.length === 0) { return null; @@ -429,6 +555,7 @@ export function ProviderSettingsForm({ onChange={onChange} /> ))} + {isLocalAcp ? : null} ); } diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 41fd9efb5da4..cc8fe347c124 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -922,6 +922,7 @@ export function EnvironmentProviderSettings({ } if (row.driver !== ProviderDriverKind.make("acpRegistry")) return; + if (providerConfigString(row.instance.config, "source") === "local") return; const agentId = providerConfigString(row.instance.config, "agentId"); if (agentId === null) return; diff --git a/apps/web/src/lib/embeddedScripts.test.ts b/apps/web/src/lib/embeddedScripts.test.ts new file mode 100644 index 000000000000..d99263b6479a --- /dev/null +++ b/apps/web/src/lib/embeddedScripts.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { embeddedScripts } from "./embeddedScripts"; + +/** Each script's language, its text, and the part of the command that spells it. */ +function scriptsOf(command: string) { + return embeddedScripts(command).map((script) => { + // Every character maps inside the command, in order. + script.starts.forEach((start, index) => { + expect(start).toBeLessThan(script.ends[index]!); + if (index > 0) expect(start).toBeGreaterThanOrEqual(script.ends[index - 1]!); + }); + return [ + script.language, + script.text, + command.slice(script.starts[0], script.ends.at(-1)), + ] as const; + }); +} + +const pythonScript = `from pathlib import Path +import json + +# print every package.json name and version +for p in sorted(Path('.').glob('**/package.json')): + data = json.loads(p.read_text()) + print(f"{data['name']:16} {data.get('version', '?')}") +`; + +describe("embeddedScripts", () => { + it("finds the script inside a Codex login-shell wrapper and the script inside that", () => { + // Recorded in the turn_interrupt_mid_tool Codex transcript + // (apps/server/src/orchestration-v2/testkit/fixtures). + const inner = `console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)`; + const command = `/bin/bash -lc "node -e \\"${inner}\\""`; + expect(scriptsOf(command)).toEqual([ + ["shellscript", `node -e "${inner}"`, `node -e \\"${inner}\\"`], + ["javascript", inner, inner], + ]); + }); + + it("highlights a heredoc fed to an interpreter in that interpreter's language", () => { + const command = `python3 - <<'PY'\n${pythonScript}PY`; + expect(scriptsOf(command)).toEqual([["python", pythonScript, pythonScript]]); + }); + + it("maps escaped characters back to both source characters", () => { + const command = `/bin/zsh -lc "python3 - <<'PY'\nprint(\\"\\$HOME\\")\nPY"`; + const [shell, python] = scriptsOf(command); + expect(shell?.[1]).toBe(`python3 - <<'PY'\nprint("$HOME")\nPY`); + expect(python).toEqual(["python", `print("$HOME")\n`, `print(\\"\\$HOME\\")\n`]); + }); + + it.each([ + [`python3 -c 'print(1)'`, "python", "print(1)"], + [`python3 -X dev -c 'print(1)'`, "python", "print(1)"], + [`py -3 -c 'print(1)'`, "python", "print(1)"], + [`C:\\Python312\\python.exe -c 'print(1)'`, "python", "print(1)"], + [`"C:\\Program Files\\Python312\\python.exe" -c 'print(1)'`, "python", "print(1)"], + [`node --eval "process.exit(0)"`, "javascript", "process.exit(0)"], + [`bun -e 'console.log(Bun.version)'`, "typescript", "console.log(Bun.version)"], + [`deno eval 'console.log(1)'`, "typescript", "console.log(1)"], + [`deno eval --config deno.json 'console.log(1)'`, "typescript", "console.log(1)"], + [`deno eval --env-file 'console.log(1)'`, "typescript", "console.log(1)"], + [`psql -f schema.sql -c 'select 1'`, "sql", "select 1"], + [`node -C development -e 'console.log(1)'`, "javascript", "console.log(1)"], + [`ruby -e 'puts 1'`, "ruby", "puts 1"], + [`ruby -I lib -e 'puts 1'`, "ruby", "puts 1"], + [`perl -E 'say 1'`, "perl", "say 1"], + [`sqlite3 app.db "select * from users"`, "sql", "select * from users"], + [`psql -d app -c 'select 1'`, "sql", "select 1"], + [`pwsh -NoProfile -Command "Get-ChildItem"`, "powershell", "Get-ChildItem"], + [`env FOO=1 sudo -u bob python3 -c 'print(1)'`, "python", "print(1)"], + [`env -- FOO=1 python3 -c 'print(1)'`, "python", "print(1)"], + [`node --eval="process.exit(0)"`, "javascript", "process.exit(0)"], + [`psql --command='select 1'`, "sql", "select 1"], + [`mysql -c -e 'select 1'`, "sql", "select 1"], + [`mysql -f app <<'EOF'\nselect 1;\nEOF`, "sql", "select 1;\n"], + [`psql app <<'EOF'\nselect 1;\nEOF`, "sql", "select 1;\n"], + [`echo "$(node -e 'console.log(1)')"`, "javascript", "console.log(1)"], + [`python3 <<< 'print(1)'`, "python", "print(1)"], + ])("finds the inline script in %s", (command, language, script) => { + expect(scriptsOf(command)).toEqual([[language, script, expect.any(String)]]); + }); + + it.each([[`psql -c 'select 1' -c 'select 2'`], [`sqlite3 app.db "select 1" "select 2"`]])( + "finds every statement a SQL shell runs: %s", + (command) => { + expect(scriptsOf(command).map(([language, text]) => [language, text])).toEqual([ + ["sql", "select 1"], + ["sql", "select 2"], + ]); + }, + ); + + it.each([ + ["the file `cat` writes", `cat > src/app.ts <<'EOF'\nconst a = 1;\nEOF`, "typescript"], + ["the file `tee` writes", `tee -a config.json < { + expect(scriptsOf(command).map(([lang]) => lang)).toEqual([language]); + }); + + it.each([ + ["a script file", `python3 build.py <log.ts <<'EOF'\nx\nEOF`], + ])("finds nothing for %s", (_case, command) => { + expect(scriptsOf(command)).toEqual([]); + }); + + it("trusts nothing in a command whose quoting does not parse", () => { + // Codex's secret redaction dropped the backslash of an escaped quote, so + // the -lc argument ends early and the rest no longer balances. + const command = `/bin/zsh -lc "python3 -c 'token=[REDACTED_SECRET]"apiToken\\"]; print(token)'"`; + expect(scriptsOf(command)).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/embeddedScripts.ts b/apps/web/src/lib/embeddedScripts.ts new file mode 100644 index 000000000000..809481b6973f --- /dev/null +++ b/apps/web/src/lib/embeddedScripts.ts @@ -0,0 +1,544 @@ +import { getFiletypeFromFileName } from "@pierre/diffs"; +import { + parse, + type Command, + type DoubleQuotedChild, + type HereDoc, + type Word, + type WordPart, +} from "unbash"; + +/** + * Code in another language inside a shell command: a `bash -lc "…"` script, a + * `python3 -c '…'` argument, or a heredoc fed to an interpreter or written to + * a file. `starts[i]` and `ends[i]` are the span of the original command that + * spells `text[i]`, so a quoted `\"` maps back to both of its characters. + */ +export interface EmbeddedScript { + readonly language: string; + readonly text: string; + readonly starts: ReadonlyArray; + readonly ends: ReadonlyArray; +} + +interface MappedText { + readonly text: string; + readonly starts: ReadonlyArray; + readonly ends: ReadonlyArray; +} + +interface Interpreter { + readonly language: string; + /** Flags whose next argument is the script. */ + readonly inline: (flag: string) => boolean; + /** Options that consume the next argument. */ + readonly valueOptions?: ReadonlySet; + /** Options after which the script comes from a file or module instead. */ + readonly scriptFileOptions?: ReadonlySet; + /** "always" for SQL shells, which read statements from stdin next to their database argument. */ + readonly stdin: "always" | "without-script-file" | "never"; + /** The first positional argument holding a script, as in `sqlite3 app.db "select 1"`. */ + readonly positionalScript?: number; + /** SQL shells run every `-c` and every statement argument, in order. */ + readonly repeatable?: boolean; + readonly subcommand?: string; + /** PowerShell matches parameters case-insensitively; the lists above are lowercase. */ + readonly ignoreCase?: boolean; +} + +const MAX_DEPTH = 4; + +const flagIn = + (...flags: string[]) => + (flag: string) => + flags.includes(flag); + +const SHELL: Interpreter = { + language: "shellscript", + inline: (flag) => /^-[a-z]*c[a-z]*$/u.test(flag), + valueOptions: new Set(["-o", "-O", "--rcfile", "--init-file"]), + stdin: "without-script-file", +}; + +const PYTHON: Interpreter = { + language: "python", + inline: flagIn("-c"), + valueOptions: new Set(["-W", "-X"]), + scriptFileOptions: new Set(["-m"]), + stdin: "without-script-file", +}; + +// Shared by SQL clients; each names its own script flags, since `-c` and `-f` +// mean different things to psql and mysql. +const SQL_SHELL = { + language: "sql", + valueOptions: new Set([ + "-d", + "-h", + "-u", + "-U", + "--dbname", + "--host", + "--user", + "--username", + "-separator", + "-nullvalue", + "-init", + ]), + stdin: "always", + repeatable: true, +} satisfies Omit; + +const INTERPRETERS: Record = { + sh: SHELL, + bash: SHELL, + zsh: SHELL, + dash: SHELL, + ash: SHELL, + ksh: SHELL, + fish: { language: "fish", inline: flagIn("-c", "--command"), stdin: "without-script-file" }, + node: { + language: "javascript", + inline: flagIn("-e", "--eval", "-p", "--print"), + valueOptions: new Set(["-r", "--require", "--import", "--loader", "-C", "--conditions"]), + stdin: "without-script-file", + }, + bun: { language: "typescript", inline: flagIn("-e", "--eval", "-p", "--print"), stdin: "never" }, + deno: { + language: "typescript", + inline: () => false, + // `--env-file` takes its optional path only as `--env-file=path`. + valueOptions: new Set(["-c", "--config", "--import-map", "--ext"]), + stdin: "never", + subcommand: "eval", + }, + ruby: { + language: "ruby", + inline: flagIn("-e"), + valueOptions: new Set(["-r", "-I", "-C"]), + stdin: "without-script-file", + }, + perl: { language: "perl", inline: flagIn("-e", "-E"), stdin: "without-script-file" }, + osascript: { + language: "applescript", + inline: flagIn("-e"), + valueOptions: new Set(["-l", "-s"]), + stdin: "without-script-file", + }, + pwsh: { + language: "powershell", + inline: flagIn("-c", "-command"), + valueOptions: new Set(["-executionpolicy", "-workingdirectory", "-windowstyle"]), + scriptFileOptions: new Set(["-f", "-file", "-encodedcommand"]), + stdin: "never", + ignoreCase: true, + }, + psql: { + ...SQL_SHELL, + inline: flagIn("-c", "--command"), + scriptFileOptions: new Set(["-f", "--file"]), + }, + mysql: { ...SQL_SHELL, inline: flagIn("-e", "--execute") }, + sqlite3: { ...SQL_SHELL, inline: flagIn("-cmd"), positionalScript: 1 }, + duckdb: { + ...SQL_SHELL, + inline: flagIn("-c", "-cmd"), + scriptFileOptions: new Set(["-f"]), + positionalScript: 1, + }, +}; +INTERPRETERS.powershell = INTERPRETERS.pwsh!; +INTERPRETERS.mariadb = INTERPRETERS.mysql!; + +// Commands that run the next word as the program, with the options that consume a value. +const PROGRAM_WRAPPERS: Record> = { + env: new Set(["-u", "--unset", "-C", "--chdir"]), + sudo: new Set(["-u", "--user", "-g", "--group", "-C", "-D", "--chdir"]), + nice: new Set(["-n", "--adjustment"]), + nohup: new Set(), + exec: new Set(["-a"]), + command: new Set(), + builtin: new Set(), +}; + +// Heredoc delimiters that name their language, as in `<<'SQL'`. +const DELIMITER_LANGUAGES: Record = { + py: "python", + python: "python", + js: "javascript", + javascript: "javascript", + ts: "typescript", + typescript: "typescript", + sql: "sql", + json: "json", + yaml: "yaml", + yml: "yaml", + toml: "toml", + sh: "shellscript", + bash: "shellscript", + shell: "shellscript", + rb: "ruby", + ruby: "ruby", + html: "html", + css: "css", + md: "markdown", + markdown: "markdown", + xml: "xml", + diff: "diff", + patch: "diff", +}; + +/** + * The scripts embedded in a shell command, outermost first, so a highlighter + * painting them in order lets a nested script override the one containing it. + * Returns nothing for a command that does not parse cleanly; where a broken + * quote ends cannot be trusted. + */ +export function embeddedScripts(command: string): EmbeddedScript[] { + const identity = Array.from({ length: command.length }, (_, index) => index); + return scriptsIn( + { text: command, starts: identity, ends: identity.map((index) => index + 1) }, + 0, + ); +} + +function scriptsIn(source: MappedText, depth: number): EmbeddedScript[] { + const script = parse(source.text); + if (script.errors?.length) return []; + const found: EmbeddedScript[] = []; + forEachCommand(script.commands, (command) => { + for (const { language, mapped } of commandScripts(command, source.text)) { + if (mapped.text.trim() === "") continue; + const embedded: EmbeddedScript = { + language, + text: mapped.text, + starts: mapped.starts.map((start) => source.starts[start]!), + ends: mapped.ends.map((end) => source.ends[end - 1]!), + }; + found.push(embedded); + if (language === "shellscript" && depth + 1 < MAX_DEPTH) { + found.push(...scriptsIn(embedded, depth + 1)); + } + } + }); + return found; +} + +function isNode(value: unknown): value is { readonly type: string } { + return typeof value === "object" && value !== null && "type" in value; +} + +/** Visits every simple command, including those inside `$(…)`, loops and subshells. */ +function forEachCommand( + node: unknown, + visit: (command: Command) => void, + seen = new WeakSet(), +): void { + if (Array.isArray(node)) { + for (const child of node) forEachCommand(child, visit, seen); + return; + } + if (!isNode(node) || seen.has(node)) return; + seen.add(node); + switch (node.type) { + case "Command": + visit(node as Command); + break; + case "CommandExpansion": + case "ProcessSubstitution": { + const { script } = node as Extract; + // Escaped-backtick scripts own a decoded source their positions index. + if (script && !script.errors?.length && script.source === undefined) { + forEachCommand(script.commands, visit, seen); + } + return; + } + } + // Word parts are lazy getters, which Object.values skips. + if ("parts" in node) forEachCommand(node.parts, visit, seen); + for (const value of Object.values(node)) { + if (typeof value === "object") forEachCommand(value, visit, seen); + } +} + +function programName(word: Word): string { + // Bash reads `C:\Python312\python.exe` unquoted as escapes; Windows meant a path. + const path = /\\/u.test(word.text) ? word.text.replace(/^["']|["']$/gu, "") : word.value; + return (path.split(/[\\/]/u).at(-1) ?? "").toLowerCase().replace(/\.exe$/u, ""); +} + +function interpreterFor(name: string): Interpreter | undefined { + // `py` is the Windows Python launcher, as in `py -3 -c '…'`. + if (/^python(?:\d+(?:\.\d+)?)?$|^pypy3?$|^py$/u.test(name)) return PYTHON; + return Object.hasOwn(INTERPRETERS, name) ? INTERPRETERS[name] : undefined; +} + +/** Skips `env FOO=1`, `sudo -u bob` and similar to the word naming the real program. */ +function programIndex(words: ReadonlyArray): number { + let index = 0; + while (index < words.length) { + const name = programName(words[index]!); + if (!Object.hasOwn(PROGRAM_WRAPPERS, name)) return index; + const valueOptions = PROGRAM_WRAPPERS[name]!; + index += 1; + let optionsEnded = false; + while (index < words.length) { + const value = words[index]!.value; + const isOption = !optionsEnded && value.startsWith("-"); + if (isOption && value === "--") optionsEnded = true; + // `env -- FOO=1 cmd` still takes assignments after the options end. + if (isOption || /^[A-Za-z_]\w*=/u.test(value)) { + index += isOption && valueOptions.has(value) ? 2 : 1; + } else { + break; + } + } + } + return index; +} + +function commandScripts( + command: Command, + source: string, +): Array<{ language: string; mapped: MappedText }> { + if (!command.name) return []; + const words = [command.name, ...command.args.filter((arg): arg is Word => arg.type === "Word")]; + const index = programIndex(words); + const program = words[index]; + if (!program) return []; + const name = programName(program); + const interpreter = interpreterFor(name); + const args = words.slice(index + 1); + const scripts: Array<{ language: string; mapped: MappedText }> = []; + + let readsStdinScript = false; + if (interpreter) { + const found = interpreterArgs(interpreter, args); + for (const script of found.scripts) { + const mapped = wordText(script.word, source); + if (!mapped) continue; + const start = script.inValue ? mapped.text.indexOf("=") + 1 : 0; + scripts.push({ language: interpreter.language, mapped: sliceMapped(mapped, start) }); + } + readsStdinScript = + interpreter.stdin !== "never" && found.scripts.length === 0 && !found.scriptFromFile; + } + + const stdinLanguage = readsStdinScript ? interpreter?.language : undefined; + const writtenLanguage = writtenFileLanguage(name, command, args); + for (const redirect of command.redirects) { + const toStdin = isDescriptor(redirect.descriptor, 0); + if (redirect.type === "HereString" && toStdin && stdinLanguage && redirect.target) { + const mapped = wordText(redirect.target, source); + if (mapped) scripts.push({ language: stdinLanguage, mapped }); + } + if (redirect.type === "HereDoc") { + const language = + (toStdin ? (stdinLanguage ?? writtenLanguage) : undefined) ?? delimiterLanguage(redirect); + if (language) scripts.push({ language, mapped: hereDocText(redirect, source) }); + } + } + return scripts; +} + +/** Whether a redirection without a descriptor, or with `fd`, targets file descriptor `fd`. */ +function isDescriptor(descriptor: HereDoc["descriptor"], fd: number): boolean { + return ( + descriptor === undefined || (descriptor.type === "FileDescriptor" && descriptor.value === fd) + ); +} + +interface ScriptArgument { + readonly word: Word; + /** The script follows the `=` of `--eval=…` rather than filling the word. */ + readonly inValue: boolean; +} + +/** The script arguments, and whether the script comes from a file instead. */ +function interpreterArgs( + interpreter: Interpreter, + args: ReadonlyArray, +): { scripts: ScriptArgument[]; scriptFromFile: boolean } { + const positionals: Word[] = []; + const scripts: ScriptArgument[] = []; + const whole = (word: Word): ScriptArgument => ({ word, inValue: false }); + let optionsEnded = false; + let scriptFromFile = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]!; + const flag = interpreter.ignoreCase ? arg.value.toLowerCase() : arg.value; + if (!optionsEnded && flag === "--") { + optionsEnded = true; + continue; + } + if (!optionsEnded && flag.startsWith("-") && flag !== "-") { + // `--eval=code` and `--file=path` carry their value in the same word. + const [name = flag, value] = flag.startsWith("--") ? flag.split(/=(.*)/su) : [flag]; + if (interpreter.scriptFileOptions?.has(name)) { + if (!interpreter.repeatable) return { scripts, scriptFromFile: true }; + // SQL shells run `-f` files and `-c` statements alike, in order. + scriptFromFile = true; + if (value === undefined) index += 1; + continue; + } + if (interpreter.inline(name)) { + const next = args[index + 1]; + if (value !== undefined) scripts.push({ word: arg, inValue: true }); + else if (next) scripts.push(whole(next)); + if (!interpreter.repeatable) return { scripts, scriptFromFile: false }; + if (value === undefined) index += 1; + continue; + } + if (value === undefined && interpreter.valueOptions?.has(flag)) index += 1; + continue; + } + positionals.push(arg); + // An interpreter stops reading its own options at the script file. + if (interpreter.stdin === "without-script-file" && !interpreter.subcommand) break; + } + + if (interpreter.subcommand) { + if (positionals[0]?.value !== interpreter.subcommand) return { scripts, scriptFromFile: true }; + return { scripts: positionals.slice(1, 2).map(whole), scriptFromFile: false }; + } + if (interpreter.positionalScript !== undefined) { + scripts.push(...positionals.slice(interpreter.positionalScript).map(whole)); + } + // SQL shells take databases as positional arguments, not script files. + if (interpreter.stdin === "always") return { scripts, scriptFromFile }; + const first = positionals[0]; + return { scripts, scriptFromFile: first !== undefined && first.value !== "-" }; +} + +function sliceMapped(mapped: MappedText, start: number): MappedText { + if (start === 0) return mapped; + return { + text: mapped.text.slice(start), + starts: mapped.starts.slice(start), + ends: mapped.ends.slice(start), + }; +} + +/** The language of the file `cat > file` or `tee file` writes its stdin to. */ +function writtenFileLanguage( + name: string, + command: Command, + args: ReadonlyArray, +): string | undefined { + let path: string | undefined; + if (name === "cat") { + for (const redirect of command.redirects) { + if ( + redirect.type === "Redirect" && + [">", ">>", ">|", "&>", "&>>"].includes(redirect.operator) && + isDescriptor(redirect.descriptor, 1) + ) { + path = redirect.target?.value; + } + } + } else if (name === "tee") { + path = args.find((arg) => !arg.value.startsWith("-"))?.value; + } + if (!path) return undefined; + const language = getFiletypeFromFileName(path); + return language === "text" ? undefined : language; +} + +function delimiterLanguage(hereDoc: HereDoc): string | undefined { + const name = hereDoc.delimiter?.value.toLowerCase(); + return name && Object.hasOwn(DELIMITER_LANGUAGES, name) ? DELIMITER_LANGUAGES[name] : undefined; +} + +/** A heredoc body as written; the interpreter reads its characters one to one. */ +function hereDocText(hereDoc: HereDoc, source: string): MappedText { + const { pos, end } = hereDoc.body; + const starts = Array.from({ length: end - pos }, (_, index) => pos + index); + return { + text: source.slice(pos, end), + starts, + ends: starts.map((start) => start + 1), + }; +} + +/** + * A word's value with the source span of each character: quotes removed and + * escapes resolved, the way the program receives it. Expansions such as + * `$HOME` stay as written. Returns undefined for spellings it cannot map. + */ +function wordText(word: Word, source: string): MappedText | undefined { + const builder = new MappedTextBuilder(); + const parts: ReadonlyArray = word.parts ?? [ + { type: "Literal", pos: word.pos, end: word.end, text: word.text, value: word.value }, + ]; + for (const part of parts) { + switch (part.type) { + case "Literal": + if (!builder.addEscaped(part, false)) return undefined; + break; + case "SingleQuoted": + builder.addRaw(part.value, part.pos + 1); + break; + case "DoubleQuoted": + case "LocaleString": + for (const child of part.parts) { + if (!builder.addDoubleQuotedChild(child)) return undefined; + } + break; + case "AnsiCQuoted": + // $'…' escapes such as \x41 do not map character for character. + if (part.value !== part.text.slice(2, -1)) return undefined; + builder.addRaw(part.value, part.pos + 2); + break; + default: + builder.addRaw(source.slice(part.pos, part.end), part.pos); + } + } + return builder.build(); +} + +class MappedTextBuilder { + private text = ""; + private readonly starts: number[] = []; + private readonly ends: number[] = []; + + addRaw(text: string, pos: number) { + for (let index = 0; index < text.length; index += 1) this.push(text[index]!, pos + index, 1); + } + + addDoubleQuotedChild(child: DoubleQuotedChild): boolean { + if (child.type === "Literal") return this.addEscaped(child, true); + this.addRaw(child.text, child.pos); + return true; + } + + /** Adds a literal, resolving backslash escapes; false when the result disagrees with the parser. */ + addEscaped(literal: { pos: number; text: string; value: string }, doubleQuoted: boolean) { + const before = this.text.length; + const { text, pos } = literal; + for (let index = 0; index < text.length; index += 1) { + const next = text[index + 1]; + if ( + text[index] === "\\" && + next !== undefined && + (!doubleQuoted || '$`"\\\n'.includes(next)) + ) { + // A backslash-newline joins lines and spells nothing. + if (next !== "\n") this.push(next, pos + index, 2); + index += 1; + } else { + this.push(text[index]!, pos + index, 1); + } + } + return this.text.slice(before) === literal.value; + } + + build(): MappedText { + return { text: this.text, starts: this.starts, ends: this.ends }; + } + + private push(character: string, start: number, length: number) { + this.text += character; + this.starts.push(start); + this.ends.push(start + length); + } +} diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index a5bb26e691c0..4ff23880ade6 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -619,6 +619,45 @@ describe("resolveDefaultProviderModelSelection", () => { }); describe("provider icon metadata", () => { + it("clears stale registry branding when an instance switches to a local command", () => { + const instanceId = ProviderInstanceId.make("custom-acp"); + const driver = ProviderDriverKind.make("acpRegistry"); + const iconUrl = "https://cdn.agentclientprotocol.com/registry/icons/swe-agent.svg"; + const snapshots = deriveProviderInstanceEntries([ + { ...provider({ provider: driver, instanceId }), iconUrl }, + ]); + const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl }; + const brandedEntries = applyProviderInstanceSettings(snapshots, { + providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, + providers: {} as never, + }); + expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent"); + expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl); + + const [localEntry] = applyProviderInstanceSettings(brandedEntries, { + providerInstances: { + [instanceId]: { + driver, + enabled: false, + config: { ...registryConfig, source: "local", commandPath: "dsh" }, + }, + }, + providers: {} as never, + }); + expect(localEntry?.acpRegistryAgentId).toBeUndefined(); + expect(localEntry?.acpRegistryIconUrl).toBeUndefined(); + expect(localEntry?.enabled).toBe(false); + expect(localEntry?.snapshot).toBe(snapshots[0]?.snapshot); + + const [restoredEntry] = applyProviderInstanceSettings(localEntry ? [localEntry] : [], { + providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, + providers: {} as never, + }); + expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent"); + expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl); + expect(restoredEntry?.enabled).toBe(true); + }); + it("retains server-published registry icons without local settings", () => { const iconUrl = "https://cdn.agentclientprotocol.com/registry/icons/swe-agent.svg"; const [entry] = deriveProviderInstanceEntries([ diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index ae2ea5699bf5..60630c5f826b 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -210,6 +210,9 @@ export function applyProviderInstanceSettings( explicitInstance.config !== null && typeof explicitInstance.config === "object" ? (explicitInstance.config as Readonly>) : null; + if (config?.source === "local") { + return { ...entry, enabled, acpRegistryAgentId: undefined, acpRegistryIconUrl: undefined }; + } const agentId = config?.agentId; const iconUrl = config?.registryIconUrl; return { diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index b67f79234813..56862b1e9af8 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -2,6 +2,9 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import { Spinner } from "~/components/ui/spinner"; import { useShortcutModifierState } from "~/shortcutModifierState"; import type { PullRequestSpeedActionResult } from "~/components/pullRequest/PullRequestSpeedActions"; +import { usePullRequestCloseBatch } from "~/components/pullRequest/usePullRequestActions"; +import { SidebarPointerSensor } from "~/components/Sidebar.pointer"; +import { resolveSidebarSweepKeys } from "~/components/Sidebar.logic"; import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import type { EnvironmentId, @@ -793,6 +796,11 @@ function PullRequestsRouteView() { ], ); const baselineQuery = usePullRequestList(baselineTargets); + const baselineEmpty = + baselineQuery.data?.entries.length === 0 && + baselineQuery.data.errors.length === 0 && + !baselineQuery.isPending && + baselineQuery.error === null; const facetTargets = useMemo(() => { if (!filtersOpen) return NO_LIST_TARGETS; return environmentQueries.map(({ environmentId, projectIds }) => ({ @@ -961,12 +969,14 @@ function PullRequestsRouteView() { speedActionRef.current = ({ entry, action }) => { // Some hosts accept a merge before it completes. Let the next host read declare it merged. if (action !== "merge") overrideEntry(entry, action); - setDetailRefreshToken((token) => token + 1); - refreshListAndStats(undefined, entry.environmentId); }; const onSpeedAction = useCallback((result: PullRequestSpeedActionResult) => { speedActionRef.current(result); }, []); + const onBatchClosed = useCallback((entry: EnvironmentPullRequestEntry) => { + speedActionRef.current({ entry, action: "close" }); + }, []); + const { close: closeBatch, closingKeys } = usePullRequestCloseBatch(onBatchClosed); // A reload recreates the registry the queries live in, so with nothing held the page would // cold-start into skeletons even though almost every row is unchanged. The last answer for // this set of environments is kept across reloads and hydrated here as the carried rows: they @@ -1005,13 +1015,15 @@ function PullRequestsRouteView() { // stay — hydrated or previously answered — rather than being dropped for a feed that // merely settled first. const partitions = - partitionsWanted && authoredQuery.data !== null && reviewingQuery.data !== null - ? { authored: authoredQuery.data.entries, reviewing: reviewingQuery.data.entries } - : current !== null && - current.environmentKey === environmentKey && - current.scope === scopeKey - ? current.partitions - : undefined; + partitionsWanted && baselineEmpty + ? { authored: [], reviewing: [] } + : partitionsWanted && authoredQuery.data !== null && reviewingQuery.data !== null + ? { authored: authoredQuery.data.entries, reviewing: reviewingQuery.data.entries } + : current !== null && + current.environmentKey === environmentKey && + current.scope === scopeKey + ? current.partitions + : undefined; // A search's answer is the search's, not the workspace's, so only unsearched lists // persist. Written here where the held partitions are in reach, so a feed settling // ahead of them cannot overwrite a stored snapshot that already had both groups. @@ -1052,6 +1064,7 @@ function PullRequestsRouteView() { sentQuery, listQuery.data, listQuery.isPending, + baselineEmpty, partitionsWanted, authoredQuery.data, reviewingQuery.data, @@ -1343,6 +1356,11 @@ function PullRequestsRouteView() { */ const groups = useMemo(() => { if (search.involvement !== "all") return [{ key: "others" as const, label: "", entries }]; + // An empty whole-list answer also empties the priority groups. Their old snapshot must + // not restore the last merged rows after the partition reads are no longer mounted. + if (baselineEmpty) { + return groupPullRequestsByInvolvement(entries, viewers); + } // Until both partitions have answered, the snapshot's stand in — they are yesterday's // groups, but whole ones, where grouping the feed's first page locally loses every // authored row older than it. Once the live reads land they take over; with neither, @@ -1372,6 +1390,7 @@ function PullRequestsRouteView() { return partitionPullRequestsWithPriority(entries, authored, reviewing); }, [ hasLocalFilters, + baselineEmpty, localFilters, authoredQuery.data?.entries, entries, @@ -1573,6 +1592,97 @@ function PullRequestsRouteView() { ]); /** What is actually on screen once the reader's pending answers are on the rows. */ const shownCount = displayGroups.reduce((count, group) => count + group.entries.length, 0); + const [closeSweepKeys, setCloseSweepKeys] = useState>(() => new Set()); + const closeSensorRef = useRef(null); + const closeSweepRows = useMemo( + () => + new Map( + displayGroups.flatMap((group) => + group.entries.map( + (entry) => [pullRequestEntryKey(entry), { entry, groupKey: group.key }] as const, + ), + ), + ), + [displayGroups], + ); + const closeSweepRef = useRef({ displayGroups, closeSweepRows, closingKeys, closeBatch }); + closeSweepRef.current = { displayGroups, closeSweepRows, closingKeys, closeBatch }; + useEffect(() => () => closeSensorRef.current?.cancel(), [filterKey, search.q, sort]); + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || closeSensorRef.current === null) return; + event.preventDefault(); + closeSensorRef.current.cancel(); + }; + document.addEventListener("keydown", onKeyDown, true); + return () => document.removeEventListener("keydown", onKeyDown, true); + }, []); + const startCloseSweep = useCallback((entry: EnvironmentPullRequestEntry, event: PointerEvent) => { + closeSensorRef.current?.cancel(); + const originKey = pullRequestEntryKey(entry); + const group = closeSweepRef.current.displayGroups.find((candidate) => + candidate.entries.some((row) => pullRequestEntryKey(row) === originKey), + ); + if (!group || closeSweepRef.current.closingKeys.has(originKey)) return; + const orderedKeys = closeSweepRef.current.displayGroups.flatMap((candidate) => + candidate.entries.map(pullRequestEntryKey), + ); + const canClose = (key: string) => { + const row = closeSweepRef.current.closeSweepRows.get(key); + return ( + row?.groupKey === group.key && + row.entry.state === "open" && + row.entry.provider === "github" && + !closeSweepRef.current.closingKeys.has(key) && + !scrollRef.current?.querySelector( + `[data-pull-request-key="${CSS.escape(key)}"] [data-pull-request-action-pending="true"]`, + ) + ); + }; + let sweptKeys: string[] = []; + let targetKey: string | null = null; + const sweepTo = (key: string) => { + if (key === targetKey) return; + targetKey = key; + sweptKeys = resolveSidebarSweepKeys(orderedKeys, originKey, key, canClose); + setCloseSweepKeys(new Set(sweptKeys)); + }; + closeSensorRef.current = new SidebarPointerSensor({ + active: originKey, + event, + options: { + distance: 6, + onAttach: () => {}, + onFinish: () => { + closeSensorRef.current = null; + setCloseSweepKeys(new Set()); + }, + }, + onPending: () => {}, + onStart: () => sweepTo(originKey), + onMove: ({ y }) => { + const viewport = scrollRef.current; + if (!viewport) return; + const bounds = viewport.getBoundingClientRect(); + const visibleY = Math.min(Math.max(y, bounds.top), bounds.bottom - 1); + let key: string | null = null; + for (const row of viewport.querySelectorAll("[data-pull-request-key]")) { + if (key !== null && row.getBoundingClientRect().top > visibleY) break; + key = row.dataset.pullRequestKey ?? null; + } + if (key !== null) sweepTo(key); + }, + onEnd: () => { + const batch = sweptKeys.filter(canClose).flatMap((key) => { + const row = closeSweepRef.current.closeSweepRows.get(key); + return row ? [row.entry] : []; + }); + void closeSweepRef.current.closeBatch(batch); + }, + onCancel: () => {}, + onAbort: () => {}, + }); + }, []); const heldPullRequestsBySurface = useMemo( () => new Map( @@ -1801,7 +1911,7 @@ function PullRequestsRouteView() { onLoadMore={loadMore} /> ) : ( -
+
0 && "**:pointer-events-none")}> {displayGroups.map((group) => (
{group.label ? : null} @@ -1834,6 +1944,9 @@ function PullRequestsRouteView() { onSelect={selectEntry} speedMode={speedMode} onActed={onSpeedAction} + closing={closingKeys.has(entryKey)} + sweeping={closeSweepKeys.has(entryKey)} + onCloseSweepStart={startCloseSweep} /> ); })} diff --git a/docs/internals/mobile-development.md b/docs/internals/mobile-development.md index 9f0cb9d2e2fb..d1d375584bb0 100644 --- a/docs/internals/mobile-development.md +++ b/docs/internals/mobile-development.md @@ -17,12 +17,11 @@ shutdown, but a supervisor created after its cleanup runs would escape it. A clo parent scope also closes late arrivals, preventing interrupted startup or runtime replacement from leaving a WebSocket alive outside the new registry. -The [Uniwind patch](../../patches/uniwind@1.11.0.patch) still compiles CSS on Metro -updates so newly used classes are discovered. It skips global style invalidation -only when the generated stylesheet and theme list are unchanged. Skipping compilation -would lose new classes; invalidating every consumer for unchanged output makes an -ordinary component edit refresh the whole app. The fingerprint is recorded only -after initialization succeeds. +Uniwind compiles CSS on Metro updates so newly used classes are discovered. It +skips global style invalidation only when the generated stylesheet and theme list +are unchanged. Skipping compilation would lose new classes; invalidating every +consumer for unchanged output makes an ordinary component edit refresh the whole +app. The fingerprint is recorded only after initialization succeeds. The [expo-notifications patch](../../patches/expo-notifications@57.0.15.patch) protects `NotificationCenterManager`'s delegates and pending responses with a lock. React runtimes can diff --git a/docs/user/providers-acp.md b/docs/user/providers-acp.md index adf4e17af631..9f179e88da0f 100644 --- a/docs/user/providers-acp.md +++ b/docs/user/providers-acp.md @@ -1,6 +1,6 @@ -# ACP Registry +# ACP providers -T3 Code can run coding agents from the official +T3 Code can run installed ACP commands or coding agents from the official [ACP Registry](https://agentclientprotocol.com/get-started/registry). Registry agents bring their own models, tools, and sign-in, while T3 Code provides projects, threads, checkpoints, and task delegation. @@ -23,6 +23,17 @@ work through the same generic integration. Search only shows agents that can run on the connected server. Registry agents are third-party code; review an agent's source and license before adding it. +## Add a local command + +In **Settings → Providers → Add provider**, select **Local ACP command**. Enter the executable +name or path on the selected environment, add one literal argument per row, and choose a display +name. For DeepSeek Harness, use `dsh` with arguments `--profile` and `acp`. + +Local commands require no registry entry or download. Environment overrides apply to the command; +credentials and skills remain managed by the installed agent or its wrapper. Models and reasoning +options come from ACP, including model IDs that contain JSON. T3 Code launches the executable directly +without expanding shell expressions. + ## Where agents run Registry agents always run on the machine that hosts your T3 Code server. That stays true when you diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 64beb946c408..fb00b56677c3 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -113,6 +113,10 @@ Open **Pull requests** to review changes and comments, request reviewers, check or merge. You can edit review titles and descriptions and your own comments where the host allows it. GitLab calls these merge requests. +On web and desktop, hold **Shift** in the GitHub pull request list for quick actions. +To close several, press **Close**, drag across the rows in the same group, and release. +Press **Escape** before releasing to cancel. Failed closes stay in the list so you can retry them. + GitHub, GitLab, and Azure DevOps support auto-merge while checks are outstanding. GitHub also supports approving waiting fork workflows and opening a revert pull request for a merged change. @@ -186,7 +190,8 @@ the thread is active, the server checks the pull request every minute and wakes fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict. Comments from your own account do not wake it. Watching ends when the pull request merges or closes, after 10 wakes in a row that bring only comments, or when the server cannot read the pull request for -15 minutes. To start or stop it yourself, use the row menu in the **Linked pull requests** panel. +15 minutes. Settling a thread also ends all its watches. Unsettle the thread before starting a new +watch. To start or stop it yourself, use the row menu in the **Linked pull requests** panel. Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked out from the matching organization and repository. diff --git a/docs/user/thread-migration.md b/docs/user/thread-migration.md index f01488e1a56e..51f4b87ec9f0 100644 --- a/docs/user/thread-migration.md +++ b/docs/user/thread-migration.md @@ -10,7 +10,9 @@ and changes in either version do not sync to the other. Settings, attachments, a remain shared. The V2 desktop app uses a separate browser profile, so browser cookies and caches do not carry -over from V1. You may need to sign in again to websites opened inside the app. +over from V1. You may need to sign in again to websites opened inside the app. On its first launch, +the V2 desktop app copies stashed prompts, unsent drafts, layout, and theme from V1. Anything you +change in V2 afterwards stays in V2. The migrated thread keeps its title, project, provider and model selection, permission and interaction modes, branch or worktree, archive state, settlement state, snooze and pin state, and diff --git a/infra/relay/src/agentActivity/fcmPayloads.ts b/infra/relay/src/agentActivity/fcmPayloads.ts index 894bb8d55dbb..0f2c2e2e049e 100644 --- a/infra/relay/src/agentActivity/fcmPayloads.ts +++ b/infra/relay/src/agentActivity/fcmPayloads.ts @@ -5,12 +5,6 @@ import { } from "./agentActivityAggregate.ts"; import { agentActivityExpiresAt } from "./agentActivityPayloads.ts"; -export function androidActivityHero(aggregate: RelayAgentActivityAggregateState) { - return [...aggregate.activities].sort( - (a, b) => activityPhasePriority(a.phase) - activityPhasePriority(b.phase), - )[0]; -} - /** Android focuses the priority thread while retaining rows for older clients. */ export function androidActivityData(aggregate: RelayAgentActivityAggregateState | null) { const rows = [...(aggregate?.activities ?? [])].sort( diff --git a/packages/client-runtime/src/state/pullRequestRouting.ts b/packages/client-runtime/src/state/pullRequestRouting.ts index 643c6f8735ea..ce46fb314947 100644 --- a/packages/client-runtime/src/state/pullRequestRouting.ts +++ b/packages/client-runtime/src/state/pullRequestRouting.ts @@ -3,9 +3,13 @@ import { PullRequestRef, PullRequestInvalidateInput, type EnvironmentId, + type PullRequestRoutingResult, + type PullRequestRoutingIdentityResult, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -18,6 +22,7 @@ import { } from "../connection/githubRoutingPermissions.ts"; import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; import * as ConnectionProfileStore from "../connection/profileStore.ts"; +import type { RpcSession } from "../rpc/session.ts"; import { request, EnvironmentRpcUnavailableError, @@ -77,6 +82,44 @@ const isUnregistered = Schema.is(EnvironmentRegistry.EnvironmentNotRegisteredErr const encodeKey = Schema.encodeSync( Schema.fromJsonString(Schema.Array(Schema.NullOr(Schema.String))), ); +type PendingMetadata = WeakMap>>>; +const pendingRouting: PendingMetadata = new WeakMap(); +const pendingIdentities: PendingMetadata = new WeakMap(); + +/** Share only active read probes. Completed probes and mutations always read the current account. */ +function shareMetadataRead( + pending: PendingMetadata, + key: string, + probe: Effect.Effect, +): Effect.Effect { + return Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const session = yield* SubscriptionRef.get(supervisor.session); + if (Option.isNone(session)) return yield* restore(probe); + const entries = + pending.get(session.value) ?? new Map>>(); + pending.set(session.value, entries); + const existing = entries.get(key); + if (existing !== undefined) { + const result = yield* restore(Deferred.await(existing)); + // A cancelled first reader must not cancel the other readers waiting on its probe. + return yield* restore( + Exit.hasInterrupts(result) ? shareMetadataRead(pending, key, probe) : result, + ); + } + const result = Deferred.makeUnsafe>(); + entries.set(key, result); + return yield* restore(probe).pipe( + Effect.onExit((exit) => + Effect.sync(() => entries.delete(key)).pipe( + Effect.andThen(Deferred.succeed(result, exit)), + ), + ), + ); + }), + ); +} function isLocal(entry: ConnectionCatalogEntry): boolean { const url = @@ -276,12 +319,19 @@ export function createPullRequestRouter() { } if (alternatives.length === 0) return yield* finish(source); - const identity = yield* request(WS_METHODS.pullRequestsRouting, ref).pipe( + const identityProbe = request(WS_METHODS.pullRequestsRouting, ref).pipe( Effect.timeout("2 seconds"), Effect.catchCause((cause) => Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(null), ), ); + const identity = yield* reads.has(tag) + ? shareMetadataRead( + pendingRouting, + encodeKey([ref.projectId, ref.host?.toLowerCase() ?? null, ref.repository.toLowerCase()]), + identityProbe, + ) + : identityProbe; // Old servers and unknown accounts retain the existing path. if (identity === null || identity.provider !== "github") return yield* finish(source); @@ -338,8 +388,21 @@ export function createPullRequestRouter() { if (!(yield* routingAllowed(registry, origin.target.environmentId, id, writes.has(tag)))) return yield* visit(index + 1); // An older server would discard expectedAccountId. Verify it implements the guard first. + const alternateProbe = request(WS_METHODS.pullRequestsRoutingIdentity, { + host: identity.host, + }).pipe( + Effect.timeout("2 seconds"), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(null), + ), + ); const alternate = yield* registry - .run(id, request(WS_METHODS.pullRequestsRoutingIdentity, { host: identity.host })) + .run( + id, + reads.has(tag) + ? shareMetadataRead(pendingIdentities, identity.host.toLowerCase(), alternateProbe) + : alternateProbe, + ) .pipe( Effect.timeout("2 seconds"), Effect.catchCause((cause) => diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index c33623bbe1bf..8f3ee5b2bc59 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -236,6 +236,267 @@ for (const scenario of [ ); } +it.effect("shares active read identity probes while keeping later reads and mutations fresh", () => + Effect.scoped( + Effect.gen(function* () { + const probes: string[] = []; + const reads: number[] = []; + const mutations: string[] = []; + let accountId = "123"; + const clientFor = (local: boolean) => + ({ + [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: () => + Effect.yieldNow.pipe( + Effect.andThen( + Effect.sync(() => { + probes.push(local ? "local" : "origin"); + return { host: "github.com", provider: "github", viewer: "maria", accountId }; + }), + ), + ), + [WS_METHODS.pullRequestsSummary]: (input: { + number: number; + expectedAccountId: string; + }) => + Effect.sync(() => { + expect(local).toBe(true); + expect(input.expectedAccountId).toBe(accountId); + reads.push(input.number); + return null; + }), + [WS_METHODS.pullRequestsRunAction]: (input: { expectedAccountId: string }) => + Effect.sync(() => mutations.push(input.expectedAccountId)), + [WS_METHODS.pullRequestsInvalidate]: () => Effect.void, + }) as unknown as WsRpcProtocolClient; + const { environmentRegistry, supervisor } = yield* makeTestRuntime( + clientFor(false), + clientFor(true), + ); + const reference = { + projectId: ProjectId.make("project-1"), + repository: "acme/web", + number: 7, + }; + yield* Effect.gen(function* () { + yield* Effect.all( + Array.from({ length: 4 }, (_, index) => + createPullRequestRouter()(WS_METHODS.pullRequestsSummary, { + ...reference, + number: index + 1, + }), + ), + { concurrency: "unbounded" }, + ); + expect(probes).toEqual(["origin", "local"]); + expect(reads.toSorted()).toEqual([1, 2, 3, 4]); + accountId = "456"; + yield* createPullRequestRouter()(WS_METHODS.pullRequestsSummary, reference); + yield* createPullRequestRouter()(WS_METHODS.pullRequestsRunAction, { + ...reference, + action: "close", + }); + expect(probes).toEqual(["origin", "local", "origin", "local", "origin", "local"]); + expect(mutations).toEqual(["456"]); + }).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(GitHubRoutingPermissions, trustedRouting), + ); + }), + ), +); + +it.effect.each([ + { probe: "origin", cancelled: "first" }, + { probe: "origin", cancelled: "waiter" }, + { probe: "alternate", cancelled: "first" }, + { probe: "alternate", cancelled: "waiter" }, +] as const)( + "preserves a shared $probe probe's other reader when the $cancelled cancels", + ({ probe, cancelled }) => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let probes = 0; + const clientFor = (local: boolean) => + ({ + [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: () => + Effect.gen(function* () { + if (local === (probe === "alternate")) { + probes++; + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + } + return { + host: "github.com", + provider: "github", + viewer: "maria", + accountId: "123", + }; + }), + [WS_METHODS.pullRequestsSummary]: () => Effect.succeed(null), + }) as unknown as WsRpcProtocolClient; + const { environmentRegistry, supervisor } = yield* makeTestRuntime( + clientFor(false), + clientFor(true), + ); + const read = () => + createPullRequestRouter()(WS_METHODS.pullRequestsSummary, { + projectId: ProjectId.make("project-1"), + repository: "acme/web", + number: 7, + }).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(GitHubRoutingPermissions, trustedRouting), + ); + const first = yield* read().pipe(Effect.forkChild); + yield* Deferred.await(started); + const waiter = yield* read().pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Fiber.interrupt(cancelled === "first" ? first : waiter); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(cancelled === "first" ? waiter : first)).toBeNull(); + expect(probes).toBe(cancelled === "first" ? 2 : 1); + yield* read(); + expect(probes).toBe(cancelled === "first" ? 3 : 2); + }), + ), +); + +it.effect.each(["revoked", "disabled"] as const)( + "rechecks routing after an alternate with a shared probe is %s", + (change) => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const reads: string[] = []; + let trusted = true; + let probes = 0; + const clientFor = (local: boolean) => + ({ + [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: () => + Effect.gen(function* () { + if (local) { + probes++; + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + } + return { + host: "github.com", + provider: "github", + viewer: "maria", + accountId: "123", + }; + }), + [WS_METHODS.pullRequestsSummary]: () => + Effect.sync(() => { + reads.push(local ? "local" : "origin"); + return null; + }), + }) as unknown as WsRpcProtocolClient; + const { environmentRegistry, supervisor } = yield* makeTestRuntime( + clientFor(false), + clientFor(true), + ); + const readers = yield* Effect.all( + Array.from({ length: 2 }, () => + createPullRequestRouter()(WS_METHODS.pullRequestsSummary, { + projectId: ProjectId.make("project-1"), + repository: "acme/web", + number: 7, + }), + ), + { concurrency: "unbounded" }, + ).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(GitHubRoutingPermissions, { + ...trustedRouting, + get: () => Effect.sync(() => (trusted ? "read-write" : "off")), + }), + Effect.forkChild, + ); + yield* Deferred.await(started); + yield* Effect.yieldNow; + if (change === "revoked") trusted = false; + else + yield* SubscriptionRef.update( + environmentRegistry.entries, + (entries) => + new Map( + [...entries].map(([id, entry]) => [ + id, + id === TARGET.environmentId ? entry : { ...entry, enabled: false }, + ]), + ), + ); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(readers)).toEqual([null, null]); + expect(probes).toBe(1); + expect(reads).toEqual(["origin", "origin"]); + }), + ), +); + +it.effect.each(["origin", "alternate"] as const)( + "keeps pending metadata from an old %s connection separate from its replacement", + (reconnected) => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const probes: string[] = []; + const clientFor = (local: boolean, replacement = false) => + ({ + [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: () => + Effect.gen(function* () { + if (local === (reconnected === "alternate")) { + probes.push(replacement ? "new" : "old"); + if (!replacement) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + } + } + return { + host: "github.com", + provider: "github", + viewer: "maria", + accountId: "123", + }; + }), + [WS_METHODS.pullRequestsSummary]: () => Effect.succeed(null), + }) as unknown as WsRpcProtocolClient; + const { environmentRegistry, supervisor, localSupervisor } = yield* makeTestRuntime( + clientFor(false), + clientFor(true), + ); + const read = () => + createPullRequestRouter()(WS_METHODS.pullRequestsSummary, { + projectId: ProjectId.make("project-1"), + repository: "acme/web", + number: 7, + }).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(GitHubRoutingPermissions, trustedRouting), + ); + const first = yield* read().pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* SubscriptionRef.set( + (reconnected === "origin" ? supervisor : localSupervisor).session, + Option.some(session(clientFor(reconnected === "alternate", true))), + ); + expect(yield* read()).toBeNull(); + expect(probes).toEqual(["old", "new"]); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(first)).toBeNull(); + }), + ), +); + it.effect.each(["github", "gitlab", "bitbucket", "azure-devops"] as const)( "routes %s viewed marks to their storage environment", (provider) => @@ -417,7 +678,7 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* ( const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => Effect.sync(() => registry.dispose()), ); - return { runtime, atoms, registry, environmentRegistry, supervisor }; + return { runtime, atoms, registry, environmentRegistry, supervisor, localSupervisor }; }); it.effect.each(["default", "origin-off", "destination-off", "read-only"] as const)( @@ -891,6 +1152,159 @@ it.effect("keeps concurrent diff file reads on different hosts separate", () => ), ); +it.effect("queues merge preparation with actions without restarting it on refresh", () => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const refreshEvents = yield* PubSub.unbounded(); + const calls: string[] = []; + const client = { + [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.fromPubSub(refreshEvents), + [WS_METHODS.pullRequestsDetail]: (input: { number: number; allowStale?: boolean }) => + Effect.gen(function* () { + expect(input.allowStale).toBe(false); + calls.push(`detail:${input.number}`); + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return { + state: "open", + isDraft: false, + capabilities: { actions: ["merge"], stackActions: true }, + viewerPermissions: { actions: ["merge"] }, + }; + }), + [WS_METHODS.pullRequestsStack]: (input: { number: number; allowStale?: boolean }) => + Effect.sync(() => { + expect(input.allowStale).toBe(false); + calls.push(`stack:${input.number}`); + return null; + }), + [WS_METHODS.pullRequestsRunAction]: (input: { + action: string; + number: number; + mergeMethod?: string; + }) => + Effect.sync(() => { + if (input.action === "merge") expect(input.mergeMethod).toBe("squash"); + expect(input).not.toHaveProperty("resolveMergeMethod"); + calls.push(`${input.action}:${input.number}`); + }), + } as unknown as WsRpcProtocolClient; + const { atoms, registry } = yield* makeTestRuntime(client); + const unmount = registry.mount( + atoms.refreshes({ environmentId: TARGET.environmentId, input: {} }), + ); + yield* Effect.addFinalizer(() => Effect.sync(unmount)); + const reference = { projectId: ProjectId.make("project-1"), repository: "acme/web" }; + const first = atoms.runAction.run(registry, { + environmentId: TARGET.environmentId, + input: { ...reference, number: 1, action: "merge", resolveMergeMethod: () => "squash" }, + }); + yield* Deferred.await(started); + const second = atoms.runAction.run(registry, { + environmentId: TARGET.environmentId, + input: { ...reference, number: 2, action: "close" }, + }); + yield* PubSub.publish(refreshEvents, 1); + expect(calls).toEqual(["detail:1"]); + yield* Deferred.succeed(release, undefined); + const results = yield* Effect.promise(() => Promise.all([first, second])); + expect(results.every(AsyncResult.isSuccess)).toBe(true); + expect(calls).toEqual(["detail:1", "stack:1", "merge:1", "close:2"]); + }), + ), +); + +it.effect.each(["closed", "draft", "permission", "stack", "method"] as const)( + "rejects an unsafe quick merge with %s and keeps later actions running", + (reason) => + Effect.scoped( + Effect.gen(function* () { + const calls: string[] = []; + const client = { + [WS_METHODS.pullRequestsDetail]: () => + Effect.succeed({ + state: reason === "closed" ? "closed" : "open", + isDraft: reason === "draft", + capabilities: { actions: ["merge"], stackActions: true }, + viewerPermissions: { actions: reason === "permission" ? [] : ["merge"] }, + }), + [WS_METHODS.pullRequestsStack]: () => Effect.succeed(reason === "stack" ? {} : null), + [WS_METHODS.pullRequestsRunAction]: (input: { action: string }) => + Effect.sync(() => calls.push(input.action)), + } as unknown as WsRpcProtocolClient; + const { atoms, registry } = yield* makeTestRuntime(client); + const target = { + environmentId: TARGET.environmentId, + input: { projectId: ProjectId.make("project-1"), repository: "acme/web", number: 1 }, + }; + const merge = atoms.runAction.run(registry, { + ...target, + input: { + ...target.input, + action: "merge", + resolveMergeMethod: () => { + if (reason === "method") throw new Error("No merge method is available."); + return "squash"; + }, + }, + }); + const close = atoms.runAction.run(registry, { + ...target, + input: { ...target.input, action: "close" }, + }); + const results = yield* Effect.promise(() => Promise.all([merge, close])); + expect(results.map((result) => result._tag)).toEqual(["Failure", "Success"]); + expect(calls).toEqual(["close"]); + }), + ), +); + +it.effect("keeps a close batch ordered and continues after a refused close", () => + Effect.scoped( + Effect.gen(function* () { + const calls: number[] = []; + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const client = { + [WS_METHODS.pullRequestsRunAction]: (input: { number: number; action: string }) => + Effect.gen(function* () { + expect(input.action).toBe("close"); + calls.push(input.number); + if (input.number === 6) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + } + if (input.number === 5) + return yield* new PullRequestOperationError({ + operation: "runAction", + detail: "You cannot close this pull request.", + }); + }), + } as unknown as WsRpcProtocolClient; + const { atoms, registry } = yield* makeTestRuntime(client); + const batch = [6, 5, 4].map((number) => + atoms.runAction.run(registry, { + environmentId: TARGET.environmentId, + input: { + projectId: ProjectId.make("project-1"), + repository: "acme/web", + number, + action: "close", + }, + }), + ); + yield* Deferred.await(started); + expect(calls).toEqual([6]); + yield* Deferred.succeed(release, undefined); + const results = yield* Effect.promise(() => Promise.all(batch)); + expect(results.map((result) => result._tag)).toEqual(["Success", "Failure", "Success"]); + expect(calls).toEqual([6, 5, 4]); + }), + ), +); + it.effect("keeps hover previews fresh after edits and turns", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index ad6f4f9e1209..6031b05b6f9d 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -2,9 +2,12 @@ import { WS_METHODS, type EnvironmentId, type PullRequestActor, + type PullRequestActionInput, type PullRequestDetail, type PullRequestDiffInput, type PullRequestRef, + type PullRequestMergeMethod, + PullRequestOperationError, type PullRequestSummary, type VcsStatusResult, } from "@t3tools/contracts"; @@ -16,6 +19,7 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { createAtomCommandScheduler, + createEnvironmentCommand, createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily, createEnvironmentRpcSubscriptionAtomFamily, @@ -316,11 +320,58 @@ export function createPullRequestEnvironmentAtoms( JSON.stringify([environmentId, input.projectId, input.repository, input.number]), }, }), - runAction: createEnvironmentRpcCommand(runtime, { + runAction: createEnvironmentCommand(runtime, { label: "environment-data:pull-requests:run-action", - tag: WS_METHODS.pullRequestsRunAction, - execute: (input) => routedRequest(WS_METHODS.pullRequestsRunAction, input), - onSuccess: (target, registry) => Effect.sync(() => registry.refresh(preview(target))), + // Preparation belongs to the write's lane. Refreshable queries would restart it after + // every preceding action, and preparing outside the lane could reorder the clicks. + execute: ( + input: PullRequestActionInput & { + readonly resolveMergeMethod?: (detail: PullRequestDetail) => PullRequestMergeMethod; + }, + registry, + environmentId, + ) => + Effect.gen(function* () { + const { resolveMergeMethod, ...actionInput } = input; + let preparedInput = actionInput; + if (actionInput.action === "merge" && resolveMergeMethod !== undefined) { + const { projectId, host, repository, number } = actionInput; + const reference = { projectId, host, repository, number, allowStale: false }; + const detail = yield* routedRequest(WS_METHODS.pullRequestsDetail, reference); + if ( + detail.state !== "open" || + detail.isDraft || + !detail.capabilities.actions.includes("merge") || + !detail.viewerPermissions.actions.includes("merge") + ) { + return yield* new PullRequestOperationError({ + operation: "runAction", + detail: "This pull request cannot be merged.", + }); + } + if (detail.capabilities.stackActions) { + const stack = yield* routedRequest(WS_METHODS.pullRequestsStack, reference); + if (stack !== null) { + return yield* new PullRequestOperationError({ + operation: "runAction", + detail: "Open this pull request to merge its stack.", + }); + } + } + const mergeMethod = yield* Effect.try({ + try: () => resolveMergeMethod(detail), + catch: (cause) => + new PullRequestOperationError({ + operation: "runAction", + detail: + cause instanceof Error ? cause.message : "Could not choose a merge method.", + }), + }); + preparedInput = { ...actionInput, mergeMethod }; + } + yield* routedRequest(WS_METHODS.pullRequestsRunAction, preparedInput); + registry.refresh(preview({ environmentId, input: actionInput })); + }), scheduler: commandScheduler, concurrency: serialPerEnvironment, }), diff --git a/packages/client-runtime/src/work-log/commandLabel.test.ts b/packages/client-runtime/src/work-log/commandLabel.test.ts index e048ef35d7c1..2ba38fd37a57 100644 --- a/packages/client-runtime/src/work-log/commandLabel.test.ts +++ b/packages/client-runtime/src/work-log/commandLabel.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { commandDisplayText, commandProgramName } from "./commandLabel.ts"; +import { + commandDisplayText, + commandHighlightLanguage, + commandProgramName, +} from "./commandLabel.ts"; describe("commandProgramName", () => { it.each([ @@ -493,3 +497,107 @@ describe("commandDisplayText", () => { expect(commandDisplayText(input)).toBe(expected); }); }); + +describe("commands recorded from each provider", () => { + // Inputs are copied from the recorded replay transcripts in + // apps/server/src/orchestration-v2/testkit/fixtures (turn_interrupt_mid_tool, + // todo_list, grok_background_bash and + // opencode_running_child_approval). In turn_interrupt_mid_tool every provider + // ran the same script, and only Codex wraps it in a login shell. + const interruptScript = `node -e "console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)"`; + it.each([ + ["Claude", interruptScript, interruptScript], + ["Cursor", interruptScript, interruptScript], + ["Pi", interruptScript, interruptScript], + [ + "Codex", + `/bin/bash -lc "node -e \\"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\\""`, + interruptScript, + ], + [ + "Codex", + "/bin/bash -lc 'cat package.json && cat tsconfig.json'", + "cat package.json && cat tsconfig.json", + ], + [ + "Grok", + "for i in 1 2 3; do sleep 8; echo tock $i; done", + "for i in 1 2 3; do sleep 8; echo tock $i; done", + ], + [ + "OpenCode", + "printf 'running child approval' > running-child.txt", + "printf 'running child approval' > running-child.txt", + ], + ])("%s shows the script it ran: %s", (_provider, input, script) => { + expect(commandDisplayText(input)).toBe(script); + expect(commandHighlightLanguage(commandDisplayText(input))).toBe("shellscript"); + }); +}); + +describe("commandDisplayText with redacted Codex commands", () => { + // Codex replaces secrets in command text before sending it + // (codex-rs/secrets/src/sanitizer.rs). Usually the quoting survives. + it("unwraps a redacted command whose quoting is intact", () => { + expect( + commandDisplayText( + `/bin/zsh -lc 'curl -H "Authorization: Bearer [REDACTED_SECRET]" https://example.com'`, + ), + ).toBe('curl -H "Authorization: Bearer [REDACTED_SECRET]" https://example.com'); + }); + + // Its secret-assignment pattern can also swallow the backslash of an escaped + // quote: `token=config[\"apiToken\"]` becomes `token=[REDACTED_SECRET]"apiToken\"]`. + // The bare quote ends the -lc argument early, so where the script ends is unknown. + it("keeps the wrapper when redaction broke the quoting", () => { + const input = `/bin/zsh -lc "python3 -c 'token=[REDACTED_SECRET]"apiToken\\"]; print(token)'"`; + expect(commandDisplayText(input)).toBe(input); + }); +}); + +describe("commandDisplayText control characters", () => { + it.each([ + ["printf '\u001b[31mred\u001b[0m'", "printf '␛[31mred␛[0m'"], + ["zsh -lc 'printf \"\u001b[1m\"'", 'printf "␛[1m"'], + ["echo bell\u0007 delete\u007f", "echo bell␇ delete␡"], + ["printf 'a\\tb'\r\necho done", "printf 'a\\tb'\r\necho done"], + ["cat <<'EOF'\n\tindented\nEOF", "cat <<'EOF'\n\tindented\nEOF"], + ])("shows %j as %j", (input, expected) => { + expect(commandDisplayText(input)).toBe(expected); + }); +}); + +describe("commandHighlightLanguage", () => { + it.each([ + [ + '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoProfile -Command "Get-ChildItem -Recurse"', + "powershell", + ], + [ + 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\\work\\scripts\\doctor.ps1"', + "powershell", + ], + ["& pwsh -c 'Get-Date'", "powershell"], + ["PWSH.EXE -Command Get-Date", "powershell"], + ["pwsh-preview -c 'Get-Date'", "shellscript"], + ["git status; pwsh -c 'Get-Date'", "shellscript"], + ["cat <<'EOF' > notes.txt\npwsh\nEOF", "shellscript"], + ["", "shellscript"], + // Windows agents also run PowerShell directly, without a pwsh wrapper. + ["Get-Content package.json | Select-String version", "powershell"], + ["$env:CI='1'; npm test", "powershell"], + ["$tmp = Join-Path $env:TEMP repo; git clone example", "powershell"], + ['"=== CHECK FILE ==="; Get-Content file.txt', "powershell"], + ["& 'C:\\Python312\\python.exe' -c 'print(1)'", "powershell"], + ["Install-Module Pester -Scope CurrentUser", "powershell"], + ["ConvertTo-Json @{ a = 1 }", "powershell"], + ["update-alternatives --list java", "shellscript"], + ["install-info --version", "shellscript"], + ["Make-Thing now", "shellscript"], + ["echo Get-Content; git status", "shellscript"], + ["FOO=1 npm test", "shellscript"], + ["echo $HOME && ls", "shellscript"], + ])("%s", (command, language) => { + expect(commandHighlightLanguage(command)).toBe(language); + }); +}); diff --git a/packages/client-runtime/src/work-log/commandLabel.ts b/packages/client-runtime/src/work-log/commandLabel.ts index c3df952f477d..12557bdc8698 100644 --- a/packages/client-runtime/src/work-log/commandLabel.ts +++ b/packages/client-runtime/src/work-log/commandLabel.ts @@ -1368,8 +1368,31 @@ export function commandProgramName(command: string, depth = 0): string | null { return commandProgramNameInternal(command, depth, "shell", MAX_COMMAND_SEGMENTS); } -/** Removes a plain shell -c wrapper for display; callers retain the original for details. */ +// Escape bytes and other control characters render as invisible gaps; display +// shows them as Unicode control pictures (ESC becomes ␛) instead. Tab, newline +// and carriage return lay out as whitespace, so CRLF scripts stay unmarked. +// oxlint-disable-next-line no-control-regex +const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu; + +/** + * The command as rows show it: a plain shell -c wrapper removed and control + * characters made visible. Callers retain the original for details. + */ export function commandDisplayText(command: string): string { + return withVisibleControlCharacters(withoutShellWrapper(command)); +} + +/** Replaces each control character with its control picture; the length does not change. */ +export function withVisibleControlCharacters(text: string): string { + return text.replace(CONTROL_CHARACTERS, (character) => + character === "\u007f" ? "␡" : String.fromCharCode(0x2400 + character.charCodeAt(0)), + ); +} + +// A wrapper whose quoting does not balance is kept: the script's end cannot be +// known, which happens when Codex's secret redaction removes the backslash of +// an escaped quote inside `-lc "…"`. +function withoutShellWrapper(command: string): string { const trimmed = command.trim(); const split = splitFirstShellCommand(trimmed); if (split.remainingCommand !== null) return trimmed; @@ -1384,3 +1407,55 @@ export function commandDisplayText(command: string): string { if (scriptIndex === null || scriptIndex !== tokens.length - 1) return trimmed; return tokens[scriptIndex]?.trim() || trimmed; } + +const POWERSHELL_PROGRAMS = new Set(["powershell", "pwsh"]); + +// PowerShell's approved verbs (`Get-Verb`), plus the ForEach, Where, Sort and +// Tee of its built-in *-Object cmdlets. Cmdlets match only capitalized, as +// agents write them: lowercase `update-grub` or `install-info` are Linux tools. +const POWERSHELL_VERBS = new Set( + `Add Approve Assert Backup Block Build Checkpoint Clear Close Compare Complete Compress Confirm Connect Convert ConvertFrom ConvertTo Copy Debug Deny Deploy Disable Disconnect Dismount Edit Enable Enter Exit Expand Export Find ForEach Format Get Grant Group Hide Import Initialize Install Invoke Join Limit Lock Measure Merge Mount Move New Open Optimize Out Ping Pop Protect Publish Push Read Receive Redo Register Remove Rename Repair Request Reset Resize Resolve Restart Restore Resume Revoke Save Search Select Send Set Show Skip Sort Split Start Step Stop Submit Suspend Switch Sync Tee Test Trace Unblock Undo Uninstall Unlock Unprotect Unpublish Unregister Update Use Wait Watch Where Write`.split( + " ", + ), +); + +function isPowerShellCmdlet(word: string): boolean { + const cmdlet = /^([A-Z][a-z]+(?:[A-Z][a-z]+)?)-[A-Z][A-Za-z]*$/u.exec(word); + return cmdlet !== null && POWERSHELL_VERBS.has(cmdlet[1]!); +} + +/** + * The grammar to highlight a command with: PowerShell when pwsh or powershell + * runs it, or when it is written in PowerShell, as Windows agents run it. + */ +export function commandHighlightLanguage(command: string): "powershell" | "shellscript" { + const program = /^(?:&\s*)?(?:"([^"]*)"|'([^']*)'|(\S+))/u.exec(command.trim()); + const name = (program?.[1] ?? program?.[2] ?? program?.[3] ?? "") + .split(/[\\/]/u) + .at(-1) + ?.toLowerCase() + .replace(/\.exe$/u, ""); + if (name && POWERSHELL_PROGRAMS.has(name)) return "powershell"; + return isPowerShellScript(command) ? "powershell" : "shellscript"; +} + +/** Whether any statement starts like PowerShell and never like POSIX shell. */ +function isPowerShellScript(command: string): boolean { + let rest: string | null = command.trim(); + // A leading call operator; POSIX shell cannot start a command with `&`. + if (/^&\s*\S/u.test(rest)) return true; + for (let segment = 0; rest && segment < MAX_COMMAND_SEGMENTS; segment += 1) { + const { firstCommand, remainingCommand } = splitFirstShellCommand(rest); + const statement = firstCommand.trim(); + if ( + // `$env:NAME` or `$name = value`; neither parses as POSIX shell. + /^\$(?:env|global|local|script):/iu.test(statement) || + /^\$[A-Za-z_]\w*\s+=/u.test(statement) || + isPowerShellCmdlet(statement.match(/^\S+/u)?.[0] ?? "") + ) { + return true; + } + rest = remainingCommand; + } + return false; +} diff --git a/packages/contracts/src/applicationEvent.ts b/packages/contracts/src/applicationEvent.ts index 0c69aa59c1ed..6b712889c848 100644 --- a/packages/contracts/src/applicationEvent.ts +++ b/packages/contracts/src/applicationEvent.ts @@ -14,7 +14,7 @@ import { import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { ModelSelection } from "./modelSelection.ts"; import type { OrchestrationV2StoredEvent } from "./orchestrationV2.ts"; -import { ProjectIconOverride, ProjectScript } from "./project.ts"; +import { ProjectScript, StoredProjectIcon } from "./project.ts"; /** * Which client dispatched the command that produced this event (#7774). @@ -51,7 +51,7 @@ export const ApplicationProjectCreatedPayload = Schema.Struct({ defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), // Optional so persisted events from older servers still decode. faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), + projectIcon: Schema.optional(Schema.NullOr(StoredProjectIcon)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -68,7 +68,7 @@ export const ApplicationProjectMetaUpdatedPayload = Schema.Struct({ defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), autoPull: Schema.optional(Schema.Boolean), faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), + projectIcon: Schema.optional(Schema.NullOr(StoredProjectIcon)), scripts: Schema.optional(Schema.Array(ProjectScript)), updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/baseSchemas.test.ts b/packages/contracts/src/baseSchemas.test.ts new file mode 100644 index 000000000000..10ece5be6b4a --- /dev/null +++ b/packages/contracts/src/baseSchemas.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + ForwardCompatibleArray, + ForwardCompatibleUnion, + ForwardCompatibleUnionArray, + isUnknownUnionMember, + UnknownUnionMember, +} from "./baseSchemas.ts"; + +const at = "2026-10-05T00:00:00.000Z"; +const Shape = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("circle"), radius: Schema.Number, at: Schema.DateTimeUtc }), + Schema.Struct({ kind: Schema.Literal("square"), side: Schema.Number }), +]); +const members = Shape.members; +/** How clients decode: the JSON wire codec over the runtime schema. */ +const fromWire = (schema: S) => + Schema.decodeUnknownSync(Schema.toCodecJson(schema) as never) as (value: unknown) => S["Type"]; + +describe("ForwardCompatibleArray", () => { + it("decodes elements through their own codec and drops ones it cannot read", () => { + const decoded = fromWire(ForwardCompatibleArray(Shape))([ + { kind: "circle", radius: 1, at }, + { kind: "triangle" }, + { kind: "square", side: "wide" }, + ]); + expect(decoded).toHaveLength(1); + expect(DateTime.isDateTime((decoded[0] as { at: unknown }).at)).toBe(true); + }); + + it("applies decoding defaults instead of failing", () => { + const WithDefault = Schema.Struct({ + name: Schema.String, + count: Schema.optionalKey(Schema.Number).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(7)), + ), + }); + expect(Schema.decodeUnknownSync(ForwardCompatibleArray(WithDefault))([{ name: "a" }])).toEqual([ + { name: "a", count: 7 }, + ]); + }); +}); + +describe("ForwardCompatibleUnion", () => { + const decode = fromWire(ForwardCompatibleUnion(members, "kind")); + + it("decodes a member from a newer server as unknown", () => { + const decoded = decode({ kind: "triangle", corners: 3 }); + expect(isUnknownUnionMember(decoded)).toBe(true); + expect(decoded).toEqual(new UnknownUnionMember("kind", "triangle")); + }); + + it("decodes known members in full, transformations included", () => { + const decoded = decode({ kind: "circle", radius: 1, at }); + expect(isUnknownUnionMember(decoded)).toBe(false); + expect(DateTime.isDateTime((decoded as { at: unknown }).at)).toBe(true); + }); + + it("still fails a known member whose payload is broken", () => { + expect(() => decode({ kind: "square", side: "wide" })).toThrow(); + }); + + it("refuses to encode an unknown member", () => { + const encode = Schema.encodeSync(ForwardCompatibleUnion(members, "kind") as never); + expect(() => encode(new UnknownUnionMember("kind", "triangle") as never)).toThrow(); + }); +}); + +describe("ForwardCompatibleUnionArray", () => { + it("keeps a known member however its fields are named", () => { + const Flagged = Schema.Struct({ + kind: Schema.Literal("flag"), + _unknown: Schema.Literal(true), + tag: Schema.String, + value: Schema.String, + }); + const flag = { kind: "flag", _unknown: true, tag: "kind", value: "x" }; + expect(fromWire(ForwardCompatibleUnionArray([Flagged], "kind"))([flag])).toEqual([flag]); + }); + + const decode = fromWire(ForwardCompatibleUnionArray(members, "kind")); + + it("drops members a newer server added and keeps the rest", () => { + const decoded = decode([ + { kind: "triangle" }, + { kind: "circle", radius: 1, at }, + { kind: "square", side: 2 }, + ]); + expect(decoded.map((shape) => shape.kind)).toEqual(["circle", "square"]); + }); + + it("fails when a known member is broken, so real bugs stay visible", () => { + expect(() => decode([{ kind: "square", side: "wide" }])).toThrow(); + }); + + it("encodes as a plain array", () => { + const encode = Schema.encodeSync( + Schema.toCodecJson(ForwardCompatibleUnionArray(members, "kind")), + ); + const square = { kind: "square" as const, side: 2 }; + expect(encode([square])).toEqual([square]); + }); +}); diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index c81b81426b6a..19d7bf67cdfe 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -1,6 +1,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as SchemaGetter from "effect/SchemaGetter"; import * as SchemaTransformation from "effect/SchemaTransformation"; export const TrimmedString = Schema.String.pipe( @@ -35,14 +36,6 @@ export type DpopFailureReason = typeof DpopFailureReason.Type; export const IsoDateTime = Schema.String; export type IsoDateTime = typeof IsoDateTime.Type; -/** - * Wire codec for server→client arrays whose element unions grow over time - * (new literal members, new struct variants). Decoding drops elements the - * current build cannot decode instead of failing the whole payload — a client - * has to keep decoding configs sent by servers newer than itself, and - * rejecting the payload would take down the connection over data the client - * couldn't act on anyway. Encoding is the plain array encoding. - */ /** * Same idea for one optional value whose literal set grows over time: a * member this build does not know decodes as absent rather than failing the @@ -107,20 +100,172 @@ export const OmittedWhenNull = (value: Value) => { ); }; -export const ForwardCompatibleArray = (element: Element) => { - const decodeElement = Schema.decodeUnknownOption(element as never); - return Schema.Array(Schema.Unknown).pipe( +/** + * Wire codec for a server→client value whose shape grows over time: a union + * a newer server may add members to, or an array of such values. Clients keep + * decoding payloads from servers newer than themselves instead of failing the + * connection over data they could not act on anyway. + * + * Decoding runs each value through its own schema, so transformations (dates, + * trimming, decoding defaults) apply as usual. Only values that schema + * rejects are dropped; encoding is the plain encoding. + * + * For a tagged union, prefer {@link ForwardCompatibleUnion}: it drops only + * values whose tag this build does not know, so a known member with a broken + * payload still fails loudly. + */ +export const ForwardCompatibleArray = (element: Element) => + Schema.Array( + Schema.UndefinedOr(element).pipe( + // An element this build cannot read becomes a hole, filtered out below. + Schema.catchDecoding(() => Effect.succeedSome(undefined)), + ), + ).pipe( + Schema.decodeTo( + Schema.Array(Schema.toType(element)), + SchemaTransformation.transform< + ReadonlyArray, + ReadonlyArray + >({ + decode: (values) => values.filter((value) => value !== undefined), + encode: (values) => values, + }), + ), + ) as unknown as ForwardCompatibleArray; +export type ForwardCompatibleArray = Schema.Codec< + ReadonlyArray, + ReadonlyArray, + Element["DecodingServices"], + Element["EncodingServices"] +>; + +/** + * A member of a {@link ForwardCompatibleUnion} whose tag this build does not + * know. A class, so only the codec can make one: no decoded payload, however + * it is shaped, is ever mistaken for it. + */ +export class UnknownUnionMember { + readonly tag: Tag; + readonly value: string; + constructor(tag: Tag, value: string) { + this.tag = tag; + this.value = value; + } +} + +/** + * Wire codec for a server→client tagged union a newer server may add members + * to. A value whose `tag` field holds a value this build does not know decodes + * to {@link UnknownUnionMember} instead of failing; a known member decodes + * through its own schema, so a broken known payload still fails. Callers + * decide what an unknown member means: skip it, or show a fallback. + * + * Unknown members are decode-only; encoding one fails. + */ +export const ForwardCompatibleUnion = < + const Members extends ReadonlyArray, + const Tag extends string, +>( + members: Members, + tag: Tag, +) => { + const known = knownTags(members, tag); + const unknownMember = Schema.Struct({ + [tag]: Schema.String.check( + Schema.makeFilter( + (value: string) => !known.has(value) || `A known ${tag} must decode in full.`, + ), + ), + } as Record).pipe( + Schema.decodeTo(Schema.instanceOf(UnknownUnionMember), { + decode: SchemaGetter.transform( + (raw: Record) => new UnknownUnionMember(tag, raw[tag]!), + ), + encode: SchemaGetter.forbidden(() => `Unknown ${tag} values are never sent.`), + }), + ); + return Schema.Union([...members, unknownMember]) as unknown as ForwardCompatibleUnion< + Members, + Tag + >; +}; +export type ForwardCompatibleUnion< + Members extends ReadonlyArray, + Tag extends string, +> = Schema.Codec< + Members[number]["Type"] | UnknownUnionMember, + Members[number]["Encoded"], + Members[number]["DecodingServices"], + Members[number]["EncodingServices"] +>; + +/** + * Whether a raw, undecoded value carries a `tag` this build does not know + * among `members`. For checks outside a {@link ForwardCompatibleUnion}, such + * as an envelope that must skip a payload of an unknown kind. + */ +export const hasUnknownUnionTag = ( + members: ReadonlyArray, + tag: string, +): ((value: unknown) => boolean) => { + const known = knownTags(members, tag); + return (value) => + typeof value === "object" && + value !== null && + tag in value && + !known.has((value as Record)[tag] as string); +}; + +/** Whether a decoded {@link ForwardCompatibleUnion} value is a member this build does not know. */ +export const isUnknownUnionMember = ( + value: A, +): value is Extract> => value instanceof UnknownUnionMember; + +/** + * An array of a growing tagged union that drops members this build does not + * know. Known members decode through their own schema and still fail when + * broken. Encoding is the plain array encoding. + */ +export const ForwardCompatibleUnionArray = < + const Members extends ReadonlyArray, + const Tag extends string, +>( + members: Members, + tag: Tag, +) => + Schema.Array(ForwardCompatibleUnion(members, tag)).pipe( Schema.decodeTo( - Schema.Array(element), - SchemaTransformation.transform, ReadonlyArray>({ + Schema.Array(Schema.toType(Schema.Union(members))), + SchemaTransformation.transform< + ReadonlyArray, + ReadonlyArray> + >({ decode: (values) => - values.filter((value) => Option.isSome(decodeElement(value))) as ReadonlyArray< - Element["Encoded"] + values.filter((value) => !isUnknownUnionMember(value)) as ReadonlyArray< + Members[number]["Type"] >, encode: (values) => values, }), ), ); + +const knownTags = ( + members: ReadonlyArray, + tag: string, +): ReadonlySet => { + const tags = new Set(); + for (const member of members) { + const field = (member.fields as Record)[tag] as + | { readonly literal?: unknown; readonly literals?: ReadonlyArray } + | undefined; + for (const literal of field?.literals ?? [field?.literal]) { + if (typeof literal !== "string") { + throw new Error(`Every ForwardCompatibleUnion member needs a string literal ${tag}.`); + } + tags.add(literal); + } + } + return tags; }; /** diff --git a/packages/contracts/src/orchestrationProject.ts b/packages/contracts/src/orchestrationProject.ts index 7aadf2917e1a..4248558c93ad 100644 --- a/packages/contracts/src/orchestrationProject.ts +++ b/packages/contracts/src/orchestrationProject.ts @@ -3,7 +3,7 @@ import * as Schema from "effect/Schema"; import { IsoDateTime, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { ModelSelection } from "./modelSelection.ts"; -import { ProjectIconOverride, ProjectScript } from "./project.ts"; +import { ProjectScript, ReceivedProjectIcon } from "./project.ts"; /** Project summary shared by the V2 shell and application project APIs. */ export const OrchestrationProjectShell = Schema.Struct({ @@ -20,7 +20,7 @@ export const OrchestrationProjectShell = Schema.Struct({ autoPull: Schema.optional(Schema.Boolean), // Optional on the wire so cached snapshots from older servers still decode. faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), + projectIcon: Schema.optional(Schema.NullOr(ReceivedProjectIcon)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index aae9cc40bd8c..543386ca20e3 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -34,6 +34,7 @@ import { OrchestrationV2ShellSnapshot, OrchestrationV2SubscribeThreadInput, OrchestrationV2Subagent, + OrchestrationV2ThreadHistoryPage, OrchestrationV2ThreadProjection, OrchestrationV2ThreadStreamItem, OrchestrationV2ThreadShell, @@ -175,6 +176,134 @@ describe("orchestration V2 contracts", () => { ).toThrow(); }); + it("skips turn item types from a newer server in snapshots and turn-item events", () => { + const decodeWireItems = Schema.decodeUnknownSync( + Schema.toCodecJson(Schema.Array(OrchestrationV2RpcSchemas.subscribeThread.output)), + ); + const decodeHistoryPage = Schema.decodeUnknownSync( + Schema.toCodecJson(OrchestrationV2ThreadHistoryPage), + ); + const item = (id: string, type: string, extra: Record) => ({ + id, + type, + threadId: "thread-1", + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "completed", + title: null, + startedAt: null, + completedAt: null, + updatedAt: DateTime.formatIso(now), + ...extra, + }); + const known = item("item-known", "system_notice", { message: "Hello" }); + // A type no build of this client knows, standing in for a newer server's item. + const future = item("item-future", "secret_request", { secretRef: "ref-1" }); + const projected = (position: number, turnItem: { readonly id: string }) => ({ + position, + visibility: "local", + sourceThreadId: "thread-1", + sourceItemId: turnItem.id, + item: turnItem, + }); + const projection = { + thread: { + createdBy: "user", + creationSource: "web", + id: "thread-1", + projectId: "project-1", + title: "Thread", + providerInstanceId: "codex", + modelSelection: { instanceId: "codex", model: "gpt-5-codex" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: "thread-1" }, + forkedFrom: null, + createdAt: DateTime.formatIso(now), + updatedAt: DateTime.formatIso(now), + archivedAt: null, + deletedAt: null, + }, + runs: [], + attempts: [], + nodes: [], + subagents: [], + providerSessions: [], + providerThreads: [], + providerTurns: [], + runtimeRequests: [], + messages: [], + plans: [], + turnItems: [future, known], + checkpointScopes: [], + checkpoints: [], + contextHandoffs: [], + contextTransfers: [], + visibleTurnItems: [projected(0, future), projected(1, known)], + updatedAt: DateTime.formatIso(now), + }; + const turnItemEvent = (sequence: number, payload: unknown) => ({ + kind: "event", + sequence, + event: { + id: `event-${sequence}`, + type: "turn-item.updated", + threadId: "thread-1", + occurredAt: DateTime.formatIso(now), + payload, + }, + }); + + const [snapshot, futureEvent, knownEvent] = decodeWireItems([ + { kind: "snapshot", snapshotSequence: 1, projection }, + turnItemEvent(2, future), + turnItemEvent(3, known), + ]); + + expect(snapshot).toMatchObject({ kind: "snapshot" }); + if (snapshot?.kind !== "snapshot") throw new Error("expected a snapshot"); + expect(snapshot.projection.turnItems.map((turnItem) => turnItem.id)).toEqual(["item-known"]); + expect(snapshot.projection.visibleTurnItems.map((row) => row.item.id)).toEqual(["item-known"]); + expect(futureEvent).toEqual({ + kind: "unknown-event", + sequence: 2, + eventType: "turn-item.updated", + }); + expect(knownEvent).toMatchObject({ + kind: "event", + event: { type: "turn-item.updated", payload: { id: "item-known", type: "system_notice" } }, + }); + expect( + decodeHistoryPage({ + snapshotSequence: 1, + items: [projected(0, future), projected(1, known)], + nextCursor: null, + hasMoreHistory: false, + }).items.map((row) => row.item.id), + ).toEqual(["item-known"]); + + // A known turn item type with a broken payload is a real defect, not a newer item. + const broken = item("item-broken", "system_notice", {}); + expect(() => decodeWireItems([turnItemEvent(4, broken)])).toThrow(); + expect(() => + decodeWireItems([ + { + kind: "snapshot", + snapshotSequence: 1, + projection: { ...projection, turnItems: [broken] }, + }, + ]), + ).toThrow(); + }); + it("negotiates bounded socket snapshots as an optional capability", () => { expect( decodeOrchestrationV2SubscribeThreadInput({ diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 675faa4b7f4f..9498fdb00002 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SchemaAST from "effect/SchemaAST"; import * as SchemaGetter from "effect/SchemaGetter"; +import * as SchemaTransformation from "effect/SchemaTransformation"; import { CheckpointId, @@ -15,6 +16,10 @@ import { IsoDateTime, MessageId, NodeId, + ForwardCompatibleUnion, + ForwardCompatibleUnionArray, + hasUnknownUnionTag, + isUnknownUnionMember, NonNegativeInt, PlanId, PositiveInt, @@ -1494,6 +1499,36 @@ export const OrchestrationV2TurnItem = Schema.Union([ ]); export type OrchestrationV2TurnItem = typeof OrchestrationV2TurnItem.Type; +/** + * Turn item types grow over time, so clients decode them forward-compatibly: + * an item whose type this build does not know is dropped from snapshots and + * history instead of failing the thread. A known type that does not decode + * still fails. Arrays of projected rows filter on the row's nested item. + */ +const isUnknownTurnItem = hasUnknownUnionTag(OrchestrationV2TurnItem.members, "type"); + +const turnItemArray = >( + union: Schema.Union, +) => ForwardCompatibleUnionArray(union.members, "type"); + +/** Projected rows whose nested item may be of a type this build does not know. */ +const projectedTurnItemArray = ( + row: Row, + rowWithUnknownItem: Item, +) => + Schema.Array(rowWithUnknownItem).pipe( + Schema.decodeTo( + Schema.Array(Schema.toType(row)), + SchemaTransformation.transform, ReadonlyArray>({ + decode: (rows) => + rows.filter( + (projected) => !isUnknownUnionMember((projected as { readonly item: unknown }).item), + ) as ReadonlyArray, + encode: (rows) => rows, + }), + ), + ); + export const OrchestrationV2ProjectedTurnItem = Schema.Struct({ position: NonNegativeInt, visibility: Schema.Literals(["local", "inherited", "synthetic"]), @@ -1681,12 +1716,18 @@ export const OrchestrationV2ThreadProjection = Schema.Struct({ runtimeRequests: Schema.Array(OrchestrationV2RuntimeRequest), messages: Schema.Array(OrchestrationV2ConversationMessage), plans: Schema.Array(OrchestrationV2PlanArtifact), - turnItems: Schema.Array(OrchestrationV2TurnItem), + turnItems: turnItemArray(OrchestrationV2TurnItem), checkpointScopes: Schema.Array(OrchestrationV2CheckpointScope), checkpoints: Schema.Array(OrchestrationV2Checkpoint), contextHandoffs: Schema.Array(OrchestrationV2ContextHandoff), contextTransfers: Schema.Array(OrchestrationV2ContextTransfer), - visibleTurnItems: Schema.Array(OrchestrationV2ProjectedTurnItem), + visibleTurnItems: projectedTurnItemArray( + OrchestrationV2ProjectedTurnItem, + OrchestrationV2ProjectedTurnItem.mapFields((fields) => ({ + ...fields, + item: ForwardCompatibleUnion(OrchestrationV2TurnItem.members, "type"), + })), + ), updatedAt: Schema.DateTimeUtc, }); export type OrchestrationV2ThreadProjection = typeof OrchestrationV2ThreadProjection.Type; @@ -2249,12 +2290,18 @@ export const OrchestrationV2ThreadProjectionJson = OrchestrationV2ThreadProjecti runtimeRequests: Schema.Array(OrchestrationV2RuntimeRequestJson), messages: Schema.Array(OrchestrationV2ConversationMessageJson), plans: Schema.Array(OrchestrationV2PlanArtifact), - turnItems: Schema.Array(OrchestrationV2TurnItemJson), + turnItems: turnItemArray(OrchestrationV2TurnItemJson), checkpointScopes: Schema.Array(OrchestrationV2CheckpointScopeJson), checkpoints: Schema.Array(OrchestrationV2CheckpointJson), contextHandoffs: Schema.Array(OrchestrationV2ContextHandoffJson), contextTransfers: Schema.Array(OrchestrationV2ContextTransferJson), - visibleTurnItems: Schema.Array(OrchestrationV2ProjectedTurnItemJson), + visibleTurnItems: projectedTurnItemArray( + OrchestrationV2ProjectedTurnItemJson, + OrchestrationV2ProjectedTurnItemJson.mapFields((fields) => ({ + ...fields, + item: ForwardCompatibleUnion(OrchestrationV2TurnItemJson.members, "type"), + })), + ), updatedAt: Schema.DateTimeUtcFromString, }), ); @@ -3129,7 +3176,13 @@ export type OrchestrationV2ThreadBoundedSnapshot = typeof OrchestrationV2ThreadB /** Older timeline page for progressive history. Rows are chronological. */ export const OrchestrationV2ThreadHistoryPage = Schema.Struct({ snapshotSequence: NonNegativeInt, - items: Schema.Array(OrchestrationV2ProjectedTurnItem), + items: projectedTurnItemArray( + OrchestrationV2ProjectedTurnItem, + OrchestrationV2ProjectedTurnItem.mapFields((fields) => ({ + ...fields, + item: ForwardCompatibleUnion(OrchestrationV2TurnItem.members, "type"), + })), + ), nextCursor: Schema.NullOr(TrimmedNonEmptyString), hasMoreHistory: Schema.Boolean, }); @@ -3143,22 +3196,26 @@ const knownDomainEventTypes: ReadonlySet = new Set( ); /** - * A thread event whose type this build does not know. Newer servers add event - * types; older clients decode them to this case and skip them, still advancing - * their resume cursor, instead of failing the whole subscription. A known type - * whose payload does not decode still fails. Decode-only: servers never send it. + * A thread event whose type this build does not know, or a turn-item.updated + * carrying a turn item type it does not know. Newer servers add both; older + * clients decode them to this case and skip them, still advancing their resume + * cursor, instead of failing the whole subscription. A known type whose payload + * does not decode still fails. Decode-only: servers never send it. */ const OrchestrationV2UnknownThreadStreamEvent = Schema.Struct({ kind: Schema.Literal("event"), sequence: NonNegativeInt, event: Schema.Struct({ - type: Schema.String.check( - Schema.makeFilter( - (type: string) => - !knownDomainEventTypes.has(type) || "A known event type must decode in full.", - ), + type: Schema.String, + payload: Schema.optional(Schema.Unknown), + }).check( + Schema.makeFilter( + (event) => + !knownDomainEventTypes.has(event.type) || + (event.type === "turn-item.updated" && isUnknownTurnItem(event.payload)) || + "A known event type must decode in full.", ), - }), + ), }).pipe( Schema.decodeTo( Schema.Struct({ diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 565f42866466..bed1e7a782e2 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -280,7 +280,15 @@ export const OrchestratorMcpThreadStatus = Schema.Union([ ]); export type OrchestratorMcpThreadStatus = typeof OrchestratorMcpThreadStatus.Type; +const OrchestratorMcpProjectTarget = Schema.optional( + ProjectId.annotate({ + description: + "Project to act on. Omit for the calling thread's project; required when the caller is not a T3 thread.", + }), +); + export const OrchestratorMcpThreadListInput = Schema.Struct({ + projectId: OrchestratorMcpProjectTarget, statuses: Schema.optional( Schema.Array(OrchestratorMcpThreadStatus).check(Schema.isMaxLength(10)), ), @@ -316,7 +324,8 @@ export type OrchestratorMcpThreadListItem = typeof OrchestratorMcpThreadListItem export const OrchestratorMcpThreadListResult = Schema.Struct({ projectId: ProjectId, - currentThreadId: ThreadId, + /** The calling thread, or null when the caller is not a T3 thread. */ + currentThreadId: Schema.NullOr(ThreadId), threads: Schema.Array(OrchestratorMcpThreadListItem), nextCursor: Schema.NullOr(NonNegativeInt), total: NonNegativeInt, @@ -475,9 +484,11 @@ export const OrchestratorMcpProviderCapability = Schema.Struct({ export type OrchestratorMcpProviderCapability = typeof OrchestratorMcpProviderCapability.Type; export const OrchestratorMcpCapabilitiesResult = Schema.Struct({ - parentThreadId: ThreadId, - inheritedProviderInstanceId: ProviderInstanceId, - inheritedModel: Schema.String, + /** The calling thread, or null when the caller is not a T3 thread. */ + parentThreadId: Schema.NullOr(ThreadId), + /** The calling thread's selection, or null when the caller is not a T3 thread. */ + inheritedProviderInstanceId: Schema.NullOr(ProviderInstanceId), + inheritedModel: Schema.NullOr(Schema.String), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, providers: Schema.Array(OrchestratorMcpProviderCapability), @@ -495,6 +506,7 @@ export const OrchestratorMcpCapabilitiesResult = Schema.Struct({ export type OrchestratorMcpCapabilitiesResult = typeof OrchestratorMcpCapabilitiesResult.Type; export const OrchestratorMcpScheduleTaskInput = Schema.Struct({ + projectId: OrchestratorMcpProjectTarget, prompt: OrchestratorMcpPrompt.annotate({ description: "Prompt executed on every scheduled run.", }), @@ -535,6 +547,17 @@ export type OrchestratorMcpScheduledTask = typeof OrchestratorMcpScheduledTask.T export const OrchestratorMcpScheduleTaskResult = OrchestratorMcpScheduledTask; export type OrchestratorMcpScheduleTaskResult = typeof OrchestratorMcpScheduleTaskResult.Type; +export const OrchestratorMcpListScheduledTasksInput = Schema.Struct({ + projectId: Schema.optional( + ProjectId.annotate({ + description: + "Only list this project's tasks. Omit for the calling thread's project, or for every project when the caller is not a T3 thread.", + }), + ), +}); +export type OrchestratorMcpListScheduledTasksInput = + typeof OrchestratorMcpListScheduledTasksInput.Type; + export const OrchestratorMcpListScheduledTasksResult = Schema.Struct({ tasks: Schema.Array(OrchestratorMcpScheduledTask), }); @@ -583,6 +606,8 @@ export class OrchestratorMcpFailure extends Schema.TaggedError }), ); -const decodeProjectIcon = Schema.decodeUnknownEffect(ProjectIconOverride); -const encodeProjectIcon = Schema.encodeEffect(ProjectIconOverride); +const decodeStoredIcon = Schema.decodeUnknownEffect(StoredProjectIcon); +const encodeStoredIcon = Schema.encodeEffect(StoredProjectIcon); +const decodeReceivedIcon = Schema.decodeUnknownEffect(ReceivedProjectIcon); +const encodeReceivedIcon = Schema.encodeEffect(ReceivedProjectIcon); +const decodeProjectShell = Schema.decodeUnknownEffect(OrchestrationProjectShell); -// Pre-monogram clients reject unknown variants; nightly clients additionally validate monogram. -const decodeOldIcon = Schema.decodeUnknownEffect( - Schema.Union([ - Schema.Struct({ kind: Schema.Literal("lucide"), name: Schema.String, color: Schema.String }), - Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }), - ]), -); -const decodeNightlyIcon = Schema.decodeUnknownEffect( - Schema.Union([ - Schema.Struct({ - kind: Schema.Literal("lucide"), - name: Schema.String, - color: Schema.String, - // Fail if this field is ever sent; old validators must never see the new text. - monogram: Schema.optional(Schema.Never), - }), - Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }), - ]), -); - -effectIt.effect("sends monograms as fallback icons that old and nightly clients can decode", () => +effectIt.effect("sends and stores icons in their plain shape", () => Effect.gen(function* () { - const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const; - for (const text of ["T3", "क्ष्म", "e\u0301"]) { - const monogram = { kind: "monogram", text, color: "violet" } as const; - const wire = yield* encodeProjectIcon(monogram); - assert.deepEqual(wire, { ...fallback, monogramText: text }); - assert.deepEqual(yield* decodeOldIcon(wire), fallback); - assert.deepEqual(yield* decodeNightlyIcon(wire), fallback); - assert.deepEqual(yield* decodeProjectIcon(wire), monogram); - assert.deepEqual(yield* decodeProjectIcon(monogram), monogram); - assert.deepEqual(yield* decodeProjectIcon({ ...fallback, monogram: text }), monogram); - } for (const icon of [ + { kind: "monogram", text: "क्ष्म", color: "violet" }, { kind: "lucide", name: "alarm-clock", color: "blue" }, { kind: "emoji", emoji: "🚀" }, ] as const) { - assert.deepEqual(yield* decodeProjectIcon(icon), icon); - assert.deepEqual(yield* encodeProjectIcon(icon), icon); + assert.deepEqual(yield* encodeReceivedIcon(icon), icon); + assert.deepEqual(yield* encodeStoredIcon(icon), icon); + assert.deepEqual(yield* decodeReceivedIcon(icon), icon); } }), ); -const encodeProjectShell = Schema.encodeEffect(OrchestrationProjectShell); -const encodeProjectUpdate = Schema.encodeEffect(ProjectUpdatePayload); -const decodeLegacyShell = Schema.decodeUnknownEffect( - Schema.Struct({ - ...OrchestrationProjectShell.fields, - projectIcon: Schema.optional( - Schema.NullOr( - Schema.Struct({ - kind: Schema.Literal("lucide"), - name: Schema.String, - color: Schema.String, - }), - ), - ), +effectIt.effect("reads monograms stored in the pre-v2 fallback shape", () => + Effect.gen(function* () { + const monogram = { kind: "monogram", text: "T3", color: "violet" } as const; + const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const; + for (const legacy of [ + { ...fallback, monogramText: "T3" }, + { ...fallback, monogram: "T3" }, + ]) { + assert.deepEqual(yield* decodeStoredIcon(legacy), monogram); + assert.deepEqual(yield* decodeReceivedIcon(legacy), monogram); + } }), ); -effectIt.effect("encodes compatible icons inside snapshots and project updates", () => +effectIt.effect("an icon kind from a newer server shows the default icon", () => Effect.gen(function* () { - const projectIcon = { kind: "monogram", text: "क्ष्म", color: "violet" } as const; - const shell = yield* encodeProjectShell({ - id: ProjectId.make("monogram"), - title: "Monogram", - workspaceRoot: "/tmp/monogram", + assert.isNull(yield* decodeReceivedIcon({ kind: "image", url: "https://example.com/a.png" })); + const shell = yield* decodeProjectShell({ + id: "project-1", + title: "Project", + workspaceRoot: "/tmp/project", defaultModelSelection: null, scripts: [], - projectIcon, + projectIcon: { kind: "image", url: "https://example.com/a.png" }, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); - const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const; - assert.deepEqual((yield* decodeLegacyShell(shell)).projectIcon, fallback); - const update = yield* encodeProjectUpdate({ projectIcon }); - assert.deepEqual(yield* decodeNightlyIcon(update.projectIcon), fallback); + assert.isNull(shell.projectIcon); + // A known kind with a broken payload still fails. + const broken = yield* Effect.exit(decodeReceivedIcon({ kind: "emoji" })); + assert.strictEqual(broken._tag, "Failure"); }), ); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index a1a3d17e9c53..97997ef3deeb 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -4,12 +4,15 @@ import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { ModelSelection } from "./modelSelection.ts"; import { CommandId, + ForwardCompatibleUnion, + isUnknownUnionMember, IsoDateTime, NonNegativeInt, PositiveInt, ProjectId, TrimmedNonEmptyString, TrimmedString, + type UnknownUnionMember, } from "./baseSchemas.ts"; const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; @@ -89,13 +92,6 @@ const ProjectMonogramIcon = Schema.Struct({ text: ProjectMonogramText, color: ProjectIconColor, }); -const ProjectIcon = Schema.Union([ProjectLucideIcon, ProjectEmojiIcon, ProjectMonogramIcon]); -const ProjectLucideIconWire = Schema.Struct({ - ...ProjectLucideIcon.fields, - monogramText: Schema.optional(ProjectMonogramText), - monogram: Schema.optional(ProjectMonogramText), -}); - /** A workspace-relative image a project may use as its favicon. */ export const ProjectFaviconPath = TrimmedNonEmptyString.check( Schema.isMaxLength(1024), @@ -103,36 +99,66 @@ export const ProjectFaviconPath = TrimmedNonEmptyString.check( ); export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; -// Older peers only know lucide/emoji. Keep monograms out of their validated -// `monogram` field too: old grapheme counters can reject otherwise valid text. +/** A project's chosen icon, as clients set it. */ export const ProjectIconOverride = Schema.Union([ - ProjectLucideIconWire, + ProjectLucideIcon, ProjectEmojiIcon, ProjectMonogramIcon, -]).pipe( +]); +export type ProjectIconOverride = typeof ProjectIconOverride.Type; + +/** + * Before v2, servers sent a monogram as a lucide icon carrying its text, so + * older clients showed a folder. Icons stored then, and v2 servers released + * before this change, still use that shape; it is read here, never written. + */ +const ProjectLucideIconWithLegacyMonogram = Schema.Struct({ + ...ProjectLucideIcon.fields, + monogramText: Schema.optional(ProjectMonogramText), + monogram: Schema.optional(ProjectMonogramText), +}); +const projectIconMembers = [ + ProjectLucideIconWithLegacyMonogram, + ProjectEmojiIcon, + ProjectMonogramIcon, +] as const; +type ProjectIconMember = (typeof projectIconMembers)[number]["Type"]; + +const fromLegacyMonogram = (icon: ProjectIconMember): ProjectIconOverride => { + if (icon.kind !== "lucide") return icon; + const text = icon.monogramText ?? icon.monogram; + return text === undefined + ? { kind: "lucide", name: icon.name, color: icon.color } + : { kind: "monogram", text, color: icon.color }; +}; + +/** An icon the server stores; it reads legacy monograms and writes the plain shape. */ +export const StoredProjectIcon = Schema.Union(projectIconMembers).pipe( Schema.decodeTo( - ProjectIcon, - SchemaTransformation.transform({ - decode: (icon): typeof ProjectIcon.Type => { - if (icon.kind !== "lucide") return icon; - const text = icon.monogramText ?? icon.monogram; - return text === undefined - ? { kind: "lucide", name: icon.name, color: icon.color } - : { kind: "monogram", text, color: icon.color }; - }, - encode: (icon) => - icon.kind === "monogram" - ? { - kind: "lucide" as const, - name: "folder-code", - color: icon.color, - monogramText: icon.text, - } - : icon, + Schema.toType(ProjectIconOverride), + SchemaTransformation.transform({ + decode: fromLegacyMonogram, + encode: (icon) => icon, + }), + ), +); + +/** + * An icon as clients receive it. A kind from a newer server decodes as no + * override, so the client shows the project's default icon. + */ +export const ReceivedProjectIcon = ForwardCompatibleUnion(projectIconMembers, "kind").pipe( + Schema.decodeTo( + Schema.NullOr(Schema.toType(ProjectIconOverride)), + SchemaTransformation.transform< + ProjectIconOverride | null, + ProjectIconMember | UnknownUnionMember<"kind"> + >({ + decode: (icon) => (isUnknownUnionMember(icon) ? null : fromLegacyMonogram(icon)), + encode: (icon) => icon as ProjectIconMember, }), ), ); -export type ProjectIconOverride = typeof ProjectIconOverride.Type; export const Project = Schema.Struct({ id: ProjectId, @@ -140,7 +166,7 @@ export const Project = Schema.Struct({ workspaceRoot: TrimmedNonEmptyString, repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), + projectIcon: Schema.optional(Schema.NullOr(ReceivedProjectIcon)), defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), // Opt-in because background sync performs network I/O and may move the checkout. diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 45e90d06a1e3..03c08ea06bdb 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -921,6 +921,19 @@ export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPr export const AcpRegistrySettings = makeProviderSettingsSchema( { + source: Schema.Literals(["registry", "local"]).pipe( + Schema.withDecodingDefault(Effect.succeed("registry")), + Schema.annotateKey({ + title: "ACP source", + providerSettingsForm: { + control: "select", + options: [ + { value: "registry", label: "ACP Registry" }, + { value: "local", label: "Local command" }, + ], + }, + }), + ), enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), @@ -938,10 +951,14 @@ export const AcpRegistrySettings = makeProviderSettingsSchema( Schema.annotateKey({ title: "Executable override", description: - "Optional local executable to use instead of installing the registry distribution. Registry arguments and environment are still applied.", + "Executable on this environment. For registry agents, this overrides the distribution executable while keeping its arguments and environment.", providerSettingsForm: { placeholder: "Registry default", clearWhenEmpty: "omit" }, }), ), + commandArgs: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), authMethodId: TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ @@ -961,7 +978,7 @@ export const AcpRegistrySettings = makeProviderSettingsSchema( ), }, { - order: ["agentId", "commandPath", "authMethodId"], + order: ["source", "agentId", "commandPath", "authMethodId"], }, ); export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; diff --git a/packages/contracts/src/threadMetadataMcp.ts b/packages/contracts/src/threadMetadataMcp.ts index ca15a524d8d7..7c132e4d45f4 100644 --- a/packages/contracts/src/threadMetadataMcp.ts +++ b/packages/contracts/src/threadMetadataMcp.ts @@ -80,7 +80,7 @@ export type ThreadMetadataMcpPullRequest = typeof ThreadMetadataMcpPullRequest.T export const ThreadMetadataMcpUpdateInput = Schema.Struct({ threadId: Schema.optional(ThreadId).annotate({ - description: "Thread in the calling project. Omit to update the calling thread.", + description: "Thread to update. Omit to update the calling thread.", }), action: ThreadMetadataMcpAction, title: Schema.optional(ThreadMetadataTitle), diff --git a/packages/contracts/src/worktreeMcp.ts b/packages/contracts/src/worktreeMcp.ts index e0cf1446fc49..1ceafef55d9e 100644 --- a/packages/contracts/src/worktreeMcp.ts +++ b/packages/contracts/src/worktreeMcp.ts @@ -121,6 +121,7 @@ export class WorktreeMcpFailure extends Schema.TaggedError() "handoff_in_progress", "invalid_request", "operation_failed", + "thread_credential_required", ]), message: Schema.String, }, diff --git a/patches/uniwind@1.11.0.patch b/patches/uniwind@1.11.0.patch deleted file mode 100644 index 4a418bf6b077..000000000000 --- a/patches/uniwind@1.11.0.patch +++ /dev/null @@ -1,1011 +0,0 @@ -diff --git a/dist/common/bundler/css-processor/processor.js b/dist/common/bundler/css-processor/processor.js -index 7dd369f..48321d1 100644 ---- a/dist/common/bundler/css-processor/processor.js -+++ b/dist/common/bundler/css-processor/processor.js -@@ -105,6 +105,64 @@ class ProcessorBuilder { - style.importantProperties.push(declaration.property); - } - } -+ readSelectorVariants(tokens) { -+ const variants = { -+ rtl: null, -+ theme: null, -+ active: null, -+ focus: null, -+ disabled: null, -+ dataAttributes: null -+ }; -+ tokens.forEach((token) => { -+ if (token.type === "pseudo-class" && token.kind === "where") { -+ token.selectors.forEach((compound) => { -+ compound.forEach((inner) => { -+ if (inner.type === "class" && this.bundlerConfig.themes.includes(inner.name)) { -+ variants.theme = inner.name; -+ } -+ if (inner.type === "pseudo-class" && inner.kind === "dir") { -+ variants.rtl = inner.direction === "rtl"; -+ } -+ }); -+ }); -+ } -+ if (token.type === "pseudo-class" && token.kind === "active") { -+ variants.active = true; -+ } -+ if (token.type === "pseudo-class" && token.kind === "focus") { -+ variants.focus = true; -+ } -+ if (token.type === "pseudo-class" && token.kind === "disabled") { -+ variants.disabled = true; -+ } -+ if (token.type === "attribute" && token.operation === null && token.name.startsWith("data-")) { -+ variants.dataAttributes ??= {}; -+ variants.dataAttributes[token.name] = `"true"`; -+ } -+ if (token.type === "attribute" && token.operation?.operator === "equal" && token.name.startsWith("data-")) { -+ variants.dataAttributes ??= {}; -+ variants.dataAttributes[token.name] = `"${token.operation.value}"`; -+ } -+ }); -+ const hasVariants = Object.values(variants).some(_utils.isDefined); -+ return { variants, hasVariants }; -+ } -+ withSelectorVariants(variants, run) { -+ this.declarationConfig.rtl ??= variants.rtl; -+ this.declarationConfig.theme ??= variants.theme; -+ this.declarationConfig.active ??= variants.active; -+ this.declarationConfig.focus ??= variants.focus; -+ this.declarationConfig.disabled ??= variants.disabled; -+ this.declarationConfig.dataAttributes ??= variants.dataAttributes; -+ run(); -+ this.declarationConfig.rtl = null; -+ this.declarationConfig.theme = null; -+ this.declarationConfig.active = null; -+ this.declarationConfig.focus = null; -+ this.declarationConfig.disabled = null; -+ this.declarationConfig.dataAttributes = null; -+ } - parseRuleRec(rule) { - if (this.declarationConfig.className !== null) { - const lastStyle = this.stylesheets[this.declarationConfig.className]?.at(-1); -@@ -120,64 +178,30 @@ class ProcessorBuilder { - this.declarationConfig.className = newClassName; - this.stylesheets[newClassName] ??= []; - this.stylesheets[newClassName].push({}); -- rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration)); -- rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true)); -- rule.value.rules?.forEach(rule2 => this.parseRuleRec(rule2)); -- return; -- } -- let rtl = null; -- let theme = null; -- let active = null; -- let focus = null; -- let disabled = null; -- let dataAttributes = null; -- selector.forEach(selector2 => { -- if (selector2.type === "pseudo-class" && selector2.kind === "where") { -- selector2.selectors.forEach(selector3 => { -- selector3.forEach(selector4 => { -- if (selector4.type === "class" && this.bundlerConfig.themes.includes(selector4.name)) { -- theme = selector4.name; -- } -- if (selector4.type === "pseudo-class" && selector4.kind === "dir") { -- rtl = selector4.direction === "rtl"; -- } -- }); -- }); -- } -- if (selector2.type === "pseudo-class" && selector2.kind === "active") { -- active = true; -+ const trailing = selector.slice(1); -+ const { variants, hasVariants } = this.readSelectorVariants(trailing); -+ if (trailing.length > 0 && !hasVariants) { -+ return; - } -- if (selector2.type === "pseudo-class" && selector2.kind === "focus") { -- focus = true; -- } -- if (selector2.type === "pseudo-class" && selector2.kind === "disabled") { -- disabled = true; -- } -- if (selector2.type === "attribute" && selector2.operation === null && selector2.name.startsWith("data-")) { -- dataAttributes ??= {}; -- dataAttributes[selector2.name] = `"true"`; -- } -- if (selector2.type === "attribute" && selector2.operation?.operator === "equal" && selector2.name.startsWith("data-")) { -- dataAttributes ??= {}; -- dataAttributes[selector2.name] = `"${selector2.operation.value}"`; -+ const parseClassRule = () => { -+ rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration)); -+ rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true)); -+ rule.value.rules?.forEach(rule2 => this.parseRuleRec(rule2)); -+ }; -+ if (hasVariants) { -+ this.withSelectorVariants(variants, parseClassRule); -+ } else { -+ parseClassRule(); - } -- }); -- if ([rtl, theme, active, focus, disabled, dataAttributes].some(_utils.isDefined)) { -- this.declarationConfig.rtl ??= rtl; -- this.declarationConfig.theme ??= theme; -- this.declarationConfig.active ??= active; -- this.declarationConfig.focus ??= focus; -- this.declarationConfig.disabled ??= disabled; -- this.declarationConfig.dataAttributes ??= dataAttributes; -- rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration)); -- rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true)); -- rule.value.rules?.forEach(rule2 => this.parseRuleRec(rule2)); -- this.declarationConfig.rtl = null; -- this.declarationConfig.theme = null; -- this.declarationConfig.active = null; -- this.declarationConfig.focus = null; -- this.declarationConfig.disabled = null; -- this.declarationConfig.dataAttributes = null; -+ return; -+ } -+ const { variants, hasVariants } = this.readSelectorVariants(selector); -+ if (hasVariants) { -+ this.withSelectorVariants(variants, () => { -+ rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration)); -+ rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true)); -+ rule.value.rules?.forEach(rule2 => this.parseRuleRec(rule2)); -+ }); - return; - } - selector.forEach(selectorToken => { -@@ -199,11 +223,16 @@ class ProcessorBuilder { - const { - mediaQueries - } = rule.value.query; -- this.declarationConfig.mediaQueries.push(...mediaQueries); -+ const outerConfig = this.declarationConfig; -+ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries]; - rule.value.rules.forEach(rule2 => { -+ this.declarationConfig = { -+ ...outerConfig, -+ mediaQueries: blockMediaQueries -+ }; - this.parseRuleRec(rule2); -- this.declarationConfig = this.getDeclarationConfig(); - }); -+ this.declarationConfig = outerConfig; - return; - } - if (rule.type === "layer-block") { -diff --git a/dist/metro/transformer.cjs b/dist/metro/transformer.cjs -index d57ca81..83b58d1 100644 ---- a/dist/metro/transformer.cjs -+++ b/dist/metro/transformer.cjs -@@ -7,6 +7,7 @@ const culori = require('culori'); - const node = require('@tailwindcss/node'); - const oxide = require('@tailwindcss/oxide'); - const fs = require('fs'); -+const node_crypto = require('node:crypto'); - - function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; } - -@@ -1537,6 +1538,64 @@ class ProcessorBuilder { - style.importantProperties.push(declaration.property); - } - } -+ readSelectorVariants(tokens) { -+ const variants = { -+ rtl: null, -+ theme: null, -+ active: null, -+ focus: null, -+ disabled: null, -+ dataAttributes: null -+ }; -+ tokens.forEach((token) => { -+ if (token.type === "pseudo-class" && token.kind === "where") { -+ token.selectors.forEach((compound) => { -+ compound.forEach((inner) => { -+ if (inner.type === "class" && this.bundlerConfig.themes.includes(inner.name)) { -+ variants.theme = inner.name; -+ } -+ if (inner.type === "pseudo-class" && inner.kind === "dir") { -+ variants.rtl = inner.direction === "rtl"; -+ } -+ }); -+ }); -+ } -+ if (token.type === "pseudo-class" && token.kind === "active") { -+ variants.active = true; -+ } -+ if (token.type === "pseudo-class" && token.kind === "focus") { -+ variants.focus = true; -+ } -+ if (token.type === "pseudo-class" && token.kind === "disabled") { -+ variants.disabled = true; -+ } -+ if (token.type === "attribute" && token.operation === null && token.name.startsWith("data-")) { -+ variants.dataAttributes ??= {}; -+ variants.dataAttributes[token.name] = `"true"`; -+ } -+ if (token.type === "attribute" && token.operation?.operator === "equal" && token.name.startsWith("data-")) { -+ variants.dataAttributes ??= {}; -+ variants.dataAttributes[token.name] = `"${token.operation.value}"`; -+ } -+ }); -+ const hasVariants = Object.values(variants).some(config.isDefined); -+ return { variants, hasVariants }; -+ } -+ withSelectorVariants(variants, run) { -+ this.declarationConfig.rtl ??= variants.rtl; -+ this.declarationConfig.theme ??= variants.theme; -+ this.declarationConfig.active ??= variants.active; -+ this.declarationConfig.focus ??= variants.focus; -+ this.declarationConfig.disabled ??= variants.disabled; -+ this.declarationConfig.dataAttributes ??= variants.dataAttributes; -+ run(); -+ this.declarationConfig.rtl = null; -+ this.declarationConfig.theme = null; -+ this.declarationConfig.active = null; -+ this.declarationConfig.focus = null; -+ this.declarationConfig.disabled = null; -+ this.declarationConfig.dataAttributes = null; -+ } - parseRuleRec(rule) { - if (this.declarationConfig.className !== null) { - const lastStyle = this.stylesheets[this.declarationConfig.className]?.at(-1); -@@ -1552,64 +1611,30 @@ class ProcessorBuilder { - this.declarationConfig.className = newClassName; - this.stylesheets[newClassName] ??= []; - this.stylesheets[newClassName].push({}); -- rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -- rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -- rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -- return; -- } -- let rtl = null; -- let theme = null; -- let active = null; -- let focus = null; -- let disabled = null; -- let dataAttributes = null; -- selector.forEach((selector2) => { -- if (selector2.type === "pseudo-class" && selector2.kind === "where") { -- selector2.selectors.forEach((selector3) => { -- selector3.forEach((selector4) => { -- if (selector4.type === "class" && this.bundlerConfig.themes.includes(selector4.name)) { -- theme = selector4.name; -- } -- if (selector4.type === "pseudo-class" && selector4.kind === "dir") { -- rtl = selector4.direction === "rtl"; -- } -- }); -- }); -- } -- if (selector2.type === "pseudo-class" && selector2.kind === "active") { -- active = true; -- } -- if (selector2.type === "pseudo-class" && selector2.kind === "focus") { -- focus = true; -+ const trailing = selector.slice(1); -+ const { variants, hasVariants } = this.readSelectorVariants(trailing); -+ if (trailing.length > 0 && !hasVariants) { -+ return; - } -- if (selector2.type === "pseudo-class" && selector2.kind === "disabled") { -- disabled = true; -- } -- if (selector2.type === "attribute" && selector2.operation === null && selector2.name.startsWith("data-")) { -- dataAttributes ??= {}; -- dataAttributes[selector2.name] = `"true"`; -- } -- if (selector2.type === "attribute" && selector2.operation?.operator === "equal" && selector2.name.startsWith("data-")) { -- dataAttributes ??= {}; -- dataAttributes[selector2.name] = `"${selector2.operation.value}"`; -+ const parseClassRule = () => { -+ rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -+ rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -+ rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -+ }; -+ if (hasVariants) { -+ this.withSelectorVariants(variants, parseClassRule); -+ } else { -+ parseClassRule(); - } -- }); -- if ([rtl, theme, active, focus, disabled, dataAttributes].some(config.isDefined)) { -- this.declarationConfig.rtl ??= rtl; -- this.declarationConfig.theme ??= theme; -- this.declarationConfig.active ??= active; -- this.declarationConfig.focus ??= focus; -- this.declarationConfig.disabled ??= disabled; -- this.declarationConfig.dataAttributes ??= dataAttributes; -- rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -- rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -- rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -- this.declarationConfig.rtl = null; -- this.declarationConfig.theme = null; -- this.declarationConfig.active = null; -- this.declarationConfig.focus = null; -- this.declarationConfig.disabled = null; -- this.declarationConfig.dataAttributes = null; -+ return; -+ } -+ const { variants, hasVariants } = this.readSelectorVariants(selector); -+ if (hasVariants) { -+ this.withSelectorVariants(variants, () => { -+ rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -+ rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -+ rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -+ }); - return; - } - selector.forEach((selectorToken) => { -@@ -1629,11 +1654,13 @@ class ProcessorBuilder { - } - if (rule.type === "media") { - const { mediaQueries } = rule.value.query; -- this.declarationConfig.mediaQueries.push(...mediaQueries); -+ const outerConfig = this.declarationConfig; -+ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries]; - rule.value.rules.forEach((rule2) => { -+ this.declarationConfig = { ...outerConfig, mediaQueries: blockMediaQueries }; - this.parseRuleRec(rule2); -- this.declarationConfig = this.getDeclarationConfig(); - }); -+ this.declarationConfig = outerConfig; - return; - } - if (rule.type === "layer-block") { -@@ -1753,10 +1780,11 @@ const transform = async (config$1, projectRoot, filePath, data, options) => { - await bundlerConfig.generateArtifacts(cssArtifactPath); - const virtualCode = await compileCSS(bundlerConfig); - const isWeb = bundlerConfig.platform === config.Platform.Web; -+ const nativeStylesFingerprint = isWeb ? void 0 : node_crypto.createHash("sha256").update(virtualCode).update("\0").update(bundlerConfig.stringifiedThemes).digest("hex"); - data = Buffer.from( - isWeb ? virtualCode : [ - `const { Uniwind } = require('uniwind');`, -- `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes});` -+ `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes}, '${nativeStylesFingerprint}');` - ].join(""), - "utf-8" - ); -diff --git a/dist/metro/transformer.mjs b/dist/metro/transformer.mjs -index d3e7475..f92b73b 100644 ---- a/dist/metro/transformer.mjs -+++ b/dist/metro/transformer.mjs -@@ -5,6 +5,7 @@ import { converter, parse, formatHex, formatHex8 } from 'culori'; - import { compile } from '@tailwindcss/node'; - import { Scanner } from '@tailwindcss/oxide'; - import fs from 'fs'; -+import { createHash } from 'node:crypto'; - - const toCamelCase = (str) => str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); - const pipe = (data) => ((...fns) => fns.reduce((acc, fn) => fn(acc), data)); -@@ -1530,6 +1531,64 @@ class ProcessorBuilder { - style.importantProperties.push(declaration.property); - } - } -+ readSelectorVariants(tokens) { -+ const variants = { -+ rtl: null, -+ theme: null, -+ active: null, -+ focus: null, -+ disabled: null, -+ dataAttributes: null -+ }; -+ tokens.forEach((token) => { -+ if (token.type === "pseudo-class" && token.kind === "where") { -+ token.selectors.forEach((compound) => { -+ compound.forEach((inner) => { -+ if (inner.type === "class" && this.bundlerConfig.themes.includes(inner.name)) { -+ variants.theme = inner.name; -+ } -+ if (inner.type === "pseudo-class" && inner.kind === "dir") { -+ variants.rtl = inner.direction === "rtl"; -+ } -+ }); -+ }); -+ } -+ if (token.type === "pseudo-class" && token.kind === "active") { -+ variants.active = true; -+ } -+ if (token.type === "pseudo-class" && token.kind === "focus") { -+ variants.focus = true; -+ } -+ if (token.type === "pseudo-class" && token.kind === "disabled") { -+ variants.disabled = true; -+ } -+ if (token.type === "attribute" && token.operation === null && token.name.startsWith("data-")) { -+ variants.dataAttributes ??= {}; -+ variants.dataAttributes[token.name] = `"true"`; -+ } -+ if (token.type === "attribute" && token.operation?.operator === "equal" && token.name.startsWith("data-")) { -+ variants.dataAttributes ??= {}; -+ variants.dataAttributes[token.name] = `"${token.operation.value}"`; -+ } -+ }); -+ const hasVariants = Object.values(variants).some(isDefined); -+ return { variants, hasVariants }; -+ } -+ withSelectorVariants(variants, run) { -+ this.declarationConfig.rtl ??= variants.rtl; -+ this.declarationConfig.theme ??= variants.theme; -+ this.declarationConfig.active ??= variants.active; -+ this.declarationConfig.focus ??= variants.focus; -+ this.declarationConfig.disabled ??= variants.disabled; -+ this.declarationConfig.dataAttributes ??= variants.dataAttributes; -+ run(); -+ this.declarationConfig.rtl = null; -+ this.declarationConfig.theme = null; -+ this.declarationConfig.active = null; -+ this.declarationConfig.focus = null; -+ this.declarationConfig.disabled = null; -+ this.declarationConfig.dataAttributes = null; -+ } - parseRuleRec(rule) { - if (this.declarationConfig.className !== null) { - const lastStyle = this.stylesheets[this.declarationConfig.className]?.at(-1); -@@ -1545,64 +1604,30 @@ class ProcessorBuilder { - this.declarationConfig.className = newClassName; - this.stylesheets[newClassName] ??= []; - this.stylesheets[newClassName].push({}); -- rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -- rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -- rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -- return; -- } -- let rtl = null; -- let theme = null; -- let active = null; -- let focus = null; -- let disabled = null; -- let dataAttributes = null; -- selector.forEach((selector2) => { -- if (selector2.type === "pseudo-class" && selector2.kind === "where") { -- selector2.selectors.forEach((selector3) => { -- selector3.forEach((selector4) => { -- if (selector4.type === "class" && this.bundlerConfig.themes.includes(selector4.name)) { -- theme = selector4.name; -- } -- if (selector4.type === "pseudo-class" && selector4.kind === "dir") { -- rtl = selector4.direction === "rtl"; -- } -- }); -- }); -- } -- if (selector2.type === "pseudo-class" && selector2.kind === "active") { -- active = true; -- } -- if (selector2.type === "pseudo-class" && selector2.kind === "focus") { -- focus = true; -+ const trailing = selector.slice(1); -+ const { variants, hasVariants } = this.readSelectorVariants(trailing); -+ if (trailing.length > 0 && !hasVariants) { -+ return; - } -- if (selector2.type === "pseudo-class" && selector2.kind === "disabled") { -- disabled = true; -- } -- if (selector2.type === "attribute" && selector2.operation === null && selector2.name.startsWith("data-")) { -- dataAttributes ??= {}; -- dataAttributes[selector2.name] = `"true"`; -- } -- if (selector2.type === "attribute" && selector2.operation?.operator === "equal" && selector2.name.startsWith("data-")) { -- dataAttributes ??= {}; -- dataAttributes[selector2.name] = `"${selector2.operation.value}"`; -+ const parseClassRule = () => { -+ rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -+ rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -+ rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -+ }; -+ if (hasVariants) { -+ this.withSelectorVariants(variants, parseClassRule); -+ } else { -+ parseClassRule(); - } -- }); -- if ([rtl, theme, active, focus, disabled, dataAttributes].some(isDefined)) { -- this.declarationConfig.rtl ??= rtl; -- this.declarationConfig.theme ??= theme; -- this.declarationConfig.active ??= active; -- this.declarationConfig.focus ??= focus; -- this.declarationConfig.disabled ??= disabled; -- this.declarationConfig.dataAttributes ??= dataAttributes; -- rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -- rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -- rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -- this.declarationConfig.rtl = null; -- this.declarationConfig.theme = null; -- this.declarationConfig.active = null; -- this.declarationConfig.focus = null; -- this.declarationConfig.disabled = null; -- this.declarationConfig.dataAttributes = null; -+ return; -+ } -+ const { variants, hasVariants } = this.readSelectorVariants(selector); -+ if (hasVariants) { -+ this.withSelectorVariants(variants, () => { -+ rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -+ rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -+ rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -+ }); - return; - } - selector.forEach((selectorToken) => { -@@ -1622,11 +1647,13 @@ class ProcessorBuilder { - } - if (rule.type === "media") { - const { mediaQueries } = rule.value.query; -- this.declarationConfig.mediaQueries.push(...mediaQueries); -+ const outerConfig = this.declarationConfig; -+ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries]; - rule.value.rules.forEach((rule2) => { -+ this.declarationConfig = { ...outerConfig, mediaQueries: blockMediaQueries }; - this.parseRuleRec(rule2); -- this.declarationConfig = this.getDeclarationConfig(); - }); -+ this.declarationConfig = outerConfig; - return; - } - if (rule.type === "layer-block") { -@@ -1746,10 +1773,11 @@ const transform = async (config, projectRoot, filePath, data, options) => { - await bundlerConfig.generateArtifacts(cssArtifactPath); - const virtualCode = await compileCSS(bundlerConfig); - const isWeb = bundlerConfig.platform === Platform.Web; -+ const nativeStylesFingerprint = isWeb ? void 0 : createHash("sha256").update(virtualCode).update("\0").update(bundlerConfig.stringifiedThemes).digest("hex"); - data = Buffer.from( - isWeb ? virtualCode : [ - `const { Uniwind } = require('uniwind');`, -- `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes});` -+ `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes}, '${nativeStylesFingerprint}');` - ].join(""), - "utf-8" - ); -diff --git a/dist/module/bundler/css-processor/processor.js b/dist/module/bundler/css-processor/processor.js -index 58c96b6..6849ca7 100644 ---- a/dist/module/bundler/css-processor/processor.js -+++ b/dist/module/bundler/css-processor/processor.js -@@ -99,6 +99,64 @@ export class ProcessorBuilder { - style.importantProperties.push(declaration.property); - } - } -+ readSelectorVariants(tokens) { -+ const variants = { -+ rtl: null, -+ theme: null, -+ active: null, -+ focus: null, -+ disabled: null, -+ dataAttributes: null -+ }; -+ tokens.forEach((token) => { -+ if (token.type === "pseudo-class" && token.kind === "where") { -+ token.selectors.forEach((compound) => { -+ compound.forEach((inner) => { -+ if (inner.type === "class" && this.bundlerConfig.themes.includes(inner.name)) { -+ variants.theme = inner.name; -+ } -+ if (inner.type === "pseudo-class" && inner.kind === "dir") { -+ variants.rtl = inner.direction === "rtl"; -+ } -+ }); -+ }); -+ } -+ if (token.type === "pseudo-class" && token.kind === "active") { -+ variants.active = true; -+ } -+ if (token.type === "pseudo-class" && token.kind === "focus") { -+ variants.focus = true; -+ } -+ if (token.type === "pseudo-class" && token.kind === "disabled") { -+ variants.disabled = true; -+ } -+ if (token.type === "attribute" && token.operation === null && token.name.startsWith("data-")) { -+ variants.dataAttributes ??= {}; -+ variants.dataAttributes[token.name] = `"true"`; -+ } -+ if (token.type === "attribute" && token.operation?.operator === "equal" && token.name.startsWith("data-")) { -+ variants.dataAttributes ??= {}; -+ variants.dataAttributes[token.name] = `"${token.operation.value}"`; -+ } -+ }); -+ const hasVariants = Object.values(variants).some(isDefined); -+ return { variants, hasVariants }; -+ } -+ withSelectorVariants(variants, run) { -+ this.declarationConfig.rtl ??= variants.rtl; -+ this.declarationConfig.theme ??= variants.theme; -+ this.declarationConfig.active ??= variants.active; -+ this.declarationConfig.focus ??= variants.focus; -+ this.declarationConfig.disabled ??= variants.disabled; -+ this.declarationConfig.dataAttributes ??= variants.dataAttributes; -+ run(); -+ this.declarationConfig.rtl = null; -+ this.declarationConfig.theme = null; -+ this.declarationConfig.active = null; -+ this.declarationConfig.focus = null; -+ this.declarationConfig.disabled = null; -+ this.declarationConfig.dataAttributes = null; -+ } - parseRuleRec(rule) { - if (this.declarationConfig.className !== null) { - const lastStyle = this.stylesheets[this.declarationConfig.className]?.at(-1); -@@ -114,64 +172,30 @@ export class ProcessorBuilder { - this.declarationConfig.className = newClassName; - this.stylesheets[newClassName] ??= []; - this.stylesheets[newClassName].push({}); -- rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -- rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -- rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -- return; -- } -- let rtl = null; -- let theme = null; -- let active = null; -- let focus = null; -- let disabled = null; -- let dataAttributes = null; -- selector.forEach((selector2) => { -- if (selector2.type === "pseudo-class" && selector2.kind === "where") { -- selector2.selectors.forEach((selector3) => { -- selector3.forEach((selector4) => { -- if (selector4.type === "class" && this.bundlerConfig.themes.includes(selector4.name)) { -- theme = selector4.name; -- } -- if (selector4.type === "pseudo-class" && selector4.kind === "dir") { -- rtl = selector4.direction === "rtl"; -- } -- }); -- }); -- } -- if (selector2.type === "pseudo-class" && selector2.kind === "active") { -- active = true; -+ const trailing = selector.slice(1); -+ const { variants, hasVariants } = this.readSelectorVariants(trailing); -+ if (trailing.length > 0 && !hasVariants) { -+ return; - } -- if (selector2.type === "pseudo-class" && selector2.kind === "focus") { -- focus = true; -- } -- if (selector2.type === "pseudo-class" && selector2.kind === "disabled") { -- disabled = true; -- } -- if (selector2.type === "attribute" && selector2.operation === null && selector2.name.startsWith("data-")) { -- dataAttributes ??= {}; -- dataAttributes[selector2.name] = `"true"`; -- } -- if (selector2.type === "attribute" && selector2.operation?.operator === "equal" && selector2.name.startsWith("data-")) { -- dataAttributes ??= {}; -- dataAttributes[selector2.name] = `"${selector2.operation.value}"`; -+ const parseClassRule = () => { -+ rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -+ rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -+ rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -+ }; -+ if (hasVariants) { -+ this.withSelectorVariants(variants, parseClassRule); -+ } else { -+ parseClassRule(); - } -- }); -- if ([rtl, theme, active, focus, disabled, dataAttributes].some(isDefined)) { -- this.declarationConfig.rtl ??= rtl; -- this.declarationConfig.theme ??= theme; -- this.declarationConfig.active ??= active; -- this.declarationConfig.focus ??= focus; -- this.declarationConfig.disabled ??= disabled; -- this.declarationConfig.dataAttributes ??= dataAttributes; -- rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -- rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -- rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -- this.declarationConfig.rtl = null; -- this.declarationConfig.theme = null; -- this.declarationConfig.active = null; -- this.declarationConfig.focus = null; -- this.declarationConfig.disabled = null; -- this.declarationConfig.dataAttributes = null; -+ return; -+ } -+ const { variants, hasVariants } = this.readSelectorVariants(selector); -+ if (hasVariants) { -+ this.withSelectorVariants(variants, () => { -+ rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -+ rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -+ rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -+ }); - return; - } - selector.forEach((selectorToken) => { -@@ -191,11 +215,13 @@ export class ProcessorBuilder { - } - if (rule.type === "media") { - const { mediaQueries } = rule.value.query; -- this.declarationConfig.mediaQueries.push(...mediaQueries); -+ const outerConfig = this.declarationConfig; -+ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries]; - rule.value.rules.forEach((rule2) => { -+ this.declarationConfig = { ...outerConfig, mediaQueries: blockMediaQueries }; - this.parseRuleRec(rule2); -- this.declarationConfig = this.getDeclarationConfig(); - }); -+ this.declarationConfig = outerConfig; - return; - } - if (rule.type === "layer-block") { -diff --git a/src/bundler/adapters/metro/transformer.ts b/src/bundler/adapters/metro/transformer.ts -index 08059b0..05c0515 100644 ---- a/src/bundler/adapters/metro/transformer.ts -+++ b/src/bundler/adapters/metro/transformer.ts -@@ -5,6 +5,7 @@ import { Platform } from '@/common/consts' - import type * as ExpoMetroConfig from '@expo/metro-config' - import type * as MetroTransformWorker from 'metro-transform-worker' - import type { JsTransformerConfig, JsTransformOptions } from 'metro-transform-worker' -+import { createHash } from 'node:crypto' - import path from 'path' - - const cssArtifactPath = path.resolve(__dirname, '../../uniwind.css') -@@ -69,13 +70,20 @@ export const transform = async ( - await bundlerConfig.generateArtifacts(cssArtifactPath) - const virtualCode = await compileCSS(bundlerConfig) - const isWeb = bundlerConfig.platform === Platform.Web -+ const nativeStylesFingerprint = isWeb -+ ? undefined -+ : createHash('sha256') -+ .update(virtualCode) -+ .update('\0') -+ .update(bundlerConfig.stringifiedThemes) -+ .digest('hex') - - data = Buffer.from( - isWeb - ? virtualCode - : [ - `const { Uniwind } = require('uniwind');`, -- `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes});`, -+ `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes}, '${nativeStylesFingerprint}');`, - ].join(''), - 'utf-8', - ) -diff --git a/src/bundler/css-processor/processor.ts b/src/bundler/css-processor/processor.ts -index c8b3bb8..4af4199 100644 ---- a/src/bundler/css-processor/processor.ts -+++ b/src/bundler/css-processor/processor.ts -@@ -123,6 +123,77 @@ export class ProcessorBuilder { - } - } - -+ private readSelectorVariants(tokens: ReadonlyArray) { -+ const variants = { -+ rtl: null as boolean | null, -+ theme: null as string | null, -+ active: null as boolean | null, -+ focus: null as boolean | null, -+ disabled: null as boolean | null, -+ dataAttributes: null as Record | null, -+ } -+ -+ tokens.forEach(token => { -+ if (token.type === 'pseudo-class' && token.kind === 'where') { -+ token.selectors.forEach((compound: Array) => { -+ compound.forEach(inner => { -+ if (inner.type === 'class' && this.bundlerConfig.themes.includes(inner.name)) { -+ variants.theme = inner.name -+ } -+ -+ if (inner.type === 'pseudo-class' && inner.kind === 'dir') { -+ variants.rtl = inner.direction === 'rtl' -+ } -+ }) -+ }) -+ } -+ -+ if (token.type === 'pseudo-class' && token.kind === 'active') { -+ variants.active = true -+ } -+ -+ if (token.type === 'pseudo-class' && token.kind === 'focus') { -+ variants.focus = true -+ } -+ -+ if (token.type === 'pseudo-class' && token.kind === 'disabled') { -+ variants.disabled = true -+ } -+ -+ // data-x -+ if (token.type === 'attribute' && token.operation === null && token.name.startsWith('data-')) { -+ variants.dataAttributes ??= {} -+ variants.dataAttributes[token.name] = `"true"` -+ } -+ -+ // data-x= -+ if (token.type === 'attribute' && token.operation?.operator === 'equal' && token.name.startsWith('data-')) { -+ variants.dataAttributes ??= {} -+ variants.dataAttributes[token.name] = `"${token.operation.value}"` -+ } -+ }) -+ -+ return { variants, hasVariants: Object.values(variants).some(isDefined) } -+ } -+ -+ private withSelectorVariants(variants: ReturnType['variants'], run: () => void) { -+ this.declarationConfig.rtl ??= variants.rtl -+ this.declarationConfig.theme ??= variants.theme -+ this.declarationConfig.active ??= variants.active -+ this.declarationConfig.focus ??= variants.focus -+ this.declarationConfig.disabled ??= variants.disabled -+ this.declarationConfig.dataAttributes ??= variants.dataAttributes -+ -+ run() -+ -+ this.declarationConfig.rtl = null -+ this.declarationConfig.theme = null -+ this.declarationConfig.active = null -+ this.declarationConfig.focus = null -+ this.declarationConfig.disabled = null -+ this.declarationConfig.dataAttributes = null -+ } -+ - private parseRuleRec(rule: Rule) { - if (this.declarationConfig.className !== null) { - const lastStyle = this.stylesheets[this.declarationConfig.className]?.at(-1) -@@ -142,78 +213,41 @@ export class ProcessorBuilder { - this.stylesheets[newClassName] ??= [] - this.stylesheets[newClassName].push({}) - -- rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration)) -- rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true)) -- rule.value.rules?.forEach(rule => this.parseRuleRec(rule)) -+ // Tailwind >= 4.3.3 emits `.active\:x:active {}` instead of -+ // nesting `&:active` under the class, so the variant tokens -+ // sit right after the class token. A compound the runtime -+ // cannot express (e.g. `[aria-disabled="true"]`) used to be -+ // an empty nested rule and must not become unconditional. -+ const trailing = selector.slice(1) -+ const { variants, hasVariants } = this.readSelectorVariants(trailing) - -- return -- } -- -- let rtl = null as boolean | null -- let theme = null as string | null -- let active = null as boolean | null -- let focus = null as boolean | null -- let disabled = null as boolean | null -- let dataAttributes = null as Record | null -- -- selector.forEach(selector => { -- if (selector.type === 'pseudo-class' && selector.kind === 'where') { -- selector.selectors.forEach(selector => { -- selector.forEach(selector => { -- if (selector.type === 'class' && this.bundlerConfig.themes.includes(selector.name)) { -- theme = selector.name -- } -- -- if (selector.type === 'pseudo-class' && selector.kind === 'dir') { -- rtl = selector.direction === 'rtl' -- } -- }) -- }) -+ if (trailing.length > 0 && !hasVariants) { -+ return - } - -- if (selector.type === 'pseudo-class' && selector.kind === 'active') { -- active = true -- } -- -- if (selector.type === 'pseudo-class' && selector.kind === 'focus') { -- focus = true -+ const parseClassRule = () => { -+ rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration)) -+ rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true)) -+ rule.value.rules?.forEach(rule => this.parseRuleRec(rule)) - } - -- if (selector.type === 'pseudo-class' && selector.kind === 'disabled') { -- disabled = true -+ if (hasVariants) { -+ this.withSelectorVariants(variants, parseClassRule) -+ } else { -+ parseClassRule() - } - -- // data-x -- if (selector.type === 'attribute' && selector.operation === null && selector.name.startsWith('data-')) { -- dataAttributes ??= {} -- dataAttributes[selector.name] = `"true"` -- } -+ return -+ } - -- // data-x= -- if (selector.type === 'attribute' && selector.operation?.operator === 'equal' && selector.name.startsWith('data-')) { -- dataAttributes ??= {} -- dataAttributes[selector.name] = `"${selector.operation.value}"` -- } -- }) -+ const { variants, hasVariants } = this.readSelectorVariants(selector) - -- if ([rtl, theme, active, focus, disabled, dataAttributes].some(isDefined)) { -- this.declarationConfig.rtl ??= rtl -- this.declarationConfig.theme ??= theme -- this.declarationConfig.active ??= active -- this.declarationConfig.focus ??= focus -- this.declarationConfig.disabled ??= disabled -- this.declarationConfig.dataAttributes ??= dataAttributes -- -- rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration)) -- rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true)) -- rule.value.rules?.forEach(rule => this.parseRuleRec(rule)) -- -- this.declarationConfig.rtl = null -- this.declarationConfig.theme = null -- this.declarationConfig.active = null -- this.declarationConfig.focus = null -- this.declarationConfig.disabled = null -- this.declarationConfig.dataAttributes = null -+ if (hasVariants) { -+ this.withSelectorVariants(variants, () => { -+ rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration)) -+ rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true)) -+ rule.value.rules?.forEach(rule => this.parseRuleRec(rule)) -+ }) - - return - } -@@ -241,13 +275,21 @@ export class ProcessorBuilder { - - if (rule.type === 'media') { - const { mediaQueries } = rule.value.query -+ // Media queries have to outlive the reset between the block's sibling -+ // rules: resetting only after each parse left every rule past the first -+ // without the block's queries, shipping ios:/android: variants and later -+ // utilities of a width breakpoint unguarded to every platform. Reset to -+ // the outer config instead of a fresh one so media nested inside a class -+ // rule keeps writing into that class. -+ const outerConfig = this.declarationConfig -+ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries] - -- this.declarationConfig.mediaQueries.push(...mediaQueries) - rule.value.rules.forEach(rule => { -+ this.declarationConfig = { ...outerConfig, mediaQueries: blockMediaQueries } - this.parseRuleRec(rule) -- this.declarationConfig = this.getDeclarationConfig() - }) - -+ this.declarationConfig = outerConfig - return - } - -diff --git a/src/core/config/config.native.ts b/src/core/config/config.native.ts -index 5e0832b..01aed1b 100644 ---- a/src/core/config/config.native.ts -+++ b/src/core/config/config.native.ts -@@ -8,6 +8,8 @@ import type { CSSVariables, GenerateStyleSheetsCallback, ThemeName } from '../ty - import { UniwindConfigBuilder as UniwindConfigBuilderBase } from './config.common' - - class UniwindConfigBuilder extends UniwindConfigBuilderBase { -+ private stylesFingerprint: string | undefined -+ - constructor() { - super() - } -@@ -35,9 +37,18 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase { - UniwindListener.notify([StyleDependency.Insets]) - } - -- protected __reinit(generateStyleSheetCallback: GenerateStyleSheetsCallback, themes: Array) { -+ protected __reinit( -+ generateStyleSheetCallback: GenerateStyleSheetsCallback, -+ themes: Array, -+ stylesFingerprint?: string, -+ ) { -+ if (__DEV__ && stylesFingerprint !== undefined && stylesFingerprint === this.stylesFingerprint) { -+ return -+ } -+ - super.__reinit(generateStyleSheetCallback, themes) - UniwindStore.reinit(generateStyleSheetCallback, themes) -+ this.stylesFingerprint = stylesFingerprint - } - - protected onThemeChange() { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04e4187e7fee..6d42e532c725 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,7 +115,6 @@ patchedDependencies: react-native-nitro-markdown@0.5.8: 642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c react-native-nitro-modules@0.35.9: daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2 react-native-screens@4.28.0: cc1301436ee62c39860acc9ee1a1e60b7d47e6abd211c42effa62c056d165483 - uniwind@1.11.0: a91f3953483cfd434436de6358ff5b63c49974ef148fad91246c26ba95332625 importers: @@ -496,8 +495,8 @@ importers: specifier: ^3.5.0 version: 3.6.0 uniwind: - specifier: 1.11.0 - version: 1.11.0(patch_hash=a91f3953483cfd434436de6358ff5b63c49974ef148fad91246c26ba95332625)(@expo/metro-config@58.0.6(patch_hash=90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760)(bufferutil@4.1.0)(expo@58.0.2)(typescript@7.0.2)(utf-8-validate@6.0.6))(metro-cache@0.87.1)(metro-transform-worker@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(tailwindcss@4.3.3) + specifier: 1.12.1 + version: 1.12.1(@expo/metro-config@58.0.6(patch_hash=90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760)(bufferutil@4.1.0)(expo@58.0.2)(typescript@7.0.2)(utf-8-validate@6.0.6))(metro-cache@0.87.1)(metro-transform-worker@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(tailwindcss@4.3.3) devDependencies: '@effect/vitest': specifier: 4.0.0-rc.115 @@ -776,6 +775,9 @@ importers: tailwind-merge: specifier: ^3.4.0 version: 3.6.0 + unbash: + specifier: ^5.0.0 + version: 5.0.0 zustand: specifier: ^5.0.11 version: 5.0.14(@types/react@19.3.0)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) @@ -10904,6 +10906,10 @@ packages: resolution: {integrity: sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==} engines: {node: '>=14'} + unbash@5.0.0: + resolution: {integrity: sha512-RYNm/ISFtflWpIcXTPHHLJZ0mqzInav+L9ke9PcL+hHsSf9+Fd/syPb614Mlqs/O3AYgjnvxDOFl0a+zVqi1/w==} + engines: {node: '>=14'} + uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} @@ -10977,8 +10983,8 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - uniwind@1.11.0: - resolution: {integrity: sha512-e2mYlHrZzAQtZUf3STrfVDTetspv7p5vm/cu6p8kjLRQaL9r6QFG+MlwZ3hrI2LHnzgzjbSo+MwvoE380zz2JQ==} + uniwind@1.12.1: + resolution: {integrity: sha512-u0OVMVqefGchOpsPIwIYvcF2Jl9JtVVaXBb+wfqAYoyLrhN27Ll50aQ7aFT81k73JSq+He6dNlcbTl2Qb9cTMw==} hasBin: true peerDependencies: '@expo/metro-config': 58.0.6 @@ -22132,6 +22138,8 @@ snapshots: unbash@4.0.11: {} + unbash@5.0.0: {} + uncrypto@0.1.3: {} undici-types@7.16.0: {} @@ -22206,7 +22214,7 @@ snapshots: universalify@2.0.1: {} - uniwind@1.11.0(patch_hash=a91f3953483cfd434436de6358ff5b63c49974ef148fad91246c26ba95332625)(@expo/metro-config@58.0.6(patch_hash=90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760)(bufferutil@4.1.0)(expo@58.0.2)(typescript@7.0.2)(utf-8-validate@6.0.6))(metro-cache@0.87.1)(metro-transform-worker@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(tailwindcss@4.3.3): + uniwind@1.12.1(@expo/metro-config@58.0.6(patch_hash=90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760)(bufferutil@4.1.0)(expo@58.0.2)(typescript@7.0.2)(utf-8-validate@6.0.6))(metro-cache@0.87.1)(metro-transform-worker@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(tailwindcss@4.3.3): dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5bd65fcacbbb..11f052624a48 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -125,6 +125,7 @@ minimumReleaseAgeExclude: - "@distilled.cloud/stripe@1.0.0-rc.12" - "@shadcn/lint@0.1.5" - cn@0.3.2 + - uniwind@1.12.1 - "@expo/dom-webview@58.0.2" - "expo-auth-session@58.0.7" - "expo-camera@58.0.7" @@ -263,7 +264,6 @@ patchedDependencies: react-native-keyboard-controller@1.22.6: patches/react-native-keyboard-controller@1.22.6.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch react-native-screens@4.28.0: patches/react-native-screens@4.28.0.patch - uniwind@1.11.0: patches/uniwind@1.11.0.patch peerDependencyRules: allowAny: