From 934c7f8d8e7a012aa55be9c0b17498979040fe02 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 10 Nov 2026 00:14:18 -0600 Subject: [PATCH 0001/1253] fix(web): center short PR titles in thread details (#33724) --- apps/web/src/components/chat/ThreadDetailsControl.tsx | 2 +- apps/web/src/components/chat/ThreadDetailsPrRow.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsControl.tsx b/apps/web/src/components/chat/ThreadDetailsControl.tsx index e72e1cd7c46d7..3c5161b9b344a 100644 --- a/apps/web/src/components/chat/ThreadDetailsControl.tsx +++ b/apps/web/src/components/chat/ThreadDetailsControl.tsx @@ -52,7 +52,7 @@ export function ThreadDetailsControl({ className: cn( "relative inline-flex shrink-0 cursor-pointer items-center whitespace-nowrap border outline-none transition-[box-shadow,scale] [&:not([aria-haspopup]):not(:disabled):not([aria-disabled=true])]:will-change-[scale] [&:active:not([aria-haspopup])]:scale-[0.97] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 disabled:pointer-events-none disabled:opacity-64 aria-disabled:cursor-not-allowed aria-disabled:opacity-64 [&_svg]:shrink-0", parts[part], - multiline && "h-auto min-h-8 py-0.75 disabled:opacity-100 sm:h-auto", + multiline && "h-auto min-h-8 py-1.5 disabled:opacity-100 sm:h-auto", tone === "muted" && "text-muted-foreground hover:text-foreground/80", tone === "primary" && // The row surface forces a faint hover fill with !important; the solid pill keeps its own. diff --git a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx index 23d705a8dae7c..3f867992d75c9 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx @@ -409,7 +409,7 @@ export function ThreadDetailsPrRow({ size="sm" part={part} multiline={wrap} - className={wrap ? "items-start [&>svg]:mt-0.5" : undefined} + className={wrap ? "items-start [&>svg]:mt-px" : undefined} aria-label={openAriaLabel} onClick={onOpen} onAuxClick={onOpen} From 4c361199f47f6ea32a2c14835df392640d0f7c63 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 9 Nov 2026 22:47:42 -0800 Subject: [PATCH 0002/1253] fix(server): Claude threads can allow an MCP app for the session (#33707) Co-authored-by: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 47 +++++++++++++++---- .../Adapters/ClaudeAdapterV2.ts | 18 +++++-- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index bca1d6769cbea..272d2cf10454c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -9,6 +9,7 @@ import { beforeEach, vi } from "vite-plus/test"; import type { ElicitationRequest, + ElicitationResult, Query as ClaudeQuery, SDKMessage, SDKResultMessage, @@ -5703,6 +5704,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { options: [ { decision: "cancel", label: "Cancel" }, { decision: "decline", label: "Decline" }, + { decision: "acceptForSession", label: "Always allow this session" }, { decision: "accept", label: "Approve" }, ], }); @@ -5732,16 +5734,41 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); - it.effect.each(["acceptForSession", "acceptAlways"] as const)( - "declines an unsupported %s decision", - (decision) => - Effect.gen(function* () { - const { harness, elicit, awaitCard } = yield* startElicitationTurn(); - const answer = yield* elicit(approvalOnly); - const request = yield* awaitCard(1); - yield* harness.runtime.respondToRuntimeRequest({ requestId: request.id, decision }); - assert.deepEqual(yield* Fiber.join(answer), { action: "decline" }); - }).pipe(Effect.scoped, Effect.provide(testLayer)), + it.effect("declines an unsupported acceptAlways decision", () => + Effect.gen(function* () { + const { harness, elicit, awaitCard } = yield* startElicitationTurn(); + const answer = yield* elicit(approvalOnly); + const request = yield* awaitCard(1); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: request.id, + decision: "acceptAlways", + }); + assert.deepEqual(yield* Fiber.join(answer), { action: "decline" }); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("answers a repeated prompt from a session grant without a card", () => + Effect.gen(function* () { + const { harness, elicit, awaitCard, runtimeRequests } = yield* startElicitationTurn(); + const first = yield* elicit(approvalOnly); + const request = yield* awaitCard(1); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: request.id, + decision: "acceptForSession", + }); + const granted = { action: "accept", content: {} } satisfies ElicitationResult; + assert.deepEqual(yield* Fiber.join(first), granted); + + assert.deepEqual(yield* Fiber.join(yield* elicit(approvalOnly)), granted); + assert.equal(runtimeRequests().length, 1); + + const otherMessage = yield* elicit({ ...approvalOnly, message: "Deploy api?" }); + yield* awaitCard(2); + const otherServer = yield* elicit({ ...approvalOnly, serverName: "builds" }); + yield* awaitCard(3); + assert.isUndefined(otherMessage.pollUnsafe()); + assert.isUndefined(otherServer.pollUnsafe()); + }).pipe(Effect.scoped, Effect.provide(testLayer)), ); it.effect("declines URL and value forms without a card", () => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 5c71e79d98278..be938673b5502 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3825,11 +3825,12 @@ export const awaitClaudeApprovalDecision = Effect.fn("awaitClaudeApprovalDecisio return yield* Effect.raceFirst(Deferred.await(decision), cancellation); }); -// Web and mobile offer a session grant when options are omitted, and this -// path only ever answers once. +// MCP servers such as Computer Use ask on every call and expect the client to +// remember a session grant, as Codex does. The CLI drops a reply's `_meta.persist`. const CLAUDE_MCP_ELICITATION_OPTIONS: ReadonlyArray = [ { decision: "cancel", label: "Cancel" }, { decision: "decline", label: "Decline" }, + { decision: "acceptForSession", label: "Always allow this session" }, { decision: "accept", label: "Approve" }, ]; @@ -5212,6 +5213,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const pendingRuntimeRequests = yield* Ref.make( new Map(), ); + const mcpElicitationSessionGrants = yield* Ref.make(new Set()); // Background local_bash tasks can trigger wake turns on the query stream with no active provider turn. const lastTurnRouteByNativeThread = yield* Ref.make( new Map< @@ -10620,6 +10622,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); return { action: "decline" } satisfies ElicitationResult; } + const grantKey = `${request.serverName}\u0000${request.message}`; + if ((yield* Ref.get(mcpElicitationSessionGrants)).has(grantKey)) { + return acceptance; + } // The CLI waits on this answer, so a turn held for its prompt echo // must show its output now, as canUseTool does. if (context.heldRootFrames.length > 0) { @@ -10706,8 +10712,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( runtimeRequest: { ...artifacts.request, status: "cancelled", resolvedAt: now }, }); } + if (resolved === "acceptForSession") { + yield* Ref.update(mcpElicitationSessionGrants, (current) => + new Set(current).add(grantKey), + ); + return acceptance; + } // Never null: the SDK reads null as answered out of band and leaves - // the server waiting. Every accept is one-time; no grant is offered. + // the server waiting. return resolved === "accept" ? acceptance : ({ From 25da7ebca692d576517c330332cfd921e71a33ae Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:22:38 +0530 Subject: [PATCH 0003/1253] fix(web): reclaim notice says the machine will be offline (#33730) Co-authored-by: Claude Fable 5.2 --- apps/web/src/components/settings/DatabaseStorageNotice.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/DatabaseStorageNotice.tsx b/apps/web/src/components/settings/DatabaseStorageNotice.tsx index b9a0de493a290..b4668a07f216e 100644 --- a/apps/web/src/components/settings/DatabaseStorageNotice.tsx +++ b/apps/web/src/components/settings/DatabaseStorageNotice.tsx @@ -163,7 +163,7 @@ function ReclaimRow({ title={`${amount} on ${label}`} description={ status.reclaimFailure === undefined - ? `Restarts T3 Code there. It is offline for about ${minutes} min.` + ? `Restarts T3 Code there. It will be offline for about ${minutes} min.` : `The last attempt failed: ${status.reclaimFailure}` } control={ @@ -184,8 +184,8 @@ function ReclaimRow({ Reclaim {amount} on {label}? - T3 Code on {label} stops, rebuilds its database, and starts again. It is offline - for about {minutes} min. Running agents stop when it restarts. + T3 Code on {label} stops, rebuilds its database, and starts again. It will be + offline for about {minutes} min. Running agents stop when it restarts. From 577c524089368176e00b331a8217b642fd6d06a5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 23:55:39 -0800 Subject: [PATCH 0004/1253] fix(server): detaching a session no longer strands its pending approval (#33535) Co-authored-by: Claude Opus 5.5 --- .../ProviderSessionManager.test.ts | 61 +++++++++++++++++++ .../ProviderSessionManager.ts | 27 +++++--- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index a8e2e84ba74c5..b4fc69727c1de 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -3599,6 +3599,67 @@ it.effect.each(["manual_shutdown", "server_shutdown"] as const)( }), ); +it.effect( + "ProviderSessionManagerV2 cancels a pending approval when a detach releases its session", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const projectId = yield* idAllocator.allocate.project({ + fixtureName: "provider-session-manager-detach-request", + }); + const threadId = yield* idAllocator.allocate.thread({ + fixtureName: "provider-session-manager-detach-request", + projectId, + }); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + const providerThread = makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + }); + + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const pendingRequest = yield* makePendingRuntimeRequestEvents({ + idAllocator, + threadId, + providerSessionId, + providerThread, + now, + }); + yield* eventSink.write({ events: pendingRequest.events }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + + yield* manager.detach({ providerSessionId, threadId, detail: "Workspace changed." }); + + assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); + const projection = yield* projectionStore.getThreadProjection(threadId); + const request = projection.runtimeRequests.find( + (candidate) => candidate.id === pendingRequest.requestId, + ); + assert.equal(request?.status, "cancelled"); + assert.equal(request?.responseCapability.type, "not_resumable"); + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ state, idleTimeoutMs: 60_000, capabilities: ExclusiveCapabilities }), + ), + ); + }), +); + it.effect("ProviderSessionManagerV2 persists session-scoped runtime requests without a run", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 62fdda69e5111..0fb31e81a0732 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -665,12 +665,23 @@ export const layerWithOptions = ( }); }); + // A detach removes its thread before releasing, but that thread's records still need releasing. + const releasedThreadIds = (input: { + readonly entry: LiveSessionEntry; + readonly detachedThreadId?: ThreadId; + }) => { + const threadIds = new Set(input.entry.attachedThreadIds); + if (input.detachedThreadId !== undefined) threadIds.add(input.detachedThreadId); + return threadIds; + }; + const writeReleasedRuntimeRequestEvents = Effect.fn( "ProviderSessionManager.writeReleasedRuntimeRequestEvents", )(function* (input: { readonly entry: LiveSessionEntry; readonly reason: ProviderSessionReleaseReason; readonly releasedAt: DateTime.Utc; + readonly detachedThreadId?: ThreadId; }) { const providerSessionId = input.entry.runtime.providerSessionId; const now = yield* DateTime.now; @@ -681,7 +692,7 @@ export const layerWithOptions = ( : "Provider session was closed before this runtime request was resolved."; const events: Array = []; - for (const threadId of input.entry.attachedThreadIds) { + for (const threadId of releasedThreadIds(input)) { const projection = yield* projectionStore.getThreadRecords( threadId, ["runtimeRequests"], @@ -800,10 +811,8 @@ export const layerWithOptions = ( return []; } const providerSessionId = input.entry.runtime.providerSessionId; - const threadIds = new Set(input.entry.attachedThreadIds); - if (input.detachedThreadId !== undefined) threadIds.add(input.detachedThreadId); const released = []; - for (const threadId of threadIds) { + for (const threadId of releasedThreadIds(input)) { const projection = yield* projectionStore.getThreadRecords(threadId, [ "providerThreads", ]); @@ -892,6 +901,7 @@ export const layerWithOptions = ( readonly detail?: string; readonly releasedBackgroundTasks: ReadonlyArray; readonly releasedAt: DateTime.Utc; + readonly detachedThreadId?: ThreadId; readonly replaced: boolean; }) => Effect.all( @@ -1068,12 +1078,14 @@ export const layerWithOptions = ( onNone: () => Effect.void, onSome: (entry) => Effect.gen(function* () { + const detachedThread = + input.detachedThreadId === undefined + ? {} + : { detachedThreadId: input.detachedThreadId }; const releasedBackgroundTasks = yield* readReleasedBackgroundTasks({ entry, reason: input.reason, - ...(input.detachedThreadId === undefined - ? {} - : { detachedThreadId: input.detachedThreadId }), + ...detachedThread, }); if (input.cancelIdleFiber !== false) { yield* cancelIdleFiber(entry.idleFiber); @@ -1098,6 +1110,7 @@ export const layerWithOptions = ( ...(input.detail === undefined ? {} : { detail: input.detail }), releasedBackgroundTasks, releasedAt, + ...detachedThread, }; const recorded = yield* Effect.exit( writeReleaseRecords({ ...records, replaced: false }), From eff45664fae739f0efadaee13acc145ddf0d0b66 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 23:56:10 -0800 Subject: [PATCH 0005/1253] fix(opencode): long command turns no longer fail when the command request times out (#27677) Co-authored-by: Claude Opus 5.5 --- .../src/server/adapter.test.ts | 80 +++++++++++++++++++ .../provider-opencode/src/server/adapter.ts | 2 + 2 files changed, 82 insertions(+) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 769d3a81e63d0..b2460ff90a596 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -1509,6 +1509,86 @@ describe("OpenCodeAdapterV2", () => { ), ); + it.effect("keeps an admitted native command running when its HTTP request times out", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const commandCalled = promiseGate(); + let rejectCommand!: (cause: unknown) => void; + const commandResponse = new Promise((_, reject) => { + rejectCommand = reject; + }); + const sessionId = "native-command-timeout"; + const harness = yield* makeOpenCodeRuntimeHarness("native-command-timeout", sessionId, { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + command: { list: async () => ({ data: [{ name: "review" }] }) }, + session: { + create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), + command: (input: { messageID: string }) => { + commandCalled.resolve(input.messageID); + return commandResponse; + }, + }, + }); + const start = yield* harness.startTurn("/review staged changes").pipe(Effect.forkScoped); + const messageID = yield* Effect.promise(() => commandCalled.promise); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: sessionId, + info: { + id: messageID, + sessionID: sessionId, + role: "user", + time: { created: DateTime.toEpochMillis(harness.now) }, + }, + }, + }), + ); + yield* Fiber.join(start); + const terminal = yield* harness.runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + ); + + // Node's fetch gives up on a command still running in OpenCode after 300 s. + rejectCommand( + new TypeError("fetch failed", { + cause: Object.assign(new Error("Headers Timeout Error"), { + name: "HeadersTimeoutError", + code: "UND_ERR_HEADERS_TIMEOUT", + }), + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.status", + properties: { sessionID: sessionId, status: { type: "busy" } }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ type: "session.idle", properties: { sessionID: sessionId } }), + ); + + const [event] = yield* Fiber.join(terminal); + assert.equal( + event?.type === "turn.terminal" ? event.status : undefined, + "completed", + "a command admitted by its user receipt must not fail when its HTTP request times out", + ); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + it.effect("compacts with the native summarize API and emits a completed compaction", () => Effect.gen(function* () { const nativeEvents = asyncEventStream(); diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 94bdb56eb29ab..a1b5ccb008ebe 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -3026,7 +3026,9 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function }), ).pipe( Effect.asVoid, + // OpenCode answers only when generation ends; once admitted, the event stream owns the outcome. Effect.tapError((cause) => + Deferred.isDoneUnsafe(receipt) || abortController.signal.aborted || turn.finalized || turn.admissionGeneration !== generation From 4168c14c7db15702bc3745a36b9cd3b9c366630a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 23:59:19 -0800 Subject: [PATCH 0006/1253] fix(server): a queued message that names no model keeps the model picked ahead of it (#33591) --- .../orchestration-v2/NotificationMailbox.ts | 5 +- .../src/orchestration-v2/Orchestrator.ts | 39 ++- .../SteeringCompletion.integration.test.ts | 40 +++ .../src/orchestration-v2/runtimeLayer.test.ts | 236 +++++++++++++++++- 4 files changed, 314 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/NotificationMailbox.ts b/apps/server/src/orchestration-v2/NotificationMailbox.ts index 29ba40c5777fe..79458677c1af0 100644 --- a/apps/server/src/orchestration-v2/NotificationMailbox.ts +++ b/apps/server/src/orchestration-v2/NotificationMailbox.ts @@ -6,7 +6,10 @@ import { } from "@t3tools/contracts"; export function isBufferedProviderNotification( - message: OrchestrationV2ConversationMessage, + message: Pick< + OrchestrationV2ConversationMessage, + "createdBy" | "creationSource" | "notification" | "delegatedCompletion" + >, ): boolean { return ( message.createdBy === "agent" && diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 9f22aa28c3ba2..e7dd92e3c912a 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -833,7 +833,12 @@ function nextQueuedRun( } function followsCurrentConversation( - message: OrchestrationV2ConversationMessage | undefined, + message: + | Pick< + OrchestrationV2ConversationMessage, + "createdBy" | "creationSource" | "notification" | "delegatedCompletion" + > + | undefined, ): boolean { return ( message?.delegatedCompletion !== undefined || @@ -851,6 +856,20 @@ function queuedRunModelSelection( : run.modelSelection; } +/** The selection the thread will be on once every queued run has started. */ +function queueTailModelSelection( + projection: Pick, +): ModelSelection { + return ( + queuedRunsInDeliveryOrder(projection).findLast( + (run) => + !followsCurrentConversation( + projection.messages.find((message) => message.id === run.userMessageId), + ), + )?.modelSelection ?? projection.thread.modelSelection + ); +} + function usageLimitHoldsQueue( projection: Pick, providerInstanceId: ModelSelection["instanceId"], @@ -5932,7 +5951,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ]); projection = applyToProjection(projection, event); } - const modelSelection = command.modelSelection ?? projection.thread.modelSelection; + let modelSelection = command.modelSelection ?? projection.thread.modelSelection; let dispatchMode = resolveMessageDispatchIntent( projection, command.dispatchMode, @@ -5984,7 +6003,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : parsedGoalCommand; let goalStopRunId: RunId | undefined; if (goalCommand !== null) { - yield* ensureGoalCommandSupported(command, command, modelSelection.instanceId); // A goal command never joins a running turn. One that starts or ends goal // work stops the run and goes first in the queue; the rest wait. A send // that asked to be queued (no delivery intent) only queues, and a run @@ -6168,6 +6186,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); if (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") { + yield* ensureGoalCommandSupported(command, command, modelSelection.instanceId); yield* dispatchSteerIntoRun({ command, events, @@ -6220,6 +6239,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio dispatchMode.type === "start_immediately" || dispatchMode.type === "queue_after_active"; const shouldQueue = queueBehindRun !== undefined && startsOrQueuesRun; + // An explicit pick queued ahead reaches the thread only when its run starts. A goal + // command that stops the active run goes first, so nothing queued applies before it. + // Buffered provider wakes drain output their own provider already produced. + const incomingMessage = { ...command, delegatedCompletion }; + if ( + shouldQueue && + command.modelSelection === undefined && + goalStopRunId === undefined && + !followsCurrentConversation(incomingMessage) && + !isBufferedProviderNotification(incomingMessage) + ) { + modelSelection = queueTailModelSelection(projection); + } + yield* ensureGoalCommandSupported(command, command, modelSelection.instanceId); // Client messages resume held queues; server continuations and scheduled tasks do not. // Provider failure or usage limits re-hold the queue. const worktreeSetupUnresolved = worktreeSetupPending(projection); diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 2c311f2569753..9bacc71abee8d 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -1606,6 +1606,46 @@ it.effect("stops the running turn and starts a goal sent into it next", () => ), ); +it.effect("starts a goal that names no model on the thread's model ahead of a queued pick", () => + Effect.scoped( + Effect.gen(function* () { + const { started, layer, startFirstTurn, read } = yield* goalHarness("goal-ahead-of-pick"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + for (const [id, text, modelSelection] of [ + ["picked", "picked", composerSelection], + ["goal", "/goal all tests pass", undefined], + ] as const) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(id), + threadId, + messageId: MessageId.make(`message:${id}`), + text, + attachments: [], + ...(modelSelection === undefined ? {} : { modelSelection }), + dispatchMode: + id === "picked" ? { type: "queue_after_active" } : { type: "start_immediately" }, + ...(id === "picked" ? {} : { deliveryIntent: "auto" as const }), + createdBy: "user", + creationSource: "web", + }); + } + yield* worker.drain(); + + assert.deepEqual(yield* read(threadId), { + queue: ["picked"], + started: ["first", "/goal all tests pass"], + stopRequested: true, + }); + assert.deepEqual(started[1]?.modelSelection, runSelection); + }).pipe(Effect.provide(layer)); + }), + ), +); + it.effect("queues a goal clear without stopping work when no goal drives it", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 077980acd2eec..a49e323770cc1 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -162,17 +162,35 @@ const alternateProviderInstance = { instanceId: alternateInstanceId, }, } satisfies ProviderInstance; +const claudeInstanceId = ProviderInstanceId.make("claude_test"); +const claudeDriver = ProviderDriverKind.make("claudeAgent"); +const claudeProviderInstance = { + ...providerInstance, + instanceId: claudeInstanceId, + driverKind: claudeDriver, + continuationIdentity: { driverKind: claudeDriver, continuationKey: "claude:test" }, + displayName: "Claude test", + orchestrationAdapter: { + ...orchestrationAdapter, + instanceId: claudeInstanceId, + driver: claudeDriver, + }, +} satisfies ProviderInstance; const layerTestProviderInstanceRegistry = Layer.succeed( ProviderInstanceRegistry.ProviderInstanceRegistry, { getInstance: (instanceId) => Effect.succeed( - [providerInstance, alternateProviderInstance].find( + [providerInstance, alternateProviderInstance, claudeProviderInstance].find( (instance) => instanceId === instance.instanceId, ), ), - listInstances: Effect.succeed([providerInstance, alternateProviderInstance]), + listInstances: Effect.succeed([ + providerInstance, + alternateProviderInstance, + claudeProviderInstance, + ]), listUnavailable: Effect.succeed([]), streamChanges: Stream.empty, subscribeChanges: Effect.never, @@ -6157,6 +6175,220 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("keeps a model picked ahead of a queued agent message that names none", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make("runtime-layer-queued-agent-message-model-pick"); + const pickedSelection = { ...modelSelection, model: "gpt-5.5" }; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + createdBy: "user", + creationSource: "web", + title: "Queued model pick", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + for (const [key, selection] of [ + ["active", modelSelection], + ["picked", pickedSelection], + ] as const) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:${key}`), + threadId, + messageId: MessageId.make(`${threadId}:${key}`), + text: key, + attachments: [], + modelSelection: selection, + dispatchMode: { type: key === "active" ? "start_immediately" : "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + } + // What t3_thread_send dispatches: no model of its own. + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:agent`), + threadId, + messageId: MessageId.make(`${threadId}:agent`), + text: "Message from another thread", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "mcp", + }); + const [active, picked, agent] = (yield* orchestrator.getThreadProjection(threadId)).runs; + assert.isDefined(active); + assert.isDefined(picked); + assert.isDefined(agent); + const now = yield* DateTime.now; + for (const runId of [active.id, picked.id]) { + const current = yield* orchestrator.getThreadProjection(threadId); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${runId}:completed`), + type: "run.updated", + threadId, + runId, + occurredAt: now, + payload: { + ...current.runs.find((run) => run.id === runId)!, + status: "completed", + completedAt: now, + }, + }, + ], + }); + yield* orchestrator.resumeQueuedRuns; + } + const after = yield* orchestrator.getThreadProjection(threadId); + const delivered = after.runs.find((run) => run.id === agent.id)!; + assert.equal(delivered.status, "starting"); + assert.deepEqual(delivered.modelSelection, pickedSelection); + assert.deepEqual(after.thread.modelSelection, pickedSelection); + }), + ); + + it.effect("keeps a buffered provider wake on its own provider behind a queued pick", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-buffered-wake-behind-queued-pick"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + createdBy: "user", + creationSource: "web", + title: "Buffered wake behind a pick", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + for (const [key, selection] of [ + ["active", modelSelection], + ["picked", { ...modelSelection, instanceId: alternateInstanceId }], + ] as const) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:${key}`), + threadId, + messageId: MessageId.make(`${threadId}:${key}`), + text: key, + attachments: [], + modelSelection: selection, + dispatchMode: { type: key === "active" ? "start_immediately" : "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + } + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:wake`), + threadId, + messageId: MessageId.make(`${threadId}:wake`), + text: "Background activity updated", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "provider", + notification: { + source: { kind: "background_task" }, + outcome: "updated", + summary: "Background activity updated", + }, + }); + const projection = yield* orchestrator.getThreadProjection(threadId); + const active = projection.runs.find( + (run) => run.userMessageId === MessageId.make(`${threadId}:active`), + ); + const wake = projection.runs.find( + (run) => run.userMessageId === MessageId.make(`${threadId}:wake`), + ); + assert.deepEqual(wake?.modelSelection, modelSelection); + assert.equal(wake?.providerThreadId, active?.providerThreadId); + }), + ); + + it.effect("checks a queued goal pause against the model picked ahead of it", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-goal-pause-behind-queued-pick"); + const claudeSelection = { ...modelSelection, instanceId: claudeInstanceId }; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + createdBy: "user", + creationSource: "web", + title: "Goal pause behind a pick", + modelSelection: claudeSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + for (const [key, selection] of [ + ["active", claudeSelection], + ["picked", modelSelection], + ] as const) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:${key}`), + threadId, + messageId: MessageId.make(`${threadId}:${key}`), + text: key, + attachments: [], + modelSelection: selection, + dispatchMode: { type: key === "active" ? "start_immediately" : "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + } + const sendGoal = (key: string, text: string, deliveryIntent?: "auto") => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:${key}`), + threadId, + messageId: MessageId.make(`${threadId}:${key}`), + text, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + ...(deliveryIntent === undefined ? {} : { deliveryIntent }), + createdBy: "agent", + creationSource: "mcp", + }); + // It runs after the pick, on a provider that can pause goals. + yield* sendGoal("pause", "/goal pause"); + // A resume stops the active run and starts before the pick, so the thread's + // Claude instance still refuses it. + const sequence = yield* orchestrator.getThreadEventSequence(threadId); + const refused = yield* Effect.flip(sendGoal("resume", "/goal resume", "auto")); + assert.instanceOf(refused, Orchestrator.OrchestratorDispatchError); + assert.equal( + refused.cause, + "Claude goals cannot be paused or resumed. Send /goal clear to remove the goal.", + ); + assert.equal(yield* orchestrator.getThreadEventSequence(threadId), sequence); + + const pause = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === MessageId.make(`${threadId}:pause`), + ); + assert.deepEqual(pause?.modelSelection, modelSelection); + }), + ); + it.effect("keeps the queue after a user interrupts the active run", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; From 4d9caf14eeadcbbf7247177077e87feed0ad6f26 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 23:59:33 -0800 Subject: [PATCH 0007/1253] fix(ssh): tunnel readiness no longer counts ssh's connection time (#33554) --- packages/ssh/src/tunnel.test.ts | 136 +++++++++++++++++++++++++++++++- packages/ssh/src/tunnel.ts | 40 +++++++++- 2 files changed, 169 insertions(+), 7 deletions(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 663f30fb40c48..7a23d7eb3846b 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -13,11 +14,11 @@ import * as Result from "effect/Result"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; -import { HttpClient, HttpClientResponse } from "effect/http"; +import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as SshAuth from "./auth.ts"; -import { SshCommandError, SshPasswordPromptError } from "./errors.ts"; +import { SshCommandError, SshPasswordPromptError, SshReadinessError } from "./errors.ts"; import * as SshTunnel from "./tunnel.ts"; const isSshCommandError = Schema.is(SshCommandError); @@ -74,7 +75,7 @@ const hangingHttpClient = HttpClient.make(() => Effect.never); const testNetService = NetService.NetService.of({ canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), - hasListenerOnHost: () => Effect.succeed(false), + hasListenerOnHost: () => Effect.succeed(true), reserveLoopbackPort: () => Effect.succeed(41_773), findAvailablePort: (preferred) => Effect.succeed(preferred), }); @@ -272,6 +273,135 @@ describe("ssh tunnel scripts", () => { }), ); + it.effect("waits for a tunnel whose ssh connection outlasts the readiness timeout", () => + Effect.gen(function* () { + const LOCAL_PORT = 41_773; + // ssh spends ConnectTimeout on each unreachable address before the forward listens. + const SSH_CONNECT_MS = 25_000; + let forwardListensAt = Number.POSITIVE_INFINITY; + const tunnelSpawned = yield* Deferred.make(); + const forwardListening = (port: number) => + Clock.currentTimeMillis.pipe( + Effect.map((now) => port === LOCAL_PORT && now >= forwardListensAt), + ); + const spawner = ChildProcessSpawner.make((command) => + commandArgs(command).includes("-N") + ? Effect.gen(function* () { + forwardListensAt = (yield* Clock.currentTimeMillis) + SSH_CONNECT_MS; + yield* Deferred.succeed(tunnelSpawned, undefined); + return makeRunningProcess(() => undefined); + }) + : Effect.succeed(makeSuccessfulProcess('{"remotePort":3773}\n')), + ); + const httpClient = HttpClient.make((request) => + forwardListening(LOCAL_PORT).pipe( + Effect.flatMap((listening) => + listening + ? Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("", { status: 200 })), + ) + : Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: `connect ECONNREFUSED 127.0.0.1:${LOCAL_PORT}`, + }), + }), + ), + ), + ), + ); + const net = NetService.NetService.of({ + ...testNetService, + reserveLoopbackPort: () => Effect.succeed(LOCAL_PORT), + canListenOnHost: (port) => + forwardListening(port).pipe(Effect.map((listening) => !listening)), + isPortAvailableOnLoopback: (port) => + forwardListening(port).pipe(Effect.map((listening) => !listening)), + hasListenerOnHost: (port) => forwardListening(port), + }); + const layer = Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HttpClient.HttpClient, httpClient), + Layer.succeed(NetService.NetService, net), + SshAuth.SshPasswordPrompt.disabledLayer, + SshTunnel.SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }), + ); + yield* Effect.gen(function* () { + const manager = yield* SshTunnel.SshEnvironmentManager; + const ensure = yield* Effect.forkChild( + manager.ensureEnvironment({ + alias: "devbox", + hostname: "devbox", + username: null, + port: null, + }), + ); + yield* Deferred.await(tunnelSpawned); + yield* TestClock.adjust(Duration.seconds(60)); + const environment = yield* Fiber.join(ensure); + assert.equal(environment.httpBaseUrl, `http://127.0.0.1:${LOCAL_PORT}/`); + }).pipe(Effect.provide(layer), Effect.scoped); + }), + ); + + it.effect("fails with a readiness error when ssh never connects", () => + Effect.gen(function* () { + const tunnelSpawned = yield* Deferred.make(); + let tunnelKilled = false; + const spawner = ChildProcessSpawner.make((command) => + commandArgs(command).includes("-N") + ? Deferred.succeed(tunnelSpawned, undefined).pipe( + Effect.as( + makeRunningProcess(() => { + tunnelKilled = true; + }), + ), + ) + : Effect.succeed(makeSuccessfulProcess('{"remotePort":3773}\n')), + ); + const layer = Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HttpClient.HttpClient, hangingHttpClient), + Layer.succeed( + NetService.NetService, + NetService.NetService.of({ + ...testNetService, + hasListenerOnHost: () => Effect.succeed(false), + }), + ), + SshAuth.SshPasswordPrompt.disabledLayer, + SshTunnel.SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }), + ); + yield* Effect.gen(function* () { + const manager = yield* SshTunnel.SshEnvironmentManager; + const ensure = yield* Effect.forkChild( + Effect.result( + manager.ensureEnvironment({ + alias: "devbox", + hostname: "devbox", + username: null, + port: null, + }), + ), + ); + yield* Deferred.await(tunnelSpawned); + yield* TestClock.adjust(Duration.minutes(2)); + const result = yield* Fiber.join(ensure); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, SshReadinessError); + assert.include(result.failure.message, "SSH connection to devbox did not come up"); + } + assert.isTrue(tunnelKilled); + }).pipe(Effect.provide(layer), Effect.scoped); + }), + ); + it.effect.each([ { name: "does not ask for a password when the agent cannot sign an accepted key", diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 6a08b20a4256a..39631aa5390a8 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -10,12 +10,14 @@ import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; import { satisfiesSemverRange } from "@t3tools/shared/semver"; import * as Context from "effect/Context"; import * as Digest from "effect/crypto/Digest"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -52,6 +54,9 @@ const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; const TUNNEL_STDERR_TAIL_CHARS = 16_384; const REMOTE_READY_TIMEOUT_MS = 60_000; const REMOTE_LAUNCH_TIMEOUT_MS = 90_000; +// Matches the node-script launch command's whole ssh run against the same host. +const SSH_TUNNEL_CONNECT_TIMEOUT_MS = REMOTE_LAUNCH_TIMEOUT_MS; +const SSH_TUNNEL_FORWARD_PROBE_INTERVAL_MS = 100; // Cold archive launches download and extract ~70 MB and may wait on an install lock. // Lock waits must outlast both downloads and extraction; SSH must outlast install or wait plus readiness. // Leave slack for verification and extraction, which have no timeout. @@ -1096,6 +1101,29 @@ const reserveLocalTunnelPort = Effect.fn("ssh/tunnel.reserveLocalTunnelPort")(fu return yield* net.reserveLoopbackPort(); }); +// ssh only binds the forward after it connects, which can take ConnectTimeout per dead address. +const waitForTunnelForward = Effect.fn("ssh/tunnel.waitForTunnelForward")(function* (input: { + readonly target: DesktopSshEnvironmentTarget; + readonly localPort: number; +}) { + const net = yield* NetService.NetService; + yield* net.hasListenerOnHost(input.localPort, "127.0.0.1").pipe( + Effect.repeat({ + schedule: Schedule.spaced(Duration.millis(SSH_TUNNEL_FORWARD_PROBE_INTERVAL_MS)), + until: (listening) => listening, + }), + Effect.timeoutOrElse({ + duration: Duration.millis(SSH_TUNNEL_CONNECT_TIMEOUT_MS), + orElse: () => + Effect.fail( + new SshReadinessError({ + message: `SSH connection to ${input.target.alias} did not come up within ${SSH_TUNNEL_CONNECT_TIMEOUT_MS}ms.`, + }), + ), + }), + ); +}); + const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: { readonly key: string; readonly resolvedTarget: DesktopSshEnvironmentTarget; @@ -1271,10 +1299,14 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: }), ); yield* Effect.raceFirst( - waitForHttpReady({ - baseUrl: input.httpBaseUrl, - timeoutMs: SSH_READY_TIMEOUT_MS, - }), + waitForTunnelForward({ target: input.resolvedTarget, localPort: input.localPort }).pipe( + Effect.andThen( + waitForHttpReady({ + baseUrl: input.httpBaseUrl, + timeoutMs: SSH_READY_TIMEOUT_MS, + }), + ), + ), exitFailure, ).pipe( Effect.tap(() => From 4f570443c68e0250ac66b123f44c5405ef963a4d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 23:59:47 -0800 Subject: [PATCH 0008/1253] fix(server): preview_evaluate accepts top-level await (#31925) Co-authored-by: Claude Opus 5.5 --- .../src/preview/ServerBrowserPage.test.ts | 106 +++++++++++++++++- apps/server/src/preview/ServerBrowserPage.ts | 17 ++- 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 8b69f41cbdb29..78f03f1f91b72 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -5,7 +5,16 @@ import { type CDPSession, type Page, } from "playwright-core"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; import * as ServerBrowserPage from "./ServerBrowserPage.ts"; @@ -269,6 +278,67 @@ describe("server browser element refs", () => { await ServerBrowserPage.click(page, { selector: 'button[data-example=" >> aria-ref=e1"]' }); expect(await page.locator("button").textContent()).toBe("saved"); }); + + it("evaluates top-level await and returns the last expression's value", async () => { + await page.setContent("Ready"); + const result = await ServerBrowserPage.evaluate( + cdp, + { expression: "await Promise.resolve(); document.title" }, + 5_000, + ); + expect(result).toBe("Ready"); + }); + + it("still awaits the promise an async function returns", async () => { + const result = await ServerBrowserPage.evaluate( + cdp, + { expression: "(async () => { await 0; return 1 })()" }, + 5_000, + ); + expect(result).toBe(1); + }); + + it("does not rerun an expression whose SyntaxError is thrown at runtime", async () => { + const evaluation = ServerBrowserPage.evaluate( + cdp, + { expression: "window.runs = (window.runs ?? 0) + 1; new Function('await 0')" }, + 5_000, + ); + await expect(evaluation).rejects.toThrow("await is only valid"); + expect(await page.evaluate("window.runs")).toBe(1); + }); + + it("does not rerun an expression whose awaited browser call rejects with a SyntaxError", async () => { + let posts = 0; + await page.route("http://preview.test/**", (route) => { + if (route.request().method() === "POST") posts++; + return route.fulfill({ contentType: "text/html", body: "Ready" }); + }); + await page.goto("http://preview.test/"); + const evaluation = ServerBrowserPage.evaluate( + cdp, + { + expression: + "(async () => { const r = await fetch('/api', { method: 'POST' }); return r.json() })()", + }, + 5_000, + ); + await expect(evaluation).rejects.toThrow("is not valid JSON"); + expect(posts).toBe(1); + }); + + it("returns the result of a top-level await expression that reloads the page", async () => { + await page.route("http://preview.test/", (route) => + route.fulfill({ contentType: "text/html", body: "Ready" }), + ); + await page.goto("http://preview.test/"); + const result = await ServerBrowserPage.evaluate( + cdp, + { expression: "await 0; location.reload(); ({ clicked: true })" }, + 5_000, + ); + expect(result).toEqual({ clicked: true }); + }); }); describe("server browser snapshot screenshot", () => { @@ -417,6 +487,40 @@ describe("server browser drag", () => { }); }); +describe("server browser evaluate", () => { + it("does not retry a parse error that arrives after the timeout", async () => { + vi.useFakeTimers(); + try { + const classic = Promise.withResolvers(); + const send = vi.fn((method: string) => + method === "Runtime.evaluate" ? classic.promise : Promise.resolve({}), + ); + const cdp = { send } as unknown as CDPSession; + const evaluation = ServerBrowserPage.evaluate(cdp, { expression: "await 0; 1" }, 10); + const rejected = expect(evaluation).rejects.toMatchObject({ + tag: "PreviewAutomationTimeoutError", + }); + await vi.advanceTimersByTimeAsync(10); + await rejected; + classic.resolve({ + result: { type: "object" }, + exceptionDetails: { + text: "Uncaught", + scriptId: "4", + exception: { type: "object", className: "SyntaxError" }, + }, + }); + await classic.promise; + expect(send.mock.calls.map(([method]) => method)).toEqual([ + "Runtime.evaluate", + "Runtime.terminateExecution", + ]); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("server browser net errors", () => { it.each([ ["net::ERR_CONNECTION_REFUSED", "ERR_CONNECTION_REFUSED", -102], diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index fdba996799180..31cff821c6e42 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -892,8 +892,10 @@ export const evaluate = async ( timeoutMs: number, ) => { let timer: ReturnType | undefined; + let timedOut = false; const expired = new Promise((_, reject) => { timer = setTimeout(() => { + timedOut = true; // Stops a busy script; an awaited promise is abandoned and settles unread. void cdp.send("Runtime.terminateExecution").catch(constVoid); reject( @@ -904,10 +906,23 @@ export const evaluate = async ( ); }, timeoutMs); }); - const evaluation = cdp.send("Runtime.evaluate", { + const params = { expression: input.expression, awaitPromise: input.awaitPromise ?? true, returnByValue: input.returnByValue ?? true, + }; + // The devtools console's replMode allows top-level await but returns a trailing promise unawaited, + // so it only retries what the page's parser rejected. A parse error has a scriptId but no stack and + // ran nothing; a browser-internal rejection has neither. + const evaluation = cdp.send("Runtime.evaluate", params).then((classic) => { + const failure = classic.exceptionDetails; + const unparsed = + failure?.exception?.className === "SyntaxError" && + failure.scriptId !== undefined && + failure.stackTrace === undefined; + return unparsed && !timedOut && input.expression.includes("await") + ? cdp.send("Runtime.evaluate", { ...params, replMode: true }) + : classic; }); void evaluation.catch(constVoid); const result = await Promise.race([evaluation, expired]).finally(() => clearTimeout(timer)); From 5636b5666d3fb9b2b280dbe8a0c5cb42874f1bcd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 00:00:02 -0800 Subject: [PATCH 0009/1253] fix(server): quitting no longer restarts the relay client mid-shutdown (#33628) Co-authored-by: Claude Opus 5.5 --- .../src/cloud/ManagedEndpointRuntime.test.ts | 48 +++++++++++++++++++ .../src/cloud/ManagedEndpointRuntime.ts | 17 +++++++ 2 files changed, 65 insertions(+) diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index d1f95140fa0a6..2916c8f965386 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -955,6 +955,54 @@ describe("CloudManagedEndpointRuntime", () => { }), ); + it.effect.each(["SIGINT", "SIGTERM"] as const)( + "does not restart a connector that exits after the server receives %s", + (signal) => + Effect.gen(function* () { + const spawned: Array = []; + const firstExit = yield* Deferred.make(); + const firstKilled = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const pid = 400 + spawned.length; + spawned.push(pid); + const handle = makeHandle({ + pid, + exitCode: + pid === 400 + ? Deferred.await(firstExit) + : (Effect.never as Effect.Effect), + onKill: () => { + if (pid === 400) Deferred.doneUnsafe(firstKilled, Exit.void); + }, + }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + // Invoke only the runtime's own listeners; emitting would reach the test worker's too. + const listenersBefore = new Set(process.listeners(signal)); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); + const runtimeListeners = process + .listeners(signal) + .filter((listener) => !listenersBefore.has(listener)); + + yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token", + tunnelId: "tunnel-1", + }); + for (const listener of runtimeListeners) listener(signal); + // The process group receives the signal too, so the connector exits cleanly. + yield* Deferred.succeed(firstExit, ChildProcessSpawner.ExitCode(0)); + yield* Deferred.await(firstKilled); + // Holding the reconcile lock waits out the supervisor's restart decision. + yield* runtime.restartConnector(400, Effect.succeed(true)); + + expect(spawned).toEqual([400]); + }), + ); + it.effect("serializes concurrent connector config changes", () => Effect.gen(function* () { const spawned: Array = []; diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 66ef48f547730..6e86f87ffef95 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -228,6 +228,22 @@ export const make = Effect.gen(function* () { const prunedRef = yield* Ref.make(false); const retryLoopRunningRef = yield* Ref.make(false); const notReadyChecksBeforeRestartRef = yield* Ref.make(CONNECTOR_NOT_READY_CHECKS_BEFORE_RESTART); + // The relay client shares the server's process group, so it exits on these signals before the finalizer runs. + let shutdownSignalReceived = false; + const onShutdownSignal = () => { + shutdownSignalReceived = true; + }; + yield* Effect.acquireRelease( + Effect.sync(() => { + process.on("SIGINT", onShutdownSignal); + process.on("SIGTERM", onShutdownSignal); + }), + () => + Effect.sync(() => { + process.off("SIGINT", onShutdownSignal); + process.off("SIGTERM", onShutdownSignal); + }), + ); // An explicit cloudflared transport in the environment is the user's choice. const env = yield* HostProcess.Environment; const transportChosenByUser = Option.isSome( @@ -363,6 +379,7 @@ export const make = Effect.gen(function* () { const desiredConfig = yield* Ref.get(desiredConfigRef); if ( + shutdownSignalReceived || !desiredConfig || desiredConfig.providerKind !== "cloudflare_tunnel" || runtimeConfigKey(desiredConfig) !== connector.configKey From 9e12eca12526c842f97cfcbc6f3cd2149259c776 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 00:00:17 -0800 Subject: [PATCH 0010/1253] fix(shared): relative Windows file links open the file (#33643) Co-authored-by: Claude Opus 5.5 --- packages/shared/src/markdownLinks.test.ts | 16 ++++++++++++++++ packages/shared/src/markdownLinks.ts | 8 ++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/shared/src/markdownLinks.test.ts b/packages/shared/src/markdownLinks.test.ts index 1ca0b0ea0401b..d915039528ec7 100644 --- a/packages/shared/src/markdownLinks.test.ts +++ b/packages/shared/src/markdownLinks.test.ts @@ -52,6 +52,7 @@ describe("parseMarkdownFileLink", () => { ["pages/posts/%5Bid%5D.tsx", "pages/posts/[id].tsx"], ["+page.svelte", "+page.svelte"], ["[id].tsx", "[id].tsx"], + ["/\\server\\share\\x.ts", "/\\server\\share\\x.ts"], ])("recognizes %s as a file", (href, path) => { expect(parseMarkdownFileLink(href)?.path).toBe(path); }); @@ -75,6 +76,7 @@ describe("parseMarkdownFileLink", () => { "me@example.com", "foo(bar)", "$HOME", + "\\foo.ts", ])("does not treat %s as a file", (href) => { expect(parseMarkdownFileLink(href)).toBeNull(); }); @@ -142,6 +144,20 @@ describe("resolveMarkdownFileLinkTarget", () => { expect(resolveMarkdownFileLinkTarget("patches", "/repo/project")).toBeNull(); }); + // regression: #33625 backslash-separated relative links open the workspace file + it.each([ + ["src/Example.cs", "C:\\repo\\src\\Example.cs"], + ["src\\Example.cs", "C:\\repo\\src\\Example.cs"], + ["src%5CExample.cs", "C:\\repo\\src\\Example.cs"], + ["src\\Example.cs:3", "C:\\repo\\src\\Example.cs:3"], + ])("resolves the relative link %s against a windows cwd", (href, target) => { + expect(resolveMarkdownFileLinkTarget(href, "C:\\repo")).toBe(target); + }); + + it("keeps a backslash in a relative link that already names a file", () => { + expect(resolveMarkdownFileLinkTarget("./foo%5Cbar.ts", "/repo")).toBe("/repo/./foo\\bar.ts"); + }); + it("does not treat filename line references as external schemes", () => { expect(resolveMarkdownFileLinkTarget("script.ts:10", "/Users/julius/project")).toBe( "/Users/julius/project/script.ts:10", diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts index 0d4e7556d072b..5b92d33c06356 100644 --- a/packages/shared/src/markdownLinks.ts +++ b/packages/shared/src/markdownLinks.ts @@ -314,6 +314,14 @@ export function parseMarkdownFileLink(href: string): FilePathPosition | null { const hash = safeDecodeURIComponent(source.hash.trim()); if (path.length === 0 || hasExternalScheme(path)) return null; + const position = parseFilePathLink(path, hash); + // `src\a.ts` is Windows-relative. A link that already parses keeps its spelling, since POSIX + // names may contain `\`; a rooted `\\host` or `\foo` would change root if rewritten. + if (position || /^[\\/]/.test(path) || isWindowsAbsolutePath(path)) return position; + return path.includes("\\") ? parseFilePathLink(path.replaceAll("\\", "/"), hash) : null; +} + +function parseFilePathLink(path: string, hash: string): FilePathPosition | null { const position = splitFilePathPosition(path, hash); return looksLikeFilePath(position.path, path) ? position : null; } From 8d54c8b1a7ab58528ec7fcddb30fdc68a6e68ea0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 00:02:56 -0800 Subject: [PATCH 0011/1253] fix(server): a steer that restarts a completion wake no longer strands later subagent results (#33469) Co-authored-by: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 214 ++++++++----- .../SteeringCompletion.integration.test.ts | 298 ++++++++++++++++++ 2 files changed, 429 insertions(+), 83 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index e7dd92e3c912a..3607e7272273d 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1600,6 +1600,104 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } }); + /** Marks a wake the provider received delivered and reserves the next one for pending siblings. */ + const acceptDelegatedCompletionDelivery = Effect.fn( + "Orchestrator.acceptDelegatedCompletionDelivery", + )(function* (input: { + readonly command: OrchestrationV2ServerCommand; + readonly events: Ref.Ref>; + readonly projection: Pick< + OrchestrationV2ThreadProjection, + "thread" | "runs" | "messages" | "subagents" + >; + readonly messageId: OrchestrationV2ConversationMessage["id"]; + readonly now: DateTime.Utc; + }) { + const { projection, now } = input; + const message = projection.messages.find((row) => row.id === input.messageId); + const ownership = message?.delegatedCompletion; + const parentRun = projection.runs.find((run) => run.id === ownership?.parentRunId); + const cohort = parentRun?.delegatedCompletion; + const delivery = cohort?.delivery; + if ( + parentRun === undefined || + cohort === undefined || + cohort.disposition !== "open" || + delivery == null || + delivery.messageId !== input.messageId || + delivery.generation !== ownership?.generation + ) { + return undefined; + } + const threadId = projection.thread.id; + const parentIsLive = hasLiveRun({ runs: [parentRun] }); + const pendingTaskIds = + projection.thread.archivedAt === null && projection.thread.deletedAt === null + ? projection.subagents + .filter( + (task) => + task.origin === "app_owned" && + task.runId === parentRun.id && + task.completionDelivery?.state === "pending" && + isTerminalDelegatedTaskStatus(task.status) && + (!parentIsLive || task.completionWake === "always"), + ) + .map((task) => task.id) + : []; + const nextDelivery = + pendingTaskIds.length === 0 + ? null + : { + generation: cohort.nextGeneration, + messageId: yield* mapDelegatedCompletionError( + idAllocator.allocate.message({ + threadId, + ordinal: + (yield* mapDelegatedCompletionError(projectionStore.getMessageCount(threadId))) + + 1, + }), + ), + taskIds: pendingTaskIds, + }; + const emitEvent = emit(input.events, input.command); + const acceptedIds = new Set(delivery.taskIds); + const pendingIds = new Set(pendingTaskIds); + for (const task of projection.subagents) { + const state = pendingIds.has(task.id) + ? "claimed" + : acceptedIds.has(task.id) && task.completionDelivery?.state === "claimed" + ? "delivered" + : undefined; + if (state === undefined) continue; + yield* emitEvent({ + type: "subagent.updated", + threadId, + runId: parentRun.id, + nodeId: task.id, + driver: task.driver, + providerInstanceId: task.providerInstanceId, + occurredAt: now, + payload: { ...task, completionDelivery: { state, observedByRunId: null }, updatedAt: now }, + }); + } + yield* emitEvent({ + type: "run.updated", + threadId, + runId: parentRun.id, + providerInstanceId: parentRun.providerInstanceId, + occurredAt: now, + payload: { + ...parentRun, + delegatedCompletion: { + ...cohort, + nextGeneration: cohort.nextGeneration + (nextDelivery === null ? 0 : 1), + delivery: nextDelivery, + }, + }, + }); + return parentRun.id; + }); + const emitQueuedRunCancellation = Effect.fn("Orchestrator.emitQueuedRunCancellation")( function* (input: { readonly command: OrchestrationV2ServerCommand; @@ -5698,6 +5796,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio occurredAt: now, payload: checkpointScope, }); + // The restart replaces the run's input, so the wake it was running can no longer be + // settled at its terminal. Its provider turn was running, so the wake already landed. + yield* acceptDelegatedCompletionDelivery({ + command: input.command, + events: input.events, + projection: input.projection, + messageId: targetRun.userMessageId, + now, + }); yield* appendSteeringMessage({ runId: targetRun.id, nodeId: nextRootNodeId, @@ -13347,95 +13454,26 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio events: Ref.Ref>, ) { const projection = yield* getProjectionWithPendingEvents(command.threadId, events); - const message = projection.messages.find((row) => row.id === command.messageId); - const ownership = message?.delegatedCompletion; - const parentRun = projection.runs.find((run) => run.id === ownership?.parentRunId); - const cohort = parentRun?.delegatedCompletion; - const delivery = cohort?.delivery; const now = yield* DateTime.now; - const emitEvent = emit(events, command); - if ( - parentRun === undefined || - cohort === undefined || - cohort.disposition !== "open" || - delivery == null || - delivery.messageId !== command.messageId || - delivery.generation !== ownership?.generation - ) { - yield* emitEvent({ + const parentRunId = yield* acceptDelegatedCompletionDelivery({ + command, + events, + projection, + messageId: command.messageId, + now, + }); + if (parentRunId === undefined) { + yield* emit( + events, + command, + )({ type: "thread.metadata-updated", threadId: command.threadId, occurredAt: now, payload: projection.thread, }); - return; } - const parentIsLive = hasLiveRun({ runs: [parentRun] }); - const pendingTaskIds = - projection.thread.archivedAt === null && projection.thread.deletedAt === null - ? projection.subagents - .filter( - (task) => - task.origin === "app_owned" && - task.runId === parentRun.id && - task.completionDelivery?.state === "pending" && - isTerminalDelegatedTaskStatus(task.status) && - (!parentIsLive || task.completionWake === "always"), - ) - .map((task) => task.id) - : []; - const nextDelivery = - pendingTaskIds.length === 0 - ? null - : { - generation: cohort.nextGeneration, - messageId: yield* mapDelegatedCompletionError( - idAllocator.allocate.message({ - threadId: command.threadId, - ordinal: - (yield* mapDelegatedCompletionError( - projectionStore.getMessageCount(projection.thread.id), - )) + 1, - }), - ), - taskIds: pendingTaskIds, - }; - const acceptedIds = new Set(delivery.taskIds); - const pendingIds = new Set(pendingTaskIds); - for (const task of projection.subagents) { - const state = pendingIds.has(task.id) - ? "claimed" - : acceptedIds.has(task.id) && task.completionDelivery?.state === "claimed" - ? "delivered" - : undefined; - if (state === undefined) continue; - yield* emitEvent({ - type: "subagent.updated", - threadId: command.threadId, - runId: parentRun.id, - nodeId: task.id, - driver: task.driver, - providerInstanceId: task.providerInstanceId, - occurredAt: now, - payload: { ...task, completionDelivery: { state, observedByRunId: null }, updatedAt: now }, - }); - } - yield* emitEvent({ - type: "run.updated", - threadId: command.threadId, - runId: parentRun.id, - providerInstanceId: parentRun.providerInstanceId, - occurredAt: now, - payload: { - ...parentRun, - delegatedCompletion: { - ...cohort, - nextGeneration: cohort.nextGeneration + (nextDelivery === null ? 0 : 1), - delivery: nextDelivery, - }, - }, - }); - return parentRun.id; + return parentRunId; }); const dispatchUnsupported = (command: OrchestrationV2ServerCommand) => @@ -14125,7 +14163,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); } - if (command.type === "notification.delivery.accept") { + if ( + command.type === "notification.delivery.accept" || + // A steer restart that settled a wake can reserve the next one for its siblings. + ((command.type === "message.dispatch" || + command.type === "queued-message.promote-to-steer") && + committed.storedEvents.some( + ({ event }) => + event.type === "subagent.updated" && + event.payload.completionDelivery?.state === "claimed", + )) + ) { yield* mapDispatchError(command)(offerDelegatedCompletionDeliveries(command.threadId)); } if ( diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 9bacc71abee8d..733ace700ba9e 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import { ChatAttachmentId, CommandId, + EnvironmentId, MessageId, EventId, NodeId, @@ -19,6 +20,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; @@ -29,6 +31,7 @@ import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; const driver = ProviderDriverKind.make("codex"); const instanceId = ProviderInstanceId.make("codex"); @@ -1215,6 +1218,301 @@ it.effect("queues a steer until the turn ends when the provider cannot steer it" ), ); +// regression: #33454 a steer that restarts a completion wake run left its cohort claimed forever +const restartCompletionWake = Effect.fn("restartCompletionWake")(function* ( + harness: Pick< + Effect.Success>, + "events" | "started" | "startFirstTurn" + >, + siblingFinishesFirst: boolean, +) { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const sink = yield* EventSink.EventSinkV2; + const threadId = yield* harness.startFirstTurn; + const watchRun = (runId: string, status: string) => + orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "run.updated" && + event.payload.id === runId && + event.payload.status === status, + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + const watchTurnRunning = (ordinal: number) => + orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "provider-turn.updated" && + event.payload.status === "running" && + event.payload.ordinal === ordinal, + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + const finishTurn = (turn: ProviderAdapter.ProviderAdapterV2TurnInput) => + Effect.gen(function* () { + const providerTurn = (yield* orchestrator.getThreadProjection(threadId)).providerTurns.find( + (candidate) => candidate.runAttemptId === turn.attemptId, + )!; + const settled = yield* watchRun(turn.runId, "waiting"); + yield* Queue.offer(harness.events, { + type: "provider_turn.updated", + driver, + providerTurn: { + ...providerTurn, + status: "completed", + completedAt: yield* DateTime.now, + }, + }); + yield* Queue.offer(harness.events, { + type: "turn.terminal", + driver, + providerThreadId: providerTurn.providerThreadId, + providerTurnId: providerTurn.id, + runOrdinal: turn.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(settled); + yield* worker.drain(); + }); + + const origin = harness.started[0]!; + const firstTaskId = NodeId.make("task:steered-wake-first"); + const siblingTaskId = NodeId.make("task:steered-wake-sibling"); + const now = yield* DateTime.now; + yield* sink.write({ + events: [firstTaskId, siblingTaskId].map((taskId) => ({ + id: EventId.make(`event:seed:${taskId}`), + type: "subagent.updated" as const, + threadId, + runId: origin.runId, + nodeId: taskId, + occurredAt: now, + payload: { + id: taskId, + threadId, + runId: origin.runId, + parentNodeId: origin.rootNodeId, + origin: "app_owned" as const, + createdBy: "agent" as const, + driver, + providerInstanceId: instanceId, + providerThreadId: null, + childThreadId: null, + remoteChild: { + environmentId: EnvironmentId.make("environment:box"), + threadId: ThreadId.make(`thread:remote:${taskId}`), + label: "box", + }, + nativeTaskRef: null, + prompt: "Do background work", + title: null, + model: null, + completionWake: "always" as const, + completionDelivery: { state: "pending" as const, observedByRunId: null }, + status: "running" as const, + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + })), + }); + yield* finishTurn(origin); + + const completeTask = (taskId: NodeId) => + orchestrator.dispatch({ + type: "delegated_task.remote.complete", + commandId: CommandId.make(`command:remote-complete:${taskId}`), + parentThreadId: threadId, + taskId, + status: "completed", + result: `${taskId} finished`, + stoppedThere: "ran-out", + }); + yield* completeTask(firstTaskId); + const firstDelivery = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.id === origin.runId, + )!.delegatedCompletion!.delivery!; + assert.deepEqual(firstDelivery.taskIds, [firstTaskId]); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("command:first-wake"), + threadId, + messageId: firstDelivery.messageId, + text: "Delegated task completed", + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "queue_after_active" }, + delegatedCompletion: { + parentRunId: origin.runId, + generation: firstDelivery.generation, + taskIds: firstDelivery.taskIds, + }, + createdBy: "agent", + creationSource: "server", + }); + const wakeRunning = yield* watchTurnRunning(2); + yield* orchestrator.resumeQueuedRuns; + yield* worker.drain(); + yield* Fiber.join(wakeRunning); + const wake = harness.started[1]!; + assert.equal(wake.message.messageId, firstDelivery.messageId); + + if (siblingFinishesFirst) { + yield* completeTask(siblingTaskId); + } + + // The provider cannot steer natively, so the steer restarts the wake run. + const steeredRunning = yield* watchTurnRunning(3); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("command:steer-wake"), + threadId, + messageId: MessageId.make("message:steer-wake"), + text: "also check the logs", + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "steer_active", targetRunId: wake.runId }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* Fiber.join(steeredRunning); + const steered = harness.started[2]!; + assert.equal(steered.runId, wake.runId); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).attempts.find( + (attempt) => attempt.id === steered.attemptId, + )?.reason, + "steering_restart", + ); + return { + threadId, + origin, + wake, + steered, + firstTaskId, + siblingTaskId, + watchRun, + finishTurn, + completeTask, + }; +}); + +it.effect("wakes the parent for a sibling result after a steer restarts the completion wake", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* nextTurnSelectionHarness("steered-completion-wake", true, false); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { + threadId, + origin, + wake, + steered, + firstTaskId, + siblingTaskId, + watchRun, + finishTurn, + completeTask, + } = yield* restartCompletionWake(harness, false); + + // A follow-up queued behind the wake starts once the terminal-run listener has settled it. + const followUp = MessageId.make("message:follow-up"); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("command:follow-up"), + threadId, + messageId: followUp, + text: "follow up", + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const followUpRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === followUp, + )!; + const followUpStarted = yield* watchRun(followUpRun.id, "starting"); + yield* finishTurn(steered); + yield* Fiber.join(followUpStarted); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.id === wake.runId, + )?.status, + "completed", + ); + + yield* completeTask(siblingTaskId); + const final = yield* orchestrator.getThreadProjection(threadId); + const tasks = new Map( + final.subagents.map((task) => [task.id, task.completionDelivery?.state]), + ); + assert.deepInclude( + final.runs.find((run) => run.id === origin.runId)?.delegatedCompletion?.delivery ?? {}, + { generation: 2, taskIds: [siblingTaskId] }, + "the sibling finished after the steered wake but no new completion wake was reserved", + ); + assert.equal(tasks.get(siblingTaskId), "claimed"); + assert.equal( + tasks.get(firstTaskId), + "delivered", + "the steered wake run's own task stays claimed after that run completed", + ); + }).pipe(Effect.provide(harness.layer)); + }), + ), +); + +it.effect( + "offers the next completion wake when a steer restarts a wake a sibling finished behind", + () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* nextTurnSelectionHarness( + "steered-completion-wake-behind", + true, + false, + ); + const offers: ProviderContinuationRequests.ProviderContinuationRequest[] = []; + const requests = Layer.succeed(ProviderContinuationRequests.ProviderContinuationRequests, { + offer: (request) => Effect.sync(() => void offers.push(request)), + take: Effect.never, + }); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { threadId, origin, firstTaskId, siblingTaskId } = yield* restartCompletionWake( + harness, + true, + ); + const projection = yield* orchestrator.getThreadProjection(threadId); + const delivery = projection.runs.find((run) => run.id === origin.runId) + ?.delegatedCompletion?.delivery; + assert.deepInclude(delivery ?? {}, { generation: 2, taskIds: [siblingTaskId] }); + const tasks = new Map( + projection.subagents.map((task) => [task.id, task.completionDelivery?.state]), + ); + assert.equal(tasks.get(firstTaskId), "delivered"); + assert.equal(tasks.get(siblingTaskId), "claimed"); + assert.deepEqual(offers.at(-1)?.delegatedCompletion, { + parentRunId: origin.runId, + generation: 2, + messageId: delivery!.messageId, + }); + }).pipe(Effect.provide(harness.layer.pipe(Layer.provide(requests)))); + }), + ), +); + // Codex flushes held drafts after the final answer; reserve their pre-steer position before turn end. it.effect("keeps a held commentary draft before the steer when turn end flushes it", () => Effect.scoped( From 5dcf69ebfa955669b8d34d0fa73d2d05d6b466d0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 00:03:11 -0800 Subject: [PATCH 0012/1253] fix(server): provider status checks no longer make mise download missing CLIs (#33448) Co-authored-by: Claude Opus 5.5 --- apps/server/src/provider/ClaudeProvider.ts | 9 +- apps/server/src/provider/CodexProvider.ts | 8 +- .../src/provider/ProviderRegistry.test.ts | 43 ++++++ .../src/server/driver.test.ts | 2 +- .../src/server/driver.ts | 7 +- .../provider-core/src/server/snapshotProbe.ts | 17 ++ packages/provider-fx/src/server/status.ts | 10 +- .../src/server/probeEnvironment.ts | 4 +- .../provider-grok/src/server/skills.test.ts | 1 + packages/provider-grok/src/server/status.ts | 4 +- packages/provider-kiro/src/server/status.ts | 18 ++- packages/provider-muse/src/server/status.ts | 6 +- .../src/server/driver.test.ts | 49 ++++++ .../provider-opencode/src/server/driver.ts | 145 +++++++++++------- .../src/server/versionProbe.ts | 1 + packages/provider-pi/src/server/status.ts | 14 +- 16 files changed, 252 insertions(+), 86 deletions(-) diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 5ea745622bb34..040a0671d3d5a 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -33,8 +33,9 @@ import { COMPACT_SLASH_COMMAND, DEFAULT_TIMEOUT_MS, isCommandMissingCause, - parseGenericCliVersion, + parseCliVersionResult, providerModelsFromSettings, + providerProbeEnvironment, spawnAndCollect, type ServerProviderDraft, } from "@t3tools/provider-core/server/snapshotProbe"; @@ -232,7 +233,7 @@ export function buildClaudeCapabilitiesProbeQueryOptions(input: { mcpServers: {}, ...(input.managedMcpConfig ? {} : { strictMcpConfig: true }), env: { - ...input.environment, + ...providerProbeEnvironment(input.environment), // Disable claude.ai MCP separately; it is discovered outside filesystem config. ENABLE_CLAUDEAI_MCP_SERVERS: "false", // Skipping this avoids a Windows tasklist | findstr process tree on every refresh. @@ -552,7 +553,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const versionProbe = yield* runClaudeCommand( claudeSettings, ["--version"], - resolvedEnvironment, + providerProbeEnvironment(resolvedEnvironment), cwd, ).pipe(Effect.timeoutOption(CLAUDE_VERSION_CHECK_TIMEOUT_MS), Effect.result); @@ -596,7 +597,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( } const version = versionProbe.success.value; - const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + const parsedVersion = parseCliVersionResult(version); if (version.code !== 0) { yield* Effect.logWarning("Claude Agent CLI version probe exited with a non-zero status.", { exitCode: version.code, diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 2d546b36a9b15..2e56eede84cd6 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -41,6 +41,7 @@ import { COMPACT_SLASH_COMMAND, DEFAULT_TIMEOUT_MS, providerModelsFromSettings, + providerProbeEnvironment, spawnAndCollect, type ServerProviderDraft, } from "@t3tools/provider-core/server/snapshotProbe"; @@ -650,7 +651,10 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi readonly cwd: string; readonly environment?: NodeJS.ProcessEnv; }) { - const { client } = yield* withCodexAppServerClient(input); + const { client } = yield* withCodexAppServerClient({ + ...input, + environment: providerProbeEnvironment(input.environment ?? process.env), + }); const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] }); return parseCodexSkillsListResponse(skillsResponse, input.cwd); }); @@ -753,7 +757,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu ServerSettingsError, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { - const resolvedEnvironment = environment ?? process.env; + const resolvedEnvironment = providerProbeEnvironment(environment ?? process.env); const checkedAt = DateTime.formatIso(yield* DateTime.now); const emptyModels = emptyCodexModelsFromSettings(codexSettings); diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 6acb9bb9741f9..542e641b37b39 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -3125,6 +3125,49 @@ it.layer( ), ); + it.effect("does not let a lazy mise shim install Claude during a status check (#33415)", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fake-mise-claude-" }); + const installMarker = path.join(directory, "installed"); + const binaryPath = path.join(directory, "claude"); + // A `lazy = true` mise shim downloads and installs the tool unless MISE_OFFLINE is set. + yield* fs.writeFileString( + binaryPath, + [ + `#!/bin/sh`, + `case "$MISE_OFFLINE" in`, + ` 1|true) cat >&2 <<'EOF'`, + `mise WARN Failed to resolve tool version list for claude: [/home/u/.config/mise/config.toml] claude@latest: no versions found for claude`, + `mise ERROR Failed to install aqua:anthropics/claude-code@latest: no versions found for claude`, + `mise ERROR Version: 2026.11.5 linux-x64 (2026-11-09)`, + `mise ERROR Run with --verbose or MISE_VERBOSE=1 for more information`, + `EOF`, + ` exit 1 ;;`, + `esac`, + `touch "${installMarker}"`, + `echo "2.1.0 (Claude Code)"`, + ].join("\n"), + ); + yield* fs.chmod(binaryPath, 0o755); + const { MISE_OFFLINE: _, ...environment } = process.env; + + const status = yield* checkClaudeProviderStatus( + { ...defaultClaudeSettings, binaryPath }, + claudeCapabilities(), + environment, + ); + + assert.isFalse( + yield* fs.exists(installMarker), + "the Claude --version status probe let mise install Claude", + ); + assert.isNull(status.version, "mise's own version is not Claude's"); + }), + ); + it.effect("reports a logged-out CLI as unauthenticated", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/packages/provider-acp-registry/src/server/driver.test.ts b/packages/provider-acp-registry/src/server/driver.test.ts index 7fba45fd6bec0..fbb4f16e97c75 100644 --- a/packages/provider-acp-registry/src/server/driver.test.ts +++ b/packages/provider-acp-registry/src/server/driver.test.ts @@ -394,7 +394,7 @@ describe("acpRegistrySnapshotReadiness", () => { ), ); - expect(receivedEnvironment).toBe(environment); + expect(receivedEnvironment).toEqual({ ...environment, MISE_OFFLINE: "1" }); expect(snapshot).toMatchObject({ auth: { status: "unknown" }, models: [{ slug: "agent-model" }], diff --git a/packages/provider-acp-registry/src/server/driver.ts b/packages/provider-acp-registry/src/server/driver.ts index dd5a95cb85900..ef3e0c3e7416c 100644 --- a/packages/provider-acp-registry/src/server/driver.ts +++ b/packages/provider-acp-registry/src/server/driver.ts @@ -33,7 +33,10 @@ import { type ProviderDriver, type ProviderInstance, } from "@t3tools/provider-core/server/driver"; -import { providerModelsFromSettings } from "@t3tools/provider-core/server/snapshotProbe"; +import { + providerModelsFromSettings, + providerProbeEnvironment, +} from "@t3tools/provider-core/server/snapshotProbe"; import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; import { haveProviderSnapshotSettingsChanged, @@ -414,7 +417,7 @@ export const checkAcpRegistryProviderStatus = Effect.fn("AcpRegistryDriver.check instanceId: input.instanceId, settings: input.settings, cwd: input.cwd, - environment: input.environment, + environment: providerProbeEnvironment(input.environment), }), ); return Result.isFailure(probed) diff --git a/packages/provider-core/src/server/snapshotProbe.ts b/packages/provider-core/src/server/snapshotProbe.ts index 3303b9d6ff51a..8718231cd9679 100644 --- a/packages/provider-core/src/server/snapshotProbe.ts +++ b/packages/provider-core/src/server/snapshotProbe.ts @@ -103,6 +103,15 @@ export function isCommandMissingCause(error: unknown): boolean { return error instanceof PlatformError.PlatformError && error.reason._tag === "NotFound"; } +/** + * Environment for background status probes. A mise shim for a missing tool would otherwise + * download it on every refresh and leave a partial download behind when the probe times out. + * Sessions and user-started commands keep the plain environment, so mise still installs there. + */ +export function providerProbeEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return { ...environment, MISE_OFFLINE: "1" }; +} + export const spawnAndCollect = Effect.fn("snapshotProbe.spawnAndCollect")(function* ( binaryPath: string, command: ChildProcess.Command, @@ -136,6 +145,14 @@ export function parseGenericCliVersion(output: string): string | null { return match?.[1] ?? null; } +/** A failed `--version` run reports no version: its output can be a wrapper's, such as mise's. */ +export function parseCliVersionResult( + result: CommandResult, + parse: (output: string) => string | null = parseGenericCliVersion, +): string | null { + return result.code === 0 ? parse(`${result.stdout}\n${result.stderr}`) : null; +} + export function providerModelsFromSettings( builtInModels: ReadonlyArray, customModels: ReadonlyArray, diff --git a/packages/provider-fx/src/server/status.ts b/packages/provider-fx/src/server/status.ts index c71e909197df2..d2196dd9c787e 100644 --- a/packages/provider-fx/src/server/status.ts +++ b/packages/provider-fx/src/server/status.ts @@ -21,10 +21,11 @@ import { buildServerProvider, isCommandMissingCause, nonEmptyTrimmed, - parseGenericCliVersion, + parseCliVersionResult, type ProviderProbeResult, providerModelsFromSettings, type ServerProviderDraft, + providerProbeEnvironment, spawnAndCollect, } from "@t3tools/provider-core/server/snapshotProbe"; @@ -163,7 +164,8 @@ export const checkFxProviderStatus = Effect.fn("checkFxProviderStatus")(function }); } - const versionResult = yield* runFxCli(settings, ["--version"], environment).pipe( + const probeEnvironment = providerProbeEnvironment(environment); + const versionResult = yield* runFxCli(settings, ["--version"], probeEnvironment).pipe( Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), Effect.result, ); @@ -178,7 +180,7 @@ export const checkFxProviderStatus = Effect.fn("checkFxProviderStatus")(function }); } const versionOutput = versionResult.success.value; - const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + const version = parseCliVersionResult(versionOutput); if (versionOutput.code !== 0) { return snapshot(settings, checkedAt, { installed: true, @@ -190,7 +192,7 @@ export const checkFxProviderStatus = Effect.fn("checkFxProviderStatus")(function } const read = (args: ReadonlyArray) => - runFxCli(settings, args, environment).pipe( + runFxCli(settings, args, probeEnvironment).pipe( Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), Effect.map(Option.filter((output) => output.code === 0)), Effect.map(Option.map((output) => output.stdout)), diff --git a/packages/provider-grok/src/server/probeEnvironment.ts b/packages/provider-grok/src/server/probeEnvironment.ts index a7677d13c8f56..439d009dd9040 100644 --- a/packages/provider-grok/src/server/probeEnvironment.ts +++ b/packages/provider-grok/src/server/probeEnvironment.ts @@ -1,4 +1,6 @@ +import { providerProbeEnvironment } from "@t3tools/provider-core/server/snapshotProbe"; + /** Suppress probe-time updater consoles on Windows; preserve update settings for other invocations. */ export function grokProbeEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - return { ...environment, GROK_DISABLE_AUTOUPDATER: "1" }; + return { ...providerProbeEnvironment(environment), GROK_DISABLE_AUTOUPDATER: "1" }; } diff --git a/packages/provider-grok/src/server/skills.test.ts b/packages/provider-grok/src/server/skills.test.ts index d5344e74d8f1a..59e975eb503cb 100644 --- a/packages/provider-grok/src/server/skills.test.ts +++ b/packages/provider-grok/src/server/skills.test.ts @@ -192,6 +192,7 @@ describe("discoverGrokSkills", () => { expect(spawnOptions[0]?.env).toEqual({ GROK_DISABLE_AUTOUPDATER: "1", GROK_HOME: "/home/dev/.grok", + MISE_OFFLINE: "1", }); expect(environment.GROK_DISABLE_AUTOUPDATER).toBe("0"); }); diff --git a/packages/provider-grok/src/server/status.ts b/packages/provider-grok/src/server/status.ts index aa58c8babae7d..4b356c4f22610 100644 --- a/packages/provider-grok/src/server/status.ts +++ b/packages/provider-grok/src/server/status.ts @@ -28,7 +28,7 @@ import { buildServerProvider, COMPACT_SLASH_COMMAND, isCommandMissingCause, - parseGenericCliVersion, + parseCliVersionResult, providerModelsFromSettings, spawnAndCollect, type ServerProviderDraft, @@ -470,7 +470,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func } const versionOutput = versionResult.success.value; - const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + const version = parseCliVersionResult(versionOutput); if (versionOutput.code !== 0) { yield* Effect.logWarning("Grok CLI version probe exited with a non-zero status.", { exitCode: versionOutput.code, diff --git a/packages/provider-kiro/src/server/status.ts b/packages/provider-kiro/src/server/status.ts index 83de43df93a8e..93298551527d5 100644 --- a/packages/provider-kiro/src/server/status.ts +++ b/packages/provider-kiro/src/server/status.ts @@ -27,11 +27,12 @@ import { buildServerProvider, COMPACT_SLASH_COMMAND, isCommandMissingCause, - parseGenericCliVersion, + parseCliVersionResult, buildSelectOptionDescriptor, type ProviderProbeResult, providerModelsFromSettings, type ServerProviderDraft, + providerProbeEnvironment, spawnAndCollect, } from "@t3tools/provider-core/server/snapshotProbe"; @@ -373,7 +374,8 @@ export const checkKiroProviderStatus = Effect.fn("checkKiroProviderStatus")(func }); } - const versionResult = yield* runKiroCli(settings, ["--version"], environment).pipe( + const probeEnvironment = providerProbeEnvironment(environment); + const versionResult = yield* runKiroCli(settings, ["--version"], probeEnvironment).pipe( Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), Effect.result, ); @@ -390,7 +392,7 @@ export const checkKiroProviderStatus = Effect.fn("checkKiroProviderStatus")(func }); } const versionOutput = versionResult.success.value; - const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + const version = parseCliVersionResult(versionOutput); if (versionOutput.code !== 0) { return snapshot(settings, checkedAt, { installed: true, @@ -404,7 +406,7 @@ export const checkKiroProviderStatus = Effect.fn("checkKiroProviderStatus")(func const whoamiResult = yield* runKiroCli( settings, ["whoami", "--format", "json"], - environment, + probeEnvironment, ).pipe(Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), Effect.result); const auth = kiroAuthFromWhoami( Result.isSuccess(whoamiResult) ? Option.getOrUndefined(whoamiResult.success) : undefined, @@ -426,12 +428,16 @@ export const checkKiroProviderStatus = Effect.fn("checkKiroProviderStatus")(func auth.status === "authenticated" ? yield* Effect.all( [ - runKiroCli(settings, ["chat", "--list-models", "--format", "json"], environment).pipe( + runKiroCli( + settings, + ["chat", "--list-models", "--format", "json"], + probeEnvironment, + ).pipe( Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), Effect.map((output) => kiroModelsFromList(Option.getOrUndefined(output))), Effect.orElseSucceed(() => undefined), ), - probeKiroAgents(settings, environment).pipe( + probeKiroAgents(settings, probeEnvironment).pipe( Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), Effect.map(Option.getOrUndefined), Effect.orElseSucceed(() => undefined), diff --git a/packages/provider-muse/src/server/status.ts b/packages/provider-muse/src/server/status.ts index bcd910b3573b7..57d24d5f2ad78 100644 --- a/packages/provider-muse/src/server/status.ts +++ b/packages/provider-muse/src/server/status.ts @@ -15,7 +15,9 @@ import { COMPACT_SLASH_COMMAND, DEFAULT_TIMEOUT_MS, isCommandMissingCause, + parseCliVersionResult, providerModelsFromSettings, + providerProbeEnvironment, spawnAndCollect, type ProviderProbeResult, } from "@t3tools/provider-core/server/snapshotProbe"; @@ -59,7 +61,7 @@ export const checkMuseProviderStatus = Effect.fn("checkMuseProviderStatus")(func ) { if (!settings.enabled) return yield* makePendingMuseProvider(settings); const checkedAt = DateTime.formatIso(yield* DateTime.now); - const museEnvironment = environment ?? makeMuseEnvironment(); + const museEnvironment = providerProbeEnvironment(environment ?? makeMuseEnvironment()); const snapshot = (probe: ProviderProbeResult, models: ReadonlyArray = []) => buildServerProvider({ presentation: MUSE_PRESENTATION, @@ -105,7 +107,7 @@ export const checkMuseProviderStatus = Effect.fn("checkMuseProviderStatus")(func }); } const versionOutput = versionResult.success.value; - const version = parseMuseVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + const version = parseCliVersionResult(versionOutput, parseMuseVersion); if (versionOutput.code !== 0) { return snapshot({ installed: true, diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index 665f4785459d9..d40a732c3b815 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -157,6 +157,20 @@ it.live("discovers models through a home-relative OpenCode wrapper", () => ), ); +// Under MISE_OFFLINE the shim cannot install OpenCode; a normal run installs 2.x first. +const lazyMiseShim = { + ...openCode2Runtime, + runOpenCodeCommand: ({ environment }: { readonly environment?: NodeJS.ProcessEnv }) => + environment?.MISE_OFFLINE === "1" + ? Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "runOpenCodeCommand", + detail: "mise ERROR Failed to install opencode@latest", + }), + ) + : Effect.succeed({ stdout: "opencode v2.0.18\n", stderr: "", code: 0 }), +} as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"]; + it.layer(layer)("OpenCodeDriver runtime selection", (it) => { it.effect("never starts a 1.x server for an OpenCode 2 instance", () => Effect.gen(function* () { @@ -173,6 +187,41 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { }).pipe(Effect.scoped), ); + it.effect("opens a session through OpenCode 2 that a lazy mise shim installs on first use", () => + Effect.gen(function* () { + serverStarts.length = 0; + const instance = yield* create({}, noHttp).pipe( + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, lazyMiseShim), + // The background status check probes offline, which this shim always fails. + Effect.provide(TestProviderHost.layer({ runBackgroundWork: false })), + ); + + yield* instance.orchestrationAdapter + .openSession({ + threadId: ThreadId.make("thread-lazy-mise"), + providerSessionId: ProviderSessionId.make("session-lazy-mise"), + modelSelection: { instanceId: instance.instanceId, model: "opencode/big-pickle" }, + runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, + }) + .pipe(Effect.scoped, Effect.flip); + assert.deepStrictEqual(serverStarts, ["start"], "the OpenCode 2 adapter starts its server"); + const capabilities = yield* instance.orchestrationAdapter.getCapabilities(); + assert.isTrue(capabilities.sessions.supportsRuntimeModeSwitchInSession); + }).pipe(Effect.scoped), + ); + + it.effect("does not scan a workspace through a 1.x server it could not probe offline", () => + Effect.gen(function* () { + serverStarts.length = 0; + const instance = yield* create({}, noHttp).pipe( + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, lazyMiseShim), + ); + + yield* Effect.flip(instance.snapshotForCwd!(process.cwd())); + assert.deepStrictEqual(serverStarts, []); + }).pipe(Effect.scoped), + ); + it.effect("answers capability reads without waiting on an unreachable server", () => Effect.gen(function* () { const hang = HttpClient.make(() => Effect.never); diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 0da1a04ff1fd7..4599c4203993d 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -53,6 +53,7 @@ import { } from "@t3tools/provider-core/server/driver"; import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { providerProbeEnvironment } from "@t3tools/provider-core/server/snapshotProbe"; import { enrichProviderSnapshotWithVersionAdvisory, makeCachedProviderMaintenanceResolution, @@ -109,6 +110,7 @@ function byOpenCodeRuntime( // Hot reads use the last successful probe; session opens await it to select the correct protocol. function selectOpenCodeRuntimeAdapter(input: { readonly probe: OpenCodeRuntimeProbe; + readonly resolve: Effect.Effect; readonly v1: ProviderAdapter.ProviderAdapterV2["Service"]; readonly v2: ProviderAdapter.ProviderAdapterV2["Service"]; }): ProviderAdapter.ProviderAdapterV2["Service"] { @@ -122,43 +124,43 @@ function selectOpenCodeRuntimeAdapter(input: { planSelectionTransition: (transition) => Effect.flatMap(hot, (adapter) => adapter.planSelectionTransition(transition)), openSession: (session) => - Effect.flatMap(pick(input.probe.get), (adapter) => adapter.openSession(session)), + Effect.flatMap(pick(input.resolve), (adapter) => adapter.openSession(session)), }; } function selectOpenCodeRuntimeTextGeneration( - probe: OpenCodeRuntimeProbe, + resolve: Effect.Effect, v1: ProviderTextGeneration, v2: ProviderTextGeneration, ): ProviderTextGeneration { return { generateCommitMessage: (input) => - byOpenCodeRuntime(probe.get, { + byOpenCodeRuntime(resolve, { v1: v1.generateCommitMessage(input), v2: v2.generateCommitMessage(input), }), generatePrContent: (input) => - byOpenCodeRuntime(probe.get, { + byOpenCodeRuntime(resolve, { v1: v1.generatePrContent(input), v2: v2.generatePrContent(input), }), generateBranchName: (input) => - byOpenCodeRuntime(probe.get, { + byOpenCodeRuntime(resolve, { v1: v1.generateBranchName(input), v2: v2.generateBranchName(input), }), generateThreadTitle: (input) => - byOpenCodeRuntime(probe.get, { + byOpenCodeRuntime(resolve, { v1: v1.generateThreadTitle(input), v2: v2.generateThreadTitle(input), }), generateReviewGuide: (input) => - byOpenCodeRuntime(probe.get, { + byOpenCodeRuntime(resolve, { v1: v1.generateReviewGuide(input), v2: v2.generateReviewGuide(input), }), generateTranscriptionPostProcessing: (input) => - byOpenCodeRuntime(probe.get, { + byOpenCodeRuntime(resolve, { v1: v1.generateTranscriptionPostProcessing(input), v2: v2.generateTranscriptionPostProcessing(input), }), @@ -225,12 +227,27 @@ export const OpenCodeDriver: ProviderDriver< // The instance registry has already read a referenced password from its secret provider. serverPassword: settingsSecretLiteral(config.serverPassword), } satisfies OpenCodeSettings; - const runtimeProbe = yield* makeOpenCodeRuntimeProbe( - probeOpenCodeRuntime(effectiveConfig, processEnv).pipe( + const probeRuntime = (probeEnv: NodeJS.ProcessEnv) => + probeOpenCodeRuntime(effectiveConfig, probeEnv).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), - ), + ); + const runtimeProbe = yield* makeOpenCodeRuntimeProbe( + probeRuntime(providerProbeEnvironment(processEnv)), ); + // A mise shim cannot install OpenCode offline; a session or text generation the user started + // may, so without a cached result it probes once online and caches what mise installed. + const resolveRuntime = + effectiveConfig.serverUrl.trim().length > 0 + ? runtimeProbe.get + : runtimeProbe.lastSuccess.pipe( + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => probeRuntime(processEnv).pipe(Effect.tap(runtimeProbe.record)), + }), + ), + ); const noUpdate = makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER_KIND, packageName: null, @@ -296,6 +313,7 @@ export const OpenCodeDriver: ProviderDriver< ); const orchestrationAdapter = selectOpenCodeRuntimeAdapter({ probe: runtimeProbe, + resolve: resolveRuntime, v1: openCodeV1Adapter, v2: yield* OpenCode2AdapterV2.make(instanceId).pipe( Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), @@ -344,7 +362,7 @@ export const OpenCodeDriver: ProviderDriver< environment: processEnv, }); const textGeneration = selectOpenCodeRuntimeTextGeneration( - runtimeProbe, + resolveRuntime, yield* makeOpenCodeTextGeneration(effectiveConfig).pipe( Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), ), @@ -497,55 +515,70 @@ export const OpenCodeDriver: ProviderDriver< accentColor, enabled, snapshot, + // A scan never installs OpenCode: without an offline probe result it fails and keeps the + // last inventory rather than guessing 1.x. snapshotForCwd: (cwd) => !effectiveConfig.enabled ? snapshot.getSnapshot - : byOpenCodeRuntime(runtimeProbe.get, { - v2: Effect.all([ - snapshot.getSnapshot, - listOpenCode2Workspace(cwd).pipe( - Effect.timeout("20 seconds"), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to list OpenCode commands and skills for '${cwd}'`, - cause, - }), - ), - ), - ]).pipe( - Effect.map(([machineSnapshot, { skills, commands }]) => ({ - ...machineSnapshot, - skills: openCode2SkillsToServerProviderSkills(skills), - slashCommands: openCode2CommandsToServerProviderSlashCommands(commands), - // A finished scan lists at least the built-ins. None means it - // timed out, so the registry decides from the provider's health. - slashCommandsPending: commands.length > 0 ? false : undefined, - })), + : runtimeProbe.get.pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to probe OpenCode for '${cwd}'`, + cause, + }), ), - v1: Effect.all([ - snapshot.getSnapshot, - loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")), - ]).pipe( - Effect.map(([machineSnapshot, { skills, commands, slashCommandsPending }]) => ({ - ...machineSnapshot, - skills: openCodeSkillsToServerProviderSkills(skills), - slashCommands: openCodeCommandsToServerProviderSlashCommands(commands), - slashCommandsPending, - })), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to probe OpenCode commands and skills for '${cwd}'`, - cause, - }), - ), + Effect.flatMap((probed) => + byOpenCodeRuntime(Effect.succeed(probed), { + v2: Effect.all([ + snapshot.getSnapshot, + listOpenCode2Workspace(cwd).pipe( + Effect.timeout("20 seconds"), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to list OpenCode commands and skills for '${cwd}'`, + cause, + }), + ), + ), + ]).pipe( + Effect.map(([machineSnapshot, { skills, commands }]) => ({ + ...machineSnapshot, + skills: openCode2SkillsToServerProviderSkills(skills), + slashCommands: openCode2CommandsToServerProviderSlashCommands(commands), + // A finished scan lists at least the built-ins. None means it + // timed out, so the registry decides from the provider's health. + slashCommandsPending: commands.length > 0 ? false : undefined, + })), + ), + v1: Effect.all([ + snapshot.getSnapshot, + loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")), + ]).pipe( + Effect.map(([machineSnapshot, { skills, commands, slashCommandsPending }]) => ({ + ...machineSnapshot, + skills: openCodeSkillsToServerProviderSkills(skills), + slashCommands: openCodeCommandsToServerProviderSlashCommands(commands), + slashCommandsPending, + })), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to probe OpenCode commands and skills for '${cwd}'`, + cause, + }), + ), + ), + }), ), - }), + ), orchestrationAdapter, textGeneration, } satisfies ProviderInstance; diff --git a/packages/provider-opencode/src/server/versionProbe.ts b/packages/provider-opencode/src/server/versionProbe.ts index ab40142de845a..7918958880175 100644 --- a/packages/provider-opencode/src/server/versionProbe.ts +++ b/packages/provider-opencode/src/server/versionProbe.ts @@ -185,5 +185,6 @@ export const makeOpenCodeRuntimeProbe = (probe: Effect.Effect Cache.set(cache, undefined, probed), }), ); diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index e70bd1ad804ec..5dfce10b81060 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -31,8 +31,9 @@ import { buildServerProvider, DEFAULT_TIMEOUT_MS, isCommandMissingCause, - parseGenericCliVersion, + parseCliVersionResult, providerModelsFromSettings, + providerProbeEnvironment, spawnAndCollect, type ServerProviderDraft, } from "@t3tools/provider-core/server/snapshotProbe"; @@ -204,7 +205,7 @@ export const discoverPiCommandsForCwd = Effect.fn("discoverPiCommandsForCwd")( } const connection = yield* makePiDiscoveryConnection( piSettings, - environment, + providerProbeEnvironment(environment), launchArgs.args, cwd, ); @@ -349,7 +350,8 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function }); } - const versionResult = yield* runPiVersionCommand(piSettings, environment).pipe( + const probeEnvironment = providerProbeEnvironment(environment); + const versionResult = yield* runPiVersionCommand(piSettings, probeEnvironment).pipe( Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), Effect.result, ); @@ -391,7 +393,7 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function } const versionOutput = versionResult.success.value; - const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + const version = parseCliVersionResult(versionOutput); if (versionOutput.code !== 0) { return buildServerProvider({ presentation: PI_PRESENTATION, @@ -459,7 +461,7 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function const discoveryExit = yield* discoverPiViaRpc( piSettings, - environment, + probeEnvironment, resolvedLaunchArgs.args, cwd, ).pipe(Effect.timeoutOption(PI_RPC_DISCOVERY_TIMEOUT_MS), Effect.exit); @@ -507,7 +509,7 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function ? undefined : yield* describeUnavailablePiModel( piSettings, - environment, + probeEnvironment, version, resolvedLaunchArgs.args, discovery.unavailableActiveModel, From b37c7f6fa41e36f259ce3b8ae7821e0a3a22793c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 00:03:25 -0800 Subject: [PATCH 0013/1253] fix(forgejo): find merged pull requests whose head branch was deleted (#33675) --- .../ForgejoSourceControlProvider.test.ts | 71 +++++++++++++++++++ .../server/ForgejoSourceControlProvider.ts | 4 +- .../src/server/forgejoPullRequestJson.test.ts | 43 +++++++++++ .../src/server/forgejoPullRequestJson.ts | 5 +- .../src/server/forgejoPullRequests.ts | 19 ++++- 5 files changed, 138 insertions(+), 4 deletions(-) diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index fbfe278b32ba4..cd5e9fb2832d7 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -961,6 +961,77 @@ it.effect("loads Forgejo pull request references from files and commits views", ), ); +it.effect("finds a merged Forgejo pull request whose head branch was deleted", () => + Effect.gen(function* () { + const provider = yield* ForgejoSourceControlProvider.make; + const found = yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature", + state: "merged", + }); + assert.deepStrictEqual( + found.map((pr) => [pr.number, pr.headRefName]), + [[7, "feature"]], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(FileSystem.FileSystem, FileSystem.makeNoop({})), + TestSourceControlHost.layer({ process: {} }), + Layer.mock(ForgejoCli.ForgejoCli)({ + resolveRepository: () => + Effect.succeed({ + login: "work", + repository: "maria/project", + baseUrl: "https://forgejo.test", + }), + api: (input) => { + const repo = { full_name: "maria/project", owner: { login: "maria" } }; + const page = new URLSearchParams(input.path.split("?")[1]).get("page"); + return encodeJsonEffect( + page === "1" + ? [ + { + number: 7, + title: "Merged feature", + html_url: "https://forgejo.test/maria/project/pulls/7", + state: "closed", + merged: true, + merged_at: "2026-10-12T00:00:00Z", + base: { ref: "main", label: "main", sha: "base", repo }, + head: { ref: "refs/pull/7/head", label: "feature", sha: "head", repo }, + }, + { + number: 8, + title: "Merged from a deleted fork", + html_url: "https://forgejo.test/maria/project/pulls/8", + state: "closed", + merged: true, + merged_at: "2026-10-11T00:00:00Z", + base: { ref: "main", label: "main", sha: "base", repo }, + head: { ref: "refs/pull/8/head", label: "feature", sha: "fork", repo: null }, + }, + { + number: 9, + title: "AGit topic", + html_url: "https://forgejo.test/maria/project/pulls/9", + state: "closed", + merged: true, + merged_at: "2026-10-10T00:00:00Z", + flow: 1, + base: { ref: "main", label: "main", sha: "base", repo }, + head: { ref: "refs/pull/9/head", label: "feature", sha: "topic", repo }, + }, + ] + : [], + ).pipe(Effect.orDie, Effect.map(processOutput)); + }, + }), + ), + ), + ), +); + it.effect( "loads Forgejo reactions on comments, reviews and inline threads and resolves review mutations", () => { diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts index 155d9734bb915..77a5a198b0f5a 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts @@ -343,13 +343,13 @@ export const make = Effect.gen(function* () { Schema.Array(ForgejoPullRequestSchema), ); for (const item of items) { + const normalized = toForgejoChangeRequest(item); if ( - item.head.ref !== branch || + normalized.headRefName !== branch || (source?.repository && item.head.repo?.full_name !== source.repository) || (source?.owner && item.head.repo?.owner.login !== source.owner) ) continue; - const normalized = toForgejoChangeRequest(item); if (input.state === "all" || normalized.state === input.state) results.push(normalized); } if (items.length === 0) break; diff --git a/packages/source-control-forgejo/src/server/forgejoPullRequestJson.test.ts b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.test.ts index 4a5299ec47af2..63635459baaa9 100644 --- a/packages/source-control-forgejo/src/server/forgejoPullRequestJson.test.ts +++ b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.test.ts @@ -3,12 +3,15 @@ import * as Schema from "effect/Schema"; import { ForgejoComment, + ForgejoPullRequest, ForgejoReview, + forgejoChangeRequest, forgejoComment, forgejoReview, } from "./forgejoPullRequestJson.ts"; const decodeForgejoComment = Schema.decodeSync(ForgejoComment); +const decodeForgejoPullRequest = Schema.decodeSync(ForgejoPullRequest); const decodeForgejoReview = Schema.decodeSync(ForgejoReview); describe("forgejoComment", () => { @@ -41,6 +44,46 @@ describe("forgejoComment", () => { }); }); +describe("forgejoChangeRequest", () => { + const headBranch = ( + head: { ref: string; label?: string; repo: { full_name: string } | null }, + flow?: number, + ) => + forgejoChangeRequest( + decodeForgejoPullRequest({ + number: 7, + title: "Merged feature", + body: null, + html_url: "https://forgejo.test/maria/project/pulls/7", + user: null, + state: "closed", + merged: true, + ...(flow === undefined ? {} : { flow }), + head: { ...head, sha: "head" }, + base: { ref: "main", label: "main", sha: "base", repo: null }, + created_at: "2026-10-10T19:52:40Z", + updated_at: "2026-10-12T00:00:00Z", + closed_at: "2026-10-12T00:00:00Z", + merged_at: "2026-10-12T00:00:00Z", + labels: null, + }), + ).headBranch; + + it("reads a deleted head branch from its label", () => { + const head = { ref: "refs/pull/7/head", label: "feature" }; + const repo = { full_name: "maria/project" }; + assert.strictEqual(headBranch({ ...head, repo }, 0), "feature"); + // A deleted fork leaves no repository to say whose `feature` that was. + assert.strictEqual(headBranch({ ...head, repo: null }), "refs/pull/7/head"); + // An AGit label is a `user/topic` name, not a branch. + assert.strictEqual(headBranch({ ...head, label: "alice/topic", repo }, 1), "refs/pull/7/head"); + }); + + it("keeps the ref when the server sends no label", () => { + assert.strictEqual(headBranch({ ref: "feature", repo: null }), "feature"); + }); +}); + describe("forgejoReview", () => { it.each([ ["REQUEST_CHANGES", false, "CHANGES_REQUESTED"], diff --git a/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts index 7a7daeac64f00..d06518a0674d1 100644 --- a/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts +++ b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts @@ -12,6 +12,7 @@ import type { } from "@t3tools/contracts"; import type { ProviderChangeRequest } from "@t3tools/source-control-core/server/PullRequestProvider"; import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks"; +import { forgejoHeadBranch } from "./forgejoPullRequests.ts"; export const ForgejoUser = Schema.Struct({ login: Schema.String, @@ -36,6 +37,7 @@ export const ForgejoRepository = Schema.Struct({ }); const Branch = Schema.Struct({ ref: Schema.String, + label: Schema.optional(Schema.String), sha: Schema.String, repo: Schema.NullOr(ForgejoRepository), }); @@ -47,6 +49,7 @@ export const ForgejoPullRequest = Schema.Struct({ user: Schema.NullOr(ForgejoUser), state: Schema.String, draft: Schema.optional(Schema.Boolean), + flow: Schema.optional(Schema.Int), merged: Schema.Boolean, mergeable: Schema.optional(Schema.Boolean), is_locked: Schema.optional(Schema.Boolean), @@ -130,7 +133,7 @@ export function forgejoChangeRequest(pr: typeof ForgejoPullRequest.Type) { title: pr.title, url: pr.html_url, author: forgejoActor(pr.user), - headBranch: pr.head.ref, + headBranch: forgejoHeadBranch(pr), baseBranch: pr.base.ref, headRepositoryNameWithOwner: pr.head.repo?.full_name ?? null, state: pr.merged ? "merged" : pr.state === "closed" ? "closed" : "open", diff --git a/packages/source-control-forgejo/src/server/forgejoPullRequests.ts b/packages/source-control-forgejo/src/server/forgejoPullRequests.ts index 57e2c316df1db..522ae4849100e 100644 --- a/packages/source-control-forgejo/src/server/forgejoPullRequests.ts +++ b/packages/source-control-forgejo/src/server/forgejoPullRequests.ts @@ -9,9 +9,25 @@ const Repository = Schema.Struct({ }); const Branch = Schema.Struct({ ref: Schema.String, + label: Schema.optional(Schema.String), sha: Schema.String, repo: Schema.NullOr(Repository), }); +// Once a pull request's head branch is deleted, Forgejo reports `ref` as `refs/pull//head` +// and keeps the branch name only in `label`. An AGit pull request (`flow` 1) has no branch, only a +// `user/topic` label, and without `repo` (a deleted fork) the label could name any repository's +// branch, so the ref stays in both cases. +export function forgejoHeadBranch(pr: { + readonly flow?: number | undefined; + readonly head: { + readonly ref: string; + readonly label?: string | undefined; + readonly repo: unknown; + }; +}) { + const { ref, label, repo } = pr.head; + return ref.startsWith("refs/pull/") && label && repo !== null && pr.flow !== 1 ? label : ref; +} export const ForgejoPullRequestSchema = Schema.Struct({ number: Schema.Int, title: Schema.String, @@ -19,6 +35,7 @@ export const ForgejoPullRequestSchema = Schema.Struct({ state: Schema.String, merged: Schema.Boolean, draft: Schema.optional(Schema.Boolean), + flow: Schema.optional(Schema.Int), base: Branch, head: Branch, closed_at: Schema.optional(Schema.NullOr(Schema.String)), @@ -34,7 +51,7 @@ export function toForgejoChangeRequest(raw: typeof ForgejoPullRequestSchema.Type state: raw.merged ? "merged" : raw.state === "closed" ? "closed" : "open", isDraft: raw.draft ?? /^(?:\[WIP\]|WIP:)/i.test(raw.title), baseRefName: raw.base.ref, - headRefName: raw.head.ref, + headRefName: forgejoHeadBranch(raw), ...(raw.head.sha.trim().length > 0 ? { headSha: raw.head.sha.trim() } : {}), closedAt: raw.closed_at ?? null, mergedAt: raw.merged_at ?? null, From 65ffcbf647e706dcb4c7d9834724c74ff4b8dabb Mon Sep 17 00:00:00 2001 From: Akshar Patel <123344143+AksharP5@users.noreply.github.com> Date: Tue, 10 Nov 2026 03:03:39 -0500 Subject: [PATCH 0014/1253] perf(shared): speed up escaped Markdown in long PR descriptions (#33438) Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- .../shared/src/markdownWindowsPaths.test.ts | 77 +++++++++++++++++++ packages/shared/src/markdownWindowsPaths.ts | 16 +++- 2 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 packages/shared/src/markdownWindowsPaths.test.ts diff --git a/packages/shared/src/markdownWindowsPaths.test.ts b/packages/shared/src/markdownWindowsPaths.test.ts new file mode 100644 index 0000000000000..58d4c7ed46303 --- /dev/null +++ b/packages/shared/src/markdownWindowsPaths.test.ts @@ -0,0 +1,77 @@ +import { expect, it, vi } from "vite-plus/test"; +import { unified } from "unified"; +import remarkParse from "remark-parse"; + +import { remarkKeepWindowsPaths, remarkKeepWindowsProsePaths } from "./markdownWindowsPaths.ts"; + +const plugins = [ + ["paths and destinations", remarkKeepWindowsPaths], + ["prose paths", remarkKeepWindowsProsePaths], +] as const; + +it.each(plugins)("%s limits drive-path checks to the word containing each escape", (_, plugin) => { + const processor = unified().use(remarkParse).use(plugin); + const identifiers = Array.from({ length: 200 }, (_, index) => `metric_name_${index}`); + const markdown = `Supported metrics: ${identifiers.map((name) => name.replaceAll("_", "\\_")).join(", ")}.`; + const pattern = /(?:^|\W)[A-Za-z]:(?:[\\/]\S*)?$/.source; + const test = RegExp.prototype.test; + let inspectedCharacters = 0; + using _test = vi.spyOn(RegExp.prototype, "test").mockImplementation(function ( + this: RegExp, + value: string, + ) { + if (this.source === pattern) inspectedCharacters += value.length; + return test.call(this, value); + }); + + expect(processor.parse(markdown)).toMatchObject({ + children: [ + { + type: "paragraph", + children: [{ type: "text", value: `Supported metrics: ${identifiers.join(", ")}.` }], + }, + ], + }); + expect(inspectedCharacters).toBeGreaterThan(0); + expect(inspectedCharacters).toBeLessThan(markdown.length * 2); +}); + +it.each(plugins)( + "%s preserves prose paths and ordinary escapes across whitespace boundaries", + (_, plugin) => { + const processor = unified().use(remarkParse).use(plugin); + for (const whitespace of [" ", "\t", "\u00a0", "\u2003", "\u202f", "\ufeff"]) { + const markdown = String.raw`Before${whitespace}C:\Users\me\_build${whitespace}\*literal\*`; + expect(processor.parse(markdown)).toMatchObject({ + children: [ + { + type: "paragraph", + children: [ + { + type: "text", + value: String.raw`Before${whitespace}C:\Users\me\_build${whitespace}*literal*`, + }, + ], + }, + ], + }); + } + }, +); + +it.each(plugins)("%s finds each escape's word after earlier escapes in the text", (_, plugin) => { + const processor = unified().use(remarkParse).use(plugin); + const markdown = String.raw`one\_two C:\a\_b \*x\* three\_four D:\c\_d \*y\* *em*E:\e\_f \*z\*`; + expect(processor.parse(markdown)).toMatchObject({ + children: [ + { + type: "paragraph", + children: [ + { type: "text", value: String.raw`one_two C:\a\_b *x* three_four D:\c\_d *y* ` }, + { type: "emphasis" }, + { type: "text", value: String.raw`E:\e\_f *z*` }, + ], + }, + ], + }); +}); diff --git a/packages/shared/src/markdownWindowsPaths.ts b/packages/shared/src/markdownWindowsPaths.ts index a1569eb1f8358..d47932a8f6ef8 100644 --- a/packages/shared/src/markdownWindowsPaths.ts +++ b/packages/shared/src/markdownWindowsPaths.ts @@ -26,13 +26,23 @@ interface EscapeCompileContext { }>; } +const WHITESPACE = /\s/g; +// Text nodes only grow by appending while parsing, so each one is scanned for whitespace once. +const wordScans = new WeakMap(); + function keepWindowsPathEscape(this: EscapeCompileContext) { const text = this.stack.at(-1)?.children?.at(-1); if (text?.type !== "text" || text.value === undefined) return; - const before = text.value.slice(0, -1); - if (UNFINISHED_DRIVE_PATH.test(before)) { - text.value = `${before}\\${text.value.slice(-1)}`; + let scan = wordScans.get(text); + if (!scan) wordScans.set(text, (scan = { scannedTo: 0, wordStart: 0 })); + WHITESPACE.lastIndex = scan.scannedTo; + while (WHITESPACE.exec(text.value)) scan.wordStart = WHITESPACE.lastIndex; + // A prose path cannot cross whitespace, so preceding words cannot affect this escape. + const end = text.value.length - 1; + if (UNFINISHED_DRIVE_PATH.test(text.value.slice(scan.wordStart, end))) { + text.value = `${text.value.slice(0, end)}\\${text.value.slice(end)}`; } + scan.scannedTo = text.value.length; } function keepWindowsProsePathEscape(this: EscapeCompileContext) { From cec8d73a1f0fd3bd9a48df88640436e73a04afa3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 00:03:53 -0800 Subject: [PATCH 0015/1253] fix(server): a background agent tab survives a desktop window reload (#33666) Co-authored-by: Claude Opus 5.5 --- apps/server/src/preview/Manager.ts | 27 ++++++ apps/server/src/preview/ServerBrowser.test.ts | 87 ++++++++++++++++++- apps/server/src/preview/ServerBrowser.ts | 15 +++- 3 files changed, 124 insertions(+), 5 deletions(-) diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index f5db1d2fefc1e..ff8442306f2ff 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -46,6 +46,10 @@ export class PreviewManager extends Context.Service< readonly markHeadless: ( input: PreviewCloseInput & { readonly tabId: string }, ) => Effect.Effect; + /** Publishes the tab's unchanged snapshot again, for clients that dropped the tab. */ + readonly announce: ( + input: PreviewCloseInput & { readonly tabId: string }, + ) => Effect.Effect; readonly requestReveal: ( input: PreviewCloseInput & { readonly tabId: string; readonly force: boolean }, ) => Effect.Effect; @@ -298,6 +302,28 @@ export const make = Effect.gen(function* PreviewManagerMake() { ); }); + const announce: PreviewManager["Service"]["announce"] = Effect.fn("PreviewManager.announce")( + function* (input) { + return yield* mutateExistingSession( + input.threadId, + input.tabId, + Effect.fn("PreviewManager.announceSession")(function* (session) { + return { + next: session, + emit: { + type: "resized", + threadId: session.threadId, + tabId: session.tabId, + createdAt: yield* currentIsoTimestamp, + snapshot: session.snapshot, + }, + result: undefined, + }; + }), + ); + }, + ); + const resize: PreviewManager["Service"]["resize"] = Effect.fn("PreviewManager.resize")( function* (input) { return yield* mutateExistingSession( @@ -452,6 +478,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { requestReveal, reportStatus, markHeadless, + announce, resize, adjust, close, diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 97d75c2c4ecb0..c32947b30b995 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -321,6 +321,9 @@ const desktopRenders = (tabId: string) => { } return desktopTabs.has(tabId); }; +/** When set, a desktop that has not attached a tab waits for it the way the real channel does. */ +let desktopAttachesLate = false; +let desktopAttachWaiting = Promise.withResolvers(); const shownDesktopTabs = new Set(); const releasedDesktopTabs: Array = []; const desktopTurns = new NodeEvents.EventEmitter(); @@ -355,7 +358,30 @@ const dependencies = Layer.mergeAll( }), Layer.succeed(DesktopChannel.DesktopBrowserChannel, { available: true, - awaitAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), + awaitAttached: (key, timeout) => + !desktopAttachesLate || desktopRenders(key.tabId) + ? Effect.sync(() => desktopRenders(key.tabId)) + : Effect.raceFirst( + Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const onAttach = (attached: { tabId: string }) => + Queue.offerUnsafe(queue, attached.tabId); + desktopDetaches.on("attach", onAttach); + return onAttach; + }), + (onAttach) => Effect.sync(() => desktopDetaches.off("attach", onAttach)), + ), + ).pipe( + Stream.filter((tabId) => tabId === key.tabId), + Stream.runHead, + Effect.as(true), + ), + Effect.sync(() => desktopAttachWaiting.resolve()).pipe( + Effect.andThen(Effect.sleep(timeout)), + Effect.as(false), + ), + ), detached: Stream.callback<{ threadId: string; tabId: string }>((queue) => Effect.acquireRelease( Effect.sync(() => { @@ -460,6 +486,8 @@ beforeEach(() => { desktopTabs.clear(); shownDesktopTabs.clear(); desktopRendersNext = false; + desktopAttachesLate = false; + desktopAttachWaiting = Promise.withResolvers(); desktopPageUrl = "about:blank"; desktopSizeAnswer = null; desktopDetachReady = Promise.withResolvers(); @@ -627,6 +655,63 @@ it.effect("a tab the desktop does not draw is marked headless, so the desktop st ).pipe(Effect.provide(layer)), ); +it.effect("an agent's tab the reloaded desktop dropped goes back to the desktop", () => + Effect.scoped( + Effect.gen(function* () { + const { broker } = yield* ready; + const manager = yield* Manager.PreviewManager; + const threadId = scope.thread.threadId; + desktopRendersNext = true; + const opened = yield* broker.invoke({ + scope, + operation: "open", + input: { url: "https://example.com/?t=1", reuseExistingTab: false, open: false }, + }); + const tabId = PreviewTabId.make(opened.tabId!); + // The renderer reloads on another thread: its webview goes, and it only mounts this + // thread's tabs again once a preview event for the thread reaches it. + yield* Effect.promise(() => desktopDetachReady.promise); + desktopAttachesLate = true; + desktopTabs.delete(tabId); + desktopDetaches.emit("detach", { threadId, tabId }); + yield* Effect.promise(() => desktopReleased.promise); + // It is still reloading when the agent's call arrives, so it misses the first event. + yield* Effect.gen(function* () { + yield* Effect.promise(() => desktopAttachWaiting.promise); + const events = yield* manager.subscribeEvents; + const nextForThread = Effect.gen(function* () { + let event = yield* PubSub.take(events); + while (event.threadId !== threadId) event = yield* PubSub.take(events); + }); + yield* nextForThread; + yield* nextForThread; + desktopTabs.add(tabId); + desktopDetaches.emit("attach", { threadId, tabId }); + }).pipe(Effect.forkScoped); + + const evaluating = yield* broker + .invoke({ + scope, + tabId, + operation: "evaluate", + input: { expression: "navigator.userAgent" }, + }) + .pipe(Effect.forkScoped); + yield* Effect.raceFirst( + Fiber.join(evaluating), + Effect.promise(() => desktopAttachWaiting.promise).pipe( + Effect.andThen(TestClock.adjust("10 seconds")), + Effect.andThen(Fiber.join(evaluating)), + ), + ); + const { sessions } = yield* manager.list({ threadId }); + + expect(sessions.find((session) => session.tabId === tabId)?.headless).toBeUndefined(); + expect(desktopConnections).toHaveLength(2); + }), + ).pipe(Effect.provide(layer)), +); + it.effect("a desktop page that loaded before the server attached reports its loaded title", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 2f681d0c151c9..ee31b17cc5889 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -766,10 +766,17 @@ const make = Effect.gen(function* () { const desktopRenders = (snapshot: PreviewSessionSnapshot) => desktopChannel.available && !snapshot.headless ? runPromise( - desktopChannel.awaitAttached( - { threadId: snapshot.threadId, tabId: snapshot.tabId }, - DESKTOP_ATTACH_TIMEOUT, - ), + Effect.gen(function* () { + const key = { threadId: snapshot.threadId, tabId: snapshot.tabId }; + // A reloaded desktop mounts a thread's tabs again only once an event for it arrives, + // and a renderer still reloading misses events, so repeat it until the wait ends. + if (!(yield* desktopChannel.isAttached(key))) { + yield* manager + .announce({ threadId: ThreadId.make(key.threadId), tabId: key.tabId }) + .pipe(Effect.ignore, Effect.repeat(Schedule.spaced("1 second")), Effect.forkScoped); + } + return yield* desktopChannel.awaitAttached(key, DESKTOP_ATTACH_TIMEOUT); + }).pipe(Effect.scoped), ) : Promise.resolve(false); From ad91636f0b48fe41119c888ebe937acb5167f0ee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 00:18:17 -0800 Subject: [PATCH 0016/1253] fix(server): Commit, push & PR no longer pushes the default branch into itself (#33695) Co-authored-by: Claude Opus 5.5 --- apps/server/src/git/GitManager.test.ts | 187 ++++++++++++++++++++++++ apps/server/src/git/GitManager.ts | 37 +++++ apps/server/src/vcs/GitVcsDriver.ts | 10 ++ apps/server/src/vcs/GitVcsDriverCore.ts | 170 +++++++++++---------- 4 files changed, 329 insertions(+), 75 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 634898612b077..72b3504807c93 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3503,6 +3503,125 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect.each([ + { localBranch: "develop" }, + // Pushes to origin's develop under a name that differs from the base. + { localBranch: "origin/develop" }, + ] as const)( + "commit_push_pr refuses to commit or push on the default branch when origin/HEAD is missing ($localBranch)", + ({ localBranch }) => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["branch", "-m", "main", "develop"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "develop"]); + if (localBranch !== "develop") yield* runGit(repoDir, ["checkout", "-b", localBranch]); + const readRemoteDevelop = runGit(repoDir, [ + "ls-remote", + "origin", + "refs/heads/develop", + ]).pipe(Effect.map((output) => output.stdout)); + const remoteBefore = yield* readRemoteDevelop; + const localBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout; + NodeFS.writeFileSync(NodePath.join(repoDir, "change.txt"), "change\n"); + + const { manager } = yield* makeManager({ + providerScenario: { defaultBranch: "develop", prListSequence: [[], []] }, + }); + + const error = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }).pipe(Effect.flip); + + expect(yield* readRemoteDevelop).toBe(remoteBefore); + expect((yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout).toBe(localBefore); + expect(error.message).toContain("develop is the base branch"); + }), + ); + + it.effect("commit_push_pr on the default branch returns its open PR into another base", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + const releasePr = { + number: 43, + title: "Release", + url: "https://github.com/pingdotgg/codething-mvp/pull/43", + baseRefName: "release", + headRefName: "main", + }; + + const { manager } = yield* makeManager({ + providerScenario: { defaultBranch: "main", prListSequence: [[releasePr], [releasePr]] }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("opened_existing"); + expect(result.pr.number).toBe(43); + }), + ); + + it.effect( + "commit_push_pr publishes a branch that tracks the default branch under its own name", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["branch", "-m", "main", "develop"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "develop"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/feature", "--track", "origin/develop"]); + const readRemoteDevelop = runGit(repoDir, [ + "ls-remote", + "origin", + "refs/heads/develop", + ]).pipe(Effect.map((output) => output.stdout)); + const remoteBefore = yield* readRemoteDevelop; + NodeFS.writeFileSync(NodePath.join(repoDir, "change.txt"), "change\n"); + + const { manager, providerCalls } = yield* makeManager({ + providerScenario: { + defaultBranch: "develop", + prListSequence: [ + [], + [ + { + number: 506, + title: "Feature", + url: "https://github.com/pingdotgg/codething-mvp/pull/506", + baseRefName: "develop", + headRefName: "t3code/feature", + }, + ], + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(yield* readRemoteDevelop).toBe(remoteBefore); + expect( + created(providerCalls).some( + (call) => call.baseRefName === "develop" && call.headSelector === "t3code/feature", + ), + ).toBe(true); + }), + ); + it.effect("create_pr reports a created PR when the lookup after creation fails", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -4355,6 +4474,74 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("commit_push_pr publishes the default branch to the push remote's fork", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["push", "fork", "main"]); + yield* runGit(repoDir, ["config", "remote.pushDefault", "fork"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork", + "git@github.com:octocat/codething-mvp.git", + forkDir, + ); + const readOriginMain = runGit(repoDir, ["ls-remote", "origin", "refs/heads/main"]).pipe( + Effect.map((output) => output.stdout), + ); + const originBefore = yield* readOriginMain; + NodeFS.writeFileSync(NodePath.join(repoDir, "change.txt"), "change\n"); + + const { manager, providerCalls } = yield* makeManager({ + providerScenario: { + defaultBranch: "main", + prListSequenceByHeadSelector: { + "octocat:main": [ + [], + [ + { + number: 189, + title: "Contribute from main", + url: "https://github.com/pingdotgg/codething-mvp/pull/189", + baseRefName: "main", + headRefName: "main", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + ], + ], + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(yield* readOriginMain).toBe(originBefore); + expect( + created(providerCalls).some( + (call) => call.baseRefName === "main" && call.headSelector === "octocat:main", + ), + ).toBe(true); + }), + ); + it.effect("rejects push/pr actions from detached HEAD", () => 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 e6b4bdcfe4230..d9e1d7d4c09e1 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -2282,6 +2282,34 @@ export const make = Effect.gen(function* () { }; }); + const ensurePrHeadIsNotBase = Effect.fn("ensurePrHeadIsNotBase")(function* ( + cwd: string, + status: { branch: string; upstreamRef: string | null; hasUpstream: boolean }, + changeRequestLabel: string, + ) { + const target = yield* gitCore.resolvePushTarget(cwd, status); + if (!target) return; + const headContext = yield* resolveBranchHeadContext(cwd, { + branch: status.branch, + upstreamRef: `${target.remoteName}/${target.branchName}`, + remoteName: target.remoteName, + }); + if (headContext.isCrossRepository) return; + const { baseBranch } = yield* resolvePrBase( + cwd, + status.branch, + status.upstreamRef, + headContext, + ); + if (baseBranch !== target.branchName) return; + if (yield* findOpenPr(cwd, headContext)) return; + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd, + detail: `${baseBranch} is the base branch. Check out a feature branch to create ${changeRequestLabel}.`, + }); + }); + const runPrStep = Effect.fn("runPrStep")(function* ( settings: SourceControlTextGenerationSettings, cwd: string, @@ -3251,6 +3279,15 @@ export const make = Effect.gen(function* () { const currentBranch = branchStep.name ?? initialStatus.branch; const commitAction = isCommitAction(input.action) ? input.action : null; const changeRequestTerms = wantsPr ? yield* changeRequestTermsFor(input.cwd) : null; + // A stale or missing origin/HEAD hides the default branch from status, so check here + // before anything is committed or pushed to it. + if (changeRequestTerms && !input.featureBranch && initialStatus.branch) { + yield* ensurePrHeadIsNotBase( + input.cwd, + { ...initialStatus, branch: initialStatus.branch }, + changeRequestTerms.shortLabelWithArticle, + ); + } const commit = commitAction ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 59db351198182..78f428901a48b 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -181,6 +181,11 @@ export interface GitPushResult { setUpstream?: boolean | undefined; } +export interface GitPushTarget { + remoteName: string; + branchName: string; +} + export interface GitRangeContext { commitSummary: string; diffSummary: string; @@ -339,6 +344,11 @@ export class GitVcsDriver extends Context.Service< fallbackBranch: string | null, options?: { readonly remoteName?: string | null }, ) => Effect.Effect; + /** Where `pushCurrentBranch` would push the checked-out branch; null with no remote or when Git picks. */ + readonly resolvePushTarget: ( + cwd: string, + current: { readonly branch: string; readonly hasUpstream: boolean }, + ) => Effect.Effect; readonly readRangeContext: ( cwd: string, baseRef: string, diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 4fcf1b05fe6f5..ef91660a0e9a7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2663,6 +2663,54 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); + // `publishes` is false only when HEAD goes to the head of its current `upstream`. + const resolvePushDestination = Effect.fn("resolvePushDestination")(function* ( + cwd: string, + branch: string, + hasUpstream: boolean, + ) { + if (!hasUpstream) { + const remoteName = yield* resolvePushRemoteName(cwd, branch); + if (!remoteName) return null; + const branchName = yield* resolvePublishBranchName(cwd, branch); + return { remoteName, branchName, publishes: true, upstream: null }; + } + + const upstream = yield* resolveCurrentUpstream(cwd).pipe(Effect.orElseSucceed(() => null)); + if (!upstream) return null; + // Differently named upstreams are bases, not publish targets; pushing HEAD there would write features to shared branches. + const isAliasOfUpstreamHead = + branch === upstream.branchName || + (branch.endsWith(`/${upstream.branchName}`) && upstream.upstreamRef.endsWith(`/${branch}`)); + if (isAliasOfUpstreamHead) { + return { + remoteName: upstream.remoteName, + branchName: upstream.branchName, + publishes: false, + upstream, + }; + } + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( + Effect.orElseSucceed(() => null), + ); + const branchName = yield* resolvePublishBranchName(cwd, branch); + return { + remoteName: publishRemoteName ?? upstream.remoteName, + branchName, + publishes: true, + upstream, + }; + }); + + const resolvePushTarget: GitVcsDriver.GitVcsDriver["Service"]["resolvePushTarget"] = Effect.fn( + "resolvePushTarget", + )(function* (cwd, current) { + const destination = yield* resolvePushDestination(cwd, current.branch, current.hasUpstream); + return destination + ? { remoteName: destination.remoteName, branchName: destination.branchName } + : null; + }); + const pushCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pushCurrentBranch"] = Effect.fn( "pushCurrentBranch", )(function* (cwd, fallbackBranch, options) { @@ -2697,30 +2745,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; - if (hasNoLocalDelta) { - if (details.hasUpstream) { - return { - status: "skipped_up_to_date" as const, - branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), - }; - } + if (hasNoLocalDelta && details.hasUpstream) { + return { + status: "skipped_up_to_date" as const, + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + }; + } + const destination = yield* resolvePushDestination(cwd, branch, details.hasUpstream); + if (hasNoLocalDelta) { const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( Effect.orElseSucceed(() => null), ); if (comparableBaseBranch) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( - Effect.orElseSucceed(() => null), - ); - if (!publishRemoteName) { + if (!destination) { return { status: "skipped_up_to_date" as const, branch, }; } - const publishBranch = yield* resolvePublishBranchName(cwd, branch); // The configured diff base can contain HEAD while the publish branch is still behind. const publishContainsHead = yield* executeGit( "GitVcsDriver.pushCurrentBranch.publishContainsHead", @@ -2729,7 +2774,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "merge-base", "--is-ancestor", "HEAD", - `refs/remotes/${publishRemoteName}/${publishBranch}`, + `refs/remotes/${destination.remoteName}/${destination.branchName}`, ], { allowNonZeroExit: true }, ).pipe( @@ -2745,87 +2790,61 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } - if (!details.hasUpstream) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); - if (!publishRemoteName) { - return yield* new GitCommandError({ - ...gitCommandContext({ - operation: "GitVcsDriver.pushCurrentBranch", - cwd, - args: ["push"], - }), - detail: "Cannot push because no git remote is configured for this repository.", + if (!details.hasUpstream && !destination) { + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.pushCurrentBranch", + cwd, + args: ["push"], + }), + detail: "Cannot push because no git remote is configured for this repository.", + }); + } + + if (destination?.publishes) { + const { remoteName, branchName: publishBranch, upstream } = destination; + // Publishing retargets the upstream, so preserve its comparison base first. + if (upstream && (yield* readBranchBase(cwd, branch)) === null) { + yield* writeConfigValue({ + cwd, + key: branchBaseConfigKey(branch), + value: upstream.branchName, }); + if (!(yield* readConfigValue(cwd, `branch.${branch}.t3-merge-base-ref`))) { + yield* writeConfigValue({ + cwd, + key: `branch.${branch}.t3-merge-base-ref`, + value: `refs/remotes/${upstream.upstreamRef}`, + }); + } } - const publishBranch = yield* resolvePublishBranchName(cwd, branch); yield* runGit( - "GitVcsDriver.pushCurrentBranch.pushWithUpstream", + upstream + ? "GitVcsDriver.pushCurrentBranch.pushOwnBranch" + : "GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, - ["push", "--", publishRemoteName, `HEAD:refs/heads/${publishBranch}`], + ["push", "--", remoteName, `HEAD:refs/heads/${publishBranch}`], { timeoutMs: null }, ); return { status: "pushed" as const, branch, - upstreamBranch: `${publishRemoteName}/${publishBranch}`, - setUpstream: yield* recordPushedUpstream(cwd, branch, publishRemoteName, publishBranch), + upstreamBranch: `${remoteName}/${publishBranch}`, + setUpstream: yield* recordPushedUpstream(cwd, branch, remoteName, publishBranch), }; } - const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( - Effect.orElseSucceed(() => null), - ); - if (currentUpstream) { - // Differently named upstreams are bases, not publish targets; pushing HEAD there would write features to shared branches. - const isAliasOfUpstreamHead = - branch === currentUpstream.branchName || - (branch.endsWith(`/${currentUpstream.branchName}`) && - currentUpstream.upstreamRef.endsWith(`/${branch}`)); - if (!isAliasOfUpstreamHead) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( - Effect.orElseSucceed(() => null), - ); - const remoteName = publishRemoteName ?? currentUpstream.remoteName; - const publishBranch = yield* resolvePublishBranchName(cwd, branch); - // Publishing retargets the upstream, so preserve its comparison base first. - if ((yield* readBranchBase(cwd, branch)) === null) { - yield* writeConfigValue({ - cwd, - key: branchBaseConfigKey(branch), - value: currentUpstream.branchName, - }); - if (!(yield* readConfigValue(cwd, `branch.${branch}.t3-merge-base-ref`))) { - yield* writeConfigValue({ - cwd, - key: `branch.${branch}.t3-merge-base-ref`, - value: `refs/remotes/${currentUpstream.upstreamRef}`, - }); - } - } - yield* runGit( - "GitVcsDriver.pushCurrentBranch.pushOwnBranch", - cwd, - ["push", "--", remoteName, `HEAD:refs/heads/${publishBranch}`], - { timeoutMs: null }, - ); - return { - status: "pushed" as const, - branch, - upstreamBranch: `${remoteName}/${publishBranch}`, - setUpstream: yield* recordPushedUpstream(cwd, branch, remoteName, publishBranch), - }; - } - + if (destination?.upstream) { yield* runGit( "GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, - ["push", "--", currentUpstream.remoteName, `HEAD:refs/heads/${currentUpstream.branchName}`], + ["push", "--", destination.remoteName, `HEAD:refs/heads/${destination.branchName}`], { timeoutMs: null }, ); return { status: "pushed" as const, branch, - upstreamBranch: currentUpstream.upstreamRef, + upstreamBranch: destination.upstream.upstreamRef, setUpstream: false, }; } @@ -5104,6 +5123,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* commit: Effect.fnUntraced(function* (cwd, subject, body, options) { return yield* withListRefsInvalidation(cwd, commit(cwd, subject, body, options)); }), + resolvePushTarget, pushCurrentBranch: Effect.fnUntraced(function* (cwd, fallbackBranch, options) { return yield* withListRefsInvalidation(cwd, pushCurrentBranch(cwd, fallbackBranch, options)); }), From d47591f613a28818d583bd3003f950ccd5060951 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 00:18:33 -0800 Subject: [PATCH 0017/1253] fix(acp): OMP threads show html_render pages (#33677) Co-authored-by: Claude Opus 5.5 --- .../src/server/runtimeModel.test.ts | 62 +++++++++++++++++++ .../provider-acp/src/server/runtimeModel.ts | 10 +++ 2 files changed, 72 insertions(+) diff --git a/packages/provider-acp/src/server/runtimeModel.test.ts b/packages/provider-acp/src/server/runtimeModel.test.ts index 086628fcfae94..1495d40cc8a18 100644 --- a/packages/provider-acp/src/server/runtimeModel.test.ts +++ b/packages/provider-acp/src/server/runtimeModel.test.ts @@ -1380,6 +1380,68 @@ describe("extractMcpToolCallIdentity", () => { } }); + // regression: #33667 OMP mounts MCP tools as xd:// devices and calls them through `write`. + it("recovers T3 identity from OMP xd:// device writes but not its doc reads", () => { + const args = { + html: '', + title: "Static SVG", + }; + const result = { + htmlRender: { attachmentId: "synthetic-attachment-html", title: "Static SVG", height: 136 }, + message: "Shown to the reader above your reply.", + }; + const text = JSON.stringify(result); + const created = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "toolu_015ATZG99gw99Rrp4htieLux", + title: "Rendering static SVG", + kind: "execute", + status: "pending", + rawInput: { path: "xd://mcp__t3_code_html_render", content: JSON.stringify(args) }, + }); + const completed = mergeToolCallState( + created, + toolCallFromUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_015ATZG99gw99Rrp4htieLux", + status: "completed", + rawOutput: { + content: [{ type: "text", text }], + details: { + xdev: { + tool: "mcp__t3_code_html_render", + mode: "execute", + args, + tier: "write", + inner: { + serverName: "t3-code", + mcpToolName: "html_render", + rawContent: [{ type: "text", text }], + provider: "acp", + providerName: "ACP Client", + structuredContent: result, + }, + }, + }, + }, + content: [{ type: "content", content: { type: "text", text } }], + }), + ); + const docRead = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "toolu_read", + title: "Reading html_render schema", + kind: "read", + status: "pending", + rawInput: { path: "xd://mcp__t3_code_html_render" }, + }); + + const expected = { server: "t3-code", tool: "html_render", input: args }; + expect(extractMcpToolCallIdentity(created), "pending write").toEqual(expected); + expect(extractMcpToolCallIdentity(completed), "completed write").toEqual(expected); + expect(extractMcpToolCallIdentity(docRead), "doc read").toBeUndefined(); + }); + it("does not claim an agent's own tool that shares a T3 tool name", () => { const toolCall = toolCallFromUpdate({ sessionUpdate: "tool_call", diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts index bc418eefcb228..454fa3011ed39 100644 --- a/packages/provider-acp/src/server/runtimeModel.ts +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -1142,6 +1142,16 @@ export function extractMcpToolCallIdentity( const tool = prefix === undefined ? metaToolName : metaToolName.slice(prefix.length); return tool ? { server: metaServerId, tool } : undefined; } + // OMP mounts MCP tools as xd://mcp___ devices and calls them by writing JSON + // args; a read of the same path only fetches the tool's docs. + const devicePath = typeof rawInput?.path === "string" ? rawInput.path : ""; + if (devicePath.startsWith("xd://") && typeof rawInput?.content === "string") { + const deviceTool = T3_MCP_TITLE_CALL.exec(devicePath.slice("xd://".length))?.groups?.tool; + if (deviceTool !== undefined && T3_MCP_TOOL_NAMES.has(deviceTool)) { + const input = acpMcpFallbackInput(rawInput.content); + return { server: "t3-code", tool: deviceTool, ...(input === undefined ? {} : { input }) }; + } + } const candidates = [ meta?.toolName, claudeCode?.toolName, From 56064fc4f6f376af29d4cd5831714fe06b26fe42 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 00:18:47 -0800 Subject: [PATCH 0018/1253] fix(ssh): Intel Mac hosts no longer try to download a missing t3 archive (#33325) --- packages/shared/src/cliRelease.ts | 3 +- packages/ssh/src/tunnel.test.ts | 46 +++++++++++++++++++++++++++++++ packages/ssh/src/tunnel.ts | 17 +++++------- 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts index 80eeec4397545..07a0c73b17947 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -8,7 +8,8 @@ export const CLI_RELEASES_PAGE_URL = `https://github.com/${CLI_RELEASE_REPOSITOR export const CLI_RELEASE_CHECKSUMS_FILE = "SHA256SUMS"; export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL"; -// Keep archive keys aligned with the Linux matrix, Windows ARM64 build, and cli_archive rows in release.yml. +// Keep archive keys aligned with the Linux matrix, Windows ARM64 build, and cli_archive rows in release.yml, +// and with the SSH runner's host check in packages/ssh/src/tunnel.ts. // Node single-executables do not support darwin-x64 and segfault at startup. const CLI_ARCHIVE_PLATFORM_KEYS = [ "darwin-arm64", diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 7a23d7eb3846b..65d6448deaf6a 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -1,3 +1,9 @@ +// @effect-diagnostics nodeBuiltinImport:off - Runs the generated runner in a real shell. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + import { assert, describe, it } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -109,6 +115,46 @@ describe("ssh tunnel scripts", () => { ); }); + it.each([ + { host: "Darwin x86_64", system: "Darwin", machine: "x86_64", downloads: false }, + { host: "Darwin arm64", system: "Darwin", machine: "arm64", downloads: true }, + ])( + "only downloads a release archive the host can run: $host", + ({ system, machine, downloads }) => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-ssh-runner-")); + try { + const bin = NodePath.join(root, "bin"); + NodeFS.mkdirSync(bin); + NodeFS.mkdirSync(NodePath.join(root, "home")); + NodeFS.writeFileSync( + NodePath.join(bin, "uname"), + `#!/bin/sh\ncase "$1" in -s) echo ${system} ;; -m) echo ${machine} ;; esac\n`, + { mode: 0o755 }, + ); + NodeFS.writeFileSync( + NodePath.join(bin, "curl"), + "#!/bin/sh\necho 'download attempt' >&2\nexit 22\n", + { mode: 0o755 }, + ); + const runner = NodePath.join(root, "run-t3.sh"); + NodeFS.writeFileSync(runner, SshTunnel.buildRemoteT3RunnerScript(ARCHIVE)); + const result = NodeChildProcess.spawnSync("sh", [runner, "--version"], { + encoding: "utf8", + env: { HOME: NodePath.join(root, "home"), PATH: `${bin}:${process.env.PATH ?? ""}` }, + }); + assert.notEqual(result.status, 0); + if (downloads) { + assert.include(result.stderr, "download attempt"); + } else { + assert.notInclude(result.stderr, "download attempt"); + assert.include(result.stderr, "has no t3 release archive."); + } + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } + }, + ); + it.effect("accepts launch JSON after remote shell startup noise", () => { const target = { alias: "devbox", diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 39631aa5390a8..2c55e1a453aa7 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -480,17 +480,14 @@ if ! t3_runtime_ready; then trap 'rm -rf "$T3_LOCK"' EXIT fi if ! t3_runtime_ready; then - case "$(uname -s)" in - Darwin) T3_PLATFORM="darwin" ;; - Linux) T3_PLATFORM="linux" ;; - *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -s)" >&2; exit 1 ;; + # Keep aligned with CLI_ARCHIVE_PLATFORM_KEYS in packages/shared/src/cliRelease.ts. + case "$(uname -s) $(uname -m)" in + "Darwin arm64") T3_PLATFORM_KEY="darwin-arm64" ;; + "Linux arm64" | "Linux aarch64") T3_PLATFORM_KEY="linux-arm64" ;; + "Linux x86_64" | "Linux amd64") T3_PLATFORM_KEY="linux-x64" ;; + *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -s) $(uname -m)" >&2; exit 1 ;; esac - case "$(uname -m)" in - arm64 | aarch64) T3_ARCH="arm64" ;; - x86_64 | amd64) T3_ARCH="x64" ;; - *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -m)" >&2; exit 1 ;; - esac - T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz" + T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM_KEY.tar.gz" T3_STAGING="$(mktemp -d "$HOME/.t3/runtime/versions/.staging-XXXXXX")" trap 'rm -rf "$T3_STAGING" "$T3_LOCK"' EXIT t3_fetch() { From a22068f3711e684b99acfff9f2a0974365046815 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 00:19:00 -0800 Subject: [PATCH 0019/1253] fix(server): opening the branch menu no longer wipes PR status (#33263) Co-authored-by: Claude Opus 5.5 --- .../src/vcs/VcsStatusBroadcaster.test.ts | 99 ++++++++++++++++++- apps/server/src/vcs/VcsStatusBroadcaster.ts | 21 +--- 2 files changed, 101 insertions(+), 19 deletions(-) diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index a9df07044943a..ec4d4942d84e4 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -375,17 +375,19 @@ describe("VcsStatusBroadcaster", () => { yield* Deferred.await(subscribed); state.currentLocalStatus = { ...baseLocalStatus, refName: "main" }; + state.currentRemoteStatus = baseRemoteStatus; const refreshed = yield* broadcaster.refreshStatus("/repo", { localOnly: true }); assert.equal(refreshed.refName, "main"); assert.isNull(refreshed.pr); assert.equal(state.localInvalidationCalls, 1); assert.equal(state.remoteStatusCalls, 1); - assert.equal(state.remoteInvalidationCalls, 2); + assert.equal(state.remoteInvalidationCalls, 1); assert.deepStrictEqual((yield* Fiber.join(events)).slice(1), [ { _tag: "remoteUpdated", remote: null }, { _tag: "localUpdated", local: state.currentLocalStatus }, ]); assert.isNull((yield* broadcaster.getStatus({ cwd: "/repo" })).pr); + assert.equal(state.remoteStatusCalls, 2); state.currentLocalStatus = { ...state.currentLocalStatus, hasWorkingTreeChanges: true }; yield* broadcaster.refreshLocalStatus("/repo"); state.currentLocalStatus = { ...baseLocalStatus, refName: "union" }; @@ -433,6 +435,98 @@ describe("VcsStatusBroadcaster", () => { }, ); + it.effect("a local-only refresh that overlaps the first remote poll keeps its PR", () => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: remoteStatusWithPr, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const pollStarted = yield* Deferred.make(); + const releasePoll = yield* Deferred.make(); + state.beforeRemoteStatus = Deferred.succeed(pollStarted, undefined).pipe( + Effect.andThen(Deferred.await(releasePoll)), + ); + const localSeen = yield* Deferred.make(); + yield* broadcaster.streamStatus({ cwd: "/repo" }).pipe( + Stream.runForEach((event) => + event._tag === "localUpdated" ? Deferred.succeed(localSeen, undefined) : Effect.void, + ), + Effect.forkScoped, + ); + yield* Deferred.await(pollStarted); + state.beforeRemoteStatus = Effect.void; + + state.currentLocalStatus = { ...baseLocalStatus, hasWorkingTreeChanges: true }; + const refresh = yield* broadcaster + .refreshStatus("/repo", { localOnly: true }) + .pipe(Effect.forkScoped); + yield* Deferred.await(localSeen); + yield* Deferred.succeed(releasePoll, undefined); + yield* Fiber.join(refresh); + + const status = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.deepStrictEqual(status.pr, remoteStatusWithPr.pr); + assert.equal(state.remoteStatusCalls, 1); + }).pipe(Effect.provide(layerTestFor(state))); + }); + + it.effect( + "a local-only refresh before any remote read leaves the PR refresh able to read", + () => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: remoteStatusWithPr, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.refreshStatus("/repo", { localOnly: true }); + + const remote = yield* broadcaster.refreshPullRequestStatus("/repo"); + assert.deepStrictEqual(remote?.pr, remoteStatusWithPr.pr); + assert.equal(state.remoteStatusCalls, 1); + }).pipe(Effect.provide(layerTestFor(state))); + }, + ); + + it.effect("a local-only refresh after a branch switch leaves the PR refresh able to read", () => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: remoteStatusWithPr, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + + state.currentLocalStatus = { ...baseLocalStatus, refName: "feature/next" }; + const nextBranchRemote: VcsStatusRemoteResult = { + ...baseRemoteStatus, + pr: { ...remoteStatusWithPr.pr!, number: 3001, headRef: "feature/next" }, + }; + state.currentRemoteStatus = nextBranchRemote; + yield* broadcaster.refreshStatus("/repo", { localOnly: true }); + + const remote = yield* broadcaster.refreshPullRequestStatus("/repo"); + assert.deepStrictEqual(remote?.pr, nextBranchRemote.pr); + assert.equal(state.remoteStatusCalls, 2); + }).pipe(Effect.provide(layerTestFor(state))); + }); + it.effect("clears the previous branch's remote status after an in-flight PR refresh", () => { const remoteReadStarted = Deferred.makeUnsafe(); const releaseRemoteRead = Deferred.makeUnsafe(); @@ -463,13 +557,14 @@ describe("VcsStatusBroadcaster", () => { yield* Deferred.succeed(releaseRemoteRead, undefined); yield* Fiber.join(remoteRefresh); yield* Fiber.join(localRefresh); + state.currentRemoteStatus = baseRemoteStatus; const status = yield* broadcaster.getStatus({ cwd: "/repo" }); assert.equal(status.refName, "main"); assert.isNull(status.pr); assert.equal(status.aheadCount, 0); assert.equal(status.behindCount, 0); - assert.equal(state.remoteStatusCalls, 2); + assert.equal(state.remoteStatusCalls, 3); }).pipe(Effect.provide(layerTestFor(state))); }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index be1c86940e64e..1868d3418198d 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -537,24 +537,11 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.refreshStatus", )(function* (rawCwd, options) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - const previous = options?.localOnly ? yield* getCachedStatus(cwd) : null; - const local = yield* refreshLocalStatusCore(cwd); + yield* refreshLocalStatusCore(cwd); if (options?.localOnly) { - return yield* withRemoteWriteLock( - cwd, - Effect.gen(function* () { - if (previous?.local?.value.refName !== local.refName || !previous?.remote) { - yield* workflow.invalidateRemoteStatus(cwd); - yield* updateCachedRemoteStatus( - cwd, - { branch: local.refName, fingerprint: fingerprintStatusPart(null), value: null }, - { publish: true }, - ); - } - const parts = yield* getStatusParts(cwd); - return mergeGitStatusParts(parts.local, parts.remote); - }), - ); + // Never cache a remote here: readers take `value: null` as "read and found no PR". + const parts = yield* getStatusParts(cwd); + return mergeGitStatusParts(parts.local, parts.remote); } return yield* withRemoteWriteLock( cwd, From 633438d54f17b99f73f1a09466ecbe4a6aa0d545 Mon Sep 17 00:00:00 2001 From: Tanav Poswal <63358333+tanav29@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:49:15 +0530 Subject: [PATCH 0020/1253] fix(server): failed updates report download diagnostics (#33239) Co-authored-by: Julius Marminge --- apps/server/src/cloud/pinnedRuntime.ts | 9 +++++ apps/server/src/cloud/selfUpdate.test.ts | 42 ++++++++++++++++++++++-- apps/server/src/cloud/selfUpdate.ts | 31 +++++++++++------ 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 0df57f10f3bd3..90069f46f9173 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -71,6 +71,8 @@ export class PinnedRuntimeInstallError extends Schema.TaggedError new PinnedRuntimeInstallError({ step: cause._tag === "TimeoutError" ? `${step} (timed out)` : step, + httpStatus: + HttpClientError.isHttpClientError(cause) && cause.reason._tag === "StatusCodeError" + ? cause.reason.response.status + : undefined, + downloadHost: URL.parse( + HttpClientError.isHttpClientError(cause) ? cause.request.url : url, + )?.host, cause, }), ), diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index a938d6778820f..6be4288ce35e4 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -11,7 +11,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import { HttpClient, HttpClientResponse } from "effect/http"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import packageJson from "../../package.json" with { type: "json" }; @@ -30,6 +30,7 @@ interface HarnessOptions { readonly preflightDuration?: Duration.Input; readonly preflightStarted?: Deferred.Deferred; readonly beforeDownload?: Effect.Effect; + readonly httpClient?: HttpClient.HttpClient; readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"]; readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"]; readonly policy?: ManagedPolicyDocument; @@ -145,7 +146,7 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( ), Effect.provideService( HttpClient.HttpClient, - releaseHttpClient(order, urls, options.beforeDownload), + options.httpClient ?? releaseHttpClient(order, urls, options.beforeDownload), ), Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HostProcess.Architecture, "x64"), @@ -156,10 +157,45 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( ), ), ); - return { selfUpdate, order, urls }; + return { selfUpdate, order, urls, baseDir }; }); it.layer(NodeServices.layer)("server self update", (it) => { + it.effect.each([ + { asset: "checksums", status: 504 }, + { asset: "archive", status: 503 }, + { asset: "checksums", status: 403 }, + ])("reports an HTTP $status downloading $asset and allows retry", ({ asset, status }) => + Effect.gen(function* () { + let failDownload = true; + const { selfUpdate, order, baseDir } = yield* makeHarness({ + httpClient: releaseHttpClient([], []).pipe( + HttpClient.transform((response, request) => + failDownload && request.url.endsWith("/SHA256SUMS") === (asset === "checksums") + ? Effect.succeed( + HttpClientResponse.fromWeb( + HttpClientRequest.get("https://user:password@cdn.example/private?token=secret"), + new Response("private response body", { status }), + ), + ) + : response, + ), + ), + }); + const error = yield* selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip); + expect(error.reason).toBe( + `Could not prepare t3@1.1.0 while downloading the t3 release ${asset} (HTTP ${status} from cdn.example).`, + ); + expect(order).toEqual([]); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + expect(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions"))).toEqual([]); + failDownload = false; + expect((yield* selfUpdate.update({ targetVersion: "1.1.0" })).updateId).toBe("launcher-id"); + expect(order).toEqual(["extract", "preflight", "accept"]); + }), + ); + // regression: #5181 make remote updates rollback-safe it.effect("rejects invalid versions and desktop-managed servers before staging", () => Effect.gen(function* () { diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 58baa160adf71..5e299964138da 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -282,16 +282,27 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { ), ), }).pipe( - Effect.mapError((error) => - error._tag === "PinnedRuntimePreflightBlockedError" - ? failWith(error.reason, error) - : mirror !== undefined && isUnpublishedRelease(error) - ? failWith( - `${mirror.organization}'s update mirror has no t3@${targetVersion} for this machine.`, - error, - ) - : failWith(`Could not prepare t3@${targetVersion}.`, error), - ), + Effect.mapError((error) => { + if (error._tag === "PinnedRuntimePreflightBlockedError") { + return failWith(error.reason, error); + } + if (mirror !== undefined && isUnpublishedRelease(error)) { + return failWith( + `${mirror.organization}'s update mirror has no t3@${targetVersion} for this machine.`, + error, + ); + } + const downloadFailure = [ + error.httpStatus === undefined ? undefined : `HTTP ${error.httpStatus}`, + error.downloadHost === undefined ? undefined : `from ${error.downloadHost}`, + ] + .filter((part) => part !== undefined) + .join(" "); + return failWith( + `Could not prepare t3@${targetVersion} while ${error.step}${downloadFailure ? ` (${downloadFailure})` : ""}.`, + error, + ); + }), ); yield* reportProgress("installing"); From e4d86abca9c0d0ddd8107ea557f2e0c879632cf3 Mon Sep 17 00:00:00 2001 From: F4llen <76191805+f4llenz@users.noreply.github.com> Date: Tue, 10 Nov 2026 03:32:03 -0500 Subject: [PATCH 0021/1253] fix(web): remote Lineage rows keep their title when the environment name is long (#33181) Co-authored-by: Julius Marminge --- ...hreadRelationshipsControl.browser.test.tsx | 24 ++++++++++++------- .../chat/ThreadRelationshipsControl.tsx | 11 +++++---- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx index 6a677fc02f665..9679f42569c6b 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx @@ -5,6 +5,8 @@ import { AsyncResult } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; +import "~/index.css"; + const state = vi.hoisted(() => ({ projection: null as unknown, commandSucceeds: false, @@ -295,18 +297,20 @@ it("shows a remote subagent's full title in its tooltip even when its environmen remoteChild: { environmentId: remoteEnvId, threadId: remoteThreadId, - label: "Remote VM", + label: "Remote VM with a long machine name in the office", }, }, ], }; await using screen = await render( - , +
+ +
, { router: "/environment/fork" }, ); @@ -316,12 +320,16 @@ it("shows a remote subagent's full title in its tooltip even when its environmen .not.toBeInTheDocument(); await screen.getByRole("button", { name: /Previous agents/ }).click(); const row = screen.getByRole("button", { name: new RegExp(longTitle) }); + // A long environment label must not squeeze the title out of the row. + expect( + page.getByText(longTitle, { exact: true }).element().getBoundingClientRect().width, + ).toBeGreaterThan(0); await userEvent.hover(row); await vi.waitFor(() => expect(document.querySelector('[data-slot="tooltip-popup"]')?.textContent).toContain(longTitle), ); expect(document.querySelector('[data-slot="tooltip-popup"]')?.textContent).toContain( - "Runs on Remote VM. Connect to it to open the thread there.", + "Runs on Remote VM with a long machine name in the office. Connect to it to open the thread there.", ); await row.click({ force: true }); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index f392c652e05de..102d9734ded92 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -309,9 +309,10 @@ function RemoteSubagentRow(props: { - on {remoteChild.label} · {threadRelationshipStatusLabel(status)} + on {remoteChild.label} + · {threadRelationshipStatusLabel(status)} @@ -373,7 +374,9 @@ function DelegatedFromRow(props: { {label ? ( - on {label} + + on {label} + ) : null} @@ -382,7 +385,7 @@ function DelegatedFromRow(props: { hint={ environment === null ? `Delegated from ${label ?? "a linked environment"}. Connect to it to open the parent thread.` - : "Open parent agent" + : `Open parent agent on ${environment.label}` } /> From 580503ed302bf81dd8bf074b4084a0ae000c410a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 00:58:31 -0800 Subject: [PATCH 0022/1253] fix(web): custom snooze footer stays inside the dialog (#33759) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/CustomSnoozeDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index 91d4a54fd9e64..6781cf1b4a091 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -85,7 +85,7 @@ function CustomSnoozeDialog() { if (!open) finish(null); }} > - +
{ From 2922e8fb5952277793e0915b6adbceb58ebef1b0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 00:58:45 -0800 Subject: [PATCH 0023/1253] fix(desktop): right-clicking a mailto link offers Copy Email Address (#33262) Co-authored-by: Claude Opus 5.5 --- apps/desktop/src/window/DesktopWindow.test.ts | 57 +++++++++++++++++-- apps/desktop/src/window/DesktopWindow.ts | 28 +++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 03b9a2e6ad64a..3e461303556ab 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -233,7 +233,7 @@ function layerTest(input: { readonly destroyAll?: Effect.Effect; readonly onWindowCreate?: Effect.Effect; readonly openedExternalUrls?: unknown[]; - readonly copiedTexts?: string[]; + readonly onCopyText?: (text: string) => Effect.Effect; readonly onPopupTemplate?: (input: ElectronMenu.ElectronMenuTemplateInput) => Effect.Effect; readonly previewZoomReapplies?: number[]; readonly onReveal?: (window: Electron.BrowserWindow) => void; @@ -325,10 +325,7 @@ function layerTest(input: { return true; }), openSystemSettings: () => Effect.succeed(true), - copyText: (text) => - Effect.sync(() => { - input.copiedTexts?.push(text); - }), + copyText: input.onCopyText ?? (() => Effect.void), } satisfies ElectronShell.ElectronShell["Service"]), layerElectronTheme, layerElectronWindow, @@ -1186,6 +1183,56 @@ describe("DesktopWindow", () => { }), ); + it.effect("offers to copy the bare address when right-clicking a mailto link", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const popup = yield* Deferred.make(); + const copied = yield* Deferred.make(); + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + onPopupTemplate: (input) => Deferred.succeed(popup, input), + onCopyText: (text) => Deferred.succeed(copied, text), + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const contextMenu = fakeWindow.webContentsListeners.get("context-menu"); + if (!contextMenu) { + return yield* Effect.die("context-menu listener was not registered"); + } + contextMenu( + { preventDefault: vi.fn() }, + { + linkURL: "mailto:restaurant%40example.com?subject=Hi", + misspelledWord: "", + dictionarySuggestions: [], + mediaType: "none", + editFlags: { canCut: false, canCopy: false, canPaste: false, canSelectAll: true }, + }, + ); + + const { template } = yield* Deferred.await(popup); + const copyEmail = template.find((item) => item.label === "Copy Email Address"); + assert.isDefined( + copyEmail, + `no "Copy Email Address" item in ${JSON.stringify(template.map((item) => item.label ?? item.role ?? item.type))}`, + ); + copyEmail?.click?.( + {} as Electron.MenuItem, + fakeWindow.window, + {} as Electron.KeyboardEvent, + ); + assert.equal(yield* Deferred.await(copied), "restaurant@example.com"); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect( "retries opening the real main on activate when a failed post-readiness open left only the splash", () => diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 17dba6742f795..b8446f78740c4 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -53,6 +53,21 @@ class ContextMenuImageCopyError extends Schema.TaggedError { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return Option.none(); + } + if (url.protocol !== "mailto:" || url.pathname === "") return Option.none(); + try { + return Option.some(decodeURIComponent(url.pathname)); + } catch { + return Option.some(url.pathname); + } +} + function syncMacosWindowButtons(window: Electron.BrowserWindow): void { if (window.isDestroyed() || window.isFullScreen()) return; window.setWindowButtonPosition({ @@ -590,6 +605,19 @@ export const make = Effect.gen(function* () { ); } + const mailtoAddress = parseMailtoAddress(params.linkURL); + if (Option.isSome(mailtoAddress)) { + menuTemplate.push( + { + label: "Copy Email Address", + click: () => { + void runPromise(electronShell.copyText(mailtoAddress.value)); + }, + }, + { type: "separator" }, + ); + } + if (params.mediaType === "image") { menuTemplate.push({ label: "Copy Image", From 7171fdd7716b05dc1b4be40c26ac78e8bb35f80c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 01:14:10 -0800 Subject: [PATCH 0024/1253] fix(acp-registry): commands past the 128th are no longer dropped (#33768) Co-authored-by: Claude Opus 5.5 --- apps/server/src/provider/AcpRegistryProbe.test.ts | 8 +++++--- packages/provider-acp-registry/src/server/probe.ts | 4 ---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/AcpRegistryProbe.test.ts b/apps/server/src/provider/AcpRegistryProbe.test.ts index 1f45e78320e5a..b1cf5c4f349d2 100644 --- a/apps/server/src/provider/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/AcpRegistryProbe.test.ts @@ -273,7 +273,7 @@ describe("ACP Registry probe", () => { }, ); - it("bounds and de-duplicates advertised commands", () => { + it("keeps every distinct advertised command and de-duplicates the rest", () => { const commands = normalizeAcpRegistryCommands([ { name: "create_plan", @@ -285,9 +285,11 @@ describe("ACP Registry probe", () => { name: `command_${index}`, description: "", })), + { name: "$late-skill", description: "Advertised after 128 commands" }, ]); - expect(commands.slashCommands).toHaveLength(128); + expect(commands.slashCommands).toHaveLength(141); + expect(commands.slashCommands.at(-1)).toEqual({ name: "command_139" }); expect(commands.slashCommands[0]).toEqual({ name: "create_plan", description: "Create a plan", @@ -296,7 +298,7 @@ describe("ACP Registry probe", () => { expect( commands.slashCommands.filter((command) => command.name.toLowerCase() === "create_plan"), ).toHaveLength(1); - expect(commands.skills).toEqual([]); + expect(commands.skills.map((skill) => skill.name)).toEqual(["late-skill"]); }); it("routes dollar-prefixed ACP commands to the provider skill menu", () => { diff --git a/packages/provider-acp-registry/src/server/probe.ts b/packages/provider-acp-registry/src/server/probe.ts index 58b16e337079e..7a43ac83acacb 100644 --- a/packages/provider-acp-registry/src/server/probe.ts +++ b/packages/provider-acp-registry/src/server/probe.ts @@ -41,7 +41,6 @@ const MAX_MODELS = 256; const MAX_ID_LENGTH = 128; const MAX_NAME_LENGTH = 160; const MAX_DESCRIPTION_LENGTH = 1_024; -const MAX_COMMANDS = 128; const MAX_COMMAND_LINE_LENGTH = 2_048; const PROBE_TIMEOUT_SECONDS = 60; const PROBE_TIMEOUT = `${PROBE_TIMEOUT_SECONDS} seconds`; @@ -215,7 +214,6 @@ export function normalizeAcpRegistryCommands( const seen = new Set(); const slashCommands: Array = []; const skills: Array = []; - let accepted = 0; for (const command of commands) { const name = boundedOpaqueValue(command.name, MAX_ID_LENGTH); if (name === undefined) continue; @@ -241,8 +239,6 @@ export function normalizeAcpRegistryCommands( ...(hint ? { input: { hint } } : {}), }); } - accepted += 1; - if (accepted === MAX_COMMANDS) break; } return { slashCommands, skills }; } From f8fbc199ae14cd6c034407baca476e3a419257f6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 01:39:39 -0800 Subject: [PATCH 0025/1253] fix(server): one finished turn looks up its branch's pull request once (#33752) Co-authored-by: Claude Opus 5.5 --- .../ThreadPullRequestService.test.ts | 133 ++++++++++++++++++ .../ThreadPullRequestService.ts | 27 +++- 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index 423d1cddd943c..21c54ff4ce1e8 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -181,6 +181,139 @@ describe("ThreadPullRequestServiceV2 reads", () => { ), ); + it.effect("looks up a finished turn's branch once for all its turn-end events", () => + Effect.scoped( + Effect.gen(function* () { + const thread = { ...threadShell("turn-end-thread"), branch: "t3/turn-end" }; + const otherThread = { + ...threadShell("other-turn-end-thread"), + branch: "t3/other-turn-end", + }; + const project: OrchestrationProjectShell = { + id: thread.projectId, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }; + // A turn end commits its checkpoints and the run's end together. The queued + // non-refresh request ahead of them must still become a refresh when they join it. + const turnEnd = [ + { type: "thread.metadata-updated", threadId: thread.id }, + { type: "checkpoint.captured", threadId: thread.id }, + { type: "checkpoint.captured", threadId: thread.id }, + { type: "run.updated", threadId: thread.id, payload: { status: "completed" } }, + { type: "checkpoint.captured", threadId: otherThread.id }, + { type: "run.updated", threadId: otherThread.id, payload: { status: "completed" } }, + ] as Array; + // Arrives during the thread's own lookup, which may already have read the old host state. + const duringLookup = [ + { type: "checkpoint.captured", threadId: thread.id }, + { type: "thread.metadata-updated", threadId: thread.id }, + ] as Array; + const activation = yield* Deferred.make(); + const sweepStarted = yield* Deferred.make(); + const turnEndQueued = yield* Deferred.make(); + const lookupStarted = yield* Deferred.make(); + const followUpQueued = yield* Deferred.make(); + const lookups: Array<{ readonly branch: string; readonly refresh: boolean }> = []; + const dependencies = Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + // The batch arrives while the worker is busy with the startup sweep. + streamDomainEvents: Stream.fromEffect(Deferred.await(sweepStarted)).pipe( + Stream.drain, + Stream.concat(Stream.fromIterable(turnEnd)), + Stream.concat( + Stream.fromEffect(Deferred.succeed(turnEndQueued, undefined)).pipe(Stream.drain), + ), + Stream.concat(Stream.fromEffect(Deferred.await(lookupStarted)).pipe(Stream.drain)), + Stream.concat(Stream.fromIterable(duringLookup)), + Stream.concat( + Stream.fromEffect(Deferred.succeed(followUpQueued, undefined)).pipe(Stream.drain), + ), + Stream.concat(Stream.never), + ), + getPullRequestSnapshot: ({ threadId }) => + Effect.succeed({ + schemaVersion: 2, + snapshotSequence: 1, + threads: [thread, otherThread].filter( + (candidate) => threadId === undefined || candidate.id === threadId, + ), + archivedThreads: [], + }), + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => Effect.succeed([project]), + getShell: () => Effect.succeedSome(project), + }), + Layer.mock(GitManager.GitManager)({ + branchPullRequest: (input, options) => + Effect.gen(function* () { + lookups.push({ branch: input.branch, refresh: options?.refresh ?? false }); + if (!(yield* Deferred.isDone(turnEndQueued))) { + yield* Deferred.succeed(sweepStarted, undefined); + yield* Deferred.await(turnEndQueued); + } else if ( + input.branch === thread.branch && + !(yield* Deferred.isDone(followUpQueued)) + ) { + yield* Deferred.succeed(lookupStarted, undefined); + yield* Deferred.await(followUpQueued); + } + return null; + }), + }), + Layer.mock(PullRequestService.PullRequestService)({}), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/project", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:acme/project.git", + }, + provider: SourceControlProviderKind.make("github"), + owner: "acme", + name: "project", + }), + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + shouldRunOpportunisticWork: Effect.succeed(false), + }), + Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), + Layer.succeed( + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size).fill(1)), + ), + FileSystem.layerNoop({}), + Path.layer, + ); + yield* Effect.gen(function* () { + const service = yield* ThreadPullRequestService.make; + yield* service.start(); + yield* Deferred.succeed(activation, undefined); + yield* Deferred.await(followUpQueued); + yield* service.drain; + }).pipe(Effect.provide(dependencies)); + + // The startup sweep, one refresh lookup per thread for its turn-end batch, then one + // follow-up refresh for the events that arrived during the thread's lookup. + expect(lookups).toEqual([ + { branch: thread.branch, refresh: false }, + { branch: otherThread.branch, refresh: false }, + { branch: thread.branch, refresh: true }, + { branch: otherThread.branch, refresh: true }, + { branch: thread.branch, refresh: true }, + ]); + }), + ), + ); + it.effect.each([ { worktree: "a linked worktree", diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index d84c532d06ecd..4648660ce62b7 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -346,8 +346,15 @@ export const make = Effect.gen(function* () { ); }); + // At most one queued request per thread, so a turn end's events share one lookup. + // A request after that lookup starts queues another, so later host changes are still read. + const queuedThreadRequests = new Map(); + const worker = yield* makeDrainableWorker((request: RefreshRequest) => - synchronize(request).pipe( + Effect.suspend(() => { + if (request.threadId !== null) queuedThreadRequests.delete(request.threadId); + return synchronize(request); + }).pipe( Effect.provideService(GitVcsDriver.BackgroundGitWork, true), Effect.catchCauseIf( (cause) => !Cause.hasInterruptsOnly(cause), @@ -359,15 +366,27 @@ export const make = Effect.gen(function* () { ), ); + const enqueueThread = (threadId: ThreadId, refresh: boolean) => + Effect.suspend(() => { + const queued = queuedThreadRequests.get(threadId); + if (queued !== undefined) { + queued.refresh ||= refresh; + return Effect.void; + } + const request = { threadId, refresh }; + queuedThreadRequests.set(threadId, request); + return worker.enqueue(request); + }); + const processEvent = (event: OrchestrationV2DomainEvent) => { switch (event.type) { case "thread.created": case "thread.unarchived": case "thread.metadata-updated": - return worker.enqueue({ threadId: event.threadId, refresh: false }); + return enqueueThread(event.threadId, false); case "thread.unsettled": case "checkpoint.captured": - return worker.enqueue({ threadId: event.threadId, refresh: true }); + return enqueueThread(event.threadId, true); case "run.updated": if ( event.payload.status === "completed" || @@ -375,7 +394,7 @@ export const make = Effect.gen(function* () { event.payload.status === "cancelled" || event.payload.status === "interrupted" ) { - return worker.enqueue({ threadId: event.threadId, refresh: true }); + return enqueueThread(event.threadId, true); } break; } From ca796d52119592de771d8ccfa955d70dba84d11e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Ma=C5=88=C3=A1k?= Date: Tue, 10 Nov 2026 10:39:53 +0100 Subject: [PATCH 0026/1253] fix(mobile): Android Files sheet scrolls its tree instead of dragging the sheet (#33223) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/features/files/FileTreeBrowser.tsx | 1 + .../mobile/src/features/files/thread-file-navigator-pane.tsx | 5 +++-- apps/mobile/src/features/layout/inspector-host.android.tsx | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index a4da7ee42bfbf..279269b8a13c3 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -215,6 +215,7 @@ export function FileTreeBrowser(props: { return ( + // Material drags target the first scroll view, so place content before the header. + + {fileTree} - {fileTree} ); } diff --git a/apps/mobile/src/features/layout/inspector-host.android.tsx b/apps/mobile/src/features/layout/inspector-host.android.tsx index 9537811916f07..be1f400d4d935 100644 --- a/apps/mobile/src/features/layout/inspector-host.android.tsx +++ b/apps/mobile/src/features/layout/inspector-host.android.tsx @@ -53,7 +53,8 @@ export function InspectorPanes(props: { position: "absolute", inset: 0, opacity: visible ? 1 : 0, - zIndex: visible ? 1 : 0, + // A lower zIndex puts the visible pane first in native order, where Material finds its scroll view. + zIndex: visible ? 0 : 1, }} > From b48b8ea2a0019b3d38de09406fe8be820dd8df15 Mon Sep 17 00:00:00 2001 From: Nathan Wong Date: Tue, 10 Nov 2026 17:53:27 +0800 Subject: [PATCH 0027/1253] fix(web): prompt suggestions show after a steered turn (#33706) Co-authored-by: Nathan Wong <20381195+thelogicmatrix@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../orchestration-v2/ProjectionStore.test.ts | 44 +++++++++++- .../src/orchestration-v2/ProjectionStore.ts | 34 +++++---- apps/web/src/components/ChatView.tsx | 2 + apps/web/src/components/chat/ChatComposer.tsx | 9 ++- .../chat/usePromptSuggestion.test.ts | 71 +++++++++++++++++++ .../components/chat/usePromptSuggestion.ts | 37 ++++++++-- 6 files changed, 178 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/components/chat/usePromptSuggestion.test.ts diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 1ca7eb67036fa..da27303352b24 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -3517,7 +3517,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { activeAttemptId: null, status: "cancelled", requestedAt: nowIso, - startedAt: nowIso, + startedAt: null, completedAt: nowIso, checkpointId: null, contextHandoffId: null, @@ -3646,6 +3646,48 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ), ); + // A steer cancels the queued run before it starts while the steered run keeps writing, so + // the queued message leaves the window but its run is still the shell's latest run. + const steeredItemId = "turn-item:bounded-sql-history:steered-output"; + const steeredRunId = RunId.make("run:bounded-sql-history:1000"); + yield* sql` + INSERT INTO orchestration_v2_projection_turn_items ( + turn_item_id, thread_id, run_id, node_id, provider_thread_id, provider_turn_id, + parent_item_id, ordinal, type, status, updated_at, payload_json + ) VALUES ( + ${steeredItemId}, ${threadId}, ${steeredRunId}, NULL, NULL, NULL, NULL, 1203, + 'command_execution', 'completed', ${nowIso}, + ${encodeUnknownJsonString({ + id: steeredItemId, + threadId, + runId: steeredRunId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1203, + status: "completed", + title: "steered output", + input: "echo steered", + output: "steered", + exitCode: 0, + startedAt: nowIso, + completedAt: nowIso, + updatedAt: nowIso, + type: "command_execution", + })} + ) + `; + const steeredSnapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + }); + assert.isFalse( + steeredSnapshot.projection.turnItems.some((item) => item.runId === cancelledRunId), + ); + assert.isTrue(steeredSnapshot.projection.runs.some((run) => run.id === cancelledRunId)); + yield* sql`DELETE FROM orchestration_v2_projection_turn_items WHERE turn_item_id = ${steeredItemId}`; + const interruptRunId = RunId.make("run:bounded-sql-history:interrupt"); const interruptNodeId = NodeId.make("node:bounded-sql-history:interrupt"); const interruptRequestId = TurnItemId.make("turn-item:bounded-sql-history:interrupt-request"); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 3d33f76b7a494..80ee86d3d75a6 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -3378,6 +3378,12 @@ export const layer: Layer.Layer = AND (status IN ('queued','preparing','starting','running','waiting') OR run_id IN (SELECT value FROM json_each(${cohortRunIds})) OR run_id = ${window.requiredRunId ?? null} + ${ + // The shell's latest run, even a steer-cancelled one outside the newest window. + window.anchorItemId === undefined && window.requiredRunId === undefined + ? sql`OR run_id = ${presentedShellRunId(sql`${threadId}`)}` + : sql`` + } -- Queued or cancelled turns must not hide the latest actual session. OR run_id = ${latestTodoList?.run_id ?? null} OR run_id = ( @@ -6192,6 +6198,19 @@ export const layer: Layer.Layer = ) )`; }; + // The newest run not waiting in a held queue, matching latestUnheldRun. + const presentedShellRunId = (threadId: Statement.Fragment) => sql`( + SELECT candidate.run_id + FROM orchestration_v2_projection_runs candidate + WHERE candidate.thread_id = ${threadId} + AND NOT ( + candidate.status = 'queued' + AND json_extract(candidate.payload_json, '$.queueHeld') IS 1 + ) + AND NOT ${isSilentShellRun("candidate")} + ORDER BY candidate.ordinal DESC, candidate.run_id DESC + LIMIT 1 + )`; const selectShellThreadRows = ( threadId?: ThreadId, @@ -6359,19 +6378,8 @@ export const layer: Layer.Layer = ORDER BY r.ordinal DESC, r.run_id DESC LIMIT 1 ) - -- The newest run not waiting in a held queue, matching latestUnheldRun. - LEFT JOIN orchestration_v2_projection_runs presented ON presented.run_id = ( - SELECT candidate.run_id - FROM orchestration_v2_projection_runs candidate - WHERE candidate.thread_id = t.thread_id - AND NOT ( - candidate.status = 'queued' - AND json_extract(candidate.payload_json, '$.queueHeld') IS 1 - ) - AND NOT ${isSilentShellRun("candidate")} - ORDER BY candidate.ordinal DESC, candidate.run_id DESC - LIMIT 1 - ) + LEFT JOIN orchestration_v2_projection_runs presented + ON presented.run_id = ${presentedShellRunId(sql.literal("t.thread_id"))} LEFT JOIN orchestration_v2_projection_runs blocked ON blocked.run_id = ( SELECT candidate.run_id FROM orchestration_v2_projection_runs candidate diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6daf189f7d6df..082abaf83f6e2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -380,6 +380,7 @@ import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplat const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_TURN_ITEMS: ThreadStructure["turnItems"] = []; +const EMPTY_RUNS: ThreadStructure["runs"] = []; const EMPTY_AUTO_UPDATE_ENVIRONMENTS: ReadonlyArray = []; const EMPTY_COMPOSER_BANNER_ITEMS: ReadonlyArray = []; @@ -3575,6 +3576,7 @@ export default function ChatView(props: ChatViewProps) { draftId={draftId} activeThreadId={activeThreadId} activeThread={activeThread} + activeThreadRuns={serverProjection?.runs ?? EMPTY_RUNS} promptHistoryEntries={timelineEntries} forceExpandedOnMobile={ forceExpandedMobileComposer && isDraftHeroState diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 65eb3896f48ce..f722fd1eb6e1f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -187,7 +187,11 @@ import { useThreadContextDrop, threadContextDropTargetProps } from "./threadCont import { readThreadShell } from "~/state/entities"; import { useLinkedEnvironments } from "~/state/peerLinks"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; -import type { ComposerContextRecord, ThreadPullRequestLink } from "@t3tools/contracts"; +import type { + ComposerContextRecord, + OrchestrationV2Run, + ThreadPullRequestLink, +} from "@t3tools/contracts"; import { threadContextAttachment } from "~/lib/threadContextAttachment"; import { type ComposerCommandItem, @@ -353,6 +357,7 @@ export interface ChatComposerProps { activeThreadId: ThreadId | null; activeThread: Thread | undefined; + activeThreadRuns: ReadonlyArray; promptHistoryEntries: ReadonlyArray; forceExpandedOnMobile: boolean; projectSelectionRequired: boolean; @@ -499,6 +504,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onMultipleModelSelectionsChange: setMultipleModelSelections, activeThreadId, activeThread, + activeThreadRuns, promptHistoryEntries, forceExpandedOnMobile, projectSelectionRequired, @@ -2258,6 +2264,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const { suggestion: promptSuggestion, dismiss: dismissPromptSuggestion } = usePromptSuggestion( environmentId, activeThread, + activeThreadRuns, ); // After /clear the next message starts a fresh session that cannot see what the suggestion refers to. const freshSessionArmed = useFreshSessionArmed(scopedThreadKey(routeThreadRef)); diff --git a/apps/web/src/components/chat/usePromptSuggestion.test.ts b/apps/web/src/components/chat/usePromptSuggestion.test.ts new file mode 100644 index 0000000000000..4d0d79a8c0d4a --- /dev/null +++ b/apps/web/src/components/chat/usePromptSuggestion.test.ts @@ -0,0 +1,71 @@ +import { + MessageId, + type OrchestrationV2Run, + ProviderInstanceId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { describe, expect, it } from "vite-plus/test"; + +import { offeredPromptSuggestion } from "./usePromptSuggestion"; + +const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); +const run = ( + id: RunId, + ordinal: number, + status: OrchestrationV2Run["status"], + started = true, +): OrchestrationV2Run => ({ + id, + threadId: ThreadId.make("thread-1"), + ordinal, + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-haiku-4-5" }, + providerThreadId: null, + userMessageId: MessageId.make(`message-${ordinal}`), + rootNodeId: null, + activeAttemptId: null, + status, + requestedAt: now, + startedAt: started ? now : null, + completedAt: now, + checkpointId: null, + contextHandoffId: null, +}); +const summary = (value: OrchestrationV2Run) => ({ + runId: value.id, + status: value.status, + requestedAt: null, + startedAt: value.startedAt === null ? null : "2026-06-20T00:00:00.000Z", + completedAt: "2026-06-20T00:00:00.000Z", + assistantMessageId: null, +}); + +describe("offeredPromptSuggestion", () => { + it("offers the finished turn's suggestion after a queued message was promoted to Steer", () => { + const turn = run(RunId.make("run-1"), 1, "completed"); + const steered = run(RunId.make("run-2"), 2, "cancelled", false); + const suggestion = { runId: turn.id, text: "run the tests" }; + + expect(offeredPromptSuggestion(suggestion, summary(steered), [turn, steered])).toBe(suggestion); + + // A later run that did start still replaces the earlier turn's suggestion. + const stopped = run(RunId.make("run-2"), 2, "cancelled"); + expect(offeredPromptSuggestion(suggestion, summary(stopped), [turn, stopped])).toBeNull(); + + // Several never-started runs in a row still fall back to the turn that ran. + const removed = run(RunId.make("run-3"), 3, "cancelled", false); + expect(offeredPromptSuggestion(suggestion, summary(removed), [turn, steered, removed])).toBe( + suggestion, + ); + + // A newer turn that completed behind the cancelled run owns the suggestion slot. + const next = run(RunId.make("run-2"), 2, "completed"); + expect(offeredPromptSuggestion(suggestion, summary(removed), [turn, next, removed])).toBeNull(); + + // Cached detail that predates the shell's cancelled run cannot vouch for what ran in between. + const unseen = run(RunId.make("run-3"), 3, "cancelled", false); + expect(offeredPromptSuggestion(suggestion, summary(unseen), [turn])).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/usePromptSuggestion.ts b/apps/web/src/components/chat/usePromptSuggestion.ts index 9002c368d32b5..b3f52b2a7dc78 100644 --- a/apps/web/src/components/chat/usePromptSuggestion.ts +++ b/apps/web/src/components/chat/usePromptSuggestion.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId, RunId } from "@t3tools/contracts"; +import type { EnvironmentId, OrchestrationV2Run, RunId } from "@t3tools/contracts"; import { useSyncExternalStore } from "react"; import type { Thread } from "../../types"; @@ -6,6 +6,7 @@ import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useServerConfigs } from "../../state/entities"; import { orchestrationEnvironment } from "../../state/orchestration"; import { useEnvironmentQuery } from "../../state/query"; +import { announcedRun } from "./threadActivityAnnouncement"; // Dismissed suggestions, keyed by environment and run, shared by every mounted composer and kept // across remounts. Run ids derive from thread ids, which can repeat across environments. @@ -18,6 +19,35 @@ const subscribeDismissed = (listener: () => void) => { const readDismissed = () => dismissedKeys; const dismissKey = (environmentId: EnvironmentId, runId: RunId) => `${environmentId}\u0000${runId}`; +/** + * The suggestion while the run that produced it is the thread's latest completed run. A message + * promoted to Steer leaves its queued run cancelled before it started, still the latest run, so + * the comparison skips to the last run that ran. Detail can lag the shell, so it skips only once + * the runs include that cancelled run. + */ +export function offeredPromptSuggestion( + suggestion: Suggestion | null | undefined, + latestRun: Thread["latestRun"] | undefined, + runs: ReadonlyArray, +): Suggestion | null { + const run = announcedRun(latestRun ?? null, runs); + const skippedUnseenRun = + latestRun != null && + run?.runId !== latestRun.runId && + !runs.some( + (candidate) => + candidate.id === latestRun.runId && + candidate.status === "cancelled" && + candidate.startedAt === null, + ); + return suggestion && + !skippedUnseenRun && + run?.runId === suggestion.runId && + run.status === "completed" + ? suggestion + : null; +} + /** * The provider's predicted next prompt, while the run that produced it is the thread's latest * completed run and Escape has not dismissed it. @@ -25,6 +55,7 @@ const dismissKey = (environmentId: EnvironmentId, runId: RunId) => `${environmen export function usePromptSuggestion( environmentId: EnvironmentId, thread: Thread | undefined, + runs: ReadonlyArray, ): { readonly suggestion: string | null; readonly dismiss: () => void } { // Older servers decode the missing setting as false, so true also proves the method exists. const enabled = useServerConfigs().get(environmentId)?.settings.promptSuggestionsEnabled === true; @@ -40,9 +71,7 @@ export function usePromptSuggestion( : null, ); const dismissed = useSyncExternalStore(subscribeDismissed, readDismissed); - const latestRun = thread?.latestRun; - const offered = - data && latestRun?.runId === data.runId && latestRun.status === "completed" ? data : null; + const offered = offeredPromptSuggestion(data, thread?.latestRun, runs); return { suggestion: offered && !dismissed.has(dismissKey(environmentId, offered.runId)) ? offered.text : null, From 0ca73a19f06bc82d729f6d1c1c8903aa22d78868 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 02:08:20 -0800 Subject: [PATCH 0028/1253] fix(server): usage prices Claude Mythos 5.2 from its Bedrock rate (#33721) --- apps/server/src/usage/usagePricing.test.ts | 31 ++++++++++++++++++++++ apps/server/src/usage/usagePricing.ts | 10 +++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index b22625b7e490c..dc0f37cf1376e 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -277,6 +277,37 @@ describe("usage pricing", () => { } }); + it("prices a bare name listed only under Bedrock IDs at the base anthropic rate", () => { + const bedrock = (input: number, output: number, cacheRead: number, cacheCreation: number) => ({ + input_cost_per_token: input, + output_cost_per_token: output, + cache_read_input_token_cost: cacheRead, + cache_creation_input_token_cost: cacheCreation, + }); + const base = bedrock(8e-6, 4e-5, 2e-7, 1e-5); + const regional = bedrock(8.8e-6, 4.4e-5, 2.2e-7, 1.1e-5); + const govCloud = bedrock(9.6e-6, 4.8e-5, 2.4e-7, 1.2e-5); + const rows = [ + ["anthropic.claude-mythos-5-2", base], + ["global.anthropic.claude-mythos-5-2", base], + ["us.anthropic.claude-mythos-5-2", regional], + ["eu.anthropic.claude-mythos-5-2", regional], + ["us-gov.anthropic.claude-mythos-5-2", govCloud], + ["bedrock/us-gov-east-1/anthropic.claude-mythos-5-2", govCloud], + ["bedrock/us-gov-west-1/anthropic.claude-mythos-5-2", govCloud], + ] as const; + + for (const entries of [rows, rows.toReversed()]) { + const table = parseRateTable(Object.fromEntries(entries)); + expect(lookupRate(table, "claude-mythos-5-2")).toMatchObject({ + inputCostPerToken: 8e-6, + outputCostPerToken: 4e-5, + cacheReadCostPerToken: 2e-7, + cacheCreationCostPerToken: 1e-5, + }); + } + }); + it("leaves a bare name unpriced when model makers' own entries disagree", () => { const table = parseRateTable({ "openai/example-model": rate(1), diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 1f515abb736c8..609a573f54f63 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -126,9 +126,10 @@ export function parseRateTable(document: unknown): RateTable { const aliasCandidates = new Map(); for (const [key, rate] of table) { - const alias = bareModelName(key); + const slash = key.indexOf("/"); + const alias = slash === -1 ? bedrockBaseModelName(key) : bareModelName(key); if (alias.length === 0 || alias === key || table.has(alias)) continue; - const firstParty = FIRST_PARTY_PROVIDERS.has(key.slice(0, key.indexOf("/"))); + const firstParty = slash !== -1 && FIRST_PARTY_PROVIDERS.has(key.slice(0, slash)); const held = aliasCandidates.get(alias); if (held === undefined || (firstParty && !held.firstParty)) { aliasCandidates.set(alias, { rate, firstParty }); @@ -180,6 +181,11 @@ function bareModelName(key: string): string { return slash === -1 ? key : key.slice(slash + 1); } +// Only Bedrock's base ID gives a bare alias; its regional (us., eu.) and GovCloud IDs carry markups. +function bedrockBaseModelName(key: string): string { + return key.startsWith("anthropic.") ? key.slice("anthropic.".length) : key; +} + // Price Claude's bracketed context variants at the base tier, the only name the rate table knows. function stripVariantSuffix(key: string): string { const bracket = key.indexOf("["); From bcee0f5d2872ba193c66ed9ba979bbe424f89ff9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 02:08:34 -0800 Subject: [PATCH 0029/1253] fix(server): Claude turns whose CLI exits before its first frame fail instead of running forever (#33008) Co-authored-by: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 133 +++++++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 25 ++-- 2 files changed, 146 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 272d2cf10454c..1add2822a01e9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -56,6 +56,7 @@ import { } from "@t3tools/contracts"; import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -2466,7 +2467,7 @@ describe("ClaudeAdapterV2 native fork", () => { Effect.sync(() => { openedQueries.push(input); return { - messages: Stream.empty, + messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, applyFlagSettings: () => Effect.void, @@ -2808,7 +2809,7 @@ describe("ClaudeAdapterV2 native session identity", () => { Effect.sync(() => { openedQueries.push(input); return { - messages: Stream.empty, + messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, applyFlagSettings: () => Effect.void, @@ -10979,6 +10980,134 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + const openAdapterWhoseFirstQueryEndsBeforeItsFirstFrame = Effect.fnUntraced(function* ( + name: string, + ) { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: `t3-claude-v2-${name}-`, + }); + const deadStreamEnded = yield* Deferred.make(); + let opened = 0; + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + // startTurn reads the auto-compact window from settings between opening the query and + // attaching the turn; holding that read until the CLI's stream has ended pins the race. + fileSystem: { + ...fileSystem, + readFileString: (filePath, encoding) => + opened === 1 && filePath.endsWith("settings.json") + ? Deferred.await(deadStreamEnded).pipe( + Effect.andThen(fileSystem.readFileString(filePath, encoding)), + ) + : fileSystem.readFileString(filePath, encoding), + }, + path: yield* Path.Path, + random: yield* SecureRandom.SecureRandom, + idAllocator, + continuationRequests: { offer: () => Effect.void }, + queryRunner: { + allocateSessionId: Effect.succeed(`native-${name}`), + open: () => + Effect.gen(function* () { + opened += 1; + const sdkMessages = yield* Queue.unbounded(); + return { + // The first CLI exits without writing a frame. + messages: + opened === 1 + ? Stream.empty.pipe(Stream.ensuring(Deferred.succeed(deadStreamEnded, undefined))) + : Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(sdkMessages), + }; + }), + forkSession: () => Effect.die("unused forkSession"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make(`thread-${name}`); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make(`provider-session-${name}`), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const now = yield* DateTime.now; + const turnInput = (attempt: string, providerTurnOrdinal: number) => + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make(`attempt-${name}-${attempt}`), + text: "Continue with the plan.", + attachments: [], + providerTurnOrdinal, + }); + return { runtime, turnInput }; + }); + + it.effect("fails a turn whose Claude query ends before its first frame", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* openAdapterWhoseFirstQueryEndsBeforeItsFirstFrame( + "claude-query-dies-before-frame", + ); + const started = yield* harness.runtime + .startTurn(harness.turnInput("a", 1)) + .pipe(Effect.exit); + assert.isTrue( + Exit.isFailure(started), + "a turn whose Claude query ended before its first frame started running on the dead query", + ); + if (Exit.isFailure(started)) { + assert.include( + Cause.pretty(started.cause), + "Claude provider query ended before the turn could start.", + ); + } + const followUp = yield* harness.runtime + .startTurn(harness.turnInput("b", 2)) + .pipe(Effect.exit); + assert.isTrue( + Exit.isSuccess(followUp), + `the turn whose query ended before its first frame is still active: ${ + Exit.isFailure(followUp) ? Cause.pretty(followUp.cause) : "" + }`, + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + it.effect( "clears the replaced sibling native thread roster when openQuery switches processes", () => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index be938673b5502..7b63b7efc0b65 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -11311,8 +11311,22 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( executedModel: undefined, }; const attachTurn = Effect.gen(function* () { - querySession.lastTurn = context; + // Set before claiming: a query that exits after the claim finalizes this turn; one that exits before it fails the start. yield* Ref.set(activeTurn, context); + const attached = yield* Ref.modify(queryContext, (current) => { + if (current?.query !== querySession.query || querySession.stopping) { + return [false, current]; + } + querySession.lastTurn = context; + return [true, current]; + }); + if (!attached) { + yield* Ref.update(activeTurn, (current) => (current === context ? null : current)); + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Claude provider query ended before the turn could start.", + }); + } if ( !isClaudeProviderContinuationTurn(turnInput) && permissionModeForClaudeRuntimePolicy(turnInput.runtimePolicy) === @@ -11389,15 +11403,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // Transcript reads suspend replay; hold SDK frames until the buffer is applied to preserve order. yield* sdkMessageLock.withPermits(1)( Effect.gen(function* () { - if ( - (yield* Ref.get(queryContext))?.query !== querySession.query || - querySession.stopping - ) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver: CLAUDE_PROVIDER, - detail: "Claude provider query ended before the continuation could start.", - }); - } yield* attachTurn; const drained = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? []; yield* Ref.update(requestedContinuations, (current) => { From fa2f4ff2a2bac5ac92e07749f6b83b0003d642a0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 02:36:22 -0800 Subject: [PATCH 0030/1253] fix(web): panel resize handles follow touch drags (#33066) Co-authored-by: Claude Opus 5.5 --- .../src/components/ThreadTerminalDrawer.tsx | 36 ++++++++----------- .../preview/RightPanelResizeHandle.tsx | 2 +- apps/web/src/components/ui/sidebar.tsx | 2 +- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 45318dd598133..049589d0b671a 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1790,6 +1790,17 @@ export default function ThreadTerminalDrawer({ }; }, [isPanel, visible]); + const resizeHandle = isPanel ? null : ( +
+ ); + if (normalizedTerminalIds.length === 0) { return (