diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee12b02b4d47..f7d0c106593e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1348,6 +1348,24 @@ jobs: args: - --filter=@t3tools/scripts... + - name: Read published release notes + if: needs.preflight.outputs.is_prerelease == 'true' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ needs.preflight.outputs.tag }} + RELEASE_CHANNEL: ${{ needs.preflight.outputs.release_channel }} + run: | + if [ "$RELEASE_CHANNEL" = 'nightly' ]; then + if ! gh release view "$RELEASE_TAG" --json body --jq '.body // ""' > "$RUNNER_TEMP/discord-release-notes.md"; then + : > "$RUNNER_TEMP/discord-release-notes.md" + echo '::warning::Could not read published release notes; announcing without a changelog.' + fi + else + : > "$RUNNER_TEMP/discord-release-notes.md" + fi + - name: Announce prerelease on Discord if: needs.preflight.outputs.is_prerelease == 'true' continue-on-error: true @@ -1360,7 +1378,8 @@ jobs: --release-name "${{ needs.preflight.outputs.release_name }}" \ --release-version "${{ needs.preflight.outputs.version }}" \ --tag "${{ needs.preflight.outputs.tag }}" \ - --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" + --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" \ + --release-notes-file "$RUNNER_TEMP/discord-release-notes.md" - name: Announce latest release on Discord if: needs.preflight.outputs.make_latest == 'true' diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 365363881f5b..d98f61d6fc00 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"; @@ -179,6 +180,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; 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/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 4b43cd0eee96..a16535c4a5eb 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -70,6 +70,10 @@ import { } from "./methods/snapShot.ts"; import * as PreviewIpc from "./methods/preview.ts"; import * as AppActivationIpc from "./methods/appActivation.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* () { @@ -80,6 +84,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 151cd633d04e..098748dff124 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -119,3 +119,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 d1c32d5ae7cd..42a557bc84c8 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -8,6 +8,8 @@ for (const stream of [process.stdout, process.stderr]) { import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - Version output must flush before Electron exits, without acquiring the runtime. +import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -58,6 +60,7 @@ 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 DesktopUpdates from "./updates/DesktopUpdates.ts"; import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; @@ -69,6 +72,15 @@ import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; import * as DesktopWslServerTree from "./wsl/DesktopWslServerTree.ts"; +if (process.argv.includes("--version")) { + try { + NodeFS.writeSync(process.stdout.fd, `${Electron.app.getVersion()}\n`); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "EPIPE") throw error; + } + Electron.app.exit(0); +} + const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( @@ -129,6 +141,7 @@ const desktopFoundationLayer = Layer.mergeAll( MacPermissions.layer, DesktopState.layer, DesktopShutdown.layer, + DesktopLegacyLocalStorage.layer, DesktopAppSettings.layer, DesktopClientSettings.layer, DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index e84a5821e9ff..7d7269974bcd 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/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index 8fa5abfe2a8e..c0db129ee8c8 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -311,6 +311,12 @@ - (instancetype)initWithFrame:(CGRect)frame _textView.editable = false; _textView.textContainerInset = UIEdgeInsetsZero; _textView.textContainer.lineFragmentPadding = 0; + // The shadow node already sized this view to fit every line. A container + // that tracks the frame can come out a fraction of a point shorter after + // pixel rounding, and TextKit then drops the last line. Size it by hand + // with unbounded height and let the view's bounds clip. + _textView.textContainer.widthTracksTextView = NO; + _textView.textContainer.heightTracksTextView = NO; _textView.delegate = self; // Chat text supports selection and contextual actions, but not drag-and-drop. _textView.textDragInteraction.enabled = NO; @@ -477,6 +483,7 @@ - (void)drawRect:(CGRect)rect } if (frameChanged) { _textView.frame = _view.frame; + _textView.textContainer.size = CGSizeMake(CGRectGetWidth(_view.frame), CGFLOAT_MAX); } // Text attachments have no native link element. Expose their existing runs diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 75d1e852acf8..6ae50075093f 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -1,4 +1,5 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; +import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { imageMimeType } from "@t3tools/shared/image"; import { isWindowsAbsolutePath } from "@t3tools/shared/path"; @@ -109,7 +110,8 @@ export function nativeMarkdownContextCopyRanges( }>, ) { let offset = 0; - return runs.flatMap(({ run, text, inlineImageLength }) => { + const ranges: Array<{ start: number; end: number; text: string }> = []; + for (const [index, { run, text, inlineImageLength }] of runs.entries()) { const start = offset; offset += text.length + inlineImageLength; const reference = parseComposerContextHref(run.href ?? ""); @@ -117,11 +119,25 @@ export function nativeMarkdownContextCopyRanges( ? formatComposerContextReference({ ...reference, label: run.text }) : run.skillName ? `$${run.skillName}` - : run.fileIcon && run.href - ? (run.sourceText ?? `[${run.text}](<${run.href}>)`) - : null; - return source === null ? [] : [{ start, end: offset, text: source }]; - }); + : run.sourceText !== undefined + ? run.sourceText + : run.fileIcon && run.href + ? `[${run.text}](<${run.href}>)` + : null; + if (source === null) continue; + const previous = ranges.at(-1); + if ( + run.sourceText !== undefined && + !runs[index - 1]?.run.fileIcon && + previous?.end === start && + previous.text === source + ) { + previous.end = offset; + } else { + ranges.push({ start, end: offset, text: source }); + } + } + return ranges; } import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; @@ -181,6 +197,7 @@ interface RunContext { readonly href?: string; readonly externalHost?: string; readonly fileIcon?: MarkdownFileIcon; + readonly sourceText?: string; readonly role?: NativeMarkdownTextRun["role"]; readonly headingLevel?: number; readonly depth?: number; @@ -268,6 +285,7 @@ function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun) left.href === right.href && left.externalHost === right.externalHost && left.fileIcon === right.fileIcon && + left.sourceText === right.sourceText && left.skillName === right.skillName && left.skillLabel === right.skillLabel && left.role === right.role && @@ -298,6 +316,7 @@ function appendRun( ...(context.href ? { href: context.href } : {}), ...(context.externalHost ? { externalHost: context.externalHost } : {}), ...(context.fileIcon ? { fileIcon: context.fileIcon } : {}), + ...(context.sourceText !== undefined ? { sourceText: context.sourceText } : {}), ...(context.role ? { role: context.role } : {}), ...(context.headingLevel ? { headingLevel: context.headingLevel } : {}), ...(context.depth ? { depth: context.depth } : {}), @@ -432,6 +451,36 @@ function nodeTextContent(node: MarkdownNode): string { return (node.children ?? []).map(nodeTextContent).join(""); } +function fileLinkLabelMarkdown(node: MarkdownNode): string { + const children = (node.children ?? []).map(fileLinkLabelMarkdown).join(""); + switch (node.type) { + case "bold": + return `**${children}**`; + case "italic": + return `*${children}*`; + case "strikethrough": + return `~~${children}~~`; + case "code_inline": { + const content = nodeTextContent(node); + const fence = "`".repeat( + Math.max(0, ...(content.match(/`+/g) ?? []).map((run) => run.length)) + 1, + ); + const padding = /^`|`$|^ .* $/.test(content) && content.trim().length > 0 ? " " : ""; + return `${fence}${padding}${content}${padding}${fence}`; + } + case "soft_break": + return "\n"; + case "line_break": + return " \n"; + case "text": + return textNodeContent(nodeTextContent(node)).replace(/[\\[\]*_`~]/g, "\\$&"); + case "image": + return `![${(node.alt ?? "").replace(/[\\[\]]/g, "\\$&")}](<${node.href ?? ""}>)`; + default: + return node.content ?? children; + } +} + function appendNode( runs: NativeMarkdownTextRun[], node: MarkdownNode, @@ -482,6 +531,23 @@ function appendNode( } const presentation = resolveMarkdownLinkPresentation(node.href ?? ""); if (presentation.kind === "file") { + const label = textNodeContent(nodeTextContent(node)); + if (!isMarkdownFileLinkLabel(label, presentation.href)) { + const fileRuns: NativeMarkdownTextRun[] = []; + const fileContext = { + ...context, + href: presentation.href, + sourceText: `[${fileLinkLabelMarkdown(node)}](<${presentation.href}>)`, + }; + appendChildren(fileRuns, node, fileContext); + appendRun(fileRuns, " ", fileContext); + appendRun(fileRuns, presentation.label, { + ...fileContext, + fileIcon: presentation.icon, + }); + runs.push(...fileRuns); + return runs; + } return appendRun(runs, presentation.label, { ...context, href: presentation.href, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index df0d8aea4c52..602ffb66bace 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -127,7 +127,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/SourceControlIcon.tsx b/apps/mobile/src/components/SourceControlIcon.tsx index 873f3729a8a1..4c7fd9e05baa 100644 --- a/apps/mobile/src/components/SourceControlIcon.tsx +++ b/apps/mobile/src/components/SourceControlIcon.tsx @@ -67,29 +67,162 @@ export function SourceControlIcon(props: { ); case "azure-devops": + // Microsoft Azure DevOps mark via selfhst/icons (CC BY 4.0): https://github.com/selfhst/icons/blob/main/svg/azure-devops.svg return ( - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + ); diff --git a/apps/mobile/src/connection/onboarding.ts b/apps/mobile/src/connection/onboarding.ts index fdf1f527feb7..5e443dfac82d 100644 --- a/apps/mobile/src/connection/onboarding.ts +++ b/apps/mobile/src/connection/onboarding.ts @@ -13,10 +13,20 @@ const onboardingScheduler = createAtomCommandScheduler(); export const connectPairingUrl = createRuntimeCommand(connectionAtomRuntime, { label: "mobile:connection:connect-pairing-url", scheduler: onboardingScheduler, - concurrency: { mode: "singleFlight", key: (pairingUrl: string) => pairingUrl }, - execute: (pairingUrl: string) => + concurrency: { + mode: "singleFlight", + // Adding a route to a different machine with the same link is its own + // operation: it must check its own expected machine. + key: (input: { readonly pairingUrl: string; readonly expectedEnvironmentId?: EnvironmentId }) => + JSON.stringify([input.pairingUrl, input.expectedEnvironmentId ?? null]), + }, + execute: (input: { + readonly pairingUrl: string; + /** Set when adding a route to this saved machine. */ + readonly expectedEnvironmentId?: EnvironmentId; + }) => ConnectionOnboarding.ConnectionOnboarding.pipe( - Effect.flatMap((onboarding) => onboarding.registerPairing({ pairingUrl })), + Effect.flatMap((onboarding) => onboarding.registerPairing(input)), ), }); diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 5fd5665a0a64..96c0a08e43ee 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -27,6 +27,7 @@ import * as MobileStorage from "../persistence/mobile-storage"; import { appAtomRegistry } from "../state/atom-registry"; import { clearThreadOutboxEnvironment } from "../state/thread-outbox-removal"; import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; +import { clearThreadComposerErrorsForEnvironment } from "../state/thread-composer-error"; import { mobileApplicationActiveWakeup } from "./app-state-wakeups"; import { connectionStorageLayer } from "./storage"; import { presenceLayer } from "./user-input"; @@ -83,29 +84,75 @@ const connectivityLayer = Connectivity.layer({ ), }); +/** + * Wakes connections when the device moves between networks while staying + * online, such as Wi-Fi to cellular. Connectivity only reports online or + * offline, so leaving home on cellular would otherwise go unnoticed until the + * LAN socket times out. + */ +const networkPathChanges = Stream.callback<"network-changed">((queue) => + Effect.acquireRelease( + Effect.sync(() => { + let active = true; + let previous: Network.NetworkStateType | undefined; + const record = (state: Network.NetworkState) => { + const type = state.isConnected === true ? state.type : undefined; + if (previous !== undefined && type !== undefined && type !== previous) { + Queue.offerUnsafe(queue, "network-changed"); + } + previous = type ?? previous; + }; + // The listener reports changes only, so seed the current type; without + // it the first Wi-Fi to cellular move would go unnoticed. + void Network.getNetworkStateAsync() + .then((state) => { + if (active && previous === undefined && state.isConnected === true) { + previous = state.type; + } + }) + .catch(() => undefined); + const subscription = Network.addNetworkStateListener(record); + return { + remove: () => { + active = false; + subscription.remove(); + }, + }; + }), + (subscription) => Effect.sync(() => subscription.remove()), + ).pipe(Effect.asVoid), +); + const wakeupsLayer = Wakeups.layer({ - changes: Stream.merge( - Stream.callback<"application-active-probe" | "application-active-reconnect">((queue) => - Effect.acquireRelease( - Effect.sync(() => { - let backgroundedAtMs = AppState.currentState === "background" ? Date.now() : null; - return AppState.addEventListener("change", (state) => { - if (state === "background") { - backgroundedAtMs = Date.now(); - return; - } - if (state === "active") { - Queue.offerUnsafe(queue, mobileApplicationActiveWakeup(backgroundedAtMs, Date.now())); - backgroundedAtMs = null; - } - }); - }), - (subscription) => Effect.sync(() => subscription.remove()), - ).pipe(Effect.asVoid), - ), - managedRelayAccountChanges(appAtomRegistry).pipe( - Stream.map(() => "credentials-changed" as const), - ), + changes: Stream.mergeAll( + [ + Stream.callback<"application-active-probe" | "application-active-reconnect">((queue) => + Effect.acquireRelease( + Effect.sync(() => { + let backgroundedAtMs = AppState.currentState === "background" ? Date.now() : null; + return AppState.addEventListener("change", (state) => { + if (state === "background") { + backgroundedAtMs = Date.now(); + return; + } + if (state === "active") { + Queue.offerUnsafe( + queue, + mobileApplicationActiveWakeup(backgroundedAtMs, Date.now()), + ); + backgroundedAtMs = null; + } + }); + }), + (subscription) => Effect.sync(() => subscription.remove()), + ).pipe(Effect.asVoid), + ), + managedRelayAccountChanges(appAtomRegistry).pipe( + Stream.map(() => "credentials-changed" as const), + ), + networkPathChanges, + ], + { concurrency: "unbounded" }, ), }); @@ -219,6 +266,7 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( [ Effect.promise(() => clearThreadOutboxEnvironment(environmentId)), Effect.promise(() => clearComposerDraftsEnvironment(environmentId)), + Effect.sync(() => clearThreadComposerErrorsForEnvironment(environmentId)), ], { concurrency: "unbounded", discard: true }, ).pipe( diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index d2fbdd8cd9a9..6c1fd326a7b6 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -3,6 +3,7 @@ import { registerConnectionInCatalog, removeConnectionFromCatalog, setConnectionEnabledInCatalog, + setRoutesInCatalog, removeCatalogValue, replaceCatalogValue, Persistence, @@ -27,6 +28,7 @@ function targetPersistenceError( | "list-targets" | "list-disabled-targets" | "register-connection" + | "set-connection-routes" | "remove-connection" | "set-connection-enabled", error: ConnectionTransientError, @@ -57,13 +59,17 @@ export const connectionStorageLayer = Layer.effectContext( ), }); const registrationStore = Persistence.ConnectionRegistrationStore.of({ - register: (registration) => + register: (registration, routes) => catalog - .update((document) => registerConnectionInCatalog(document, registration)) + .update((document) => registerConnectionInCatalog(document, registration, routes)) .pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))), - remove: (target) => + setRoutes: (environmentId, routes) => catalog - .update((document) => removeConnectionFromCatalog(document, target)) + .update((document) => setRoutesInCatalog(document, environmentId, routes)) + .pipe(Effect.mapError((error) => targetPersistenceError("set-connection-routes", error))), + remove: (environmentId) => + catalog + .update((document) => removeConnectionFromCatalog(document, environmentId)) .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))), setEnabled: (environmentId, enabled) => catalog diff --git a/apps/mobile/src/features/cloud/cloud-drafts.ts b/apps/mobile/src/features/cloud/cloud-drafts.ts index 1cc94da52bb3..124aef277485 100644 --- a/apps/mobile/src/features/cloud/cloud-drafts.ts +++ b/apps/mobile/src/features/cloud/cloud-drafts.ts @@ -1,4 +1,4 @@ -import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; +import { EnvironmentRegistry, removedWithRelay } from "@t3tools/client-runtime/connection"; import { createRuntimeCommand } from "@t3tools/client-runtime/state/runtime"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -27,7 +27,8 @@ export const removeCloudEnvironments = createRuntimeCommand(connectionAtomRuntim const entries = yield* SubscriptionRef.get(registry.entries); const environmentIds = new Set( [...entries.values()] - .filter((entry) => entry.target._tag === "RelayConnectionTarget") + // Only environments that sign-out removes entirely lose their drafts. + .filter(removedWithRelay) .map((entry) => entry.target.environmentId), ); // Credentials are already revoked. A failed backup must leave the local diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 1cf2dfd6b69b..b46f5433ff60 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -6,6 +6,7 @@ import { useRoute, type StaticScreenProps, } from "@react-navigation/native"; +import type { EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Linking, Platform, View } from "react-native"; @@ -23,6 +24,8 @@ type ConnectionsNewRouteParams = { readonly mode?: string; readonly pairingUrl?: string; readonly autoConnect?: string; + /** Adds a route to this saved machine instead of a new environment. */ + readonly routeFor?: EnvironmentId; }; export function ConnectionsNewRouteScreen({ @@ -158,7 +161,7 @@ export function ConnectionsNewRouteScreen({ setIsSubmitting(true); onChangeConnectionPairingUrl(pairingUrl); try { - const result = await onConnectPress(pairingUrl); + const result = await onConnectPress(pairingUrl, params.routeFor); if (AsyncResult.isSuccess(result)) { if (replaceWithHome || !navigation.canGoBack()) { navigation.dispatch(StackActions.replace("Home")); @@ -170,7 +173,7 @@ export function ConnectionsNewRouteScreen({ setIsSubmitting(false); } }, - [navigation, onChangeConnectionPairingUrl, onConnectPress], + [navigation, onChangeConnectionPairingUrl, onConnectPress, params.routeFor], ); const handleSubmit = useCallback(async () => { diff --git a/apps/mobile/src/features/connection/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts index 3eff54259a03..1293e7893235 100644 --- a/apps/mobile/src/features/connection/environmentSections.test.ts +++ b/apps/mobile/src/features/connection/environmentSections.test.ts @@ -157,4 +157,21 @@ describe("mobile environment settings sections", () => { expect(sections.connectedCloudEnvironments).toEqual([cloud]); expect(sections.availableCloudEnvironments).toEqual([]); }); + + it("offers T3 Connect for a machine saved only over the LAN, as an added route", () => { + const local = connectedEnvironment({ + environmentId: "environment-desk", + isRelayManaged: false, + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [local], + cloudEnvironments: [cloudEnvironment("environment-desk")], + }); + + expect(sections.localEnvironments).toEqual([local]); + expect( + sections.availableCloudEnvironments.map((environment) => environment.environmentId), + ).toEqual([EnvironmentId.make("environment-desk")]); + }); }); diff --git a/apps/mobile/src/features/connection/useConnectionController.ts b/apps/mobile/src/features/connection/useConnectionController.ts index 78bf8a5a4702..6445a6ff71cb 100644 --- a/apps/mobile/src/features/connection/useConnectionController.ts +++ b/apps/mobile/src/features/connection/useConnectionController.ts @@ -69,7 +69,11 @@ export function useConnectionController() { ); const connectPairingUrl = useCallback( - (pairingUrl: string) => connectPairingUrlMutation(pairingUrl), + (pairingUrl: string, expectedEnvironmentId?: EnvironmentId) => + connectPairingUrlMutation({ + pairingUrl, + ...(expectedEnvironmentId === undefined ? {} : { expectedEnvironmentId }), + }), [connectPairingUrlMutation], ); const connectRelayEnvironment = useCallback( diff --git a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx new file mode 100644 index 000000000000..c95306c90132 --- /dev/null +++ b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx @@ -0,0 +1,310 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + type ConnectionRoute, + connectionRouteAddress, + connectionRouteId, + connectionRouteLabel, + connectionRoutes, + isLearned, +} from "@t3tools/client-runtime/connection"; +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 { Gesture, GestureDetector } from "react-native-gesture-handler"; +import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { environmentCatalog } from "../../connection/catalog"; +import { environmentSession } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { SettingsSection } from "./components/SettingsSection"; + +const ROW_HEIGHT = 64; + +/** + * The ways this device reaches an environment, preferred first. The first + * route that answers is used, and the connection moves back up the list when + * a better route is reachable again. Edit shows drag handles and remove + * buttons; Add route pairs this machine over another address. + */ +export function EnvironmentRoutesSection({ + environmentId, + connected, + onAddRoute, +}: { + readonly environmentId: EnvironmentId; + readonly connected: boolean; + readonly onAddRoute: () => void; +}) { + const entry = useAtomValue(environmentCatalog.catalogValueAtom).entries.get(environmentId); + const prepared = useAtomValue(environmentSession.preparedConnectionValueAtom(environmentId)); + const reorder = useAtomCommand(environmentCatalog.reorderRoutes, "route reorder"); + const removeRoute = useAtomCommand(environmentCatalog.removeRoute, "route removal"); + const [editing, setEditing] = useState(false); + const saved = entry === undefined ? [] : connectionRoutes(entry); + const savedIds = saved.map((route) => connectionRouteId(route.target)); + // A dropped order shows until the catalog matches it. + const [pending, setPending] = useState | null>(null); + const order = + pending !== null && + pending.length === savedIds.length && + pending.some((id, index) => id !== savedIds[index]) + ? pending + : savedIds; + const [drag, setDrag] = useState<{ readonly id: string; readonly translation: number } | null>( + null, + ); + if (entry === undefined) return null; + + const byId = new Map(saved.map((route) => [connectionRouteId(route.target), route])); + const routes = order.flatMap((id) => byId.get(id) ?? []); + const activeRouteId = + connected && Option.isSome(prepared) ? connectionRouteId(prepared.value.target) : null; + + const commit = (next: ReadonlyArray) => { + setPending(next); + void reorder({ environmentId, routeIds: next }).then((result) => { + if (result._tag === "Failure") setPending(null); + }); + }; + const move = (from: number, to: number) => { + if (to < 0 || to >= order.length || from === to) return; + const next = [...order]; + const [moved] = next.splice(from, 1); + 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))); + const confirmRemove = (route: ConnectionRoute) => + Alert.alert( + `Remove ${connectionRouteLabel(route)}?`, + connectionRouteAddress(route) ?? undefined, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: () => + void removeRoute({ environmentId, routeId: connectionRouteId(route.target) }), + }, + ], + ); + + const dragFrom = drag === null ? -1 : order.indexOf(drag.id); + const dragTo = drag === null ? -1 : dropIndex(dragFrom, drag.translation); + + return ( + 1 ? ( + setEditing((value) => !value)} + className="px-2 py-1 active:opacity-70" + > + + {editing ? "Done" : "Edit"} + + + ) : undefined + } + > + {routes.map((route, index) => { + const id = connectionRouteId(route.target); + // Rows between the lifted row and its drop slot shift to make room. + const shift = + dragFrom === -1 || index === dragFrom + ? 0 + : dragFrom < dragTo && index > dragFrom && index <= dragTo + ? -ROW_HEIGHT + : dragFrom > dragTo && index < dragFrom && index >= dragTo + ? ROW_HEIGHT + : 0; + return ( + setDrag({ id, translation: 0 })} + onDragMove={(translation) => setDrag({ id, translation })} + onDragEnd={(translation, cancelled) => { + setDrag(null); + if (!cancelled) move(index, dropIndex(index, translation)); + }} + onStep={(direction) => move(index, direction === "up" ? index - 1 : index + 1)} + // The last route goes with the machine, which is "Remove" on the row. + // A learned route would be learned again, so it is only reordered. + onRemove={ + routes.length > 1 && !isLearned(route) ? () => confirmRemove(route) : undefined + } + /> + ); + })} + + + Add route + + + ); +} + +function RouteRow(props: { + readonly route: ConnectionRoute; + readonly position: number; + readonly count: number; + readonly inUse: boolean; + readonly editing: boolean; + readonly offset: number; + readonly lifted: boolean; + readonly onDragStart: () => void; + readonly onDragMove: (translation: number) => void; + readonly onDragEnd: (translation: number, cancelled: boolean) => void; + readonly onStep: (direction: "up" | "down") => void; + readonly onRemove: (() => void) | undefined; +}) { + const { route, lifted, offset } = props; + const label = connectionRouteLabel(route); + const address = connectionRouteAddress(route); + const style = useAnimatedStyle(() => ({ + transform: [ + { + translateY: lifted + ? offset + : withTiming(offset, { duration: 160, reduceMotion: ReduceMotion.System }), + }, + ], + zIndex: lifted ? 1 : 0, + })); + return ( + + {props.editing && props.onRemove ? ( + + + + ) : null} + part !== null) + .join(", ")} + className="min-w-0 flex-1 gap-0.5 px-4" + > + + {label} + {props.inUse ? ( + In use + ) : null} + + {address !== null ? ( + + {isLearned(route) ? `${address} · found automatically` : address} + + ) : null} + + {props.editing ? ( + 1} + canMoveDown={props.position < props.count} + onStart={props.onDragStart} + onMove={props.onDragMove} + onEnd={props.onDragEnd} + onStep={props.onStep} + /> + ) : null} + + ); +} + +/** Pan recognition wins over the settings scroll view only inside the handle. */ +function DragHandle(props: { + readonly title: string; + readonly canMoveUp: boolean; + readonly canMoveDown: boolean; + readonly onStart: () => void; + readonly onMove: (translation: number) => void; + readonly onEnd: (translation: number, cancelled: boolean) => void; + readonly onStep: (direction: "up" | "down") => void; +}) { + const latest = useRef(props); + useEffect(() => { + latest.current = props; + }); + const translation = useRef(0); + const gesture = useMemo( + () => + Gesture.Pan() + .minDistance(0) + .shouldCancelWhenOutside(false) + .runOnJS(true) + .onStart(() => { + translation.current = 0; + latest.current.onStart(); + }) + .onUpdate((event) => { + translation.current = event.translationY; + latest.current.onMove(event.translationY); + }) + .onFinalize((_, success) => latest.current.onEnd(translation.current, !success)), + [], + ); + return ( + + { + if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up"); + if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down"); + }} + style={{ width: 48, height: ROW_HEIGHT, alignItems: "center", justifyContent: "center" }} + > + + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx index be334da900ab..1e8e6c73ff0f 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import type { StaticScreenProps } from "@react-navigation/native"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import type { EnvironmentId, ServerProvider } from "@t3tools/contracts"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -15,6 +15,7 @@ import { environmentSession } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; +import { EnvironmentRoutesSection } from "./EnvironmentRoutesSection"; import { SettingsActionRow } from "./components/SettingsActionRow"; import { SettingsScreen } from "./components/SettingsScreen"; import { SettingsSection } from "./components/SettingsSection"; @@ -41,6 +42,7 @@ export function SettingsEnvironmentDetailRouteScreen({ function EnvironmentDetail({ environmentId }: { readonly environmentId: EnvironmentId }) { const insets = useSafeAreaInsets(); + const navigation = useNavigation(); const connections = useRemoteConnections(); const environment = connections.connectedEnvironments.find( (entry) => entry.environmentId === environmentId, @@ -167,6 +169,19 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm onUpdate={connections.onUpdateEnvironment} /> + + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { + screen: "SettingsEnvironmentNew", + params: { routeFor: environmentId }, + }, + }) + } + /> {!connected ? ( Connect this environment to manage it. diff --git a/apps/mobile/src/features/threads/ComposerErrorNotice.tsx b/apps/mobile/src/features/threads/ComposerErrorNotice.tsx new file mode 100644 index 000000000000..37ea6078f2ab --- /dev/null +++ b/apps/mobile/src/features/threads/ComposerErrorNotice.tsx @@ -0,0 +1,55 @@ +import { useEffect } from "react"; +import { AccessibilityInfo, Platform, Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; + +/** Why the thread's last message did not send, above the composer until dismissed. */ +export function ComposerErrorNotice({ + message, + onDismiss, +}: { + readonly message: string; + readonly onDismiss: () => void; +}) { + // accessibilityLiveRegion below only reaches TalkBack; VoiceOver needs an + // explicit announcement. + useEffect(() => { + if (Platform.OS === "ios") { + AccessibilityInfo.announceForAccessibility(message); + } + }, [message]); + return ( + + + + + {message} + + + + + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 0e5ddf41e48a..0294726131d2 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { useThreadReportedModelSelection } from "../../state/entities"; import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; import { useNavigation } from "@react-navigation/native"; @@ -93,6 +94,10 @@ import { useEnvironmentQuery } from "../../state/query"; import { threadDevicePreviews } from "../devices/threadDevicePreviews"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { + clearThreadComposerError, + threadComposerErrorsAtom, +} from "../../state/thread-composer-error"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { useDelayedStatus } from "../../lib/useDelayedStatus"; @@ -104,6 +109,7 @@ import type { ThreadFeedLatestRun, } from "../../lib/threadActivity"; import { PendingApprovalCard } from "./PendingApprovalCard"; +import { ComposerErrorNotice } from "./ComposerErrorNotice"; import { ComposerFeedback } from "./ComposerFeedback"; import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; @@ -364,6 +370,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44; const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null; const queuedCount = useThreadQueuedCount({ environmentId: props.environmentId, threadId: props.selectedThread.id, @@ -1196,6 +1203,18 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onDismiss={() => props.onDismissFeedback(submission.id)} /> ))} + {composerError !== null ? ( + + clearThreadComposerError(selectedThreadKey)} + /> + + ) : null} {usageLimitsReport && activeUserInputRequestId === null ? ( (); -const MarkdownLinkLabelContext = createContext(false); +const MarkdownLinkLabelContext = createContext<"file" | "other" | null>(null); const markdownLinkStyles = StyleSheet.create({ inlineIcon: { width: 14, @@ -813,6 +815,22 @@ function MarkdownInlineCode(props: { ); } +function MarkdownImage(props: { + readonly node: MarkdownNode; + readonly renderImage: MarkdownImageRenderer; +}) { + const insideLink = useContext(MarkdownLinkLabelContext); + if (insideLink === "file") + return {props.node.alt ?? props.node.title ?? ""}; + return props.node.href + ? props.renderImage({ + href: props.node.href, + alt: props.node.alt ?? null, + title: props.node.title ?? null, + }) + : null; +} + const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record< CodexArtifactTemplate["artifactKind"], AppSymbolName @@ -1183,26 +1201,31 @@ function useMarkdownStyles( preserveSoftBreaks: boolean, highlightCode: boolean, ): CustomRenderers => ({ - link: ({ children, href = "" }) => { + link: ({ children, node, href = "" }) => { const presentation = resolveMarkdownLinkPresentation(href); if (presentation.kind === "file") { return ( - onLinkPress(href)} - style={{ color: inlineTextColor }} - > - - {presentation.label} - + + onLinkPress(href)} style={{ color: inlineTextColor }}> + {!isMarkdownFileLinkLabel(getTextContent(node), href) && <>{children} } + onLinkPress(href)} + style={{ color: inlineTextColor }} + > + + {presentation.label} + + + ); } if (presentation.kind === "external") { return ( - + + ), - image: ({ node }) => - node.href - ? (renderImage({ - href: node.href, - alt: node.alt ?? null, - title: node.title ?? null, - }) ?? undefined) - : undefined, + image: ({ node }) => , code_inline: ({ content }) => ( { + // Push, not navigate: navigate reuses this Thread route, so back + // would skip the parent thread. if (threadId !== null) - navigation.navigate("Thread", { - environmentId: String(props.environmentId), - threadId: String(threadId), - }); + navigation.dispatch( + StackActions.push("Thread", { + environmentId: String(props.environmentId), + threadId: String(threadId), + }), + ); }} className="rounded-lg px-3 py-3 active:bg-subtle" > diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 57287c81eda1..dd2ebeb76ff6 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -60,9 +60,12 @@ import { cn } from "../../lib/cn"; import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; import { type AgentSpawnSummary, + formatItemFullDetail, type ThreadFeedActivity, workEntryRowLabel, } from "../../lib/threadActivity"; +import { toolCallLines, turnItemOutputText } from "@t3tools/client-runtime/work-log/item-detail"; +import { useTurnItemDetail } from "../../state/queries"; import { resolveThreadWorkGroupInitialScroll, shouldFollowThreadWorkGroupAppend, @@ -70,6 +73,7 @@ import { } from "./thread-feed-live-follow"; import { resolveWorkEntryToolPresentation, + toolGroupAction, type ToolGroupSummaryKind, workEntryViewedImagePath, } from "@t3tools/client-runtime/work-log/presentation"; @@ -836,6 +840,11 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( ) { const { row, expanded } = props; const navigation = useNavigation(); + const fetchedDetail = useTurnItemDetail( + expanded && row.fetchesDetail + ? { environmentId: props.environmentId, row: row.projectedItem } + : null, + ); const failureItem = row.projectedItem.item; if (failureItem.type === "error" && failureItem.status === "failed") { const warning = failureItem.failure.class === "usage_limit"; @@ -917,7 +926,44 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( : undefined; const canExpand = row.canExpand && notifiedSubagentThreadId === undefined; const reasoning = row.projectedItem.item.type === "reasoning" ? row.projectedItem.item : null; - const fullDetail = expanded && !reasoning ? row.getFullDetail() : null; + const fetchedItem = fetchedDetail.data?.item ?? null; + // Reads keep their path list; the fetched file contents show as output. + const isRead = toolGroupAction(row.workEntry) === "read"; + // Tool calls show the call in the foreground and the result muted below it. + const shownItem = fetchedItem ?? row.projectedItem.item; + const call = + expanded && !isRead && shownItem.type === "command_execution" + ? toolCallLines({ command: shownItem.input }) + : expanded && !isRead && shownItem.type === "dynamic_tool" + ? toolCallLines({ args: shownItem.input }) + : expanded && shownItem.type === "file_search" + ? toolCallLines({ args: { pattern: shownItem.pattern } }) + : expanded && shownItem.type === "web_search" + ? toolCallLines({ args: { query: shownItem.patterns?.join(", ") } }) + : null; + const failedExitCode = + call && shownItem.type === "command_execution" && shownItem.exitCode + ? shownItem.exitCode + : null; + const fullDetail = + expanded && !reasoning && !call + ? fetchedItem && !isRead + ? formatItemFullDetail(row.projectedItem, fetchedItem) + : row.getFullDetail() + : null; + const fetchedOutput = !expanded + ? null + : shownItem.type === "file_search" || shownItem.type === "web_search" + ? turnItemOutputText(shownItem) + : fetchedItem + ? (turnItemOutputText(fetchedItem) ?? "No output.") + : fetchedDetail.error + ? `Couldn't load output: ${fetchedDetail.error}` + : row.fetchesDetail + ? fetchedDetail.data + ? "Output is no longer available." + : "Loading output…" + : null; const viewedImagePath = workEntryViewedImagePath(row.workEntry); const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); const previewText = workEntryRowLabel(row.workEntry); @@ -1060,7 +1106,13 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( - {expanded && (reasoning || fullDetail || viewedImagePath || row.workEntry.questionAnswer) ? ( + {expanded && + (reasoning || + fullDetail || + call || + fetchedOutput || + viewedImagePath || + row.workEntry.questionAnswer) ? ( {reasoning ? ( props.renderReasoning(reasoning.text) - ) : ( + ) : call ? ( + [ + call.command, + ...(call.args ?? []).map(([key, value]) => `${key} ${value}`), + call.argsText, + ] + .filter((line): line is string => Boolean(line)) + .map((line, index) => ( + + {line} + + )) + ) : fullDetail ? ( {fullDetail} - )} + ) : null} + {fetchedOutput ? ( + + {fetchedOutput} + + ) : null} + {failedExitCode !== null ? ( + + exit {failedExitCode} + + ) : null} ) : null} diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 3b748f2a99d0..ecf4cd8b4191 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -1,5 +1,14 @@ -import { describe, expect, it, vi } from "vite-plus/test"; -import { ProviderDriverKind } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +const refreshProviders = vi.hoisted(() => vi.fn()); vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); vi.mock("../../state/queries", () => ({ @@ -8,6 +17,7 @@ vi.mock("../../state/queries", () => ({ })); vi.mock("../../state/use-composer-drafts", () => ({ getComposerDraftSnapshot: vi.fn(), + readComposerDraftSelection: vi.fn(), setComposerDraftContext: vi.fn(), })); vi.mock("../../lib/uuid", () => ({ uuidv4: () => "context-id" })); @@ -15,12 +25,13 @@ vi.mock("../../state/server", () => ({ serverEnvironment: { refreshProviders: Symbol("refreshProviders") }, })); vi.mock("../../state/use-atom-command", () => ({ - useAtomCommand: () => vi.fn(), + useAtomCommand: () => refreshProviders, })); import { buildComposerSlashCommandItems, resolveComposerCommandSelection, + useComposerCommandMenu, } from "./use-composer-command-menu"; describe("mobile slash commands", () => { @@ -101,3 +112,214 @@ describe("mobile slash commands", () => { ).toEqual({ text: "/plan ", cursor: 6, interactionMode: null }); }); }); + +describe("workspace command discovery retry", () => { + let root: Root; + const environmentId = EnvironmentId.make("test-environment"); + const instanceId = ProviderInstanceId.make("claude"); + const provider = { + instanceId, + driver: ProviderDriverKind.make("claude"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + workspaceSnapshots: [ + { + cwd: "/project-a", + checkedAt: "2026-01-01T00:00:00.000Z", + slashCommandsPending: true, + slashCommands: [{ name: "compact" }], + skills: [], + }, + ], + } satisfies ServerProvider; + + function Probe({ cwd, status = provider }: { cwd: string; status?: ServerProvider }) { + useComposerCommandMenu({ + draftMessage: "/project", + ownerKey: null, + environmentId, + projectCwd: cwd, + selectedProviderStatus: status, + provisionedSkills: undefined, + hasThread: false, + hasCompactableConversation: false, + onChangeDraftMessage: () => {}, + }); + return null; + } + + beforeEach(() => { + vi.useFakeTimers(); + refreshProviders.mockReset(); + refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [provider] } }); + const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("retries partial commands after the cooldown without editing the draft", async () => { + const recovered = { + ...provider, + workspaceSnapshots: [{ ...provider.workspaceSnapshots[0], slashCommandsPending: false }], + }; + refreshProviders.mockResolvedValueOnce({ _tag: "Success", value: { providers: [provider] } }); + refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [recovered] } }); + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a" })); + }); + expect(refreshProviders).toHaveBeenCalledTimes(1); + await act(() => vi.advanceTimersByTimeAsync(9_999)); + expect(refreshProviders).toHaveBeenCalledTimes(1); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(refreshProviders).toHaveBeenCalledTimes(2); + expect(refreshProviders).toHaveBeenLastCalledWith({ + environmentId, + input: { instanceId, cwd: "/project-a" }, + }); + await act(() => vi.advanceTimersByTimeAsync(20_000)); + expect(refreshProviders).toHaveBeenCalledTimes(2); + }); + + it.each(["pi", "acpRegistry"])( + "does not poll a healthy %s workspace without discovery", + async (driver) => { + const unsupported = { + ...provider, + driver: ProviderDriverKind.make(driver), + workspaceSnapshots: [], + }; + refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [unsupported] } }); + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a", status: unsupported })); + }); + expect(refreshProviders).toHaveBeenCalledTimes(1); + await act(() => vi.advanceTimersByTimeAsync(30_000)); + expect(refreshProviders).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }, + ); + + it("arms the remaining cooldown when a concurrent scan publishes partial commands", async () => { + const missing = { ...provider, workspaceSnapshots: [] }; + const recovered = { + ...provider, + workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({ + ...snapshot, + slashCommandsPending: false, + })), + }; + refreshProviders.mockResolvedValueOnce({ _tag: "Success", value: { providers: [missing] } }); + refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [recovered] } }); + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a", status: missing })); + }); + await act(() => vi.advanceTimersByTimeAsync(5_000)); + expect(refreshProviders).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a", status: provider })); + }); + await act(() => vi.advanceTimersByTimeAsync(4_999)); + expect(refreshProviders).toHaveBeenCalledTimes(1); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(refreshProviders).toHaveBeenCalledTimes(2); + await act(() => vi.advanceTimersByTimeAsync(20_000)); + expect(refreshProviders).toHaveBeenCalledTimes(2); + }); + + it("does not retry when a concurrent scan publishes a complete workspace", async () => { + const missing = { ...provider, workspaceSnapshots: [] }; + const recovered = { + ...provider, + workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({ + ...snapshot, + slashCommandsPending: false, + })), + }; + refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [missing] } }); + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a", status: missing })); + }); + await act(() => vi.advanceTimersByTimeAsync(5_000)); + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a", status: recovered })); + }); + await act(() => vi.advanceTimersByTimeAsync(30_000)); + expect(refreshProviders).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it("retries a rejected refresh while published discovery remains pending", async () => { + const recovered = { + ...provider, + workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({ + ...snapshot, + slashCommandsPending: false, + })), + }; + refreshProviders.mockRejectedValueOnce(new Error("Connection lost")); + refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [recovered] } }); + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a" })); + }); + await act(() => vi.advanceTimersByTimeAsync(10_000)); + expect(refreshProviders).toHaveBeenCalledTimes(2); + await act(() => vi.advanceTimersByTimeAsync(20_000)); + expect(refreshProviders).toHaveBeenCalledTimes(2); + }); + + it("cancels the old workspace retry and does not duplicate an in-flight request", async () => { + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a" })); + }); + refreshProviders.mockImplementationOnce(() => new Promise(() => {})); + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-b" })); + }); + expect(refreshProviders).toHaveBeenCalledTimes(2); + await act(() => vi.advanceTimersByTimeAsync(20_000)); + expect(refreshProviders).toHaveBeenCalledTimes(2); + expect(refreshProviders).toHaveBeenLastCalledWith({ + environmentId, + input: { instanceId, cwd: "/project-b" }, + }); + expect(vi.getTimerCount()).toBe(0); + }); + + it("cancels a scheduled retry when the composer unmounts", async () => { + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a" })); + }); + await act(async () => { + root.unmount(); + }); + await act(() => vi.advanceTimersByTimeAsync(20_000)); + expect(refreshProviders).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 46f7bd17ee04..83e961b6d723 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -37,6 +37,7 @@ import { getProviderSkillsForSlashMenu, getProviderSlashCommandsForSlashMenu, isProviderSkillUserInvocable, + hasCompleteProviderWorkspaceSnapshot, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, } from "@t3tools/client-runtime/providerSkills"; @@ -251,17 +252,43 @@ export function useComposerCommandMenu({ reportFailure: false, }); const selectedProviderInstanceId = selectedProviderStatus?.instanceId; - const hasWorkspaceSnapshot = Boolean( - projectCwd && - selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd), + const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot( + selectedProviderStatus, + projectCwd, ); const workspaceRefreshKeyRef = useRef(null); - const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null); + const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ + key: string; + notBefore: number; + } | null>(null); + const workspaceRefreshScopeKey = + environmentId && projectCwd && selectedProviderInstanceId + ? `${environmentId}:${selectedProviderInstanceId}:${projectCwd}` + : null; + const workspaceSlashCommandsPending = + selectedProviderStatus?.workspaceSnapshots?.some( + (snapshot) => snapshot.cwd === projectCwd && snapshot.slashCommandsPending === true, + ) ?? false; + useEffect(() => { + if ( + !workspaceSlashCommandsPending || + !workspaceRefreshRetry || + workspaceRefreshRetry.key !== workspaceRefreshScopeKey + ) + return; + const timeout = setTimeout( + () => { + setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current)); + }, + Math.max(0, workspaceRefreshRetry.notBefore - Date.now()), + ); + return () => clearTimeout(timeout); + }, [workspaceRefreshRetry, workspaceRefreshScopeKey, workspaceSlashCommandsPending]); const hadWorkspaceSnapshotRef = useRef(false); useEffect(() => { if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { workspaceRefreshKeyRef.current = null; - workspaceRefreshRetryRef.current = null; + setWorkspaceRefreshRetry(null); } hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; }, [hasWorkspaceSnapshot]); @@ -271,19 +298,19 @@ export function useComposerCommandMenu({ if (workspaceRefreshKeyRef.current === key) return; if (hasWorkspaceSnapshot) { workspaceRefreshKeyRef.current = key; - workspaceRefreshRetryRef.current = null; + setWorkspaceRefreshRetry(null); return; } - const retry = workspaceRefreshRetryRef.current; + const retry = workspaceRefreshRetry; if (retry?.key === key && Date.now() < retry.notBefore) return; workspaceRefreshKeyRef.current = key; const retryLater = () => { if (workspaceRefreshKeyRef.current !== key) return; workspaceRefreshKeyRef.current = null; - workspaceRefreshRetryRef.current = { + setWorkspaceRefreshRetry({ key, notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, - }; + }); }; void refreshProviders({ environmentId, @@ -291,9 +318,12 @@ export function useComposerCommandMenu({ }).then((result) => { const refreshed = result._tag === "Success" && - result.value.providers - .find((provider) => provider.instanceId === selectedProviderInstanceId) - ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd); + hasCompleteProviderWorkspaceSnapshot( + result.value.providers.find( + (provider) => provider.instanceId === selectedProviderInstanceId, + ), + projectCwd, + ); if (!refreshed && workspaceRefreshKeyRef.current === key) { retryLater(); } @@ -305,6 +335,7 @@ export function useComposerCommandMenu({ projectCwd, refreshProviders, selectedProviderInstanceId, + workspaceRefreshRetry, ]); const trigger = useMemo(() => { diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index fca30deea89a..0a4357ee1240 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -138,7 +138,7 @@ describe("nativeMarkdownTextRuns", () => { { type: "link", href: "file:///repo/README.md#L12", - children: [{ type: "text", content: "ignored label" }], + children: [{ type: "text", content: "validates the input" }], }, ], }; @@ -150,14 +150,116 @@ describe("nativeMarkdownTextRuns", () => { externalHost: "example.com", }, { text: " " }, + { + text: "validates the input ", + href: "file:///repo/README.md#L12", + sourceText: "[validates the input]()", + }, { text: "README.md:12", href: "file:///repo/README.md#L12", fileIcon: "markdown", + sourceText: "[validates the input]()", + }, + ]); + }); + + it.each([true, false])("copies descriptive file links with collapsed chips=%s", (collapsed) => { + const link: MarkdownNode = { + type: "link", + href: "/repo/src/example.ts:12", + children: [ + { type: "text", content: "validates " }, + { type: "bold", children: [{ type: "text", content: "the input" }] }, + ], + }; + const runs = nativeMarkdownTextRuns({ type: "paragraph", children: [link, link] }); + expect(runs.map((run) => run.text).join("")).toBe( + "validates the input example.ts:12validates the input example.ts:12", + ); + expect(runs.filter((run) => run.bold).map((run) => run.text)).toEqual([ + "the input", + "the input", + ]); + const ranges = nativeMarkdownContextCopyRanges( + runs.map((run) => ({ + run, + text: collapsed && run.fileIcon ? "" : run.text, + inlineImageLength: run.fileIcon ? 1 : 0, + })), + ); + const length = "validates the input ".length + (collapsed ? 1 : "example.ts:12".length + 1); + expect(ranges).toEqual([ + { start: 0, end: length, text: "[validates **the input**]()" }, + { + start: length, + end: length * 2, + text: "[validates **the input**]()", }, ]); }); + it("keeps filename-labelled links as chips", () => { + expect( + nativeMarkdownTextRuns({ + type: "paragraph", + children: [ + { + type: "link", + href: "/repo/src/example.ts:12", + children: [{ type: "code_inline", content: "src/example.ts:12" }], + }, + ], + }), + ).toEqual([{ text: "example.ts:12", href: "/repo/src/example.ts:12", fileIcon: "typescript" }]); + }); + + it.each([ + [{ type: "italic", children: [{ type: "text", content: "details" }] }, "*details*"], + [{ type: "strikethrough", children: [{ type: "text", content: "details" }] }, "~~details~~"], + [{ type: "code_inline", content: "`details`" }, "`` `details` ``"], + [{ type: "code_inline", content: " details " }, "` details `"], + [{ type: "text", content: "[details] *literal*" }, "\\[details\\] \\*literal\\*"], + [{ type: "soft_break" }, "\n"], + [{ type: "line_break" }, " \n"], + [ + { type: "image", alt: "details", href: "https://example.com/icon.png" }, + "![details]()", + ], + ] satisfies ReadonlyArray)( + "keeps descriptive label markup when copying %j", + (child, source) => { + const runs = nativeMarkdownTextRuns({ + type: "paragraph", + children: [ + { + type: "link", + href: "/repo/a.ts", + children: [{ type: "text", content: "see " }, child], + }, + ], + }); + expect( + nativeMarkdownContextCopyRanges( + runs.map((run) => ({ + run, + text: run.fileIcon ? "" : run.text, + inlineImageLength: run.fileIcon ? 1 : 0, + })), + ), + ).toEqual([ + { + start: 0, + end: + runs + .filter((run) => !run.fileIcon) + .reduce((length, run) => length + run.text.length, 0) + 1, + text: `[see ${source}]()`, + }, + ]); + }, + ); + it("keeps hard breaks and collapses soft breaks", () => { const node: MarkdownNode = { type: "paragraph", diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 16fd407f537b..5471f24704a9 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -271,6 +271,35 @@ describe("buildThreadFeed", () => { expect(items[0]).toMatchObject({ output: rawOutput }); }); + it("expands tool rows only when they have detail or withheld output", () => { + const items: OrchestrationV2TurnItem[] = [ + { ...command(), input: "", outputOmitted: true }, + { + ...base("dynamic-empty", "2026-06-20T00:00:03.000Z", 2), + type: "dynamic_tool", + toolName: "example", + input: {}, + }, + { + ...base("read-omitted", "2026-06-20T00:00:04.000Z", 3), + type: "dynamic_tool", + toolName: "Read", + input: { path: "src/env.ts" }, + outputOmitted: true, + }, + ]; + const activities = buildThreadFeed(items.map((item, index) => projected(item, index))).flatMap( + (entry) => (entry.type === "activity-group" ? entry.activities : []), + ); + expect( + activities.map(({ canExpand, fetchesDetail }) => ({ canExpand, fetchesDetail })), + ).toEqual([ + { canExpand: true, fetchesDetail: true }, + { canExpand: false, fetchesDetail: false }, + { canExpand: true, fetchesDetail: true }, + ]); + }); + it("recognizes automation attribution after projecting a user message", () => { const feed = buildThreadFeed([ projected( @@ -1236,6 +1265,7 @@ describe("buildThreadFeed", () => { summary: `Tool ${id}`, detail: null, canExpand: false, + fetchesDetail: false, getFullDetail: () => null, getCopyText: () => id, icon: "command", diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 3a77324a7ac8..a77491795704 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -6,6 +6,10 @@ import type { import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; +import { + turnItemHasDetail, + turnItemNeedsDetailFetch, +} from "@t3tools/client-runtime/work-log/item-detail"; import { commandDisplayText, commandProgramName, @@ -74,6 +78,8 @@ export interface ThreadFeedActivity { readonly summary: string; readonly detail: string | null; readonly canExpand: boolean; + /** Expanding fetches the withheld input and output with getTurnItem. */ + readonly fetchesDetail: boolean; readonly getFullDetail: () => string | null; readonly getCopyText: () => string; readonly icon: @@ -721,6 +727,23 @@ function toWorkLogEntry( } } +/** Expanded detail for a row, from its wire item or the full item from getTurnItem. */ +export function formatItemFullDetail( + row: OrchestrationV2ProjectedTurnItem, + item: OrchestrationV2TurnItem, +): string { + return JSON.stringify( + { + visibility: row.visibility, + sourceThreadId: row.sourceThreadId, + sourceItemId: row.sourceItemId, + item: toolItemForDisplay(item), + }, + null, + 2, + ); +} + function toFeedActivity( row: OrchestrationV2ProjectedTurnItem, attemptId: RunAttemptId | null, @@ -735,21 +758,9 @@ function toFeedActivity( item.type === "dynamic_tool" && toolGroupAction(workEntry) === "read" ? collectToolFilePaths(item) : null; - const getFullDetail = memoizeValue(() => { - if (readPaths) { - return readPaths.join("\n") || null; - } - return JSON.stringify( - { - visibility: row.visibility, - sourceThreadId: row.sourceThreadId, - sourceItemId: row.sourceItemId, - item: toolItemForDisplay(item), - }, - null, - 2, - ); - }); + const getFullDetail = memoizeValue(() => + readPaths ? readPaths.join("\n") || null : formatItemFullDetail(row, item), + ); const getCopyText = memoizeValue(() => [summary, detail, getFullDetail()] .filter( @@ -765,7 +776,13 @@ function toFeedActivity( attemptId, summary, detail, - canExpand: !(item.type === "error" && item.status === "failed") && (readPaths?.length ?? 1) > 0, + canExpand: + !(item.type === "error" && item.status === "failed") && + (readPaths + ? readPaths.length > 0 || turnItemNeedsDetailFetch(item) + : turnItemHasDetail(item) || workEntry.questionAnswer !== undefined), + // Read rows show their paths, then the fetched file contents. + fetchesDetail: turnItemNeedsDetailFetch(item), getFullDetail, getCopyText, icon: workEntry.toolSurface ?? itemIcon(item), 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/mobile/src/state/environments.ts b/apps/mobile/src/state/environments.ts index 88d80631ad31..776a615f46af 100644 --- a/apps/mobile/src/state/environments.ts +++ b/apps/mobile/src/state/environments.ts @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { connectionCatalogDisplayUrl, + hasRelayRoute, type EnvironmentPresentation as BaseEnvironmentPresentation, } from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; @@ -26,7 +27,7 @@ export function projectEnvironmentPresentation( environmentId, label: presentation.entry.target.label, displayUrl: connectionCatalogDisplayUrl(presentation.entry), - relayManaged: presentation.entry.target._tag === "RelayConnectionTarget", + relayManaged: hasRelayRoute(presentation.entry), }; } diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index d9cce63b65ac..db8269ff130b 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -2,6 +2,7 @@ import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRe import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, + OrchestrationV2ProjectedTurnItem, ProjectId, ThreadId, VcsListRefsResult, @@ -14,6 +15,7 @@ import { } from "@t3tools/client-runtime/state/thread-search"; import { useAtomValue } from "@effect/atom-react"; import * as Cause from "effect/Cause"; +import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -351,3 +353,24 @@ export function useCheckpointDiff(target: CheckpointDiffTarget) { ); return targets.fullThread === null ? turn : fullThread; } + +/** Full input and output for one tool row; pass null to skip fetching. */ +export function useTurnItemDetail( + target: { + readonly environmentId: EnvironmentId; + readonly row: OrchestrationV2ProjectedTurnItem; + } | null, +) { + return useEnvironmentQuery( + target === null + ? null + : orchestrationEnvironment.turnItem({ + environmentId: target.environmentId, + input: { + threadId: target.row.sourceThreadId, + itemId: target.row.sourceItemId, + revision: turnItemDetailRevision(target.row.item), + }, + }), + ); +} diff --git a/apps/mobile/src/state/remote-environment-projections.ts b/apps/mobile/src/state/remote-environment-projections.ts index b1315c299d66..48b9649f5475 100644 --- a/apps/mobile/src/state/remote-environment-projections.ts +++ b/apps/mobile/src/state/remote-environment-projections.ts @@ -52,7 +52,10 @@ export function createRemoteEnvironmentProjectionAtoms(input: { : displayUrl.replace(/^http:/, "ws:") : new URL(socketUrl).origin; const authorization = prepared?.httpAuthorization ?? null; - const relayManaged = presentation.entry.target._tag === "RelayConnectionTarget"; + // Credentials follow the route in use, which may not be the preferred one. + const relayManaged = prepared + ? prepared.target._tag === "RelayConnectionTarget" + : presentation.entry.target._tag === "RelayConnectionTarget"; previousEntry = presentation.entry; previousPrepared = prepared; diff --git a/apps/mobile/src/state/thread-composer-error.test.ts b/apps/mobile/src/state/thread-composer-error.test.ts new file mode 100644 index 000000000000..7c3ef653ebba --- /dev/null +++ b/apps/mobile/src/state/thread-composer-error.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { appAtomRegistry } from "./atom-registry"; +import { + clearThreadComposerError, + clearThreadComposerErrorsForEnvironment, + setThreadComposerError, + threadComposerErrorsAtom, +} from "./thread-composer-error"; + +afterEach(() => { + appAtomRegistry.set(threadComposerErrorsAtom, {}); +}); + +describe("thread composer errors", () => { + it("clears a message-scoped error only for that message", () => { + setThreadComposerError("environment-1:thread-1", "rejected", "message-1"); + + clearThreadComposerError("environment-1:thread-1", "message-2"); + expect(appAtomRegistry.get(threadComposerErrorsAtom)["environment-1:thread-1"]?.message).toBe( + "rejected", + ); + + clearThreadComposerError("environment-1:thread-1"); + expect(appAtomRegistry.get(threadComposerErrorsAtom)).toEqual({}); + }); + + it("clears every thread's error for a removed environment and no others", () => { + setThreadComposerError("environment-1:thread-1", "one"); + setThreadComposerError("environment-1:thread-2", "two"); + setThreadComposerError("environment-10:thread-1", "other environment"); + + clearThreadComposerErrorsForEnvironment("environment-1"); + + expect(Object.keys(appAtomRegistry.get(threadComposerErrorsAtom))).toEqual([ + "environment-10:thread-1", + ]); + }); +}); diff --git a/apps/mobile/src/state/thread-composer-error.ts b/apps/mobile/src/state/thread-composer-error.ts new file mode 100644 index 000000000000..bf51139ede97 --- /dev/null +++ b/apps/mobile/src/state/thread-composer-error.ts @@ -0,0 +1,56 @@ +import { Atom } from "effect/unstable/reactivity"; + +import { appAtomRegistry } from "./atom-registry"; + +interface ThreadComposerError { + readonly message: string; + /** The queued message this error is about, when it is about one. */ + readonly messageId: string | null; +} + +/** + * Why a thread's last message did not go out, shown above that thread's + * composer. The outbox drain can reject a message after the user has left the + * thread, so the reason is kept per thread until they dismiss it, send again, + * or the message it describes is delivered after all. Keyed by `scopedThreadKey`. + */ +export const threadComposerErrorsAtom = Atom.make>>( + {}, +).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer-errors")); + +export function setThreadComposerError( + threadKey: string, + message: string, + messageId: string | null = null, +): void { + appAtomRegistry.set(threadComposerErrorsAtom, { + ...appAtomRegistry.get(threadComposerErrorsAtom), + [threadKey]: { message, messageId }, + }); +} + +/** With `messageId`, clears only an error that describes that message. */ +export function clearThreadComposerError(threadKey: string, messageId?: string): void { + const current = appAtomRegistry.get(threadComposerErrorsAtom); + const error = current[threadKey]; + if (!error || (messageId !== undefined && error.messageId !== messageId)) { + return; + } + const next = { ...current }; + delete next[threadKey]; + appAtomRegistry.set(threadComposerErrorsAtom, next); +} + +export function clearThreadComposerErrorsForEnvironment(environmentId: string): void { + const current = appAtomRegistry.get(threadComposerErrorsAtom); + const prefix = `${environmentId}:`; + const keys = Object.keys(current).filter((key) => key.startsWith(prefix)); + if (keys.length === 0) { + return; + } + const next = { ...current }; + for (const key of keys) { + delete next[key]; + } + appAtomRegistry.set(threadComposerErrorsAtom, next); +} diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 6a2b72c8f8c7..3a8a1022d665 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -110,10 +110,10 @@ export function useRemoteConnections() { }, []); const onConnectPress = useCallback( - async (pairingUrl?: string) => { + async (pairingUrl?: string, expectedEnvironmentId?: EnvironmentId) => { const nextPairingUrl = pairingUrl ?? connectionPairingUrl; setPendingConnectionError(null); - const result = await controller.connectPairingUrl(nextPairingUrl); + const result = await controller.connectPairingUrl(nextPairingUrl, expectedEnvironmentId); if (AsyncResult.isFailure(result)) { const error = Cause.squash(result.cause); const message = diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 1f9eed3d675c..9688eddc65a4 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -96,6 +96,7 @@ import { useQueuedRunEdit, } from "./queued-run-edit"; import { setPendingConnectionError } from "../state/use-remote-environment-registry"; +import { clearThreadComposerError, setThreadComposerError } from "./thread-composer-error"; import { useSelectedThreadProjection, useSelectedThreadVisibleTurnItems, @@ -452,7 +453,8 @@ export function useThreadComposerState() { }); } endQueuedRunEdit(selectedThreadKey, { deferAttachmentCleanup: keepable }); - setPendingConnectionError( + setThreadComposerError( + selectedThreadKey, keepable ? "That message already started. Your edit is back in the composer." : "That message already started, so the edit was discarded.", @@ -670,6 +672,8 @@ export function useThreadComposerState() { const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); + // A new send supersedes the reason the previous one bounced back. + clearThreadComposerError(threadKey); // Enqueue publishes the queued atom synchronously (the durable write // happens behind it), so clearing the draft here gives send feedback on // the tap frame instead of after file I/O. If the write fails the message @@ -706,7 +710,8 @@ export function useThreadComposerState() { attachments: [], }); appendComposerDraftAttachments(threadKey, attachments, { allowOverflow: true }); - setPendingConnectionError( + setThreadComposerError( + threadKey, error instanceof Error ? error.message : "Failed to save the queued message.", ); }, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index b0e6497fe8f8..3a9b98237356 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -19,7 +19,6 @@ const harness = vi.hoisted(() => ({ removePersistedFile: vi.fn(async () => undefined), removeOutboxMessage: vi.fn(async (_message: QueuedThreadMessage) => undefined), prepareTurnAttachments: vi.fn(), - setPendingConnectionError: vi.fn(), draftFile: (() => { let document = ""; let writeError: Error | null = null; @@ -106,7 +105,6 @@ vi.mock("./use-thread-outbox", async () => { }); vi.mock("./use-remote-environment-registry", () => ({ - setPendingConnectionError: harness.setPendingConnectionError, useRemoteConnectionStatus: () => ({ connectedEnvironments: [] }), })); @@ -139,6 +137,11 @@ import { clearPendingThreadCreationOutcome, pendingThreadCreationOutcomesAtom, } from "./pending-thread-creation"; +import { + clearThreadComposerError, + setThreadComposerError, + threadComposerErrorsAtom, +} from "./thread-composer-error"; import type { QueuedThreadMessage } from "./thread-outbox-model"; import * as composerDrafts from "./use-composer-drafts"; import { recoverFailedThreadDraft } from "./recover-failed-thread-draft"; @@ -211,11 +214,11 @@ afterEach(() => { appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} }); appAtomRegistry.set(editingQueuedMessageIdsAtom, {}); appAtomRegistry.set(pendingThreadCreationOutcomesAtom, {}); + appAtomRegistry.set(threadComposerErrorsAtom, {}); harness.draftFile.setWriteError(null); harness.removePersistedFile.mockClear(); harness.removeOutboxMessage.mockClear(); harness.prepareTurnAttachments.mockReset(); - harness.setPendingConnectionError.mockClear(); }); describe("thread outbox attachment preparation", () => { @@ -444,6 +447,25 @@ describe("thread outbox drain delivery cleanup", () => { expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([message]); }); + it("clears an error about the delivered message but keeps one about another message", async () => { + const threadKey = "environment-1:thread-1"; + const retried = queuedMessage({ messageId: "message-retried", text: "retried" }); + await harness.manager.enqueue(retried); + // A failed recovery left this message queued with an error about it. + setThreadComposerError(threadKey, "could not be restored", retried.messageId); + + await completeQueuedMessageDelivery(retried, harness.manager.revisionOf(retried.messageId)); + expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]).toBeUndefined(); + + const other = queuedMessage({ messageId: "message-other", text: "other" }); + await harness.manager.enqueue(other); + // The thread's error explains a different, rejected message still in the draft. + setThreadComposerError(threadKey, "rejected", "message-rejected"); + + await completeQueuedMessageDelivery(other, harness.manager.revisionOf(other.messageId)); + expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBe("rejected"); + }); + it("keeps a delivered message when its editor opens during storage removal", async () => { const message = queuedMessage({ messageId: "message-editor-removal-race", @@ -699,7 +721,8 @@ describe("thread outbox recovery rollback", () => { }, }); expect(remainingMessages()).toEqual([]); - expect(harness.setPendingConnectionError).toHaveBeenCalledWith("rejected by server"); + // The creation's failure card shows the reason; the composer is hidden. + expect(appAtomRegistry.get(threadComposerErrorsAtom)).toEqual({}); // The thread screen opened for this creation reads the failure from here. expect( appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[ @@ -708,6 +731,37 @@ describe("thread outbox recovery rollback", () => { ).toEqual({ kind: "failed", message, reason: "rejected by server" }); }); + it("drops an earlier recovery error once a rejected new task is restored", async () => { + const message: QueuedThreadMessage = { + ...queuedMessage({ messageId: "message-creation-retried", text: "new task text" }), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + creation: { + projectId: ProjectId.make("project-1"), + workspaceMode: "local", + branch: null, + worktreePath: null, + }, + }; + await harness.manager.enqueue(message); + harness.draftFile.setWriteError(new Error("disk full")); + await expect(restoreRejectedQueuedMessage(message, "rejected by server")).resolves.toBe( + "retry", + ); + const threadKey = `${message.environmentId}:${message.threadId}`; + expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.messageId).toBe( + message.messageId, + ); + + harness.draftFile.setWriteError(null); + await expect(restoreRejectedQueuedMessage(message, "rejected by server")).resolves.toBe( + "restored", + ); + + // The failure card carries the reason now; nothing stale sits above it. + expect(appAtomRegistry.get(threadComposerErrorsAtom)).toEqual({}); + expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[threadKey]?.kind).toBe("failed"); + }); + it("keeps a failed outcome until its thread screen consumes it", async () => { const message: QueuedThreadMessage = { ...queuedMessage({ messageId: "message-creation-kept", text: "new task text" }), @@ -736,6 +790,46 @@ describe("thread outbox recovery rollback", () => { await expect(restoreRejectedQueuedMessage(message, "rejected")).resolves.toBe("restored"); expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)).toEqual({}); + // The thread screen shows why the message came back into the composer. + expect( + appAtomRegistry.get(threadComposerErrorsAtom)[`${message.environmentId}:${message.threadId}`], + ).toEqual({ message: "rejected", messageId: message.messageId }); + }); + + it("leaves no error when the restored text is resent before recovery finishes", async () => { + const message = queuedMessage({ messageId: "message-resent", text: "resend me" }); + const threadKey = `${message.environmentId}:${message.threadId}`; + await harness.manager.enqueue(message); + // The user sees the restored text as soon as the merge publishes it and + // sends it again while the recovery is still awaiting persistence. + const unsubscribe = appAtomRegistry.subscribe(composerDrafts.composerDraftsAtom, (drafts) => { + if (drafts[threadKey]?.text === "resend me") { + unsubscribe(); + clearThreadComposerError(threadKey); + void composerDrafts.clearComposerDraftContent(threadKey); + } + }); + + await expect(restoreRejectedQueuedMessage(message, "rejected")).resolves.toBe("restored"); + + expect(appAtomRegistry.get(threadComposerErrorsAtom)).toEqual({}); + }); + + it("withdraws the error when an edit makes the recovery back out", async () => { + const message = queuedMessage({ messageId: "message-edited-mid-recovery", text: "edit me" }); + const threadKey = `${message.environmentId}:${message.threadId}`; + await harness.manager.enqueue(message); + const unsubscribe = appAtomRegistry.subscribe(composerDrafts.composerDraftsAtom, (drafts) => { + if (drafts[threadKey]?.text === "edit me") { + unsubscribe(); + appAtomRegistry.set(editingQueuedMessageIdsAtom, { [message.messageId]: true }); + } + }); + + await expect(restoreRejectedQueuedMessage(message, "rejected")).resolves.toBe("deferred"); + + expect(appAtomRegistry.get(threadComposerErrorsAtom)).toEqual({}); + expect(remainingMessages()).toEqual([message]); }); it("rolls a failed recovery merge back so the retry cannot duplicate the text", async () => { @@ -761,6 +855,6 @@ describe("thread outbox recovery rollback", () => { "typed offline\n\nqueued text", ); expect(remainingMessages()).toEqual([]); - expect(harness.setPendingConnectionError).toHaveBeenCalledWith("too large"); + expect(appAtomRegistry.get(threadComposerErrorsAtom)[draftKey]?.message).toBe("too large"); }); }); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 62e21199d6da..831ffef6cba3 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -81,10 +81,8 @@ import { useThreadOutboxMessages, useThreadOutboxShellStatuses, } from "./use-thread-outbox"; -import { - setPendingConnectionError, - useRemoteConnectionStatus, -} from "./use-remote-environment-registry"; +import { clearThreadComposerError, setThreadComposerError } from "./thread-composer-error"; +import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; // Ordinary offline behavior (a socket dropping mid-request, a retryable // attachment upload failure) must not spam `console.warn` on every backoff @@ -283,6 +281,12 @@ export async function completeQueuedMessageDelivery( queuedMessage: QueuedThreadMessage, deliveryRevision: number, ): Promise<"removed" | "edited" | "failed"> { + // The server took it after all: an error left by an earlier failed recovery + // of this same message no longer applies. + clearThreadComposerError( + scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId), + queuedMessage.messageId, + ); try { await removeDeliveredCloudQueuedMessage(queuedMessage).catch((error) => { console.warn("[thread-outbox] could not update sign-out snapshot after delivery", { @@ -439,6 +443,7 @@ export async function restoreRejectedQueuedMessage( message: string, ): Promise<"restored" | "deferred" | "blocked" | "retry"> { const draftKey = recoveryDraftKey(queuedMessage); + const threadKey = scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId); // Set once the merge publishes, cleared once the queued message is removed. // The catch below uses it to take the merged content back out, so a retry // after a mid-recovery failure cannot append the recovered text again. @@ -468,12 +473,21 @@ export async function restoreRejectedQueuedMessage( (attachment) => !existingAttachmentIds.has(attachment.id), ).length; if (existingAttachmentIds.size + addedAttachmentCount > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { - setPendingConnectionError( + setThreadComposerError( + threadKey, `Remove attachments from the draft before restoring this message. Messages can contain at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments.`, + queuedMessage.messageId, ); return "blocked"; } + // Shown before the merge publishes the text, so a resend of that text, + // which can happen while this recovery still awaits persistence, clears + // it. Withdrawn below wherever the recovery backs out. + const withdrawError = () => clearThreadComposerError(threadKey, queuedMessage.messageId); + if (!queuedMessage.creation) { + setThreadComposerError(threadKey, message, queuedMessage.messageId); + } let mergedDraft: ComposerDraft; try { stampRecoveryDraftProject(queuedMessage, draftKey); @@ -493,6 +507,7 @@ export async function restoreRejectedQueuedMessage( rollback = { snapshot: originalDraft, merged: mergedDraft }; } if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + withdrawError(); await undoComposerDraftMerge(draftKey, originalDraft, mergedDraft); return "deferred"; } @@ -521,6 +536,7 @@ export async function restoreRejectedQueuedMessage( !(await confirmThreadOutboxMessageQueued(queuedMessage)) || appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId] ) { + withdrawError(); await undoComposerDraftMerge(draftKey, originalDraft, restoredDraft); return "deferred"; } @@ -533,6 +549,7 @@ export async function restoreRejectedQueuedMessage( () => !appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId], )) ) { + withdrawError(); await undoComposerDraftMerge(draftKey, originalDraft, restoredDraft); return "deferred"; } @@ -540,15 +557,17 @@ export async function restoreRejectedQueuedMessage( // must never be rolled back. rollback = null; if (queuedMessage.creation) { + // The failure card shows the reason, so an error left by an earlier + // failed attempt at this recovery no longer applies. + withdrawError(); // The thread screen for this creation is likely open; it reads the - // outcome to offer reopening the restored draft. + // outcome to offer reopening the restored draft, and shows the reason. recordPendingThreadCreationOutcome({ kind: "failed", message: queuedMessage, reason: message, }); } - setPendingConnectionError(message); return "restored"; } catch (error) { if (rollback !== null) { @@ -562,8 +581,10 @@ export async function restoreRejectedQueuedMessage( ); } console.warn("[thread-outbox] failed to restore an undeliverable message", error); - setPendingConnectionError( + setThreadComposerError( + threadKey, error instanceof Error ? error.message : "The unsent message could not be restored.", + queuedMessage.messageId, ); return "retry"; } diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 6e426b52c4e0..5548bebac84c 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1077,6 +1077,70 @@ const program = Effect.gen(function* () { status: "completed", rawInput: { query: "TODO", path: "apps/web" }, }, + // Grok backend searches: the query only arrives in the completed rawOutput. + { + sessionUpdate: "tool_call_update", + toolCallId: "grok-x-search", + title: "X search:", + kind: "search", + status: "in_progress", + rawInput: { variant: "XSearch", backend: true }, + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "grok-x-search", + title: "X search:", + status: "completed", + rawOutput: { + call_id: "xs_call-1", + input: '{"query":"conversation_id:42","limit":"10","mode":"Latest"}', + name: "x_keyword_search", + id: "grok-x-search", + }, + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "grok-web-search", + title: "Web search:", + kind: "search", + status: "completed", + rawInput: { variant: "WebSearch", backend: true }, + rawOutput: { + action: { + type: "search", + query: "t3 code", + sources: [ + { type: "url", url: "https://t3.codes" }, + { type: "url", url: "https://t3.codes" }, + { type: "url", url: "https://github.com/pingdotgg/t3code" }, + ], + }, + id: "grok-web-search", + status: "completed", + }, + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "grok-web-fetch", + title: "Fetch: https://t3.codes", + kind: "fetch", + status: "completed", + rawInput: { variant: "WebFetch", url: "https://t3.codes" }, + rawOutput: { + type: "WebFetch", + Content: { url: "https://t3.codes", content: "T3 Code page" }, + }, + content: [{ type: "content", content: { type: "text", text: "T3 Code page" } }], + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "antigravity-shell", + title: "run_command", + kind: "execute", + status: "completed", + rawInput: { command: "cat probe.txt" }, + rawOutput: { commandLine: "cat probe.txt", exitCode: 0, combinedOutput: "after\n" }, + }, { sessionUpdate: "compaction_update", compactionId: "compact-1", diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index a596ca55c32a..35844a6be11a 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; import { AssetAccessError, AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; @@ -35,6 +36,11 @@ vi.mock("node:fs/promises", async (importOriginal) => { return { ...actual, open: vi.fn(actual.open), realpath: vi.fn(actual.realpath) }; }); +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: vi.fn(actual.homedir) }; +}); + const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-asset-access-test-", }); @@ -183,6 +189,55 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("resolves home-relative media paths independently of the workspace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-home-" }); + const home = path.join(directory, "var", "home", "alice"); + const filePath = path.join(home, "Downloads", "repro.mp4"); + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, "recording bytes"); + const canonicalFile = yield* fs.realPath(filePath); + const homeSpy = vi.mocked(NodeOS.homedir).mockReturnValue(home); + try { + for (const workspaceRoot of [ + path.join(home, "project"), + path.join(directory, "srv", "project"), + undefined, + ]) { + if (workspaceRoot) yield* fs.makeDirectory(workspaceRoot, { recursive: true }); + for (const requestedPath of ["~/Downloads/repro.mp4", "~\\Downloads/repro.mp4"]) { + const result = yield* issueAssetUrl({ + resource: { + _tag: "media-file", + threadId: ThreadId.make("thread-1"), + path: requestedPath, + }, + ...(workspaceRoot ? { workspaceRoot } : {}), + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const asset = yield* resolveAsset( + suffix.slice(0, separator), + suffix.slice(separator + 1), + ); + expect(asset).toMatchObject({ + kind: "file", + path: canonicalFile, + mimeType: "video/mp4", + }); + if (asset?.kind !== "file") throw new Error("Expected a resolved home media file"); + const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset)); + expect(yield* Effect.promise(() => response.text())).toBe("recording bytes"); + } + } + } finally { + homeSpy.mockRestore(); + } + }).pipe(Effect.provide(testLayer)), + ); + it.effect.skipIf(!symlinksSupported)( "rejects non-previewable files, disguised targets, and directories", () => diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 25a2748cd9e5..be0d280fef76 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -49,6 +49,7 @@ import { import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; +import { expandHomePathWith } from "../pathExpansion.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; @@ -433,7 +434,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i switch (input.resource._tag) { case "media-file": { - let requestedPath = input.resource.path; + let requestedPath = expandHomePathWith(input.resource.path, path); if (!path.isAbsolute(requestedPath)) { if (!input.workspaceRoot) { return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 970ba88050b9..3f5eb6dedca5 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -35,6 +35,7 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: AuthOrchestrationReadScope, + [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.launchThread]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope, diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts index a4148e707bf6..c39fa3e3b620 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -15,7 +15,7 @@ import { pairCommand } from "./cli/pair.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; import { projectCommand } from "./cli/project.ts"; -import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; +import { runDefaultServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { updateCommand } from "./cli/update.ts"; import { uninstallCommand } from "./cli/uninstall.ts"; import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; @@ -56,8 +56,14 @@ const connectUnavailableCommand = Command.make("connect", { export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => Command.make("t3", { ...sharedServerCommandFlags }).pipe( Command.withDescription("Run the T3 Code server."), - Command.withHandler((flags) => runServerCommand(flags)), + Command.withHandler(runDefaultServerCommand), Command.withSubcommands([ + Command.make("help").pipe( + Command.withDescription("Show command help."), + Command.withHandler(() => + Effect.fail(new CliError.ShowHelp({ commandPath: ["t3"], errors: [] })), + ), + ), acpMcpBridgeCommand, acpMcpCallCommand, startCommand, diff --git a/apps/server/src/claudeModelOptions.ts b/apps/server/src/claudeModelOptions.ts index d018c2f0af32..ef7b92e1a2e9 100644 --- a/apps/server/src/claudeModelOptions.ts +++ b/apps/server/src/claudeModelOptions.ts @@ -37,7 +37,7 @@ export function compileClaudeModelSelection( const resolvedEffort = resolveClaudeCatalogEffort(catalog, selection.model, rawEffort); const effort = normalizeClaudeCatalogEffort(catalog, resolvedEffort, selection.model); const fastMode = supportsBoolean("fastMode") - ? getModelSelectionBooleanOptionValue(selection, "fastMode") + ? (getModelSelectionBooleanOptionValue(selection, "fastMode") ?? false) : undefined; const thinking = supportsBoolean("thinking") ? getModelSelectionBooleanOptionValue(selection, "thinking") diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index e478f66f0731..a91ea416ffca 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -17,10 +17,12 @@ import * as NetService from "@t3tools/shared/Net"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { Command } from "effect/unstable/cli"; import { afterEach, describe, expect, vi } from "vite-plus/test"; import { makeCli } from "../binCli.ts"; +import { PersistedServerRuntimeState } from "../serverRuntimeState.ts"; vi.mock("node:os", async (importOriginal) => { const os = await importOriginal(); @@ -29,6 +31,8 @@ vi.mock("node:os", async (importOriginal) => { afterEach(() => vi.mocked(NodeOS.homedir).mockReset()); +const encodeRuntimeState = Schema.encodeEffect(Schema.fromJsonString(PersistedServerRuntimeState)); + const runCli = (args: ReadonlyArray, env: Record = {}) => Command.runWith(makeCli(), { version: "0.0.0" })(args).pipe( Effect.provide( @@ -130,6 +134,88 @@ const withTempDirectory = ( (root) => Effect.promise(() => NodeFSP.rm(root, { recursive: true, force: true })), ); +describe("t3 server command safety", () => { + it.effect("rejects unknown command words without creating a home or project", () => + withTempDirectory("t3-cli-unknown-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "home"); + for (const word of [ + "account", + "login", + "clients", + "conenct", + "package.json", + "C:new-project", + ]) { + const error = yield* runCli([word, "--base-dir", baseDir]).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.flip, + ); + expect(String(error)).toContain(`Unknown command "${word}"`); + expect(yield* pathExists(word)).toBe(word === "package.json"); + expect(yield* pathExists(baseDir)).toBe(false); + } + }), + ), + ); + + it.effect("shows help without creating state", () => + withTempDirectory("t3-cli-help-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "home"); + const help = yield* runCli(["help"], { T3CODE_HOME: baseDir }).pipe(Effect.flip); + expect(help).toMatchObject({ _tag: "ShowHelp", commandPath: ["t3"], errors: [] }); + expect(yield* pathExists(baseDir)).toBe(false); + }), + ), + ); + + it.effect("refuses manual startup over a live server before creating directories", () => + withTempDirectory("t3-cli-running-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "home"); + const stateDir = NodePath.join(baseDir, "userdata"); + const statePath = NodePath.join(stateDir, "server-runtime.json"); + const record = yield* encodeRuntimeState({ + version: 1, + pid: process.pid, + port: 3773, + origin: "http://127.0.0.1:3773", + startedAt: "2026-10-01T00:00:00.000Z", + serviceManaged: true, + }); + yield* Effect.promise(() => NodeFSP.mkdir(stateDir, { recursive: true })); + yield* Effect.promise(() => NodeFSP.writeFile(statePath, record)); + const newDirectory = NodePath.join(root, "new-project"); + const platform = yield* HostProcessPlatform; + for (const args of [ + [], + ["start"], + ["."], + ["node_modules"], + ["C:new-project"], + [newDirectory], + ["start", newDirectory], + ]) { + const error = yield* runCli(args, { T3CODE_HOME: baseDir }).pipe( + Effect.provideService( + HostProcessPlatform, + args[0] === "C:new-project" ? "win32" : platform, + ), + Effect.flip, + ); + expect(String(error)).toContain("A T3 Code server is already running"); + expect(yield* Effect.promise(() => NodeFSP.readFile(statePath, "utf8"))).toBe(record); + expect(yield* pathExists(newDirectory)).toBe(false); + expect(yield* Effect.promise(() => NodeFSP.readdir(stateDir))).toEqual([ + "server-runtime.json", + ]); + } + }), + ), + ); +}); + describe("t3 app", () => { it.effect("rejects SSH before it tries to reach a desktop app", () => withTempDirectory("t3-app-ssh-test-", (root) => diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 093e92f248e6..c08761c79871 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -80,6 +80,53 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ); }); + it.effect("keeps stale records and supervised startup out of the manual launch preflight", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-preflight-" }); + for (const [name, pid, mode, rejectRunningServer] of [ + ["stale", 2_147_483_647, "web", true], + ["desktop", process.pid, "desktop", true], + ["serve", process.pid, "web", false], + ] as const) { + const baseDir = path.join(root, name); + const stateDir = path.join(baseDir, "userdata"); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* fs.writeFileString( + path.join(stateDir, "server-runtime.json"), + yield* encodeUnknownJson({ + version: 1, + pid, + port: 3773, + origin: "http://127.0.0.1:3773", + startedAt: "2026-10-01T00:00:00.000Z", + }), + ); + const cwd = path.join(root, `${name}-project`); + const config = yield* resolveServerConfig( + { + ...minimalWebFlags(baseDir), + mode: Option.some(mode), + port: Option.some(8788), + cwd: Option.some(cwd), + }, + Option.none(), + { rejectRunningServer }, + ).pipe( + Effect.provide( + Layer.merge( + NetService.layer, + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + ), + ), + ); + expect(config.cwd).toBe(cwd); + expect(yield* fs.exists(cwd)).toBe(true); + } + }), + ); + it.effect("enables a trimmed reusable auth token only for web dev mode", () => Effect.gen(function* () { const baseDir = yield* FileSystem.FileSystem.pipe( diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 0395a2cd204e..ce9ac4a42da5 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -19,10 +19,12 @@ import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { Argument, Flag } from "effect/unstable/cli"; +import * as CliError from "effect/unstable/cli/CliError"; import { readBootstrapEnvelope } from "../bootstrap.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; +import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; const modeFlag = Flag.Literals("mode", ServerConfig.RuntimeMode.literals).pipe( Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), @@ -255,6 +257,7 @@ export const resolveServerConfig = ( options?: { readonly startupPresentation?: ServerConfig.StartupPresentation; readonly forceAutoBootstrapProjectFromCwd?: boolean; + readonly rejectRunningServer?: boolean; }, ) => Effect.gen(function* () { @@ -325,10 +328,20 @@ export const resolveServerConfig = ( ); const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd()); const cwd = path.resolve(yield* expandHomePath(rawCwd.trim())); - yield* fs.makeDirectory(cwd, { recursive: true }); const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: Option.isSome(explicitBaseDir), }); + // An interactive CLI must not start over a discovered server. Lifetime locking + // and supervisor handoff are separate; this preflight cannot arbitrate two starts. + if (options?.rejectRunningServer && mode === "web") { + const runtime = yield* readPersistedServerRuntimeState(derivedPaths.serverRuntimeStatePath); + if (Option.isSome(runtime) && runtime.value.pid > 0 && isProcessAlive(runtime.value.pid)) { + return yield* new CliError.UserError({ + cause: `A T3 Code server is already running for ${baseDir} (pid ${runtime.value.pid}, ${runtime.value.origin}). Connect to that server, stop it before starting another, or use a different --base-dir.`, + }); + } + } + yield* fs.makeDirectory(cwd, { recursive: true }); yield* ServerConfig.ensureServerDirectories(derivedPaths); const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings( derivedPaths.settingsPath, diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index 067d9fa09d80..4c20f6106afa 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -1,6 +1,16 @@ -import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, expect, it } from "@effect/vitest"; +import { + HostProcessArguments, + HostProcessExecutablePath, + HostProcessIsExecutable, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; -import { formatCliCommand } from "./invocation.ts"; +import { formatCliCommand, resolveServerInstallation } from "./invocation.ts"; it("formats package runner commands from their cache entry paths", () => { for (const [entryPath, expected] of [ @@ -87,3 +97,123 @@ it("formats serve suggestions to match the launching command", () => { "t3 serve", ); }); + +it.layer(NodeServices.layer)("manual server installation ownership", (it) => { + it.effect("recognizes runner caches for both script and executable packages", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped(); + for (const [relative, kind] of [ + ["npm/_npx/hash/node_modules/t3/dist/bin.mjs", "npx"], + ["npm/_npx/hash/node_modules/@t3code/t3-linux-x64/t3", "npx"], + ["pnpm/dlx/hash/node_modules/t3/dist/bin.mjs", "pnpm-dlx"], + [".bun/install/cache/t3/dist/bin.mjs", "bunx"], + ] as const) { + const entry = path.join(root, relative); + yield* fs.makeDirectory(path.dirname(entry), { recursive: true }); + yield* fs.writeFileString(entry, ""); + const installation = yield* resolveServerInstallation.pipe( + Effect.provideService(HostProcessArguments, ["node", entry]), + Effect.provideService(HostProcessExecutablePath, entry), + Effect.provideService(HostProcessIsExecutable, entry.endsWith("/t3")), + ); + expect(installation).toEqual({ kind }); + } + }), + ); + + it.effect("requires the npm prefix's bin to point to the running package", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped(); + const prefix = path.join(root, "bunx-tools"); + const packageRoot = path.join(prefix, "lib/node_modules/t3"); + const entry = path.join(packageRoot, "dist/bin.mjs"); + const globalBin = path.join(prefix, "bin/t3"); + yield* fs.makeDirectory(path.dirname(entry), { recursive: true }); + yield* fs.makeDirectory(path.dirname(globalBin), { recursive: true }); + yield* fs.writeFileString(entry, ""); + yield* fs.writeFileString( + path.join(packageRoot, "package.json"), + '{"name":"t3","version":"0.0.45","bin":{"t3":"./dist/bin.mjs"}}', + ); + const resolve = resolveServerInstallation.pipe( + Effect.provideService(HostProcessArguments, ["node", entry]), + Effect.provideService(HostProcessIsExecutable, false), + Effect.provideService(HostProcessPlatform, "linux"), + ); + expect(yield* resolve).toBeNull(); + yield* fs.symlink(entry, globalBin); + expect(yield* resolve).toEqual({ kind: "npm-global", prefix }); + yield* fs.remove(globalBin); + yield* fs.writeFileString(globalBin, "an unrelated t3 command"); + expect(yield* resolve).toBeNull(); + expect(yield* resolve.pipe(Effect.provideService(HostProcessPlatform, "win32"))).toBeNull(); + }), + ); + + it.effect("proves the native executable belongs to the npm launcher", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped(); + const prefix = path.join(root, "bunx-tools"); + const packageRoot = path.join(prefix, "lib/node_modules/t3"); + const launcher = path.join(packageRoot, "bin/t3.js"); + const entry = path.join(packageRoot, "node_modules/@t3code/t3-linux-x64/t3"); + yield* fs.makeDirectory(path.dirname(launcher), { recursive: true }); + yield* fs.makeDirectory(path.dirname(entry), { recursive: true }); + yield* fs.makeDirectory(path.join(prefix, "bin")); + yield* fs.writeFileString(launcher, ""); + yield* fs.writeFileString(entry, ""); + yield* fs.writeFileString( + path.join(packageRoot, "package.json"), + '{"name":"t3","version":"0.0.45","bin":{"t3":"./bin/t3.js"},"optionalDependencies":{"@t3code/t3-linux-x64":"0.0.45"}}', + ); + yield* fs.symlink(launcher, path.join(prefix, "bin/t3")); + const resolve = resolveServerInstallation.pipe( + Effect.provideService(HostProcessExecutablePath, entry), + Effect.provideService(HostProcessIsExecutable, true), + Effect.provideService(HostProcessPlatform, "linux"), + ); + for (const [version, expected] of [ + ["0.0.44", null], + ["0.0.45", { kind: "npm-global", prefix }], + ]) { + yield* fs.writeFileString( + path.join(path.dirname(entry), "package.json"), + `{"name":"@t3code/t3-linux-x64","version":"${version}"}`, + ); + expect(yield* resolve).toEqual(expected); + } + }), + ); + + it.effect("leaves local, standalone, missing and unreadable installs unknown", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped(); + for (const relative of [ + "project/node_modules/t3/dist/bin.mjs", + "project/apps/server/dist/bin.mjs", + ".t3/runtime/0.0.45/t3", + "missing/dist/bin.mjs", + ]) { + const entry = path.join(root, relative); + if (!relative.startsWith("missing")) { + yield* fs.makeDirectory(path.dirname(entry), { recursive: true }); + yield* fs.writeFileString(entry, ""); + } + expect( + yield* resolveServerInstallation.pipe( + Effect.provideService(HostProcessArguments, ["node", entry]), + Effect.provideService(HostProcessIsExecutable, false), + ), + ).toBeNull(); + } + }), + ); +}); diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index 1fc0e774129f..96c367f92ebf 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -1,6 +1,15 @@ import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; -import { HostProcessArguments } from "@t3tools/shared/hostProcess"; +import type { ServerInstallation } from "@t3tools/contracts"; +import { + HostProcessArguments, + HostProcessExecutablePath, + HostProcessIsExecutable, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import packageJson from "../../package.json" with { type: "json" }; @@ -36,6 +45,68 @@ function detectCliRunner(entryPath: string): CliRunner | null { return null; } +const InstallManifest = Schema.Struct({ + name: Schema.String, + version: Schema.String, + bin: Schema.optionalKey(Schema.Struct({ t3: Schema.String })), + optionalDependencies: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), +}); +const decodeInstallManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(InstallManifest)); + +/** Prove the running package and its global bin belong together before suggesting an update. */ +export const resolveServerInstallation = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const args = yield* HostProcessArguments; + const executable = yield* HostProcessIsExecutable; + const executablePath = yield* HostProcessExecutablePath; + const platform = yield* HostProcessPlatform; + const entry = yield* fs.realPath(executable ? executablePath : (args[1] ?? "")); + const match = + /^(.*)\/lib\/node_modules\/t3\/(?:dist\/bin\.mjs|bin\/t3\.js|node_modules\/@t3code\/t3-[^/]+\/t3)$/.exec( + entry, + ); + if (!match) { + const runner = detectCliRunner(entry); + return runner === null + ? null + : ({ kind: runner === "pnpm dlx" ? "pnpm-dlx" : runner } satisfies ServerInstallation); + } + // A global prefix can contain runner-like names; prove its ownership first. + // Windows shims and other package managers need their own ownership proof. + if (platform === "win32") return null; + const prefix = match[1] || "/"; + if ( + prefix.includes("/node_modules/") || + /\/(?:Cellar|Caskroom)\//i.test(prefix) || + /\/mise\/installs\/(?!node\/)[^/]+\//.test(prefix) + ) + return null; + + const packageRoot = path.join(prefix, "lib/node_modules/t3"); + const manifest = yield* fs + .readFileString(path.join(packageRoot, "package.json")) + .pipe(Effect.flatMap(decodeInstallManifest)); + if (manifest.name !== "t3" || !manifest.bin) return null; + const bin = yield* fs.realPath(path.join(packageRoot, manifest.bin.t3)); + const globalBin = yield* fs.realPath(path.join(prefix, "bin/t3")); + if (globalBin !== bin) return null; + if (executable) { + const nativeManifest = yield* fs + .readFileString(path.join(path.dirname(entry), "package.json")) + .pipe(Effect.flatMap(decodeInstallManifest)); + if ( + manifest.bin.t3 !== "./bin/t3.js" || + manifest.optionalDependencies?.[nativeManifest.name] !== nativeManifest.version || + nativeManifest.version !== manifest.version + ) + return null; + } else if (bin !== entry) { + return null; + } + return { kind: "npm-global", prefix } satisfies ServerInstallation; +}).pipe(Effect.orElseSucceed(() => null)); + /** * The `t3` package spec to suggest. The literal spec the user typed (e.g. * `t3@nightly`) is resolved away before our process starts, so re-derive it diff --git a/apps/server/src/cli/server.ts b/apps/server/src/cli/server.ts index 298109abdb5f..44662c36b540 100644 --- a/apps/server/src/cli/server.ts +++ b/apps/server/src/cli/server.ts @@ -1,15 +1,23 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { Command, GlobalFlag } from "effect/unstable/cli"; +import * as CliError from "effect/unstable/cli/CliError"; import * as ServerConfig from "../config.ts"; import { runServer } from "../server.ts"; import { type CliServerFlags, resolveServerConfig, sharedServerCommandFlags } from "./config.ts"; -export const runServerCommand = ( +const encodeCommand = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); + +const runServerCommand = ( flags: CliServerFlags, options?: { readonly startupPresentation?: ServerConfig.StartupPresentation; readonly forceAutoBootstrapProjectFromCwd?: boolean; + readonly rejectRunningServer?: boolean; }, ) => Effect.gen(function* () { @@ -18,9 +26,35 @@ export const runServerCommand = ( return yield* runServer.pipe(Effect.provideService(ServerConfig.ServerConfig, config)); }); +/** Bare words can name existing directories, but must not create typo projects. */ +export const runDefaultServerCommand = (flags: CliServerFlags) => + Effect.gen(function* () { + if (Option.isSome(flags.cwd)) { + const cwd = flags.cwd.value.trim(); + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const explicitPath = + cwd === "." || + cwd === ".." || + cwd === "~" || + /[/\\]/.test(cwd) || + (platform === "win32" && /^[a-z]:/i.test(cwd)); + if ( + !explicitPath && + (!(yield* fs.exists(cwd)) || (yield* fs.stat(cwd)).type !== "Directory") + ) { + return yield* new CliError.UserError({ + cause: cwd, + userMessage: `Unknown command ${yield* encodeCommand(cwd)}. Use "t3 --help" for commands or an explicit path such as "t3 ./my-project" for a new directory.`, + }); + } + } + return yield* runServerCommand(flags, { rejectRunningServer: true }); + }); + export const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe( Command.withDescription("Run the T3 Code server."), - Command.withHandler((flags) => runServerCommand(flags)), + Command.withHandler((flags) => runServerCommand(flags, { rejectRunningServer: true })), ); export const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe( diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 5c9a6dcaf4d4..3f299db6d0c4 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -4,9 +4,11 @@ import { HostProcessEnvironment, HostProcessPlatform, HostProcessIsExecutable, + HostProcessUserId, } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as FileSystem from "effect/FileSystem"; @@ -134,6 +136,54 @@ it.effect("puts detected Android tools on the helper PATH without losing existin }).pipe(Effect.provide(NodePath.layer)), ); +it.effect( + "recovers an owned Linux runtime directory without overriding explicit environments", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped(); + const stat = yield* fs.stat(directory); + for (const [platform, uid, runtimeDir, type, owner, expected] of [ + ["linux", 1000, undefined, "Directory", 1000, "/run/user/1000"], + ["linux", 1000, "/custom/runtime", "Directory", 1000, "/custom/runtime"], + ["linux", 1000, "", "Directory", 1000, ""], + ["linux", 1000, undefined, "Directory", 1001, undefined], + ["linux", 1000, undefined, "File", 1000, undefined], + ["linux", undefined, undefined, "Directory", 1000, undefined], + ["darwin", 1000, undefined, "Directory", 1000, undefined], + ["win32", 1000, undefined, "Directory", 1000, undefined], + ] as const) { + const environment = { PATH: "/usr/bin", XDG_RUNTIME_DIR: runtimeDir }; + const result = yield* LocalDeviceHost.__testing.hubEnvironment(environment).pipe( + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HostProcessUserId, uid), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + stat: (path) => { + expect(path).toBe("/run/user/1000"); + return Effect.succeed({ ...stat, type, uid: Option.some(owner) }); + }, + }), + ), + ); + expect(result.XDG_RUNTIME_DIR).toBe(expected); + expect(result.PATH).toBe("/usr/bin"); + expect(result.FORCE_COLOR).toBe("0"); + expect(result.NO_COLOR).toBe("1"); + expect(environment.XDG_RUNTIME_DIR).toBe(runtimeDir); + } + const missing = yield* LocalDeviceHost.__testing + .hubEnvironment({}) + .pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessUserId, 1000), + Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})), + ); + expect(missing.XDG_RUNTIME_DIR).toBeUndefined(); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect( "constructs and inspects an unconfigured host without installing or starting helpers", () => diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 5d0ba5176a1e..096b8312f89e 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -19,7 +19,11 @@ import { LOCAL_DEVICE_HOST_ID, } from "@t3tools/contracts"; import { waitForHttpReady } from "@t3tools/shared/httpReadiness"; -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + HostProcessEnvironment, + HostProcessPlatform, + HostProcessUserId, +} from "@t3tools/shared/hostProcess"; import { resolveNodeExecutable, type NodeRuntimeUnavailableError, @@ -32,8 +36,8 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -199,6 +203,28 @@ const deviceHostEnvironment = ( : environment; }; +const hubEnvironment = Effect.fn("LocalDeviceHost.hubEnvironment")(function* ( + environment: NodeJS.ProcessEnv, +) { + const env: NodeJS.ProcessEnv = { ...environment, FORCE_COLOR: "0", NO_COLOR: "1" }; + const platform = yield* HostProcessPlatform; + const uid = yield* HostProcessUserId; + if (platform === "linux" && env.XDG_RUNTIME_DIR === undefined && uid !== undefined) { + // SSH sessions may omit the directory where the emulator publishes its gRPC token. + const runtimeDir = `/run/user/${uid}`; + const fs = yield* FileSystem.FileSystem; + const stat = yield* fs.stat(runtimeDir).pipe(Effect.option); + if ( + stat._tag === "Some" && + stat.value.type === "Directory" && + Option.contains(stat.value.uid, uid) + ) { + env.XDG_RUNTIME_DIR = runtimeDir; + } + } + return env; +}); + export const make = Effect.fn("LocalDeviceHost.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const config = yield* ServerConfig.ServerConfig; @@ -267,12 +293,6 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { }; }); - const hubEnvironment = (): NodeJS.ProcessEnv => ({ - ...hostEnvironment, - FORCE_COLOR: "0", - NO_COLOR: "1", - }); - const stopHub = (hub: HubProcess | undefined) => hub ? Scope.close(hub.scope, Exit.void).pipe(Effect.ignore) : Effect.void; @@ -376,7 +396,10 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { shell: false, stdout: "pipe", stderr: "pipe", - env: hubEnvironment(), + env: yield* hubEnvironment(hostEnvironment).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(HostProcessPlatform, hostPlatform), + ), }, ), ) @@ -766,4 +789,5 @@ export const __testing = { androidSdk, platformReason, deviceHostEnvironment, + hubEnvironment, }; diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts new file mode 100644 index 000000000000..5bbce9d86686 --- /dev/null +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -0,0 +1,207 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it as effectIt } from "@effect/vitest"; +import type * as NodeOS from "node:os"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { describe, expect, it } from "vite-plus/test"; + +import * as ServerConfig from "../config.ts"; +import * as DirectEndpoints from "./DirectEndpoints.ts"; +import { resolveBoundEndpoints } from "./DirectEndpoints.ts"; + +const INTERFACES: ReturnType = { + lo0: [ + { + address: "127.0.0.1", + netmask: "255.0.0.0", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: true, + cidr: "127.0.0.1/8", + }, + ], + en0: [ + { + address: "192.168.1.10", + netmask: "255.255.255.0", + family: "IPv4", + mac: "aa:bb:cc:dd:ee:ff", + internal: false, + cidr: "192.168.1.10/24", + }, + { + address: "fe80::1", + netmask: "ffff:ffff:ffff:ffff::", + family: "IPv6", + mac: "aa:bb:cc:dd:ee:ff", + internal: false, + cidr: "fe80::1/64", + scopeid: 4, + }, + ], + en1: [ + { + address: "203.0.113.20", + netmask: "255.255.255.0", + family: "IPv4", + mac: "aa:bb:cc:dd:ee:00", + internal: false, + cidr: "203.0.113.20/24", + }, + ], + utun4: [ + { + address: "100.101.102.103", + netmask: "255.255.255.255", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: false, + cidr: "100.101.102.103/32", + }, + ], +}; + +const virtualInterface = (address: string) => [ + { + address, + netmask: "255.255.0.0", + family: "IPv4" as const, + mac: "02:42:ac:11:00:01", + internal: false, + cidr: `${address}/16`, + }, +]; + +describe("resolveBoundEndpoints", () => { + it("lists nothing for a loopback-only server", () => { + expect(resolveBoundEndpoints({ host: undefined, port: 3773, interfaces: INTERFACES })).toEqual( + [], + ); + expect( + resolveBoundEndpoints({ host: "127.0.0.1", port: 3773, interfaces: INTERFACES }), + ).toEqual([]); + }); + + it("lists every external IPv4 address for a wildcard bind, tagging the tailnet one", () => { + expect(resolveBoundEndpoints({ host: "0.0.0.0", port: 3773, interfaces: INTERFACES })).toEqual([ + { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }, + { kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }, + ]); + }); + + it("skips container and VM networks, which only this machine reaches", () => { + const interfaces = { + ...INTERFACES, + docker0: virtualInterface("172.17.0.1"), + "br-3f2a1b": virtualInterface("172.18.0.1"), + virbr0: virtualInterface("192.168.122.1"), + "vEthernet (WSL)": virtualInterface("172.24.0.1"), + bridge100: virtualInterface("192.168.64.1"), + vmbr0: virtualInterface("192.168.1.20"), + }; + expect(resolveBoundEndpoints({ host: "0.0.0.0", port: 3773, interfaces })).toEqual([ + { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }, + { kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }, + { kind: "lan", httpBaseUrl: "http://192.168.1.20:3773/" }, + ]); + }); + + it("lists only the bound address for a specific bind", () => { + expect( + resolveBoundEndpoints({ host: "100.101.102.103", port: 3773, interfaces: INTERFACES }), + ).toEqual([{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }]); + }); + + it("never reports a host name, which can resolve to another machine per client", () => { + for (const host of ["server.local", "devbox", "devbox.home.arpa"]) { + expect(resolveBoundEndpoints({ host, port: 3773, interfaces: INTERFACES })).toEqual([]); + } + }); + + it("never reports a public address, which would carry the credential over plain HTTP", () => { + expect( + resolveBoundEndpoints({ host: "203.0.113.20", port: 3773, interfaces: INTERFACES }), + ).toEqual([]); + }); +}); + +const TAILSCALE_STATUS_JSON = JSON.stringify({ + Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, +}); + +/** `tailscale status --json` reporting a MagicDNS name. */ +const tailscaleUpLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(new TextEncoder().encode(TAILSCALE_STATUS_JSON)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ), +); + +/** Answers the Serve probe with `status`. */ +const serveProbeLayer = (status: number) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status }))), + ), + ); + +/** A loopback-only server with Tailscale Serve on, so only the Serve name can be listed. */ +const serveConfigLayer = Layer.effect( + ServerConfig.ServerConfig, + Effect.map(ServerConfig.ServerConfig, (config) => ({ + ...config, + host: "127.0.0.1", + tailscaleServeEnabled: true, + tailscaleServePort: 443, + })), +).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-direct-" })), + Layer.provide(NodeServices.layer), +); + +const resolveWithServe = (probeStatus: number) => + Effect.flatMap(DirectEndpoints.DirectEndpoints, (service) => service.resolve()).pipe( + Effect.provide( + DirectEndpoints.layer.pipe( + Layer.provide( + Layer.mergeAll(serveConfigLayer, tailscaleUpLayer, serveProbeLayer(probeStatus)), + ), + ), + ), + ); + +describe("DirectEndpoints Tailscale Serve", () => { + effectIt.effect("lists the tailnet name once Serve answers for this server", () => + Effect.gen(function* () { + expect(yield* resolveWithServe(200)).toEqual([ + { kind: "tailnet", httpBaseUrl: "https://bb-1.tail1234.ts.net/" }, + ]); + }), + ); + + effectIt.effect("leaves the tailnet name out when Serve is not forwarding", () => + Effect.gen(function* () { + expect(yield* resolveWithServe(502)).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts new file mode 100644 index 000000000000..aa333a598ca4 --- /dev/null +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -0,0 +1,143 @@ +/** + * DirectEndpoints - the LAN and tailnet addresses this server listens on now. + * + * Clients connected one way (often T3 Connect) save these as extra routes so + * they can move to a faster path when one is reachable, and replace a saved + * LAN address when DHCP or a new Wi-Fi network changes it. Only addresses the + * server is actually bound to are listed: a loopback-only server lists none, + * because its loopback address means a different machine to every client. + */ +import type { ServerDirectEndpoint } from "@t3tools/contracts"; +import { + buildTailscaleHttpsBaseUrl, + isTailscaleIpv4Address, + probeTailscaleHttpsEndpoint, + readTailscaleStatus, +} from "@t3tools/tailscale"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { HttpClient } from "effect/unstable/http"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; + +import { isPrivateNetworkHost } from "@t3tools/shared/hostClassification"; + +import * as ServerConfig from "../config.ts"; +import { formatHostForUrl, isLoopbackHost, isWildcardHost } from "../startupAccess.ts"; + +type NetworkInterfacesMap = ReturnType; + +/** + * Stays under the config read's own discovery timeout, so a slow `tailscale` + * CLI drops only the tailnet name and never the LAN addresses. An empty list + * would make clients forget the routes they learned. + */ +const TAILSCALE_ENDPOINT_TIMEOUT = Duration.seconds(3); + +export class DirectEndpoints extends Context.Service< + DirectEndpoints, + { + readonly resolve: () => Effect.Effect>; + } +>()("t3/environment/DirectEndpoints") {} + +/** + * Only numeric private-network and tailnet IPv4 addresses are reported. These + * routes are plain HTTP and carry the client's credential, so a public address + * would send it across the internet unencrypted, and a name (`server.local`) + * can resolve to a different machine on each client's network. + */ +const isAdvertisableAddress = (address: string): boolean => + NodeNet.isIPv4(address) && + !address.startsWith("127.") && + !address.startsWith("169.254.") && + (isTailscaleIpv4Address(address) || isPrivateNetworkHost(address)); + +/** + * Container and VM networks (Docker, libvirt, VMware, VirtualBox, Hyper-V, and + * macOS's `bridge100`+) have private addresses only this machine reaches. A + * host bridge such as `br0` or Proxmox's `vmbr0` carries the LAN address, so + * it stays listed. + */ +const VIRTUAL_INTERFACE = + /^(docker|br-|veth|virbr|vmnet|vboxnet|vEthernet|podman|cni|flannel|cali|lxcbr|lxdbr|bridge1\d\d)/; + +/** + * Plain HTTP endpoints for the private addresses a server bound to `host` + * accepts. IPv4 only: link-local and temporary IPv6 addresses change too + * often to be worth saving. + */ +export function resolveBoundEndpoints(input: { + readonly host: string | undefined; + readonly port: number; + readonly interfaces: NetworkInterfacesMap; +}): ReadonlyArray { + if (isLoopbackHost(input.host)) return []; + const addresses = isWildcardHost(input.host) + ? Object.entries(input.interfaces) + .flatMap(([name, entries]) => (VIRTUAL_INTERFACE.test(name) ? [] : (entries ?? []))) + .filter( + (entry) => + !entry.internal && entry.family === "IPv4" && isAdvertisableAddress(entry.address), + ) + .map((entry) => entry.address) + : [input.host!].filter(isAdvertisableAddress); + return [...new Set(addresses)].map((address) => ({ + kind: isTailscaleIpv4Address(address) ? "tailnet" : "lan", + httpBaseUrl: `http://${formatHostForUrl(address)}:${input.port}/`, + })); +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + + const resolve = Effect.gen(function* () { + const endpoints = [ + ...resolveBoundEndpoints({ + host: config.host, + port: config.port, + interfaces: NodeOS.networkInterfaces(), + }), + ]; + // Tailscale Serve terminates HTTPS on the tailnet name and forwards to + // loopback, so it works even for a loopback-only server. The name is only + // listed once it answers as this server: Serve setup can fail without + // stopping startup. Anything slower than the probe leaves it out rather + // than holding back the addresses already found. + if (config.tailscaleServeEnabled) { + const servedUrl = yield* readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.flatMap((magicDnsName) => { + if (magicDnsName === null) return Effect.succeed(null); + const httpBaseUrl = buildTailscaleHttpsBaseUrl({ + magicDnsName, + servePort: config.tailscaleServePort, + }); + return probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl }).pipe( + Effect.map((reachable) => (reachable ? httpBaseUrl : null)), + ); + }), + Effect.timeoutOption(TAILSCALE_ENDPOINT_TIMEOUT), + Effect.map(Option.flatMap(Option.fromNullishOr)), + Effect.orElseSucceed(() => Option.none()), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + if (Option.isSome(servedUrl)) { + endpoints.push({ kind: "tailnet", httpBaseUrl: servedUrl.value }); + } + } + return endpoints; + }); + + return DirectEndpoints.of({ resolve: () => resolve }); +}); + +export const layer = Layer.effect(DirectEndpoints, make); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 9e151474fe11..b6479f5c5132 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -10,6 +10,12 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; +import { + HostProcessArguments, + HostProcessEnvironment, + HostProcessIsExecutable, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -80,6 +86,47 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { }); it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { + it.effect("publishes proven install ownership only for manually updated servers", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped(); + const prefix = `${baseDir}/node`; + const entry = `${prefix}/lib/node_modules/t3/dist/bin.mjs`; + yield* fs.makeDirectory(`${prefix}/lib/node_modules/t3/dist`, { recursive: true }); + yield* fs.makeDirectory(`${prefix}/bin`, { recursive: true }); + yield* fs.writeFileString(entry, ""); + yield* fs.writeFileString( + `${prefix}/lib/node_modules/t3/package.json`, + '{"name":"t3","version":"0.0.45","bin":{"t3":"./dist/bin.mjs"}}', + ); + yield* fs.symlink(entry, `${prefix}/bin/t3`); + const config = yield* makeServerConfig(baseDir); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + for (const mode of ["web", "desktop"] as const) { + const descriptor = yield* Effect.gen(function* () { + const environment = yield* ServerEnvironment.ServerEnvironment; + return yield* environment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironment.layer.pipe( + Layer.provide(emptySecretStoreLayer), + Layer.provide(ServerConfig.layer({ ...config, mode })), + ), + ), + Effect.provideService(HostProcessArguments, ["node", entry]), + Effect.provideService(HostProcessIsExecutable, false), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, {}), + ); + expect(descriptor.capabilities.serverInstallation).toEqual( + mode === "web" ? { kind: "npm-global", prefix } : undefined, + ); + expect(descriptor.capabilities.serverSelfUpdate).toBe( + mode === "web" ? undefined : "desktop-managed", + ); + } + }), + ); it.effect.each([ { name: "missing", content: undefined }, { name: "empty", content: "" }, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 16a4a93b3f20..ef66b6d5419a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -15,6 +15,7 @@ import * as Schema from "effect/Schema"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { resolveServerInstallation } from "../cli/invocation.ts"; import { readAgentActivityPublishingActive } from "../cloud/config.ts"; import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; @@ -196,6 +197,7 @@ export const make = Effect.gen(function* () { desktopManaged: serverConfig.mode === "desktop", launcherManaged: launcher.managed, }); + const serverInstallation = serverSelfUpdate === null ? yield* resolveServerInstallation : null; // Static is correct: the control fd is known at bootstrap, and the desktop // app and its bundled server ship in one artifact, so a present fd means // the app speaks the requestDesktopUpdate protocol. WSL backends never get @@ -248,6 +250,7 @@ export const make = Effect.gen(function* () { environmentIcon: true, projectCloneTracking: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), + ...(serverInstallation === null ? {} : { serverInstallation }), // V2 restart recovery uses the environment-owned opt-in. The old // per-update request flag is not wired into the V2 update RPC path. ...(serverSelfUpdate === "boot-service" || desktopAppUpdate diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 1b6b44008467..501e97cedad5 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3116,6 +3116,53 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect.each([undefined, ["README.md"]])( + "a failed generation preserves staging changed while generating (paths: %s)", + (filePaths) => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nstaged\n"); + yield* runGit(repoDir, ["add", "README.md"]); + NodeFS.appendFileSync(NodePath.join(repoDir, "README.md"), "unstaged\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked\n"); + const indexBefore = NodeFS.readFileSync(NodePath.join(repoDir, ".git/index")); + const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout; + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + let indexDuring: Buffer | undefined; + let indexAfterUserStage: Buffer | undefined; + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: () => + Effect.gen(function* () { + indexDuring = NodeFS.readFileSync(NodePath.join(repoDir, ".git/index")); + yield* runGit(repoDir, ["add", "untracked.txt"]).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, gitDriver), + Effect.orDie, + ); + indexAfterUserStage = NodeFS.readFileSync(NodePath.join(repoDir, ".git/index")); + return yield* new TextGenerationError({ + operation: "generateCommitMessage", + detail: "Provider rejected generation", + }); + }), + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + ...(filePaths ? { filePaths } : {}), + }).pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + expect(indexDuring).toEqual(indexBefore); + expect(NodeFS.readFileSync(NodePath.join(repoDir, ".git/index"))).toEqual( + indexAfterUserStage, + ); + expect((yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout).toBe(headBefore); + expect((yield* runGit(repoDir, ["diff"])).stdout).toContain("+unstaged"); + }), + ); + it.effect("uses custom commit message when provided", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 2b88cd79f8e6..2829502c8cbd 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1985,6 +1985,7 @@ export const make = Effect.gen(function* () { : null; const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, { timeoutMs: COMMIT_TIMEOUT_MS, + stage: filePaths ? { filePaths } : {}, ...(commitProgress ? { progress: commitProgress } : {}), }); if (currentHookName !== null) { 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 ac447919be11..bbaf23424e2e 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"; @@ -632,6 +633,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), ); @@ -677,9 +688,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, }; @@ -2327,31 +2342,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, @@ -3549,6 +3571,7 @@ describe("orchestrator MCP toolkit", () => { ), Layer.provide(providerRegistryLayer), Layer.provide(unusedScheduledTaskStubLayer), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(NodeServices.layer), ); @@ -3603,9 +3626,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..f17b22204f45 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, 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/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index b6cb0be45acf..031fa17c0506 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -1008,9 +1008,12 @@ describe("AcpAdapterV2", () => { ); type Runtime = AcpSessionRuntime.AcpSessionRuntime["Service"]; let handler: Parameters[0] | undefined; + let createTerminal: Parameters[0] | undefined; const instanceId = ProviderInstanceId.make("devin-replay"); const adapter = makeAcpAdapterV2({ instanceId, + // Production Devin runs commands through client terminals. + clientTerminals: { childProcessSpawner, shellCommands: true }, crypto: yield* Crypto.Crypto, fileSystem: yield* FileSystem.FileSystem, idAllocator, @@ -1031,9 +1034,18 @@ describe("AcpAdapterV2", () => { Effect.sync(() => { handler = next; }).pipe(Effect.andThen(runtime.handleSessionUpdate(next))), + handleCreateTerminal: (next) => + Effect.sync(() => { + createTerminal = next; + }).pipe(Effect.andThen(runtime.handleCreateTerminal(next))), prompt: () => Effect.gen(function* () { assert.isDefined(handler); + assert.isDefined(createTerminal); + const fallbackTerminal = yield* createTerminal( + { sessionId: "mock-session-1", command: "true acp-mcp-call task_status {}" }, + { requestId: "child-mcp-terminal", method: "terminal/create" }, + ); // Production thread 54aeb6d7 split after "(command". Metadata shapes // below were captured from live Devin sessions showy-mile/fragrant-chamomile. const updates = [ @@ -1075,6 +1087,48 @@ describe("AcpAdapterV2", () => { "cognition.ai/subagent_context": { parentAgentId: "child-a" }, }, }, + { + sessionUpdate: "tool_call", + toolCallId: "child-weather", + title: "Check weather", + status: "completed", + rawInput: { server: "weather", tool: "get_weather", city: "Berlin" }, + rawOutput: { + result: { + _meta: { + source: { name: "Weather", logoUrl: "https://example.com/weather.png" }, + }, + content: [{ type: "text", text: "Sunny" }], + }, + }, + _meta: { + is_mcp_tool_call: true, + "cognition.ai/subagent_context": { parentAgentId: "child-a" }, + }, + }, + { + sessionUpdate: "tool_call", + toolCallId: "child-mcp-fallback", + title: "Ran command", + kind: "execute", + status: "completed", + content: [{ type: "terminal", terminalId: fallbackTerminal.terminalId }], + _meta: { "cognition.ai/subagent_context": { parentAgentId: "child-a" } }, + }, + { + sessionUpdate: "tool_call", + toolCallId: "parent-weather", + title: "Check weather", + status: "completed", + rawInput: { server: "weather", tool: "get_weather", city: "Berlin" }, + rawOutput: { + _meta: { + source: { name: "Weather", logoUrl: "https://example.com/weather.png" }, + }, + content: [{ type: "text", text: "Sunny" }], + }, + _meta: { is_mcp_tool_call: true }, + }, { sessionUpdate: "agent_message_chunk", content: { type: "text", text: " → decider → event)." }, @@ -1192,6 +1246,37 @@ describe("AcpAdapterV2", () => { ); assert.deepEqual([...childMessages.values()], ["Checking the code.", "ONE"]); assert.equal(task?.prompt, "Run pwd, then reply ONE."); + // Terminal-fallback MCP calls in a child session keep their T3 identity. + assert.isTrue( + items.some( + (item) => + item.threadId === task?.childThreadId && + item.type === "dynamic_tool" && + item.toolName === "t3-code.task_status", + ), + ); + const childMcp = items.find( + (item) => + item.threadId === task?.childThreadId && + item.type === "dynamic_tool" && + item.toolName === "weather.get_weather", + ); + const parentMcp = items.find( + (item) => + item.threadId === threadId && + item.type === "dynamic_tool" && + item.toolName === "weather.get_weather", + ); + for (const item of [parentMcp, childMcp]) { + assert.equal(item?.title, "get weather"); + assert.deepEqual(item?.toolSource, { + key: "mcp:weather", + name: "Weather", + kind: "integration", + icon: { _tag: "themed-logo", logoUrl: "https://example.com/weather.png" }, + }); + assert.deepEqual(item?.toolIcon, item?.toolSource?.icon); + } assert.isTrue( items.some( (item) => @@ -1216,7 +1301,7 @@ describe("AcpAdapterV2", () => { const parentTools = items.filter( (item) => item.threadId === threadId && item.type === "dynamic_tool", ); - assert.equal(parentTools.length, 1); + assert.equal(parentTools.length, 2); assert.equal(parentTools[0]?.title, "Parent tool finished"); }).pipe(Effect.provide(testLayer), Effect.scoped), ); @@ -1324,6 +1409,12 @@ describe("AcpAdapterV2", () => { ), { input: "printf proof", output: "proof" }, ); + assert.deepInclude( + items.flatMap((item) => + item.type === "command_execution" ? [{ input: item.input, output: item.output }] : [], + ), + { input: "cat probe.txt", output: "after\n" }, + ); assert.isTrue( items.some((item) => item.title === "Action required" && item.status === "waiting"), ); @@ -1345,6 +1436,35 @@ describe("AcpAdapterV2", () => { search?.type === "file_search" ? { title: search.title, pattern: search.pattern } : null, { title: "Searched TODO in web", pattern: "apps/web" }, ); + const webItem = (nativeId: string, status: string) => { + const item = items.findLast( + (candidate) => + candidate.type === "web_search" && + candidate.status === status && + candidate.nativeItemRef?.nativeId?.endsWith(nativeId) === true, + ); + return item?.type === "web_search" + ? { title: item.title, patterns: item.patterns, results: item.results } + : null; + }; + assert.deepEqual(webItem("grok-x-search", "running"), { + title: "X search", + patterns: undefined, + results: undefined, + }); + assert.deepEqual(webItem("grok-x-search", "completed"), { + title: "X search: conversation_id:42", + patterns: ["conversation_id:42"], + results: undefined, + }); + assert.deepEqual(webItem("grok-web-search", "completed"), { + title: "Web search: t3 code", + patterns: ["t3 code"], + results: [{ url: "https://t3.codes" }, { url: "https://github.com/pingdotgg/t3code" }], + }); + assert.deepEqual(webItem("grok-web-fetch", "completed")?.results, [ + { url: "https://t3.codes", snippet: "T3 Code page" }, + ]); const completedCompaction = items.find( (item) => item.type === "compaction" && diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index bf01d9cb2d7d..e5ae85129e2f 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -19,6 +19,7 @@ import { type OrchestrationV2Subagent, type OrchestrationV2TurnItem, type OrchestrationV2UserInputQuestion, + type OrchestrationV2WebSearchResult, type ProviderApprovalDecision, type ProviderApprovalOption, type ProviderInstanceId, @@ -62,6 +63,7 @@ import { makeAcpMcpOverAcpBridge, type AcpMcpOverAcpBridge, } from "../../mcp/AcpMcpOverAcpBridge.ts"; +import { mcpToolPresentation } from "../../provider/McpToolPresentation.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { applyAcpAgentTerminalUpdate, @@ -846,11 +848,14 @@ function textFromUnknown(value: unknown): string | undefined { return undefined; } // Prefer prompt-facing Grok fields before nested envelopes. + // Antigravity reports shell output as combinedOutput. for (const key of [ "output_for_prompt", "stdout", "stderr", "output", + "combinedOutput", + "combined_output", "content", "text", "message", @@ -989,6 +994,46 @@ function pathFromToolCall(toolCall: AcpToolCallState): string | undefined { return undefined; } +/** + * Grok runs X and web searches server-side as `search` tools whose rawInput is + * only `{ variant: "XSearch" | "WebSearch", backend: true }`. The query arrives + * with completion: web searches report `action: { query, sources }`, X searches + * the backend call `{ name, input }` with JSON-encoded arguments. + */ +function acpBackendWebSearch( + rawInput: Record | undefined, + rawOutput: Record | undefined, +): + | { readonly query: string | undefined; readonly results: OrchestrationV2WebSearchResult[] } + | undefined { + const variant = typeof rawInput?.variant === "string" ? rawInput.variant.toLowerCase() : ""; + const action = unknownRecord(rawOutput?.action); + if (variant !== "xsearch" && variant !== "websearch" && action?.type !== "search") { + return undefined; + } + let args: Record | undefined; + if (typeof rawOutput?.input === "string") { + try { + args = unknownRecord(JSON.parse(rawOutput.input)); + } catch { + args = undefined; + } + } + const argsText = Object.entries(args ?? {}) + .filter(([, value]) => typeof value === "string" || typeof value === "number") + .map(([key, value]) => `${key}: ${value}`) + .join(", "); + const query = [action?.query, args?.query, argsText] + .find((value): value is string => typeof value === "string" && value.trim().length > 0) + ?.trim(); + const urls = new Set(); + for (const source of Array.isArray(action?.sources) ? action.sources : []) { + const url = unknownRecord(source)?.url; + if (typeof url === "string" && url.trim().length > 0) urls.add(url.trim()); + } + return { query, results: [...urls].map((url) => ({ url })) }; +} + function providerRequestKind(kind: string | "unknown"): ProviderRequestKind { switch (kind) { case "execute": @@ -1588,6 +1633,21 @@ export function makeAcpAdapterV2( embeddedTerminalsByToolCallId.delete(oldest); } }; + // Command lines of the terminals embedded in a tool call, so MCP calls + // made through the acp-mcp-call terminal fallback keep their identity. + const embeddedTerminalCommands = ( + sessionId: string, + toolCallId: string, + ): ReadonlyArray => + ( + embeddedTerminalsByToolCallId.get(sessionScopedId(sessionId, toolCallId)) + ?.terminalIds ?? [] + ).flatMap((terminalId) => { + const command = + clientTerminals?.readCommandLine(terminalId) ?? + agentTerminalsById.get(sessionScopedId(sessionId, terminalId))?.command; + return command === undefined ? [] : [command]; + }); // Client terminals (Devin) run with the T3 server's privileges, so they // are policy-checked against the active turn policy; a command the user // already approved satisfies an "ask" disposition. @@ -3223,17 +3283,10 @@ export function makeAcpAdapterV2( // agent-specific shape and project the same branded dynamic_tool // item native providers produce (e.g. the T3 orchestration tools). const mcpIdentity = extractMcpToolCallIdentity(toolCall, { - embeddedTerminalCommands: ( - embeddedTerminalsByToolCallId.get( - sessionScopedId(context.nativeThreadId, toolCall.toolCallId), - )?.terminalIds ?? [] - ).flatMap((terminalId) => { - const command = - clientTerminals?.readCommandLine(terminalId) ?? - agentTerminalsById.get(sessionScopedId(context.nativeThreadId, terminalId)) - ?.command; - return command === undefined ? [] : [command]; - }), + embeddedTerminalCommands: embeddedTerminalCommands( + context.nativeThreadId, + toolCall.toolCallId, + ), }); let turnItem: OrchestrationV2TurnItem; if (toolCall.toolCallId.startsWith("acp-compaction:")) { @@ -3258,9 +3311,14 @@ export function makeAcpAdapterV2( } else if (mcpIdentity !== undefined) { turnItem = { ...base, - // Identity lives in toolName, like native Codex MCP items; the - // agent's own title (e.g. "Ran command") would shadow it. title: null, + ...mcpToolPresentation({ + serverName: mcpIdentity.server, + toolName: mcpIdentity.tool, + source: unknownRecord( + (unknownRecord(rawOutputRecord?.result) ?? rawOutputRecord)?._meta, + )?.source, + }), type: "dynamic_tool", toolName: `${mcpIdentity.server}.${mcpIdentity.tool}`, input: @@ -3297,7 +3355,30 @@ export function makeAcpAdapterV2( ...(rawOutput === undefined ? {} : { output: rawOutput }), }; break; - case "search": + case "search": { + const backendSearch = acpBackendWebSearch(rawInputRecord, rawOutputRecord); + if (backendSearch !== undefined) { + // Grok titles these "X search:" / "Web search:" awaiting the query. + const label = nonEmptyText(toolCall.data.title, title ?? "Web search").replace( + /:\s*$/u, + "", + ); + turnItem = { + ...base, + title: + backendSearch.query === undefined + ? label + : `${label}: ${backendSearch.query}`, + type: "web_search", + ...(backendSearch.query === undefined + ? {} + : { patterns: [backendSearch.query] }), + ...(backendSearch.results.length === 0 + ? {} + : { results: backendSearch.results }), + }; + break; + } turnItem = { ...base, title: @@ -3322,6 +3403,7 @@ export function makeAcpAdapterV2( }), }; break; + } case "execute": { const exitCode = acpProjectedCommandExitCode(status, rawOutput); turnItem = { @@ -3345,7 +3427,11 @@ export function makeAcpAdapterV2( ...(diffText === undefined ? {} : { diffStr: diffText }), }; break; - case "fetch": + case "fetch": { + // Grok nests the page under rawOutput.Content, which textFromUnknown + // cannot read; the (bounded) content blocks carry the same text. + const snippet = + textFromUnknown(toolCall.data.content) ?? textFromUnknown(rawOutput); turnItem = { ...base, type: "web_search", @@ -3356,14 +3442,13 @@ export function makeAcpAdapterV2( results: [ { url: path, - ...(textFromUnknown(rawOutput) === undefined - ? {} - : { snippet: textFromUnknown(rawOutput) }), + ...(snippet === undefined ? {} : { snippet }), }, ], }), }; break; + } default: if (projectAsCommandExecution) { const exitCode = acpProjectedCommandExitCode(status, rawOutput); @@ -4257,7 +4342,8 @@ export function makeAcpAdapterV2( return; } if (context.finalized) return; - if (notification.sessionId !== (yield* Ref.get(activeSessionId))) { + const rootSessionId = yield* Ref.get(activeSessionId); + if (notification.sessionId !== rootSessionId) { // Finalize may have completed during the activeSessionId yield. if (context.finalized) return; if (flavor.extractSubagentUpdate === undefined) return; @@ -4285,6 +4371,17 @@ export function makeAcpAdapterV2( const key = `${nativeTaskId}:tool:${toolCall.toolCallId}`; const merged = mergeToolCallState(context.tools.get(key), toolCall); context.tools.set(key, merged); + // Terminals are remembered under the raw session id: the child's + // own session, or the root one when the flavor routes child + // updates out of it (Devin). + const mcpIdentity = extractMcpToolCallIdentity(merged, { + embeddedTerminalCommands: [ + ...embeddedTerminalCommands(notification.sessionId, toolCall.toolCallId), + ...(rootSessionId === null + ? [] + : embeddedTerminalCommands(rootSessionId, toolCall.toolCallId)), + ], + }); const now = yield* DateTime.now; const status = toolStatus(merged.status); const startedAt = context.toolStartedAt.get(key) ?? now; @@ -4309,7 +4406,22 @@ export function makeAcpAdapterV2( completedAt: completedAtForStatus(status, now), updatedAt: now, type: "dynamic_tool", - toolName: merged.title ?? merged.kind ?? "Tool", + ...(mcpIdentity === undefined + ? {} + : mcpToolPresentation({ + serverName: mcpIdentity.server, + toolName: mcpIdentity.tool, + source: unknownRecord( + ( + unknownRecord(unknownRecord(merged.data.rawOutput)?.result) ?? + unknownRecord(merged.data.rawOutput) + )?._meta, + )?.source, + })), + toolName: + mcpIdentity === undefined + ? (merged.title ?? merged.kind ?? "Tool") + : `${mcpIdentity.server}.${mcpIdentity.tool}`, input: merged.data.rawInput ?? null, output: merged.data.rawOutput ?? merged.data.content ?? null, }, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b837575c1faf..9bb9ee2dce16 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -126,6 +126,7 @@ function makeClaudeTestTurnInput(input: { readonly text: string; readonly attachments: ProviderAdapterV2TurnInput["message"]["attachments"]; readonly providerTurnOrdinal?: number; + readonly nativeThreadHasTurns?: boolean; readonly messageCreatedBy?: ProviderAdapterV2TurnInput["message"]["createdBy"]; readonly messageCreationSource?: ProviderAdapterV2TurnInput["message"]["creationSource"]; readonly modelSelection?: ModelSelection; @@ -137,6 +138,9 @@ function makeClaudeTestTurnInput(input: { runId: RunId.make(`run-${input.attemptId}`), runOrdinal: 1, providerTurnOrdinal: input.providerTurnOrdinal ?? 1, + ...(input.nativeThreadHasTurns === undefined + ? {} + : { nativeThreadHasTurns: input.nativeThreadHasTurns }), attemptId: input.attemptId, rootNodeId: NodeId.make(`node-${input.attemptId}`), providerThread: input.providerThread, @@ -867,6 +871,20 @@ describe("ClaudeAdapterV2 context usage", () => { }); describe("ClaudeAdapterV2 session permissions", () => { + it("keeps explicit user refusals classified as user_reject", () => { + const result = ClaudeAdapterV2.permissionResultFromDecision({ + toolName: "Bash", + decision: "decline", + toolInput: { command: "make" }, + toolUseID: "denied-build", + }); + assert.equal(result.behavior, "deny"); + if (result.behavior !== "deny") return; + assert.equal(result.decisionClassification, "user_reject"); + assert.equal(result.message, "User declined tool execution."); + assert.equal(result.interrupt, undefined); + }); + it("forces suggested permission updates to session scope", () => { const result = ClaudeAdapterV2.permissionResultFromDecision({ toolName: "Bash", @@ -1783,7 +1801,7 @@ describe("ClaudeAdapterV2 native fork", () => { }); describe("ClaudeAdapterV2 native session identity", () => { - const openTurnWithOrdinal = (providerTurnOrdinal: number) => + const openTurnWithOrdinal = (providerTurnOrdinal: number, nativeThreadHasTurns?: boolean) => Effect.scoped( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1842,6 +1860,7 @@ describe("ClaudeAdapterV2 native session identity", () => { text: "Respond with identity ok", attachments: [], providerTurnOrdinal, + ...(nativeThreadHasTurns === undefined ? {} : { nativeThreadHasTurns }), }), ); return openedQueries; @@ -1867,6 +1886,14 @@ describe("ClaudeAdapterV2 native session identity", () => { assert.equal(openedQueries[0]?.options.sessionId, undefined); }), ); + + it.effect("creates a fresh native session despite earlier provider-thread turns", () => + Effect.gen(function* () { + const openedQueries = yield* openTurnWithOrdinal(4, false); + assert.equal(openedQueries[0]?.options.sessionId, "native-session-identity"); + assert.equal(openedQueries[0]?.options.resume, undefined); + }), + ); }); const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -2182,6 +2209,173 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const makeWakeHarness = makeWakeHarnessWithOptions(); + it.effect.each([ + { isError: false, title: "Check weather" }, + { isError: true, title: "Check weather" }, + { isError: false, title: undefined }, + ])("keeps late MCP display metadata with %j", ({ isError, title }) => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("mcp-presentation"), + text: "Check the weather", + attachments: [], + }), + ); + const toolName = "mcp__weather__get_weather"; + const id = "weather-call"; + yield* Effect.promise(() => + harness.getOpenedOptions()!.canUseTool!( + toolName, + { city: "Berlin" }, + { + signal: new AbortController().signal, + toolUseID: id, + requestId: "weather-request", + }, + ), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "assistant", + uuid: "weather-assistant", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + message: { + id: "weather-message", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [{ type: "tool_use", id, name: toolName, input: { city: "Berlin" } }], + }, + tool_use_meta: [ + { + id, + display_name: title, + server_display_name: "Weather", + icon_url: "https://example.com/weather.png", + }, + ], + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "user", + uuid: "weather-result", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: id, + content: "Weather result", + is_error: isError, + }, + ], + }, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "weather-terminal", result: "Weather checked" }), + ); + yield* Queue.take(harness.terminalReceipts); + const items = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" + ? [event.turnItem] + : [], + ); + assert.deepEqual( + items.map((item) => item.status), + ["running", "running", isError ? "failed" : "completed"], + ); + assert.isNull(items[0]?.title); + for (const item of items.slice(1)) { + assert.equal(item.title, title ?? "get weather"); + assert.deepEqual(item.toolIcon, { + _tag: "themed-logo", + logoUrl: "https://example.com/weather.png", + }); + assert.deepEqual(item.toolSource, { + key: "mcp:weather", + name: "Weather", + kind: "integration", + icon: { _tag: "themed-logo", logoUrl: "https://example.com/weather.png" }, + }); + assert.deepEqual(item.input, { city: "Berlin" }); + } + assert.equal(new Set(items.map((item) => item.id)).size, 1); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect( + "reuses a background shell's query for omitted and explicit Normal, but blocks Fast", + () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const normal = { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-opus-5-5", + } satisfies ModelSelection; + const turn = (ordinal: number, modelSelection: ModelSelection) => + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`attempt-normal-background:${ordinal}`), + text: `Request ${ordinal}`, + attachments: [], + providerTurnOrdinal: ordinal, + modelSelection, + }); + yield* harness.runtime.startTurn(turn(1, normal)); + const originalOptions = harness.getOpenedOptions(); + yield* harness.offerAndWait(wakeTaskStarted); + yield* harness.offerAndWait(turnOneResult); + yield* Queue.take(harness.terminalReceipts); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + yield* harness.runtime.startTurn( + turn(2, { + ...normal, + options: [{ id: "fastMode", value: false }], + }), + ); + assert.strictEqual(harness.getOpenedOptions(), originalOptions); + assert.lengthOf(harness.offeredMessages, 2); + yield* harness.offerAndWait(turnOneResult); + yield* Queue.take(harness.terminalReceipts); + + const refused = yield* harness.runtime + .startTurn( + turn(3, { + ...normal, + options: [{ id: "fastMode", value: true }], + }), + ) + .pipe(Effect.result); + assert.equal(refused._tag, "Failure"); + if (refused._tag === "Failure") { + assert.instanceOf( + refused.failure.cause, + ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError, + ); + } + assert.strictEqual(harness.getOpenedOptions(), originalOptions); + assert.lengthOf(harness.offeredMessages, 2); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + it.effect.each(["completed", "interrupted"] as const)( "projects Claude thinking blocks when %s", (status) => @@ -2300,6 +2494,77 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ); + it.effect.each(["cancelled", "denied", "permission_denied", undefined])( + "preserves native tool non-execution metadata %s without inferring a denial from text", + (kind) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-tool-non-execution"), + text: "Run the tool.", + attachments: [], + }), + ); + // Same error text can describe a cancellation or a real refusal. + // Each result must use its own metadata, even in a multi-result frame. + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "user", + uuid: "tool-non-execution", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-error", + is_error: true, + content: "STOP and wait for the user.", + }, + { type: "tool_result", tool_use_id: "tool-ok", is_error: false, content: "OK" }, + ], + }, + ...(kind === undefined + ? {} + : { + tool_result_meta: [ + { id: "tool-error", non_execution_kind: kind }, + { id: "tool-ok", non_execution_kind: null }, + ], + }), + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "result-non-execution", result: "Done" }), + ); + yield* Queue.take(harness.terminalReceipts); + const items = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" + ? [event.turnItem] + : [], + ); + const failed = items.findLast((item) => item.nativeItemRef?.nativeId === "tool-error")!; + assert.equal(failed.status, kind === "cancelled" ? "cancelled" : "failed"); + assert.equal(failed.toolNonExecutionKind, kind); + const ok = items.findLast((item) => item.nativeItemRef?.nativeId === "tool-ok")!; + assert.equal(ok.status, "completed"); + assert.equal(ok.toolNonExecutionKind, undefined); + const node = harness.events.findLast( + (event) => + event.type === "node.updated" && event.node.nativeItemRef?.nativeId === "tool-error", + ); + assert.equal(node?.type === "node.updated" ? node.node.status : undefined, failed.status); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + it.effect.each( (["aborted_tools", "aborted_streaming"] as const).flatMap((terminalReason) => [true, false].map((steered) => ({ terminalReason, steered })), @@ -4325,6 +4590,89 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("stores a Bash result's stdout and stderr as command output", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const attemptId = RunAttemptId.make("attempt-claude-bash-output"); + const bashToolUseId = "toolu_01BashOutput"; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId, + text: "Run it.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_bash_output", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: bashToolUseId, + name: "Bash", + input: { command: "git status" }, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000790", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { type: "tool_result", tool_use_id: bashToolUseId, content: "On branch main" }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000791", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: { + stdout: "On branch main", + stderr: "warning: dirty", + interrupted: false, + isImage: false, + }, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000792", result: "Done." }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + + const bash = harness.events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === bashToolUseId, + ); + assert.equal( + bash?.type === "turn_item.updated" && bash.turnItem.type === "command_execution" + ? bash.turnItem.output + : undefined, + "On branch main\nwarning: dirty", + ); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + it.effect("answers an approval a held wake turn raises without waiting for the echo", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 0aca36abe77c..a9e3b8cfff0a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -67,7 +67,6 @@ import { type ProviderUserInputAnswers, type ProviderThreadId, type ThreadId, - type ToolActivitySource, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -116,6 +115,7 @@ import type { ServerProviderShape } from "../../provider/Services/ServerProvider import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "../../provider/T3OrchestrationInstructions.ts"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; +import { mcpToolPresentation, normalizeMcpText } from "../../provider/McpToolPresentation.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as IdAllocator from "../IdAllocator.ts"; import { makeProviderFailure, makeProviderRetryTurnItem } from "../ProviderFailure.ts"; @@ -198,6 +198,7 @@ export const ClaudeProviderCapabilitiesV2 = { emitsTurnCompleted: true, supportsInterrupt: true, supportsActiveSteering: true, + activeSteeringInterruptsTools: true, supportsSteeringByInterruptRestart: false, supportsQueuedMessages: true, terminalStatusQuality: "strong", @@ -1864,6 +1865,28 @@ function claudeNativeToolOutputText(output: ClaudeNativeToolOutput): string { return typeof value === "string" ? value : value === undefined ? "" : jsonStringifyForTool(value); } +/** + * Bash results arrive as `{ stdout, stderr, interrupted, ... }`; keep only the + * text. A background run has empty streams, so keep its acknowledgement instead. + */ +function claudeCommandOutputText(output: ClaudeNativeToolOutput): string { + const value = claudeNativeToolOutputValue(output); + if (typeof value === "object" && value !== null) { + const stdout = Reflect.get(value, "stdout"); + const stderr = Reflect.get(value, "stderr"); + if (typeof stdout === "string" || typeof stderr === "string") { + const text = [stdout, stderr] + .filter((part): part is string => typeof part === "string" && part.trim().length > 0) + .join("\n"); + if (text.length > 0) return text; + return output.type === "structured_tool_use_result" && output.fallbackValue !== undefined + ? claudeSubagentResultText({ type: "content_block", value: output.fallbackValue }) + : ""; + } + } + return claudeNativeToolOutputText(output); +} + function claudeSubagentResultText(output: ClaudeNativeToolOutput): string { const value = claudeNativeToolOutputValue(output); const content = Array.isArray(value) @@ -1904,6 +1927,8 @@ function isClaudeSubagentAsyncLaunchAck(output: ClaudeNativeToolOutput): boolean return claudeSubagentResultText(output).startsWith("Async agent launched successfully."); } +const WEB_FETCH_SNIPPET_MAX_CHARS = 8_000; + function webSearchPatternsFromClaudeTool(input: { readonly toolInput: ClaudeNativeToolInput; readonly output: ClaudeNativeToolOutput; @@ -2065,62 +2090,33 @@ function claudeToolUseBlocksFromAssistantMessage( return message.message.content.filter(isClaudeToolUseContentBlock); } -interface ClaudeToolPresentation { - readonly title: string; - readonly toolSource?: ToolActivitySource; -} - -function boundedMetaText(value: unknown, maxLength: number): string | undefined { - if (typeof value !== "string") return undefined; - const text = value.trim().replace(/\s+/gu, " "); - return text.length > 0 && text.length <= maxLength ? text : undefined; -} - -function metaHttpUrl(value: unknown): string | undefined { - if (typeof value !== "string" || value.length > 4096) return undefined; - try { - const url = new URL(value); - return (url.protocol === "https:" || url.protocol === "http:") && url.href.length <= 4096 - ? url.href - : undefined; - } catch { - return undefined; - } -} +type ClaudeToolPresentation = ReturnType; -/** - * Claude Code's own label for each tool use (the MCP tool's title, or its - * humanized name) plus the server's name and icon. The CLI sends these as - * `tool_use_meta` on assistant frames; the SDK types do not declare it yet. - */ function claudeToolPresentationsFromAssistantMessage( message: SDKMessage, ): ReadonlyMap { const presentations = new Map(); const meta = message.type === "assistant" ? Reflect.get(message, "tool_use_meta") : undefined; if (!Array.isArray(meta)) return presentations; + const toolNames = new Map( + claudeToolUseBlocksFromAssistantMessage(message).map((tool) => [ + tool.id, + tool.name.replace(/^mcp__claude_ai_/u, "mcp__"), + ]), + ); for (const entry of meta) { if (typeof entry !== "object" || entry === null) continue; - const id = boundedMetaText(Reflect.get(entry, "id"), 512); - const title = boundedMetaText(Reflect.get(entry, "display_name"), 160); - if (id === undefined || title === undefined) continue; - const serverName = boundedMetaText(Reflect.get(entry, "server_display_name"), 160); - const iconUrl = metaHttpUrl(Reflect.get(entry, "icon_url")); - presentations.set(id, { - title, - ...(serverName === undefined - ? {} - : { - toolSource: { - key: `mcp:${serverName.toLowerCase()}`, - name: serverName, - kind: "integration", - ...(iconUrl === undefined - ? {} - : { icon: { _tag: "themed-logo", logoUrl: iconUrl } as const }), - }, - }), - }); + const id = normalizeMcpText(Reflect.get(entry, "id"), 512); + if (id === undefined) continue; + presentations.set( + id, + mcpToolPresentation({ + toolName: toolNames.get(id), + title: Reflect.get(entry, "display_name"), + serverDisplayName: Reflect.get(entry, "server_display_name"), + iconUrl: Reflect.get(entry, "icon_url"), + }), + ); } return presentations; } @@ -2168,6 +2164,24 @@ function claudeToolResultEntriesFromMessage(message: SDKMessage): ReadonlyArray< ]; } +// These wire fields are not yet declared by the SDK's SDKUserMessage type. +const ClaudeToolResultMetadata = Schema.Struct({ + tool_result_meta: Schema.Array( + Schema.Struct({ + id: Schema.String, + non_execution_kind: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), +}); +const isClaudeToolResultMetadata = Schema.is(ClaudeToolResultMetadata); + +function claudeToolNonExecutionKind(message: SDKMessage, toolUseId: string) { + return isClaudeToolResultMetadata(message) + ? (message.tool_result_meta.find((meta) => meta.id === toolUseId)?.non_execution_kind ?? + undefined) + : undefined; +} + function parentToolUseIdFromSdkMessage(message: SDKMessage): string | null { return message.type === "assistant" || message.type === "user" ? message.parent_tool_use_id @@ -3710,6 +3724,7 @@ export function makeClaudeAdapterV2( readonly startedAt: DateTime.Utc; readonly updatedAt: DateTime.Utc; readonly presentation: ClaudeToolPresentation | undefined; + readonly toolNonExecutionKind?: string; }) => { const completedAt = input.status === "running" ? null : input.updatedAt; const nodeId = idAllocator.derive.nodeFromProviderItem({ @@ -3757,6 +3772,9 @@ export function makeClaudeAdapterV2( }) : undefined; const itemBase = { + ...(input.toolNonExecutionKind === undefined + ? {} + : { toolNonExecutionKind: input.toolNonExecutionKind }), id: turnItemId, threadId: input.threadId, runId: input.runId, @@ -3807,8 +3825,12 @@ export function makeClaudeAdapterV2( output: input.output, }); const webSearchResults = webSearchResultsFromClaudeOutput(input.output); + const webFetchUrl = firstStringInputField(input.toolInput, ["url"])?.trim(); const outputValue = claudeNativeToolOutputValue(input.output); - const outputText = claudeNativeToolOutputText(input.output); + const outputText = + itemType === "command_execution" + ? claudeCommandOutputText(input.output) + : claudeNativeToolOutputText(input.output); const turnItem: OrchestrationV2TurnItem = itemType === "command_execution" ? { @@ -3831,11 +3853,28 @@ export function makeClaudeAdapterV2( ...(webSearchPatterns.length === 0 ? {} : { patterns: [...webSearchPatterns] }), - ...(webSearchResults.length === 0 ? {} : { results: [...webSearchResults] }), + ...(webSearchResults.length > 0 + ? { results: [...webSearchResults] } + : input.classification.normalizedName === "webfetch" && + outputText.trim().length > 0 + ? { + // WebFetch returns page text, not search hits. Keep a + // bounded preview so the row has something to show. + results: [ + { + ...(webFetchUrl === undefined ? {} : { url: webFetchUrl }), + snippet: outputText.slice(0, WEB_FETCH_SNIPPET_MAX_CHARS), + }, + ], + } + : {}), } : { ...itemBase, type: "dynamic_tool", + ...(input.presentation?.toolIcon === undefined + ? {} + : { toolIcon: input.presentation.toolIcon }), ...(input.presentation?.toolSource === undefined ? {} : { toolSource: input.presentation.toolSource }), @@ -6011,7 +6050,11 @@ export function makeClaudeAdapterV2( toolName: toolUse.name, toolInput: nativeToolInput, parentToolUseId: parentToolUseIdFromSdkMessage(message), - presentation: toolPresentations.get(toolUse.id), + presentation: + toolPresentations.get(toolUse.id) ?? + mcpToolPresentation({ + toolName: toolUse.name.replace(/^mcp__claude_ai_/u, "mcp__"), + }), }); const heldPlan = heldProposedPlansByToolUseId.get(toolUse.id); if (heldPlan !== undefined) { @@ -6060,6 +6103,10 @@ export function makeClaudeAdapterV2( parentToolUseId, })); const completedAt = yield* DateTime.now; + const toolNonExecutionKind = claudeToolNonExecutionKind( + message, + toolResult.tool_use_id, + ); const artifacts = buildToolCallArtifacts({ context, nativeItemId: toolCall.nativeItemId, @@ -6072,7 +6119,13 @@ export function makeClaudeAdapterV2( parentNodeId: toolCall.parentNodeId, ordinal: toolCall.ordinal, output, - status: isClaudeToolResultError(toolResult) ? "failed" : "completed", + status: + toolNonExecutionKind === "cancelled" + ? "cancelled" + : isClaudeToolResultError(toolResult) + ? "failed" + : "completed", + ...(toolNonExecutionKind === undefined ? {} : { toolNonExecutionKind }), startedAt: toolCall.startedAt, updatedAt: completedAt, presentation: toolCall.presentation, @@ -6927,11 +6980,12 @@ export function makeClaudeAdapterV2( const openedWithResume = (yield* Ref.get(openedNativeThreads)).has(nativeThreadId); // openedNativeThreads is per session instance and is lost when the - // provider session is idle-released. A prior persisted provider turn - // proves the native session already exists, so the query must resume - // it; reopening with a fixed session id makes the CLI fail fast with - // "Session ID ... is already in use". - const hasPersistedProviderTurn = turnInput.providerTurnOrdinal > 1; + // provider session is idle-released. A prior turn on this native id + // requires resume; sessionId would fail with "already in use". + // A fresh-session fallback keeps provider-thread history but binds + // a new native id, which must be created before it can be resumed. + const hasPersistedProviderTurn = + turnInput.nativeThreadHasTurns ?? turnInput.providerTurnOrdinal > 1; const shouldResume = resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; const queryOptions = makeClaudeQueryOptions({ diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 85748fa056e7..f8b635a9fa9c 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -823,14 +823,62 @@ describe("CodexAdapterV2 dynamic tool projection", () => { ); assert.equal( CodexAdapterV2.projectCodexDynamicToolItem({ ...call, arguments: { title: " " } }).title, - undefined, + "js", ); assert.equal( CodexAdapterV2.projectCodexDynamicToolItem({ ...call, server: "github" }).title, - undefined, + "js", ); }); + it.each(["inProgress", "completed", "failed"] as const)( + "presents ordinary MCP calls when %s", + (status) => { + const projection = CodexAdapterV2.projectCodexDynamicToolItem({ + type: "mcpToolCall", + id: "weather-call", + server: "weather", + tool: "get_weather", + status, + arguments: { city: "Berlin" }, + }); + assert.equal(projection.title, "get weather"); + assert.deepEqual(projection.toolSource, { + key: "mcp:weather", + name: "weather", + kind: "integration", + }); + assert.deepEqual(projection.input, { city: "Berlin" }); + }, + ); + + it("uses Codex connector names without reading a display title from arguments", () => { + const projection = CodexAdapterV2.projectCodexDynamicToolItem({ + type: "mcpToolCall", + id: "connector-call", + server: "_apps", + tool: "connector_get_weather", + status: "completed", + arguments: { title: "Argument, not display metadata" }, + appContext: { + connectorId: "weather-app", + appName: "Weather", + actionName: "Check weather", + }, + result: { + content: [], + _meta: { source: { logoUrl: "https://example.com/weather.png" } }, + }, + }); + assert.equal(projection.title, "Check weather"); + assert.equal(projection.toolSource?.name, "Weather"); + assert.deepEqual(projection.toolIcon, { + _tag: "themed-logo", + logoUrl: "https://example.com/weather.png", + }); + assert.deepEqual(projection.toolSource?.icon, projection.toolIcon); + }); + it("preserves native browser and app icons alongside MCP tool output", () => { const browser = CodexAdapterV2.projectCodexDynamicToolItem({ type: "mcpToolCall", @@ -3869,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({ @@ -3888,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 3727b419fe1d..019919807908 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -489,9 +489,10 @@ export function projectCodexDynamicToolItem( item.type === "mcpToolCall" ? `${item.server}.${item.tool}` : [trimText(item.namespace), item.tool].filter(Boolean).join("."); - const title = dynamicToolTitle(toolName, item.arguments); + const presentation = item.type === "mcpToolCall" ? mcpToolPresentation(item) : {}; + const title = dynamicToolTitle(toolName, item.arguments) ?? presentation.title; const projection: CodexDynamicToolProjection = { - ...(item.type === "mcpToolCall" ? mcpToolPresentation(item) : {}), + ...presentation, toolName, ...(title ? { title } : {}), input: item.arguments, @@ -2053,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/Adapters/CursorAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts index 3a172baa67c8..60ece7a42f3a 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts @@ -359,6 +359,22 @@ describe("CursorAdapterV2", () => { numFiles: 0, }; const updates: ReadonlyArray = [ + ...(["tool-call-started", "tool-call-completed"] as const).map((type) => ({ + type, + modelCallId: "native-model-call", + callId: "mcp-weather", + toolCall: { + type: "mcp" as const, + args: { + providerIdentifier: "weather", + toolName: "get_weather", + args: { city: "Berlin" }, + }, + ...(type === "tool-call-completed" + ? { result: { status: "success" as const, value: { content: [], isError: false } } } + : {}), + }, + })), { type: "tool-call-completed", modelCallId: "native-model-call", @@ -430,6 +446,26 @@ describe("CursorAdapterV2", () => { result: { status: "error", error: "ENOENT" }, }, }, + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "grep-failed", + toolCall: { + type: "grep", + args: { pattern: "TODO", path: "src" }, + result: { status: "error", error: "search failed" }, + }, + }, + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "glob-failed", + toolCall: { + type: "glob", + args: { globPattern: "*.ts" }, + result: { status: "error", error: "search failed" }, + }, + }, { type: "tool-call-completed", modelCallId: "native-model-call", @@ -605,6 +641,30 @@ describe("CursorAdapterV2", () => { Stream.takeUntil((event) => event.type === "turn.terminal"), Stream.runCollect, ); + const mcpItems = events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.toolName === "mcp__weather__get_weather" + ? [event.turnItem] + : [], + ); + assert.deepEqual( + mcpItems.map((item) => item.status), + ["running", "completed"], + ); + for (const item of mcpItems) { + assert.equal(item.title, "get weather"); + assert.deepEqual(item.toolSource, { + key: "mcp:weather", + name: "weather", + kind: "integration", + }); + assert.deepEqual(item.input, { + providerIdentifier: "weather", + toolName: "get_weather", + args: { city: "Berlin" }, + }); + } const fileSearchItems = events.flatMap((event) => event.type === "turn_item.updated" && event.turnItem.type === "file_search" && @@ -648,7 +708,17 @@ describe("CursorAdapterV2", () => { { pattern: path.join(workspace, "missing"), status: "failed", - results: undefined, + results: [{ fileName: path.join(workspace, "missing"), preview: "ENOENT" }], + }, + { + pattern: "TODO", + status: "failed", + results: [{ fileName: "src", preview: "search failed" }], + }, + { + pattern: "*.ts", + status: "failed", + results: [{ fileName: ".", preview: "search failed" }], }, { pattern: "src/a.ts, src/b.ts", @@ -667,7 +737,7 @@ describe("CursorAdapterV2", () => { { pattern: "src/a.ts", status: "failed", - results: undefined, + results: [{ fileName: "src/a.ts", preview: "lint failed" }], }, ], ); diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts index bb9de20da64f..948d74762492 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts @@ -44,6 +44,7 @@ import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import * as ServerConfig from "../../config.ts"; +import { mcpToolPresentation } from "../../provider/McpToolPresentation.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { CursorTransportFailure } from "../../provider/acp/CursorTransportFailure.ts"; import { cursorSdkModelSelection } from "../../provider/cursorSdkModel.ts"; @@ -1227,7 +1228,10 @@ export function makeCursorAdapterV2( toolCall.result?.status === "success" && toolCall.result.value.diffString !== undefined ? { diffStr: toolCall.result.value.diffString } - : {}), + : // A failed change keeps its error where the diff would be. + toolCall.result?.status === "error" && outputText.trim().length > 0 + ? { diffStr: outputText } + : {}), ...(toolCall.type === "write" ? { newStr: toolCall.args.fileText } : {}), }; break; @@ -1248,8 +1252,20 @@ export function makeCursorAdapterV2( case "ls": case "readLints": case "semSearch": { - const results = cursorToolSearchResults(toolCall, path); const pattern = cursorToolSearchPattern(toolCall); + const searchPath = + toolCall.type === "grep" + ? toolCall.args.path + : toolCall.type === "glob" + ? toolCall.args.targetDirectory + : toolCall.type === "semSearch" + ? toolCall.args.targetDirectories?.join(", ") + : pattern; + // A failed search keeps its error as one row under the searched path. + const results = + toolCall.result?.status === "error" && outputText.trim().length > 0 + ? [{ fileName: searchPath?.trim() || ".", preview: outputText }] + : cursorToolSearchResults(toolCall, path); turnItem = { ...base, title: @@ -1267,6 +1283,12 @@ export function makeCursorAdapterV2( turnItem = { ...base, type: "dynamic_tool", + ...(toolCall.type === "mcp" + ? mcpToolPresentation({ + serverName: toolCall.args.providerIdentifier, + toolName: toolCall.args.toolName, + }) + : {}), toolName: cursorToolName(toolCall), input: toolCall.args, ...(cursorToolOutput(toolCall) === undefined diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index d1e81eb725c4..bcc9bcc646bb 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -78,6 +78,7 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; import { t3OrchestrationSystemPrompt } from "../../provider/T3OrchestrationInstructions.ts"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import { causeErrorTag } from "@t3tools/shared/observability"; @@ -887,18 +888,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const stagedReverts = new Set(); // Starting a turn and cutting the history take turns on a session: each // checks that the other is not running before its own requests yield. - const sessionGates = new Map(); + const sessionGates = yield* KeyedLock.make(); const exclusive = (providerThread: OrchestrationV2ProviderThread) => (effect: Effect.Effect) => { const sessionId = providerThread.nativeThreadRef?.nativeId; if (sessionId == null) return effect; - let gate = sessionGates.get(sessionId); - if (gate === undefined) { - gate = Semaphore.makeUnsafe(1); - sessionGates.set(sessionId, gate); - } - return gate.withPermit(effect); + return sessionGates.withLock(sessionId, effect); }; const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index eca4aa9115a4..5ae9368a784e 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -833,10 +833,12 @@ describe("OpenCodeAdapterV2", () => { Stream.runCollect, Effect.forkScoped, ); - for (const [tool, input] of [ - ["read", { filePath: "src/env.ts" }], - ["grep", { pattern: "TODO", path: "apps/web" }], - ["websearch", { query: "OpenCode documentation" }], + for (const [tool, input, output] of [ + ["read", { filePath: "src/env.ts" }, "---\nfile body"], + ["grep", { pattern: "TODO", path: "apps/web" }, "---\nfile body"], + ["websearch", { query: "OpenCode documentation" }, "---\nfile body"], + ["glob", { pattern: "missing", path: "apps/web" }, ""], + ["codesearch", {}, " \n\t"], ] as const) { yield* Effect.promise(() => nativeEvents.push({ @@ -853,7 +855,7 @@ describe("OpenCodeAdapterV2", () => { state: { status: "completed", input, - output: "---\nfile body", + output, title: tool, metadata: {}, time: { start: 1, end: 2 }, @@ -877,10 +879,270 @@ describe("OpenCodeAdapterV2", () => { const grep = items.find((item) => item.type === "file_search"); assert.equal(grep?.title, "Searched TODO in web"); assert.equal(grep?.type === "file_search" ? grep.pattern : null, "TODO"); + assert.deepEqual(grep?.type === "file_search" ? grep.results : null, [ + { fileName: "apps/web", preview: "---\nfile body" }, + ]); const webSearch = items.find((item) => item.type === "web_search"); assert.deepEqual(webSearch?.type === "web_search" ? webSearch.patterns : null, [ "OpenCode documentation", ]); + assert.deepEqual(webSearch?.type === "web_search" ? webSearch.results : null, [ + { snippet: "---\nfile body" }, + ]); + const emptyFileSearch = items.find( + (item) => item.type === "file_search" && item.pattern === "missing", + ); + assert.ok(emptyFileSearch?.type === "file_search"); + assert.equal(emptyFileSearch.results, undefined); + const emptyWebSearch = items.find( + (item) => item.type === "web_search" && item.patterns === undefined, + ); + assert.ok(emptyWebSearch?.type === "web_search"); + assert.equal(emptyWebSearch.results, undefined); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect("presents OpenCode MCP calls without treating remote tools as local edits", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const nativeSessionId = "native-opencode-mcp"; + let statusReads = 0; + const harness = yield* makeOpenCodeRuntimeHarness("mcp-presentation", nativeSessionId, { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ data: { id: nativeSessionId, time: { created: 1, updated: 1 } } }), + promptAsync: async () => ({ data: true }), + }, + mcp: { + status: async () => { + statusReads++; + return { + data: { + "my.server_with_underscores": { status: "connected" }, + ambiguous: { status: "connected" }, + ambiguous_server: { status: "connected" }, + code: { status: "connected" }, + apply: { status: "connected" }, + }, + }; + }, + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ), + Stream.runCollect, + Effect.forkScoped, + ); + for (const status of ["running", "completed", "error"] as const) { + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + sessionID: nativeSessionId, + part: { + id: "mcp-part", + sessionID: nativeSessionId, + messageID: "mcp-assistant", + type: "tool", + callID: "mcp-call", + tool: "my_server_with_underscores_edit_document", + state: { + status, + input: { document: "remote-doc" }, + title: "Edit remote document", + metadata: {}, + output: "saved", + error: "failed", + time: { start: 1, end: 2 }, + }, + }, + }, + }), + ); + } + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + sessionID: nativeSessionId, + part: { + id: "ambiguous-part", + sessionID: nativeSessionId, + messageID: "mcp-assistant", + type: "tool", + callID: "ambiguous-call", + tool: "ambiguous_server_edit_document", + state: { + status: "completed", + input: {}, + title: "Ambiguous tool", + metadata: {}, + output: "", + time: { start: 1, end: 2 }, + }, + }, + }, + }), + ); + for (const tool of ["code_search", "apply_patch"]) { + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + sessionID: nativeSessionId, + part: { + id: `native-${tool}`, + sessionID: nativeSessionId, + messageID: "mcp-assistant", + type: "tool", + callID: `native-${tool}`, + tool, + state: { + status: "completed", + input: { query: "needle", filePath: "local.ts" }, + title: `Native ${tool}`, + metadata: {}, + output: "done", + time: { start: 1, end: 2 }, + }, + }, + }, + }), + ); + } + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.compacted", + properties: { sessionID: nativeSessionId }, + }), + ); + const allItems = (yield* Fiber.join(received)).flatMap((event) => + event.type === "turn_item.updated" ? [event.turnItem] : [], + ); + assert.equal( + allItems.find((item) => item.title === "Native code_search")?.type, + "web_search", + ); + assert.equal( + allItems.find((item) => item.title === "Native apply_patch")?.type, + "file_change", + ); + const items = allItems.filter((item) => item.type === "dynamic_tool"); + assert.deepEqual( + items.slice(0, 3).map((item) => item.status), + ["running", "completed", "failed"], + ); + assert.deepEqual( + items.slice(0, 3).map((item) => item.title), + ["Edit remote document", "Edit remote document", "edit document"], + ); + for (const item of items.slice(0, 3)) { + assert.deepEqual(item.toolSource, { + key: "mcp:my.server_with_underscores", + name: "my.server with underscores", + kind: "integration", + }); + assert.deepEqual(item.input, { document: "remote-doc" }); + } + assert.equal(items[3]?.toolName, "ambiguous_server_edit_document"); + assert.isUndefined(items[3]?.toolSource); + assert.equal(statusReads, 1); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + + it.effect.each(["failure", "timeout"] as const)("retries MCP status after %s", (failure) => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const called = promiseGate(); + const sessionId = `mcp-status-${failure}`; + let statusReads = 0; + let signal: AbortSignal | undefined; + const harness = yield* makeOpenCodeRuntimeHarness(sessionId, sessionId, { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), + promptAsync: async () => ({ data: true }), + }, + mcp: { + status: async (_input: unknown, options: { signal?: AbortSignal }) => { + statusReads++; + if (statusReads === 1) { + signal = options?.signal; + called.resolve(); + if (failure === "timeout") return new Promise(() => {}); + throw new Error("MCP status unavailable"); + } + return { data: { weather: { status: "connected" } } }; + }, + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ), + Stream.runCollect, + Effect.forkScoped, + ); + for (const status of ["running", "completed"] as const) { + const emitted = yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + sessionID: sessionId, + part: { + id: "weather-part", + sessionID: sessionId, + messageID: "weather-message", + type: "tool", + callID: "weather-call", + tool: "weather_edit_document", + state: { + status, + input: { document: "remote-doc" }, + title: "Edit remote document", + metadata: {}, + output: "saved", + time: { start: 1, end: 2 }, + }, + }, + }, + }), + ).pipe(Effect.forkScoped); + if (status === "running" && failure === "timeout") { + yield* Effect.promise(() => called.promise); + yield* TestClock.adjust("1 second"); + } + yield* Fiber.join(emitted); + } + yield* Effect.promise(() => + nativeEvents.push({ type: "session.compacted", properties: { sessionID: sessionId } }), + ); + const completed = (yield* Fiber.join(received)).find( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.status === "completed", + ); + assert.equal(statusReads, 2); + assert.equal( + completed?.type === "turn_item.updated" && completed.turnItem.title, + "Edit remote document", + ); + assert.deepEqual(completed?.type === "turn_item.updated" && completed.turnItem.toolSource, { + key: "mcp:weather", + name: "weather", + kind: "integration", + }); + if (failure === "timeout") assert.isTrue(signal?.aborted); }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), ); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts index b18c35b7de8e..0694ec76cc7b 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts @@ -383,6 +383,7 @@ function makeReplayClient(controller: OpenCodeReplayController): OpencodeClient reply: (input: unknown) => request("question.reply", input), }, mcp: { + status: () => request("mcp.status", {}), add: (input: unknown) => request("mcp.add", input), }, } as unknown as OpencodeClient; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index 11d6da13c7d5..fdae326f9238 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -56,6 +56,7 @@ import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import * as ServerConfig from "../../config.ts"; +import { mcpToolPresentation } from "../../provider/McpToolPresentation.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts"; @@ -286,6 +287,7 @@ interface ActiveOpenCodeTurn { readonly parts: Map>; readonly partIdsByMessage: Map>; readonly toolNamesByCallId: Map; + mcpServerNames?: ReadonlyArray; readonly providerTurn: OrchestrationV2ProviderTurn; nextItemOrdinal: number; nativeUserMessageId: string | null; @@ -1593,12 +1595,48 @@ export function makeOpenCodeAdapterV2( | "completedAt" | "updatedAt" >; - const turnItem = openCodeToolTurnItem(base, { - name: part.tool, - input: toolInput(part), - output: toolOutput(part), - completedMetadata: part.state.status === "completed" ? part.state.metadata : undefined, - }); + const input = toolInput(part); + const output = toolOutput(part); + const isNativeTool = part.tool === "code_search" || part.tool === "apply_patch"; + if (!isNativeTool && part.tool.includes("_") && turn.mcpServerNames === undefined) { + const serverNames = yield* OpenCodeRuntime.runOpenCodeSdk("mcp.status", (signal) => + client.mcp.status(undefined, { signal, throwOnError: true }), + ).pipe( + Effect.timeout("1 second"), + Effect.map((response) => Object.keys(response.data ?? {})), + Effect.catch(() => Effect.succeed(undefined)), + ); + if (serverNames !== undefined) turn.mcpServerNames = serverNames; + } + const matchingServers = turn.mcpServerNames?.filter( + (name) => + !isNativeTool && part.tool.startsWith(`${name.replace(/[^a-zA-Z0-9_-]/g, "_")}_`), + ); + const serverName = matchingServers?.length === 1 ? matchingServers[0] : undefined; + const presentation = + serverName === undefined + ? {} + : mcpToolPresentation({ + serverName, + toolName: part.tool.slice(serverName.replace(/[^a-zA-Z0-9_-]/g, "_").length + 1), + title: toolTitle(part) === part.tool ? undefined : toolTitle(part), + }); + const turnItem: OrchestrationV2TurnItem = matchingServers?.length + ? { + ...base, + type: "dynamic_tool", + ...presentation, + toolName: part.tool, + input, + ...(output === undefined ? {} : { output }), + } + : openCodeToolTurnItem(base, { + name: part.tool, + input, + output, + completedMetadata: + part.state.status === "completed" ? part.state.metadata : undefined, + }); yield* emitProviderEvent({ type: "node.updated", driver: OPENCODE_PROVIDER, diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts index d4d8c86df654..1b01b2b8007c 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts @@ -6,6 +6,9 @@ import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; +// Search results stay on the timeline wire, so keep their text a preview. +const SEARCH_PREVIEW_MAX_CHARS = 8_000; + type ToolItemBase = Omit< Extract, "type" | "toolName" | "input" | "output" @@ -96,7 +99,10 @@ export function openCodeToolTurnItem( case "file_change": { const oldStr = recordString(input, "oldString", "oldText"); const newStr = recordString(input, "newString", "content", "newText"); - const diffStr = recordString(tool.completedMetadata, "diff", "patch"); + // A failed edit has no diff; keep its error where the diff would be. + const diffStr = + recordString(tool.completedMetadata, "diff", "patch") ?? + (base.status === "failed" && output?.trim() ? output : undefined); return { ...base, type: "file_change", @@ -108,6 +114,9 @@ export function openCodeToolTurnItem( } case "file_search": { const pattern = recordString(input, "pattern", "query", "path", "filePath"); + // OpenCode reports matches as plain text, so keep it as one result row + // under the searched path, like the ACP search projection. + const searchRoot = (recordString(input, "path", "filePath") ?? pattern)?.trim(); return { ...base, title: @@ -115,14 +124,32 @@ export function openCodeToolTurnItem( base.title, type: "file_search", ...(pattern === undefined ? {} : { pattern }), + ...(!output?.trim() || searchRoot === undefined + ? {} + : { + results: [ + { fileName: searchRoot, preview: output.slice(0, SEARCH_PREVIEW_MAX_CHARS) }, + ], + }), }; } case "web_search": { const pattern = recordString(input, "query", "url", "pattern"); + const url = recordString(input, "url")?.trim(); return { ...base, type: "web_search", ...(pattern === undefined ? {} : { patterns: [pattern] }), + ...(!output?.trim() + ? {} + : { + results: [ + { + ...(url === undefined ? {} : { url }), + snippet: output.slice(0, SEARCH_PREVIEW_MAX_CHARS), + }, + ], + }), }; } case "dynamic_tool": { diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index fb912714f12f..f31e0425cdbc 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -1063,6 +1063,47 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect("presents explicitly namespaced MCP extension tools", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + for (const type of ["tool_execution_start", "tool_execution_end"]) { + yield* fake.emit({ + type, + toolCallId: "weather-call", + toolName: "mcp__weather__get_weather", + args: { city: "Berlin" }, + result: { content: [{ type: "text", text: "Sunny" }] }, + isError: false, + }); + const event = yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool", + ); + if (event.type !== "turn_item.updated" || event.turnItem.type !== "dynamic_tool") + return yield* Effect.die("Expected an MCP tool item"); + assert.equal(event.turnItem.title, "get weather"); + assert.equal( + event.turnItem.status, + type === "tool_execution_start" ? "running" : "completed", + ); + assert.deepEqual(event.turnItem.toolSource, { + key: "mcp:weather", + name: "weather", + kind: "integration", + }); + assert.deepEqual(event.turnItem.input, { city: "Berlin" }); + } + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("observes official subagent results without inventing child threads", () => Effect.gen(function* () { const fake = yield* makeFakePi; @@ -1155,6 +1196,102 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect("persists edit patches and write content on file change items", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + const patch = "--- a.ts\n+++ a.ts\n@@ -1 +1 @@\n-old\n+new\n"; + yield* fake.emit({ + type: "tool_execution_start", + toolCallId: "call_edit", + toolName: "edit", + args: { path: "a.ts", edits: [{ oldText: "old", newText: "new" }] }, + }); + yield* fake.emit({ + type: "tool_execution_end", + toolCallId: "call_edit", + toolName: "edit", + isError: false, + result: { + content: [{ type: "text", text: "Successfully replaced 1 block(s) in a.ts." }], + details: { diff: "-1 old\n+1 new", patch, firstChangedLine: 1 }, + }, + }); + const edit = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "file_change" && + event.turnItem.status === "completed", + ); + assert.isTrue( + edit.type === "turn_item.updated" && + edit.turnItem.type === "file_change" && + edit.turnItem.fileName === "a.ts" && + edit.turnItem.diffStr === patch, + ); + + yield* fake.emit({ + type: "tool_execution_start", + toolCallId: "call_write", + toolName: "write", + args: { path: "b.ts", content: "export {};\n" }, + }); + yield* fake.emit({ + type: "tool_execution_end", + toolCallId: "call_write", + toolName: "write", + isError: false, + result: { content: [{ type: "text", text: "Successfully wrote to b.ts" }] }, + }); + const write = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "file_change" && + event.turnItem.status === "completed", + ); + assert.isTrue( + write.type === "turn_item.updated" && + write.turnItem.type === "file_change" && + write.turnItem.fileName === "b.ts" && + write.turnItem.newStr === "export {};\n", + ); + + // A failed edit has no patch, so it keeps the error to show when expanded. + yield* fake.emit({ + type: "tool_execution_start", + toolCallId: "call_edit_failed", + toolName: "edit", + args: { path: "c.ts", edits: [{ oldText: "missing", newText: "new" }] }, + }); + yield* fake.emit({ + type: "tool_execution_end", + toolCallId: "call_edit_failed", + toolName: "edit", + isError: true, + result: { content: [{ type: "text", text: "Could not find the text in c.ts." }] }, + }); + const failedEdit = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "file_change" && + event.turnItem.status === "failed", + ); + assert.isTrue( + failedEdit.type === "turn_item.updated" && + failedEdit.turnItem.type === "file_change" && + failedEdit.turnItem.diffStr === "Could not find the text in c.ts.", + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("settles a command-only prompt from its deferred ack and idle probe", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 6f0bcf258acb..80238ee56bcc 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -61,6 +61,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import * as ServerConfig from "../../config.ts"; +import { mcpToolPresentation } from "../../provider/McpToolPresentation.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { expandPiSkillReference, @@ -988,6 +989,12 @@ export function makePiAdapterV2( if (toolName === "edit" || toolName === "write") { const fileName = recordString(args, "path") ?? recordString(args, "file_path"); if (fileName !== undefined) { + // edit reports a unified patch in its result details; write only + // carries the new content in its args. A failed call keeps its error. + const diffStr = + recordString(recordField(resultRecord, "details"), "patch") ?? + (isError && outputText.trim().length > 0 ? outputText : undefined); + const newStr = toolName === "write" ? recordString(args, "content") : undefined; yield* emit({ type: "turn_item.updated", driver: PI_PROVIDER, @@ -996,6 +1003,8 @@ export function makePiAdapterV2( title: toolName, type: "file_change", fileName, + ...(diffStr === undefined ? {} : { diffStr }), + ...(newStr === undefined ? {} : { newStr }), }, }); return; @@ -1008,6 +1017,7 @@ export function makePiAdapterV2( ...shared, title: toolName, type: "dynamic_tool", + ...mcpToolPresentation({ toolName }), toolName, input: args ?? {}, ...(outputText.length > 0 ? { output: outputText } : {}), diff --git a/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts index 2b1c529ba177..3c183da6268c 100644 --- a/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts +++ b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts @@ -41,7 +41,7 @@ const modelSelection = { instanceId, model: "test-model" }; // Codex turns leave commands running, then the thread moves to another // provider thread (a provider switch). Stop on the newer, settled run must // reach both provider threads and end all of the Codex work. -it.effect("Stop reaches background work an earlier provider thread still runs", () => +const stopEarlierBackgroundWork = (failedStart: boolean) => Effect.scoped( Effect.gen(function* () { const cwd = yield* checkpointWorkspace("background-work-stop"); @@ -233,7 +233,10 @@ it.effect("Stop reaches background work an earlier provider thread still runs", const settledRun = (input: { readonly ordinal: number; readonly providerThreadId: ProviderThreadId; - readonly runningItem?: { readonly id: TurnItemId; readonly kind: "command" | "subagent" }; + readonly runningItem?: { + readonly id: TurnItemId; + readonly kind: "command" | "subagent"; + }; }) => { const runId = RunId.make(`run:${input.ordinal}`); const attemptId = RunAttemptId.make(`attempt:${input.ordinal}`); @@ -407,11 +410,41 @@ it.effect("Stop reaches background work an earlier provider thread still runs", ], }); + const failedRun = settledRun({ ordinal: 5, providerThreadId: otherProviderThreadId }); + if (failedStart) { + yield* sink.write({ + events: failedRun.events.flatMap((event): Array => { + switch (event.type) { + case "provider-turn.updated": + return []; + case "run.created": + return [{ ...event, payload: { ...event.payload, status: "failed" } }]; + case "node.updated": + return [ + { + ...event, + payload: { ...event.payload, status: "failed", providerTurnId: null }, + }, + ]; + case "run-attempt.created": + return [ + { + ...event, + payload: { ...event.payload, status: "failed", providerTurnId: null }, + }, + ]; + default: + return [event]; + } + }), + }); + } + yield* orchestrator.dispatch({ type: "run.interrupt", commandId: CommandId.make("stop-background-work"), threadId, - runId: latestRun.runId, + runId: failedStart ? failedRun.runId : latestRun.runId, }); yield* worker.drain(); @@ -442,5 +475,13 @@ it.effect("Stop reaches background work an earlier provider thread still runs", ), ); }), - ), + ); + +it.effect("Stop reaches background work an earlier provider thread still runs", () => + stopEarlierBackgroundWork(false), +); + +it.effect( + "Stop reaches earlier background work after the newest run fails before provider start", + () => stopEarlierBackgroundWork(true), ); diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index 1471de750357..1338f68328d1 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -14,10 +14,9 @@ import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import * as Semaphore from "effect/Semaphore"; import { parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; @@ -249,29 +248,9 @@ export const layer: Layer.Layer< Effect.gen(function* () { const checkpointStore = yield* CheckpointStore.CheckpointStore; const idAllocator = yield* IdAllocator.IdAllocatorV2; - const workspaceSemaphores = yield* Ref.make(new Map()); - - const getWorkspaceSemaphore = (cwd: string) => - Effect.gen(function* () { - const existing = (yield* Ref.get(workspaceSemaphores)).get(cwd); - if (existing !== undefined) { - return existing; - } - - const created = yield* Semaphore.make(1); - return yield* Ref.modify(workspaceSemaphores, (current) => { - const concurrent = current.get(cwd); - if (concurrent !== undefined) { - return [concurrent, current]; - } - const updated = new Map(current); - updated.set(cwd, created); - return [created, updated]; - }); - }); - + const workspaceLocks = yield* KeyedLock.make(); const withWorkspaceLock = (cwd: string, effect: Effect.Effect) => - Effect.flatMap(getWorkspaceSemaphore(cwd), (semaphore) => semaphore.withPermits(1)(effect)); + workspaceLocks.withLock(cwd, effect); const isGitCheckpointable = (cwd: string) => checkpointStore.isGitRepository(cwd).pipe(Effect.orElseSucceed(() => false)); diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts new file mode 100644 index 000000000000..b13a8b5d90c1 --- /dev/null +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -0,0 +1,420 @@ +import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + ClaudeSettings, + CommandId, + EventId, + MessageId, + NodeId, + ProjectId, + ProviderInstanceId, + ScheduledTaskUpsertInput, + ThreadId, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +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 Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as Scheduler from "../scheduling/Scheduler.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as ClaudeAdapterV2 from "./Adapters/ClaudeAdapterV2.ts"; +import * as EffectWorker from "./EffectWorker.ts"; +import * as EventSink from "./EventSink.ts"; +import * as IdAllocator from "./IdAllocator.ts"; +import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as ThreadLaunchService from "./ThreadLaunchService.ts"; +import * as ThreadManagementService from "./ThreadManagementService.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +const sessionId = "automatic-delivery-session"; +const settings = Schema.decodeSync(ClaudeSettings)({}); +const decodeScheduledTask = Schema.decodeEffect(ScheduledTaskUpsertInput); +const refusal = + "The user doesn't want to take this action right now. STOP what you are doing and wait for the user to tell you how to proceed."; +const statusIds = ["status-1", "status-2", "status-3", "status-4"]; +const modelSelection = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", +}; + +// Native frames match the captured Bash/status batch and tool_result_meta shape. +// The external SDK boundary is replayed; the adapter and durable dispatch are real. +function frame(value: unknown): SDKMessage { + return value as SDKMessage; +} + +const batch = frame({ + type: "assistant", + uuid: "batch", + session_id: sessionId, + parent_tool_use_id: null, + message: { + id: "tool-batch", + role: "assistant", + type: "message", + model: modelSelection.model, + content: [ + { type: "tool_use", id: "build", name: "Bash", input: { command: "make" } }, + ...statusIds.map((id) => ({ + type: "tool_use", + id, + name: "mcp__t3-code__task_status", + input: { taskId: id }, + })), + ], + stop_reason: "tool_use", + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }, +}); + +const toolResult = (id: string, cancelled: boolean) => + frame({ + type: "user", + uuid: `result-${id}`, + session_id: sessionId, + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: id, + is_error: cancelled, + content: cancelled ? refusal : id === "build" ? "build OK" : "Task completed normally", + }, + ], + }, + ...(cancelled ? { tool_result_meta: [{ id, non_execution_kind: "cancelled" }] } : {}), + }); + +const result = (uuid: string, aborted: boolean) => + frame({ + type: "result", + subtype: "success", + uuid, + session_id: sessionId, + is_error: false, + num_turns: 1, + result: "", + stop_reason: "end_turn", + terminal_reason: aborted ? "aborted_tools" : "completed", + permission_denials: [], + duration_ms: 1, + duration_api_ms: 1, + total_cost_usd: 0, + usage: { input_tokens: 1, output_tokens: 1 }, + modelUsage: {}, + }); + +it.effect.each(["child completion", "scheduled message", "user steering"] as const)( + "delivers %s with Claude's native pending-tool cancellation behavior", + (delivery) => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("claude-automatic-delivery"); + const sdkMessages = yield* Queue.unbounded(); + const offers: SDKUserMessage[] = []; + const nativeQueue: SDKUserMessage[] = []; + const batchAbort = new AbortController(); + const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: modelSelection.instanceId, + settings, + environment: {}, + attachmentsDir: cwd, + fileSystem: yield* FileSystem.FileSystem, + path: yield* Path.Path, + idAllocator: yield* IdAllocator.IdAllocatorV2, + queryRunner: { + allocateSessionId: Effect.succeed(sessionId), + open: () => + Effect.succeed({ + messages: Stream.fromQueue(sdkMessages), + offer: (message) => + Effect.sync(() => { + offers.push(message); + nativeQueue.push(message); + // Claude Code 2.1.289's queue watcher aborts when a now-priority + // command arrives, including while Bash blocks pending reads. + if (nativeQueue.some((command) => command.priority === "now")) { + batchAbort.abort({ kind: "interrupt" }); + } + }), + setModel: () => Effect.void, + setPermissionMode: () => Effect.void, + interrupt: Effect.die("automatic delivery must never interrupt"), + close: Effect.void, + }), + forkSession: () => Effect.die("unused"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make("thread:automatic-delivery"); + const projectId = ProjectId.make("project:automatic-delivery"); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId, + title: "Automatic delivery", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const running = yield* watch( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("first"), + threadId, + messageId: MessageId.make("first"), + text: "Build and check all four tasks.", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* Fiber.join(running); + const pending = yield* watch( + (event) => + event.type === "turn-item.updated" && + event.payload.nativeItemRef?.nativeId === "status-4", + ); + yield* Queue.offer(sdkMessages, batch); + yield* Fiber.join(pending); + const before = yield* orchestrator.getThreadProjection(threadId); + assert.equal(before.turnItems.filter((item) => item.status === "running").length, 5); + const parent = before.runs[0]!; + if (parent.rootNodeId === null) return yield* Effect.die("parent has no root node"); + const messageId = MessageId.make("automatic-notice"); + if (delivery === "child completion") { + const taskId = NodeId.make("completed-child"); + const now = yield* DateTime.now; + const sink = yield* EventSink.EventSinkV2; + yield* sink.write({ + events: [ + { + id: EventId.make("cohort"), + type: "run.updated", + threadId, + runId: parent.id, + occurredAt: now, + payload: { + ...parent, + delegatedCompletion: { + disposition: "open", + nextGeneration: 2, + delivery: { generation: 1, messageId, taskIds: [taskId] }, + }, + }, + }, + { + id: EventId.make("child"), + type: "subagent.updated", + threadId, + runId: parent.id, + nodeId: taskId, + occurredAt: now, + payload: { + id: taskId, + threadId, + runId: parent.id, + parentNodeId: parent.rootNodeId, + origin: "app_owned", + createdBy: "agent", + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + providerInstanceId: modelSelection.instanceId, + providerThreadId: null, + childThreadId: null, + nativeTaskRef: null, + prompt: "Background work", + title: "Child", + model: null, + completionWake: "always", + completionDelivery: { state: "claimed", observedByRunId: null }, + status: "completed", + result: "done", + startedAt: now, + completedAt: now, + updatedAt: now, + }, + }, + ], + }); + const command = { + type: "message.dispatch" as const, + commandId: CommandId.make("completion"), + threadId, + messageId, + text: "Child completed", + attachments: [], + dispatchMode: { type: "queue_after_active" as const }, + createdBy: "agent" as const, + creationSource: "server" as const, + delegatedCompletion: { parentRunId: parent.id, generation: 1, taskIds: [taskId] }, + }; + yield* orchestrator.dispatch(command); + yield* orchestrator.dispatch(command); + yield* orchestrator.recoverDelegatedTasks; + } else if (delivery === "scheduled message") { + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const { task } = yield* service.upsert( + yield* decodeScheduledTask({ + title: "Status check", + prompt: "Check the four tasks.", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId, + threadId, + workspaceStrategy: { type: "root" }, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + }), + ); + const ran = yield* service.runNow({ id: task.id }); + assert.equal(ran.task.lastRunStatus, "succeeded"); + }).pipe( + Effect.provide( + ScheduledTaskService.layer.pipe( + Layer.provide(ThreadManagementService.layer), + Layer.provide( + Layer.mock(LegacyV1ThreadImporter.LegacyV1ThreadImporter)({ + ensureTranscript: () => + Effect.succeed({ importedThreadCount: 0, importedMessageCount: 0 }), + }), + ), + Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), + Layer.provide( + Layer.mergeAll(NodeCrypto.layer, Scheduler.layer, SqlitePersistenceMemory), + ), + ), + ), + ); + } else { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("steer"), + threadId, + messageId, + text: "Change direction now.", + attachments: [], + dispatchMode: { type: "steer_active", targetRunId: parent.id }, + createdBy: "user", + creationSource: "web", + }); + } + yield* worker.drain(); + const explicitSteer = delivery === "user steering"; + assert.equal(batchAbort.signal.aborted, explicitSteer); + assert.equal(offers.length, explicitSteer ? 2 : 1); + const finished = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.id === parent.id && + event.payload.status === "waiting", + ); + // Bash finishes before the pending status reads, as in the capture. + yield* Queue.offer(sdkMessages, toolResult("build", false)); + for (const id of statusIds) + yield* Queue.offer(sdkMessages, toolResult(id, batchAbort.signal.aborted)); + if (explicitSteer) yield* Queue.offer(sdkMessages, result("aborted", true)); + yield* Queue.offer(sdkMessages, result("completed", false)); + yield* Fiber.join(finished); + yield* worker.drain(); + yield* orchestrator.resumeQueuedRuns; + yield* worker.drain(); + if (!explicitSteer) { + const queued = (yield* orchestrator.getThreadProjection(threadId)).runs[1]!; + const noticeFinished = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.id === queued.id && + event.payload.status === "waiting", + ); + const delivered = + delivery === "child completion" + ? yield* watch( + (event) => + event.type === "subagent.updated" && + event.payload.completionDelivery?.state === "delivered", + ) + : null; + yield* Queue.offer(sdkMessages, result("notice-completed", false)); + yield* Fiber.join(noticeFinished); + yield* worker.drain(); + if (delivered !== null) yield* Fiber.join(delivered); + } + const after = yield* orchestrator.getThreadProjection(threadId); + const reads = after.turnItems.filter((item) => + statusIds.includes(item.nativeItemRef?.nativeId ?? ""), + ); + assert.equal(reads.length, 4); + for (const read of reads) { + assert.equal(read.status, explicitSteer ? "cancelled" : "completed"); + assert.equal(read.toolNonExecutionKind, explicitSteer ? "cancelled" : undefined); + assert.equal(read.type === "dynamic_tool" && read.output === refusal, explicitSteer); + } + assert.equal(offers.length, 2); + assert.equal( + offers.filter((offer) => offer.priority === "now").length, + explicitSteer ? 1 : 0, + ); + if (!explicitSteer) { + assert.equal(after.runs.length, 2); + if (delivery === "child completion") { + assert.equal(after.messages.filter((message) => message.id === messageId).length, 1); + assert.equal(after.subagents[0]?.completionDelivery?.state, "delivered"); + } else { + assert.equal( + after.messages.filter((message) => message.scheduledTaskId !== undefined).length, + 1, + ); + } + } + yield* orchestrator.recoverDelegatedTasks; + yield* orchestrator.resumeQueuedRuns; + yield* worker.drain(); + assert.equal(offers.length, 2); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: "claude-automatic-delivery" }, + ProviderAdapterRegistry.makeSingleLayer(adapter), + { runEffectWorker: false }, + ), + ), + ); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), +); diff --git a/apps/server/src/orchestration-v2/KeyedSerialExecutor.test.ts b/apps/server/src/orchestration-v2/KeyedSerialExecutor.test.ts deleted file mode 100644 index 6f376b9f330e..000000000000 --- a/apps/server/src/orchestration-v2/KeyedSerialExecutor.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Queue from "effect/Queue"; -import * as Ref from "effect/Ref"; - -import { makeKeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; - -describe("makeKeyedSerialExecutor", () => { - it.effect("allows unrelated keys to run concurrently", () => - Effect.gen(function* () { - const executor = yield* makeKeyedSerialExecutor(); - const arrivals = yield* Queue.unbounded(); - const release = yield* Deferred.make(); - const run = (key: string) => - executor.withLock( - key, - Queue.offer(arrivals, key).pipe(Effect.andThen(Deferred.await(release))), - ); - - const fibers = yield* Effect.forEach(["a", "b"], run, { - concurrency: "unbounded", - discard: false, - }).pipe(Effect.forkChild); - const observed = [yield* Queue.take(arrivals), yield* Queue.take(arrivals)]; - yield* Deferred.succeed(release, undefined); - yield* Fiber.join(fibers); - expect(new Set(observed)).toEqual(new Set(["a", "b"])); - }).pipe(Effect.timeout("1 second")), - ); - - it.effect("serializes work for the same key", () => - Effect.gen(function* () { - const executor = yield* makeKeyedSerialExecutor(); - const firstEntered = yield* Deferred.make(); - const releaseFirst = yield* Deferred.make(); - const events = yield* Ref.make>([]); - - const first = yield* executor - .withLock( - "thread", - Ref.update(events, (current) => [...current, "first:start"]).pipe( - Effect.andThen(Deferred.succeed(firstEntered, undefined)), - Effect.andThen(Deferred.await(releaseFirst)), - Effect.andThen(Ref.update(events, (current) => [...current, "first:end"])), - ), - ) - .pipe(Effect.forkChild); - yield* Deferred.await(firstEntered); - const second = yield* executor - .withLock( - "thread", - Ref.update(events, (current) => [...current, "second:start"]), - ) - .pipe(Effect.forkChild); - - yield* Effect.yieldNow; - expect(yield* Ref.get(events)).toEqual(["first:start"]); - yield* Deferred.succeed(releaseFirst, undefined); - yield* Fiber.join(first); - yield* Fiber.join(second); - expect(yield* Ref.get(events)).toEqual(["first:start", "first:end", "second:start"]); - }).pipe(Effect.timeout("1 second")), - ); -}); 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 42c55371d5f7..fab0276e55ef 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -49,6 +49,7 @@ import { RunId, ThreadLinkedPullRequest, ThreadId, + type TurnItemId, } from "@t3tools/contracts"; import { modelSelectionsEqual } from "@t3tools/shared/model"; import { @@ -264,6 +265,10 @@ export interface OrchestratorV2Shape { options: ProjectionTimelinePageOptions, ) => Effect.Effect; readonly getMessageCount: (threadId: ThreadId) => Effect.Effect; + readonly getTurnItem: (input: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + }) => Effect.Effect; readonly getThreadRecords: ( threadId: ThreadId, fields: ReadonlyArray, @@ -4377,8 +4382,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const source = projection.runs.find((run) => run.id === command.restartContinuationOfRunId); if ( !source || - (source.status !== "cancelled" && - !isRestartNoteSource(source, projection.providerTurns)) || + source.status !== "cancelled" || + isRestartNoteSource(source, projection.providerTurns) || projection.thread.archivedAt !== null || projection.thread.deletedAt !== null || projection.thread.providerInstanceId !== source.providerInstanceId || @@ -4591,7 +4596,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .pipe(Effect.orElseSucceed(() => Option.none())); if ( Option.isSome(session) && - session.value.providerSession.capabilities.turns.supportsActiveSteering + session.value.providerSession.capabilities.turns.supportsActiveSteering && + session.value.providerSession.capabilities.turns.activeSteeringInterruptsTools !== true ) { dispatchMode = { type: "steer_active", targetRunId: active.id }; } @@ -8035,11 +8041,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio activeProviderThreadId: projection.thread.activeProviderThreadId, runs: projection.runs, }).length > 0; - const providerTurn = projection.providerTurns.findLast( - (candidate) => - candidate.runAttemptId === run?.activeAttemptId && - (candidate.status === "running" || hasBackgroundWork), - ); + // A failed start has no provider turn. Background work still belongs + // to the provider thread, so Stop reaches its latest accepted turn. + const providerTurn = + projection.providerTurns.findLast( + (candidate) => + candidate.runAttemptId === run?.activeAttemptId && + (candidate.status === "running" || hasBackgroundWork), + ) ?? + (hasBackgroundWork + ? projection.providerTurns.findLast( + (candidate) => candidate.providerThreadId === run?.providerThreadId, + ) + : undefined); if (run === undefined || rootNode === undefined || providerThread === undefined) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -10067,6 +10081,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projectionStore .getMessageCount(threadId) .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), + getTurnItem: (input) => + projectionStore + .getTurnItem(input) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: input.threadId, cause }), + ), + ), getThreadRecords: (threadId, fields, filter) => projectionStore .getThreadRecords(threadId, fields, filter) @@ -10188,6 +10210,7 @@ const layerUnavailable: Layer.Layer = Layer.succeed( ), getTimelinePage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), getMessageCount: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), + getTurnItem: ({ threadId }) => Effect.fail(new OrchestratorProjectionError({ threadId })), getThreadRecords: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), getThreadProjection: (threadId) => Effect.fail( diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 3e979544c87b..3bb7617eef0f 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -959,6 +959,14 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { assert.strictEqual(older.projection.turnItems[0]?.ordinal, 850); assert.strictEqual(older.projection.turnItems.at(-1)?.ordinal, 925); + // A single item reads back with its full output, scoped to its thread. + const itemId = TurnItemId.make("turn-item:bounded-sql-history:925"); + const stored = yield* projectionStore.getTurnItem({ threadId, itemId }); + assert.strictEqual(stored?.type === "command_execution" ? stored.output : undefined, "ok"); + assert.isNull( + yield* projectionStore.getTurnItem({ threadId: ThreadId.make("thread:other"), itemId }), + ); + const sqlPageLimit = THREAD_HISTORY_PAGE_POLICY.maxItems + 2; const initialSnapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, { rowLimit: sqlPageLimit, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 93f1e1e90de2..a6be3079946f 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -318,6 +318,11 @@ export interface ProjectionStoreV2Shape { readonly getNextTurnItemOrdinal: ( threadId: ThreadId, ) => Effect.Effect; + /** One persisted turn item, or null when the thread has no such item. */ + readonly getTurnItem: (input: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + }) => Effect.Effect; readonly getThreadRecords: ( threadId: ThreadId, fields: ReadonlyArray, @@ -4504,6 +4509,18 @@ export const layer: Layer.Layer = Effect.mapError(controlReadError(threadId)), ); + const getTurnItem: ProjectionStoreV2Shape["getTurnItem"] = ({ threadId, itemId }) => + sql<{ payload_json: string }>`SELECT payload_json + FROM orchestration_v2_projection_turn_items + WHERE turn_item_id = ${itemId} AND thread_id = ${threadId}`.pipe( + Effect.flatMap((rows) => + rows[0] === undefined + ? Effect.succeed(null) + : decodeTurnItemPayload(rows[0].payload_json), + ), + Effect.mapError(controlReadError(threadId)), + ); + const getThreadAttachmentIds: ProjectionStoreV2Shape["getThreadAttachmentIds"] = (threadId) => sql<{ id: string }>` SELECT DISTINCT json_extract(attachment.value, '$.id') AS id @@ -5539,6 +5556,7 @@ export const layer: Layer.Layer = hasUnpairedRunInterruptRequest, getMessageCount, getNextTurnItemOrdinal, + getTurnItem, getThreadRecords, getRuntimeRequest, getPlan, @@ -5791,6 +5809,13 @@ export const layerMemory: Layer.Layer = Layer.effect( ?.turnItems.reduce((max, item) => Math.max(max, item.ordinal), 0) ?? 0) + 1, ), ), + getTurnItem: ({ threadId, itemId }) => + Ref.get(replayState).pipe( + Effect.map( + (state) => + state.projections.get(threadId)?.turnItems.find((item) => item.id === itemId) ?? null, + ), + ), getThreadAttachmentIds: (threadId) => service .getThreadProjection(threadId) diff --git a/apps/server/src/orchestration-v2/ProviderAdapter.ts b/apps/server/src/orchestration-v2/ProviderAdapter.ts index ac300004322e..0db242085c5e 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapter.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapter.ts @@ -398,6 +398,8 @@ export interface ProviderAdapterV2TurnInput { readonly threadId: ThreadId; readonly runId: RunId; readonly runOrdinal: number; + /** Whether the current native session has an accepted turn; omitted when unknown. */ + readonly nativeThreadHasTurns?: boolean; readonly providerTurnOrdinal: number; readonly restartContinuationOfRunId?: RunId; readonly attemptId: RunAttemptId; diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index 98287944dee1..6eb0ef12e605 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -149,33 +149,6 @@ function resolveStaleBackgroundItemProviderInstanceId( return projection.providerThreads[0]?.providerInstanceId ?? projection.thread.providerInstanceId; } -/** - * Provider threads with background work reconciliation would cancel and - * record for their next turn. - */ -function providerThreadsWithOpenBackgroundWork( - projection: ProjectionStore.ProjectionRuntimeRecoveryState, -): ReadonlySet { - const ids = new Set(); - for (const item of projection.turnItems ?? []) { - if ( - !isBackgroundCapableTurnItemType(item.type) || - !isNonterminalTurnItemStatus(item.status) || - isAppOwnedDelegationItem(item) - ) - continue; - const providerThreadId = - item.providerThreadId ?? - projection.runs.find((run) => run.id === item.runId)?.providerThreadId; - if (providerThreadId != null) ids.add(providerThreadId); - } - for (const thread of projection.providerThreads ?? []) { - if (thread.ownerNodeId === null && providerThreadHasPendingBackgroundTasks(thread)) - ids.add(thread.id); - } - return ids; -} - /** * A provider thread's latest started run: the last turn that provider saw. * Restart recovery records the thread's cancelled background work on it, and @@ -668,7 +641,7 @@ export const make = Effect.gen(function* () { } const continuationRun = continueAfterRestart && trigger === "startup" - ? restartContinuationRun(projection, new Set(cancelledBackgroundWork.keys())) + ? restartContinuationRun(projection) : undefined; const effects: Array = continuationRun ? [ @@ -804,12 +777,7 @@ export const make = Effect.gen(function* () { .continueThreadsAfterServerUpdate ) return; - // Shutdown reconciliation cancels the background work below, so a - // settled thread's continuation must be captured while it is still open. - const run = restartContinuationRun( - projection, - providerThreadsWithOpenBackgroundWork(projection), - ); + const run = restartContinuationRun(projection); if (!run) return; const commandId = CommandId.make(`command:restart-prepare:${run.id}`); yield* eventSink.writeWithEffects({ 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 20e240e79c58..307bbb708b18 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -1,3 +1,4 @@ +import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { ModelSelection, @@ -36,7 +37,6 @@ import * as ServerSettings from "../serverSettings.ts"; import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "./IdAllocator.ts"; -import { makeKeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import { ProviderAdapterEventStreamError, @@ -386,9 +386,9 @@ export const layerWithOptions = ( // cannot drop cleanup for threads only the earlier session served. const releaseRecordRetries = yield* FiberSet.make(); const nextSubscriberId = yield* Ref.make(0); - const sessionOpen = yield* makeKeyedSerialExecutor(); + const sessionOpen = yield* KeyedLock.make(); // Orders a thread's attach against a detach unloading it on the same session. - const threadAttachment = yield* makeKeyedSerialExecutor(); + const threadAttachment = yield* KeyedLock.make(); const threadAttachmentKey = (input: { readonly providerSessionId: ProviderSessionId; readonly threadId: ThreadId; @@ -427,7 +427,7 @@ export const layerWithOptions = ( }; const isMcpCredentialReserved = (threadId: ThreadId, mcpCredentialId: string) => (mcpCredentialReservations.get(mcpReservationKey(threadId, mcpCredentialId)) ?? 0) > 0; - const mcpPrepareLock = yield* makeKeyedSerialExecutor(); + const mcpPrepareLock = yield* KeyedLock.make(); /** * Resolves (or mints) the thread's MCP credential and returns it with a * reservation held; the caller must drop the reservation exactly once. @@ -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/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index eba830319aaf..91fe20459ca2 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -227,6 +227,7 @@ it.effect( getTimelinePage: () => Effect.die("Unused timeline read"), getMessageCount: () => Effect.die("unused message count"), getNextTurnItemOrdinal: () => Effect.die("unused ordinal read"), + getTurnItem: () => Effect.die("unused turn item read"), getThreadRecords: () => Effect.die("unused record read"), getRuntimeRequest: () => Effect.die("unused getRuntimeRequest"), getRunningTurnContext: () => Effect.die("unused getRunningTurnContext"), diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index a3e3b122ca45..172b64d8a144 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -1231,6 +1231,13 @@ export const layer: Layer.Layer< .filter((turn) => turn.providerThreadId === providerThread.id) .map((turn) => turn.ordinal), ) + 1, + // Legacy accepted attempts have no native id. They count only before + // a replacement, while no accepted attempt records a native identity. + nativeThreadHasTurns: + nativeInputRunIds.size > 0 || + (legacyInputRunIds.size > 0 && + sameNativeThread && + !acceptedAttempts.some((source) => source.nativeThreadId !== undefined)), shouldStartProviderTurn: runControls.shouldStartProviderTurn, shouldFinalizeRun: runControls.shouldFinalizeRun, hasUnpairedRunInterruptRequest: runControls.hasUnpairedRunInterruptRequest, diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index e7cd4b9476a7..60704e23ec83 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -32,6 +32,8 @@ const driver = ProviderDriverKind.make("codex"); const providerThreadId = ProviderThreadId.make("provider-thread:restart"); const sessionId = ProviderSessionId.make("session:restart"); const attemptId = RunAttemptId.make("attempt:restart"); +// "No project" threads belong to the environment's Scratch project. +const scratchProjectId = ProjectId.make("project:scratch"); function makeProjection() { return { @@ -155,76 +157,63 @@ it("recovers an admitted continuation after another crash before provider start" assert.equal(restartContinuationRun(starting)?.id, runId); }); -it("continues a settled root run only when the restart cancelled its background work", () => { +it("does not continue settled root runs with restart-cancelled background work", () => { const projection = makeProjection(); const settled = { ...projection, - runs: [{ ...projection.runs[0]!, status: "completed" as const }], + runs: [ + { + ...projection.runs[0]!, + status: "completed" as const, + restartCancelledBackgroundWork: [{ kind: "shell" as const, label: "sleep 25" }], + }, + ], providerThreads: [{ ...projection.providerThreads[0]!, status: "idle" as const }], providerSessions: [{ ...projection.providerSessions[0]!, status: "stopped" as const }], providerTurns: [], }; - const lostWork = new Set([providerThreadId]); - assert.isUndefined(restartContinuationRun(settled)); - assert.equal(restartContinuationRun(settled, lostWork)?.id, runId); - // Work an older provider thread launched (before a provider switch) is not - // this run's: its provider was never told about it and cannot continue it. - assert.isUndefined( - restartContinuationRun(settled, new Set([ProviderThreadId.make("provider-thread:claude")])), - ); - for (const invalid of [ - { ...settled, thread: { ...settled.thread, archivedAt: {} } }, - { ...settled, thread: { ...settled.thread, deletedAt: {} } }, - { ...settled, runs: [{ ...settled.runs[0]!, status: "failed" as const }] }, - { - ...settled, - providerThreads: [{ ...settled.providerThreads[0]!, nativeThreadRef: null }], - }, - ]) - assert.isUndefined( - restartContinuationRun(invalid as OrchestrationV2ThreadProjection, lostWork), - ); + for (const projectId of [scratchProjectId, projection.thread.projectId]) + for (const status of ["completed", "waiting"] as const) + assert.isUndefined( + restartContinuationRun({ + ...settled, + thread: { ...settled.thread, projectId }, + runs: [{ ...settled.runs[0]!, status }], + }), + ); }); -it.effect("prompts a settled thread's continuation with the note of its lost work", () => - Effect.gen(function* () { - const base = makeProjection(); - const work = [{ kind: "shell" as const, label: "sleep 25 && echo DONE" }]; - const projection = { - ...base, - runs: [{ ...base.runs[0]!, status: "completed", restartCancelledBackgroundWork: work }], - providerTurns: [{ ...base.providerTurns[0]!, status: "completed" }], - } as unknown as OrchestrationV2ThreadProjection; - const commands: Parameters< - ThreadManagementService.ThreadManagementService["Service"]["dispatch"] - >[0][] = []; - yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( - Effect.provide( - Layer.merge( - Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadRecords: () => Effect.succeed(projection), - recoverDelegatedTask: () => Effect.void, - dispatch: (command) => { - commands.push(command); - return Effect.succeed({} as never); - }, - }), - ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), +it.effect.each(["completed", "waiting", "cancelled"] as const)( + "ignores a pending restart continuation for a %s run whose provider turn settled", + (status) => + Effect.gen(function* () { + const base = makeProjection(); + const work = [{ kind: "shell" as const, label: "sleep 25 && echo DONE" }]; + const projection = { + ...base, + runs: [{ ...base.runs[0]!, status, restartCancelledBackgroundWork: work }], + providerTurns: [{ ...base.providerTurns[0]!, status: "completed" }], + } as unknown as OrchestrationV2ThreadProjection; + const commands: Parameters< + ThreadManagementService.ThreadManagementService["Service"]["dispatch"] + >[0][] = []; + yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( + Effect.provide( + Layer.merge( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(projection), + recoverDelegatedTask: () => Effect.void, + dispatch: (command) => { + commands.push(command); + return Effect.succeed({} as never); + }, + }), + ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), + ), ), - ), - ); - assert.lengthOf(commands, 1); - const command = commands[0]!; - assert.equal( - command.type === "message.dispatch" ? command.restartContinuationOfRunId : null, - runId, - ); - assert.include( - command.type === "message.dispatch" ? command.text : "", - "sleep 25 && echo DONE", - ); - assert.notInclude(command.type === "message.dispatch" ? command.text : "", "Continue where"); - }), + ); + assert.lengthOf(commands, 0); + }), ); it.effect("does not continue a failed run that lost background work", () => @@ -419,7 +408,7 @@ it.effect("prepares no continuation for background work another provider thread ); yield* recovery.prepareForShutdown; assert.lengthOf(writes, 0); - // The same work on the Codex run's own provider thread is continued. + // Work on the settled run's own provider thread must not wake it either. const ownWork = { ...projection, turnItems: [{ ...projection.turnItems[0]!, providerThreadId }], @@ -446,13 +435,103 @@ it.effect("prepares no continuation for background work another provider thread ), ); yield* ownRecovery.prepareForShutdown; - assert.deepEqual( - writes.map((write) => write.effects[0]?.request), - [{ type: "provider-runtime.continue", sourceRunId: runId }], - ); + assert.lengthOf(writes, 0); }), ); +it.effect.each([ + ["completed", scratchProjectId], + ["completed", ProjectId.make("restart-project")], + ["waiting", scratchProjectId], + ["waiting", ProjectId.make("restart-project")], +] as const)( + "cleans up background work without waking a %s run in project %s", + ([status, projectId]) => + Effect.gen(function* () { + const base = makeProjection(); + const projection = { + ...base, + thread: { ...base.thread, projectId }, + runs: [{ ...base.runs[0]!, status }], + providerTurns: [{ ...base.providerTurns[0]!, status: "completed" }], + turnItems: [ + { + id: "turn-item:background-subagent", + runId, + nodeId: null, + providerThreadId, + providerInstanceId: instanceId, + type: "subagent", + subagentId: "subagent:background", + title: "Background reviewer", + status: "running", + }, + ], + subagents: [ + { + id: "subagent:background", + runId, + driver, + providerInstanceId: instanceId, + status: "running", + }, + ], + } as unknown as OrchestrationV2ThreadProjection; + for (const trigger of ["startup", "shutdown"] as const) { + const commits: Parameters[0][] = []; + const writes: Parameters[0][] = []; + const recovery = yield* ProviderRuntimeRecovery.make.pipe( + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getRecoveryThreadIds: () => Effect.succeed([threadId]), + getRuntimeRecoveryProjection: () => Effect.succeed(projection), + }), + Layer.mock(EventSink.EventSinkV2)({ + writeWithEffects: (input) => + Effect.sync(() => { + writes.push(input); + return []; + }), + commitCommand: (input) => + Effect.sync(() => { + commits.push(input); + return { committed: true, cancelledEffectCount: 0 } as never; + }), + }), + IdAllocator.layer, + Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ + runRecoveryOnce: Effect.succeed(false), + }), + Layer.mock(EffectOutbox.EffectOutboxV2)({ + listByCommandId: () => Effect.succeed([]), + reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), + }), + ), + ), + ); + if (trigger === "shutdown") yield* recovery.prepareForShutdown; + yield* recovery.reconcile(trigger); + assert.lengthOf(writes, 0); + assert.lengthOf(commits, 1); + assert.lengthOf(commits[0]!.effects, 0); + const events = commits[0]!.events; + for (const type of ["turn-item.updated", "subagent.updated"] as const) + assert.isTrue( + events.some((event) => event.type === type && event.payload.status === "cancelled"), + ); + const note = events.find((event) => event.type === "run.background-work-cancelled"); + assert.equal(note?.runId, runId); + assert.deepEqual(note?.payload.restartCancelledBackgroundWork, [ + { kind: "subagent", label: "Background reviewer", id: "turn-item:background-subagent" }, + ]); + if (status === "completed") + assert.isFalse(events.some((event) => event.type === "run.updated")); + } + }), +); + it.effect("does not cancel or resume a run that completes while shutdown intent commits", () => Effect.gen(function* () { let projection = makeProjection(); diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index f91d1a1e5f73..013e7a07fbc4 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -4,7 +4,6 @@ import { CommandId, MessageId, type OrchestrationV2Run, - type ProviderThreadId, type RunId, type ThreadId, } from "@t3tools/contracts"; @@ -23,16 +22,14 @@ import { const CONTINUE_PROMPT = "Continue where you left off."; /** - * The run a restart continuation resumes, if any: an unfinished root run, or a - * settled one whose own provider thread lost background work in the restart - * (`cancelledWorkProviderThreadIds`, which recovery records on that thread). + * Resume only an unfinished root turn. Leftover background work is cleaned up + * separately and reported on the next user turn; it must not wake a settled run. */ export function restartContinuationRun( projection: Pick< ProjectionRuntimeRecoveryState, "thread" | "runs" | "providerThreads" | "providerSessions" | "providerTurns" >, - cancelledWorkProviderThreadIds: ReadonlySet = new Set(), ): OrchestrationV2Run | undefined { if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) return; // Queued runs never started; recovery holds them behind the cut run. @@ -46,13 +43,8 @@ export function restartContinuationRun( if (!run) return; const preparedContinuation = run.status === "starting" && run.restartContinuationOfRunId !== undefined; - // Background work outlived this settled turn; the provider has no live turn. - const settledWithCancelledWork = - (run.status === "completed" || run.status === "waiting") && - run.providerThreadId !== null && - cancelledWorkProviderThreadIds.has(run.providerThreadId); - if (run.status !== "running" && !preparedContinuation && !settledWithCancelledWork) return; - const liveTurnRequired = !preparedContinuation && !settledWithCancelledWork; + if (run.status !== "running" && !preparedContinuation) return; + const liveTurnRequired = !preparedContinuation; if (projection.thread.providerInstanceId !== run.providerInstanceId) return; const providerThread = projection.providerThreads.find( (thread) => thread.id === run.providerThreadId, @@ -73,16 +65,13 @@ export function restartContinuationRun( const session = projection.providerSessions.find( (candidate) => candidate.id === providerThread.providerSessionId, ); - // A settled thread's session may already be stopped and out of the recovery - // read; the continuation reopens it from the provider thread's native ref. // Most adapters keep a live session "ready" through its turns, so only a // stopped or failed session rules out a live turn. if ( - session === undefined - ? !settledWithCancelledWork - : session.providerInstanceId !== run.providerInstanceId || - session.driver !== providerThread.driver || - (liveTurnRequired && (session.status === "stopped" || session.status === "error")) + session === undefined || + session.providerInstanceId !== run.providerInstanceId || + session.driver !== providerThread.driver || + (liveTurnRequired && (session.status === "stopped" || session.status === "error")) ) return; if ( @@ -119,10 +108,14 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta if (projection.messages.some((message) => message.id === messageId)) return; const source = projection.runs.find((run) => run.id === input.sourceRunId); - // A settled source prompts with the note of the background work it lost. - const noteSource = - source !== undefined && isRestartNoteSource(source, projection.providerTurns); - if (!source || (source.status !== "cancelled" && !noteSource)) return; + // Pending effects from older versions may target settled background work, + // including waiting runs that reconciliation subsequently cancelled. + if ( + !source || + source.status !== "cancelled" || + isRestartNoteSource(source, projection.providerTurns) + ) + return; // A user submission after reconciliation takes precedence over an automatic // prompt. Queued runs never started and stay held behind this one. if ( diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 6aff5da24f1e..7ab2c87cdef4 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -507,6 +507,7 @@ export interface RunExecutionServiceV2StartRootRunInput { readonly attempt: OrchestrationV2RunAttempt; readonly attemptId: RunAttemptId; readonly providerTurnOrdinal: number; + readonly nativeThreadHasTurns?: boolean; readonly loadInheritedBackgroundTurnItems?: () => Effect.Effect< ReadonlyArray, unknown @@ -1348,6 +1349,9 @@ export const layer: Layer.Layer< runId: input.run.id, runOrdinal: input.run.ordinal, providerTurnOrdinal: input.providerTurnOrdinal, + ...(input.nativeThreadHasTurns === undefined + ? {} + : { nativeThreadHasTurns: input.nativeThreadHasTurns }), ...(input.run.restartContinuationOfRunId === undefined ? {} : { diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 6a8dded63caa..d72b036316c9 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -47,6 +47,7 @@ it.effect.each( "before dispatch", "after delivery", "without native steering", + "with interrupting native steering", "settled only", ] as const ).map((timing) => ({ @@ -57,7 +58,10 @@ it.effect.each( ) .filter( ({ mailbox, timing }) => - mailbox || (timing !== "without native steering" && timing !== "settled only"), + mailbox || + (timing !== "without native steering" && + timing !== "with interrupting native steering" && + timing !== "settled only"), ), )("delivers $label when completion wins $timing", ({ mailbox, timing }) => Effect.scoped( @@ -73,6 +77,7 @@ it.effect.each( turns: { ...CodexProviderCapabilitiesV2.turns, supportsActiveSteering: timing !== "without native steering", + activeSteeringInterruptsTools: timing === "with interrupting native steering", }, }; const adapter: ProviderAdapterV2Shape = { 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/ThreadCommandExecutor.ts b/apps/server/src/orchestration-v2/ThreadCommandExecutor.ts index c9863776d44a..cec1feb122fc 100644 --- a/apps/server/src/orchestration-v2/ThreadCommandExecutor.ts +++ b/apps/server/src/orchestration-v2/ThreadCommandExecutor.ts @@ -1,13 +1,12 @@ import type { ThreadId } from "@t3tools/contracts"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as Context from "effect/Context"; import * as Layer from "effect/Layer"; -import { makeKeyedSerialExecutor, type KeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; - /** Shared by thread commands and project deletion so both plan against current thread state. */ export class ThreadCommandExecutor extends Context.Service< ThreadCommandExecutor, - KeyedSerialExecutor + KeyedLock.KeyedLock >()("t3/orchestration-v2/ThreadCommandExecutor") {} -export const layer = Layer.effect(ThreadCommandExecutor, makeKeyedSerialExecutor()); +export const layer = Layer.effect(ThreadCommandExecutor, KeyedLock.make()); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 37d4bdf3815f..d61973cfe20d 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -10,6 +10,7 @@ import { type ModelSelection, type OrchestrationV2Actor, type OrchestrationV2Command, + type OrchestrationV2GetTurnItemResult, type OrchestrationV2ServerCommand, type OrchestrationV2ConversationMessage, type OrchestrationV2CreationSource, @@ -22,6 +23,7 @@ import { RunId, type ScheduledTaskId, ThreadId, + type TurnItemId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -33,6 +35,7 @@ import * as Schema from "effect/Schema"; import { makeLocalRunRefusal } from "./cloudOnlyHost.ts"; import * as Orchestrator from "./Orchestrator.ts"; +import { projectTurnItemForDetail } from "./WireProjection.ts"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart"; @@ -277,6 +280,14 @@ export interface ThreadManagementServiceShape { ) => Effect.Effect; readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"]; readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"]; + /** + * One turn item with the input and output the thread stream withholds, + * bounded for the wire. Clients fetch it when a tool row is expanded. + */ + readonly getTurnItem: (input: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + }) => Effect.Effect; readonly getThreadRecords: Orchestrator.OrchestratorV2["Service"]["getThreadRecords"]; readonly getThreadProjection: ( threadId: ThreadId, @@ -727,6 +738,11 @@ const make = Effect.gen(function* () { ensureProjectionTranscript(threadId).pipe( Effect.andThen(orchestrator.getMessageCount(threadId)), ), + getTurnItem: (input) => + ensureProjectionTranscript(input.threadId).pipe( + Effect.andThen(orchestrator.getTurnItem(input)), + Effect.map((item) => ({ item: item === null ? null : projectTurnItemForDetail(item) })), + ), getThreadRecords: (threadId, fields, filter) => ensureProjectionTranscript(threadId).pipe( Effect.andThen(orchestrator.getThreadRecords(threadId, fields, filter)), diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index 846ed1f1a1f1..48a4881bdfb4 100644 --- a/apps/server/src/orchestration-v2/WireProjection.test.ts +++ b/apps/server/src/orchestration-v2/WireProjection.test.ts @@ -17,7 +17,11 @@ import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Schema from "effect/Schema"; -import { projectTurnItemForWire, projectDomainEventForWire } from "./WireProjection.ts"; +import { + projectTurnItemForWire, + projectTurnItemForDetail, + projectDomainEventForWire, +} from "./WireProjection.ts"; import { threadShellFromProjection } from "./ProjectionStore.ts"; const decodeTurnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem); @@ -173,7 +177,7 @@ describe("orchestration V2 wire projection", () => { it("omits even small dynamic tool results while retaining input", () => { const item = { ...base, output: { ok: true } } satisfies OrchestrationV2TurnItem; - expect(projectTurnItemForWire(item)).toEqual(base); + expect(projectTurnItemForWire(item)).toEqual({ ...base, outputOmitted: true }); expect(item.output).toEqual({ ok: true }); }); @@ -252,10 +256,24 @@ describe("orchestration V2 wire projection", () => { const projected = projectTurnItemForWire(item); expect(projected).not.toHaveProperty("output"); expect(projected).toMatchObject({ input: "test", status: "completed" }); + // Clients fetch withheld output on demand, so they need to know it exists. + expect(projected.type === "command_execution" ? projected.outputOmitted : null).toBe( + output ? true : undefined, + ); expect(item.output).toBe(output); }, ); + it.each([ + ["echo ok", "echo ok"], + ["a".repeat(262_143) + "😀", "a".repeat(262_143) + "\n… output truncated for transport"], + ])("bounds fetched command input without changing persistence, case %#", (input, expected) => { + const item = { ...base, type: "command_execution" as const, input, output: "ok" }; + const projected = projectTurnItemForDetail(item); + expect(projected).toMatchObject({ input: expected, output: "ok" }); + expect(item.input).toBe(input); + }); + it("keeps failure evidence without retaining command output", () => { const item = { ...base, @@ -290,6 +308,14 @@ describe("orchestration V2 wire projection", () => { expect(projected).not.toHaveProperty("newStr"); expect(projected).toMatchObject({ fileName: "src/main.ts", additions: 3, deletions: 1 }); expect(item.diffStr).toBe("+new code"); + // A failed edit keeps the provider's error so expanding the row can show it. + const failed = projectTurnItemForWire({ + ...item, + status: "failed", + diffStr: "String to replace not found", + }); + expect(failed).toMatchObject({ diffStr: "String to replace not found" }); + expect(failed).not.toHaveProperty("newStr"); }); it("retains only result identities and failure metadata in live tool events", () => { diff --git a/apps/server/src/orchestration-v2/WireProjection.ts b/apps/server/src/orchestration-v2/WireProjection.ts index b72cd0542457..7c17147d220a 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -8,19 +8,22 @@ import { compactDynamicToolOutput, toolOutputIndicatesFailure } from "@t3tools/s const MAX_DETAIL_STRING_BYTES = 32_768; const MAX_DYNAMIC_VALUE_BYTES = 16_384; +const MAX_ON_DEMAND_BYTES = 256 * 1024; -function truncateDetail(value: string | undefined): string | undefined { +function truncateDetail( + value: string | undefined, + maxBytes = MAX_DETAIL_STRING_BYTES, +): string | undefined { if ( value === undefined || - (value.length <= MAX_DETAIL_STRING_BYTES && - Buffer.byteLength(value, "utf8") <= MAX_DETAIL_STRING_BYTES) + (value.length <= maxBytes && Buffer.byteLength(value, "utf8") <= maxBytes) ) { return value; } // UTF-8 needs at least one byte per UTF-16 code unit. Only encode the prefix // that could fit, rather than allocating a buffer for the complete output. - const prefix = Buffer.from(value.slice(0, MAX_DETAIL_STRING_BYTES), "utf8") - .subarray(0, MAX_DETAIL_STRING_BYTES) + const prefix = Buffer.from(value.slice(0, maxBytes), "utf8") + .subarray(0, maxBytes) .toString("utf8") .replace(/\uFFFD$/u, ""); return `${prefix}\n… output truncated for transport`; @@ -80,13 +83,20 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra (item.exitCode !== undefined && item.exitCode !== 0) || (output !== undefined && toolOutputIndicatesFailure(output.slice(0, MAX_DETAIL_STRING_BYTES))); - return failed ? { ...projected, outputIndicatesFailure: true } : projected; + return { + ...projected, + ...(failed ? { outputIndicatesFailure: true } : {}), + ...(output?.trim() ? { outputOmitted: true } : {}), + }; } case "file_change": { // File identity and counts are enough for activity. Full diffs already // have a dedicated read path and remain intact in persistence. - const { diffStr: _diff, oldStr: _old, newStr: _new, ...projected } = item; - return projected; + const { diffStr, oldStr: _old, newStr: _new, ...projected } = item; + // A failed edit stores the provider's error where the diff would be. + return item.status === "failed" && diffStr?.trim() + ? { ...projected, diffStr: truncateDetail(diffStr) } + : projected; } case "subagent": return { @@ -102,6 +112,7 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra ...projected, input: summarizeDynamicValue(item.input), ...(output === undefined ? {} : { output }), + ...(hasDynamicValue(rawOutput) ? { outputOmitted: true } : {}), }; } default: @@ -109,6 +120,62 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra } } +function hasDynamicValue(value: unknown): boolean { + if (value === undefined || value === null) return false; + if (typeof value === "string") return value.trim().length > 0; + if (Array.isArray(value)) return value.length > 0; + return typeof value !== "object" || Object.keys(value).length > 0; +} + +function boundDynamicValue(value: unknown): unknown { + if (value === undefined) return value; + if (typeof value === "string") return truncateDetail(value, MAX_ON_DEMAND_BYTES); + let json: string; + try { + // Compact, so measuring does not inflate the value; clients indent it. + json = JSON.stringify(value) ?? String(value); + } catch { + return "Unserializable tool value"; + } + return Buffer.byteLength(json, "utf8") <= MAX_ON_DEMAND_BYTES + ? value + : truncateDetail(json, MAX_ON_DEMAND_BYTES); +} + +/** + * Projects one item for an on-demand detail read: keeps the input and output + * the timeline withholds, bounded so a huge result cannot stall the socket. + */ +export function projectTurnItemForDetail(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem { + switch (item.type) { + case "command_execution": + return { + ...item, + input: truncateDetail(item.input, MAX_ON_DEMAND_BYTES) ?? "", + output: truncateDetail(item.output, MAX_ON_DEMAND_BYTES), + }; + case "dynamic_tool": + return { + ...item, + input: boundDynamicValue(item.input), + output: boundDynamicValue(item.output), + }; + case "subagent": + return { + ...item, + prompt: truncateDetail(item.prompt, MAX_ON_DEMAND_BYTES) ?? "", + progress: truncateDetail(item.progress, MAX_ON_DEMAND_BYTES), + result: + item.result === null ? null : (truncateDetail(item.result, MAX_ON_DEMAND_BYTES) ?? null), + }; + case "handoff": + case "file_change": + return projectTurnItemForWire(item); + default: + return item; + } +} + export function projectContextHandoffForWire( handoff: OrchestrationV2ContextHandoff, ): OrchestrationV2ContextHandoff { diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts index 20988ac44f00..9a87bec48bbb 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts @@ -21,6 +21,7 @@ import { ThreadPullRequestLink, TurnItemId, } from "@t3tools/contracts"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -30,7 +31,6 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as EventSink from "../EventSink.ts"; -import { makeKeyedSerialExecutor } from "../KeyedSerialExecutor.ts"; import { randomUuidV4 } from "../RandomUuid.ts"; const IMPORT_EVENT_PREFIX = "migration:v1"; @@ -347,7 +347,7 @@ function chunks(items: ReadonlyArray, size: number): Array(); + const transcriptImports = yield* KeyedLock.make(); const listMessages = (threadId: ThreadId) => sql` diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 84d6df90160a..c2f0024b0542 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2645,84 +2645,86 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - it.effect("does not admit a restart continuation of a failed run that lost background work", () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const eventSink = yield* EventSink.EventSinkV2; - const threadId = ThreadId.make("runtime-layer-restart-failed-source"); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("restart-failed-create"), - threadId, - projectId: ProjectId.make("restart-project"), - title: "Restart", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: "/tmp/runtime-layer-restart-failed", - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("restart-failed-user-message"), - threadId, - messageId: MessageId.make("restart-failed-user-message"), - text: "Original work", - attachments: [], - modelSelection, - dispatchMode: { type: "start_immediately" }, - }); - const original = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; - const now = yield* DateTime.now; - yield* eventSink.commitCommand({ - commandId: CommandId.make("restart-failed-reconcile"), - threadId, - commandType: "provider-runtime.reconcile", - acceptedAt: now, - events: [ - { - id: EventId.make("restart-failed-run"), - type: "run.updated", - threadId, - runId: original.id, - occurredAt: now, - payload: { ...original, status: "failed", completedAt: now }, - }, - { - id: EventId.make("restart-failed-work"), - type: "run.background-work-cancelled", - threadId, - runId: original.id, - occurredAt: now, - payload: { + it.effect.each(["failed", "completed", "waiting", "cancelled"] as const)( + "does not admit a restart continuation of a %s run with only lost background work", + (status) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make(`runtime-layer-restart-${status}-source`); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`restart-${status}-create`), + threadId, + projectId: ProjectId.make("restart-project"), + title: "Restart", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: "/tmp/runtime-layer-restart-failed", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`restart-${status}-user-message`), + threadId, + messageId: MessageId.make(`restart-${status}-user-message`), + text: "Original work", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + const original = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + const now = yield* DateTime.now; + yield* eventSink.commitCommand({ + commandId: CommandId.make(`restart-${status}-reconcile`), + threadId, + commandType: "provider-runtime.reconcile", + acceptedAt: now, + events: [ + { + id: EventId.make(`restart-${status}-run`), + type: "run.updated", + threadId, runId: original.id, - restartCancelledBackgroundWork: [{ kind: "shell", label: "sleep 25" }], + occurredAt: now, + payload: { ...original, status, completedAt: now }, }, - }, - ], - effects: [], - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "agent", - creationSource: "server", - commandId: CommandId.make("restart-failed-continuation"), - threadId, - messageId: MessageId.make("restart-failed-continuation"), - text: "Note: the T3 server restarted.", - attachments: [], - modelSelection, - dispatchMode: { type: "start_immediately" }, - restartContinuationOfRunId: original.id, - }); - const projection = yield* orchestrator.getThreadProjection(threadId); - assert.lengthOf(projection.runs, 1); - assert.equal(projection.runs[0]?.status, "failed"); - }), + { + id: EventId.make(`restart-${status}-work`), + type: "run.background-work-cancelled", + threadId, + runId: original.id, + occurredAt: now, + payload: { + runId: original.id, + restartCancelledBackgroundWork: [{ kind: "shell", label: "sleep 25" }], + }, + }, + ], + effects: [], + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "server", + commandId: CommandId.make(`restart-${status}-continuation`), + threadId, + messageId: MessageId.make(`restart-${status}-continuation`), + text: "Note: the T3 server restarted.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + restartContinuationOfRunId: original.id, + }); + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.lengthOf(projection.runs, 1); + assert.equal(projection.runs[0]?.status, status); + }), ); it.effect("rejects settling a thread while a run is active", () => diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts index fc61c61646bc..d9e6a7f13161 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts @@ -11,6 +11,7 @@ import { makeClaudeRestartReplayHarness, } from "../Adapters/ClaudeAdapterV2.testkit.ts"; import * as IdAllocator from "../IdAllocator.ts"; +import * as EffectWorker from "../EffectWorker.ts"; import { makeSqlitePersistenceLive } from "../../persistence/Layers/Sqlite.ts"; import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; import { CLAUDE_BACKGROUND_SUBAGENT_AFTER_ROOT_PROMPT } from "./fixtures/claude_background_subagent_after_root/input.ts"; @@ -19,7 +20,11 @@ import { materializeFixtureInput, projectionFor, } from "./fixtures/shared.ts"; -import { runOrchestratorV2ProviderReplayScenario } from "./ProviderReplayHarness.ts"; +import { + makeOrchestratorV2ProviderReplayLayer, + runOrchestratorV2ProviderReplayScenario, +} from "./ProviderReplayHarness.ts"; +import { runOrchestratorV2Scenario } from "./OrchestratorScenario.ts"; import { checkpointWorkspace } from "./ReplayFixtureWorkspace.ts"; import { readProviderReplayTranscript } from "./ReplayTranscriptNdjson.ts"; @@ -155,21 +160,7 @@ const runRestart = Effect.fn("runRestart")(function* (input: { // Phase 1 ends once the root run settles; the subagent is still open. const firstIdle = materialized.steps.findIndex((step) => step.type === "await_thread_idle"); const phase1Steps = materialized.steps.slice(0, firstIdle + 1); - const threadId = materialized.projectionThreadIds[0]!; - const phase2Steps = [ - // Let recovery's continuation (run 2) finish before the next user message. - ...(input.continueThreadsAfterServerUpdate - ? [ - { - type: "await_run_status" as const, - threadId, - runId: (yield* IdAllocator.IdAllocatorV2).derive.run({ threadId, ordinal: 2 }), - status: "completed" as const, - }, - ] - : []), - ...materialized.steps.slice(firstIdle + 1), - ]; + const phase2Steps = materialized.steps.slice(firstIdle + 1); const { harness, assertComplete } = makeClaudeRestartReplayHarness(transcript); const databaseLayer = makeSqlitePersistenceLive(path.join(tempDir, "state.sqlite")).pipe( Layer.provide(NodeServices.layer), @@ -192,10 +183,35 @@ const runRestart = Effect.fn("runRestart")(function* (input: { assert.equal(settled.runs[0]?.status, "completed"); assert.equal(settled.subagents[0]?.status, "running"); + // Drain recovery before submitting any user work, which would otherwise + // take precedence over an incorrectly queued automatic continuation. + const restartScenario = scenario("restarted", []); + const restarted = yield* Effect.scoped( + Effect.gen(function* () { + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + yield* worker.drain(); + return yield* runOrchestratorV2Scenario(restartScenario); + }).pipe( + Effect.provide( + makeOrchestratorV2ProviderReplayLayer(restartScenario, harness, { + databaseLayer, + recoverOnStartup: true, + continueThreadsAfterServerUpdate: input.continueThreadsAfterServerUpdate, + }), + ), + ), + ); + const recovered = projectionFor(restarted, SCENARIO); + assert.deepEqual( + recovered.runs.map((run) => run.status), + ["completed"], + ); + assert.equal(recovered.subagents[0]?.status, "cancelled"); + assert.equal(recovered.runs[0]?.restartCancelledBackgroundWork?.[0]?.kind, "subagent"); + const after = yield* Effect.scoped( runOrchestratorV2ProviderReplayScenario(scenario("after-restart", phase2Steps), harness, { databaseLayer, - recoverOnStartup: true, continueThreadsAfterServerUpdate: input.continueThreadsAfterServerUpdate, }), ); @@ -210,61 +226,37 @@ const userTexts = (projection: ReturnType) => projection.turnItems.flatMap((item) => (item.type === "user_message" ? [item.text] : [])); describe("restart-cancelled background work", () => { - it.effect("tells the next provider turn once that its background subagent died", () => - Effect.scoped( - Effect.gen(function* () { - const projection = yield* runRestart({ - // The first turn after the restart carries the note; the second does not. - resumedPrompts: [ - `${NOTE}\n\nUser message:\n${FIRST_AFTER_RESTART}`, + it.effect.each([false, true])( + "keeps the thread settled and tells the next user turn once when continuation is %s", + (continueThreadsAfterServerUpdate) => + Effect.scoped( + Effect.gen(function* () { + const projection = yield* runRestart({ + // The first turn after the restart carries the note; the second does not. + resumedPrompts: [ + `${NOTE}\n\nUser message:\n${FIRST_AFTER_RESTART}`, + SECOND_AFTER_RESTART, + ], + userMessagesAfterRestart: [FIRST_AFTER_RESTART, SECOND_AFTER_RESTART], + continueThreadsAfterServerUpdate, + }); + assert.deepEqual( + projection.runs.map((run) => run.status), + ["completed", "completed", "completed"], + ); + assert.isFalse( + projection.runs.some((run) => run.restartContinuationOfRunId !== undefined), + ); + // The note reaches the provider only; the timeline keeps what the user sent. + assert.deepEqual(userTexts(projection), [ + CLAUDE_BACKGROUND_SUBAGENT_AFTER_ROOT_PROMPT, + FIRST_AFTER_RESTART, SECOND_AFTER_RESTART, - ], - userMessagesAfterRestart: [FIRST_AFTER_RESTART, SECOND_AFTER_RESTART], - continueThreadsAfterServerUpdate: false, - }); - assert.deepEqual( - projection.runs.map((run) => run.status), - ["completed", "completed", "completed"], - ); - assert.isFalse(projection.runs.some((run) => run.restartContinuationOfRunId !== undefined)); - // The note reaches the provider only; the timeline keeps what the user sent. - assert.deepEqual(userTexts(projection), [ - CLAUDE_BACKGROUND_SUBAGENT_AFTER_ROOT_PROMPT, - FIRST_AFTER_RESTART, - SECOND_AFTER_RESTART, - ]); - }).pipe( - provideDeterministicTestRuntime, - Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer)), - ), - ), - ); - - it.effect("continues a settled thread with the note when restart continuation is on", () => - Effect.scoped( - Effect.gen(function* () { - const projection = yield* runRestart({ - // Recovery's continuation prompts with the note; the user turn after it does not. - resumedPrompts: [NOTE, FIRST_AFTER_RESTART], - userMessagesAfterRestart: [FIRST_AFTER_RESTART], - continueThreadsAfterServerUpdate: true, - }); - const [root, continuation, user] = projection.runs; - assert.deepEqual( - projection.runs.map((run) => run.status), - ["completed", "completed", "completed"], - ); - assert.equal(continuation?.restartContinuationOfRunId, root?.id); - assert.isUndefined(user?.restartContinuationOfRunId); - assert.deepEqual(userTexts(projection), [ - CLAUDE_BACKGROUND_SUBAGENT_AFTER_ROOT_PROMPT, - NOTE, - FIRST_AFTER_RESTART, - ]); - }).pipe( - provideDeterministicTestRuntime, - Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer)), + ]); + }).pipe( + provideDeterministicTestRuntime, + Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer)), + ), ), - ), ); }); diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 9d01f22fe6a0..98ee5d6c8537 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -85,6 +85,9 @@ interface CapturedTurn { readonly providerThreadId: ProviderThreadId; readonly text: string; readonly attachments: ReadonlyArray; + readonly nativeThreadId?: string | null; + readonly providerTurnOrdinal?: number; + readonly nativeThreadHasTurns?: boolean; } function unimplemented(driver: ProviderDriverKind, detail: string) { @@ -232,6 +235,11 @@ function makeTestAdapter(input: { providerThreadId: turnInput.providerThread.id, text: turnInput.message.text, attachments: turnInput.message.attachments, + nativeThreadId: turnInput.providerThread.nativeThreadRef?.nativeId ?? null, + providerTurnOrdinal: turnInput.providerTurnOrdinal, + ...(turnInput.nativeThreadHasTurns === undefined + ? {} + : { nativeThreadHasTurns: turnInput.nativeThreadHasTurns }), }, ]); if ( @@ -1051,6 +1059,135 @@ describe("orchestration v2 provider switching", () => { ), ); + it.live.each(["native", "legacy"] as const)( + "starts a replacement native thread as new after the resume fallback with %s attempts", + (attempts) => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("resume-fallback-native-turns"); + const capturedTurns = yield* Ref.make>([]); + const failResumeOnce = yield* Ref.make(false); + const generation = yield* Ref.make(0); + const registry = ProviderAdapterRegistry.makeLayer([ + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: {}, + capturedTurns, + failResumeOnce, + nativeThreadGeneration: generation, + }), + ]); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const runToCompletion = Effect.fn("runToCompletion")(function* (ordinal: number) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`resume-fallback:${ordinal}`), + threadId, + messageId: MessageId.make(`resume-fallback:${ordinal}`), + createdBy: "user", + creationSource: "web", + text: `Request ${ordinal}`, + attachments: [], + modelSelection: CLAUDE_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }); + yield* orchestrator.streamStoredEvents.pipe( + Stream.filter( + ({ event }) => + event.type === "run.updated" && + event.payload.ordinal === ordinal && + (event.payload.status === "completed" || event.payload.status === "failed"), + ), + Stream.runHead, + ); + yield* worker.drain(); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, + "completed", + ); + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("resume-fallback:create"), + threadId, + projectId, + createdBy: "user", + creationSource: "web", + title: "Resume fallback", + modelSelection: CLAUDE_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* runToCompletion(1); + // A detached session resumes on the next turn, as after a restart. + const providerThread = (yield* orchestrator.getThreadProjection( + threadId, + )).providerThreads.at(0); + if (providerThread === undefined || providerThread.providerSessionId === null) { + return yield* Effect.die("Expected a provider session after the first turn."); + } + yield* orchestrator.dispatch({ + type: "provider-session.detach", + commandId: CommandId.make("resume-fallback:detach"), + threadId, + providerSessionId: providerThread.providerSessionId, + }); + yield* worker.drain(); + if (attempts === "legacy") { + // Attempts persisted before native identity existed carry no native id. + const existing = yield* orchestrator.getThreadProjection(threadId); + yield* (yield* EventSink.EventSinkV2).write({ + events: existing.attempts.map( + ({ nativeThreadId: _nativeThreadId, ...legacy }, index) => ({ + id: EventId.make(`resume-fallback-legacy-attempt:${index}`), + type: "run-attempt.updated" as const, + threadId, + occurredAt: existing.thread.createdAt, + payload: legacy, + }), + ), + }); + } + yield* Ref.set(failResumeOnce, true); + yield* runToCompletion(2); + yield* runToCompletion(3); + + const turns = yield* Ref.get(capturedTurns); + assert.equal(turns.length, 3); + assert.equal(yield* Ref.get(generation), 2); + // The fallback binds a new native thread to a provider thread that + // already has a turn, so the provider must not resume it. + assert.notEqual(turns[1]?.nativeThreadId, turns[0]?.nativeThreadId); + assert.isAbove(turns[1]?.providerTurnOrdinal ?? 0, 1); + assert.isFalse(turns[1]?.nativeThreadHasTurns); + assert.equal(turns[2]?.nativeThreadId, turns[1]?.nativeThreadId); + assert.isTrue(turns[2]?.nativeThreadHasTurns); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { + name: "resume-fallback-native-turns", + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { type: "readOnly" }, + }, + }, + registry, + ), + ), + ); + }), + ), + ); + it.live.each( (["failed", "interrupted"] as const).flatMap((status) => [false, true].flatMap((queued) => diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_mcp_tool_presentation/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_mcp_tool_presentation/output.ts index c68b57d83a8c..b51ca4f52f4a 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_mcp_tool_presentation/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_mcp_tool_presentation/output.ts @@ -35,6 +35,7 @@ export function assertClaudeMcpToolPresentationOutput( assert.equal(scrape?.status, "completed"); assert.equal(scrape?.title, "Firecrawl scrape"); + assert.deepEqual(scrape?.toolIcon, scrape?.toolSource?.icon); assert.deepEqual(scrape?.toolSource, { key: "mcp:firecrawl", name: "Firecrawl", @@ -46,6 +47,10 @@ export function assertClaudeMcpToolPresentationOutput( }); assert.equal(map?.status, "completed"); - assert.isNull(map?.title); - assert.notProperty(map, "toolSource"); + assert.equal(map?.title, "firecrawl map"); + assert.deepEqual(map?.toolSource, { + key: "mcp:firecrawl", + name: "Firecrawl", + kind: "integration", + }); } diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 5d0e6b68e626..247ff0aaa925 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -8,6 +8,7 @@ import { type ProjectSnapshot, type ThreadId, } from "@t3tools/contracts"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -18,7 +19,6 @@ import * as Schema from "effect/Schema"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as IdAllocator from "../orchestration-v2/IdAllocator.ts"; -import { makeKeyedSerialExecutor } from "../orchestration-v2/KeyedSerialExecutor.ts"; import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import { @@ -155,8 +155,8 @@ export const make = Effect.gen(function* () { const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; // Commands for one project run in order. Commands that claim a workspace root // also hold that root, so two projects cannot both claim it. - const projectLocks = yield* makeKeyedSerialExecutor(); - const workspaceLocks = yield* makeKeyedSerialExecutor(); + const projectLocks = yield* KeyedLock.make(); + const workspaceLocks = yield* KeyedLock.make(); const toProject = ( row: ProjectStore.ProjectRow, diff --git a/apps/server/src/provider/CodexToolPresentation.ts b/apps/server/src/provider/CodexToolPresentation.ts index 6bf7acee05b2..a01390582bda 100644 --- a/apps/server/src/provider/CodexToolPresentation.ts +++ b/apps/server/src/provider/CodexToolPresentation.ts @@ -6,25 +6,18 @@ import type { } from "@t3tools/contracts"; import type * as EffectCodexSchema from "effect-codex-app-server/schema"; +import { + mcpToolPresentation as integrationToolPresentation, + normalizeMcpHttpUrl as normalizedHttpUrl, + normalizeMcpText as normalizedDisplayName, +} from "./McpToolPresentation.ts"; + type CodexLifecycleItem = EffectCodexSchema.V2ItemCompletedNotification["item"]; function asUnknownRecord(value: unknown): Record | undefined { return value && typeof value === "object" ? (value as Record) : undefined; } -function normalizedHttpUrl(value: unknown): string | undefined { - if (typeof value !== "string" || value.length > 4096) return undefined; - try { - const url = new URL(value); - const href = url.href; - return (url.protocol === "http:" || url.protocol === "https:") && href.length <= 4096 - ? href - : undefined; - } catch { - return undefined; - } -} - function normalizedImageUrl(value: unknown): string | undefined { if (typeof value !== "string" || value.length > 4096) return undefined; try { @@ -45,12 +38,6 @@ function normalizedAppId(value: unknown): string | undefined { : undefined; } -function normalizedDisplayName(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; - const displayName = value.trim().replace(/\s+/gu, " "); - return displayName && displayName.length <= 160 ? displayName : undefined; -} - function normalizedSourceKeyPart(value: string): string { return value.trim().toLowerCase(); } @@ -130,6 +117,7 @@ function themedLogoIcon( } export interface McpToolPresentation { + readonly title?: string; readonly toolSurface?: "browser" | "computer"; readonly toolIcon?: ToolActivityIcon; readonly toolSource?: ToolActivitySource; @@ -227,5 +215,13 @@ export function mcpToolPresentation( }; } - return {}; + return integrationToolPresentation({ + serverName: appContext?.connectorId ?? item.server, + toolName: item.tool, + title: appContext?.actionName, + serverDisplayName: appContext?.appName, + source: sourceMetadata, + iconUrl: sourceLogo?._tag === "themed-logo" ? sourceLogo.logoUrl : undefined, + iconUrlDark: sourceLogo?._tag === "themed-logo" ? sourceLogo.logoUrlDark : undefined, + }); } diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 1cf47ab74bfc..64107911d6b5 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -41,6 +41,7 @@ import { checkClaudeProviderStatus, makePendingClaudeProvider, probeClaudeCapabilities, + probeClaudeWorkspaceSnapshot, } from "../Layers/ClaudeProvider.ts"; import { makeClaudeHostProbe } from "./claudeHostProbe.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; @@ -70,7 +71,6 @@ import { makeClaudeContinuationGroupKey, resolveClaudeHomePath, } from "./ClaudeHome.ts"; -import { discoverClaudeSkills } from "./ClaudeSkills.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); @@ -351,16 +351,13 @@ export const ClaudeDriver: ProviderDriver = { snapshot, invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache), snapshotForCwd: (cwd: string) => - !effectiveConfig.enabled - ? snapshot.getSnapshot - : Effect.all([ - snapshot.getSnapshot, - discoverClaudeSkills(effectiveConfig, cwd, processEnv), - ]).pipe( - Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), + snapshot.getSnapshot.pipe( + Effect.flatMap((machineSnapshot) => + probeClaudeWorkspaceSnapshot(effectiveConfig, machineSnapshot, cwd, processEnv), + ), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), orchestrationAdapter, textGeneration, consumeResetCredit, diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index d126a15c12b8..c4b328cdc337 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -154,6 +154,48 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + it.effect("recovers colon-bearing descriptions with invocation metadata intact", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + for (const [description, comment] of [ + ["Browser automation + AI test authoring via kane-cli: run browser objectives, ...", ""], + ['Read C:\\skills\\guide#tag: continue with "quoted".', " # trailing: comment"], + ] as const) { + yield* writeSkill( + path.join(configDir, "skills"), + "kane-cli", + [ + "---", + "name: frontmatter-alias", + `description: ${description}${comment}`, + "allowed-tools: [Read, Write]", + "disable-model-invocation: yes", + "user-invocable: no", + "---", + ].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, undefined); + + assert.deepEqual(skills, [ + { + name: "kane-cli", + path: path.join(configDir, "skills", "kane-cli", "SKILL.md"), + enabled: true, + scope: "user", + description, + userInvocationOnly: true, + userInvocable: false, + }, + ]); + } + }), + ); + it.effect("falls back to the directory name and skips malformed frontmatter", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -163,7 +205,17 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { const skillsDir = path.join(configDir, "skills"); yield* writeSkill(skillsDir, "no-frontmatter", "# Just a heading\n"); - yield* writeSkill(skillsDir, "broken-yaml", "---\nname: [unclosed\n---\n"); + for (const [directoryName, field] of [ + ["broken-yaml", "name: [unclosed"], + ["broken-tools", "allowed-tools: [Read, Write"], + ["broken-quoted", 'name: "unclosed: text'], + ] as const) { + yield* writeSkill( + skillsDir, + directoryName, + ["---", "description: Run: browser objectives.", field, "---"].join("\n"), + ); + } // A stray file (not a directory with SKILL.md) must be skipped. yield* fs.makeDirectory(skillsDir, { recursive: true }); yield* fs.writeFileString(path.join(skillsDir, "README.md"), "not a skill"); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 259ceeb4b775..8c9c9c669faa 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -75,11 +75,28 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { return { kind: "missing" }; } + const frontmatter = match[1] ?? ""; let parsed: unknown; try { - parsed = parseYamlDocument(match[1] ?? ""); + parsed = parseYamlDocument(frontmatter); } catch { - return { kind: "malformed" }; + // Claude Code accepts plain scalars containing `: `. Repair only those, + // leaving comments and YAML structure for the full-document parser. + const repaired = frontmatter.replace( + /^([\w-]+:[ \t]*)([^\r\n]*)/gm, + (line, prefix: string, value: string) => { + const scalar = value.split(/[ \t]+#/)[0] ?? ""; + if (!/:[ \t]/.test(scalar) || /^(?:["'[\]{}|>&*!#%@`]|[-?:](?:[ \t]|$))/.test(scalar)) { + return line; + } + return `${prefix}${JSON.stringify(scalar)}${value.slice(scalar.length)}`; + }, + ); + try { + parsed = parseYamlDocument(repaired); + } catch { + return { kind: "malformed" }; + } } if (typeof parsed !== "object" || parsed === null) { return { kind: "malformed" }; diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 72534a3a7623..3cb3bebf50cb 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -4,7 +4,12 @@ import { vi } from "vite-plus/test"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as TestClock from "effect/testing/TestClock"; -import { ClaudeSettings } from "@t3tools/contracts"; +import { + ClaudeSettings, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; import * as NodeFSP from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; @@ -17,7 +22,9 @@ import { buildClaudeCapabilitiesProbeQueryOptions, CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, probeClaudeCapabilities, + probeClaudeWorkspaceSnapshot, } from "./ClaudeProvider.ts"; +import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); @@ -53,6 +60,182 @@ it("isolates Claude capability probes without dropping workspace setting sources }); it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { + it.effect( + "discovers commands and skills separately for each cwd without replacing machine metadata", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspaces-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspaces = [path.join(tempDir, "one"), path.join(tempDir, "two")]; + for (const cwd of workspaces) { + const skillDir = path.join(cwd, ".claude", "skills", "existing-skill"); + yield* fs.makeDirectory(skillDir, { recursive: true }); + yield* fs.writeFileString( + path.join(skillDir, "SKILL.md"), + "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.", + ); + } + const machineSnapshot = { + instanceId: ProviderInstanceId.make("claude"), + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated", email: "machine@example.com" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "2.1.288", + models: [], + slashCommands: [{ name: "server-cwd-only" }], + skills: [], + } satisfies ServerProvider; + let usageCalls = 0; + const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { + assert.equal(options?.env?.CLAUDE_CONFIG_DIR, configDir); + assert.equal(options?.env?.T3_WORKSPACE_PROBE, "owned-instance"); + return { + initializationResult: async () => ({ + account: { email: "workspace@example.com" }, + commands: [ + { + name: options?.cwd === workspaces[0] ? "start-session" : "other-project", + description: "Project command", + argumentHint: "[topic]", + }, + { name: "nested:review", description: "Review changes", argumentHint: "" }, + { name: "NESTED:review", description: "", argumentHint: "[path]" }, + { name: "compact", description: "Provider compact", argumentHint: "" }, + { name: "user-command", description: "Existing user command", argumentHint: "" }, + ], + }), + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => { + usageCalls++; + return { rate_limits_available: false, rate_limits: null }; + }, + } as ReturnType; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + for (const [index, cwd] of workspaces.entries()) { + const scoped = yield* probeClaudeWorkspaceSnapshot( + decodeClaudeSettings({ homePath: configDir }), + machineSnapshot, + cwd, + { ...process.env, T3_WORKSPACE_PROBE: "owned-instance" }, + ); + assert.deepEqual(scoped, { + ...machineSnapshot, + slashCommandsPending: false, + slashCommands: [ + COMPACT_SLASH_COMMAND, + { + name: index === 0 ? "start-session" : "other-project", + description: "Project command", + input: { hint: "[topic]" }, + }, + { name: "nested:review", description: "Review changes", input: { hint: "[path]" } }, + { name: "user-command", description: "Existing user command" }, + ], + skills: [ + { + name: "existing-skill", + path: path.join(cwd, ".claude", "skills", "existing-skill", "SKILL.md"), + enabled: true, + scope: "project", + description: "Existing project skill", + }, + ], + }); + } + assert.deepEqual( + query.mock.calls.map(([input]) => input.options?.cwd), + workspaces, + ); + assert.equal(usageCalls, 0); + }).pipe(Effect.scoped), + ); + + it.effect("keeps readable skills during failed command discovery and recovers on retry", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspace-retry-" }); + const skillDir = path.join(cwd, ".claude", "skills", "existing-skill"); + yield* fs.makeDirectory(skillDir, { recursive: true }); + yield* fs.writeFileString( + path.join(skillDir, "SKILL.md"), + "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.", + ); + const machineSnapshot = { + instanceId: ProviderInstanceId.make("claude"), + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "2.1.288", + models: [], + slashCommands: [{ name: "server-cwd-only" }], + skills: [], + } satisfies ServerProvider; + const query = vi.spyOn(ClaudeSdk, "query").mockImplementation( + () => + ({ + initializationResult: () => + Promise.reject( + new Error("Initialization failed"), + ), + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ + rate_limits_available: false, + rate_limits: null, + }), + }) as ReturnType, + ); + yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + const settings = decodeClaudeSettings({ homePath: cwd }); + const failed = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd); + assert.deepEqual(failed, { + ...machineSnapshot, + slashCommands: [COMPACT_SLASH_COMMAND], + slashCommandsPending: true, + skills: [ + { + name: "existing-skill", + path: path.join(skillDir, "SKILL.md"), + enabled: true, + scope: "project", + description: "Existing project skill", + }, + ], + }); + query.mockImplementation( + () => + ({ + initializationResult: async () => ({ + commands: [{ name: "recovered", description: "", argumentHint: "" }], + }), + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ + rate_limits_available: false, + rate_limits: null, + }), + }) as ReturnType, + ); + const recovered = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd); + assert.deepEqual(recovered.slashCommands, [COMPACT_SLASH_COMMAND, { name: "recovered" }]); + assert.equal(recovered.status, "ready"); + assert.equal(recovered.slashCommandsPending, false); + assert.deepEqual(recovered.skills, failed.skills); + const disabled = yield* probeClaudeWorkspaceSnapshot( + { ...settings, enabled: false }, + machineSnapshot, + cwd, + ); + assert.equal(disabled, machineSnapshot); + assert.equal(query.mock.calls.length, 2); + }).pipe(Effect.scoped), + ); + it.effect("serializes strict no-MCP options and still resolves account capabilities", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b75999f2511e..84e2fcac30ad 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1,6 +1,7 @@ import { type ClaudeSettings, type ModelCapabilities, + type ServerProvider, type ServerProviderSlashCommand, type ServerProviderResetCredits, } from "@t3tools/contracts"; @@ -36,6 +37,7 @@ import { import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; +import type { ProviderWorkspaceSnapshot } from "../ProviderDriver.ts"; import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; import { type ClaudeScopedLimitNames, @@ -334,6 +336,7 @@ const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, cwd?: string, + includeUsage = true, ) => { const abort = new AbortController(); return Effect.gen(function* () { @@ -365,15 +368,18 @@ const probeClaudeCapabilities = ( Effect.flatMap(({ q, init }) => Effect.gen(function* () { // Usage has its own deadline so a slow optional request cannot discard initialization. - const usageResult = yield* Effect.tryPromise(() => - q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(), - ).pipe(Effect.timeout(CLAUDE_USAGE_PROBE_TIMEOUT_MS), Effect.result); - const usage = Result.isSuccess(usageResult) - ? { - rate_limits_available: usageResult.success.rate_limits_available, - rate_limits: usageResult.success.rate_limits, - } + const usageResult = includeUsage + ? yield* Effect.tryPromise(() => + q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(), + ).pipe(Effect.timeout(CLAUDE_USAGE_PROBE_TIMEOUT_MS), Effect.result) : undefined; + const usage = + usageResult && Result.isSuccess(usageResult) + ? { + rate_limits_available: usageResult.success.rate_limits_available, + rate_limits: usageResult.success.rate_limits, + } + : undefined; const account = init.account as | { readonly email?: string; @@ -418,6 +424,27 @@ export const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( return yield* spawnAndCollect(claudeSettings.binaryPath, command); }); +/** Read commands from the same cwd Claude uses for a workspace session. */ +export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnapshot")(function* ( + claudeSettings: ClaudeSettings, + machineSnapshot: ServerProvider, + cwd: string, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return { + if (!claudeSettings.enabled) return machineSnapshot; + const skills = yield* discoverClaudeSkills(claudeSettings, cwd, environment); + const capabilities = yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false); + return { + ...machineSnapshot, + skills, + slashCommands: dedupeSlashCommands([ + COMPACT_SLASH_COMMAND, + ...(capabilities?.slashCommands ?? []), + ]), + slashCommandsPending: !capabilities, + }; +}); + export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( claudeSettings: ClaudeSettings, resolveCapabilities?: ( diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 1e9253e8ec03..ddf9428e51b8 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -63,7 +63,7 @@ import { writeProviderStatusCache, } from "../providerStatusCache.ts"; import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts"; -import type { ProviderInstance } from "../ProviderDriver.ts"; +import type { ProviderInstance, ProviderWorkspaceSnapshot } from "../ProviderDriver.ts"; import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; @@ -641,6 +641,34 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test skills: scopedSnapshot.skills, }, ]); + + const pendingSnapshot = { + ...scopedSnapshot, + slashCommands: [COMPACT_SLASH_COMMAND], + slashCommandsPending: true, + } satisfies ProviderWorkspaceSnapshot; + const partial = upsertProviderWorkspaceSnapshot(result, "/project", pendingSnapshot); + assert.deepStrictEqual(partial.workspaceSnapshots?.[0]?.slashCommands, [ + { name: "project" }, + ]); + assert.strictEqual(partial.workspaceSnapshots?.[0]?.slashCommandsPending, true); + const otherProject = upsertProviderWorkspaceSnapshot( + partial, + "/other-project", + pendingSnapshot, + ); + assert.deepStrictEqual(otherProject.workspaceSnapshots?.[1]?.slashCommands, [ + COMPACT_SLASH_COMMAND, + ]); + const recovered = upsertProviderWorkspaceSnapshot(partial, "/project", { + ...scopedSnapshot, + slashCommands: [COMPACT_SLASH_COMMAND, { name: "replacement" }], + }); + assert.deepStrictEqual(recovered.workspaceSnapshots?.[0]?.slashCommands, [ + COMPACT_SLASH_COMMAND, + { name: "replacement" }, + ]); + assert.strictEqual(recovered.workspaceSnapshots?.[0]?.slashCommandsPending, undefined); }); it("preserves previously discovered provider models when a refresh returns none", () => { @@ -1573,7 +1601,12 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test slashCommands: [], } as const satisfies ServerProvider; const snapshotCalls = yield* Ref.make(0); - const scopedResult = yield* Ref.make(scopedProvider); + const scopedResult = yield* Ref.make({ + ...scopedProvider, + status: "error", + slashCommands: [COMPACT_SLASH_COMMAND], + slashCommandsPending: true, + }); const cacheInvalidations = yield* Ref.make(0); const scanGate = yield* Ref.make<{ readonly started: Deferred.Deferred; @@ -1698,8 +1731,27 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test providers[0]?.workspaceSnapshots?.[0]?.skills, scopedProvider.skills, ); + assert.deepStrictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommands, [ + COMPACT_SLASH_COMMAND, + ]); + assert.strictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, true); + yield* Ref.set(scopedResult, { + ...scopedProvider, + status: "error", + slashCommandsPending: false, + }); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.strictEqual(yield* Ref.get(snapshotCalls), 3); + assert.deepStrictEqual( + (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommands, + scopedProvider.slashCommands, + ); + assert.strictEqual( + (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, + undefined, + ); yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 2); + assert.strictEqual(yield* Ref.get(snapshotCalls), 3); const newSkills = [ ...scopedProvider.skills, { name: "added", path: "/workspace/added/SKILL.md", enabled: true }, @@ -1710,7 +1762,7 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test cwd: "/workspace", fresh: true, }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 3); + assert.strictEqual(yield* Ref.get(snapshotCalls), 4); assert.strictEqual(yield* Ref.get(cacheInvalidations), 1); assert.deepStrictEqual( (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index de476ca0299a..b9c28c155283 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -53,7 +53,7 @@ import { resolveProviderStatusCachePath, writeProviderStatusCache, } from "../providerStatusCache.ts"; -import type { ProviderInstance } from "../ProviderDriver.ts"; +import type { ProviderInstance, ProviderWorkspaceSnapshot } from "../ProviderDriver.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; @@ -94,12 +94,16 @@ function dropProviderWorkspaceSnapshot(provider: ServerProvider, cwd: string): S export function upsertProviderWorkspaceSnapshot( provider: ServerProvider, cwd: string, - scopedSnapshot: ServerProvider, + scopedSnapshot: ProviderWorkspaceSnapshot, ): ServerProvider { const workspaceSnapshot = { cwd, checkedAt: scopedSnapshot.checkedAt, - slashCommands: scopedSnapshot.slashCommands, + slashCommands: scopedSnapshot.slashCommandsPending + ? (provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd)?.slashCommands ?? + scopedSnapshot.slashCommands) + : scopedSnapshot.slashCommands, + ...(scopedSnapshot.slashCommandsPending ? { slashCommandsPending: true } : {}), skills: scopedSnapshot.skills, } satisfies NonNullable[number]; return { @@ -918,7 +922,11 @@ export const ProviderRegistryLive = Layer.effect( const workspaceSnapshotOf = (candidate: ServerProvider | undefined) => candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd); const scannedFrom = workspaceSnapshotOf(provider); - if (!provider || !provider.enabled || (!input.fresh && scannedFrom)) { + if ( + !provider || + !provider.enabled || + (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending) + ) { return providers; } const instance = yield* instanceRegistry.getInstance(input.instanceId); @@ -932,8 +940,7 @@ export const ProviderRegistryLive = Layer.effect( }); // A fresh scan never joins a running one, which may predate the change. if (!claimed && !input.fresh) return yield* Ref.get(providersRef); - // Fresh scans also re-read the machine snapshot: Claude's plugin - // commands come from it, not from the cwd scan. + // Fresh scans also re-read the machine snapshot after invalidating caches. const refreshMachineSnapshot = input.fresh ? (instance.invalidateCaches ?? Effect.void).pipe( Effect.andThen(refreshInstance(input.instanceId)), @@ -942,7 +949,7 @@ export const ProviderRegistryLive = Layer.effect( return yield* refreshMachineSnapshot.pipe( Effect.andThen(instance.snapshotForCwd(input.cwd)), Effect.flatMap((scopedSnapshot) => - scopedSnapshot.status === "error" + scopedSnapshot.status === "error" && scopedSnapshot.slashCommandsPending === undefined ? Ref.get(providersRef) : instanceRegistry.getInstance(input.instanceId).pipe( Effect.flatMap((currentInstance) => { diff --git a/apps/server/src/provider/McpToolPresentation.test.ts b/apps/server/src/provider/McpToolPresentation.test.ts new file mode 100644 index 000000000000..5218d05ebeb8 --- /dev/null +++ b/apps/server/src/provider/McpToolPresentation.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "@effect/vitest"; +import { mcpToolPresentation } from "./McpToolPresentation.ts"; + +describe("mcpToolPresentation", () => { + it("uses supplied names and logos while retaining the server identity", () => { + expect( + mcpToolPresentation({ + serverName: "firecrawl-local", + toolName: "firecrawl_scrape", + title: " Scrape\n page ", + serverDisplayName: "Firecrawl", + iconUrl: "https://example.com/icon.png", + iconUrlDark: "https://example.com/dark.png", + }), + ).toEqual({ + title: "Scrape page", + toolIcon: { + _tag: "themed-logo", + logoUrl: "https://example.com/icon.png", + logoUrlDark: "https://example.com/dark.png", + }, + toolSource: { + key: "mcp:firecrawl-local", + name: "Firecrawl", + kind: "integration", + icon: { + _tag: "themed-logo", + logoUrl: "https://example.com/icon.png", + logoUrlDark: "https://example.com/dark.png", + }, + }, + }); + }); + + it("uses MCP result source metadata for the tool and its integration", () => { + const presentation = mcpToolPresentation({ + serverName: "firecrawl-local", + toolName: "firecrawl_scrape", + source: { + name: "Firecrawl", + logoUrl: "https://example.com/firecrawl.png", + logoUrlDark: "https://example.com/firecrawl-dark.png", + }, + }); + expect(presentation.toolSource?.name).toBe("Firecrawl"); + expect(presentation.toolIcon).toEqual({ + _tag: "themed-logo", + logoUrl: "https://example.com/firecrawl.png", + logoUrlDark: "https://example.com/firecrawl-dark.png", + }); + expect(presentation.toolSource?.icon).toEqual(presentation.toolIcon); + }); + + it("uses readable fallback names without a guessed logo", () => { + expect(mcpToolPresentation({ toolName: "mcp__my_server__get_weather" })).toEqual({ + title: "get weather", + toolSource: { key: "mcp:my_server", name: "my server", kind: "integration" }, + }); + expect(mcpToolPresentation({ toolName: "Read" })).toEqual({}); + expect(mcpToolPresentation({ serverName: "t3-code", toolName: "delegate_task" })).toEqual({}); + }); + + it.each([ + "javascript:alert(1)", + "file:///tmp/logo.png", + "not a URL", + "https://example.com/" + "x".repeat(4096), + ])("ignores an invalid logo %s", (iconUrl) => { + expect( + mcpToolPresentation({ + serverName: "weather", + toolName: "get_weather", + iconUrl, + title: "x".repeat(161), + }), + ).toEqual({ + title: "get weather", + toolSource: { key: "mcp:weather", name: "weather", kind: "integration" }, + }); + }); + + it("rejects malformed and overlong names", () => { + expect(mcpToolPresentation({ serverName: {}, toolName: 1, title: [] })).toEqual({}); + expect(mcpToolPresentation({ serverName: "x".repeat(161), toolName: "get_weather" })).toEqual( + {}, + ); + expect(mcpToolPresentation({ title: "Tool title" })).toEqual({ title: "Tool title" }); + }); + + it.each([ + null, + "logo", + { logoUrl: "file:///tmp/logo.png" }, + { logoUrlDark: "https://example.com/dark.png" }, + ])("ignores source metadata without a valid logo: %j", (source) => { + expect(mcpToolPresentation({ serverName: "weather", toolName: "get_weather", source })).toEqual( + { + title: "get weather", + toolSource: { key: "mcp:weather", name: "weather", kind: "integration" }, + }, + ); + }); +}); diff --git a/apps/server/src/provider/McpToolPresentation.ts b/apps/server/src/provider/McpToolPresentation.ts new file mode 100644 index 000000000000..c8f4dbc0eb11 --- /dev/null +++ b/apps/server/src/provider/McpToolPresentation.ts @@ -0,0 +1,70 @@ +import type { ToolActivityIcon, ToolActivitySource } from "@t3tools/contracts"; +import { resolveT3McpToolDefinition } from "@t3tools/shared/t3McpToolPresentation"; + +export function normalizeMcpText(value: unknown, maxLength = 160): string | undefined { + if (typeof value !== "string") return undefined; + const text = value.trim().replace(/\s+/gu, " "); + return text.length > 0 && text.length <= maxLength ? text : undefined; +} + +export function normalizeMcpHttpUrl(value: unknown): string | undefined { + if (typeof value !== "string" || value.length > 4096) return undefined; + try { + const url = new URL(value); + return (url.protocol === "https:" || url.protocol === "http:") && url.href.length <= 4096 + ? url.href + : undefined; + } catch { + return undefined; + } +} + +export function mcpToolPresentation(input: { + readonly toolName?: unknown; + readonly serverName?: unknown; + readonly title?: unknown; + readonly serverDisplayName?: unknown; + readonly iconUrl?: unknown; + readonly iconUrlDark?: unknown; + readonly source?: unknown; +}): { + readonly title?: string; + readonly toolIcon?: ToolActivityIcon; + readonly toolSource?: ToolActivitySource; +} { + const source = + typeof input.source === "object" && input.source !== null ? input.source : undefined; + const qualified = + typeof input.toolName === "string" ? /^mcp__(.+?)__(.+)$/i.exec(input.toolName) : null; + const server = normalizeMcpText(input.serverName ?? qualified?.[1] ?? input.serverDisplayName); + const tool = normalizeMcpText(qualified?.[2] ?? input.toolName); + if (server && tool && resolveT3McpToolDefinition(`${server}.${tool}`)) return {}; + const title = + normalizeMcpText(input.title) ?? + (server && tool ? normalizeMcpText(tool.replace(/[_-]+/gu, " ")) : undefined); + if (!server) return title ? { title } : {}; + const name = + normalizeMcpText(input.serverDisplayName) ?? + normalizeMcpText(source && Reflect.get(source, "name")) ?? + normalizeMcpText(server.replace(/[_-]+/gu, " ")) ?? + server; + const logoUrl = + normalizeMcpHttpUrl(input.iconUrl) ?? + normalizeMcpHttpUrl(source && Reflect.get(source, "logoUrl")); + const logoUrlDark = + normalizeMcpHttpUrl(input.iconUrlDark) ?? + normalizeMcpHttpUrl(source && Reflect.get(source, "logoUrlDark")); + const icon = logoUrl + ? { _tag: "themed-logo" as const, logoUrl, ...(logoUrlDark ? { logoUrlDark } : {}) } + : undefined; + return { + ...(title ? { title } : {}), + ...(icon ? { toolIcon: icon } : {}), + toolSource: { + key: `mcp:${server.toLowerCase()}`, + name, + kind: "integration", + ...(icon ? { icon } : {}), + }, + }; +} diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index 4f92df965c8a..cb86075616d1 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -31,6 +31,7 @@ import type { ProviderInstanceEnvironment, ProviderInstanceId, ServerProvider, + ServerProviderWorkspaceSnapshot, } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; @@ -59,6 +60,9 @@ export interface ProviderDriverMetadata { readonly supportsMultipleInstances?: boolean; } +export type ProviderWorkspaceSnapshot = ServerProvider & + Pick; + /** * One materialized provider instance. Held by the registry, looked up by * `instanceId`, torn down by closing the scope it was created in. @@ -76,7 +80,9 @@ export interface ProviderInstance { readonly accentColor?: string | undefined; readonly enabled: boolean; readonly snapshot: ServerProviderShape; - readonly snapshotForCwd?: (cwd: string) => Effect.Effect; + readonly snapshotForCwd?: ( + cwd: string, + ) => Effect.Effect; readonly refreshModels?: () => Effect.Effect; /** Invalidate T3-owned discovery caches before an explicit provider refresh. */ readonly invalidateCaches?: Effect.Effect; diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index e69c851b258e..f2d999520e02 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -1539,9 +1539,44 @@ describe("extractMcpToolCallIdentity", () => { }); }); - it("does not brand tools whose meta asserts a foreign server", () => { - // The foreign assertion vetoes every loose source, including a title - // that would otherwise match a T3 convention. + it.each([ + { _meta: { claudeCode: { toolName: "mcp__weather__get_weather" } } }, + { _meta: { toolName: "mcp::weather::get_weather", serverId: "weather" } }, + ])("recovers external MCP identity from provider metadata", (metadata) => { + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "weather-call", + title: "Checking the forecast", + status: "in_progress", + ...metadata, + }); + expect(extractMcpToolCallIdentity(toolCall)).toEqual({ + server: "weather", + tool: "get_weather", + }); + }); + + it.each([ + { kind: "execute", toolName: "developer__shell", extensionName: "developer" }, + { kind: "edit", toolName: "developer__edit", extensionName: "developer" }, + { kind: "other", toolName: "weather__get_weather", extensionName: "weather" }, + ] as const)( + "leaves goose $toolName unclassified so built-ins keep their projection", + ({ kind, toolName, extensionName }) => { + // goose tags built-in extensions exactly like user MCP servers. + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "goose-builtin", + kind, + title: "developer: shell", + status: "in_progress", + _meta: { goose: { toolCall: { toolName, extensionName } } }, + }); + expect(extractMcpToolCallIdentity(toolCall)).toBeUndefined(); + }, + ); + + it("uses the asserted server instead of a misleading T3 title", () => { const toolCall = toolCallFromUpdate({ sessionUpdate: "tool_call", toolCallId: "foreign-1", @@ -1551,7 +1586,10 @@ describe("extractMcpToolCallIdentity", () => { _meta: { toolName: "delegate_task", serverId: "other-orchestrator" }, }); - expect(extractMcpToolCallIdentity(toolCall)).toBeUndefined(); + expect(extractMcpToolCallIdentity(toolCall)).toEqual({ + server: "other-orchestrator", + tool: "delegate_task", + }); }); it("does not brand path-like or unknown-tool titles", () => { diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index 8cb059acb2ec..522d8783dec3 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -1134,9 +1134,7 @@ export function extractMcpToolCallIdentity( // (goose, qwen, claude-acp) or only through the namespaced function name // in the title. The verbatim wire title survives merges even when a later // titleless or LLM-enriched update replaces the presentation title, so - // match those rather than the summarized state title. Name-derived matches - // are gated on the known T3 tool inventory so path-like titles (for - // example "t3-code/README.md") never brand. + // match those rather than the summarized state title. const claudeCode = isRecord(meta?.claudeCode) ? meta.claudeCode : undefined; const gooseToolCall = isRecord(meta?.goose) ? isRecord(meta.goose.toolCall) @@ -1160,15 +1158,22 @@ export function extractMcpToolCallIdentity( } } } - // A present-but-foreign origin assertion marks the whole call as another - // server's MCP call, so no loose name matching (meta or title) may brand it. const gooseExtension = typeof gooseToolCall?.extensionName === "string" ? gooseToolCall.extensionName.trim() : ""; const assertsForeignOrigin = (metaServerId.length > 0 && !/^t3[-_ ]?code$/i.test(metaServerId)) || (gooseExtension.length > 0 && !/^t3[-_ ]?code$/i.test(gooseExtension)); + // A foreign origin never brands as T3. qwen's serverId marks a real MCP + // server, but goose reports its built-in extensions (developer__shell, + // edits) the same way as user MCP servers, so goose stays unclassified and + // keeps its command and file-change projections. if (assertsForeignOrigin) { - return undefined; + if (metaServerId.length === 0 || metaToolName.length === 0) return undefined; + const prefix = [`mcp__${metaServerId}__`, `mcp::${metaServerId}::`].find((prefix) => + metaToolName.startsWith(prefix), + ); + const tool = prefix === undefined ? metaToolName : metaToolName.slice(prefix.length); + return tool ? { server: metaServerId, tool } : undefined; } const candidates = [ meta?.toolName, @@ -1178,6 +1183,9 @@ export function extractMcpToolCallIdentity( ].filter((value): value is string => typeof value === "string"); for (const candidate of candidates) { const trimmed = candidate.trim(); + const qualified = /^mcp__(.+?)__(.+)$/i.exec(trimmed); + if (qualified?.[1] && qualified[2] && !/^t3[-_ ]?code$/i.test(qualified[1])) + return { server: qualified[1], tool: qualified[2] }; const match = T3_MCP_TITLE_CALL.exec(trimmed) ?? T3_MCP_TITLE_SUFFIX_CALL.exec(trimmed) ?? diff --git a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts index 7c456c3c484d..f2fc03659be0 100644 --- a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts +++ b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts @@ -1,6 +1,6 @@ +import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as Effect from "effect/Effect"; import * as Ref from "effect/Ref"; -import * as Semaphore from "effect/Semaphore"; export interface ProviderMaintenanceCommandCoordinatorShape { readonly withCommandLock: (input: { @@ -15,7 +15,7 @@ export const makeProviderMaintenanceCommandCoordinator = Effect.fn( "makeProviderMaintenanceCommandCoordinator", )(function* (input: { readonly makeAlreadyRunningError: (targetKey: string) => E }) { const runningTargetsRef = yield* Ref.make>(new Set()); - const locksRef = yield* Ref.make>(new Map()); + const locks = yield* KeyedLock.make(); const acquireTarget = Effect.fn("acquireTarget")(function* (targetKey: string) { return yield* Ref.modify(runningTargetsRef, (runningTargets) => { @@ -35,24 +35,6 @@ export const makeProviderMaintenanceCommandCoordinator = Effect.fn( return next; }); - const getLock = Effect.fn("getProviderMaintenanceCommandLock")(function* (lockKey: string) { - const existing = (yield* Ref.get(locksRef)).get(lockKey); - if (existing) { - return existing; - } - - const lock = yield* Semaphore.make(1); - return yield* Ref.modify(locksRef, (locks) => { - const current = locks.get(lockKey); - if (current) { - return [current, locks] as const; - } - const next = new Map(locks); - next.set(lockKey, lock); - return [lock, next] as const; - }); - }); - const withCommandLock: ProviderMaintenanceCommandCoordinatorShape["withCommandLock"] = ({ targetKey, lockKey, @@ -65,13 +47,10 @@ export const makeProviderMaintenanceCommandCoordinator = Effect.fn( return yield* Effect.fail(input.makeAlreadyRunningError(targetKey)); } - return yield* Effect.gen(function* () { - const lock = yield* getLock(lockKey); - if (onQueued) { - yield* onQueued; - } - return yield* lock.withPermits(1)(run); - }).pipe(Effect.ensuring(releaseTarget(targetKey))); + return yield* (onQueued ?? Effect.void).pipe( + Effect.andThen(locks.withLock(lockKey, run)), + Effect.ensuring(releaseTarget(targetKey)), + ); }); return { diff --git a/apps/server/src/pullRequest/forgejoPullRequestJson.ts b/apps/server/src/pullRequest/forgejoPullRequestJson.ts index 1d0b5c1d4ccd..cade3b65d9c8 100644 --- a/apps/server/src/pullRequest/forgejoPullRequestJson.ts +++ b/apps/server/src/pullRequest/forgejoPullRequestJson.ts @@ -135,8 +135,8 @@ export function forgejoChangeRequest(pr: typeof ForgejoPullRequest.Type) { headRepositoryNameWithOwner: pr.head.repo?.full_name ?? null, state: pr.merged ? "merged" : pr.state === "closed" ? "closed" : "open", isDraft: pr.draft ?? /^(?:\[WIP\]|WIP:)/i.test(pr.title), - mergeability: - pr.mergeable === undefined ? "unknown" : pr.mergeable ? "mergeable" : "conflicting", + // A false value also covers drafts and unfinished or failed conflict checks. + mergeability: pr.mergeable === true ? "mergeable" : "unknown", additions: pr.additions ?? 0, deletions: pr.deletions ?? 0, createdAt: toIsoUtc(pr.created_at), diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 003403fc5c09..4467fd9768ea 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -194,6 +194,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( dispatch: unused, getTimelinePage: () => Effect.die("Unused timeline read"), getMessageCount: () => Effect.die("unused message count"), + getTurnItem: () => Effect.die("unused turn item read"), getThreadRecords: () => Effect.die("unused record read"), getThreadProjection: unused, getCheckpointContext: unused, diff --git a/apps/server/src/scheduledTasks/ScheduledCloudFire.ts b/apps/server/src/scheduledTasks/ScheduledCloudFire.ts index 4fffe49339a6..53ffe5a3c472 100644 --- a/apps/server/src/scheduledTasks/ScheduledCloudFire.ts +++ b/apps/server/src/scheduledTasks/ScheduledCloudFire.ts @@ -12,6 +12,7 @@ import { type ProvisionProvider, type ScheduledTask, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -20,6 +21,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { EnvironmentControl } from "../environmentControl/EnvironmentControl.ts"; +import type * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import { RepositoryIdentityResolver } from "../project/RepositoryIdentityResolver.ts"; @@ -98,6 +100,43 @@ export class ScheduledCloudFire extends Context.Reference<{ }), }) {} +/** + * Launches one fire of a task: a chat here for a local task, or a fresh machine for a cloud one. + * A cloud fire is keyed by its start alone, which its row keeps as `last_run_at`, so a fire + * resumed after a restart sends the same request. + */ +export const launchFire = + ( + cloudFire: (typeof ScheduledCloudFire)["Service"], + threadLaunch: ThreadLaunchService.ThreadLaunchService["Service"], + ) => + ( + task: ScheduledTask, + startedAt: DateTime.DateTime, + input: Parameters[0], + ): Effect.Effect => + task.target === "local" + ? threadLaunch.launch(input) + : cloudFire.fire({ + task, + provider: task.target, + fireKey: `${task.id}:${DateTime.toEpochMillis(startedAt)}`, + firedAt: DateTime.toUtc(startedAt), + }); + +/** + * Whether a run's end leaves its row 'running'. A cloud fire cut short by shutdown has not + * failed: its machine keeps provisioning, and startup resumes the same fire. + */ +export const keepsCloudRun = (task: ScheduledTask, cause: Cause.Cause): boolean => + task.target !== "local" && Cause.hasInterruptsOnly(cause); + +/** The start of a cloud fire its row still marks running, which startup resumes. */ +export const resumableCloudRun = (task: ScheduledTask): Option.Option => + task.target === "local" || task.lastRunAt === null + ? Option.none() + : DateTime.make(task.lastRunAt).pipe(Option.map(DateTime.toUtc)); + export const layer = Layer.effect( ScheduledCloudFire, Effect.gen(function* () { diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index c3c10960225b..6c6f1aad6482 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -32,7 +32,12 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; -import { ScheduledCloudFire } from "./ScheduledCloudFire.ts"; +import { + ScheduledCloudFire, + keepsCloudRun, + launchFire, + resumableCloudRun, +} from "./ScheduledCloudFire.ts"; import { isMissedFixedTimeRun, isSameSchedule, nextScheduledRunAt } from "./Schedule.ts"; const decodeTask = Schema.decodeUnknownEffect(ScheduledTask); @@ -214,7 +219,7 @@ export const layer = Layer.effect( const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; const threadManagement = yield* ThreadManagementService.ThreadManagementService; const scheduler = yield* Scheduler.Scheduler; - const cloudFire = yield* ScheduledCloudFire; + const launch = launchFire(yield* ScheduledCloudFire, threadLaunch); const cloudRuns = yield* FiberSet.make(); const activeRuns = yield* Ref.make>(new Set()); // Sliding(1) coalesces the dirty-signal: every notification triggers a @@ -466,213 +471,170 @@ export const layer = Layer.effect( ), ); - const reserve = (id: ScheduledTaskId) => - Ref.modify(activeRuns, (active) => { - if (active.has(id)) return [false, active] as const; + const runTask = Effect.fn("ScheduledTaskService.runTask")(function* ( + task: ScheduledTask, + trigger: "scheduled" | "manual" | "resumed", + resumedAt?: DateTime.DateTime, + ) { + const reserved = yield* Ref.modify(activeRuns, (active) => { + if (active.has(task.id)) return [false, active] as const; const next = new Set(active); - next.add(id); + next.add(task.id); return [true, next] as const; }); - const unreserve = (id: ScheduledTaskId) => - Ref.update(activeRuns, (active) => { - const next = new Set(active); - next.delete(id); - return next; - }); - - // Checks the fresh row and marks it running, or returns the task a run - // must not start for. - const beginRun = Effect.fn("ScheduledTaskService.beginRun")(function* ( - task: ScheduledTask, - trigger: "scheduled" | "manual", - ) { - const startedAt = yield* localNow; - - // The in-memory snapshot may be stale: re-read before touching run - // state. The task may have been deleted, paused, or postponed since - // the poll loaded it — none of those may fire. - const active = yield* findTask(task.id); - if (active === null) { - // A manual run on a just-deleted task must fail loudly, not report - // a successful run that never dispatched. + if (!reserved) { if (trigger === "manual") { - return yield* taskError("Schedule task not found.", { taskId: task.id }); + return yield* taskError("Schedule task is already running.", { taskId: task.id }); } - return { skipped: task } as const; - } - // A next_run_at corrupted between the poll read and this re-read must - // not defect the poll; an unparseable value is treated as not due. - const parsedNextRunAt = - active.nextRunAt === null ? Option.none() : DateTime.make(active.nextRunAt); - if ( - trigger === "scheduled" && - (!active.enabled || - Option.isNone(parsedNextRunAt) || - DateTime.toEpochMillis(parsedNextRunAt.value) > DateTime.toEpochMillis(startedAt)) - ) { - return { skipped: active } as const; + return task; } - yield* markRunning(active.id, iso(startedAt)); - yield* notifyChanged; - return { active, startedAt } as const; - }); + return yield* Effect.gen(function* () { + const startedAt = resumedAt ?? (yield* localNow); + const startedAtIso = iso(startedAt); + + // The in-memory snapshot may be stale: re-read before touching run + // state. The task may have been deleted, paused, or postponed since + // the poll loaded it — none of those may fire. + const active = yield* findTask(task.id); + if (active === null) { + // A manual run on a just-deleted task must fail loudly, not report + // a successful run that never dispatched. + if (trigger === "manual") { + return yield* taskError("Schedule task not found.", { taskId: task.id }); + } + return task; + } + // A next_run_at corrupted between the poll read and this re-read must + // not defect the poll; an unparseable value is treated as not due. + const parsedNextRunAt = + active.nextRunAt === null ? Option.none() : DateTime.make(active.nextRunAt); + if ( + trigger === "scheduled" && + (!active.enabled || + Option.isNone(parsedNextRunAt) || + DateTime.toEpochMillis(parsedNextRunAt.value) > DateTime.toEpochMillis(startedAt)) + ) { + return active; + } - // Dispatches a run its row already marks running, then records how it - // ended. The run is keyed by its start, which the row keeps as - // last_run_at, so a cloud fire resumed after a restart sends the same - // request. - const finishRun = Effect.fn("ScheduledTaskService.finishRun")(function* ( - active: ScheduledTask, - startedAt: DateTime.DateTime, - ) { - const startedAtIso = iso(startedAt); - const fireKey = `${active.id}:${DateTime.toEpochMillis(startedAt)}`; - const commandId = CommandId.make(`scheduled-task:${fireKey}`); - const messageId = MessageId.make(`scheduled-task-message:${fireKey}`); - - // A cloud task never binds a thread (upsert refuses it), so it always takes the launch - // branch, which starts its chat on a fresh machine instead of here. - const target = active.target; - const launch = ( - input: Parameters[0], - ): Effect.Effect => - target === "local" - ? threadLaunch.launch(input) - : cloudFire.fire({ - task: active, - provider: target, - fireKey, - firedAt: DateTime.toUtc(startedAt), - }); - // Effect.exit (not Effect.result) so defects and interruptions in the - // dispatch are also captured and recorded as a failed run instead of - // aborting before markCompleted. - const result = - active.threadId === null - ? yield* Effect.exit( - launch({ - commandId, - projectId: active.projectId, - title: active.title, - modelSelection: active.modelSelection, - runtimeMode: active.runtimeMode, - interactionMode: active.interactionMode, - workspaceStrategy: active.workspaceStrategy, - initialMessage: { + yield* markRunning(active.id, startedAtIso); + yield* notifyChanged; + + const fireKey = `${active.id}:${DateTime.toEpochMillis(startedAt)}:${trigger}`; + const commandId = CommandId.make(`scheduled-task:${fireKey}`); + const messageId = MessageId.make(`scheduled-task-message:${fireKey}`); + // Dispatch from the fresh row so prompt/model/binding edits made + // after the poll read are honoured. + const prompt = active.prompt; + + // Effect.exit (not Effect.result) so defects and interruptions in the + // dispatch are also captured and recorded as a failed run instead of + // aborting before markCompleted. + const result = + active.threadId === null + ? yield* Effect.exit( + launch(active, startedAt, { + commandId, + projectId: active.projectId, + title: active.title, + modelSelection: active.modelSelection, + runtimeMode: active.runtimeMode, + interactionMode: active.interactionMode, + workspaceStrategy: active.workspaceStrategy, + initialMessage: { + messageId, + scheduledTaskId: active.id, + text: prompt, + attachments: [], + }, + createdBy: active.createdBy, + creationSource: active.creationSource, + }), + ) + : yield* Effect.exit( + threadManagement.sendToThread({ + projectId: active.projectId, + commandId, + threadId: ThreadId.make(active.threadId), messageId, scheduledTaskId: active.id, - text: active.prompt, + text: prompt, attachments: [], - }, - createdBy: active.createdBy, - creationSource: active.creationSource, - }), - ) - : yield* Effect.exit( - threadManagement.sendToThread({ - projectId: active.projectId, - commandId, - threadId: ThreadId.make(active.threadId), - messageId, - scheduledTaskId: active.id, - text: active.prompt, - attachments: [], - modelSelection: active.modelSelection, - mode: "auto", - createdBy: active.createdBy, - creationSource: active.creationSource, - }), - ); - - const completedAt = yield* localNow; - const runSucceeded = result._tag === "Success"; - const lastRunStatus = runSucceeded ? ("succeeded" as const) : ("failed" as const); - const lastRunError = runSucceeded ? null : errorMessage(result.cause); - // Re-read the task so the next run is computed from the schedule as it - // is *now* (the user may have edited or deleted it while we ran). - const current = yield* findTask(active.id); - const scheduleSource = current ?? active; - const completed: ScheduledTask = { - ...scheduleSource, - updatedAt: iso(completedAt), - lastRunAt: startedAtIso, - nextRunAt: nextRunAt(scheduleSource, completedAt), - lastRunStatus, - lastRunError, - runCount: scheduleSource.runCount + 1, - }; - if (current !== null) { - // startedAtIso in the guard ensures this writes only to the row this - // run marked as running — a task deleted mid-run and recreated with - // the same id (idempotent commandId replay) must not be stamped. - yield* markCompleted({ - id: active.id, - completedAtIso: completed.updatedAt, - nextRunAtIso: completed.nextRunAt, - status: lastRunStatus, - error: lastRunError, - startedAtIso, - }); - yield* notifyChanged; - } - return completed; - }); - - // Owns the run's reservation until it ends. A cloud fire cut short by - // shutdown is not a failure: its machine keeps provisioning, so the row - // stays 'running' and startup resumes the same fire. - const settleRun = (active: ScheduledTask, startedAt: DateTime.DateTime) => - finishRun(active, startedAt).pipe( + modelSelection: active.modelSelection, + // Scheduled prompts must not interrupt tools in the bound thread. + mode: "queue", + createdBy: active.createdBy, + creationSource: active.creationSource, + }), + ); + + const completedAt = yield* localNow; + const runSucceeded = result._tag === "Success"; + const lastRunStatus = runSucceeded ? ("succeeded" as const) : ("failed" as const); + const lastRunError = runSucceeded ? null : errorMessage(result.cause); + // Re-read the task so the next run is computed from the schedule as it + // is *now* (the user may have edited or deleted it while we ran). + const current = yield* findTask(task.id); + const scheduleSource = current ?? task; + const completed: ScheduledTask = { + ...scheduleSource, + updatedAt: iso(completedAt), + lastRunAt: startedAtIso, + nextRunAt: nextRunAt(scheduleSource, completedAt), + lastRunStatus, + lastRunError, + runCount: scheduleSource.runCount + 1, + }; + if (current !== null) { + // startedAtIso in the guard ensures this writes only to the row this + // run marked as running — a task deleted mid-run and recreated with + // the same id (idempotent commandId replay) must not be stamped. + yield* markCompleted({ + id: task.id, + completedAtIso: completed.updatedAt, + nextRunAtIso: completed.nextRunAt, + status: lastRunStatus, + error: lastRunError, + startedAtIso, + }); + yield* notifyChanged; + } + return completed; + }).pipe( Effect.onError((cause) => - active.target !== "local" && Cause.hasInterruptsOnly(cause) - ? Effect.void - : releaseStuckRun(active, errorMessage(cause)), + keepsCloudRun(task, cause) ? Effect.void : releaseStuckRun(task, errorMessage(cause)), ), - Effect.ensuring(unreserve(active.id)), - ); - - // A cloud fire waits minutes for its machine, so once its checks pass it - // runs beside the poll loop and the caller rather than holding them; its - // row reads 'running' until the machine is ready. - const runCloud = (active: ScheduledTask, startedAt: DateTime.DateTime) => - FiberSet.run( - cloudRuns, - settleRun(active, startedAt).pipe( - Effect.catch((cause) => - Effect.logWarning("Cloud scheduled task run failed", { taskId: active.id, cause }), - ), + Effect.ensuring( + Ref.update(activeRuns, (active) => { + const next = new Set(active); + next.delete(task.id); + return next; + }), ), ); + }); + // A cloud fire waits minutes for its machine, so it runs beside the poll + // loop and the caller, its row reading 'running' until the machine is ready. const startRun = Effect.fn("ScheduledTaskService.startRun")(function* ( task: ScheduledTask, - trigger: "scheduled" | "manual", + trigger: "scheduled" | "manual" | "resumed", + resumedAt?: DateTime.DateTime, ) { - if (!(yield* reserve(task.id))) { - if (trigger === "manual") { - return yield* taskError("Schedule task is already running.", { taskId: task.id }); - } - return task; + if (task.target === "local") return yield* runTask(task, trigger, resumedAt); + if (trigger === "manual" && (yield* Ref.get(activeRuns)).has(task.id)) { + return yield* taskError("Schedule task is already running.", { taskId: task.id }); } - // Uninterruptible between the checks and the hand-off, so the - // reservation always ends up with exactly one owner. - return yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const begun = yield* restore(beginRun(task, trigger)).pipe( - Effect.onError(() => unreserve(task.id)), - ); - if ("skipped" in begun) { - yield* unreserve(task.id); - return begun.skipped; - } - if (begun.active.target === "local") { - return yield* restore(settleRun(begun.active, begun.startedAt)); - } - yield* runCloud(begun.active, begun.startedAt); - return begun.active; - }), + yield* FiberSet.run( + cloudRuns, + runTask(task, trigger, resumedAt).pipe( + Effect.catch((cause) => + Effect.logWarning("Cloud scheduled task run failed", { taskId: task.id, cause }), + ), + ), ); + return task; }); // A due fixed-time run that is long past its slot (server was off or @@ -742,15 +704,11 @@ export const layer = Layer.effect( (row) => Effect.gen(function* () { const decoded = yield* Effect.result(decodeRow(row)); - const cloudStartedAt = - Result.isSuccess(decoded) && decoded.success.target !== "local" - ? Option.flatMap(Option.fromNullishOr(decoded.success.lastRunAt), (at) => - DateTime.make(at), - ) - : Option.none(); - if (Result.isSuccess(decoded) && Option.isSome(cloudStartedAt)) { - yield* reserve(decoded.success.id); - yield* runCloud(decoded.success, cloudStartedAt.value); + const resumedAt = Result.isSuccess(decoded) + ? resumableCloudRun(decoded.success) + : Option.none(); + if (Result.isSuccess(decoded) && Option.isSome(resumedAt)) { + yield* startRun(decoded.success, "resumed", resumedAt.value); return; } if (Result.isSuccess(decoded)) { @@ -831,9 +789,8 @@ export const layer = Layer.effect( // keep their run history, and so real load failures propagate instead // of silently resetting an existing row. const existingTask = yield* findTask(id); - const threadId = input.threadId ?? null; const target = input.target ?? existingTask?.target ?? "local"; - if (target !== "local" && threadId !== null) { + if (target !== "local" && (input.threadId ?? null) !== null) { return yield* taskError("A task that runs on a cloud machine must start a new chat.", { taskId: id, }); @@ -852,7 +809,7 @@ export const layer = Layer.effect( enabled: input.enabled, schedule: input.schedule, projectId: input.projectId, - threadId, + threadId: input.threadId ?? null, target, workspaceStrategy: input.workspaceStrategy, modelSelection: input.modelSelection, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 01385136f832..5124f898cfa6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -118,6 +118,7 @@ import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import * as HeapSnapshot from "./observability/HeapSnapshot.ts"; import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; import * as ReplayMarkers from "./auth/replayMarkers.ts"; @@ -630,6 +631,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), Layer.provideMerge(RemoteOpenTargets.layer), + Layer.provideMerge(DirectEndpoints.layer), Layer.provideMerge(ServerLifecycleEvents.layer), Layer.provide(NetService.layer), ); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 15a6d454172a..1f5a8138b252 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -199,6 +199,87 @@ it.effect("reads Forgejo checks without repository or viewer requests", () => { ); }); +it.effect.each([ + ["Ready", false, true, false, "mergeable"], + ["Draft", true, false, true, "unknown"], + ["Blocked", false, false, false, "unknown"], + ["Unchecked", false, undefined, false, "unknown"], + ["WIP: Legacy draft", undefined, false, true, "unknown"], + ["[WIP] Legacy draft", undefined, false, true, "unknown"], + ["WIP: Explicitly ready", false, false, false, "unknown"], +] as const)( + "reads Forgejo mergeability for %s (draft=%s, mergeable=%s) across list, summary and detail", + ([title, draft, mergeable, isDraft, mergeability]) => { + const pr = { + number: 42, + title, + body: "", + html_url: "https://forgejo.test/maria/project/pulls/42", + user: { login: "maria" }, + state: "open", + merged: false, + ...(draft === undefined ? {} : { draft }), + ...(mergeable === undefined ? {} : { mergeable }), + head: { ref: "feature", sha: "head", repo: null }, + base: { ref: "main", sha: "base", repo: null }, + created_at: "2026-09-16T00:00:00Z", + updated_at: "2026-09-16T00:00:00Z", + closed_at: null, + merged_at: null, + labels: [], + }; + return Effect.gen(function* () { + const provider = yield* ForgejoPullRequestProvider.make; + const readSummary = provider.getChangeRequestSummary; + if (readSummary === undefined) return yield* Effect.die("summary read missing"); + const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 42 }; + const list = yield* provider.listChangeRequests({ + ...input, + state: "open", + involvement: "all", + viewer: "maria", + limit: 10, + }); + const summary = yield* readSummary(input); + const detail = yield* provider.getChangeRequest(input); + assert.strictEqual(list.items.length, 1); + for (const result of [list.items[0]!, summary, detail]) { + assert.strictEqual(result.mergeability, mergeability); + assert.strictEqual(result.isDraft, isDraft); + } + }).pipe( + Effect.provide( + Layer.mock(ForgejoCli.ForgejoCli)({ + api: (input) => { + const [path, query] = input.path.split("?"); + let response: unknown; + switch (path) { + case "repos/maria/project/pulls": + response = new URLSearchParams(query).get("page") === "1" ? [pr] : []; + break; + case "repos/maria/project/pulls/42": + response = pr; + break; + case "repos/maria/project": + response = { full_name: "maria/project", permissions: { push: true, admin: true } }; + break; + case "user": + response = pr.user; + break; + case "repos/maria/project/statuses/head": + response = []; + break; + default: + return Effect.die(`Unexpected Forgejo request: ${input.path}`); + } + return encodeJsonEffect(response).pipe(Effect.orDie, Effect.map(processOutput)); + }, + }), + ), + ); + }, +); + it.effect("loads Forgejo pull request references from files and commits views", () => Effect.gen(function* () { const provider = yield* ForgejoSourceControlProvider.make; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 6122c8606f0b..8766255b7226 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -40,6 +40,7 @@ import { ProviderInstanceId, } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { mergePathEntries } from "@t3tools/shared/shell"; @@ -58,7 +59,6 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ServerConfig from "../config.ts"; @@ -1569,7 +1569,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func sessions: new Map(), killFibers: new Map(), }); - const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const threadLocks = yield* KeyedLock.make(); const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect>(); const workerScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void)); @@ -1598,29 +1598,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func f: (state: TerminalManagerState) => readonly [A, TerminalManagerState], ) => SynchronizedRef.modify(managerStateRef, f); - const getThreadSemaphore = (threadId: string) => - SynchronizedRef.modifyEffect(threadLocksRef, (current) => { - const existing: Option.Option = Option.fromNullishOr( - current.get(threadId), - ); - return Option.match(existing, { - onNone: () => - Semaphore.make(1).pipe( - Effect.map((semaphore) => { - const next = new Map(current); - next.set(threadId, semaphore); - return [semaphore, next] as const; - }), - ), - onSome: (semaphore) => Effect.succeed([semaphore, current] as const), - }); - }); - const withThreadLock = ( threadId: string, effect: Effect.Effect, - ): Effect.Effect => - Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + ): Effect.Effect => threadLocks.withLock(threadId, effect); const clearKillFiber = Effect.fn("terminal.clearKillFiber")(function* ( process: PtyAdapter.PtyProcess | null, diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 99220a8bf78f..8e9147669433 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -174,6 +174,8 @@ export interface GitCommitProgress { export interface GitCommitOptions { readonly timeoutMs?: number; readonly progress?: GitCommitProgress; + /** Stage the current working tree immediately before committing. */ + readonly stage?: { readonly filePaths?: readonly string[] }; } export interface GitDeleteLocalBranchInput { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index c720a2e4958a..17ae6e309fee 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -3052,7 +3052,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("commit context", () => { - it.effect("stages selected files and commits only those files", () => + it.effect("prepares selected files without staging and commits only those files", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); yield* initRepoWithCommit(cwd); @@ -3064,8 +3064,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const context = yield* driver.prepareCommitContext(cwd, ["a.txt"]); assert.include(context?.stagedSummary ?? "", "a.txt"); assert.notInclude(context?.stagedSummary ?? "", "b.txt"); + assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), ""); - const commit = yield* driver.commit(cwd, "Add a", ""); + const commit = yield* driver.commit(cwd, "Add a", "", { stage: { filePaths: ["a.txt"] } }); assert.match(commit.commitSha, /^[a-f0-9]{40}$/); assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "Add a"); @@ -3084,14 +3085,96 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* writeTextFile(cwd, "selected[1].txt", "literal\n"); yield* writeTextFile(cwd, "selected1.txt", "pattern match\n"); - yield* driver.prepareCommitContext(cwd, ["selected[1].txt"]); + const context = yield* driver.prepareCommitContext(cwd, ["selected[1].txt"]); - assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), "selected[1].txt"); + assert.include(context?.stagedSummary ?? "", "selected[1].txt"); + assert.notInclude(context?.stagedSummary ?? "", "selected1.txt"); + assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), ""); + yield* driver.commit(cwd, "Add literal path", "", { + stage: { filePaths: ["selected[1].txt"] }, + }); + assert.equal( + yield* git(cwd, ["show", "--pretty=", "--name-only", "HEAD"]), + "selected[1].txt", + ); const status = yield* git(cwd, ["status", "--porcelain"]); assert.include(status, "?? selected1.txt"); }), ); + + it.effect.each([false, true])( + "preserves partial staging and split-index files (split: %s)", + (split) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + if (split) yield* git(cwd, ["config", "core.splitIndex", "true"]); + yield* writeTextFile(cwd, "README.md", "# test\nstaged\n"); + yield* git(cwd, ["add", "README.md"]); + yield* writeTextFile(cwd, "README.md", "# test\nstaged\nunstaged\n"); + yield* writeTextFile(cwd, "untracked.txt", "untracked\n"); + yield* writeTextFile(cwd, ".gitignore", "ignored.txt\n"); + yield* writeTextFile(cwd, "ignored.txt", "ignored\n"); + const before = NodeFS.readFileSync(`${cwd}/.git/index`); + const shared = NodeFS.readdirSync(`${cwd}/.git`).filter((name) => + name.startsWith("sharedindex."), + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + for (const paths of [undefined, ["README.md"]]) { + const context = yield* driver.prepareCommitContext(cwd, paths); + assert.include(context?.stagedPatch ?? "", "unstaged"); + assert.notInclude(context?.stagedSummary ?? "", "ignored.txt"); + assert.deepEqual(NodeFS.readFileSync(`${cwd}/.git/index`), before); + assert.deepEqual( + NodeFS.readdirSync(`${cwd}/.git`).filter((name) => name.startsWith("sharedindex.")), + shared, + ); + } + }), + ); + + it.effect("prepares an unborn repository without creating a real index", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "initial.txt", "initial\n"); + for (const paths of [undefined, ["initial.txt"]]) { + const context = yield* driver.prepareCommitContext(cwd, paths); + assert.include(context?.stagedSummary ?? "", "initial.txt"); + assert.isFalse(NodeFS.existsSync(`${cwd}/.git/index`)); + } + yield* driver.commit(cwd, "Initial", "", { stage: { filePaths: ["initial.txt"] } }); + assert.equal(yield* git(cwd, ["show", "HEAD:initial.txt"]), "initial"); + }), + ); + + it.effect("preserves merge metadata while preparing selected and all files", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["checkout", "-b", "feature"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "Feature"]); + yield* git(cwd, ["checkout", initialBranch]); + yield* git(cwd, ["merge", "--no-commit", "--no-ff", "feature"]); + yield* writeTextFile(cwd, "README.md", "# test\nmerge edit\n"); + const names = ["index", "MERGE_HEAD", "MERGE_MSG", "ORIG_HEAD"]; + const before = names.map((name) => NodeFS.readFileSync(`${cwd}/.git/${name}`)); + const driver = yield* GitVcsDriver.GitVcsDriver; + for (const paths of [undefined, ["README.md"]]) { + yield* driver.prepareCommitContext(cwd, paths); + assert.deepEqual( + names.map((name) => NodeFS.readFileSync(`${cwd}/.git/${name}`)), + before, + ); + } + }), + ); }); describe("remote operations", () => { @@ -3271,7 +3354,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { refName: "feature/push", }); yield* writeTextFile(cwd, "feature.txt", "feature\n"); - yield* (yield* GitVcsDriver.GitVcsDriver).prepareCommitContext(cwd); + yield* git(cwd, ["add", "-A"]); yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add feature", ""); const pushed = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); @@ -3343,7 +3426,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["remote", "add", "origin", remote]); yield* git(cwd, ["push", "-u", "origin", "main"]); yield* writeTextFile(cwd, "upstream.txt", "upstream\n"); - yield* driver.prepareCommitContext(cwd); + yield* git(cwd, ["add", "-A"]); yield* driver.commit(cwd, "Add upstream update", ""); const pushed = yield* driver.pushCurrentBranch(cwd, null); @@ -3384,7 +3467,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const devSha = yield* git(cwd, ["rev-parse", "HEAD"]); yield* git(cwd, ["checkout", "-b", "feature/x", "origin/dev"]); yield* writeTextFile(cwd, "feature.txt", "feature\n"); - yield* driver.prepareCommitContext(cwd); + yield* git(cwd, ["add", "-A"]); yield* driver.commit(cwd, "Add feature", ""); const pushed = yield* driver.pushCurrentBranch(cwd, null); @@ -3418,7 +3501,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["checkout", "-b", "feature/y", "origin/main"]); yield* git(cwd, ["config", "branch.feature/y.gh-merge-base", "release/v2"]); yield* writeTextFile(cwd, "feature.txt", "feature\n"); - yield* driver.prepareCommitContext(cwd); + yield* git(cwd, ["add", "-A"]); yield* driver.commit(cwd, "Add feature", ""); const pushed = yield* driver.pushCurrentBranch(cwd, null); @@ -3456,7 +3539,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { "upstream/effect-atom", ); yield* writeTextFile(cwd, "alias.txt", "alias\n"); - yield* driver.prepareCommitContext(cwd); + yield* git(cwd, ["add", "-A"]); yield* driver.commit(cwd, "Add alias update", ""); const pushed = yield* driver.pushCurrentBranch(cwd, null); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 971d0e7a9a95..935fb3d24a72 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2025,48 +2025,132 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* })), ); - const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] = - Effect.fn("prepareCommitContext")(function* (cwd, filePaths) { - if (filePaths && filePaths.length > 0) { - yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe( - Effect.catchTags({ - GitCommandError: () => Effect.void, - }), + const stageCommitChanges = Effect.fnUntraced(function* ( + cwd: string, + filePaths?: readonly string[], + env?: NodeJS.ProcessEnv, + ) { + const config = env + ? ["-c", "core.splitIndex=false", "-c", "splitIndex.sharedIndexExpire=never"] + : []; + if (filePaths && filePaths.length > 0) { + if (env) { + const head = yield* executeGit( + "GitVcsDriver.prepareCommitContext.head", + cwd, + ["rev-parse", "--verify", "--quiet", "HEAD"], + { allowNonZeroExit: true }, + ); + // Even with GIT_INDEX_FILE, reset clears the repository's merge state. + yield* runGit( + "GitVcsDriver.prepareCommitContext.readTree", + cwd, + [...config, "read-tree", ...(head.exitCode === 0 ? ["HEAD"] : ["--empty"])], + { env }, ); - yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [ - "--literal-pathspecs", - "add", - "-A", - "--", - ...filePaths, - ]); } else { - yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]); + yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset"]).pipe( + Effect.catchTags({ GitCommandError: () => Effect.void }), + ); } - - const stagedSummary = yield* runGitStdout( - "GitVcsDriver.prepareCommitContext.stagedSummary", + yield* runGit( + "GitVcsDriver.commit.addSelected", cwd, - ["diff", "--cached", "--name-status"], - ).pipe(Effect.map((stdout) => stdout.trim())); - if (stagedSummary.length === 0) { - return null; - } - - const stagedPatch = yield* runGitStdoutWithOptions( - "GitVcsDriver.prepareCommitContext.stagedPatch", + [...config, "--literal-pathspecs", "add", "-A", "--", ...filePaths], + env ? { env } : {}, + ); + } else { + yield* runGit( + "GitVcsDriver.commit.addAll", cwd, - ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"], - { - maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, - appendTruncationMarker: true, - }, + [...config, "add", "-A"], + env ? { env } : {}, ); + } + }); - return { - stagedSummary, - stagedPatch, - }; + const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] = + Effect.fn("prepareCommitContext")(function* (cwd, filePaths) { + return yield* Effect.scoped( + Effect.gen(function* () { + const indexValue = yield* runGitStdout( + "GitVcsDriver.prepareCommitContext.indexPath", + cwd, + ["rev-parse", "--git-path", "index"], + ); + const indexPath = path.resolve(cwd, indexValue.trim()); + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-commit-index-", + }); + const tempIndexPath = path.join(directory, "index"); + const env = { GIT_INDEX_FILE: tempIndexPath } satisfies NodeJS.ProcessEnv; + const config = [ + "-c", + "core.splitIndex=false", + "-c", + "splitIndex.sharedIndexExpire=never", + ]; + if (yield* fileSystem.exists(indexPath)) { + const { mtime } = yield* fileSystem.stat(indexPath); + yield* fileSystem.copyFile(indexPath, tempIndexPath); + const indexTime = Option.isSome(mtime) + ? Math.max(0, Math.floor(mtime.value.getTime() / 1000)) + : 0; + yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); + yield* runGit( + "GitVcsDriver.prepareCommitContext.expandSplitIndex", + cwd, + [...config, "update-index", "--no-split-index"], + { env }, + ); + } else { + yield* runGit( + "GitVcsDriver.prepareCommitContext.emptyIndex", + cwd, + [...config, "read-tree", "--empty"], + { env }, + ); + } + yield* stageCommitChanges(cwd, filePaths, env); + + const stagedSummary = yield* runGitStdoutWithOptions( + "GitVcsDriver.prepareCommitContext.stagedSummary", + cwd, + ["diff", "--cached", "--name-status"], + { env }, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (stagedSummary.length === 0) { + return null; + } + + const stagedPatch = yield* runGitStdoutWithOptions( + "GitVcsDriver.prepareCommitContext.stagedPatch", + cwd, + ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"], + { + env, + maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ); + + return { + stagedSummary, + stagedPatch, + }; + }), + ).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new GitCommandError({ + operation: "GitVcsDriver.prepareCommitContext", + command: "git", + cwd, + detail: "Failed to prepare the temporary commit index.", + cause, + }), + }), + ); }); const commit: GitVcsDriver.GitVcsDriver["Service"]["commit"] = Effect.fn("commit")(function* ( @@ -2075,6 +2159,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* body, options?: GitVcsDriver.GitCommitOptions, ) { + if (options?.stage !== undefined) { + yield* stageCommitChanges(cwd, options.stage.filePaths); + } const args = ["commit", "-m", subject]; const trimmedBody = body.trim(); if (trimmedBody.length > 0) { diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 118e2bd29807..a009bbdb63ae 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -10,7 +10,6 @@ import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; import type { @@ -22,6 +21,7 @@ import type { VcsStatusStreamEvent, } from "@t3tools/contracts"; import { mergeGitStatusParts } from "@t3tools/shared/git"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; @@ -234,15 +234,9 @@ export const make = Effect.gen(function* () { // One permit per cwd for remote reads that write the cache. Without it a // periodic poll that started before `gh pr create` can finish after the // turn-end refresh and overwrite the fresh PR with its stale `pr: null`. - const remoteWriteLocks = new Map(); - const withRemoteWriteLock = (cwd: string, effect: Effect.Effect) => { - let lock = remoteWriteLocks.get(cwd); - if (lock === undefined) { - lock = Semaphore.makeUnsafe(1); - remoteWriteLocks.set(cwd, lock); - } - return lock.withPermits(1)(effect); - }; + const remoteWriteLocks = yield* KeyedLock.make(); + const withRemoteWriteLock = (cwd: string, effect: Effect.Effect) => + remoteWriteLocks.withLock(cwd, effect); const pollersRef = yield* SynchronizedRef.make(new Map()); const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6de35f46a7c2..e1128c05ee2f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -209,6 +209,7 @@ import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; @@ -1255,6 +1256,7 @@ const makeWsRpcLayer = ( const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; + const directEndpoints = yield* DirectEndpoints.DirectEndpoints; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; const review = yield* ReviewService.ReviewService; const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; @@ -1699,6 +1701,7 @@ const makeWsRpcLayer = ( remoteOpenTargets: yield* resolveAvailableEditorsForConfig( remoteOpenTargets.resolveTargets(), ), + directEndpoints: yield* resolveAvailableEditorsForConfig(directEndpoints.resolve()), observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, @@ -1857,6 +1860,21 @@ const makeWsRpcLayer = ( readWorkflowScript({ scriptPath: input.scriptPath }), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) => + observeRpcEffect( + ORCHESTRATION_V2_WS_METHODS.getTurnItem, + threadManagement.getTurnItem(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationV2GetThreadProjectionError({ + threadId: input.threadId, + message: "Failed to load turn item", + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: (input) => observeRpcEffect( ORCHESTRATION_V2_WS_METHODS.getTurnDiff, diff --git a/apps/web/package.json b/apps/web/package.json index ffccf206f910..53c53c1c7cf9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -60,6 +60,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/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 5358e253af4f..26b97c24719d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -161,6 +161,7 @@ import { shouldOpenMarkdownFileLinkInEditor, type MarkdownFileLinkMeta, } from "../markdown-links"; +import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links"; import { readLocalApi } from "../localApi"; import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; @@ -3022,6 +3023,7 @@ const CHAT_MARKDOWN_COMPONENTS = { updateThreadPullRequestLink, fileLinkChip, renderContextReference, + text, } = use(ChatMarkdownRendererContext); const citation = href ? parseAssistantCitationHref(href) : null; if (citation) return ; @@ -3233,10 +3235,21 @@ const CHAT_MARKDOWN_COMPONENTS = { ); } - return fileLinkChip( - fileLinkMeta, - `[${fileLinkMeta.basename}](${normalizedHref})`, - normalizedHref, + const label = nodeToPlainText(children); + const start = node?.position?.start.offset; + const end = node?.position?.end.offset; + const source = start !== undefined && end !== undefined ? text.slice(start, end) : ""; + const copyMarkdown = + source.startsWith("[") && source.includes("](") + ? source + : `[${(label || fileLinkMeta.basename).replace(/[\\[\]]/g, "\\$&")}](${normalizedHref})`; + const chip = fileLinkChip(fileLinkMeta, copyMarkdown, normalizedHref); + return isMarkdownFileLinkLabel(label, normalizedHref) ? ( + chip + ) : ( + + {children} {chip} + ); }, code: function MarkdownCode({ node, children, className, ...props }) { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 17e4c7772cb7..02769b4a3bb0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -253,7 +253,8 @@ import { setActivePreviewTab, useThreadPreviewState, } from "../previewStateStore"; -import { BrowserSettingsReadError } from "../browser/openFileInPreview"; +import { BrowserSettingsReadError, openUrlInPreview } from "../browser/openFileInPreview"; +import { resolveDiscoveredServerUrl } from "../browser/browserTargetResolver"; import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; @@ -3005,6 +3006,7 @@ export default function ChatView(props: ChatViewProps) { }, [setDismissedVersionMismatchKey, versionMismatchDismissKey]); const serverUpdateEnvironmentId = activeThread?.environmentId ?? null; const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); + const versionMismatchInstallation = serverConfig?.environment.capabilities.serverInstallation; const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig); const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig); const serverUpdateState = useAtomValue( @@ -3127,6 +3129,7 @@ export default function ChatView(props: ChatViewProps) { environmentId={serverUpdateEnvironmentId} serverLabel={versionMismatchServerLabel} selfUpdate={versionMismatchSelfUpdate} + installation={versionMismatchInstallation} desktopAppUpdate={versionMismatchDesktopAppUpdate} threadContinuation={versionMismatchThreadContinuation} targetVersion={versionMismatch.clientVersion} @@ -3168,6 +3171,7 @@ export default function ChatView(props: ChatViewProps) { versionMismatchDismissKey, serverUpdateEnvironmentId, versionMismatchSelfUpdate, + versionMismatchInstallation, versionMismatchDesktopAppUpdate, versionMismatchThreadContinuation, versionMismatchServerLabel, @@ -4944,12 +4948,32 @@ export default function ChatView(props: ChatViewProps) { data: `${script.command}\r`, }, }); - if (writeResult._tag === "Failure" && !isAtomCommandInterrupted(writeResult)) { - const error = squashAtomCommandFailure(writeResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); + if (writeResult._tag === "Failure") { + if (!isAtomCommandInterrupted(writeResult)) { + const error = squashAtomCommandFailure(writeResult); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : `Failed to run script "${script.name}".`, + ); + } + return; + } + if (script.autoOpenPreview && script.previewUrl && isPreviewSupportedInRuntime()) { + const previewResult = await openUrlInPreview({ + threadRef: activeThreadRef, + url: resolveDiscoveredServerUrl(activeThreadRef.environmentId, script.previewUrl), + openPreview, + }); + if (previewResult._tag === "Failure" && !isAtomCommandInterrupted(previewResult)) { + const error = squashAtomCommandFailure(previewResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not open preview", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); + } } }, [ @@ -4965,6 +4989,7 @@ export default function ChatView(props: ChatViewProps) { setLastInvokedScriptByProjectId, environmentId, openTerminal, + openPreview, activeKnownTerminalIds, allocatableActiveTerminalIds, runningTerminalIds, @@ -8929,18 +8954,7 @@ export default function ChatView(props: ChatViewProps) { ); const messageIdForSend = heldCloudSend?.message.id ?? newMessageId(); const messageCreatedAt = heldCloudSend?.message.createdAt ?? new Date().toISOString(); - // Sending past the resume banner compacts first so the turn does not resend the stale - // history. The message queues behind the /compact run; steering into it is rejected, - // and a held queue would strand it. - const compactBeforeSend = - resumeCompactionBannerItem !== null && - !compactDisabled && - !hasHeldQueuedRuns && - multipleModelSelections === null && - messageTextForSend.toLowerCase() !== "/compact"; - const turnDispatchMode = compactBeforeSend ? "queue" : dispatchMode; - const shouldQueueBehindActiveRun = - compactBeforeSend || (phase === "running" && dispatchMode === "queue"); + const shouldQueueBehindActiveRun = phase === "running" && dispatchMode === "queue"; const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, @@ -9508,22 +9522,6 @@ export default function ChatView(props: ChatViewProps) { failure = turnAttachmentsResult; } - if (failure === null && compactBeforeSend) { - const compactResult = await startThreadTurn({ - environmentId, - input: { - threadId: threadIdForSend, - message: { messageId: newMessageId(), role: "user", text: "/compact", attachments: [] }, - modelSelection: ctxSelectedModelSelection, - runtimeMode, - interactionMode: sendInteractionMode, - }, - }); - if (compactResult._tag === "Failure") { - failure = compactResult; - } - } - let backgroundDraftOpened = false; let turnStartSucceeded = false; if (failure === null && turnAttachmentsResult._tag === "Success") { @@ -9605,7 +9603,7 @@ export default function ChatView(props: ChatViewProps) { titleSeed: title, runtimeMode, interactionMode: sendInteractionMode, - dispatchMode: turnDispatchMode, + dispatchMode, ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }, diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index 6f7f6e5c8f1f..456f70516b23 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -13,7 +13,7 @@ function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { "flex items-center justify-between gap-2", mode === "embedded" ? "px-2" : "px-4", shouldUseDragRegion - ? "drag-region h-[var(--workspace-topbar-height)] border-b border-border wco:h-[env(titlebar-area-height)] wco:pr-(--workspace-native-controls-inset)" + ? "drag-region h-[var(--workspace-topbar-height)] border-b border-border wco:pr-(--workspace-native-controls-inset)" : "flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-transparent", ); } diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index a940d2ae90fb..4c62f5f385ef 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -158,70 +158,166 @@ export const GitLabIcon = ({ ); +// Microsoft Azure DevOps mark via selfhst/icons (CC BY 4.0): https://github.com/selfhst/icons/blob/main/svg/azure-devops.svg export const AzureDevOpsIcon: Icon = (props) => { const id = useId().replaceAll(":", ""); - const gradientA = `${id}-azure-a`; - const gradientB = `${id}-azure-b`; - const gradientC = `${id}-azure-c`; + const gradientId = `${id}-azure`; return ( - + - - + + + + - - - - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); diff --git a/apps/web/src/components/RightPanelSheet.tsx b/apps/web/src/components/RightPanelSheet.tsx index 044369ed0f76..0cad2af5009d 100644 --- a/apps/web/src/components/RightPanelSheet.tsx +++ b/apps/web/src/components/RightPanelSheet.tsx @@ -22,7 +22,7 @@ export function RightPanelSheet(props: { side="right" showCloseButton={false} keepMounted - className="w-[min(42vw,28rem)] min-w-80 max-w-[28rem] max-[760px]:w-[min(88vw,24rem)] max-[760px]:min-w-0 wco:mt-[env(titlebar-area-height)] wco:h-[calc(100%-env(titlebar-area-height))] wco:max-h-[calc(100%-env(titlebar-area-height))]" + className="w-[min(42vw,28rem)] min-w-80 max-w-[28rem] max-[760px]:w-[min(88vw,24rem)] max-[760px]:min-w-0 wco:mt-(--workspace-topbar-height) wco:h-[calc(100%-var(--workspace-topbar-height))] wco:max-h-[calc(100%-var(--workspace-topbar-height))]" > {props.children} diff --git a/apps/web/src/components/ServerUpdateAction.test.tsx b/apps/web/src/components/ServerUpdateAction.test.tsx index 6c388bca4f98..116b7fdd21bb 100644 --- a/apps/web/src/components/ServerUpdateAction.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.test.tsx @@ -1,7 +1,7 @@ import { act, type ReactElement } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { renderToStaticMarkup } from "react-dom/server"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ServerInstallation } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -9,11 +9,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" const testState = vi.hoisted(() => ({ updateServer: vi.fn(), toast: vi.fn(), + clipboard: vi.fn(), continueThreadsAfterServerUpdate: false, })); vi.mock("~/hooks/useCopyToClipboard", () => ({ - useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }), + useCopyToClipboard: (options: { onCopy: (context: { command: string }) => void }) => ({ + copyToClipboard: (command: string, context: { command: string }) => { + testState.clipboard(command); + options.onCopy(context); + }, + }), })); vi.mock("~/hooks/useSettings", () => ({ useEnvironmentSettings: ( @@ -66,9 +72,51 @@ describe("ServerUpdateAction", () => { beforeEach(() => { testState.updateServer.mockReset(); testState.toast.mockReset(); + testState.clipboard.mockReset(); testState.continueThreadsAfterServerUpdate = false; }); + it.each([ + [ + { kind: "npm-global", prefix: "/opt/node" }, + "npm install --global --prefix '/opt/node' t3@0.0.45", + "Update command copied", + "then restart t3", + ], + [ + { kind: "npx" }, + "npx t3@0.0.45", + "Relaunch command copied", + "This does not update an installed t3 command.", + ], + [ + undefined, + "npx t3@0.0.45", + "Relaunch command copied", + "This does not update an installed t3 command.", + ], + ] satisfies ReadonlyArray)( + "copies an honest manual command for %j without invoking remote update", + (installation, command, title, guidance) => { + const action = ServerUpdateAction({ + environmentId: "env-test" as EnvironmentId, + serverLabel: "Test server", + selfUpdate: null, + installation, + targetVersion: "0.0.45", + }) as ActionElement; + action.props.onClick?.(); + expect(testState.clipboard).toHaveBeenCalledWith(command); + expect(testState.toast).toHaveBeenCalledWith( + expect.objectContaining({ + title, + description: expect.stringContaining(guidance), + }), + ); + expect(testState.updateServer).not.toHaveBeenCalled(); + }, + ); + it("reports success only after the shared update flow reconnects", async () => { testState.updateServer.mockResolvedValue( AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }), diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 00c37f683b54..638d66264ae0 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -1,4 +1,8 @@ -import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts"; +import type { + EnvironmentId, + ServerInstallation, + ServerSelfUpdateCapability, +} from "@t3tools/contracts"; import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server"; import { isAtomCommandInterrupted, @@ -44,6 +48,7 @@ export interface ServerUpdateTarget { readonly environmentId: EnvironmentId; readonly serverLabel: string; readonly selfUpdate: ServerSelfUpdateCapability | null; + readonly installation?: ServerInstallation | undefined; readonly desktopAppUpdate?: boolean; readonly threadContinuation?: boolean; readonly targetVersion: string; @@ -192,6 +197,7 @@ export function ServerUpdateAction({ environmentId, serverLabel, selfUpdate, + installation, desktopAppUpdate = false, threadContinuation = false, targetVersion, @@ -208,12 +214,16 @@ export function ServerUpdateAction({ ); const update = useServerUpdate(); const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ - target: "update command", + target: installation?.kind === "npm-global" ? "update command" : "relaunch command", onCopy: ({ command }) => { toastManager.add({ type: "success", - title: "Update command copied", - description: `Run \`${command}\` on ${serverLabel} to update it.`, + title: + installation?.kind === "npm-global" ? "Update command copied" : "Relaunch command copied", + description: + installation?.kind === "npm-global" + ? `Run \`${command}\` on ${serverLabel}, then restart t3 with your usual options.` + : `Stop t3 on ${serverLabel}, then relaunch with \`${command}\` using the same subcommand and options. This does not update an installed t3 command.`, }); }, onError: (error) => { @@ -262,8 +272,15 @@ export function ServerUpdateAction({ const guestLabel = guestServerUpdateLabel(environmentId, selfUpdate); const manualCommand = - selfUpdate === null && guestLabel === null ? manualServerUpdateCommand(targetVersion) : null; - const actionLabel = manualCommand !== null ? "Copy update command" : (guestLabel ?? label); + selfUpdate === null && guestLabel === null + ? manualServerUpdateCommand(targetVersion, installation) + : null; + const actionLabel = + manualCommand !== null + ? installation?.kind === "npm-global" + ? "Copy update command" + : "Copy relaunch command" + : (guestLabel ?? label); const onClick = manualCommand !== null ? () => copyToClipboard(manualCommand, { command: manualCommand }) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 6331ad40773f..f3efb10ddf03 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1830,7 +1830,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( @@ -1874,8 +1874,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onClick={handleUnsnoozeClick} onPointerDown={handleActionPointerDown} className={cn( - "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100", - isWoke && "group-hover/sidebar-row:static", + "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", + isWoke && "group-any-hover/sidebar-row:static", )} > @@ -1891,8 +1891,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onClick={handleUnsettleClick} onPointerDown={handleActionPointerDown} className={cn( - "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100", - isWoke && "group-hover/sidebar-row:static", + "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", + isWoke && "group-any-hover/sidebar-row:static", )} /> } @@ -1908,8 +1908,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onClick={handleSettleClick} onPointerDown={handleActionPointerDown} className={cn( - "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100", - isWoke && "group-hover/sidebar-row:static", + "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", + isWoke && "group-any-hover/sidebar-row:static", )} > @@ -1998,7 +1998,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { className={cn( isWokeStatus ? "pointer-events-auto" - : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0", + : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-any-hover/sidebar-row:absolute group-any-hover/sidebar-row:right-0 group-any-hover/sidebar-row:opacity-0", "flex items-center self-center justify-self-end tabular-nums text-secondary-label transition-opacity", snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0", )} @@ -2065,7 +2065,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // would keep the controls pinned over the status label // once the pointer moves away (e.g. after a failed // settle) instead of cross-fading back. - "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:static group-hover/sidebar-row:opacity-100", + "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:static group-any-hover/sidebar-row:opacity-100", snoozeMenuOpen && "pointer-events-auto static opacity-100", )} > diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a34ca29ec5a2..7b7715a251ee 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1123,6 +1123,7 @@ import { formatProviderSkillDisplayName, getProviderSlashCommandsForSlashMenu, getProviderSkillsForSlashMenu, + hasCompleteProviderWorkspaceSnapshot, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, } from "@t3tools/client-runtime/providerSkills"; @@ -2190,41 +2191,68 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) reportFailure: false, }); const workspaceRefreshKeyRef = useRef(null); - const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null); + const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ + key: string; + notBefore: number; + } | null>(null); + const workspaceRefreshScopeKey = + gitCwd && selectedProviderEntry + ? `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}` + : null; + const workspaceSlashCommandsPending = + selectedProviderStatus?.workspaceSnapshots?.some( + (snapshot) => snapshot.cwd === gitCwd && snapshot.slashCommandsPending === true, + ) ?? false; + useEffect(() => { + if ( + !workspaceSlashCommandsPending || + !workspaceRefreshRetry || + workspaceRefreshRetry.key !== workspaceRefreshScopeKey + ) + return; + const timeout = setTimeout( + () => { + setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current)); + }, + Math.max(0, workspaceRefreshRetry.notBefore - Date.now()), + ); + return () => clearTimeout(timeout); + }, [workspaceRefreshRetry, workspaceRefreshScopeKey, workspaceSlashCommandsPending]); const hadWorkspaceSnapshotRef = useRef(false); useEffect(() => { - const hasWorkspaceSnapshot = Boolean( - gitCwd && - selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd), + const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot( + selectedProviderStatus, + gitCwd, ); if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { workspaceRefreshKeyRef.current = null; - workspaceRefreshRetryRef.current = null; + setWorkspaceRefreshRetry(null); } hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; }, [gitCwd, selectedProviderStatus]); useEffect(() => { if (!gitCwd || !selectedProviderEntry) return; const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`; - const hasWorkspaceSnapshot = selectedProviderStatus?.workspaceSnapshots?.some( - (snapshot) => snapshot.cwd === gitCwd, + const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot( + selectedProviderStatus, + gitCwd, ); if (workspaceRefreshKeyRef.current === key) return; if (hasWorkspaceSnapshot) { workspaceRefreshKeyRef.current = key; - workspaceRefreshRetryRef.current = null; + setWorkspaceRefreshRetry(null); return; } - const retry = workspaceRefreshRetryRef.current; + const retry = workspaceRefreshRetry; if (retry?.key === key && Date.now() < retry.notBefore) return; workspaceRefreshKeyRef.current = key; const retryLater = () => { if (workspaceRefreshKeyRef.current !== key) return; workspaceRefreshKeyRef.current = null; - workspaceRefreshRetryRef.current = { + setWorkspaceRefreshRetry({ key, notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, - }; + }); }; void refreshProviders({ environmentId, @@ -2232,14 +2260,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }).then((result) => { const hasWorkspaceSnapshot = result._tag === "Success" && - result.value.providers - .find((provider) => provider.instanceId === selectedProviderEntry.instanceId) - ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd); + hasCompleteProviderWorkspaceSnapshot( + result.value.providers.find( + (provider) => provider.instanceId === selectedProviderEntry.instanceId, + ), + gitCwd, + ); if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) { retryLater(); } }, retryLater); - }, [environmentId, gitCwd, prompt, refreshProviders, selectedProviderEntry]); + }, [ + environmentId, + gitCwd, + prompt, + refreshProviders, + selectedProviderEntry, + workspaceRefreshRetry, + ]); const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], 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/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx
index 5b805d7f5649..f7aeb679a87b 100644
--- a/apps/web/src/components/chat/MessagesTimeline.test.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx
@@ -1,8 +1,11 @@
+// @vitest-environment jsdom
+
 import {
   ApprovalRequestId,
   CheckpointRef,
   EnvironmentId,
   MessageId,
+  ProjectId,
   RunId,
   ThreadId,
 } from "@t3tools/contracts";
@@ -15,6 +18,7 @@ import {
   type ReactElement,
 } from "react";
 import { renderToStaticMarkup } from "react-dom/server";
+import { createRoot } from "react-dom/client";
 import { create, type ReactTestRenderer } from "react-test-renderer";
 import { shouldUseRestingComposerLayout } from "../composerFooterLayout";
 import { useComposerFocusState } from "./useComposerFocusState";
@@ -227,6 +231,7 @@ function stubDomGlobals() {
   };
 
   vi.stubGlobal("Element", ElementStub);
+  vi.stubGlobal("getComputedStyle", undefined);
   vi.stubGlobal("localStorage", {
     getItem: () => null,
     setItem: () => {},
@@ -256,13 +261,131 @@ function stubDomGlobals() {
 
 beforeEach(stubDomGlobals);
 beforeAll(async () => {
-  stubDomGlobals();
+  Object.defineProperty(window, "matchMedia", { value: matchMedia, configurable: true });
   ({ MessagesTimeline, resolvePreviewAnnotationImage } = await import("./MessagesTimeline"));
 }, 30_000);
 
 const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
 const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z";
 
+describe("timeline tooltip scroll dismissal", () => {
+  it.each([
+    "hover",
+    "delayed hover",
+    "focus",
+    "hover then focus",
+    "outside timeline",
+    "wheel without scroll",
+    "pr hover",
+    "pr delayed hover",
+    "pr focus",
+    "pr hover then focus",
+  ])("handles %s through the real tooltip interactions", async (scenario) => {
+    const isPullRequest = scenario.startsWith("pr ");
+    const interaction = isPullRequest ? scenario.slice(3) : scenario;
+    vi.unstubAllGlobals();
+    vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+    vi.useFakeTimers();
+    const { Tooltip, TooltipTrigger, TooltipPopup, TooltipScrollDismissArea } =
+      await vi.importActual("../ui/tooltip");
+    const container = document.createElement("div");
+    document.body.append(container);
+    const root = createRoot(container);
+    const onMouseEnter = vi.fn();
+    const query = await import("~/state/query");
+    const querySpy = isPullRequest
+      ? vi.spyOn(query, "useEnvironmentQuery").mockReturnValue({
+          data: null,
+          dataUpdatedAt: 0,
+          error: "Pull request not found",
+          failure: null,
+          isPending: false,
+          isSuccess: false,
+          refresh: vi.fn(),
+        })
+      : null;
+    const { PullRequestLinkPreview } = await import("../pullRequest/PullRequestLinkPreview");
+    const tooltip = isPullRequest ? (
+      message link}
+        originalUrl="https://example.com"
+        target={{
+          environmentId: ACTIVE_THREAD_ENVIRONMENT_ID,
+          input: {
+            projectId: ProjectId.make("project-1"),
+            repository: "pingdotgg/t3code",
+            number: 1,
+          },
+        }}
+      />
+    ) : (
+      
+        
+          message link
+        
+        https://example.com
+      
+    );
+    try {
+      await act(async () => {
+        root.render(
+          <>
+            
+              
+ {interaction === "outside timeline" ? null : tooltip} +
+
+ {interaction === "outside timeline" ? tooltip : null} + , + ); + }); + const trigger = container.querySelector("button")!; + const scrollable = container.querySelector('[data-testid="scrollable"]')!; + await act(async () => { + if (interaction !== "focus") { + trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + trigger.dispatchEvent(new MouseEvent("mouseenter")); + trigger.dispatchEvent(new MouseEvent("mousemove", { bubbles: true })); + } + if (interaction === "focus" || interaction === "hover then focus") { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" })); + trigger.focus(); + } + if (interaction !== "delayed hover") { + await vi.advanceTimersByTimeAsync(isPullRequest ? 400 : 60); + } + }); + expect(onMouseEnter).toHaveBeenCalledTimes(interaction === "focus" ? 0 : 1); + expect( + document.querySelector('[data-slot="tooltip-popup"][data-open]')?.textContent ?? null, + ).toBe(interaction === "delayed hover" ? null : "https://example.com"); + + await act(async () => { + scrollable.dispatchEvent( + interaction === "wheel without scroll" + ? new WheelEvent("wheel", { bubbles: true, deltaY: 100 }) + : new Event("scroll"), + ); + await vi.advanceTimersByTimeAsync(isPullRequest ? 1000 : 100); + }); + expect( + document.querySelector('[data-slot="tooltip-popup"][data-open]')?.textContent ?? null, + ).toBe( + interaction === "hover" || interaction === "delayed hover" ? null : "https://example.com", + ); + if (interaction === "focus" || interaction === "hover then focus") { + expect(document.activeElement).toBe(trigger); + } + } finally { + await act(async () => root.unmount()); + querySpy?.mockRestore(); + container.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + } + }); +}); + function buildProps() { return { isWorking: false, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 224cb58f78c3..1f267bcfd730 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -45,6 +45,10 @@ import { workEntryViewedImagePath, } from "@t3tools/client-runtime/work-log/presentation"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; +import { + turnItemHasDetail, + turnItemNeedsDetailFetch, +} from "@t3tools/client-runtime/work-log/item-detail"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { subagentGroupSummary, @@ -216,7 +220,7 @@ import { import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Spinner } from "../ui/spinner"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Tooltip, TooltipPopup, TooltipTrigger, TooltipScrollDismissArea } from "../ui/tooltip"; import { WorktreeSetupCard } from "./WorktreeSetupCard"; import { ContextChipPopover as UserMessageContextPopover, @@ -260,7 +264,7 @@ import { formatDayAwareTimestamp, formatUpcomingTimestamp, } from "../../timestampFormat"; -import { V2ItemInspector } from "./V2ItemInspector"; +import { FetchedToolOutput, V2ItemInspector } from "./V2ItemInspector"; import { useV2ItemSupport } from "../../state/v2ItemSupport"; import { Collapsible, CollapsibleTrigger, CollapsiblePanel } from "../ui/collapsible"; import { @@ -1329,7 +1333,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return ( -
-
+
); @@ -5159,10 +5163,25 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { viewedImage ? viewedImagePath : null, ) : null; + // Projected rows expand to the item inspector, so only offer a disclosure + // when it has something to show, even if that output still has to load. + // Reads and skills still fetch the output the timeline withheld. + const plainOutputFetches = + plainOutput !== undefined && + workEntry.projectedItem !== undefined && + turnItemNeedsDetailFetch(workEntry.projectedItem.item); const canExpandProjectedItem = plainOutput !== undefined - ? Boolean(plainOutput || viewedImage || workEntry.questionAnswer) - : canExpand || workEntry.projectedItem !== undefined; + ? Boolean(plainOutput || viewedImage || workEntry.questionAnswer || plainOutputFetches) + : workEntry.projectedItem === undefined + ? canExpand + : isReasoning + ? Boolean(workEntry.detail?.trim()) + : Boolean( + viewedImage || + workEntry.questionAnswer || + turnItemHasDetail(workEntry.projectedItem.item), + ); // Reserve destructive row styling for severe failures, not routine tool errors. const iconWrapperClass = cn( "flex size-4 items-center justify-center", @@ -5333,7 +5352,9 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { !isReasoning && !workEntry.questionAnswer && canExpandProjectedItem && - (expandedBody || (workEntry.projectedItem && plainOutput === undefined)) ? ( + (expandedBody || + plainOutputFetches || + (workEntry.projectedItem && plainOutput === undefined)) ? ( {workEntry.projectedItem && plainOutput === undefined ? ( - ) : expandedBody ? ( -
{expandedBody}
- ) : null} + ) : ( + <> + {expandedBody ? ( +
{expandedBody}
+ ) : null} + {plainOutputFetches && workEntry.projectedItem ? ( + + ) : null} + + )}
) : null} 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/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index b2f0f6b9e762..7dc48a9e00a4 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -4,18 +4,27 @@ import type { RunId, ThreadId, } from "@t3tools/contracts"; +import { + toolCallLines, + turnItemDetailRevision, + turnItemNeedsDetailFetch, + turnItemOutputText, +} from "@t3tools/client-runtime/work-log/item-detail"; import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react"; import { memo, Suspense, use, useMemo } from "react"; import { useTheme } from "../../hooks/useTheme"; +import { cn } from "../../lib/utils"; import { resolveDiffThemeName } from "../../lib/diffRendering"; import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting"; +import { useTurnItemDetail } from "../../state/queries"; import { useV2ItemSupport } from "../../state/v2ItemSupport"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; import { Button } from "../ui/button"; import ChatMarkdown from "../ChatMarkdown"; import { RenderErrorBoundary } from "../RenderErrorBoundary"; import { resolveExternalWebLinkHref } from "./externalLinkContextMenu"; +import { ShellCommandBlock } from "./ShellCommandBlock"; interface V2ItemInspectorProps { readonly projectedItem: OrchestrationV2ProjectedTurnItem; @@ -48,6 +57,9 @@ function JsonTokens({ text }: { readonly text: string }) { ]); } +const monoClassName = + "font-mono text-(length:--font-size-code,var(--text-2xs)) leading-relaxed whitespace-pre-wrap break-words select-text"; + function StructuredValue({ value, highlightJson = false, @@ -67,7 +79,7 @@ function StructuredValue({ }, [highlightJson, text]); if (!text) return null; return ( -
+    
       {isJson ? (
         
           
@@ -81,8 +93,111 @@ function StructuredValue({
   );
 }
 
+/**
+ * The item behind a projected row, with the output the timeline withheld
+ * fetched while the row is open.
+ */
+function useFetchedTurnItem(
+  projectedItem: OrchestrationV2ProjectedTurnItem,
+  environmentId: EnvironmentId,
+) {
+  const wireItem = projectedItem.item;
+  const fetches = turnItemNeedsDetailFetch(wireItem);
+  const detail = useTurnItemDetail(
+    fetches
+      ? {
+          environmentId,
+          threadId: projectedItem.sourceThreadId,
+          itemId: projectedItem.sourceItemId,
+          revision: turnItemDetailRevision(wireItem),
+        }
+      : null,
+  );
+  const fetchedItem = detail.data?.item;
+  const item = fetchedItem?.type === wireItem.type ? fetchedItem : wireItem;
+  return {
+    item,
+    output: {
+      output: turnItemOutputText(item),
+      pending: item === wireItem && detail.isPending,
+      error:
+        item !== wireItem
+          ? null
+          : detail.data?.item === null
+            ? "Output is no longer available."
+            : detail.error,
+      empty: fetches && item !== wireItem,
+    },
+  };
+}
+
+interface ToolOutputState {
+  readonly output: string | null;
+  readonly pending: boolean;
+  readonly error: string | null;
+  readonly empty: boolean;
+}
+
+function ToolOutput(props: ToolOutputState) {
+  return props.output ? (
+    
{props.output}
+ ) : props.pending ? ( +
Loading output…
+ ) : props.error ? ( +
Couldn't load output: {props.error}
+ ) : props.empty ? ( +
No output.
+ ) : null; +} + +/** Fetched output for rows that show their own plain text instead of the inspector. */ +export function FetchedToolOutput(props: { + readonly projectedItem: OrchestrationV2ProjectedTurnItem; + readonly environmentId: EnvironmentId; +}) { + const { output } = useFetchedTurnItem(props.projectedItem, props.environmentId); + return ( +
+ +
+ ); +} + +/** A tool call's body: the call itself in the foreground, its result muted below. */ +function ToolCallBody( + props: ToolOutputState & { + readonly command?: string; + readonly args?: unknown; + readonly exitCode?: number | undefined; + }, +) { + const call = toolCallLines({ command: props.command, args: props.args }); + return ( +
+ {call.command ? : null} + {call.args ? ( +
+ {call.args.map(([key, value]) => ( +
+ {key} + {value} +
+ ))} +
+ ) : null} + {call.argsText ? : null} + + {props.exitCode !== undefined && props.exitCode !== 0 ? ( +
exit {props.exitCode}
+ ) : null} +
+ ); +} + export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspectorProps) { - const { item } = props.projectedItem; + const fetched = useFetchedTurnItem(props.projectedItem, props.environmentId); + const item = fetched.item; + const outputState = fetched.output; const support = useV2ItemSupport({ environmentId: props.environmentId, sourceThreadId: props.projectedItem.sourceThreadId, @@ -105,14 +220,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ) : null} {item.type === "command_execution" ? ( -
- - {item.exitCode !== undefined ? ( -

- Process exited with code {item.exitCode} -

- ) : null} -
+ ) : null} {item.type === "file_change" ? ( @@ -137,6 +245,9 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ) : null} + {item.status === "failed" && item.diffStr?.trim() ? ( + + ) : null} {item.changes !== undefined && item.changes.length > 0 ? (
    {item.changes.map((change, index) => ( @@ -153,8 +264,16 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ) : null} - {item.type === "file_search" && item.results ? ( -
      + {item.type === "file_search" && item.pattern?.trim() ? ( +
      {item.pattern}
      + ) : null} + + {item.type === "web_search" && item.patterns?.length ? ( +
      {item.patterns.join("\n")}
      + ) : null} + + {item.type === "file_search" && item.results?.length ? ( +
        {item.results.map((result) => (
      • @@ -170,8 +289,8 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
      ) : null} - {item.type === "web_search" && item.results ? ( -
        + {item.type === "web_search" && item.results?.length ? ( +
          {item.results.map((result) => { const safeHref = resolveExternalWebLinkHref(result.url); return ( @@ -198,14 +317,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
        ) : null} - {item.type === "dynamic_tool" ? ( -
        -

        - Input -

        - -
        - ) : null} + {item.type === "dynamic_tool" ? : null} {item.type === "approval_request" ? : null} {item.type === "user_input_request" ? ( @@ -213,6 +325,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ) : null} {item.type === "notification" ? : null} {item.type === "system_notice" ? : null} + {item.type === "error" ? : null} {item.type === "proposed_plan" ? : null} {item.type === "todo_list" ? ( +

        {item.fromProviderInstanceIds.join(", ")} → {item.toProviderInstanceId}

        diff --git a/apps/web/src/components/chat/WorkLog.tsx b/apps/web/src/components/chat/WorkLog.tsx index 5a0efd3550c3..4358f30f4de3 100644 --- a/apps/web/src/components/chat/WorkLog.tsx +++ b/apps/web/src/components/chat/WorkLog.tsx @@ -123,7 +123,7 @@ export function WorkLogDetails({ kind === "text" ? "ms-7 flex max-h-96 flex-col gap-3 overflow-auto px-0.5 py-1 select-text" : kind === "panel" - ? "mt-1 rounded-md bg-muted/40 px-3 py-2" + ? "mt-0.5 mb-1.5" : "mt-1", )} onClick={(event) => event.stopPropagation()} diff --git a/apps/web/src/components/chat/threadDetailsPanelStyles.ts b/apps/web/src/components/chat/threadDetailsPanelStyles.ts index 523a97f3acac..d3f81e8ddddd 100644 --- a/apps/web/src/components/chat/threadDetailsPanelStyles.ts +++ b/apps/web/src/components/chat/threadDetailsPanelStyles.ts @@ -38,7 +38,7 @@ export const THREAD_DETAILS_PANEL_ICON_CLASS = "size-4 shrink-0 text-muted-foreg export const THREAD_DETAILS_PANEL_CHEVRON_CLASS = "size-4 shrink-0 text-muted-foreground"; -export const THREAD_DETAILS_PANEL_ICON_ACTION_CLASS = `size-6 rounded-md border-transparent bg-transparent p-0 sm:size-6 ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`; +export const THREAD_DETAILS_PANEL_ICON_ACTION_CLASS = `size-6 justify-center rounded-md border-transparent bg-transparent p-0 sm:size-6 ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`; export const THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS = `group/thread-details-action flex w-full items-center rounded-lg ${THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS}`; diff --git a/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx b/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx index 13e14b031ec9..51fa6274bc13 100644 --- a/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx +++ b/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx @@ -65,6 +65,7 @@ export function useAutoBalanceUpdateBanner( environmentId: environment.environmentId, serverLabel: environment.label, selfUpdate, + installation: environment.serverConfig?.environment.capabilities.serverInstallation, desktopAppUpdate, threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig), continueThreadsAfterServerUpdate: diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx index 157861f1eaa6..d7f2e030cb58 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx @@ -327,6 +327,7 @@ describe("cloud onboarding discovery", () => { { environmentId: newMachineId, connection: { phase: "connected", error: null, traceId: null }, + relayManaged: true, }, ]} showSavedEnvironments diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 5022031e98e6..fe5eb7836239 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -48,6 +48,8 @@ function discoveredCompatibilityError( export interface SavedCloudEnvironmentConnection { readonly environmentId: EnvironmentId; readonly connection: EnvironmentConnectionPresentation; + /** False for a machine saved over another route (LAN, Tailscale, SSH) only. */ + readonly relayManaged: boolean; /** Present once connected; carries the user's icon override. */ readonly serverConfig?: ServerConfig | null; } @@ -121,7 +123,15 @@ export function CloudEnvironmentConnectRows({ ReadonlySet >(new Set()); const savedById = new Map( - savedEnvironments.map((environment) => [environment.environmentId, environment]), + savedEnvironments + .filter((environment) => environment.relayManaged) + .map((environment) => [environment.environmentId, environment]), + ); + // Saved over another route only: T3 Connect would be an added fallback. + const savedWithoutRelay = new Set( + savedEnvironments + .filter((environment) => !environment.relayManaged) + .map((environment) => environment.environmentId), ); useEffect(() => { @@ -153,8 +163,12 @@ export function CloudEnvironmentConnectRows({ if (result._tag === "Success") { toastManager.add({ type: "success", - title: "Environment added", - description: `Connecting to ${environment.label} through T3 Connect.`, + title: savedWithoutRelay.has(environment.environmentId) + ? "T3 Connect route added" + : "Environment added", + description: savedWithoutRelay.has(environment.environmentId) + ? `${environment.label} falls back to T3 Connect when its other routes are unreachable.` + : `Connecting to ${environment.label} through T3 Connect.`, }); return true; } @@ -182,10 +196,14 @@ export function CloudEnvironmentConnectRows({ return false; }; + // During onboarding selection a machine saved over another route already + // has its own row elsewhere, and selecting it must not add a T3 Connect + // route as a side effect, so it is left out here. const visibleEnvironments = [...environmentsState.environments.values()].filter( ({ environment }) => environment.environmentId !== primaryEnvironmentId && - (showSavedEnvironments || !savedById.has(environment.environmentId)), + (showSavedEnvironments || !savedById.has(environment.environmentId)) && + !(selection && savedWithoutRelay.has(environment.environmentId)), ); const selectNewComputers = useEffectEvent(() => { const seen = selection?.autoSelectedComputers; @@ -337,19 +355,22 @@ export function CloudEnvironmentConnectRows({ : availability === "checking" ? "bg-warning" : "bg-muted-foreground/35"; + const notAdded = savedWithoutRelay.has(environment.environmentId) + ? "Saved without T3 Connect" + : "Not added"; const statusText = unsupported && !savedEnvironment - ? "T3 Connect · Not added · Client not supported" + ? `T3 Connect · ${notAdded} · Client not supported` : savedConnection ? savedConnection.statusText : availability === "online" - ? "T3 Connect · Not added · Relay online" + ? `T3 Connect · ${notAdded} · Relay online` : availability === "offline" - ? "T3 Connect · Not added · Relay offline" + ? `T3 Connect · ${notAdded} · Relay offline` : availability === "checking" - ? "T3 Connect · Not added · Checking relay status…" + ? `T3 Connect · ${notAdded} · Checking relay status…` : (Option.getOrNull(error)?.message ?? - "T3 Connect · Not added · Relay status unavailable"); + `T3 Connect · ${notAdded} · Relay status unavailable`); if (selection) { return (
        diff --git a/apps/web/src/components/device/DeviceSetup.tsx b/apps/web/src/components/device/DeviceSetup.tsx index f7869bcd6aff..d4ebfe89f903 100644 --- a/apps/web/src/components/device/DeviceSetup.tsx +++ b/apps/web/src/components/device/DeviceSetup.tsx @@ -63,6 +63,9 @@ export function DeviceSetup(props: { const [step, setStep] = useState(0); const enabled = props.state.hostStatus !== "disabled"; const busy = props.state.hostStatus === "installing" || props.state.hostStatus === "starting"; + const localPlatformsUnavailable = props.state.hosts.some( + (host) => host.kind === "local" && !host.platforms.some((platform) => platform.available), + ); const update = async ( kind: NonNullable, @@ -117,8 +120,8 @@ export function DeviceSetup(props: { ) : null} - {step === 1 ? ( -
        + {step === 1 || (step === 0 && enabled && localPlatformsUnavailable) ? ( +

        Check simulator support

        ({ usePrimaryEnvironmentId: () => EnvironmentId.make("env-1"), })); vi.mock("~/hooks/useSettings", () => ({ + useEnvironmentSettings: () => undefined, useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => select(DEFAULT_CLIENT_SETTINGS), })); diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index bf5bd62de91c..53dc7642d9be 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -6,7 +6,6 @@ import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/enviro import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { type EnvironmentId, - DEFAULT_SERVER_SETTINGS, type PullRequestAction, type PullRequestMergeMethod, type PullRequestListEntry, @@ -15,7 +14,6 @@ import { resolveEnvironmentMachineKind, type ScopedThreadRef, } from "@t3tools/contracts"; -import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { ArrowDownUpIcon, ArrowLeftIcon, @@ -63,20 +61,14 @@ import { type ShortcutMatchContext, } from "~/keybindings"; import { primaryServerKeybindingsAtom } from "~/state/server"; -import { useClientSettings } from "~/hooks/useSettings"; -import { - deriveLogicalProjectKeyFromSettings, - derivePhysicalProjectKey, - selectProjectGroupingSettings, -} from "~/logicalProject"; +import { usePullRequestDefaultMergeMethodResolver } from "./usePullRequestActions"; import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink"; import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import type { ReviewCommentContext } from "~/reviewCommentContext"; -import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; import { useProjects, useServerConfigs } from "~/state/entities"; -import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; +import { useEnvironments } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; import { @@ -563,19 +555,14 @@ export function PullRequestDetailPanel({ }, [condensed]); const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod); const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod); - // Server-side and per project, like every other project setting. The - // client-local per-project map from before still answers when the server - // has no value, so a choice made on an older release keeps applying until - // it is set (or reset) in Settings. - const legacyMergeMethodOverrides = useClientSettings( - (settings) => settings.pullRequestMergeMethodOverrides, + const resolveProjectDefaultMergeMethod = usePullRequestDefaultMergeMethodResolver( + environmentId, + reference.projectId, + ); + const projectDefaultMergeMethod = useMemo( + () => resolveProjectDefaultMergeMethod(), + [resolveProjectDefaultMergeMethod], ); - const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const projectDefaultMergeMethod = - resolveProjectSettings( - environmentConfigs.get(environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS, - reference.projectId, - ).settings.pullRequestMergeMethod ?? undefined; const [mergeMethodSelection, setMergeMethodSelection] = useState<{ readonly pullRequestKey: string; readonly method: PullRequestMergeMethod; @@ -887,39 +874,12 @@ export function PullRequestDetailPanel({ const [titleSaving, setTitleSaving] = useState(false); const newThread = useNewThreadHandler(); const { environments } = useEnvironments(); - const primaryEnvironmentId = usePrimaryEnvironmentId(); const unavailableGitHubUrl = useMemo(() => { const identity = projects.find( (project) => project.id === reference.projectId && project.environmentId === environmentId, )?.repositoryIdentity; return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number); }, [environmentId, projects, reference.number, reference.projectId, reference.repository]); - // Project settings stored the override under the sidebar group's key, which a duplicate row - // borrows from its siblings, so the project alone does not always name the same key. - const legacyProjectDefaultMergeMethod = useMemo(() => { - if (projectDefaultMergeMethod !== undefined) return undefined; - const project = projects.find( - (candidate) => - candidate.environmentId === environmentId && candidate.id === reference.projectId, - ); - if (!project) return undefined; - const projectKey = - buildPhysicalToLogicalProjectKeyMap({ - projects, - settings: projectGroupingSettings, - primaryEnvironmentId, - }).get(derivePhysicalProjectKey(project)) ?? - deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings); - return legacyMergeMethodOverrides[projectKey]; - }, [ - environmentId, - legacyMergeMethodOverrides, - primaryEnvironmentId, - projectDefaultMergeMethod, - projectGroupingSettings, - projects, - reference.projectId, - ]); // Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and // the thread is already on one server's copy of the branch. const pickableEnvironments = useMemo( @@ -1421,7 +1381,7 @@ export function PullRequestDetailPanel({ const selectedMergeMethod = resolvePullRequestMergeMethod( allowedMergeMethods, currentMergeMethod, - projectDefaultMergeMethod ?? legacyProjectDefaultMergeMethod, + projectDefaultMergeMethod, lastSelectedMergeMethod, ); const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod]; diff --git a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx index 3f183b264b2b..de87d9a5f1f5 100644 --- a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx +++ b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx @@ -1,7 +1,9 @@ +import type { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"; import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; import { cloneElement, + useRef, useState, type ComponentPropsWithoutRef, type MouseEvent, @@ -45,6 +47,7 @@ export function PullRequestLinkPreview({ fallback?: ReactNode; }) { const [open, setOpen] = useState(false); + const previewActionsRef = useRef(null); const [resolvingClick, setResolvingClick] = useState(false); const detailQuery = useEnvironmentQuery( open @@ -96,8 +99,14 @@ export function PullRequestLinkPreview({ : (detail?.author.login ?? null); return ( - - + + { + // Cancel the card's delayed hover too, without changing its content preview. + if (!nextOpen && !showCard) previewActionsRef.current?.close(); + }} + > } delay={350} diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 04008281f5e7..1f4de90f55b8 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -1,5 +1,9 @@ import { SearchIcon } from "lucide-react"; import { PullRequestStackPopover } from "./PullRequestStackPopover"; +import { + PullRequestSpeedActions, + type PullRequestSpeedActionResult, +} from "./PullRequestSpeedActions"; import { memo, type RefCallback } from "react"; import { cn } from "~/lib/utils"; @@ -66,10 +70,9 @@ function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry[ /** * The page row keeps a little more room around the shared lines than the panel, which sits in - * a narrow column. The intrinsic size is the content box a skipped row reserves, which is the - * two lines without the padding: a 56px row less 20px of `py-2.5`. + * a narrow column. Its outer wrapper reserves the full row height when offscreen. */ -const PAGE_ROW_CLASS = "px-3 py-2.5 [contain-intrinsic-block-size:36.5px]"; +const PAGE_ROW_CLASS = "px-3 py-2.5"; export type PullRequestRowTarget = Pick< EnvironmentPullRequestEntry, @@ -86,6 +89,8 @@ function PullRequestRowImpl({ statsKey, statsRef, onSelect, + speedMode, + onActed, }: { entry: EnvironmentPullRequestEntry; selected: boolean; @@ -101,144 +106,152 @@ function PullRequestRowImpl({ matchedElsewhere?: boolean; /** Used by the list's shared visibility observer to defer optional line-count reads. */ statsKey?: string; - statsRef?: RefCallback; + statsRef?: RefCallback; onSelect: (entry: PullRequestRowTarget) => void; + speedMode: boolean; + onActed: (result: PullRequestSpeedActionResult) => void; }) { const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); return ( - + /> + ) : null} + + + } + metaClassName="@container/pr-row-meta" + meta={ + <> + {matchedElsewhere ? ( + + + } + > + matched in the description + + + matched in the description + + + Matched in the description + + ) : null} + {showProvider ? ( + + }> + + + {providerName} + + ) : null} + + {showProjectTitle ? {entry.repository} : null} + {environmentLabel ? ( + {environmentLabel} + ) : null} + {entry.labels.length > 0 ? : null} + + } + updatedAt={entry.updatedAt} + /> + + {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 new file mode 100644 index 000000000000..613928733d37 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx @@ -0,0 +1,135 @@ +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"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { resolvePullRequestMergeMethod } from "./pullRequestDetail.logic"; +import { PullRequestGlyph } from "./pullRequestIcons"; +import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; +import { + usePullRequestActionRunner, + usePullRequestDefaultMergeMethodResolver, +} from "./usePullRequestActions"; + +export interface PullRequestSpeedActionResult { + readonly entry: EnvironmentPullRequestEntry; + readonly action: PullRequestAction; +} + +/** No detail or stack reads until a merge is clicked, even on a long list. */ +export function PullRequestSpeedActions({ + entry, + visible, + onActed, +}: { + entry: EnvironmentPullRequestEntry; + visible: boolean; + onActed: (result: PullRequestSpeedActionResult) => void; +}) { + const resolveProjectDefault = usePullRequestDefaultMergeMethodResolver( + entry.environmentId, + entry.projectId, + ); + const reference = { + projectId: entry.projectId, + host: entry.host, + repository: entry.repository, + number: entry.number, + }; + const { actionPending, perform } = usePullRequestActionRunner({ + 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."); + } + const allowed = detail.capabilities.mergeMethods.filter( + (method) => detail.mergeCapabilities[method], + ); + if (allowed.length === 0) + throw new Error("No merge method is available for this repository."); + return resolvePullRequestMergeMethod( + allowed, + null, + resolveProjectDefault(), + useUiStateStore.getState().pullRequestMergeMethod, + ); + }, + }); + const actions = + entry.state === "closed" + ? (["reopen"] as const) + : entry.isDraft + ? (["close", "ready"] as const) + : (["close", "merge"] as const); + return ( +
        + {actions.map((action) => { + const label = ACTIONS[action].label; + const Icon = ACTIONS[action].Icon; + return ( + + void perform(action)} + /> + } + > + {actionPending ? : } + {label} + + + {action === "merge" && entry.stack + ? "Open this pull request to merge its stack" + : `${label} immediately`} + + + ); + })} +
        + ); +} + +const ACTIONS = { + close: { label: "Close", Icon: PullRequestGlyph.closed }, + merge: { label: "Merge", Icon: PullRequestGlyph.merged }, + ready: { label: "Ready for review", Icon: PullRequestGlyph.pullRequest }, + reopen: { label: "Reopen", Icon: PullRequestGlyph.reopen }, +} as const; diff --git a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx index ed692a81370e..0b168afc9867 100644 --- a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx +++ b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx @@ -50,8 +50,8 @@ function EnabledPullRequestThreadLinks({ onPickerOpenChange, }: PullRequestThreadLinksProps) { const parsed = parseChangeRequestUrl(url); - const currentThreadRef = threadRef?.environmentId === environmentId ? threadRef : null; - const thread = useThreadShell(currentThreadRef); + const thread = useThreadShell(threadRef?.environmentId === environmentId ? threadRef : null); + const currentThreadRef = thread === null ? null : threadRef; const linking = usePullRequestLinking(environmentId); const linkedHere = linking.isLinked(thread, url); const relations = useEnvironmentQuery( diff --git a/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx index 48a371a7a9c0..b0e8c69517bc 100644 --- a/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx +++ b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx @@ -102,6 +102,8 @@ function row(overrides: Partial): ReactNode { showProjectTitle: false, showProvider: false, onSelect: () => {}, + speedMode: false, + onActed: () => {}, }); } diff --git a/apps/web/src/components/pullRequest/usePullRequestActions.ts b/apps/web/src/components/pullRequest/usePullRequestActions.ts index 3ba3b60bd90e..01c2789e8209 100644 --- a/apps/web/src/components/pullRequest/usePullRequestActions.ts +++ b/apps/web/src/components/pullRequest/usePullRequestActions.ts @@ -8,12 +8,23 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, + ProjectId, PullRequestAction, PullRequestDetail, PullRequestMergeMethod, PullRequestRef, } from "@t3tools/contracts"; -import { useState } from "react"; +import { useCallback, useRef, useState } from "react"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { useClientSettings, useEnvironmentSettings } from "~/hooks/useSettings"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + selectProjectGroupingSettings, +} from "~/logicalProject"; +import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; +import { useProjects } from "~/state/entities"; +import { usePrimaryEnvironmentId } from "~/state/environments"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; @@ -25,8 +36,48 @@ import { useAtomCommand } from "~/state/use-atom-command"; import { toastManager } from "../ui/toast"; import { handoffPrompt, handoffReviewComments, readableFailure } from "./pullRequestDetail.logic"; +/** Resolve on demand so hidden quick actions do not rebuild the legacy project grouping. */ +export function usePullRequestDefaultMergeMethodResolver( + environmentId: EnvironmentId, + projectId: ProjectId, +) { + const projectDefault = useEnvironmentSettings( + environmentId, + (settings) => resolveProjectSettings(settings, projectId).settings.pullRequestMergeMethod, + ); + const legacyOverrides = useClientSettings((settings) => settings.pullRequestMergeMethodOverrides); + const grouping = useClientSettings(selectProjectGroupingSettings); + const projects = useProjects(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + return useCallback(() => { + if (projectDefault != null) return projectDefault; + if (Object.keys(legacyOverrides).length === 0) return undefined; + const project = projects.find( + (candidate) => candidate.environmentId === environmentId && candidate.id === projectId, + ); + if (!project) return undefined; + // Duplicate sidebar rows borrow their logical group key from their siblings. + const key = + buildPhysicalToLogicalProjectKeyMap({ + projects, + settings: grouping, + primaryEnvironmentId, + }).get(derivePhysicalProjectKey(project)) ?? + deriveLogicalProjectKeyFromSettings(project, grouping); + return legacyOverrides[key]; + }, [ + projectDefault, + projects, + environmentId, + projectId, + grouping, + primaryEnvironmentId, + legacyOverrides, + ]); +} + const ACTION_SUCCESS_LABELS: Record = { - merge: "Pull request merged", + merge: "Merge requested", ready: "Marked ready for review", draft: "Converted to draft", close: "Pull request closed", @@ -80,37 +131,41 @@ export function usePullRequestActionRunner({ environmentId, reference, onSuccess, + resolveMergeMethod, }: { environmentId: EnvironmentId; reference: PullRequestRef | null; onSuccess?: (action: PullRequestAction) => void; + /** Small surfaces resolve repository settings on the click, not for every visible row. */ + resolveMergeMethod?: () => Promise; }) { const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false }); const [actionPending, setActionPending] = useState(false); + const pendingRef = useRef(false); const perform = async (action: PullRequestAction, method?: PullRequestMergeMethod) => { - if (actionPending || reference === null) return; + if (pendingRef.current || reference === null) return; + pendingRef.current = true; setActionPending(true); - const result = await runAction({ - environmentId, - input: { ...reference, action, ...(method ? { mergeMethod: method } : {}) }, - }); - setActionPending(false); - if (result._tag === "Failure") { - // The host's own sentence, because it is the only thing that says why. A merge strategy a - // branch policy forbids is refused at completion and nowhere earlier — Azure DevOps - // publishes no per-strategy availability to hide the control with — so "action failed" - // would leave the reader pressing the same button again. - const failure = squashAtomCommandFailure(result); + try { + const mergeMethod = method ?? (action === "merge" ? await resolveMergeMethod?.() : undefined); + const result = await runAction({ + environmentId, + input: { ...reference, action, ...(mergeMethod ? { mergeMethod } : {}) }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] }); + onSuccess?.(action); + } catch (failure) { toastManager.add({ type: "error", title: ACTION_FAILURE_LABELS[action], description: readableFailure(failure, ACTION_FAILURE_HINTS[action]), }); - return; + } finally { + pendingRef.current = false; + setActionPending(false); } - toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] }); - onSuccess?.(action); }; return { actionPending, perform }; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index e89ab5534659..29f4c39238d2 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,8 +1,10 @@ import { + ChevronRightIcon, ChevronsLeftRightEllipsisIcon, EllipsisIcon, PlusIcon, QrCodeIcon, + RouteIcon, TerminalIcon, } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; @@ -41,7 +43,12 @@ import { type EnvironmentId, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; -import { connectionStatusText } from "@t3tools/client-runtime/connection"; +import { + RelayConnectionRegistration, + RelayConnectionTarget, + connectionRoutes, + connectionStatusText, +} from "@t3tools/client-runtime/connection"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -69,6 +76,8 @@ import { import { LocalEnvironmentSetting } from "./LocalEnvironmentSetting"; import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; +import { EnvironmentRoutesList } from "./EnvironmentRoutesList"; +import { usePreparedConnection } from "~/state/session"; import { EnvironmentRow, environmentTransportLabel, @@ -1436,6 +1445,7 @@ type SavedBackendListRowProps = { removingEnvironmentId: EnvironmentId | null; onSetEnabled: (environmentId: EnvironmentId, enabled: boolean) => void; onRemove: (environment: EnvironmentPresentation) => void; + onAddRoute: (environment: EnvironmentPresentation) => void; }; /** @@ -1487,7 +1497,9 @@ function SavedBackendListRow({ removingEnvironmentId, onSetEnabled, onRemove, + onAddRoute, }: SavedBackendListRowProps) { + const [routesOpen, setRoutesOpen] = useState(false); const environmentId = environment.environmentId; const unsupported = environment.connection.phase === "unsupported"; const enabled = environment.entry.enabled && !unsupported; @@ -1542,8 +1554,13 @@ function SavedBackendListRow({ environment.serverConfig ?? (lastDescriptor === undefined ? null : { environment: lastDescriptor }), ); + const prepared = usePreparedConnection(environmentId); + const routeCount = connectionRoutes(environment.entry).length; const subtitleText = [ - environmentTransportLabel(environment), + environmentTransportLabel( + environment, + isConnected && prepared._tag === "Some" ? prepared.value.target : null, + ), resumingServerUpdate ? "Restarting" : status.text, enabled && versionMismatch ? serverVersion : null, ] @@ -1576,26 +1593,49 @@ function SavedBackendListRow({ label={environment.label} dimmed={!enabled} subtitle={ - - {/* The status can change while the tooltip is open, and base-ui only - re-measures the popup when the trigger's payload changes. */} - - } + + + {/* The status can change while the tooltip is open, and base-ui only + re-measures the popup when the trigger's payload changes. */} + + } + > + {subtitleText} + + + {statusTooltip} + + + + · + + + } below={ serverUpdateState.status !== "idle" ? ( @@ -1604,6 +1644,14 @@ function SavedBackendListRow({ ) : null } + detail={ + routesOpen ? ( + onAddRoute(environment)} + /> + ) : null + } > {unsupported && environment.entry.serverUpdateRequired === true && @@ -1621,6 +1669,7 @@ function SavedBackendListRow({ environmentId={environmentId} serverLabel={`${environment.label} server`} selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)} + installation={environment.serverConfig?.environment.capabilities.serverInstallation} desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)} threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)} targetVersion={versionMismatch.clientVersion} @@ -1663,6 +1712,10 @@ function SavedBackendListRow({ environmentId={environmentId} serverConfig={environment.serverConfig} /> + setRoutesOpen((open) => !open)}> + + {routesOpen ? "Hide routes" : "Routes"} + {errorTraceId ? ( copyTraceId(errorTraceId)}>Copy trace ID ) : null} @@ -1852,6 +1905,10 @@ export function ConnectionsSettings() { reportFailure: false, }); const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false }); + const registerEnvironment = useAtomCommand(environmentCatalog.register, { + reportFailure: false, + }); + const relayDiscoveryState = useRelayEnvironmentDiscovery(); const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, { reportFailure: false, }); @@ -1921,6 +1978,7 @@ export function ConnectionsSettings() { environmentId: environment.environmentId, serverLabel: environment.label, selfUpdate, + installation: environment.serverConfig?.environment.capabilities.serverInstallation, desktopAppUpdate, threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig), continueThreadsAfterServerUpdate: @@ -1981,6 +2039,8 @@ export function ConnectionsSettings() { >(null); const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false); const [addBackendDialogOpen, setAddBackendDialogOpen] = useState(false); + // Set when the dialog adds a route to a saved machine instead of a new one. + const [routeTarget, setRouteTarget] = useState(null); const [savedBackendMode, setSavedBackendMode] = useState<"remote" | "ssh">("remote"); const [savedBackendHost, setSavedBackendHost] = useState(""); const [savedBackendPairingCode, setSavedBackendPairingCode] = useState(""); @@ -2323,7 +2383,11 @@ export function ConnectionsSettings() { async (target: DesktopSshEnvironmentTarget) => { setIsAddingSavedBackend(true); setSavedBackendError(null); - const result = await connectSshEnvironment({ target, label: "" }); + const result = await connectSshEnvironment({ + target, + label: "", + ...(routeTarget ? { expectedEnvironmentId: routeTarget.environmentId } : {}), + }); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { setSavedBackendError(formatDesktopSshConnectionError(squashAtomCommandFailure(result))); @@ -2340,12 +2404,14 @@ export function ConnectionsSettings() { setAddBackendDialogOpen(false); toastManager.add({ type: "success", - title: "Environment connected", - description: `${target.alias} is ready over an SSH-managed tunnel.`, + title: routeTarget ? "Route added" : "Environment connected", + description: routeTarget + ? `${routeTarget.label} can now be reached over SSH ${target.alias}.` + : `${target.alias} is ready over an SSH-managed tunnel.`, }); setIsAddingSavedBackend(false); }, - [connectSshEnvironment], + [connectSshEnvironment, routeTarget], ); const handleAddSavedBackend = useCallback(async () => { @@ -2388,7 +2454,10 @@ export function ConnectionsSettings() { return; } - const result = await connectPairing(remotePairingInput); + const result = await connectPairing({ + ...remotePairingInput, + ...(routeTarget ? { expectedEnvironmentId: routeTarget.environmentId } : {}), + }); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); @@ -2412,13 +2481,22 @@ export function ConnectionsSettings() { setSavedBackendSshUsername(""); setSavedBackendSshPort(""); setAddBackendDialogOpen(false); - toastManager.add({ - type: "success", - title: "Backend added", - description: "The environment is saved and will reconnect on app startup.", - }); + toastManager.add( + routeTarget + ? { + type: "success", + title: "Route added", + description: `${routeTarget.label} now has another way to connect.`, + } + : { + type: "success", + title: "Backend added", + description: "The environment is saved and will reconnect on app startup.", + }, + ); setIsAddingSavedBackend(false); }, [ + routeTarget, connectPairing, connectSavedBackendSshTarget, savedBackendHost, @@ -2710,8 +2788,57 @@ export function ConnectionsSettings() { ); + // T3 Connect is offered as a route when this account can reach the machine + // through it and it is not one of the machine's routes yet. + const relayRouteOffer = + routeTarget !== null && + !routeTarget.relayManaged && + relayDiscoveryState.environments.has(routeTarget.environmentId) + ? relayDiscoveryState.environments.get(routeTarget.environmentId)!.environment + : null; + const addRelayRoute = async () => { + if (relayRouteOffer === null || routeTarget === null) return; + setIsAddingSavedBackend(true); + const result = await registerEnvironment( + new RelayConnectionRegistration({ + target: new RelayConnectionTarget({ + environmentId: relayRouteOffer.environmentId, + label: routeTarget.label, + }), + }), + ); + setIsAddingSavedBackend(false); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setSavedBackendError(error instanceof Error ? error.message : "Could not add the route."); + } + return; + } + setAddBackendDialogOpen(false); + toastManager.add({ + type: "success", + title: "Route added", + description: `${routeTarget.label} falls back to T3 Connect when its other routes are unreachable.`, + }); + }; const renderRemoteModeBody = () => (
        + {relayRouteOffer !== null ? ( +
        +

        + This machine is on your T3 Connect account. Use it as a fallback route. +

        + +
        + ) : null} {renderRemoteFields()} {savedBackendError ?

        {savedBackendError}

        : null}
        ); @@ -2842,7 +2969,7 @@ export function ConnectionsSettings() { onClick={() => void handleAddSavedBackend()} > - {isAddingSavedBackend ? "Adding…" : "Add environment"} + {isAddingSavedBackend ? "Adding…" : routeTarget ? "Add route" : "Add environment"} @@ -3375,6 +3502,9 @@ export function ConnectionsSettings() { primaryEnvironment ? `${primaryEnvironment.label} server` : "server" } selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)} + installation={ + primaryServerConfig?.environment.capabilities.serverInstallation + } desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)} threadContinuation={supportsServerUpdateThreadContinuation( primaryServerConfig, @@ -3730,7 +3860,9 @@ export function ConnectionsSettings() { open={addBackendDialogOpen} onOpenChange={(open) => { setAddBackendDialogOpen(open); - if (!open) { + if (open) { + setRouteTarget(null); + } else { setSavedBackendError(null); } }} @@ -3752,8 +3884,14 @@ export function ConnectionsSettings() {
        - Add Environment - Pair another environment to this client. + + {routeTarget ? `Add a route to ${routeTarget.label}` : "Add Environment"} + + + {routeTarget + ? "Pair this machine again over another address, such as its Tailscale name. It joins the existing routes instead of adding a second machine." + : "Pair another environment to this client."} +
        @@ -3791,6 +3929,11 @@ export function ConnectionsSettings() { removingEnvironmentId={removingSavedEnvironmentId} onSetEnabled={handleSetSavedBackendEnabled} onRemove={handleRemoveSavedBackend} + onAddRoute={(target) => { + setRouteTarget(target); + setSavedBackendError(null); + setAddBackendDialogOpen(true); + }} /> ))} void; +}) { + const saved = connectionRoutes(environment.entry); + const savedIds = saved.map((route) => connectionRouteId(route.target)); + // A dropped order shows until the catalog matches it, so the row does not + // jump back while the reorder is being saved. + const [pending, setPending] = useState | null>(null); + const order: Array = + pending !== null && + pending.length === savedIds.length && + pending.some((id, index) => id !== savedIds[index]) + ? [...pending] + : savedIds; + const byId = new Map(saved.map((route) => [connectionRouteId(route.target), route])); + const routes = order.flatMap((id) => byId.get(id) ?? []); + + const prepared = usePreparedConnection(environment.environmentId); + const activeRouteId = + prepared._tag === "Some" && environment.connection.phase === "connected" + ? connectionRouteId(prepared.value.target) + : null; + const reorder = useAtomCommand(environmentCatalog.reorderRoutes, "Reorder routes"); + const removeRoute = useAtomCommand(environmentCatalog.removeRoute, "Remove route"); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + // Removing a paired route forgets its credential, so it asks first, like + // on mobile. No mounted confirm host means no removal. + const confirmRemove = async (route: ConnectionRoute) => { + const address = connectionRouteAddress(route); + const confirmed = await requestConfirmDialog( + `Remove ${connectionRouteLabel(route)} route?${address === null ? "" : `\n${address}`}`, + { variant: "destructive" }, + ); + if (confirmed !== true) return; + await removeRoute({ + environmentId: environment.environmentId, + routeId: connectionRouteId(route.target), + }); + }; + + const handleDragEnd = (event: DragEndEvent) => { + if (event.over === null || event.active.id === event.over.id) return; + const next = arrayMove( + order, + order.indexOf(String(event.active.id)), + order.indexOf(String(event.over.id)), + ); + setPending(next); + void reorder({ environmentId: environment.environmentId, routeIds: next }).then((result) => { + if (result._tag === "Failure") setPending(null); + }); + }; + + return ( +
        + + +
          + {routes.map((route, index) => ( + 1 && !isLearned(route)} + onRemove={() => void confirmRemove(route)} + /> + ))} +
        +
        +
        +
        + +
        +
        + ); +} + +function SortableRouteRow({ + route, + position, + inUse, + removable, + onRemove, +}: { + readonly route: ConnectionRoute; + readonly position: number; + readonly inUse: boolean; + readonly removable: boolean; + readonly onRemove: () => void; +}) { + const id = connectionRouteId(route.target); + const label = connectionRouteLabel(route); + const address = connectionRouteAddress(route); + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + return ( +
      • + +
        +

        + {label} + {inUse ? ( + + In use + + ) : null} +

        + {address !== null ? ( +

        + {address} + {isLearned(route) ? " · found automatically" : ""} +

        + ) : null} +
        + {removable ? ( + + + } + > + + + Remove route + + ) : null} +
      • + ); +} diff --git a/apps/web/src/components/settings/EnvironmentRow.tsx b/apps/web/src/components/settings/EnvironmentRow.tsx index 5f3dae7e47c1..a855c9aadf70 100644 --- a/apps/web/src/components/settings/EnvironmentRow.tsx +++ b/apps/web/src/components/settings/EnvironmentRow.tsx @@ -1,3 +1,9 @@ +import { + type ConnectionTarget, + connectionRouteId, + connectionRouteLabel, + connectionRoutes, +} from "@t3tools/client-runtime/connection"; import type { DesktopSshEnvironmentTarget, EnvironmentMachineKind } from "@t3tools/contracts"; import * as Option from "effect/Option"; import type { ReactNode } from "react"; @@ -15,10 +21,26 @@ export function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): str /** * How this client reaches a machine, printed first in every environment row so * T3 Connect, SSH, WSL, and plain remote links are told apart without a legend. + * A machine with several routes names the one in use, or how many it has. */ -export function environmentTransportLabel(environment: EnvironmentPresentation): string { +export function environmentTransportLabel( + environment: EnvironmentPresentation, + activeTarget: ConnectionTarget | null = null, +): string { const { entry } = environment; if (entry.target._tag === "PrimaryConnectionTarget") return "This machine"; + const routes = connectionRoutes(entry); + if (routes.length > 1) { + const active = + activeTarget === null + ? undefined + : routes.find( + (route) => connectionRouteId(route.target) === connectionRouteId(activeTarget), + ); + return active === undefined + ? connectionRouteLabel(routes[0]!) + : `via ${connectionRouteLabel(active)}`; + } if (environment.relayManaged) return "T3 Connect"; if (isDesktopLocalConnectionTarget(entry.target)) return "WSL"; if ( @@ -41,6 +63,7 @@ export function EnvironmentRow({ label, subtitle, below, + detail, dimmed = false, className, children, @@ -50,6 +73,8 @@ export function EnvironmentRow({ readonly subtitle: ReactNode; /** Extra content under the subtitle, such as update progress. */ readonly below?: ReactNode; + /** Expanded content under the whole row, aligned with the label, such as its routes. */ + readonly detail?: ReactNode; readonly dimmed?: boolean; readonly className?: string; readonly children?: ReactNode; @@ -69,6 +94,7 @@ export function EnvironmentRow({ {below}
        {children}
        + {detail ?
        {detail}
        : null} ); } diff --git a/apps/web/src/components/settings/FontFamilyPicker.tsx b/apps/web/src/components/settings/FontFamilyPicker.tsx index b7e43eef8209..086de86e3aa9 100644 --- a/apps/web/src/components/settings/FontFamilyPicker.tsx +++ b/apps/web/src/components/settings/FontFamilyPicker.tsx @@ -12,6 +12,7 @@ import { ComboboxTrigger, } from "../ui/combobox"; import { SelectButton } from "../ui/select"; +import { stackedThreadToast, toastManager } from "../ui/toast"; const DEFAULT_FONT_VALUE = "__default__"; @@ -141,8 +142,8 @@ export function FontFamilyPicker({ const families = useMemo(() => { if (enumeration.status !== "granted") return []; - return requireMonospace ? enumeration.families.filter(isMonospaceFamily) : enumeration.families; - }, [enumeration, requireMonospace]); + return enumeration.families; + }, [enumeration]); const items = useMemo(() => { const trimmedQuery = query.trim().toLowerCase(); @@ -160,6 +161,16 @@ export function FontFamilyPicker({ const handlePick = (value: string) => { setOpen(false); + if (value !== DEFAULT_FONT_VALUE && requireMonospace && !isMonospaceFamily(value)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `"${value}" isn't monospace`, + description: "Code and terminal need a fixed-width font, so the current font was kept.", + }), + ); + return; + } onSelect(value === DEFAULT_FONT_VALUE ? "" : value); }; diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index f77cfcc25768..f9dfb47c5f9b 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -646,10 +646,18 @@ function DeviceIntegrationControls({ "hub" | "check" | "agent" | "update-hub" | "update-agent" | null >(null); const busy = state.hostStatus === "installing" || state.hostStatus === "starting"; + const localPlatformsUnavailable = state.hosts.some( + (host) => host.kind === "local" && !host.platforms.some((platform) => platform.available), + ); const [platformsRevealed, setPlatformsRevealed] = useState(false); // Keep diagnostics visible through subsequent agent setup and refresh phases. if (platformsRevealed && !enabled) setPlatformsRevealed(false); - if (enabled && !platformsRevealed && state.hostStatus === "ready" && pending !== "hub") { + if ( + enabled && + !platformsRevealed && + (state.hostStatus === "ready" || localPlatformsUnavailable) && + pending !== "hub" + ) { setPlatformsRevealed(true); } diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 226043c7ce1f..415ebb97c029 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -593,7 +593,11 @@ export function ProviderInstanceCard({ compatibility.status !== "supported" && compatibility.status !== "unknown"; const VersionAdvisoryIcon = hasCompatibilityWarning ? AlertTriangleIcon : ArrowUpCircleIcon; - const onRunVersionAction = versionAdvisory?.targetVersion ? onInstallRecommended : onRunUpdate; + const onRunVersionAction = readOnly + ? undefined + : versionAdvisory?.targetVersion + ? onInstallRecommended + : onRunUpdate; const urlAuthAction = liveProvider?.auth.action; const displayName = instance.displayName?.trim() || driverOption?.label || String(instance.driver); @@ -765,6 +769,101 @@ export function ProviderInstanceCard({ ) : null} ); + const versionAdvisoryNode = versionAdvisory ? ( + + + + + + } + /> + } + /> + {versionAdvisory.title} + + +
        +
        +

        + {versionAdvisory.title} +

        +

        + {versionAdvisory.detail} +

        +
        + {onRunVersionAction ? ( + + ) : null} + {onRunVersionAction && updateCommand ? ( +
        + + or, update manually using + +
        + ) : null} + {updateCommand ? ( +
        + + {updateCommand} + + + copyToClipboard(updateCommand, { providerName: displayName })} + aria-label="Copy update command" + > + + + } + /> + Copy command + +
        + ) : null} +
        +
        +
        + ) : null; + if (mode === "list") { return (
        ) : null} - {versionAdvisory ? ( - hasCompatibilityWarning ? ( - - - - - } - /> - {versionAdvisory.detail} - - ) : updateCommand ? ( - - - copyToClipboard(updateCommand, { providerName: displayName }) - } - > - - - } - /> - Copy update command - - ) : ( - - - - ) - ) : null} + {versionAdvisoryNode} {statusDotNode ? ( @@ -876,105 +933,12 @@ export function ProviderInstanceCard({ ) : null} {versionCodeNode} + {versionAdvisoryNode} - {versionAdvisory ? ( - - - - - - } - /> - } - /> - {versionAdvisory.title} - - -
        -
        -

        - {versionAdvisory.title} -

        -

        - {versionAdvisory.detail} -

        -
        - {onRunVersionAction ? ( - - ) : null} - {onRunVersionAction && updateCommand ? ( -
        - - or, update manually using - -
        - ) : null} - {updateCommand ? ( -
        - - {updateCommand} - - - - copyToClipboard(updateCommand, { providerName: displayName }) - } - aria-label="Copy update command" - > - - - } - /> - Copy command - -
        - ) : null} -
        -
        -
        - ) : null} {titleTailNode} {onDelete ? (