From bf270e74e4c6144fc65648186e879d8bfdb411ed Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:01:57 -0700 Subject: [PATCH 001/219] fix(server): Claude accounts show the new Max and Ultra plan names (#27745) --- apps/server/src/provider/ClaudeProvider.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 5bc82a41d4eda..723101fd3447a 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -78,11 +78,10 @@ function claudeSubscriptionLabel(subscriptionType: string | undefined): string | switch (normalized) { case "claudemaxsubscription": - return "Max"; case "claudemax5xsubscription": - return "Max 5x"; + return "Max"; case "claudemax20xsubscription": - return "Max 20x"; + return "Ultra"; case "claudeenterprisesubscription": return "Enterprise"; case "claudeteamsubscription": @@ -93,11 +92,10 @@ function claudeSubscriptionLabel(subscriptionType: string | undefined): string | return "Free"; case "max": case "maxplan": - return "Max"; case "max5": - return "Max 5x"; + return "Max"; case "max20": - return "Max 20x"; + return "Ultra"; case "enterprise": return "Enterprise"; case "team": From 9b1d351ba0f63f527f884ffb3f2e394f50388764 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 12:22:13 -0700 Subject: [PATCH 002/219] perf(server): busy threads stop decoding settled subagents and idle pollers skip work (#27711) Co-authored-by: Claude Opus 5.5 --- apps/server/src/mcp/OrchestratorMcpService.ts | 57 ++++++-- .../Adapters/ClaudeAdapterV2.ts | 5 +- .../Adapters/CodexAdapterV2.ts | 5 +- .../DelegatedCompletionDelivery.test.ts | 85 ++++++++++-- .../src/orchestration-v2/Orchestrator.ts | 98 ++++++++++---- .../orchestration-v2/ProjectionStore.test.ts | 128 ++++++++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 92 ++++++++++++- .../ProviderTurnControlService.test.ts | 2 + .../PullRequestSyncReactor.test.ts | 1 + .../PullRequestSyncReactor.ts | 2 +- .../UsageLimitRecoveryWorker.test.ts | 70 ++++++++++ .../UsageLimitRecoveryWorker.ts | 32 ++++- apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/075_SubagentDeliveryState.ts | 20 +++ .../reconcileV2PreviewMigration.test.ts | 2 + .../pullRequest/PullRequestService.test.ts | 33 ++++- .../src/pullRequest/PullRequestService.ts | 39 ++++++ .../scheduledTasks/ScheduledTaskService.ts | 34 +---- apps/server/src/scheduling/DueHint.ts | 30 ++++ 19 files changed, 645 insertions(+), 92 deletions(-) create mode 100644 apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.test.ts create mode 100644 apps/server/src/persistence/Migrations/075_SubagentDeliveryState.ts create mode 100644 apps/server/src/scheduling/DueHint.ts diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index d8dacb35a0617..02f63741728b9 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -513,6 +513,21 @@ function pendingTaskApproval( return approval; } +/** Every task `directAppOwnedChildTasks` can return, so callers load only those. */ +function directChildTaskIds( + target: Pick, + returnedRunIds: ReadonlySet, +): ReadonlyArray { + const ids = new Set(); + if (target.thread.forkedFrom?.type === "node") ids.add(target.thread.forkedFrom.nodeId); + for (const run of target.runs) { + if (returnedRunIds.has(run.id) && run.delegatedTaskId !== undefined) { + ids.add(run.delegatedTaskId); + } + } + return [...ids]; +} + function directAppOwnedChildTasks( parent: Pick, target: Pick, @@ -1016,7 +1031,6 @@ const make = Effect.gen(function* () { [ "runs", "messages", - "subagents", "providerThreads", "providerTurns", "runtimeRequests", @@ -1034,6 +1048,15 @@ const make = Effect.gen(function* () { ), ); + // A dispatcher thread holds thousands of tasks, each with its full prompt; read only the ones named. + const loadTasks = (threadId: ThreadId, taskIds: ReadonlyArray) => + threadManagement.getThreadRecords(threadId, ["subagents"], { subagentIds: taskIds }).pipe( + Effect.map(({ subagents }) => subagents), + Effect.mapError((error) => + failure("orchestration_error", `Unable to read thread ${threadId}: ${errorMessage(error)}`), + ), + ); + // Task waits read on every wake of a parent that may hold thousands of // tasks, so they read only the task they watch and what its status needs. const loadTaskParent = (threadId: ThreadId, taskId: NodeId) => @@ -2385,7 +2408,7 @@ const make = Effect.gen(function* () { const childThreadId = current.childThreadId; if (childThreadId === null) { // The task runs in a linked environment: its thread there is stopped through the link. - const { subagents } = yield* loadProjection(scope.thread.threadId); + const subagents = yield* loadTasks(scope.thread.threadId, [input.taskId]); const remoteTask = subagents.find( (task) => task.id === input.taskId && @@ -2428,7 +2451,7 @@ const make = Effect.gen(function* () { } }); yield* assertStoppable(childThreadId); - const parentTask = parentProjection.subagents.find( + const parentTask = (yield* loadTasks(scope.thread.threadId, [input.taskId])).find( (task) => task.id === input.taskId && task.origin === "app_owned", ); const disposeCompletionDelivery = @@ -2548,9 +2571,11 @@ const make = Effect.gen(function* () { runId: parentTask.runId, subagentId: parentTask.id, }); - const { subagents } = yield* threadManagement.getThreadRecords(scope.thread.threadId, [ - "subagents", - ]); + const { subagents } = yield* threadManagement.getThreadRecords( + scope.thread.threadId, + ["subagents"], + { subagentIds: [input.taskId] }, + ); return subagents.some((task) => task.id === input.taskId && task.status === "cancelled"); }).pipe(Effect.mapError(threadManagementFailure)); if (isTerminalTaskStatus(current.status)) { @@ -2847,7 +2872,19 @@ const make = Effect.gen(function* () { page.filter((row) => row.sourceThreadId === target.thread.id).map((row) => row.item.runId), ); const tasks = - parent === undefined ? [] : directAppOwnedChildTasks(parent, target, returnedRunIds); + parent === undefined + ? [] + : directAppOwnedChildTasks( + { + thread: parent.thread, + subagents: yield* loadTasks( + parent.thread.id, + directChildTaskIds(target, returnedRunIds), + ), + }, + target, + returnedRunIds, + ); if ( parent !== undefined && scope.thread !== undefined && @@ -2978,14 +3015,16 @@ const make = Effect.gen(function* () { const ownedTask = result.timedOut && status !== "waiting" && - parent?.subagents.some( + parent !== undefined && + taskId !== undefined && + (yield* loadTasks(parent.thread.id, [taskId])).some( (task) => task.id === taskId && task.origin === "app_owned" && task.threadId === parent.thread.id && task.childThreadId === input.threadId && task.completionDelivery?.state !== "disposed", - ) === true; + ); // A child waiting on a question or approval needs an answer, not a wake. const childRequests = ownedTask ? (yield* threadManagement diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 428e44e19020c..f42f37d1ab1b1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4944,7 +4944,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); // Claude has no recovered frame; the next assistant message proves recovery. - const completeProviderRetry = Effect.fn("ClaudeAdapterV2.completeProviderRetry")(function* ( + // Runs on every model output frame and is almost always a no-op; only a pending retry gets a span. + const completeProviderRetry = Effect.fnUntraced(function* ( context: ActiveClaudeTurnContext, updatedAt: DateTime.Utc, ) { @@ -4977,7 +4978,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( startedAt: providerRetry.startedAt, updatedAt, }), - }); + }).pipe(Effect.withSpan("ClaudeAdapterV2.completeProviderRetry")); }); const rememberProviderThread = diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 59c26bd40b956..4f960b6593c76 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1945,7 +1945,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }); // Only new model output proves recovery; local item/completed can arrive during upstream retries. - const completeProviderRetry = Effect.fn("CodexAdapterV2.completeProviderRetry")(function* ( + // Runs on every model output frame and is almost always a no-op; only a pending retry gets a span. + const completeProviderRetry = Effect.fnUntraced(function* ( context: ActiveCodexTurnContext, updatedAt: DateTime.Utc, ) { @@ -1965,7 +1966,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( type: "turn_item.updated", driver: CODEX_PROVIDER, turnItem: codexRetryTurnItem(context, providerRetry, "completed", updatedAt), - }); + }).pipe(Effect.withSpan("CodexAdapterV2.completeProviderRetry")); }); const registerRootTurn = Effect.fn("CodexAdapterV2.registerRootTurn")(function* (input: { diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index ad2bc395d083f..5053e8d568767 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -159,6 +159,11 @@ const seedParentWithTerminalTask = (input: { readonly deliveryState: "delivered" | "claimed" | "acknowledged" | "disposed"; readonly completionWake?: "always" | "settled_only"; readonly deliveryTaskIds?: ReadonlyArray; + readonly parentRunStatus?: "running" | "completed"; + readonly siblingTasks?: ReadonlyArray<{ + readonly id: NodeId; + readonly deliveryState: "delivered" | "claimed"; + }>; readonly now: DateTime.Utc; }) => Effect.gen(function* () { @@ -241,10 +246,10 @@ const seedParentWithTerminalTask = (input: { userMessageId: MessageId.make(`message:seed-user:${input.threadId}`), rootNodeId: input.rootNodeId, activeAttemptId: null, - status: "running", + status: input.parentRunStatus ?? "running", requestedAt: input.now, startedAt: input.now, - completedAt: null, + completedAt: input.parentRunStatus === "completed" ? input.now : null, checkpointId: null, contextHandoffId: null, delegatedCompletion: { @@ -261,22 +266,25 @@ const seedParentWithTerminalTask = (input: { }, }, }, - { - id: EventId.make(`event:seed-task:${input.threadId}`), - type: "subagent.updated", + ...[ + { id: input.taskId, deliveryState: input.deliveryState }, + ...(input.siblingTasks ?? []), + ].map((task) => ({ + id: EventId.make(`event:seed-task:${input.threadId}:${task.id}`), + type: "subagent.updated" as const, threadId: input.threadId, runId: input.runId, - nodeId: input.taskId, + nodeId: task.id, driver, providerInstanceId: modelSelection.instanceId, occurredAt: input.now, payload: { - id: input.taskId, + id: task.id, threadId: input.threadId, runId: input.runId, parentNodeId: input.rootNodeId, - origin: "app_owned", - createdBy: "agent", + origin: "app_owned" as const, + createdBy: "agent" as const, driver, providerInstanceId: modelSelection.instanceId, providerThreadId: null, @@ -287,16 +295,16 @@ const seedParentWithTerminalTask = (input: { model: null, completionWake: input.completionWake ?? "settled_only", completionDelivery: { - state: input.deliveryState, - observedByRunId: input.deliveryState === "acknowledged" ? input.runId : null, + state: task.deliveryState, + observedByRunId: task.deliveryState === "acknowledged" ? input.runId : null, }, - status: "completed", + status: "completed" as const, result: "child finished", startedAt: input.now, completedAt: input.now, updatedAt: input.now, }, - }, + })), ], }); }); @@ -683,6 +691,57 @@ it.layer(layerTest)("delegated completion delivery repairs", (it) => { }), ); + it.effect("counts already delivered siblings in the completion notice", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:delegated-delivery-notice-count"); + const runId = RunId.make("run:delegated-delivery-notice-count"); + const firstTaskId = NodeId.make("node:delegated-delivery-notice-count-first"); + const secondTaskId = NodeId.make("node:delegated-delivery-notice-count-second"); + + yield* seedParentWithTerminalTask({ + threadId, + projectId: ProjectId.make("project:delegated-delivery-notice-count"), + runId, + rootNodeId: NodeId.make("node:delegated-delivery-notice-count-root"), + taskId: firstTaskId, + deliveryState: "claimed", + completionWake: "always", + deliveryTaskIds: [firstTaskId, secondTaskId], + parentRunStatus: "completed", + siblingTasks: [ + { id: secondTaskId, deliveryState: "claimed" }, + { + id: NodeId.make("node:delegated-delivery-notice-count-earlier"), + deliveryState: "delivered", + }, + ], + now, + }); + + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("command:delegated-delivery-notice-count"), + threadId, + messageId: MessageId.make(`message:delegated-delivery:${threadId}`), + text: "Delegated tasks reached a terminal state.", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "server", + delegatedCompletion: { parentRunId: runId, generation: 1, taskIds: [firstTaskId] }, + }); + + const projection = yield* orchestrator.getThreadProjection(threadId); + const notice = projection.turnItems.find((item) => item.type === "notification"); + assert.match( + notice?.type === "notification" ? notice.summary : "", + /^2 of 3 delegated tasks finished:/, + ); + }), + ); + it.effect.each([ { name: "oversized UTF-8 result", diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 50f9a2acc0f39..ce43fef58a48a 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1153,6 +1153,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio turnItemTypes: ["user_message", "error"], messageRoles: ["user"], omitSettledLeafNodes: true, + // Commands here read only undelivered or active tasks; a dispatcher thread keeps + // thousands of delivered ones, each carrying its full prompt. + omitSettledSubagents: true, }, ) .pipe( @@ -1167,6 +1170,23 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })), ); + // Command reads omit settled tasks, but a completion notice counts its whole cohort. + const notificationTasks = ( + threadId: ThreadId, + message: Pick, + loaded: ReadonlyArray, + ) => + message.delegatedCompletion === undefined + ? Effect.succeed(loaded) + : projectionStore + .getThreadRecords(threadId, ["subagents"], { + subagentRunIds: [message.delegatedCompletion.parentRunId], + }) + .pipe( + Effect.map((records) => records.subagents), + Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })), + ); + const readHandoffItems = (threadId: ThreadId, runIds?: ReadonlyArray) => projectionStore .getThreadRecords( @@ -2174,7 +2194,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio nodeId: rootNodeId, providerInstanceId: queuedRun.providerInstanceId, occurredAt: now, - payload: notificationTurnItem(userTurnItem, queuedMessage, projection.subagents), + payload: notificationTurnItem( + userTurnItem, + queuedMessage, + yield* notificationTasks(threadId, queuedMessage, projection.subagents), + ), }, { type: "run.updated", @@ -4677,7 +4701,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio nodeId: messageInput.nodeId, providerInstanceId: messageInput.providerInstanceId, occurredAt: now, - payload: notificationTurnItem(turnItem, message, input.projection.subagents), + payload: notificationTurnItem( + turnItem, + message, + yield* notificationTasks(input.command.threadId, message, input.projection.subagents), + ), }); }, ); @@ -6328,7 +6356,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio nodeId: rootNodeId, providerInstanceId: modelSelection.instanceId, occurredAt: now, - payload: notificationTurnItem(turnItem, message, projection.subagents), + payload: notificationTurnItem( + turnItem, + message, + yield* notificationTasks(command.threadId, message, projection.subagents), + ), }); if (preparationTurnItem !== null) { yield* emitEvent({ @@ -7222,7 +7254,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio nodeId: rootNodeId, providerInstanceId: modelSelection.instanceId, occurredAt: now, - payload: notificationTurnItem(turnItem, message, projection.subagents), + payload: notificationTurnItem( + turnItem, + message, + yield* notificationTasks(command.threadId, message, projection.subagents), + ), }); const forkResolution = nativeForkResolution ?? portableForkResolution; if (pendingForkTransfer !== undefined && forkResolution !== null) { @@ -7289,14 +7325,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio effects: Ref.Ref>, ) { const parentProjection = yield* projectionStore - .getThreadRecords(command.parentThreadId, [ - "runs", - "nodes", - "subagents", - "providerThreads", - "providerTurns", - "attempts", - ]) + .getThreadRecords( + command.parentThreadId, + ["runs", "nodes", "providerThreads", "providerTurns", "attempts"], + // The parent node must belong to the active run, whose nodes this keeps. + { omitSettledLeafNodes: true }, + ) .pipe( Effect.mapError( (cause) => @@ -7366,7 +7400,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio parentTitle: parentProjection.thread.title, prompt: command.task, ...(command.title === undefined ? {} : { title: command.title }), - ordinal: parentProjection.subagents.length + 1, + ordinal: (yield* subagentCount(command.parentThreadId)) + 1, }); const childThread: OrchestrationV2AppThread = { ...makeSubagentChildThread({ @@ -7601,7 +7635,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); if (run === undefined || run.delegatedTaskId !== undefined) return; const parent = yield* projectionStore - .getThreadRecords(parentThreadId, ["runs", "subagents", "providerTurns", "attempts"]) + .getThreadRecords(parentThreadId, ["runs", "subagents", "providerTurns", "attempts"], { + subagentIds: child.forkedFrom?.type === "node" ? [child.forkedFrom.nodeId] : [], + }) .pipe(mapDispatchError(command)); const original = parent.subagents.find( (task) => @@ -7729,13 +7765,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio events: Ref.Ref>, ) { const parentProjection = yield* projectionStore - .getThreadRecords(command.parentThreadId, [ - "runs", - "nodes", - "subagents", - "providerTurns", - "attempts", - ]) + .getThreadRecords(command.parentThreadId, ["runs", "providerTurns", "attempts"]) .pipe( Effect.mapError( (cause) => new OrchestratorProjectionError({ threadId: command.parentThreadId, cause }), @@ -7767,7 +7797,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio parentTitle: parentProjection.thread.title, prompt: command.task, ...(command.title === undefined ? {} : { title: command.title }), - ordinal: parentProjection.subagents.length + 1, + ordinal: (yield* subagentCount(command.parentThreadId)) + 1, }); const common = { threadId: command.parentThreadId, @@ -7872,11 +7902,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio >, events: Ref.Ref>, ) { + // Delivery planning reads only this task and the undelivered tasks of the queued delivery; + // the task's own node and turn item are all it updates besides. const parentProjection = yield* projectionStore .getThreadRecords( command.parentThreadId, ["runs", "messages", "subagents", "providerTurns", "nodes", "turnItems"], - { turnItemTypes: ["subagent"], messageRoles: ["user"] }, + { + turnItemTypes: ["subagent"], + messageRoles: ["user"], + omitSettledSubagents: { except: [command.taskId] }, + nodeIds: [command.taskId], + turnItemNodeIds: [command.taskId], + }, ) .pipe( Effect.mapError( @@ -8102,8 +8140,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command: Extract, events: Ref.Ref>, ) { + // Delivery planning reads only this task and the undelivered tasks of the queued delivery. const parentProjection = yield* projectionStore - .getThreadRecords(command.parentThreadId, ["subagents", "runs", "messages", "providerTurns"]) + .getThreadRecords( + command.parentThreadId, + ["subagents", "runs", "messages", "providerTurns"], + { + omitSettledSubagents: { except: [command.taskId] }, + }, + ) .pipe( Effect.mapError( (cause) => @@ -9546,6 +9591,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } }); + const subagentCount = (threadId: ThreadId) => + projectionStore + .getSubagentCount(threadId) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + const loadProjectionForCommand = ( command: OrchestrationV2Command, fields: ReadonlyArray, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 1dc7c912e7765..cfe57ab2c0cb8 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -850,6 +850,121 @@ const commandReadsSkipSettledLeafNodes = Effect.gen(function* () { nodeNames(yield* store.getThreadRecords(threadId, ["nodes"])), nodes.map(([name]) => name).toSorted(), ); + + const subagents = [ + ["delivered", settledRun, "completed", "delivered"], + ["acknowledged", settledRun, "failed", "acknowledged"], + ["disposed", settledRun, "cancelled", "disposed"], + ["pending-delivery", settledRun, "completed", "pending"], + ["claimed", settledRun, "completed", "claimed"], + ["no-delivery", settledRun, "interrupted", null], + ["running", settledRun, "running", null], + ["active-run-delivered", activeRun, "completed", "delivered"], + ] as const; + for (const [index, [name, run, status, delivery]] of subagents.entries()) { + const id = NodeId.make(`node:settled-subagents:${name}`); + yield* store.apply({ + id: EventId.make(`event:settled-subagents:item:${name}`), + type: "turn-item.updated", + threadId, + runId: run.id, + nodeId: id, + driver, + occurredAt: now, + payload: { + id: TurnItemId.make(`item:settled-subagents:${name}`), + type: "subagent", + threadId, + runId: run.id, + nodeId: id, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 100 + index, + status: status === "running" ? "running" : "completed", + title: name, + startedAt: now, + completedAt: status === "running" ? null : now, + updatedAt: now, + subagentId: id, + origin: "app_owned", + driver, + providerInstanceId, + childThreadId: null, + prompt: "Do the work", + result: null, + }, + }); + yield* store.apply({ + id: EventId.make(`event:settled-subagents:${name}`), + type: "subagent.updated", + threadId, + runId: run.id, + nodeId: id, + driver, + providerInstanceId, + occurredAt: now, + payload: { + id, + threadId, + runId: run.id, + parentNodeId: run.rootNodeId!, + origin: "app_owned", + createdBy: "agent", + driver, + providerInstanceId, + providerThreadId: null, + childThreadId: null, + nativeTaskRef: null, + prompt: "Do the work", + title: name, + model: null, + ...(delivery === null + ? {} + : { completionDelivery: { state: delivery, observedByRunId: null } }), + status, + result: null, + startedAt: now, + completedAt: status === "running" ? null : now, + updatedAt: now, + }, + }); + } + const subagentNames = (records: { + readonly subagents: ReadonlyArray<{ readonly title: string | null }>; + }) => records.subagents.map((subagent) => subagent.title ?? "").toSorted(); + assert.deepEqual( + subagentNames( + yield* store.getThreadRecords(threadId, ["subagents"], { omitSettledSubagents: true }), + ), + ["active-run-delivered", "claimed", "no-delivery", "pending-delivery", "running"], + ); + assert.deepEqual( + subagentNames(yield* store.getThreadRecords(threadId, ["subagents"])), + subagents.map(([name]) => name).toSorted(), + ); + // A command about one settled task keeps it, and reads only its node and turn item. + const deliveredId = NodeId.make("node:settled-subagents:delivered"); + const forTask = yield* store.getThreadRecords(threadId, ["subagents", "nodes", "turnItems"], { + omitSettledSubagents: { except: [deliveredId] }, + nodeIds: [NodeId.make("node:settled-leaf-nodes:settled-tool")], + turnItemNodeIds: [deliveredId], + }); + assert.deepEqual(subagentNames(forTask), [ + "active-run-delivered", + "claimed", + "delivered", + "no-delivery", + "pending-delivery", + "running", + ]); + assert.deepEqual(nodeNames(forTask), ["settled-tool"]); + assert.deepEqual( + forTask.turnItems.map((item) => item.title), + ["delivered"], + ); + assert.strictEqual(yield* store.getSubagentCount(threadId), subagents.length); }); const backgroundThreadUpdatesKeepTimestamp = Effect.gen(function* () { @@ -3535,6 +3650,12 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const projection = yield* projectionStore.getThreadProjection(threadId); assert.deepEqual(projection.runs[0]?.delegatedCompletion, run.delegatedCompletion); assert.deepEqual(projection.subagents[0]?.completionDelivery, task.completionDelivery); + assert.deepEqual( + (yield* projectionStore.getThreadRecords(threadId, ["subagents"], { + omitSettledSubagents: true, + })).subagents, + [], + ); }), ); @@ -4428,6 +4549,8 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { (row) => row.id === threadId, ), ); + assert.strictEqual(yield* store.getLimitRecoveryWakeAt(now), DateTime.toEpochMillis(reset)); + assert.isNull(yield* store.getLimitRecoveryWakeAt(reset)); const due = (yield* store.getLimitRecoveryCandidates({ ...recoveryOptions, now: reset, @@ -4450,9 +4573,14 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { (row) => row.id === threadId, ), ); + assert.strictEqual( + yield* store.getLimitRecoveryWakeAt(reset), + DateTime.toEpochMillis(DateTime.add(reset, { minutes: 1 })), + ); yield* sql`UPDATE orchestration_v2_projection_threads SET payload_json = json_set(payload_json, '$.snoozedUntil', NULL, '$.limitRecovery.autoResume', json('false')) WHERE thread_id = ${threadId}`; + assert.isNull(yield* store.getLimitRecoveryWakeAt(now)); assert.isUndefined( (yield* store.getLimitRecoveryCandidates({ ...recoveryOptions, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 88a9073117dd6..805828064b4a9 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -332,11 +332,19 @@ export interface ProjectionRecordFilter { readonly messageIds?: ReadonlyArray; readonly messageRunIds?: ReadonlyArray; readonly turnItemRunIds?: ReadonlyArray; + readonly turnItemNodeIds?: ReadonlyArray; readonly runIds?: ReadonlyArray; readonly subagentIds?: ReadonlyArray; + readonly subagentRunIds?: ReadonlyArray; + readonly nodeIds?: ReadonlyArray; readonly turnItemTypes?: ReadonlyArray; readonly turnItemStatuses?: ReadonlyArray; readonly omitSettledLeafNodes?: boolean; + /** + * Skips ended subagents whose completion was acknowledged, delivered or disposed, unless their + * run is active or `except` names them. + */ + readonly omitSettledSubagents?: true | { readonly except: ReadonlyArray }; } // A fork inherits its source's history before it has messages of its own, @@ -436,6 +444,7 @@ export interface ProjectionStoreV2Shape { options: ProjectionTimelinePageOptions, ) => Effect.Effect; readonly getMessageCount: (threadId: ThreadId) => Effect.Effect; + readonly getSubagentCount: (threadId: ThreadId) => Effect.Effect; readonly getNextTurnItemOrdinal: ( threadId: ThreadId, ) => Effect.Effect; @@ -496,6 +505,10 @@ export interface ProjectionStoreV2Shape { readonly autoResume: boolean; readonly snooze: boolean; }) => Effect.Effect, ProjectionStoreV2Error>; + /** Epoch ms of the next armed reset or snooze end after `now`, when a waiting thread becomes a candidate. */ + readonly getLimitRecoveryWakeAt: ( + now: DateTime.Utc, + ) => Effect.Effect; readonly getSettlementCandidates: ( threadId?: ThreadId, ) => Effect.Effect, ProjectionStoreV2Error>; @@ -2107,6 +2120,7 @@ export const layer: Layer.Layer = started_at, completed_at, updated_at, + completion_delivery_state, payload_json ) VALUES ( @@ -2124,6 +2138,7 @@ export const layer: Layer.Layer = ${nullableStringField(payload, "startedAt")}, ${nullableStringField(payload, "completedAt")}, ${stringField(payload, "updatedAt")}, + ${event.payload.completionDelivery?.state ?? null}, ${payloadJson} ) ON CONFLICT(subagent_id) @@ -2141,6 +2156,10 @@ export const layer: Layer.Layer = started_at = excluded.started_at, completed_at = excluded.completed_at, updated_at = excluded.updated_at, + completion_delivery_state = COALESCE( + excluded.completion_delivery_state, + orchestration_v2_projection_subagents.completion_delivery_state + ), payload_json = CASE WHEN json_type(excluded.payload_json, '$.completionDelivery') IS NULL AND json_type(orchestration_v2_projection_subagents.payload_json, '$.completionDelivery') IS NOT NULL @@ -2980,7 +2999,12 @@ export const layer: Layer.Layer = const boundedTurnItemRows: ReadonlyArray = (fields !== undefined && !fields.includes("turnItems")) || (window === undefined && - matchesNothing(filter?.turnItemTypes, filter?.turnItemStatuses, filter?.turnItemRunIds)) + matchesNothing( + filter?.turnItemTypes, + filter?.turnItemStatuses, + filter?.turnItemRunIds, + filter?.turnItemNodeIds, + )) ? [] : window === undefined ? yield* sql` @@ -2990,6 +3014,7 @@ export const layer: Layer.Layer = ${filter?.turnItemTypes === undefined ? sql`` : sql`AND type IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemTypes)}))`} ${filter?.turnItemStatuses === undefined ? sql`` : sql`AND status IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemStatuses)}))`} ${filter?.turnItemRunId === undefined ? sql`` : sql`AND run_id = ${filter.turnItemRunId}`} + ${filter?.turnItemNodeIds === undefined ? sql`` : sql`AND node_id IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemNodeIds)}))`} ${filter?.turnItemRunIds === undefined ? sql`` : sql`AND (run_id IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemRunIds.filter((id): id is RunId => id !== null))})) OR (${filter.turnItemRunIds.includes(null) ? 1 : 0} = 1 AND run_id IS NULL))`} ORDER BY ordinal ASC, turn_item_id ASC ` @@ -3216,13 +3241,15 @@ export const layer: Layer.Layer = OR run_id IN (SELECT value FROM json_each(${cohortRunIds}))) ORDER BY run_id ASC, attempt_ordinal ASC `, - fields !== undefined && !fields.includes("nodes") + (fields !== undefined && !fields.includes("nodes")) || + (window === undefined && matchesNothing(filter?.nodeIds)) ? Effect.succeed([]) : window === undefined ? sql` SELECT payload_json FROM orchestration_v2_projection_nodes AS node WHERE node.thread_id = ${threadId} + ${filter?.nodeIds === undefined ? sql`` : sql`AND node.node_id IN (SELECT value FROM json_each(${encodeIdList(filter.nodeIds)}))`} ${ filter?.omitSettledLeafNodes === true ? sql`AND NOT ( @@ -3253,14 +3280,34 @@ export const layer: Layer.Layer = ), ), (fields !== undefined && !fields.includes("subagents")) || - (window === undefined && matchesNothing(filter?.subagentIds)) + (window === undefined && + (matchesNothing(filter?.subagentIds) || matchesNothing(filter?.subagentRunIds))) ? Effect.succeed([]) : window === undefined ? sql` SELECT payload_json - FROM orchestration_v2_projection_subagents + FROM orchestration_v2_projection_subagents AS subagent WHERE thread_id = ${threadId} ${filter?.subagentIds === undefined ? sql`` : sql`AND subagent_id IN (SELECT value FROM json_each(${encodeIdList(filter.subagentIds)}))`} + ${filter?.subagentRunIds === undefined ? sql`` : sql`AND run_id IN (SELECT value FROM json_each(${encodeIdList(filter.subagentRunIds)}))`} + ${ + filter?.omitSettledSubagents !== undefined + ? sql`AND (${ + filter.omitSettledSubagents === true + ? sql`0` + : sql`subagent_id IN (SELECT value FROM json_each(${encodeIdList(filter.omitSettledSubagents.except)}))` + } OR NOT ( + subagent.status IN ('completed', 'failed', 'cancelled', 'interrupted') + AND COALESCE(subagent.completion_delivery_state, '') + IN ('acknowledged', 'delivered', 'disposed') + AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runs AS run + WHERE run.run_id = subagent.run_id + AND run.status IN ('preparing', 'queued', 'starting', 'running', 'waiting') + ) + ))` + : sql`` + } ORDER BY COALESCE(started_at, ''), subagent_id ASC ` : sql` @@ -3863,6 +3910,34 @@ export const layer: Layer.Layer = Effect.mapError((cause) => new ProjectionStoreSetupError({ cause })), ); + const getLimitRecoveryWakeAt = Effect.fn("ProjectionStore.getLimitRecoveryWakeAt")( + function* (now: DateTime.Utc) { + const nowIso = DateTime.formatIso(now); + const rows = yield* sql<{ + readonly reset_ms: number | null; + readonly snooze_ms: number | null; + }>` + SELECT + MIN(CASE WHEN julianday(reset_at) > julianday(${nowIso}) + THEN (julianday(reset_at) - 2440587.5) * 86400000 END) AS reset_ms, + MIN(CASE WHEN julianday(snoozed_until) > julianday(${nowIso}) + THEN (julianday(snoozed_until) - 2440587.5) * 86400000 END) AS snooze_ms + FROM ( + SELECT json_extract(payload_json, '$.limitRecovery.resetAt') AS reset_at, + json_extract(payload_json, '$.snoozedUntil') AS snoozed_until + FROM orchestration_v2_projection_threads + WHERE deleted_at IS NULL + AND json_extract(payload_json, '$.limitRecovery.autoResume') = 1 + ) + `; + const wakes = [rows[0]?.reset_ms, rows[0]?.snooze_ms].filter( + (value): value is number => value != null, + ); + return wakes.length === 0 ? null : Math.round(Math.min(...wakes)); + }, + Effect.mapError((cause) => new ProjectionStoreSetupError({ cause })), + ); + const getRecoveryThreadIds = Effect.fn("ProjectionStore.getRecoveryThreadIds")( function* (kind: ProjectionRecoveryKind) { const candidates = (() => { @@ -5204,6 +5279,13 @@ export const layer: Layer.Layer = Effect.map((rows) => rows[0]?.count ?? 0), Effect.mapError(controlReadError(threadId)), ); + const getSubagentCount: ProjectionStoreV2Shape["getSubagentCount"] = (threadId) => + sql<{ + count: number; + }>`SELECT COUNT(*) AS count FROM orchestration_v2_projection_subagents WHERE thread_id = ${threadId}`.pipe( + Effect.map((rows) => rows[0]?.count ?? 0), + Effect.mapError(controlReadError(threadId)), + ); const getNextTurnItemOrdinal: ProjectionStoreV2Shape["getNextTurnItemOrdinal"] = (threadId) => sql<{ ordinal: number | null }>`SELECT MAX(ordinal) AS ordinal FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId}`.pipe( @@ -6918,6 +7000,7 @@ export const layer: Layer.Layer = hasUnpairedRunInterruptRequest: readOnReader(hasUnpairedRunInterruptRequest), isNodeStored: readOnReader(isNodeStored), getMessageCount: readOnReader(getMessageCount), + getSubagentCount: readOnReader(getSubagentCount), getNextTurnItemOrdinal: readOnReader(getNextTurnItemOrdinal), getTurnItem: readOnReader(getTurnItem), getThreadRecords: (threadId, fields, filter) => @@ -6926,6 +7009,7 @@ export const layer: Layer.Layer = getPlan: readOnReader(getPlan), getProviderControlContext: readOnReader(getProviderControlContext), getLimitRecoveryCandidates: readOnReader(getLimitRecoveryCandidates), + getLimitRecoveryWakeAt: readOnReader(getLimitRecoveryWakeAt), getRecoveryThreadIds: readOnReader(getRecoveryThreadIds), getUnreadableThreadIds: readOnReader(getUnreadableThreadIds), getThreadSnapshot: readOnReader(getThreadSnapshot), diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 65da8b23676b0..7932a03809c94 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -210,6 +210,7 @@ it.effect( ProjectionStore.ProjectionStoreV2.of({ apply: () => Effect.void, getLimitRecoveryCandidates: () => Effect.die("unused getLimitRecoveryCandidates"), + getLimitRecoveryWakeAt: () => Effect.die("unused getLimitRecoveryWakeAt"), getOpenRemoteDelegatedTasks: Effect.die("unused getOpenRemoteDelegatedTasks"), getShellSnapshot: () => Effect.die("unused getShellSnapshot"), listActiveSubagentThreadIds: () => Effect.die("unused listActiveSubagentThreadIds"), @@ -235,6 +236,7 @@ it.effect( getThreadHistoryPage: () => Effect.die("unused"), getTimelinePage: () => Effect.die("Unused timeline read"), getMessageCount: () => Effect.die("unused message count"), + getSubagentCount: () => Effect.die("unused subagent count"), getNextTurnItemOrdinal: () => Effect.die("unused ordinal read"), getTurnItem: () => Effect.die("unused turn item read"), getThreadRecords: () => Effect.die("unused record read"), diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index eccb107bc2516..a0a284b5db5d4 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -222,6 +222,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: const layerDependencies = Layer.mergeAll( Layer.mock(PullRequestService.PullRequestService)({ + withSharedProjectReads: (effect) => effect, summary, stack, invalidate: options.invalidate ?? (() => Effect.void), diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index ab3c0dca08adc..2c97b1867ef48 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -599,7 +599,7 @@ export const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker((scope: "all" | "requested") => Effect.suspend(() => { if (scope === "requested") requestedSweepQueued = false; - return sweep(scope); + return pullRequests.withSharedProjectReads(sweep(scope)); }).pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))), ); diff --git a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.test.ts b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.test.ts new file mode 100644 index 0000000000000..2e077314b8212 --- /dev/null +++ b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.test.ts @@ -0,0 +1,70 @@ +import { assert, it } from "@effect/vitest"; +import type { OrchestrationV2DomainEvent } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as Scheduler from "../scheduling/Scheduler.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ThreadManagement from "./ThreadManagementService.ts"; +import * as UsageLimitRecoveryWorker from "./UsageLimitRecoveryWorker.ts"; + +it.effect("idle sweeps skip SQL until the next wake time or a relevant event", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-07-01T09:00:00.000Z")); + const startMs = Date.parse("2026-07-01T09:00:00.000Z"); + const queries = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); + const layer = UsageLimitRecoveryWorker.layer.pipe( + Layer.provide( + Layer.mergeAll( + Scheduler.layer, + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getLimitRecoveryCandidates: () => + Clock.currentTimeMillis.pipe( + Effect.flatMap((nowMs) => Queue.offer(queries, nowMs - startMs)), + Effect.as([]), + ), + // One armed thread resets at 60s. + getLimitRecoveryWakeAt: (now) => + Effect.succeed( + DateTime.toEpochMillis(now) < startMs + 60_000 ? startMs + 60_000 : null, + ), + }), + Layer.mock(ThreadManagement.ThreadManagementService)({}), + Layer.mock(Orchestrator.OrchestratorV2)({ + streamDomainEvents: Stream.fromQueue(events), + }), + Layer.mock(ServerSettings.ServerSettingsService)({ + getSettings: Effect.succeed({ + autoResumeLimitedThreads: true, + snoozeLimitedThreads: false, + } as never), + streamChanges: Stream.never, + }), + ), + ), + ); + yield* Effect.gen(function* () { + assert.equal(yield* Queue.take(queries), 0); + // Eleven idle ticks before the recorded wake time, then the wake tick itself. + yield* TestClock.adjust("60 seconds"); + assert.equal(yield* Queue.take(queries), 60_000); + yield* Queue.offer(events, { + type: "turn-item.updated", + payload: { type: "assistant_message" }, + } as never); + yield* TestClock.adjust("30 seconds"); + yield* Queue.offer(events, { type: "run.updated", payload: { status: "failed" } } as never); + yield* TestClock.adjust("5 seconds"); + // A streaming item at 90s does not wake it; the failed run does on the 95s tick. + assert.equal(yield* Queue.take(queries), 95_000); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts index b6ebedfec4ca5..224eecbbe5f69 100644 --- a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts +++ b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts @@ -2,7 +2,11 @@ import { CommandId, MessageId, type OrchestrationV2Command } from "@t3tools/cont import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import type { OrchestrationV2DomainEvent } from "@t3tools/contracts"; +import * as DueHint from "../scheduling/DueHint.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; +import { forkParked } from "../serverActivation.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as Orchestrator from "./Orchestrator.ts"; @@ -71,20 +75,39 @@ export function limitRecoveryCommand( }; } +// Events that can make a thread a candidate or change its command; streaming items cannot. +const affectsLimitRecovery = (event: OrchestrationV2DomainEvent) => + event.type.startsWith("thread.") || + event.type === "run.created" || + event.type === "run.updated" || + event.type === "runtime-request.updated" || + event.type.startsWith("provider-session.") || + (event.type === "turn-item.updated" && event.payload.type === "error"); + const makeSweep = Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; const threads = yield* ThreadManagement.ThreadManagementService; const orchestrator = yield* Orchestrator.OrchestratorV2; const settings = yield* ServerSettings.ServerSettingsService; + const due = DueHint.make(); + yield* forkParked( + Stream.runForEach(orchestrator.streamDomainEvents, (event) => + affectsLimitRecovery(event) ? due.invalidate : Effect.void, + ), + ); + yield* forkParked(Stream.runForEach(settings.streamChanges, () => due.invalidate)); return Effect.fn("UsageLimitRecoveryWorker.sweep")(function* () { - const preferences = yield* settings.getSettings; const now = yield* DateTime.now; + const nowMs = DateTime.toEpochMillis(now); + if (yield* due.isIdle(nowMs)) return; + const readVersion = yield* due.version; + const preferences = yield* settings.getSettings; const candidates = yield* projections.getLimitRecoveryCandidates({ now, autoResume: preferences.autoResumeLimitedThreads, snooze: preferences.snoozeLimitedThreads, }); - const nowMs = DateTime.toEpochMillis(now); + let acted = false; for (const thread of candidates) { const command = limitRecoveryCommand( thread, @@ -93,6 +116,7 @@ const makeSweep = Effect.gen(function* () { preferences.snoozeLimitedThreads, ); if (command === null) continue; + acted = true; yield* Effect.gen(function* () { if (command.type === "thread.metadata.update" && command.limitRecovery != null) { yield* orchestrator.autoArmLimitRecovery({ @@ -112,6 +136,10 @@ const makeSweep = Effect.gen(function* () { ), ); } + // A sweep that acted, or failed to, re-checks on the next tick. + if (acted) return; + const wakeAt = yield* projections.getLimitRecoveryWakeAt(now); + yield* due.record(readVersion, wakeAt ?? Infinity, nowMs); }); }); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 6a865a8fc89a9..1f3fb4bc6b8bc 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -77,6 +77,7 @@ import Migration0071 from "./Migrations/071_PeerLinkFence.ts"; import Migration0072 from "./Migrations/072_WaitingLinkRequestIndex.ts"; import Migration0073 from "./Migrations/073_ActiveTurnItemIndexes.ts"; import Migration0074 from "./Migrations/074_ExcludeAttachmentPlaceholdersFromSearch.ts"; +import Migration0075 from "./Migrations/075_SubagentDeliveryState.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -154,6 +155,7 @@ const migrationEntries = [ [72, "WaitingLinkRequestIndex", Migration0072], [73, "ActiveTurnItemIndexes", Migration0073], [74, "ExcludeAttachmentPlaceholdersFromSearch", Migration0074], + [75, "SubagentDeliveryState", Migration0075], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/075_SubagentDeliveryState.ts b/apps/server/src/persistence/Migrations/075_SubagentDeliveryState.ts new file mode 100644 index 0000000000000..4406911cc8a8c --- /dev/null +++ b/apps/server/src/persistence/Migrations/075_SubagentDeliveryState.ts @@ -0,0 +1,20 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Command reads skip settled subagents by delivery state. Read from payload_json, that parsed + // every stored prompt; an added column sits after the payload's overflow pages, so the index + // must cover the whole filter. + yield* sql`ALTER TABLE orchestration_v2_projection_subagents ADD COLUMN completion_delivery_state TEXT`; + yield* sql` + UPDATE orchestration_v2_projection_subagents + SET completion_delivery_state = json_extract(payload_json, '$.completionDelivery.state') + WHERE json_type(payload_json, '$.completionDelivery') IS NOT NULL + `; + yield* sql` + CREATE INDEX orchestration_v2_projection_subagents_settled_idx + ON orchestration_v2_projection_subagents(thread_id, status, completion_delivery_state, run_id) + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 472c09d728ac4..4fefa61ec010f 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -54,6 +54,7 @@ describe("V2 preview upgrade", () => { [72, "WaitingLinkRequestIndex"], [73, "ActiveTurnItemIndexes"], [74, "ExcludeAttachmentPlaceholdersFromSearch"], + [75, "SubagentDeliveryState"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -151,6 +152,7 @@ describe("V2 preview upgrade", () => { [72, "WaitingLinkRequestIndex"], [73, "ActiveTurnItemIndexes"], [74, "ExcludeAttachmentPlaceholdersFromSearch"], + [75, "SubagentDeliveryState"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 3c511b292afb8..49060afb27586 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -257,6 +257,7 @@ function makeService(input: { readonly providers: ReadonlyArray; readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"]; readonly resolveRepositoryIdentity?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"]["resolve"]; + readonly onProjectRead?: () => void; }) { // Build in the test scope; per-call Effect.provide would close the marks database on service acquisition. return Effect.flatMap( @@ -280,11 +281,17 @@ function makeService(input: { }), Layer.mock(ProjectService.ProjectService)({ listShells: (options) => - Effect.succeed( - input.projects.filter((project) => options?.projectIds?.includes(project.id) ?? true), - ), + Effect.sync(() => { + input.onProjectRead?.(); + return input.projects.filter( + (project) => options?.projectIds?.includes(project.id) ?? true, + ); + }), getShell: (projectId) => - Effect.succeed(Option.fromNullishOr(input.projects.find((p) => p.id === projectId))), + Effect.sync(() => { + input.onProjectRead?.(); + return Option.fromNullishOr(input.projects.find((p) => p.id === projectId)); + }), }), Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ resolve: input.resolveRepositoryIdentity ?? (() => Effect.succeed(null)), @@ -1708,7 +1715,11 @@ it.effect("resolves a project's repository identity when its shell has none cach it.effect("routes a hosted reference to another repository through a project on that host", () => Effect.gen(function* () { const seen: Array<{ cwd: string; repository: string; host: string }> = []; + let projectReads = 0; const service = yield* makeService({ + onProjectRead: () => { + projectReads += 1; + }, projects: [ project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), ], @@ -1730,6 +1741,20 @@ it.effect("routes a hosted reference to another repository through a project on assert.strictEqual(summary.number, 7); assert.deepStrictEqual(seen, [{ cwd: "/web", repository: "acme/api", host: "github.com" }]); + + // A sync sweep resolves the project once for all of its references, then reads fresh again. + const readsPerReference = projectReads; + const read = (number: number) => + service.summary( + { projectId: "frontend" as ProjectId, host: "github.com", repository: "acme/api", number }, + { recoverTransientFailure: false }, + ); + assert.isAbove(readsPerReference, 2); + yield* service.withSharedProjectReads(Effect.all([read(8), read(9), read(10)])); + // One read of the project and one of the host's projects, for all three references. + assert.strictEqual(projectReads, readsPerReference + 2); + yield* read(11); + assert.strictEqual(projectReads, readsPerReference * 2 + 2); }), ); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index e1f0f6525fb82..a3ce2e75ddacd 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -215,6 +215,10 @@ export class PullRequestService extends Context.Service< ) => Effect.Effect; readonly subscribeRefreshes: Stream.Stream; readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect; + /** Project and remote lookups inside `effect` read once per filter, e.g. across one sync sweep. */ + readonly withSharedProjectReads: ( + effect: Effect.Effect, + ) => Effect.Effect; readonly detail: (input: PullRequestRef) => Effect.Effect; readonly preview: ( input: PullRequestRef, @@ -334,6 +338,12 @@ interface WorkspaceProjects { readonly viewerRoots: ReadonlyMap>; } +// Inherited by fibers forked inside the scope, so it is closed, not just dropped, when the scope ends. +const sharedProjectReads = Context.Reference<{ + open: boolean; + readonly reads: Map>; +} | null>("t3/PullRequestService/sharedProjectReads", { defaultValue: () => null }); + interface RepositoryBatch { readonly key: string; readonly entries: ReadonlyArray; @@ -828,6 +838,34 @@ export const make = Effect.gen(function* () { const listWorkspaceProjects = ( filter: Pick, + ): Effect.Effect => + Effect.flatMap(sharedProjectReads, (shared) => { + if (shared === null || !shared.open) return readWorkspaceProjects(filter); + const key = JSON.stringify([filter.projectId, filter.projectIds, filter.host]); + const existing = shared.reads.get(key); + if (existing !== undefined) return existing; + return Effect.cached(readWorkspaceProjects(filter)).pipe( + Effect.tap((read) => Effect.sync(() => shared.reads.set(key, read))), + Effect.flatten, + ); + }); + + const withSharedProjectReads = (effect: Effect.Effect) => + Effect.suspend(() => { + const shared = { open: true, reads: new Map() }; + return effect.pipe( + Effect.provideService(sharedProjectReads, shared), + Effect.ensuring( + Effect.sync(() => { + shared.open = false; + shared.reads.clear(); + }), + ), + ); + }); + + const readWorkspaceProjects = ( + filter: Pick, ): Effect.Effect => (filter.projectId === undefined ? projects.listShells( @@ -3660,6 +3698,7 @@ export const make = Effect.gen(function* () { ); return PullRequestService.of({ + withSharedProjectReads, routing, routingIdentity, withRoutingCredential, diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index f05811ed65110..7bdfef9cab6a5 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -49,6 +49,7 @@ import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; import * as Metrics from "../observability/Metrics.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as DueHint from "../scheduling/DueHint.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import { isMissedFixedTimeRun, isSameSchedule, nextScheduledRunAt } from "./Schedule.ts"; import { @@ -396,35 +397,6 @@ export const listDueTasks = Effect.fn("ScheduledTaskService.listDueTasks")(funct return tasks; }); -// Backstop for a write path that forgets to invalidate; the tick still runs every few seconds. -const DUE_HINT_MAX_AGE_MS = 10 * 60 * 1_000; - -/** - * Remembers when a poller next has work so idle scheduler ticks skip SQL. - * `invalidate` after every write that can move work earlier; a tick records - * only if no write happened since it read `version`. - */ -const makeDueHint = () => { - let version = 0; - let hint: { readonly dueAtMs: number; readonly checkedAtMs: number } | null = null; - return { - invalidate: Effect.sync(() => { - version++; - hint = null; - }), - version: Effect.sync(() => version), - isIdle: (nowMs: number) => - Effect.sync( - () => - hint !== null && hint.dueAtMs > nowMs && nowMs - hint.checkedAtMs < DUE_HINT_MAX_AGE_MS, - ), - record: (readVersion: number, dueAtMs: number, nowMs: number) => - Effect.sync(() => { - if (readVersion === version) hint = { dueAtMs, checkedAtMs: nowMs }; - }), - }; -}; - export const layer = Layer.effect( ScheduledTaskService, Effect.gen(function* () { @@ -455,8 +427,8 @@ export const layer = Layer.effect( const activeRuns = yield* Ref.make>(new Set()); // Notifications re-emit the full list; slow subscribers need only the newest dirty signal. const changesPubSub = yield* PubSub.sliding(1); - const dueTasks = makeDueHint(); - const pendingWebhooks = makeDueHint(); + const dueTasks = DueHint.make(); + const pendingWebhooks = DueHint.make(); // Every scheduled_tasks write that can change next_run_at, enabled or run state notifies. const notifyChanged = dueTasks.invalidate.pipe( Effect.andThen(PubSub.publish(changesPubSub, undefined)), diff --git a/apps/server/src/scheduling/DueHint.ts b/apps/server/src/scheduling/DueHint.ts new file mode 100644 index 0000000000000..2df8af5f0c536 --- /dev/null +++ b/apps/server/src/scheduling/DueHint.ts @@ -0,0 +1,30 @@ +import * as Effect from "effect/Effect"; + +// Backstop for a write path that forgets to invalidate; the tick still runs every few seconds. +const DUE_HINT_MAX_AGE_MS = 10 * 60 * 1_000; + +/** + * Remembers when a poller next has work so idle scheduler ticks skip SQL. + * `invalidate` after every write that can move work earlier; a tick records + * only if no write happened since it read `version`. + */ +export const make = () => { + let version = 0; + let hint: { readonly dueAtMs: number; readonly checkedAtMs: number } | null = null; + return { + invalidate: Effect.sync(() => { + version++; + hint = null; + }), + version: Effect.sync(() => version), + isIdle: (nowMs: number) => + Effect.sync( + () => + hint !== null && hint.dueAtMs > nowMs && nowMs - hint.checkedAtMs < DUE_HINT_MAX_AGE_MS, + ), + record: (readVersion: number, dueAtMs: number, nowMs: number) => + Effect.sync(() => { + if (readVersion === version) hint = { dueAtMs, checkedAtMs: nowMs }; + }), + }; +}; From 68be2f7b3273a642f3d48dc9fd723310f3fb5eef Mon Sep 17 00:00:00 2001 From: Ben Smith Date: Fri, 30 Oct 2026 14:25:26 -0600 Subject: [PATCH 003/219] fix(server): record ACP prompt response turn usage (#19958) Co-authored-by: Ben Smith Co-authored-by: GLM-5.3 (Z.ai ACP) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../Adapters/AcpAdapterV2.test.ts | 809 ++++++++++++++++++ .../src/server/adapter.ts | 2 + packages/provider-acp/src/server/adapter.ts | 76 +- 3 files changed, 882 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index b924f6efb3e70..6f504940509a0 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -76,6 +76,7 @@ import { makeTurnInput } from "./AcpAdapterV2.testkit.ts"; import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { AcpProviderCapabilitiesV2, + acpPerTurnPromptUsage, acpScopedNativeId, acpPostSettleContinuationOfferEvidence, acpPostSettleWakeEvidence, @@ -110,6 +111,124 @@ const layerTest = Layer.mergeAll( const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test"); const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); +describe("acpPerTurnPromptUsage", () => { + it("maps a per-turn sample straight onto TurnTokenUsage", () => { + assert.deepEqual( + acpPerTurnPromptUsage( + { + inputTokens: 2453, + outputTokens: 4, + totalTokens: 2457, + cachedReadTokens: 512, + cachedWriteTokens: 32, + thoughtTokens: 3, + }, + false, + "completed", + ), + { + usageScope: "main_agent", + usageStatus: "complete", + hasSubagents: false, + inputTokens: 2453, + outputTokens: 4, + cachedInputTokens: 512, + cacheCreationTokens: 32, + reasoningTokens: 3, + }, + ); + }); + + it("clamps subsets that exceed their parents and drops unset counters", () => { + assert.deepEqual( + acpPerTurnPromptUsage( + { + inputTokens: 100, + outputTokens: 10, + totalTokens: 110, + cachedReadTokens: 150, + cachedWriteTokens: 120, + thoughtTokens: 30, + }, + false, + "completed", + ), + { + usageScope: "main_agent", + usageStatus: "complete", + hasSubagents: false, + inputTokens: 100, + outputTokens: 10, + cachedInputTokens: 100, + cacheCreationTokens: 0, + reasoningTokens: 10, + }, + ); + assert.deepEqual( + acpPerTurnPromptUsage( + { inputTokens: 100, outputTokens: 10, totalTokens: 110 }, + false, + "completed", + ), + { + usageScope: "main_agent", + usageStatus: "complete", + hasSubagents: false, + inputTokens: 100, + outputTokens: 10, + }, + ); + }); + + it("caps cache writes at the input left over after cache reads", () => { + // Each counter fits under input alone, but together they exceed it. + assert.deepEqual( + acpPerTurnPromptUsage( + { + inputTokens: 100, + outputTokens: 10, + totalTokens: 110, + cachedReadTokens: 80, + cachedWriteTokens: 80, + }, + false, + "completed", + ), + { + usageScope: "main_agent", + usageStatus: "complete", + hasSubagents: false, + inputTokens: 100, + outputTokens: 10, + cachedInputTokens: 80, + cacheCreationTokens: 20, + }, + ); + }); + + it("marks present usage partial on a non-completed terminal and missing usage unavailable", () => { + assert.deepEqual( + acpPerTurnPromptUsage( + { inputTokens: 100, outputTokens: 10, totalTokens: 110 }, + true, + "interrupted", + ), + { + usageScope: "main_agent", + usageStatus: "partial", + hasSubagents: true, + inputTokens: 100, + outputTokens: 10, + }, + ); + assert.deepEqual(acpPerTurnPromptUsage(undefined, false, "completed"), { + usageScope: "main_agent", + usageStatus: "unavailable", + hasSubagents: false, + }); + }); +}); + describe("acpToolCallDiffPatch", () => { it("builds per-file patches from ACP hunks and recorded old/new text", () => { for (const operation of ["add", "delete"]) { @@ -419,6 +538,696 @@ function assertReinjectedInstructions( } describe("AcpAdapterV2", () => { + it.live("reports per-turn usage unavailable on a continuation turn that never prompts", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("acp-test-usage-continuation"); + const threadId = ThreadId.make("thread-acp-usage-continuation"); + let promptOrdinal = 0; + const promptUsages = [ + { inputTokens: 60, outputTokens: 15, totalTokens: 75 }, + { inputTokens: 130, outputTokens: 30, totalTokens: 160 }, + ]; + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + continuationRequests: { offer: () => Effect.void }, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + reportsPerTurnPromptUsage: true, + enablePostSettleContinuation: true, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + wrapRuntime: (runtime) => ({ + ...runtime, + prompt: () => { + promptOrdinal += 1; + const usage = promptUsages[promptOrdinal - 1]; + if (usage === undefined) return Effect.die("Unexpected prompt"); + return Effect.succeed({ stopReason: "end_turn" as const, usage }); + }, + }), + }), + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-usage-continuation"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + const completedUsage = (ordinal: number) => + Effect.gen(function* () { + yield* runtime.startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal }), + ); + const events = Array.from( + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ), + ); + const completed = events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + return completed?.type === "provider_turn.updated" + ? completed.providerTurn.turnTokenUsage + : null; + }); + assert.deepEqual(yield* completedUsage(1), { + usageScope: "main_agent", + usageStatus: "complete", + hasSubagents: false, + inputTokens: 60, + outputTokens: 15, + }); + // Continuation attach: drains wake traffic and finalizes without ever + // dispatching session/prompt, so it must report unavailable usage. + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now, + ordinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + messageText: "Background task completed.", + }), + ); + const continuationEvents = Array.from( + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ), + ); + const continuation = continuationEvents.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + assert.deepEqual( + continuation?.type === "provider_turn.updated" + ? continuation.providerTurn.turnTokenUsage + : null, + { usageScope: "main_agent", usageStatus: "unavailable", hasSubagents: false }, + ); + // The continuation must not disturb the next real prompt's sample. + assert.deepEqual(yield* completedUsage(3), { + usageScope: "main_agent", + usageStatus: "complete", + hasSubagents: false, + inputTokens: 130, + outputTokens: 30, + }); + assert.equal(promptOrdinal, 2, "continuation turn must not dispatch session/prompt"); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.live("retains returned usage when Stop finalizes before the prompt callback", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("acp-test-prompt-usage-stop"); + const threadId = ThreadId.make("thread-acp-prompt-usage-stop"); + const wireSettled = yield* Deferred.make(); + const releaseCompletion = yield* Deferred.make(); + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + testHooks: { + afterPromptWireSettled: () => + Deferred.succeed(wireSettled, undefined).pipe( + Effect.andThen(Deferred.await(releaseCompletion)), + ), + }, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + reportsPerTurnPromptUsage: true, + preserveRuntimeOnSettledInterrupt: true, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + wrapRuntime: (runtime) => ({ + ...runtime, + prompt: () => + Effect.succeed({ + stopReason: "end_turn" as const, + usage: { inputTokens: 60, outputTokens: 15, totalTokens: 75 }, + }), + }), + }), + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-prompt-usage-stop"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ); + yield* Deferred.await(wireSettled); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), + }); + yield* runtime.interruptTurn({ providerThread, providerTurnId }); + const completed = Array.from( + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ), + ).find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "interrupted", + ); + assert.deepEqual( + completed?.type === "provider_turn.updated" ? completed.providerTurn.turnTokenUsage : null, + { + usageScope: "main_agent", + usageStatus: "partial", + hasSubagents: false, + inputTokens: 60, + outputTokens: 15, + }, + ); + yield* Deferred.succeed(releaseCompletion, undefined); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.live("drops a late prompt response that lands after the turn finalized", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("acp-test-prompt-usage-late"); + const threadId = ThreadId.make("thread-acp-prompt-usage-late"); + const dispatched = yield* Deferred.make(); + const gate = yield* Deferred.make(); + const wireSettled = yield* Deferred.make(); + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + testHooks: { + afterPromptWireSettled: () => + Deferred.succeed(wireSettled, undefined).pipe(Effect.asVoid), + }, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + reportsPerTurnPromptUsage: true, + terminateRuntimeProcessGroupOnInterrupt: true, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + ownDetachedProcessGroup: true, + processGroupTerminationGrace: 0, + wrapRuntime: (runtime) => ({ + ...runtime, + // The response only leaves the agent after the turn has been + // force stopped and finalized: teardown does not interrupt this fiber. + prompt: () => + Deferred.succeed(dispatched, undefined).pipe( + Effect.andThen(Deferred.await(gate)), + Effect.andThen( + Effect.succeed({ + stopReason: "cancelled" as const, + usage: { inputTokens: 60, outputTokens: 15, totalTokens: 75 }, + }), + ), + ), + }), + }), + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-prompt-usage-late"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + let stopCollector = false; + const events: Array> = []; + const usageRecorded = yield* Deferred.make(); + const collector = yield* Stream.runForEach( + runtime.events.pipe(Stream.takeUntil(() => stopCollector)), + (event) => + Effect.suspend(() => { + const record = event as Record; + events.push(record); + return record.type === "provider_turn.updated" && + (record.providerTurn as Record).turnTokenUsage !== undefined + ? Deferred.succeed(usageRecorded, undefined) + : Effect.void; + }), + ).pipe(Effect.forkScoped); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + messageText: "Say hi.", + }), + ); + yield* Deferred.await(dispatched); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), + }); + // Force stop finalizes the turn without waiting for the gated prompt response. + yield* runtime.interruptTurn({ + providerThread, + providerTurnId, + requestRuntimeRestart: true, + }); + yield* Deferred.await(usageRecorded); + const usageAt = () => { + const turns = events.filter( + (event) => + event.type === "provider_turn.updated" && + (event.providerTurn as Record).turnTokenUsage !== undefined, + ); + const last = turns[turns.length - 1] as + | { providerTurn: Record } + | undefined; + return last?.providerTurn.turnTokenUsage as Record; + }; + assert.deepEqual(usageAt(), { + usageScope: "main_agent", + usageStatus: "unavailable", + hasSubagents: false, + }); + // The response lands now; the finalized turn must not adopt its counters. + yield* Deferred.succeed(gate, undefined); + yield* Deferred.await(wireSettled); + assert.deepEqual(usageAt(), { + usageScope: "main_agent", + usageStatus: "unavailable", + hasSubagents: false, + }); + stopCollector = true; + yield* Fiber.interrupt(collector); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.live("passes per-turn usage samples through", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("acp-test-per-turn-usage"); + const threadId = ThreadId.make("thread-acp-per-turn-usage"); + let promptOrdinal = 0; + // Fixture shaped after the live glm-acp-agent probe: three sequential + // prompts whose inputTokens grow by tens, not by a context per turn. + // GLM never sends cachedWriteTokens; those values are added here to + // exercise the clamp path. + const promptUsages = [ + { + inputTokens: 2432, + outputTokens: 3, + totalTokens: 2435, + cachedReadTokens: 1024, + cachedWriteTokens: 64, + thoughtTokens: 2, + }, + { + inputTokens: 2453, + outputTokens: 4, + totalTokens: 2457, + cachedReadTokens: 1045, + cachedWriteTokens: 85, + thoughtTokens: 3, + }, + { + inputTokens: 2475, + outputTokens: 25, + totalTokens: 2500, + cachedReadTokens: 1067, + cachedWriteTokens: 107, + thoughtTokens: 20, + }, + ]; + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + reportsPerTurnPromptUsage: true, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + wrapRuntime: (runtime) => ({ + ...runtime, + prompt: () => { + const usage = promptUsages[promptOrdinal++]; + if (usage === undefined) return Effect.die("Unexpected prompt"); + return Effect.succeed({ stopReason: "end_turn" as const, usage }); + }, + }), + }), + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-per-turn-usage"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + for (const [index, sample] of promptUsages.entries()) { + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now, + ordinal: index + 1, + }), + ); + const events = Array.from( + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ), + ); + const completed = events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + // Per-turn: each response's own sample maps straight onto its turn. + assert.deepEqual( + completed?.type === "provider_turn.updated" + ? completed.providerTurn.turnTokenUsage + : null, + { + usageScope: "main_agent", + usageStatus: "complete", + hasSubagents: false, + inputTokens: sample.inputTokens, + outputTokens: sample.outputTokens, + cachedInputTokens: sample.cachedReadTokens, + cacheCreationTokens: sample.cachedWriteTokens, + reasoningTokens: sample.thoughtTokens, + }, + ); + } + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.live("clamps per-turn subsets that exceed their parent counters", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("acp-test-per-turn-clamp"); + const threadId = ThreadId.make("thread-acp-per-turn-clamp"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + reportsPerTurnPromptUsage: true, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + wrapRuntime: (runtime) => ({ + ...runtime, + prompt: () => + Effect.succeed({ + stopReason: "end_turn" as const, + usage: { + inputTokens: 100, + outputTokens: 10, + totalTokens: 110, + cachedReadTokens: 150, + cachedWriteTokens: 120, + thoughtTokens: 30, + }, + }), + }), + }), + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-per-turn-clamp"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ); + const events = Array.from( + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ), + ); + const completed = events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + // TurnTokenUsage contract: input includes cache read + write and + // reasoning is a subset of output. Reads clamp to input, writes clamp + // to what reads leave of it (100 - 100 = 0 here). + assert.deepEqual( + completed?.type === "provider_turn.updated" ? completed.providerTurn.turnTokenUsage : null, + { + usageScope: "main_agent", + usageStatus: "complete", + hasSubagents: false, + inputTokens: 100, + outputTokens: 10, + cachedInputTokens: 100, + cacheCreationTokens: 0, + reasoningTokens: 10, + }, + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.live("reports per-turn usage unavailable when the agent omits usage", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("acp-test-per-turn-missing"); + const threadId = ThreadId.make("thread-acp-per-turn-missing"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + reportsPerTurnPromptUsage: true, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + wrapRuntime: (runtime) => ({ + ...runtime, + prompt: () => Effect.succeed({ stopReason: "end_turn" as const }), + }), + }), + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-per-turn-missing"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ); + const events = Array.from( + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ), + ); + const completed = events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + assert.deepEqual( + completed?.type === "provider_turn.updated" ? completed.providerTurn.turnTokenUsage : null, + { usageScope: "main_agent", usageStatus: "unavailable", hasSubagents: false }, + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.live("keeps usage unavailable for undeclared agents even when they report usage", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("acp-test-usage-undeclared"); + const threadId = ThreadId.make("thread-acp-usage-undeclared"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + wrapRuntime: (runtime) => ({ + ...runtime, + prompt: () => + Effect.succeed({ + stopReason: "end_turn" as const, + usage: { inputTokens: 60, outputTokens: 15, totalTokens: 75 }, + }), + }), + }), + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-usage-undeclared"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + for (const ordinal of [1, 2]) { + yield* runtime.startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal }), + ); + const events = Array.from( + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ), + ); + const completed = events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + // Usage semantics are unverified for an undeclared agent, so its + // counters are never trusted, on any turn. + assert.deepEqual( + completed?.type === "provider_turn.updated" + ? completed.providerTurn.turnTokenUsage + : null, + { usageScope: "main_agent", usageStatus: "unavailable", hasSubagents: false }, + ); + } + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.live.each(["details", "none"] as const)( "fails the turn on an ACP v2 idle error with %s, settles its open tool, and accepts the next prompt", (details) => diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 71de22deb5d4d..29ae43ceffaca 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -222,6 +222,8 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu ...(registryAgentId === "factory-droid" ? { extractElicitationForm: extractDroidElicitationForm } : {}), + // glm-acp-agent is the one agent whose per-turn usage a live probe has verified. + ...(registryAgentId === "glm-acp-agent" ? { reportsPerTurnPromptUsage: true } : {}), ...(registryAgentId === "mistral-vibe" ? { registerExtensions: registerMistralVibeAcpExtensions } : {}), diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index c895abe3f5ef8..4dcccd90d13f2 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -30,6 +30,7 @@ import { type RuntimeRequestId, type ThreadTokenUsageSnapshot, type ThreadId, + type TurnTokenUsage, } from "@t3tools/contracts"; import { AgentScope, AgentScopeThreadId } from "@t3tools/shared/AgentScope"; import { HTML_RENDER_TOOL_NAME } from "@t3tools/shared/htmlRender"; @@ -217,6 +218,43 @@ class AcpModelSelectionError extends Schema.TaggedError( } } +/** + * Maps one per-turn usage sample (flavors with reportsPerTurnPromptUsage) + * directly onto TurnTokenUsage: the response's counters already cover exactly + * this prompt. + */ +export function acpPerTurnPromptUsage( + usage: EffectAcpSchema.Usage | null | undefined, + hasSubagents: boolean, + terminalStatus: OrchestrationV2ProviderTurn["status"], +): TurnTokenUsage { + if (usage == null) { + return { usageScope: "main_agent", usageStatus: "unavailable", hasSubagents }; + } + // TurnTokenUsage contract: input includes cache read + write and reasoning + // is a subset of output, so clamp counters that exceed their parent and + // drop the ones the agent left unset. Cache writes are capped at whatever + // cache reads leave of input, so the pair cannot exceed it. + const clampSubset = (value: number | null | undefined, parent: number) => + value == null ? undefined : Math.min(value, parent); + const cachedInputTokens = clampSubset(usage.cachedReadTokens, usage.inputTokens); + const cacheCreationTokens = clampSubset( + usage.cachedWriteTokens, + usage.inputTokens - (cachedInputTokens ?? 0), + ); + const reasoningTokens = clampSubset(usage.thoughtTokens, usage.outputTokens); + return { + usageScope: "main_agent", + usageStatus: terminalStatus === "completed" ? "complete" : "partial", + hasSubagents, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + ...(cachedInputTokens === undefined ? {} : { cachedInputTokens }), + ...(cacheCreationTokens === undefined ? {} : { cacheCreationTokens }), + ...(reasoningTokens === undefined ? {} : { reasoningTokens }), + }; +} + export interface AcpAdapterV2Flavor { readonly buildPrompt?: (input: { readonly text: string; @@ -355,6 +393,11 @@ export interface AcpAdapterV2Flavor { readonly assertComplete?: Effect.Effect; /** Interrupt the local prompt fiber before `session/cancel` (Grok wedged prompts). */ readonly interruptPromptOnCancel?: boolean; + /** + * The agent's PromptResponse.usage counters read per-turn. The ACP spec + * leaves usage semantics open, so this stays unset until proven live. + */ + readonly reportsPerTurnPromptUsage?: boolean; /** Grok can retain task_already_running until process exit. */ readonly restartRuntimeAfterInterrupt?: boolean; readonly restartRuntimeOnEveryInterrupt?: boolean; @@ -442,6 +485,7 @@ export interface AcpAdapterV2Options { }; readonly testHooks?: { readonly onDeferredFinalizeScheduled?: (debounce: Duration.Input) => Effect.Effect; + readonly afterPromptWireSettled?: () => Effect.Effect; readonly afterPromptSettledWithBackgroundWork?: () => Effect.Effect; readonly onNativeResponseLifecycle?: (event: { readonly generation?: number; @@ -1091,6 +1135,7 @@ interface ActiveAcpTurn { promptSettledStatus: "completed" | "interrupted" | "failed" | "cancelled" | null; workAccepted: boolean; readonly promptWireSettled: Deferred.Deferred; + promptUsage: EffectAcpSchema.Usage | undefined; backgroundFinalizeGeneration: number; } @@ -6918,6 +6963,17 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( status, startedAt: context.startedAt, completedAt, + ...(status === "running" || status === "pending" + ? {} + : { + // The prompt tap only captures usage for flavors that declared + // per-turn counters, so undeclared agents stay unavailable here. + turnTokenUsage: acpPerTurnPromptUsage( + context.promptUsage, + context.subagents.size > 0, + status, + ), + }), }); const terminalizeOpenRunOwnedItems = Effect.fnUntraced(function* ( @@ -7461,6 +7517,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( promptSettledStatus: null, workAccepted: false, promptWireSettled, + promptUsage: undefined, backgroundFinalizeGeneration: 0, }; const carryover = yield* Ref.getAndSet(carryoverSubagents, null); @@ -7620,12 +7677,21 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return updated; }), ), - // Wire settlement precedes the completion callback's permit request so - // settled-soft classification can observe the native return even when - // the completion fiber has not yet set promptSettled under the permit. - Effect.tap(() => - Deferred.succeed(context.promptWireSettled, undefined).pipe(Effect.asVoid), + // Settle the wire before the completion callback's permit so a settled-soft + // Stop sees the native return. Capture usage only before finalize: a late + // response must not flip a settled turn. + Effect.tap((result) => + Effect.sync(() => { + if (flavor.reportsPerTurnPromptUsage === true && !context.finalized) { + context.promptUsage = result.usage ?? undefined; + } + }).pipe( + Effect.andThen( + Deferred.succeed(context.promptWireSettled, undefined).pipe(Effect.asVoid), + ), + ), ), + Effect.tap(() => options.testHooks?.afterPromptWireSettled?.() ?? Effect.void), Effect.flatMap((result) => runRuntimeCallbackAtGeneration( promptGeneration, From 2e860972ae1740e0e82717a5fc2683513936188b Mon Sep 17 00:00:00 2001 From: Joseph Vidal <135430578+josephv123@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:25:42 -0400 Subject: [PATCH 004/219] perf(server): a checkpoint capture starts 8 git processes instead of 10 (#21315) Co-authored-by: maria-rcks --- apps/server/src/vcs/GitVcsDriver.test.ts | 120 +++++++++++++++++++++++ apps/server/src/vcs/GitVcsDriver.ts | 58 +++++++++-- 2 files changed, 169 insertions(+), 9 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 88ca1eed8f5ea..ba2b4abe50af7 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -918,6 +918,126 @@ it.effect.each(["missing", "invalid"] as const)( }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); +const captureRecordingGitCommands = Effect.fn("test.captureRecordingGitCommands")(function* ( + cwd: string, + checkpointRef: CheckpointRef, +) { + const liveProcess = yield* VcsProcess.VcsProcess; + const commands: Array = []; + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => { + let index = 0; + while (input.args[index] === "-C" || input.args[index] === "-c") index += 2; + commands.push(input.args[index]); + return liveProcess.run(input); + }, + }), + ); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + return commands; +}); + +it.effect.each(["committed", "unborn"] as const)( + "checkpoint capture reads the repository with one rev-parse (HEAD=%s)", + (head) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-processes-" }); + const git = (args: ReadonlyArray) => + driver.execute({ operation: "checkpoint-test", cwd, args }); + const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/test"); + if (head === "committed") { + yield* makeCheckpointFixture(driver, cwd); + } else { + yield* git(["init"]); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "unstaged\n"); + } + + const commands = yield* captureRecordingGitCommands(cwd, checkpointRef); + + assert.deepStrictEqual(commands, [ + "rev-parse", + "config", + ...(head === "committed" ? ["read-tree", "ls-files"] : []), + "check-ignore", + "add", + "write-tree", + "commit-tree", + "update-ref", + ]); + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); + const [, ...parents] = (yield* git([ + "rev-list", + "--parents", + "-n", + "1", + checkpointRef, + ])).stdout + .trim() + .split(" "); + assert.deepStrictEqual( + parents, + head === "committed" ? [(yield* git(["rev-parse", "HEAD"])).stdout.trim()] : [], + ); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + +it.effect("checkpoint capture outside a repository fails with the common dir lookup error", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-no-repo-" }); + + const error = yield* captureRecordingGitCommands( + cwd, + CheckpointRef.make("refs/t3/checkpoints/test"), + ).pipe(Effect.flip); + + assert.instanceOf(error, VcsProcessExitError); + assert.strictEqual(error.operation, "GitVcsDriver.checkpoints.resolveGitCommonDir"); + assert.strictEqual(error.exitCode, 128); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + +it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "checkpoint capture handles a repository path containing a newline", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-new\nline-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); + + const commands = yield* captureRecordingGitCommands(cwd, checkpointRef); + + assert.deepStrictEqual(commands, [ + "rev-parse", + "rev-parse", + "config", + "rev-parse", + "read-tree", + "ls-files", + "check-ignore", + "add", + "write-tree", + "commit-tree", + "update-ref", + ]); + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); + assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); + assert.deepStrictEqual( + (yield* fileSystem.readDirectory(path.join(cwd, ".git"))).filter((name) => + name.startsWith("t3-checkpoint-index-"), + ), + [], + ); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + it.effect("restores empty checkpoints without changing paths outside the workspace", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 74e409a8bc184..fe49a6b1391af 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -830,6 +830,45 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return path.resolve(physicalCwd, gitCommonDir); }); + // --verify prints HEAD after the paths; --quiet leaves only paths for an unborn HEAD. + const resolveCaptureRepository = Effect.fn("GitVcsDriver.resolveCaptureRepository")(function* ( + cwd: string, + ) { + const result = yield* execute({ + operation: VcsProcess.CHECKPOINT_CAPTURE_OPERATION, + cwd, + args: [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + "--git-path", + "index", + "--verify", + "--quiet", + "HEAD", + ], + allowNonZeroExit: true, + }); + const headExists = result.exitCode === 0; + const headChecked = headExists || result.exitCode === 1; + const lines = result.stdout.split("\n"); + const [gitCommonDir = "", indexPath = ""] = lines; + if (headChecked && lines.length === (headExists ? 4 : 3) && lines.at(-1) === "") { + return { + gitCommonDir: gitCommonDir.trim(), + indexPath: indexPath.trim(), + headExists, + }; + } + // Git before 2.31 echoes --path-format, and a path containing a newline spans lines. + // Separate commands read the paths as before and report a failure the same way. + return { + gitCommonDir: yield* resolveGitCommonDir(cwd), + indexPath: undefined, + headExists: headChecked ? headExists : yield* hasHeadCommit(cwd), + }; + }); + // Git defaults and macOS writeout-only can leave empty refs after crashes; flush before publication. // Set fsync first: Git 2.36 ignores unknown batch without resetting the method. // Batch fully flushes refs and equals fsync on macOS HFS+/APFS and Windows NTFS/ReFS. @@ -862,7 +901,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "-c", "splitIndex.sharedIndexExpire=never", ]; - const gitCommonDir = yield* resolveGitCommonDir(input.cwd); + const { gitCommonDir, indexPath, headExists } = yield* resolveCaptureRepository(input.cwd); const indexId = yield* crypto.randomUUIDv4().pipe(Effect.orDie); const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${indexId}`); const commitEnv: NodeJS.ProcessEnv = { @@ -881,7 +920,6 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ); yield* Effect.gen(function* () { - const headExists = yield* hasHeadCommit(input.cwd); const sparseConfig = yield* execute({ operation, cwd: input.cwd, @@ -900,17 +938,19 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( } if (headExists) { const reusedIndex = yield* Effect.gen(function* () { - const indexPath = yield* execute({ - operation, - cwd: input.cwd, - args: ["rev-parse", "--path-format=absolute", "--git-path", "index"], - }); - const { mtime } = yield* fileSystem.stat(indexPath.stdout.trim()); + const userIndexPath = + indexPath ?? + (yield* execute({ + operation, + cwd: input.cwd, + args: ["rev-parse", "--path-format=absolute", "--git-path", "index"], + })).stdout.trim(); + const { mtime } = yield* fileSystem.stat(userIndexPath); if (Option.isNone(mtime)) return false; // Stay below the source timestamp even if Date rounded up, preserving Git's racy check. const indexTime = Math.floor((mtime.value.getTime() - 1) / 1000); if (indexTime <= 0) return false; - yield* fileSystem.copyFile(indexPath.stdout.trim(), tempIndexPath); + yield* fileSystem.copyFile(userIndexPath, tempIndexPath); yield* execute({ operation, cwd: input.cwd, From f012aa6c4712367dbe750f796e44ee6779c42e73 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:25:57 -0500 Subject: [PATCH 005/219] fix(github): drop a clone URL's .git before a trailing slash or query (#21325) Co-authored-by: Claude Opus 5.5 --- .../server/gitHubRepositoryResolution.test.ts | 20 +++++++++++++++++++ .../src/server/gitHubRepositoryResolution.ts | 8 +++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts index 0e1d5fa3a27b5..9269116ea3c72 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts @@ -80,6 +80,26 @@ describe("selectGitHubBaseRepository", () => { }); }); +describe("parseGitHubRepositorySelector", () => { + it("drops a clone URL's .git suffix wherever the selector ends", () => { + for (const [selector, host, name] of [ + ["https://github.com/acme/web.git", "github.com", "web"], + ["https://github.com/acme/web.git/", "github.com", "web"], + ["https://github.com/acme/web.git?tab=readme", "github.com", "web"], + ["https://github.com/acme/web.git.git", "github.com", "web.git"], + ["https://github.com/acme/web.git.git/", "github.com", "web.git"], + ["acme/web.git/", "github.com", "web"], + ["ghe.example.com/acme/web.git/", "ghe.example.com", "web"], + ] as const) { + assert.deepStrictEqual(parseGitHubRepositorySelector(selector, "github.com"), { + host, + owner: "acme", + name, + }); + } + }); +}); + describe("gitHubApiHostForRemote", () => { it("reads an SSH alias through github.com and a real host as itself", () => { assert.strictEqual(gitHubApiHostForRemote("git@github:acme/web.git"), "github.com"); diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts index 4c903ee20e763..3376412f0c3b1 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts @@ -101,11 +101,13 @@ export function parseGitHubRepositorySelector( selector: string, defaultHost: string, ): GitHubRepositoryLocator | null { - const trimmed = selector.trim().replace(/\.git$/i, ""); + // Like gh, a clone URL's `.git` comes off the path, before any trailing slash, query or fragment. + const withoutGitSuffix = (path: string) => path.replace(/\.git\/*$/i, ""); + const trimmed = selector.trim(); if (/^https?:\/\//i.test(trimmed)) { try { const url = new URL(trimmed); - const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean); + const [owner, name, ...rest] = withoutGitSuffix(url.pathname).split("/").filter(Boolean); return owner && name && rest.length === 0 ? { host: url.host.toLowerCase(), owner, name } : null; @@ -113,7 +115,7 @@ export function parseGitHubRepositorySelector( return null; } } - const parts = trimmed.split("/").filter(Boolean); + const parts = withoutGitSuffix(trimmed).split("/").filter(Boolean); if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; const host = parts[0]?.toLowerCase(); if (parts.length === 3 && host !== undefined && isGitHubHost(host)) From 98a735373523f432a5d5a0b69237020e3641e4d8 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Fri, 30 Oct 2026 21:26:07 +0100 Subject: [PATCH 006/219] perf(server): avoid repeated Git directory discovery during worktree creation (#21337) Co-authored-by: maria-rcks Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 428 ++++++++++++++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 66 ++- 2 files changed, 476 insertions(+), 18 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index cd4b6949fad86..3566a48fc4caf 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5,8 +5,10 @@ import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, it, describe } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -49,7 +51,7 @@ const layerServerConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-vcs-driver-test-", }); const layerTest = GitVcsDriver.layer.pipe( - Layer.provide(layerServerConfig), + Layer.provideMerge(layerServerConfig), Layer.provideMerge(NodeServices.layer), ); @@ -330,6 +332,415 @@ it.effect("invalidates origin remote cache when a driver mutation adds origin", }).pipe(Effect.provide(layerTest)), ); +it.effect.each([ + { hookExitCode: 0, commonDirFailure: "none" }, + { hookExitCode: 1, commonDirFailure: "none" }, + { hookExitCode: 0, commonDirFailure: "error" }, + { hookExitCode: 1, commonDirFailure: "error" }, + { hookExitCode: 0, commonDirFailure: "non-repository" }, + { hookExitCode: 1, commonDirFailure: "non-repository" }, +])( + "invalidates sibling worktree refs and status after checkout hook exit $hookExitCode with common-dir failure $commonDirFailure", + ({ hookExitCode, commonDirFailure }) => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawnedArgs: Array> = []; + let invalidationFailure = "none"; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + spawnedArgs.push(command.args); + if (invalidationFailure !== "none" && command.args.includes("--git-common-dir")) { + if (invalidationFailure === "non-repository") return makeNonRepositoryHandle(); + return ChildProcessSpawner.makeHandle({ + ...makeNonRepositoryHandle(), + stderr: Stream.encodeText(Stream.make("fatal: could not read repository metadata")), + }); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const siblingPath = path.join(yield* makeTmpDir("git-worktrees-"), "sibling"); + yield* git(cwd, ["worktree", "add", "-b", "cache-sibling", siblingPath, "HEAD"]); + + const before = yield* driver.listRefs({ cwd: siblingPath, refresh: true }); + assert.isTrue(before.refs.find((ref) => ref.name === "cache-sibling")?.current); + const statusBefore = yield* driver.statusDetailsLocal(siblingPath); + assert.isFalse(statusBefore.hasOriginRemote); + assert.isFalse(statusBefore.isDefaultBranch); + yield* driver.listRefs({ cwd, refresh: true }); + yield* git(cwd, ["checkout", "-b", "creator-current"]); + yield* writeTextFile( + cwd, + ".git/hooks/post-checkout", + "#!/bin/sh\n" + + "git remote add origin https://example.invalid/repo.git || exit 2\n" + + "git update-ref refs/remotes/origin/cache-sibling HEAD || exit 2\n" + + "git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/cache-sibling || exit 2\n" + + `exit ${hookExitCode}\n`, + ); + yield* fs.chmod(path.join(cwd, ".git/hooks/post-checkout"), 0o755); + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "created"); + spawnedArgs.length = 0; + invalidationFailure = commonDirFailure; + + const created = yield* driver + .createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/cache-created", + }) + .pipe(Effect.result); + + assert.equal(Result.isSuccess(created), hookExitCode === 0); + if (Result.isFailure(created)) { + assert.equal(created.failure.operation, "GitVcsDriver.createWorktree"); + assert.equal(created.failure.detail, "git worktree add failed"); + assert.equal(created.failure.exitCode, hookExitCode); + } + const metadataQueries = spawnedArgs.filter((args) => args.includes("--git-common-dir")); + assert.deepStrictEqual(metadataQueries, [["rev-parse", "--git-common-dir"]]); + assert.isFalse(spawnedArgs.some((args) => args.includes("symbolic-ref"))); + invalidationFailure = "none"; + + spawnedArgs.length = 0; + const creatorStatus = yield* driver.statusDetailsLocal(cwd); + assert.equal(creatorStatus.branch, "creator-current"); + assert.isTrue(spawnedArgs.some((args) => args.includes("--git-common-dir"))); + const siblingRefs = yield* driver.listRefs({ cwd: siblingPath }); + const createdRef = siblingRefs.refs.find((ref) => ref.name === "feature/cache-created"); + assert.isDefined(createdRef?.worktreePath); + assert.equal( + NodeFS.realpathSync.native(createdRef!.worktreePath!), + NodeFS.realpathSync.native(worktreePath), + ); + assert.isTrue(siblingRefs.hasPrimaryRemote); + assert.isTrue(siblingRefs.refs.find((ref) => ref.name === "cache-sibling")?.current); + const statusAfter = yield* driver.statusDetailsLocal(siblingPath); + assert.isTrue(statusAfter.hasOriginRemote); + assert.isTrue(statusAfter.isDefaultBranch); + const creatorRefs = yield* driver.listRefs({ cwd }); + assert.isTrue(creatorRefs.refs.find((ref) => ref.name === "creator-current")?.current); + assert.isFalse(creatorRefs.refs.find((ref) => ref.name === initialBranch)?.current); + }), + ).pipe(Effect.provide(layerTest)), +); + +it.effect.each(["branch", "detached", "nested", "aliased-root", "aliased-nested"] as const)( + "discovers only the common directory during worktree creation from %s", + (location) => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawnedArgs: Array> = []; + const spawner = ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) spawnedArgs.push(command.args); + return delegate.spawn(command); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const path = yield* Path.Path; + if (location === "detached") yield* git(cwd, ["checkout", "--detach"]); + const isNested = location === "nested" || location === "aliased-nested"; + const physicalCwd = isNested ? path.join(cwd, "nested") : cwd; + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(physicalCwd, { recursive: true }); + const isAliased = location === "aliased-root" || location === "aliased-nested"; + const creationCwd = isAliased + ? path.join(yield* makeTmpDir("git-alias-"), "link") + : physicalCwd; + if (isAliased) NodeFS.symlinkSync(physicalCwd, creationCwd, "junction"); + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "cold"); + + yield* driver.createWorktree({ + cwd: creationCwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/cold", + }); + + assert.equal(spawnedArgs.filter((args) => args.includes("--git-common-dir")).length, 1); + assert.isFalse(spawnedArgs.some((args) => args.includes("--show-toplevel"))); + assert.isFalse(spawnedArgs.some((args) => args.includes("symbolic-ref"))); + spawnedArgs.length = 0; + const originalStatus = yield* driver.statusDetailsLocal(creationCwd); + assert.equal(originalStatus.branch, location === "detached" ? null : initialBranch); + const originalRefs = yield* driver.listRefs({ cwd: creationCwd }); + assert.equal(originalRefs.isAtRepositoryRoot, !isNested); + assert.deepStrictEqual( + originalRefs.refs.filter((ref) => ref.current).map((ref) => ref.name), + location === "detached" ? [] : [initialBranch], + ); + assert.equal(spawnedArgs.filter((args) => args.includes("--git-common-dir")).length, 1); + const refs = yield* driver.listRefs({ cwd: worktreePath }); + assert.isTrue(refs.refs.find((ref) => ref.name === "feature/cold")?.current); + assert.equal( + yield* git(worktreePath, ["rev-parse", "HEAD"]), + yield* git(cwd, ["rev-parse", "HEAD"]), + ); + }), + ).pipe(Effect.provide(layerTest)), +); + +it.effect.each(["bare", "unborn"] as const)( + "invalidates a %s repository after worktree creation", + (state) => + Effect.scoped( + Effect.gen(function* () { + const source = yield* makeTmpDir(); + const cwd = yield* makeTmpDir("git-discovery-fallback-"); + if (state === "bare") { + yield* initRepoWithCommit(source); + yield* git(source, ["clone", "--bare", source, cwd]); + } else { + yield* git(cwd, ["init"]); + } + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawnedArgs: Array> = []; + const spawner = ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) spawnedArgs.push(command.args); + return delegate.spawn( + state === "bare" ? ChildProcess.setEnv(command, { GIT_DIR: cwd }) : command, + ); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const path = yield* Path.Path; + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "fallback"); + const result = yield* driver + .createWorktree({ + cwd, + path: worktreePath, + refName: "HEAD", + newRefName: "feature/fallback", + }) + .pipe(Effect.result); + assert.equal(Result.isSuccess(result), state === "bare"); + if (Result.isFailure(result)) { + assert.equal(result.failure.operation, "GitVcsDriver.createWorktree.createBranch"); + assert.equal(result.failure.detail, "git worktree add failed"); + } + assert.equal(spawnedArgs.filter((args) => args.includes("--git-common-dir")).length, 1); + assert.isFalse(spawnedArgs.some((args) => args[0] === "symbolic-ref")); + spawnedArgs.length = 0; + const refs = yield* driver.listRefs({ cwd }); + assert.isTrue(refs.isRepo); + if (state === "bare") { + assert.isTrue(refs.refs.some((ref) => ref.name === "feature/fallback")); + } else { + assert.isEmpty(refs.refs); + } + assert.equal(spawnedArgs.filter((args) => args.includes("--git-common-dir")).length, 1); + }), + ).pipe(Effect.provide(layerTest)), +); + +it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "preserves embedded newlines in repository paths after worktree creation", + () => + Effect.scoped( + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const cwd = path.join(yield* makeTmpDir(), "repo\nwith-newline"); + yield* fs.makeDirectory(cwd); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "newline"); + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/newline", + }); + const status = yield* driver.statusDetailsLocal(cwd); + assert.equal(status.branch, initialBranch); + const refs = yield* driver.listRefs({ cwd }); + assert.isTrue(refs.refs.find((ref) => ref.name === initialBranch)?.current); + assert.isTrue(refs.refs.some((ref) => ref.name === "feature/newline")); + }), + ).pipe(Effect.provide(layerTest)), +); + +it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "preserves a trailing carriage return in the repository root after creation through an alias", + () => + Effect.scoped( + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const sandbox = yield* makeTmpDir(); + const repository = path.join(sandbox, "repo\r"); + const cwd = path.join(sandbox, "alias"); + yield* fs.makeDirectory(repository); + const { initialBranch } = yield* initRepoWithCommit(repository); + NodeFS.symlinkSync(repository, cwd, "junction"); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: path.join(yield* makeTmpDir("git-worktrees-"), "created"), + refName: initialBranch, + newRefName: "feature/carriage-return", + }); + const status = yield* driver.statusDetailsLocal(cwd); + assert.equal(status.workingTree.rootPath, NodeFS.realpathSync.native(repository)); + assert.equal(status.branch, initialBranch); + const refs = yield* driver.listRefs({ cwd }); + assert.isTrue(refs.isAtRepositoryRoot); + assert.isTrue(refs.refs.some((ref) => ref.name === "feature/carriage-return")); + }), + ).pipe(Effect.provide(layerTest)), +); + +it.effect( + "reads fresh metadata when another mutation completes during common-directory discovery", + () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const commit = yield* git(cwd, ["rev-parse", "HEAD"]); + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const discoveryComplete = yield* Deferred.make(); + const releaseDiscovery = yield* Deferred.make(); + let holdDiscovery = false; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const handle = yield* delegate.spawn(command); + if ( + !holdDiscovery || + !ChildProcess.isStandardCommand(command) || + !command.args.includes("--git-common-dir") + ) + return handle; + holdDiscovery = false; + const output = yield* Stream.runCollect(handle.stdout); + yield* handle.exitCode; + yield* Deferred.succeed(discoveryComplete, undefined); + yield* Deferred.await(releaseDiscovery); + return ChildProcessSpawner.makeHandle({ + ...handle, + stdout: Stream.fromIterable(output), + }); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + yield* driver.listRefs({ cwd, refresh: true }); + const path = yield* Path.Path; + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "concurrent"); + holdDiscovery = true; + const creation = yield* driver + .createWorktree({ + cwd, + path: worktreePath, + refName: "HEAD", + newRefName: "feature/concurrent", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.gen(function* () { + yield* Deferred.await(discoveryComplete); + yield* driver.switchRef({ cwd, refName: commit }); + }).pipe(Effect.ensuring(Deferred.succeed(releaseDiscovery, undefined))); + const created = yield* Fiber.join(creation); + assert.equal(created.worktree.path, worktreePath); + const refs = yield* driver.listRefs({ cwd }); + assert.isEmpty(refs.refs.filter((ref) => ref.current)); + assert.isTrue(refs.refs.some((ref) => ref.name === "feature/concurrent")); + assert.equal((yield* driver.statusDetailsLocal(cwd)).branch, null); + }), + ).pipe(Effect.provide(layerTest)), +); + +it.effect("invalidates cached metadata after interrupted worktree creation", () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const discoveryComplete = yield* Deferred.make(); + const releaseDiscovery = yield* Deferred.make(); + let holdDiscovery = false; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const handle = yield* delegate.spawn(command); + if ( + !holdDiscovery || + !ChildProcess.isStandardCommand(command) || + !command.args.includes("--git-common-dir") + ) + return handle; + holdDiscovery = false; + const output = yield* Stream.runCollect(handle.stdout); + yield* handle.exitCode; + yield* Deferred.succeed(discoveryComplete, undefined); + yield* Deferred.await(releaseDiscovery); + return ChildProcessSpawner.makeHandle({ ...handle, stdout: Stream.fromIterable(output) }); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + yield* driver.listRefs({ cwd, refresh: true }); + yield* driver.statusDetailsLocal(cwd); + yield* git(cwd, ["checkout", "-b", "creator-current"]); + const path = yield* Path.Path; + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "interrupted"); + holdDiscovery = true; + const creation = yield* driver + .createWorktree({ + cwd, + path: worktreePath, + refName: "HEAD", + newRefName: "feature/interrupted", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + const cancellation = yield* Effect.gen(function* () { + yield* Deferred.await(discoveryComplete); + return yield* Fiber.interrupt(creation).pipe(Effect.forkChild({ startImmediately: true })); + }).pipe(Effect.ensuring(Deferred.succeed(releaseDiscovery, undefined))); + yield* Fiber.join(cancellation); + const exit = yield* Fiber.await(creation); + assert.isTrue(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)); + const refs = yield* driver.listRefs({ cwd }); + assert.isTrue(refs.refs.find((ref) => ref.name === "creator-current")?.current); + const interruptedRef = refs.refs.find((ref) => ref.name === "feature/interrupted"); + assert.isDefined(interruptedRef?.worktreePath); + assert.equal( + NodeFS.realpathSync.native(interruptedRef!.worktreePath!), + NodeFS.realpathSync.native(worktreePath), + ); + assert.equal((yield* driver.statusDetailsLocal(cwd)).branch, "creator-current"); + const nextPath = path.join(yield* makeTmpDir("git-worktrees-"), "after-interruption"); + yield* driver.createWorktree({ + cwd, + path: nextPath, + refName: "HEAD", + newRefName: "feature/after", + }); + assert.isTrue( + (yield* driver.listRefs({ cwd })).refs.some((ref) => ref.name === "feature/after"), + ); + }), + ).pipe(Effect.provide(layerTest)), +); + describe("pullCurrentBranch", () => { // A fast-forward that prints over 1 MB takes thousands of files, so the real pull's output is // padded past the cap instead. @@ -2624,10 +3035,23 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(repo, ["checkout", "-b", "real-only"]); // The decoy must exist before the first lookup, which caches repository paths. - const refs = yield* (yield* GitVcsDriver.GitVcsDriver).listRefs({ cwd: link }); + const driver = yield* GitVcsDriver.GitVcsDriver; + const refs = yield* driver.listRefs({ cwd: link }); assert.isTrue(refs.isRepo); assert.isFalse(refs.refs.some((ref) => ref.name === "decoy-only")); assert.isTrue(refs.refs.some((ref) => ref.name === "real-only")); + + yield* driver.createWorktree({ + cwd: link, + path: pathService.join(yield* makeTmpDir("git-worktrees-"), "real"), + refName: "real-only", + newRefName: "feature/real-created", + }); + const createdRefs = yield* driver.listRefs({ cwd: link }); + assert.isFalse(createdRefs.isAtRepositoryRoot); + assert.isFalse(createdRefs.refs.some((ref) => ref.name === "decoy-only")); + assert.isTrue(createdRefs.refs.some((ref) => ref.name === "real-only" && ref.current)); + assert.isTrue(createdRefs.refs.some((ref) => ref.name === "feature/real-created")); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index c5bdf3d068ef9..79086ab9e9fa1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1371,9 +1371,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } }); - const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* ( - cwd: string, - ) { + const readGitCommonDir = Effect.fn("readGitCommonDir")(function* (cwd: string) { // Git reports relative paths from the physical cwd, which can differ from a symlink. const commonDirResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.resolveRepositoryPaths.commonDir", @@ -1407,9 +1405,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const resolvedGitCommonDir = path.isAbsolute(commonDirOutput) ? path.normalize(commonDirOutput) : path.resolve(physicalCwd, commonDirOutput); - const gitCommonDir = yield* fileSystem + return yield* fileSystem .realPath(resolvedGitCommonDir) .pipe(Effect.orElseSucceed(() => resolvedGitCommonDir)); + }); + + const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* ( + cwd: string, + ) { + const gitCommonDir = yield* readGitCommonDir(cwd); + if (gitCommonDir === null) return null; const [worktreeRootResult, currentBranchResult] = yield* Effect.all( [ executeGit( @@ -1547,11 +1552,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); const invalidateStatusStaticCaches = Effect.fn("GitVcsDriverCore.invalidateStatusStaticCaches")( - function* (cwd: string) { - const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( - Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), - ); - const cacheKey = repositoryPaths?.gitCommonDir ?? normalizeRepositoryPathsCacheKey(cwd); + function* (cwd: string, gitCommonDir?: string | null) { + const commonDir = + gitCommonDir === undefined + ? (yield* resolveRepositoryPaths(cwd).pipe( + Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), + ))?.gitCommonDir + : gitCommonDir; + const cacheKey = commonDir ?? normalizeRepositoryPathsCacheKey(cwd); yield* Cache.invalidate(defaultBranchCache, cacheKey); yield* Cache.invalidate(originExistsCache, cacheKey); }, @@ -3943,17 +3951,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); const invalidateListRefsSnapshot = Effect.fn("invalidateListRefsSnapshot")(function* ( cwd: string, + gitCommonDir?: string | null, ) { const repositoryPathsCacheKey = normalizeRepositoryPathsCacheKey(cwd); - const repositoryPaths = yield* Cache.get(repositoryPathsCache, repositoryPathsCacheKey); - if (repositoryPaths === null) return; - const previousGeneration = currentListRefsGeneration(repositoryPaths.gitCommonDir); - bumpListRefsGeneration(repositoryPaths.gitCommonDir); - bumpListRefsEpoch(repositoryPaths.gitCommonDir); + const commonDir = + gitCommonDir === undefined + ? (yield* Cache.get(repositoryPathsCache, repositoryPathsCacheKey))?.gitCommonDir + : gitCommonDir; + if (commonDir == null) return; + const previousGeneration = currentListRefsGeneration(commonDir); + bumpListRefsGeneration(commonDir); + bumpListRefsEpoch(commonDir); yield* Cache.invalidate( listRefsRefreshSnapshotCache, new GitRefsRefreshCacheKey({ - gitCommonDir: repositoryPaths.gitCommonDir, + gitCommonDir: commonDir, generation: previousGeneration, }), ); @@ -4824,6 +4836,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ]), ), ); + const invalidateWorktreeCreationCaches = Effect.fn( + "GitVcsDriverCore.invalidateWorktreeCreationCaches", + )(function* (cwd: string) { + let gitCommonDir = yield* readGitCommonDir(cwd).pipe( + Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), + ); + if (gitCommonDir === null) { + gitCommonDir = yield* Cache.getOption( + repositoryPathsCache, + normalizeRepositoryPathsCacheKey(cwd), + ).pipe( + Effect.map((cached) => Option.getOrNull(cached)?.gitCommonDir ?? null), + Effect.orElseSucceed(() => null), + ); + } + yield* Effect.all([ + invalidateListRefsSnapshot(cwd, gitCommonDir).pipe(Effect.ignore), + invalidateStatusStaticCaches(cwd, gitCommonDir).pipe(Effect.ignore), + ]); + }); const initRepoWithListRefsInvalidation: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = ( input, ) => @@ -4864,7 +4896,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* resolveBranchName, listRefs, createWorktree: Effect.fnUntraced(function* (input, options) { - return yield* withListRefsInvalidation(input.cwd, createWorktree(input, options)); + return yield* createWorktree(input, options).pipe( + Effect.ensuring(invalidateWorktreeCreationCaches(input.cwd)), + ); }), fetchRefIntoBranch: Effect.fnUntraced(function* (input) { return yield* withListRefsInvalidation(input.cwd, fetchRefIntoBranch(input)); From a3df6044a43e21d1b182175346f77bb471ad7ede Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:26:23 -0500 Subject: [PATCH 007/219] fix(web): preserve the active pointer in the flat Device view (#21430) Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../device/DeviceStreamView.browser.test.tsx | 208 ++++++++++++++++++ .../components/device/DeviceStreamView.tsx | 42 ++-- 2 files changed, 233 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/device/DeviceStreamView.browser.test.tsx diff --git a/apps/web/src/components/device/DeviceStreamView.browser.test.tsx b/apps/web/src/components/device/DeviceStreamView.browser.test.tsx new file mode 100644 index 0000000000000..71052227daa98 --- /dev/null +++ b/apps/web/src/components/device/DeviceStreamView.browser.test.tsx @@ -0,0 +1,208 @@ +import { RegistryContext } from "@effect/atom-react"; +import { AtomRegistry } from "effect/reactivity"; +import "~/index.css"; +import { EnvironmentId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render, stubGlobal } from "../../../test/render"; + +vi.mock(import("~/state/device"), async (original) => ({ + ...(await original()), + useDeviceHubAccess: () => access, +})); +vi.mock(import("./DevicePhoneViewport"), () => ({ + DevicePhoneViewport: () => <>, +})); +import { DeviceStreamView } from "./DeviceStreamView"; + +const access = { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true }; +function setup() { + const registry = AtomRegistry.make(); + const touches: unknown[] = []; + const image = vi.spyOn(HTMLImageElement.prototype, "src", "set").mockImplementation(() => {}); + // Select MJPEG without starting a WebCodecs decoder. Input uses the real stream client. + const decoder = stubGlobal("VideoDecoder", undefined); + Reflect.deleteProperty(globalThis, "VideoDecoder"); + const fetch = stubGlobal("fetch", () => Promise.resolve(new Response("prime"))); + // The client opens its input socket only after the MJPEG prime resolves. + type InputSocket = { + onmessage?: (event: { data: ArrayBuffer }) => void; + }; + let open!: (socket: InputSocket) => void; + const opened = new Promise((resolve) => { + open = resolve; + }); + const socket = stubGlobal( + "WebSocket", + class { + static OPEN = 1; + readyState = 1; + constructor() { + open(this); + } + onmessage?: (event: { data: ArrayBuffer }) => void; + send(message: string | Uint8Array) { + if (typeof message !== "string" && message[0] === 0x03) { + touches.push(JSON.parse(new TextDecoder().decode(message.subarray(1)))); + } + } + close() {} + }, + ); + return { + view: () => ( + +
{view()}
+
+ ), + [Symbol.dispose]() { + registry.dispose(); + image.mockRestore(); + socket[Symbol.dispose](); + fetch[Symbol.dispose](); + decoder[Symbol.dispose](); + }, + touches, + async configure(inputUnavailable = false) { + const json = new TextEncoder().encode( + JSON.stringify({ width: 400, height: 800, orientation: "portrait", inputUnavailable }), + ); + const packet = new Uint8Array(1 + json.length); + packet[0] = 0x82; + packet.set(json, 1); + const input = await opened; + input.onmessage?.({ data: Uint8Array.of(0x83).buffer }); + input.onmessage?.({ data: packet.buffer }); + }, + }; +} + +function view() { + return ( + ( + + {controls.handle?.inputConnected ? "Input connected" : "Input disconnected"} + + )} + /> + ); +} + +// router: false because the device view renders no links and never navigates. +const options = { router: false } as const; + +function flatPointer(container: HTMLElement) { + const surface = container.querySelector("canvas")!.parentElement!.parentElement!; + // Synthetic IDs exercise concurrent touches independently of the browser's capture state. + surface.setPointerCapture = () => {}; + const rect = new DOMRect(0, 0, 400, 800); + surface.getBoundingClientRect = () => rect; + return { + send(type: string, pointerId: number, x = 0.25, y = 0.25) { + surface.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + pointerId, + pointerType: "touch", + clientX: rect.left + rect.width * x, + clientY: rect.top + rect.height * y, + }), + ); + }, + }; +} + +it("keeps the first flat pointer active when a second finger moves and lifts", async () => { + using input = setup(); + const { configure, touches } = input; + await using screen = await render(input.view(), options); + await act(async () => configure()); + await expect.element(screen.getByText("Input connected", { exact: true })).toBeVisible(); + const { send } = flatPointer(screen.container); + send("pointerdown", 1); + send("pointerdown", 2, 0.75, 0.75); + send("pointermove", 2, 0.875, 0.875); + send("pointerup", 2); + send("pointercancel", 2); + send("pointermove", 1, 0.5, 0.5); + send("pointerup", 1, 0.75, 0.75); + send("pointermove", 1); + expect(touches).toEqual([ + { type: "begin", x: 0.25, y: 0.25 }, + { type: "move", x: 0.5, y: 0.5 }, + { type: "end", x: 0.75, y: 0.75 }, + ]); +}); + +it("ignores a flat pointer that goes down before input connects", async () => { + using input = setup(); + const { configure, touches } = input; + await using screen = await render(input.view(), options); + const { send } = flatPointer(screen.container); + send("pointerdown", 1); + await act(async () => configure()); + await expect.element(screen.getByText("Input connected", { exact: true })).toBeVisible(); + send("pointerdown", 2, 0.5, 0.5); + send("pointerup", 2, 0.75, 0.75); + expect(touches).toEqual([ + { type: "begin", x: 0.5, y: 0.5 }, + { type: "end", x: 0.75, y: 0.75 }, + ]); +}); + +it.each(["pointerup", "pointercancel", "lostpointercapture"])( + "ends the owned flat pointer exactly once on %s, then accepts a new pointer", + async (ending) => { + using input = setup(); + const { configure, touches } = input; + await using screen = await render(input.view(), options); + await act(async () => configure()); + await expect.element(screen.getByText("Input connected", { exact: true })).toBeVisible(); + const { send } = flatPointer(screen.container); + send("pointerdown", 7); + send("pointermove", 7, 0.5, 0.5); + send("lostpointercapture", 8, 0, 0); + // Capture loss reports its own point; the end must still use the last move. + const at = ending === "lostpointercapture" ? 0 : 0.5; + send(ending, 7, at, at); + send("lostpointercapture", 7, 0, 0); + send("pointerup", 7); + send("pointermove", 7); + send("pointerdown", 9); + send("pointerup", 9); + expect(touches).toEqual([ + { type: "begin", x: 0.25, y: 0.25 }, + { type: "move", x: 0.5, y: 0.5 }, + { type: "end", x: 0.5, y: 0.5 }, + { type: "begin", x: 0.25, y: 0.25 }, + { type: "end", x: 0.25, y: 0.25 }, + ]); + }, +); + +it("releases flat pointer ownership when input disconnects", async () => { + using input = setup(); + await using screen = await render(input.view(), options); + await act(() => input.configure()); + await expect.element(screen.getByText("Input connected", { exact: true })).toBeVisible(); + const { send } = flatPointer(screen.container); + send("pointerdown", 1); + await act(() => input.configure(true)); + await expect.element(screen.getByText("Input disconnected", { exact: true })).toBeVisible(); + await act(() => input.configure()); + await expect.element(screen.getByText("Input connected", { exact: true })).toBeVisible(); + send("pointerdown", 2, 0.5, 0.5); + send("pointerup", 2, 0.75, 0.75); + expect(input.touches).toEqual([ + { type: "begin", x: 0.25, y: 0.25 }, + { type: "begin", x: 0.5, y: 0.5 }, + { type: "end", x: 0.75, y: 0.75 }, + ]); +}); diff --git a/apps/web/src/components/device/DeviceStreamView.tsx b/apps/web/src/components/device/DeviceStreamView.tsx index 4b0b5c84ca5b9..33d7ca9d76884 100644 --- a/apps/web/src/components/device/DeviceStreamView.tsx +++ b/apps/web/src/components/device/DeviceStreamView.tsx @@ -1,7 +1,7 @@ import type { DevicePlatform, EnvironmentId } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { AsyncResult, Atom } from "effect/reactivity"; -import { useId, useRef, useState, type ReactNode } from "react"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { cn } from "~/lib/utils"; import { Button } from "~/components/ui/button"; @@ -201,7 +201,14 @@ export function DeviceStreamView(props: { ); const axElements = AsyncResult.getOrElse(axResult, () => NO_AX_ELEMENTS); - const pointerActive = useRef(false); + const activePointer = useRef<{ id: number; x: number; y: number } | null>(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears pointer ownership when its device input connection disconnects or is replaced + useEffect(() => { + if (!client || !inputState.connected) return; + return () => { + activePointer.current = null; + }; + }, [client, inputState.connected]); const normalizedPoint = (event: React.PointerEvent) => { const rect = event.currentTarget.getBoundingClientRect(); const x = (event.clientX - rect.left) / rect.width; @@ -209,6 +216,14 @@ export function DeviceStreamView(props: { return { x: Math.min(1, Math.max(0, x)), y: Math.min(1, Math.max(0, y)) }; }; + const endPointer = (event: React.PointerEvent) => { + const pointer = activePointer.current; + if (pointer?.id !== event.pointerId) return; + activePointer.current = null; + const { x, y } = event.type === "lostpointercapture" ? pointer : normalizedPoint(event); + client?.sendTouch("end", x, y); + }; + const phoneUnavailableReason = isDuo && !screen?.supportsHingeAngle ? "iPhone Duo 3D requires Device Hub 0.11.0 or newer" @@ -335,30 +350,23 @@ export function DeviceStreamView(props: { className={cn("relative select-none", showPhone && "invisible pointer-events-none")} style={{ width: frame.width, height: frame.height }} onPointerDown={(event) => { - if (event.button !== 0) return; + if (event.button !== 0 || !inputState.connected || activePointer.current !== null) + return; event.currentTarget.setPointerCapture(event.pointerId); (event.currentTarget.parentElement as HTMLElement | null)?.focus(); - pointerActive.current = true; const { x, y } = normalizedPoint(event); + activePointer.current = { id: event.pointerId, x, y }; client?.sendTouch("begin", x, y); }} onPointerMove={(event) => { - if (!pointerActive.current) return; + if (activePointer.current?.id !== event.pointerId) return; const { x, y } = normalizedPoint(event); + activePointer.current = { id: event.pointerId, x, y }; client?.sendTouch("move", x, y); }} - onPointerUp={(event) => { - if (!pointerActive.current) return; - pointerActive.current = false; - const { x, y } = normalizedPoint(event); - client?.sendTouch("end", x, y); - }} - onPointerCancel={(event) => { - if (!pointerActive.current) return; - pointerActive.current = false; - const { x, y } = normalizedPoint(event); - client?.sendTouch("end", x, y); - }} + onPointerUp={endPointer} + onPointerCancel={endPointer} + onLostPointerCapture={endPointer} >
Date: Fri, 30 Oct 2026 15:26:39 -0500 Subject: [PATCH 008/219] fix(codex): recover CLI version after failed app-server probe (#21443) Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- apps/server/src/provider/CodexProvider.ts | 67 +++++++- .../src/provider/ProviderRegistry.test.ts | 150 ++++++++++++++++++ 2 files changed, 210 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 2b2d5501881b4..65127e6e430e3 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -39,7 +39,9 @@ import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts import { buildServerProvider, COMPACT_SLASH_COMMAND, + DEFAULT_TIMEOUT_MS, providerModelsFromSettings, + spawnAndCollect, type ServerProviderDraft, } from "@t3tools/provider-core/server/snapshotProbe"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; @@ -75,6 +77,9 @@ const CODEX_CLIENT_NAME = "T3 Code"; // Handshake plus account/models/skills requests can exceed the shared 10s probe budget. const CODEX_AUTH_PROBE_TIMEOUT_MS = 20_000; const CODEX_DISCOVERY_MARGIN_MS = 250; +// `codex-cli 0.159.0`, `codex-cli 0.160.0-alpha.1` or the npm CLI's bare `0.1.4161006`. +// Keep a prerelease suffix so compatibility stays unknown for it, as it does for `initialize`. +const CODEX_CLI_VERSION_PATTERN = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?=\s|$)/u; const CODEX_PRESENTATION = { displayName: "Codex", @@ -459,22 +464,29 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { }; } -export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(function* (input: { - readonly binaryPath: string; +const codexProcessEnvironment = Effect.fn("codexProcessEnvironment")(function* (input: { readonly homePath?: string | undefined; - readonly launchArgs?: string | undefined; - readonly cwd: string; readonly environment?: NodeJS.ProcessEnv | undefined; }) { // Expand CODEX_HOME explicitly: spawn passes a literal ~ that Codex rejects. const resolvedHomePath = input.homePath ? expandHomePath(input.homePath, yield* HostProcess.HomeDirectory) : undefined; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const environment = { + return { ...codexAppServerEnvironment(input.environment), ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; +}); + +export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(function* (input: { + readonly binaryPath: string; + readonly homePath?: string | undefined; + readonly launchArgs?: string | undefined; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv | undefined; +}) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = yield* codexProcessEnvironment(input); const spawnCommand = yield* resolveSpawnCommand( input.binaryPath, codexAppServerArgs(input.launchArgs), @@ -594,6 +606,39 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; }); +// Old CLIs can exit before initialize reports the version needed for compatibility advice. +const probeCodexCliVersion = Effect.fn("probeCodexCliVersion")( + function* (input: { + readonly binaryPath: string; + readonly homePath?: string | undefined; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv | undefined; + }) { + const environment = yield* codexProcessEnvironment(input); + const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["--version"], { + env: environment, + extendEnv: true, + }); + const result = yield* spawnAndCollect( + input.binaryPath, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: input.cwd, + env: environment, + extendEnv: true, + forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), + ); + if (result.code !== 0) return null; + return CODEX_CLI_VERSION_PATTERN.exec(result.stdout)?.[1] ?? null; + }, + Effect.timeoutOption(Duration.millis(DEFAULT_TIMEOUT_MS)), + Effect.map(Option.getOrNull), + Effect.catch((cause) => + Effect.logDebug("Codex --version fallback failed.", { cause }).pipe(Effect.as(null)), + ), +); + export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: { readonly binaryPath: string; readonly homePath?: string; @@ -739,6 +784,14 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu if (Result.isFailure(probeResult)) { const error = probeResult.failure; const installed = !isCodexAppServerSpawnError(error); + const version = installed + ? yield* probeCodexCliVersion({ + binaryPath: codexSettings.binaryPath, + homePath: codexSettings.homePath, + cwd: process.cwd(), + environment: resolvedEnvironment, + }) + : null; return buildServerProvider({ presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, @@ -747,7 +800,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu skills: [], probe: { installed, - version: null, + version, status: "error", auth: { status: "unknown" }, message: installed diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 5e356c84516a8..f0560c8738d20 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1,3 +1,7 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as CodexErrors from "effect-codex-app-server/errors"; +import * as Path from "effect/Path"; +import * as FileSystem from "effect/FileSystem"; import { vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; @@ -88,6 +92,7 @@ const disabledDefaultSlots = ( ); const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); +const decodeCodexSettings = Schema.decodeSync(CodexSettings); const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); process.env.T3CODE_CURSOR_ENABLED = "1"; @@ -216,6 +221,20 @@ function layerMockSpawner( ); } +// A Codex CLI on disk whose `app-server` and `--version` behave as `script` says. +const writeFakeCodexCli = Effect.fn("writeFakeCodexCli")(function* (script: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fake-codex-" }); + const binaryPath = path.join(directory, "codex"); + yield* fs.writeFileString(binaryPath, `#!/bin/sh\n${script}\n`); + yield* fs.chmod(binaryPath, 0o755); + return { directory, binaryPath }; +}); + +// The npm `@openai/codex` 0.1 CLI treats `app-server` as a prompt and exits 1. +const OBSOLETE_CODEX_APP_SERVER = `echo "Missing OpenAI API key." >&2; exit 1`; + const codexModelCapabilities = createModelCapabilities({ optionDescriptors: [ selectDescriptor("reasoningEffort", "Reasoning", [ @@ -427,6 +446,137 @@ it.layer( assert.strictEqual(status.auth.status, "unknown"); }), ); + + it.effect("reads the version of a Codex CLI too old to run app-server (#20351)", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const codexHome = yield* FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "t3-codex-home-" })), + ); + const { binaryPath } = yield* writeFakeCodexCli( + [ + `if [ "$1" = "--version" ]; then`, + // The fallback runs with the provider's environment and CODEX_HOME. + ` [ "$CODEX_HOME" = "${codexHome}" ] && [ "$FAKE_CODEX_ENV" = "kept" ] || exit 2`, + ` echo 0.1.4161006; exit 0`, + `fi`, + OBSOLETE_CODEX_APP_SERVER, + ].join("\n"), + ); + const settings = decodeCodexSettings({ binaryPath, homePath: codexHome }); + + const status = yield* checkCodexProviderStatus(settings, undefined, { + ...process.env, + FAKE_CODEX_ENV: "kept", + }); + + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.version, "0.1.4161006"); + assert.match(status.message ?? "", /^Codex app-server provider probe failed: /); + const classified = withBundledCompatibility({ + ...status, + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + }); + assert.strictEqual(classified.compatibilityAdvisory?.status, "broken"); + }), + ); + + it.effect("keeps a prerelease --version unclassified instead of reading it as stable", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const { binaryPath } = yield* writeFakeCodexCli( + [ + `[ "$1" = "--version" ] && { echo "codex-cli 0.159.0-alpha.1"; exit 0; }`, + OBSOLETE_CODEX_APP_SERVER, + ].join("\n"), + ); + + const status = yield* checkCodexProviderStatus(decodeCodexSettings({ binaryPath })); + + assert.strictEqual(status.version, "0.159.0-alpha.1"); + const classified = withBundledCompatibility({ + ...status, + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + }); + assert.strictEqual(classified.compatibilityAdvisory?.status, "unknown"); + }), + ); + + it.effect("keeps the probe failure without a version when --version also fails", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const { binaryPath } = yield* writeFakeCodexCli( + [ + `[ "$1" = "--version" ] && { echo "codex 0.1.0" >&2; exit 3; }`, + OBSOLETE_CODEX_APP_SERVER, + ].join("\n"), + ); + + const status = yield* checkCodexProviderStatus(decodeCodexSettings({ binaryPath })); + + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.version, null); + assert.match(status.message ?? "", /^Codex app-server provider probe failed: /); + }), + ); + + it.effect("bounds a hanging Codex --version fallback and releases its process scope", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const released = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => Deferred.succeed(released, undefined)); + return ChildProcessSpawner.makeHandle({ + ...mockHandle({ stdout: "", stderr: "", code: 0 }), + exitCode: Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), + }); + }), + ); + const fiber = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.fail(new CodexErrors.CodexAppServerProcessExitedError({ code: 1 })), + ).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.forkChild, + ); + yield* Deferred.await(started); + yield* TestClock.adjust("4 seconds"); + const status = yield* Fiber.join(fiber); + assert.strictEqual(status.version, null); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + assert.match(status.message ?? "", /^Codex app-server provider probe failed: /); + assert.isTrue(yield* Deferred.isDone(released)); + }), + ); + + it.effect("does not run --version when the Codex executable cannot start", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { directory, binaryPath } = yield* writeFakeCodexCli( + `touch "$(dirname "$0")/ran"; echo 0.1.4161006`, + ); + + const status = yield* checkCodexProviderStatus(decodeCodexSettings({ binaryPath }), () => + Effect.fail( + new CodexErrors.CodexAppServerSpawnError({ + command: `${binaryPath} app-server`, + cause: new Error("spawn ENOENT"), + }), + ), + ); + + assert.strictEqual(status.installed, false); + assert.strictEqual(status.version, null); + assert.isFalse(yield* fs.exists(path.join(directory, "ran"))); + }), + ); }); describe("ProviderRegistry.layer", () => { From a17ab2e98438ea4507cd5d86bd978090ed3e2fcc Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:26:55 -0500 Subject: [PATCH 009/219] fix(server): release agent-device sessions when device_close runs (#21478) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/device/AgentDeviceTarget.ts | 36 +++ apps/server/src/device/DeviceHost.ts | 10 + .../server/src/device/DeviceMultiHost.test.ts | 35 +- apps/server/src/device/DeviceService.test.ts | 301 +++++++++++++++++- apps/server/src/device/DeviceService.ts | 124 ++++++-- apps/server/src/device/EasDeviceHost.test.ts | 81 ++++- apps/server/src/device/EasDeviceHost.ts | 7 + .../server/src/device/LocalDeviceHost.test.ts | 66 +++- apps/server/src/device/LocalDeviceHost.ts | 9 +- apps/server/src/device/SshDeviceHost.test.ts | 12 +- apps/server/src/device/SshDeviceHost.ts | 5 +- .../server/src/device/sshDeviceScript.test.ts | 89 +++++- apps/server/src/device/sshDeviceScript.ts | 17 +- 13 files changed, 711 insertions(+), 81 deletions(-) diff --git a/apps/server/src/device/AgentDeviceTarget.ts b/apps/server/src/device/AgentDeviceTarget.ts index ac66a52f6d2aa..54affde4eca93 100644 --- a/apps/server/src/device/AgentDeviceTarget.ts +++ b/apps/server/src/device/AgentDeviceTarget.ts @@ -1,9 +1,12 @@ +import { DeviceOperationError } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as Schema from "effect/Schema"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import type { AgentDaemonEndpoint } from "./DeviceHost.ts"; @@ -62,3 +65,36 @@ export const writeAgentDeviceConfig = Effect.fn("AgentDeviceTarget.writeConfig") yield* fs.rename(temporary, file); }).pipe(Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.ignore))); }); + +const AgentCloseResult = Schema.Union([ + Schema.Struct({ result: Schema.Struct({ ok: Schema.Literal(true) }) }), + Schema.Struct({ + error: Schema.Struct({ + data: Schema.Struct({ code: Schema.Literal("SESSION_NOT_FOUND") }), + }), + }), +]); + +/** Ends an agent-device session on a running daemon; an already-released session succeeds. */ +export const releaseAgentDeviceSession = + (http: HttpClient.HttpClient, endpoint: AgentDaemonEndpoint) => (session: string) => + HttpClientRequest.post(`${endpoint.baseUrl}/rpc`).pipe( + HttpClientRequest.bearerToken(endpoint.token), + HttpClientRequest.bodyJson({ + jsonrpc: "2.0", + id: session, + method: "agent_device.command", + params: { command: "close", session, positionals: [], flags: {} }, + }), + Effect.flatMap((request) => http.pipe(HttpClient.withScope).execute(request)), + // agent-device reports an already-released session as HTTP 404. + Effect.filterOrElse((response) => response.status === 404, HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(AgentCloseResult)), + Effect.scoped, + Effect.timeout(Duration.seconds(15)), + Effect.asVoid, + Effect.mapError( + (cause) => + new DeviceOperationError({ operation: "close", reason: "request_failed", cause }), + ), + ); diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index c168ba657afe6..7a0962e06f18b 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -145,6 +145,16 @@ export interface DeviceOperations { export interface DeviceHostReady { readonly nodePath: string; + /** + * Ends an agent-device session on the daemon that serves `deviceId`. Hosts + * with a shared daemon provide it only while that daemon runs; hosts with a + * daemon per device succeed when the device's daemon is gone. Cleanup never + * starts a daemon or a device. + */ + readonly releaseAgentSession?: ( + session: string, + deviceId: string, + ) => Effect.Effect; readonly hub: DeviceHubEndpoint; readonly devices: DeviceOperations; readonly run: ( diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts index 7bbd8418de774..44115303e7b8a 100644 --- a/apps/server/src/device/DeviceMultiHost.test.ts +++ b/apps/server/src/device/DeviceMultiHost.test.ts @@ -6,6 +6,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as Effect from "effect/Effect"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as ServerSettings from "../serverSettings.ts"; +import { releaseAgentDeviceSession } from "./AgentDeviceTarget.ts"; import * as DeviceHost from "./DeviceHost.ts"; import { makeDeviceHubOperations } from "./DeviceHubOperations.ts"; import * as DeviceService from "./DeviceService.ts"; @@ -16,19 +17,23 @@ it.effect("keeps hosts independent when serials collide and another host fails", Effect.succeed( HttpClientResponse.fromWeb( request, - Response.json({ - simulators: [], - emulators: [ - { - id: "emulator-5554", - name: "Pixel", - version: "36", - platform: "android", - booted: true, - physical: false, - }, - ], - }), + Response.json( + request.url.endsWith("/rpc") + ? { result: { ok: true } } + : { + simulators: [], + emulators: [ + { + id: "emulator-5554", + name: "Pixel", + version: "36", + platform: "android", + booted: true, + physical: false, + }, + ], + }, + ), ), ), ); @@ -50,6 +55,10 @@ it.effect("keeps hosts independent when serials collide and another host fails", androidAvailable: Effect.succeed(true), }), agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" }, + releaseAgentSession: releaseAgentDeviceSession(http, { + baseUrl: `http://${id}`, + token: "test", + }), run, helpers: { serveSimAxSettings: null, serveSimCli: null }, }; diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index a9ff957b91997..59a1892f9b15d 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -36,7 +36,7 @@ import { makeDeviceHubOperations } from "./DeviceHubOperations.ts"; import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; import * as DeviceService from "./DeviceService.ts"; -import { agentDeviceSession } from "./AgentDeviceTarget.ts"; +import { agentDeviceSession, releaseAgentDeviceSession } from "./AgentDeviceTarget.ts"; const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const decodeBootError = Schema.decodeUnknownSync(Schema.fromJsonString(DeviceBootError)); @@ -52,26 +52,65 @@ const fixture = Effect.fn("fixture")(function* ( | DeviceHost.AgentDeviceStartError, inspectError = false, installTool?: Parameters[3], - options: { readonly endsDevices?: boolean; readonly routesStreams?: boolean } = {}, + options: { + readonly endsDevices?: boolean; + readonly routesStreams?: boolean; + readonly onClose?: Effect.Effect<"ok" | "missing" | "reject" | "http_error" | "malformed">; + readonly onPlatformAvailability?: Effect.Effect; + } = {}, ) { const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); const starts: string[] = []; const agentStarts: string[] = []; const agentStops: string[] = []; const requests: string[] = []; + const closedSessions: unknown[] = []; const devices: Array> = []; const discoveryErrors = yield* Ref.make>([]); const androidAvailable = yield* Ref.make(true); const encoderResult = yield* Ref.make({ code: 0, stdout: " V....D libx264", stderr: "" }); let booted = false; let shutDown = false; + const connection = { online: true, kind: "local" as "local" | "ssh", failShutdown: false }; const http = HttpClient.make((request) => Effect.gen(function* () { requests.push(request.url); + if (request.url === "http://agent.test/rpc") { + if (request.body._tag !== "Uint8Array") throw new Error("Missing close body"); + const body = decodeJson(new TextDecoder().decode(request.body.body)); + closedSessions.push(body); + expect(request.headers.authorization).toBe("Bearer test"); + const outcome = yield* options.onClose ?? Effect.succeed("ok"); + return HttpClientResponse.fromWeb( + request, + outcome === "missing" + ? Response.json( + { + error: { + code: -32000, + message: "No active session", + data: { code: "SESSION_NOT_FOUND" }, + }, + }, + { status: 404 }, + ) + : outcome === "http_error" + ? new Response("Offline", { status: 503 }) + : outcome === "malformed" + ? new Response("not JSON") + : Response.json( + outcome === "ok" + ? { result: { ok: true } } + : { error: { code: -32000, message: "offline" } }, + ), + ); + } if (request.url.includes("/api/screenshot")) { return HttpClientResponse.fromWeb(request, new Response(new Uint8Array([137, 80, 78, 71]))); } if (request.url.endsWith("/shutdown")) { + if (connection.failShutdown) + return HttpClientResponse.fromWeb(request, Response.json({ ok: false })); shutDown = true; booted = false; return HttpClientResponse.fromWeb(request, Response.json({ ok: true })); @@ -153,9 +192,9 @@ const fixture = Effect.fn("fixture")(function* ( : {}), id: LOCAL_DEVICE_HOST_ID, label: "This machine", - summary: Effect.succeed({ + summary: Effect.sync(() => ({ id: LOCAL_DEVICE_HOST_ID, - kind: "local", + kind: connection.kind, label: "Test server", platforms: [ { platform: "android", available: true }, @@ -163,8 +202,11 @@ const fixture = Effect.fn("fixture")(function* ( ], hubInstalled: true, agentDeviceInstalled: false, - }), - platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + })), + platformAvailability: (platform) => + (options.onPlatformAvailability ?? Effect.void).pipe( + Effect.as({ platform, available: true }), + ), ensureReady: (onPhase) => Effect.gen(function* () { if (runtimeFailure && runtimeFailure._tag !== "AgentDeviceStartError") @@ -183,7 +225,21 @@ const fixture = Effect.fn("fixture")(function* ( agentDevice: { baseUrl: "http://agent.test", token: "test", entryPath: "/agent" }, }; }), - current: Effect.succeed(null), + current: Effect.sync(() => + connection.online + ? { + ...ready, + ...(agentStarts.length > agentStops.length + ? { + releaseAgentSession: releaseAgentDeviceSession(http, { + baseUrl: "http://agent.test", + token: "test", + }), + } + : {}), + } + : null, + ), stopAgent: Effect.sync(() => { agentStops.push("stop"); }), @@ -194,7 +250,7 @@ const fixture = Effect.fn("fixture")(function* ( const service = yield* DeviceService.makeWithHosts( new Map([[host.id, host]]), undefined, - undefined, + () => Effect.succeed("/host.json"), installTool, ).pipe( Effect.provide(NodeCrypto.layer), @@ -232,6 +288,8 @@ const fixture = Effect.fn("fixture")(function* ( agentStops, requests, settings, + closedSessions, + connection, devices, encoderResult, discoveryErrors, @@ -801,8 +859,8 @@ it.effect.each(["shutdown", "close"] as const)( operation === "shutdown" ? service.shutdown(input) : service.close({ threadId }), ); expect((yield* PubSub.take(updates)).sessions).toEqual([]); - // list writes through the same state lock, so the retirement has committed. - expect((yield* service.list).sessions).toEqual([]); + yield* service.refreshHosts; + expect((yield* service.state).sessions).toEqual([]); yield* Effect.sync(() => retiring.interruptUnsafe()); yield* Deferred.succeed(pauseBind.proceed, undefined); yield* Fiber.join(binding); @@ -1363,6 +1421,229 @@ it.effect("a failed tool update reports npm's exit code but not its output", () ), ); +describe("agent session release on close", () => { + const openClaim = ( + service: DeviceService.DeviceService["Service"], + threadId: ThreadId, + deviceId = "Pixel_API_35", + ) => service.open({ threadId, deviceId, platform: "android" }); + + it.effect("releases only the caller's session, keeps the device booted, and is idempotent", () => + Effect.gen(function* () { + const { service, closedSessions, requests } = yield* fixture(); + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + const threadId = ThreadId.make("closing"); + const other = ThreadId.make("other"); + const session = yield* openClaim(service, threadId); + yield* openClaim(service, other, session.deviceId); + yield* service.close({ threadId, deviceId: session.deviceId }); + yield* service.close({ threadId, deviceId: session.deviceId }); + const name = yield* agentDeviceSession(threadId, session.hostId, session.deviceId).pipe( + Effect.provide(NodeCrypto.layer), + ); + expect(closedSessions).toEqual([ + { + jsonrpc: "2.0", + id: name, + method: "agent_device.command", + params: { command: "close", session: name, positionals: [], flags: {} }, + }, + ]); + const state = yield* service.state; + expect(state.sessions.map((entry) => entry.threadId)).toEqual([other]); + expect(state.devices.find((entry) => entry.id === session.deviceId)?.booted).toBe(true); + expect(requests.some((url) => url.endsWith("/shutdown"))).toBe(false); + }), + ); + + it.effect.each(["reject", "http_error", "malformed"] as const)( + "retains retryable state on %s", + (failure) => + Effect.gen(function* () { + let fail = true; + const { service, requests, closedSessions } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + undefined, + { onClose: Effect.sync(() => (fail ? failure : "ok")) }, + ); + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + const threadId = ThreadId.make("retry"); + yield* openClaim(service, threadId); + const exit = yield* Effect.exit(service.close({ threadId, shutdown: true })); + expect(Exit.isFailure(exit)).toBe(true); + expect((yield* service.state).sessions).toHaveLength(1); + expect(requests.some((url) => url.endsWith("/shutdown"))).toBe(false); + fail = false; + yield* service.close({ threadId }); + expect((yield* service.state).sessions).toHaveLength(0); + expect(closedSessions).toHaveLength(2); + }), + ); + + it.effect.each([false, true])("accepts a missing agent session, shutdown=%s", (shutdown) => + Effect.gen(function* () { + const { service, requests } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + undefined, + { onClose: Effect.succeed("missing") }, + ); + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + const threadId = ThreadId.make("no-claim"); + yield* openClaim(service, threadId); + yield* service.close({ threadId, shutdown }); + expect((yield* service.state).sessions).toHaveLength(0); + expect(requests.some((url) => url.endsWith("/shutdown"))).toBe(shutdown); + }), + ); + + it.effect("retries shutdown after the first attempt already released the agent session", () => + Effect.gen(function* () { + let released = false; + const { service, connection } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + undefined, + { + onClose: Effect.sync(() => { + const result = released ? "missing" : "ok"; + released = true; + return result; + }), + }, + ); + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + const threadId = ThreadId.make("retry-shutdown"); + yield* openClaim(service, threadId); + connection.failShutdown = true; + expect(Exit.isFailure(yield* Effect.exit(service.close({ threadId, shutdown: true })))).toBe( + true, + ); + expect((yield* service.state).sessions).toHaveLength(1); + connection.failShutdown = false; + yield* service.close({ threadId, shutdown: true }); + expect((yield* service.state).sessions).toHaveLength(0); + }), + ); + + it.effect("releases the post-boot agentTarget session for a cold Android AVD", () => + Effect.gen(function* () { + const { service, closedSessions } = yield* fixture(); + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + const threadId = ThreadId.make("cold-avd"); + const before = yield* service.agentTarget({ + threadId, + hostId: "local", + deviceId: "Pixel_API_35", + }); + const session = yield* openClaim(service, threadId); + const after = yield* service.agentTarget({ + threadId, + hostId: session.hostId, + deviceId: session.deviceId, + }); + expect(session.deviceId).toBe("emulator-5554"); + expect(after.args[3]).not.toBe(before.args[3]); + yield* service.close({ threadId }); + expect(closedSessions).toEqual([ + expect.objectContaining({ params: expect.objectContaining({ session: after.args[3] }) }), + ]); + }), + ); + + it.effect("retains an SSH record during reconnect and releases it on retry", () => + Effect.gen(function* () { + const { service, connection, closedSessions, agentStarts } = yield* fixture(); + connection.kind = "ssh"; + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + const threadId = ThreadId.make("ssh-reconnect"); + yield* openClaim(service, threadId); + const starts = agentStarts.length; + connection.online = false; + expect(Exit.isFailure(yield* Effect.exit(service.close({ threadId })))).toBe(true); + expect((yield* service.state).sessions).toHaveLength(1); + expect(closedSessions).toHaveLength(0); + expect(agentStarts).toHaveLength(starts); + connection.online = true; + yield* service.close({ threadId }); + expect((yield* service.state).sessions).toHaveLength(0); + expect(closedSessions).toHaveLength(1); + }), + ); + it.effect("releases peer claims before a close shuts down", () => + Effect.gen(function* () { + const { service, closedSessions } = yield* fixture(); + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + const threadId = ThreadId.make("shutting-down"); + const other = ThreadId.make("peer"); + const session = yield* openClaim(service, threadId); + yield* openClaim(service, other, session.deviceId); + yield* service.close({ threadId, shutdown: true }); + expect((yield* service.state).sessions).toHaveLength(0); + yield* service.close({ threadId: other }); + expect((yield* service.state).sessions).toHaveLength(0); + const names = yield* Effect.forEach([threadId, other], (thread) => + agentDeviceSession(thread, session.hostId, session.deviceId), + ).pipe(Effect.provide(NodeCrypto.layer)); + expect( + closedSessions.map((body) => (body as { params: { session: string } }).params.session), + ).toEqual(names); + }), + ); + + it.effect("publishes a reopened session only after the old claim is released and removed", () => + Effect.gen(function* () { + const entered = yield* Queue.unbounded(); + const releasing = yield* Deferred.make(); + const finish = yield* Deferred.make(); + const { service } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + undefined, + { + onClose: Effect.gen(function* () { + yield* Deferred.succeed(releasing, undefined); + yield* Deferred.await(finish); + return "ok" as const; + }), + onPlatformAvailability: Queue.offer(entered, undefined), + }, + ); + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + const threadId = ThreadId.make("reopen"); + const session = yield* openClaim(service, threadId); + yield* Queue.take(entered); + const publications = yield* service.subscribe; + const closing = yield* service.close({ threadId }).pipe(Effect.forkChild); + yield* Deferred.await(releasing); + const opening = yield* openClaim(service, threadId, session.deviceId).pipe(Effect.forkChild); + yield* Queue.take(entered); + yield* Deferred.succeed(finish, undefined); + yield* Fiber.join(closing); + const reopened = yield* Fiber.join(opening); + const published = (yield* PubSub.takeAll(publications)) + .map(({ sessions }) => + sessions.includes(session) ? "old" : sessions.includes(reopened) ? "reopened" : "none", + ) + .filter((label, index, labels) => label !== labels[index - 1]); + expect(published).toEqual(["none", "reopened"]); + }).pipe(Effect.scoped), + ); +}); + describe("locating a device id across hosts", () => { const threadId = ThreadId.make("thread-locate"); const thisMachine = { id: LOCAL_DEVICE_HOST_ID, label: "This machine" }; diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index b2ef41f148a14..d91c9d8d0b53b 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -338,6 +338,26 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }), ); + const releaseLaunchers = (sessions: ReadonlyArray) => + launcherLock.withPermit( + Effect.forEach( + sessions, + (session) => + Effect.gen(function* () { + const name = yield* agentDeviceSession( + session.threadId, + session.hostId, + session.deviceId, + ).pipe(Effect.provideService(Crypto.Crypto, crypto)); + const launcher = boundLaunchers.get(name); + if (!launcher || hostSessionLaunchers.has(name) || !launchers?.release) return; + yield* launchers.release(launcher); + boundLaunchers.delete(name); + }), + { discard: true }, + ), + ); + // Uninterruptible so a committed retirement always prunes, even if its caller is cancelled. const publish = (update: (state: DeviceServiceState) => DeviceServiceState) => SynchronizedRef.modifyEffect(stateRef, ({ state }) => { @@ -817,12 +837,18 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }, ); + const refreshAfterShutdown = (ready: DeviceReadiness) => + refresh(ready).pipe( + Effect.catch((cause) => + Effect.logWarning("Device discovery unavailable after shutdown", { cause }), + ), + ); + const shutdownDevice = Effect.fn("DeviceService.shutdownDevice")(function* ( - hostId: DeviceHostId, + ready: DeviceReadiness, deviceId: DeviceId, platform: DevicePlatform, ) { - const ready = yield* readiness(hostId); yield* ready.devices.shutdown({ id: deviceId, platform }); yield* publish((state) => ({ ...state, @@ -831,46 +857,76 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ? { ...device, booted: false } : device, ), - sessions: state.sessions.filter( - (session) => !(session.hostId === ready.hostId && session.deviceId === deviceId), - ), })); - // Snapshot saves can stall discovery; refresh failure must not fail an accepted shutdown. - yield* refresh(ready).pipe( - Effect.catch((cause) => - Effect.logWarning("Device discovery unavailable after shutdown", { cause }), - ), - ); }); const close: DeviceService["Service"]["close"] = Effect.fn("DeviceService.close")( function* (input) { - const { state } = yield* SynchronizedRef.get(stateRef); - const closing = state.sessions.filter( - (session) => - session.threadId === input.threadId && - (input.hostId === undefined || session.hostId === input.hostId) && - (input.deviceId === undefined || session.deviceId === input.deviceId), + // Open publishes under the same lock; an old close must not release a reopened claim. + const closing = yield* lifecycleLock.withPermit( + Effect.gen(function* () { + const { state } = yield* SynchronizedRef.get(stateRef); + const closing = state.sessions.filter( + (session) => + session.threadId === input.threadId && + (input.hostId === undefined || session.hostId === input.hostId) && + (input.deviceId === undefined || session.deviceId === input.deviceId), + ); + const ending = input.shutdown + ? state.sessions.filter((session) => + closing.some( + (closed) => + closed.hostId === session.hostId && closed.deviceId === session.deviceId, + ), + ) + : closing; + for (const session of ending) { + const host = yield* resolveHost(session.hostId); + const ready = yield* host.current; + // A lost SSH tunnel does not establish that the remote daemon stopped. + if (!ready && (yield* host.summary).kind === "ssh") + return yield* new DeviceHostUnavailableError({ + hostId: session.hostId, + hostLabel: host.label, + reason: "Device host is reconnecting. Retry close after it reconnects.", + }); + if (ready?.releaseAgentSession) { + const name = yield* agentDeviceSession( + session.threadId, + session.hostId, + session.deviceId, + ).pipe(Effect.provideService(Crypto.Crypto, crypto)); + yield* ready.releaseAgentSession(name, session.deviceId); + } + } + if (input.shutdown) yield* releaseLaunchers(ending); + if (input.shutdown) { + for (const session of closing) { + const ready = yield* currentReadiness(session.hostId); + if (!ready) + return yield* new DeviceHostUnavailableError({ + hostId: session.hostId, + reason: "Device host is not running. Reconnect before retrying close.", + }); + yield* shutdownDevice(ready, session.deviceId, session.platform); + } + } + yield* publish((current) => ({ + ...current, + sessions: current.sessions.filter((session) => !ending.includes(session)), + })); + return closing; + }), ); - if (closing.length === 0) return; - // Powering off ends every session on the device, so all of them retire, and release, while it is on. - const ending = (session: DeviceSession) => - closing.includes(session) || - (input.shutdown === true && - closing.some( - (closed) => closed.hostId === session.hostId && closed.deviceId === session.deviceId, - )); - yield* publish((current) => ({ - ...current, - sessions: current.sessions.filter((session) => !ending(session)), - })); - if (input.shutdown) { + if (input.shutdown) yield* Effect.forEach( closing, - (session) => shutdownDevice(session.hostId, session.deviceId, session.platform), + (session) => + currentReadiness(session.hostId).pipe( + Effect.flatMap((ready) => (ready ? refreshAfterShutdown(ready) : Effect.void)), + ), { discard: true }, ); - } }, ); @@ -885,7 +941,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (session) => !(session.hostId === host.id && session.deviceId === input.deviceId), ), })); - yield* shutdownDevice(host.id, input.deviceId, input.platform); + const ready = yield* readiness(host.id); + yield* shutdownDevice(ready, input.deviceId, input.platform); + yield* refreshAfterShutdown(ready); }, ); diff --git a/apps/server/src/device/EasDeviceHost.test.ts b/apps/server/src/device/EasDeviceHost.test.ts index 62a6a0c247a45..b5825d3f973e7 100644 --- a/apps/server/src/device/EasDeviceHost.test.ts +++ b/apps/server/src/device/EasDeviceHost.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import type { EasDeviceHostConfig } from "@t3tools/contracts"; +import { type EasDeviceHostConfig, ThreadId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -15,6 +16,8 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as DeviceService from "./DeviceService.ts"; import * as EasDeviceHost from "./EasDeviceHost.ts"; const APP_ID = "d763fcb8-d37c-41ea-a773-b54a0ab4a454"; @@ -57,9 +60,12 @@ const fakeExpo = ( readonly activeIncludesCreated?: boolean; readonly failPreview?: boolean; readonly omitCreatedFromActive?: boolean; + /** The session's agent-device daemon answers with a server error. */ + readonly failAgent?: () => boolean; } = {}, ) => { const calls: Array<{ operation: string; variables: Record }> = []; + const releases: Array = []; let created = 0; let createdName = ""; const previews: string[] = []; @@ -91,6 +97,18 @@ const fakeExpo = ( ), ); } + if (url.origin === "https://agent.test") { + expect(request.headers.authorization).toBe("Bearer agent-token"); + if (request.body._tag === "Uint8Array") { + const rpc = JSON.parse(new TextDecoder().decode(request.body.body)) as { + params: { command: string; session: string }; + }; + if (rpc.params.command === "close") releases.push(rpc.params.session); + } + if (options.failAgent?.()) + return HttpClientResponse.fromWeb(request, new Response("bad gateway", { status: 502 })); + return HttpClientResponse.fromWeb(request, Response.json({ result: { ok: true } })); + } const body = request.body._tag === "Uint8Array" ? (JSON.parse(new TextDecoder().decode(request.body.body)) as { @@ -207,7 +225,7 @@ const fakeExpo = ( const allowCreate = () => { createFails = false; }; - return { http, named, stopped, allowPreview, allowCreate, previews }; + return { http, named, stopped, allowPreview, allowCreate, releases, previews }; }; const layer = Layer.mergeAll( @@ -566,4 +584,63 @@ describe("EAS Simulator host", () => { ]); }).pipe(Effect.scoped, Effect.provide(layer)), ); + + it.effect("releases each thread's agent session on close and keeps the session running", () => + Effect.gen(function* () { + let agentFails = false; + const expo = fakeExpo({ failAgent: () => agentFails }); + const host = yield* makeHost(expo, config(), yield* Scope.make()); + const service = yield* DeviceService.makeWithHosts(new Map([["eas", host]]), undefined, () => + Effect.succeed("/eas-agent.json"), + ); + const first = ThreadId.make("first"); + const second = ThreadId.make("second"); + const opened = yield* service.open({ + threadId: first, + hostId: "eas", + deviceId: "eas-ios", + platform: "ios", + }); + expect(opened.deviceId).toBe("session-1"); + yield* service.open({ + threadId: second, + hostId: "eas", + deviceId: opened.deviceId, + platform: "ios", + }); + const sessionOf = (threadId: ThreadId) => + service + .agentTarget({ threadId, hostId: "eas", deviceId: opened.deviceId }) + .pipe(Effect.map((target) => target.args[3])); + const firstSession = yield* sessionOf(first); + const secondSession = yield* sessionOf(second); + + yield* service.close({ threadId: first }); + expect(expo.releases).toEqual([firstSession]); + expect((yield* service.state).sessions.map((entry) => entry.threadId)).toEqual([second]); + + agentFails = true; + const failed = yield* Effect.exit(service.close({ threadId: second, shutdown: false })); + expect(Exit.isFailure(failed)).toBe(true); + expect((yield* service.state).sessions.map((entry) => entry.threadId)).toEqual([second]); + + agentFails = false; + yield* service.close({ threadId: second, shutdown: false }); + expect(expo.releases).toEqual([firstSession, secondSession, secondSession]); + expect((yield* service.state).sessions).toEqual([]); + expect(expo.stopped()).not.toContain("session-1"); + expect( + (yield* service.state).devices.find((device) => device.id === "session-1")?.booted, + ).toBe(true); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + layer, + NodeCrypto.layer, + ServerSettings.layerTest({ enableDeviceSupport: true, enableAgentDeviceAccess: true }), + ), + ), + ), + ); }); diff --git a/apps/server/src/device/EasDeviceHost.ts b/apps/server/src/device/EasDeviceHost.ts index a1137fe5156a0..e82638dbd76fa 100644 --- a/apps/server/src/device/EasDeviceHost.ts +++ b/apps/server/src/device/EasDeviceHost.ts @@ -32,6 +32,7 @@ import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "../config.ts"; +import { releaseAgentDeviceSession } from "./AgentDeviceTarget.ts"; import * as DeviceHost from "./DeviceHost.ts"; import { findEasProjectId } from "./easProjectId.ts"; import { type ExpoApi, makeExpoApi, resolveExpoCredential } from "./ExpoApi.ts"; @@ -827,6 +828,12 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice hub: { origin: "https://api.expo.dev" }, devices, upstream, + // Each session runs its own daemon, so one that has ended holds no agent sessions. + releaseAgentSession: (session, deviceId) => + Option.match(sessionFor(deviceId), { + onNone: () => Effect.void, + onSome: (running) => releaseAgentDeviceSession(http, running.agent)(session), + }), // Device settings run simctl on the simulator's own machine, which EAS does not expose. run: () => Effect.succeed({ diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index bffd912a3987f..57468c98878a8 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as FileSystem from "effect/FileSystem"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; @@ -42,6 +43,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-agent-start-" }); + const hub = { running: true, spawnFails: false }; const stateDir = agentDeviceStateDir(path, path.join(baseDir, "userdata")); for (const [name, version, entry] of [ ["expo-device-hub", DEVICE_HUB_VERSION, "dist/server/cli.mjs"], @@ -61,21 +63,30 @@ const agentFixture = Effect.fn("agentFixture")(function* ( Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => - Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(123), - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - exitCode: Effect.never, - isRunning: Effect.succeed(true), - kill: () => Effect.void, - stdin: Sink.drain, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - unref: Effect.succeed(Effect.void), - }), - ), + hub.spawnFails + ? Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "Hub entry missing", + }), + ) + : Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.never, + isRunning: Effect.sync(() => hub.running), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ), ), ), Effect.provideService(ProcessRunner.ProcessRunner, { @@ -92,7 +103,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( ), ); yield* host.ensureReady(() => Effect.void); - return { host, fs, stateDir }; + return { host, fs, stateDir, hub }; }); describe("AgentDevice startup", () => { @@ -196,9 +207,32 @@ describe("AgentDevice startup", () => { expect((yield* host.ensureAgentReady(() => Effect.void)).agentDevice).toEqual( ready.agentDevice, ); + expect((yield* host.current)?.releaseAgentSession).toBeTypeOf("function"); expect(attempts).toBe(1); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("keeps releasing agent sessions while a dead hub fails to restart and after", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { host, hub } = yield* agentFixture((stateDir) => + fs + .writeFileString( + `${stateDir}/daemon.json`, + JSON.stringify({ httpPort: 12345, token: "test-token" }), + ) + .pipe(Effect.orDie, Effect.as(processOutput(0))), + ); + yield* host.ensureAgentReady(() => Effect.void); + hub.running = false; + hub.spawnFails = true; + expect((yield* host.ensureReady(() => Effect.void).pipe(Effect.result))._tag).toBe("Failure"); + expect((yield* host.current)?.releaseAgentSession).toBeTypeOf("function"); + hub.spawnFails = false; + yield* host.ensureReady(() => Effect.void); + expect((yield* host.current)?.releaseAgentSession).toBeTypeOf("function"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); const diagnose = ( diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 051803bd49616..34250bc199f87 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -36,6 +36,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; +import { releaseAgentDeviceSession } from "./AgentDeviceTarget.ts"; import * as DeviceHost from "./DeviceHost.ts"; import { makeDeviceHubOperations } from "./DeviceHubOperations.ts"; import { @@ -650,10 +651,11 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ): Effect.fn.Return { const running = yield* Ref.get(runningRef); + // A dead hub's record stays until a replacement is published: the agent + // daemon outlives the hub, and close needs its endpoint if the restart fails. if (running) { const alive = yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false)); if (alive) return running; - yield* Ref.set(runningRef, null); } const nodePath = yield* resolveNodeExecutable("Local device support", hostEnvironment).pipe( Effect.provideService(FileSystem.FileSystem, fs), @@ -696,7 +698,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { ]); const next: RunningHost = { hub, - agentDevice: null, + agentDevice: running?.agentDevice ?? null, helpers: { serveSimAxSettings: axExists ? candidate.serveSimAxSettings : null, serveSimCli: cliExists ? candidate.serveSimCli : null, @@ -821,6 +823,9 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { nodePath: running.hub.nodePath, run, helpers: running.helpers, + ...(running.agentDevice + ? { releaseAgentSession: releaseAgentDeviceSession(httpClient, running.agentDevice) } + : {}), }); const current: DeviceHost.DeviceHost["Service"]["current"] = Ref.get(runningRef).pipe( diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts index cde68d1b50c99..193df164c3086 100644 --- a/apps/server/src/device/SshDeviceHost.test.ts +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -26,6 +26,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ let forwards = 0; let failForward = true; let rejectConfig = true; + const releases: Array = []; const spawner = ChildProcessSpawner.make((command) => Effect.gen(function* () { if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command"); @@ -118,7 +119,12 @@ it.effect("preserves installed status after probes and cleans failed agent activ Effect.provideService( HttpClient.HttpClient, HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, new Response("ok"))), + Effect.sync(() => { + if (!request.url.endsWith("/rpc")) + return HttpClientResponse.fromWeb(request, new Response("ok")); + releases.push(request.headers.authorization); + return HttpClientResponse.fromWeb(request, Response.json({ result: { ok: true } })); + }), ), ), ); @@ -156,6 +162,10 @@ it.effect("preserves installed status after probes and cleans failed agent activ yield* host.ensureAgentReady(() => Effect.void); yield* host.platformAvailability("ios"); expect((yield* host.summary).agentDeviceInstalled).toBe(true); + // Close releases agent sessions through the forwarded daemon. + const agentReady = yield* host.current; + yield* agentReady!.releaseAgentSession!("t3-session", "device"); + expect(releases).toEqual(["Bearer fixture"]); yield* host.stopAgent; expect(forwards).toBe(1); const multiplexingOff = ["ControlMaster=no", "ControlPath=none", "ControlPersist=no"]; diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index d7324d515b006..1eb0b713983a8 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -22,6 +22,7 @@ import * as HttpClient from "effect/http/HttpClient"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ServerConfig from "../config.ts"; +import { releaseAgentDeviceSession } from "./AgentDeviceTarget.ts"; import * as DeviceHost from "./DeviceHost.ts"; import { makeDeviceHubOperations } from "./DeviceHubOperations.ts"; import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts"; @@ -351,7 +352,9 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( }).pipe(Effect.provideService(HttpClient.HttpClient, http)); } if (next.agentDevice) yield* onReady({ ...next, agentDevice: next.agentDevice }); - ready = next; + ready = next.agentDevice + ? { ...next, releaseAgentSession: releaseAgentDeviceSession(http, next.agentDevice) } + : next; yield* onStatus("ready"); // Reconnect also repairs helpers that died while SSH itself stayed connected. const unhealthy = Effect.gen(function* () { diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index e3af344e63709..d855320d3103c 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -7,7 +7,7 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeUtil from "node:util"; -import { quoteRemoteArg, remoteDeviceEnvironment } from "./sshDeviceScript.ts"; +import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts"; const exec = NodeUtil.promisify(NodeChildProcess.execFile); @@ -45,3 +45,90 @@ it.effect("preserves shell metacharacters and newlines in remote arguments", () }); }), ); + +it.effect("stops a daemon left for its owner when starting without agent tools", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + yield* Effect.promise(async () => { + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ssh-start-")); + try { + const state = NodePath.join(home, ".t3/device/hosts/owner"); + const daemonFile = NodePath.join(state, "daemon.json"); + const calls = NodePath.join(home, "calls"); + const failMarker = NodePath.join(home, "fail-stop"); + const entry = NodePath.join(home, "agent-device.mjs"); + await NodeFSP.mkdir(NodePath.join(home, "bin")); + await NodeFSP.mkdir(state, { recursive: true }); + await NodeFSP.writeFile( + entry, + `import * as fs from "node:fs"; +fs.appendFileSync(${JSON.stringify(calls)}, process.argv.slice(2).join(" ") + "\\n"); +if (fs.existsSync(${JSON.stringify(failMarker)})) process.exitCode = 1;`, + ); + await NodeFSP.writeFile( + NodePath.join(state, "agent.json"), + JSON.stringify({ entryPath: entry }), + ); + await NodeFSP.writeFile(daemonFile, JSON.stringify({ httpPort: 1, token: "claims" })); + // An empty PATH leaves no platforms, so the script ends right after the daemon check. + const start = () => + NodeChildProcess.spawnSync(process.execPath, [], { + input: remoteDeviceScript("owner", "start"), + env: { HOME: home, PATH: NodePath.join(home, "bin") }, + encoding: "utf8", + }); + await NodeFSP.writeFile(failMarker, ""); + expect(start().stderr).toContain("Could not stop the agent-device daemon"); + expect(await NodeFSP.readFile(daemonFile, "utf8")).toContain("claims"); + await NodeFSP.rm(failMarker); + expect(start().stderr).toContain("Android SDK missing"); + await expect(NodeFSP.access(daemonFile)).rejects.toThrow(); + expect(await NodeFSP.readFile(calls, "utf8")).toBe( + `daemon stop --state-dir ${state}\ndaemon stop --state-dir ${state}\n`, + ); + } finally { + await NodeFSP.rm(home, { recursive: true, force: true }); + } + }); + }), +); + +it.effect("keeps a daemon record its missing agent CLI cannot stop", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + yield* Effect.promise(async () => { + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ssh-start-")); + try { + const state = NodePath.join(home, ".t3/device/hosts/owner"); + const daemonFile = NodePath.join(state, "daemon.json"); + await NodeFSP.mkdir(NodePath.join(home, "bin")); + await NodeFSP.mkdir(state, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(state, "agent.json"), + JSON.stringify({ entryPath: NodePath.join(home, "missing/agent-device.mjs") }), + ); + const start = async (pid: number) => { + await NodeFSP.writeFile( + daemonFile, + JSON.stringify({ pid, httpPort: 1, token: "claims" }), + ); + return NodeChildProcess.spawnSync(process.execPath, [], { + input: remoteDeviceScript("owner", "start"), + env: { HOME: home, PATH: NodePath.join(home, "bin") }, + encoding: "utf8", + }); + }; + // This test process stands in for a daemon that is still running. + expect((await start(process.pid)).stderr).toContain( + "Could not stop the agent-device daemon", + ); + expect(await NodeFSP.readFile(daemonFile, "utf8")).toContain("claims"); + const exited = NodeChildProcess.spawnSync(process.execPath, ["-e", ""]).pid; + expect((await start(exited)).stderr).toContain("Android SDK missing"); + await expect(NodeFSP.access(daemonFile)).rejects.toThrow(); + } finally { + await NodeFSP.rm(home, { recursive: true, force: true }); + } + }); + }), +); diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index ffe8097bae338..2e3e895293fa3 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -151,16 +151,29 @@ async function install(name, version, entry) { const hubFile = path.join(state, 'hub.json'); const daemonFile = path.join(state, 'daemon.json'); const agentFile = path.join(state, 'agent.json'); + const stopAgent = () => { + const entry = read(agentFile)?.entryPath || path.join(root, 'tools', 'agent-device@' + agentVersion, 'node_modules', 'agent-device', 'bin', 'agent-device.mjs'); + if (fs.existsSync(entry)) return run(process.execPath, [entry, 'daemon', 'stop', '--state-dir', state]).status === 0; + // A running daemon outlives its deleted CLI, so without one only a recorded pid that is gone counts as stopped. + const pid = read(daemonFile)?.pid; + return Number.isSafeInteger(pid) && pid > 0 && !maintenanceAlive(pid); + }; if (mode === 'stop' || mode === 'stop-agent') { const hub = read(hubFile); if (mode === 'stop' && hub && hub.owner === owner) { stopHub(hub); fs.rmSync(hubFile, { force: true }); } - const entry = read(agentFile)?.entryPath || path.join(root, 'tools', 'agent-device@' + agentVersion, 'node_modules', 'agent-device', 'bin', 'agent-device.mjs'); - if (fs.existsSync(entry)) run(process.execPath, [entry, 'daemon', 'stop', '--state-dir', state]); + stopAgent(); return; } + // A start without agent tools means the server holds no claims, so a daemon still here is + // orphaned. Agent access was turned off while the tunnel was down, or a server crashed. + // Close skips the release when start returns no endpoint, so the claims must end here. + if (mode === 'start' && read(daemonFile)) { + if (!stopAgent()) throw Error('Could not stop the agent-device daemon in ' + state); + fs.rmSync(daemonFile, { force: true }); + } if (!ios && !android) throw Error(platforms.map(p => p.reason).join(' ')); fs.mkdirSync(state, { recursive: true, mode: 0o700 }); const hubEntry = await install('expo-device-hub', hubVersion, 'dist/server/cli.mjs'); From dcf051e861f0ae9fd52e2376150da0b1aa5aaa06 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Fri, 30 Oct 2026 16:27:20 -0400 Subject: [PATCH 010/219] fix(composer): copied context with an apostrophe survives the HTML clipboard (#21537) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../src/composerContextClipboard.test.ts | 26 +++++++++++++++++++ .../shared/src/composerContextClipboard.ts | 4 ++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/composerContextClipboard.test.ts b/packages/shared/src/composerContextClipboard.test.ts index 59918d57b42b6..6b8b50df97c0e 100644 --- a/packages/shared/src/composerContextClipboard.test.ts +++ b/packages/shared/src/composerContextClipboard.test.ts @@ -76,6 +76,32 @@ describe("composerContextClipboard", () => { ).toBeNull(); expect(decodeComposerContextClipboardHtml("

Ordinary clipboard

")).toBeNull(); }); + it("round-trips context whose text contains an apostrophe", () => { + const raw = JSON.stringify({ + version: 1, + source: { environmentId: "env-1" }, + records: [ + { + version: 1, + kind: "terminal", + contextId: "terminal", + label: "Build", + terminalId: "main", + terminalLabel: "Main", + lineStart: 1, + lineEnd: 1, + text: "error: can't find module", + }, + ], + }); + const html = encodeComposerContextClipboardHtml("error: can't find module", raw); + expect(decodeComposerContextClipboardHtml(html)).toEqual(JSON.parse(raw)); + expect( + decodeComposerContextClipboardHtml( + `
x
`, + ), + ).toEqual(JSON.parse(raw)); + }); it("accepts a large non-ASCII fragment through the HTML attribute", () => { // CJK code units expand to nine encoded characters despite fitting the fragment limit. const raw = JSON.stringify({ diff --git a/packages/shared/src/composerContextClipboard.ts b/packages/shared/src/composerContextClipboard.ts index c6a83f727d5e9..6036795a780cb 100644 --- a/packages/shared/src/composerContextClipboard.ts +++ b/packages/shared/src/composerContextClipboard.ts @@ -35,7 +35,9 @@ export function decodeComposerContextClipboardHtml( ): ComposerContextClipboardFragment | null { // Percent encoding can expand each UTF-16 code unit to nine characters. if (!html || html.length > MAX_FRAGMENT_CHARS * 9 + HTML_WRAPPER_SLACK_CHARS) return null; - const encoded = /data-t3-context-fragment=["']([^"']+)["']/.exec(html)?.[1]; + // `encodeURIComponent` leaves `'` as is, so each quote style must end only at its own quote. + const match = /data-t3-context-fragment=(?:"([^"]+)"|'([^']+)')/.exec(html); + const encoded = match?.[1] ?? match?.[2]; if (!encoded) return null; try { return decodeComposerContextFragment(decodeURIComponent(encoded)); From 1d162e19f6ad1bfba2ad28ab3eb7abf493265b0d Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Fri, 30 Oct 2026 16:27:36 -0400 Subject: [PATCH 011/219] fix(server): storage cleanup keeps Windows worktrees with an open terminal regardless of path casing (#21545) Co-authored-by: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 64 ++++++++++++++++++++++++++ apps/server/src/storageCleanup.ts | 6 +-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 5f945abd1858c..c1293e3306463 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -461,6 +461,22 @@ const cleanupFixture = ( setOpenSessionCwd: (cwd: string | null) => { openSessionCwd = cwd; }, + openTerminal: (paths: { cwd: string; worktreePath: string | null }) => + terminalListener?.({ + type: "upsert", + terminal: { + threadId: "thread-1", + terminalId: "terminal-1", + status: "running", + pid: 1, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: false, + label: "Terminal", + updatedAt: "2026-06-10T12:00:00.000Z", + ...paths, + }, + }) ?? Effect.void, fullStatusCalls: () => fullStatusCalls, snapshotReads: () => snapshotReads, activate: (domainEvents: Stream.Stream) => { @@ -1594,6 +1610,54 @@ describe("orphan worktree reports", () => { }), ), ); + + it.live.each([ + ["win32", "cwd"], + ["win32", "worktreePath"], + ["linux", "cwd"], + ["linux", "worktreePath"], + ] as const)( + "matches a terminal at the worktree root with host casing rules (%s, %s)", + ([platform, field]) => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture().pipe( + Effect.provideService(HostProcess.Platform, platform), + ); + const upper = fixture.worktree.toUpperCase(); + yield* fixture.openTerminal( + field === "cwd" + ? { cwd: upper, worktreePath: null } + : { cwd: fixture.repo, worktreePath: upper }, + ); + const report = yield* fixture.service.runNow; + expect(report.entries[0]).toMatchObject( + platform === "win32" + ? { outcome: "kept", reason: "Open terminal" } + : { outcome: "removed" }, + ); + }), + ), + ); + + it.live.each(["win32", "linux"] as const)( + "matches a provider session at the worktree root with host casing rules (%s)", + (platform) => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture().pipe( + Effect.provideService(HostProcess.Platform, platform), + ); + fixture.setOpenSessionCwd(fixture.worktree.toUpperCase()); + const report = yield* fixture.service.runNow; + expect(report.entries[0]).toMatchObject( + platform === "win32" + ? { outcome: "kept", reason: "Provider session is still open" } + : { outcome: "removed" }, + ); + }), + ), + ); }); describe("storage cleanup reports and local file policies", () => { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 48154835e7bb4..b9f1e1f839528 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -248,8 +248,8 @@ const make = Effect.gen(function* () { const cwd = path.resolve(terminal.cwd); return ( (terminal.worktreePath !== null && - path.resolve(terminal.worktreePath) === worktreePath) || - cwd === worktreePath || + pathKey(terminal.worktreePath) === pathKey(worktreePath)) || + pathKey(cwd) === pathKey(worktreePath) || inside(worktreePath, cwd) ); }); @@ -987,7 +987,7 @@ const make = Effect.gen(function* () { for (const session of sessions) { const cwd = path.resolve(session.cwd); if ( - (cwd === worktreePath || inside(worktreePath, cwd)) && + (pathKey(cwd) === pathKey(worktreePath) || inside(worktreePath, cwd)) && (yield* providerSessions.isLive(session.id)) ) return waitForStop("provider session is still open"); From a0d6cc8ba9ed036745e2d9c747d7d82eeae1de84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Jiron=20=C2=B7=20MarMarLabs?= Date: Fri, 30 Oct 2026 15:28:02 -0500 Subject: [PATCH 012/219] fix(client-runtime): goal token counts no longer read 1000k (#21594) Co-authored-by: maria-rcks --- .../src/state/threadExecution.test.ts | 26 +++++++++++++++++++ .../src/state/threadExecution.ts | 3 ++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index 3f8ef2a7f9d31..81afabbe8aa49 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -25,6 +25,7 @@ import { deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveThreadRuntime, + presentProviderGoal, presentRunStop, threadRuntimeHasInterruptibleRun, } from "./threadExecution.ts"; @@ -462,3 +463,28 @@ describe("provider-reported model selection", () => { ).toBeNull(); }); }); + +describe("presentProviderGoal", () => { + it("rolls token counts that round to 1000k over to 1m", () => { + const usage = (tokensUsed: number) => + presentProviderGoal( + { objective: "Ship the feature", status: "active", tokensUsed, tokenBudget: 2_000_000 }, + true, + ).usage; + expect(usage(999_499)).toBe("999k / 2m tokens"); + expect(usage(999_500)).toBe("1m / 2m tokens"); + expect(usage(999_999)).toBe("1m / 2m tokens"); + expect(usage(1_000_000)).toBe("1m / 2m tokens"); + expect( + presentProviderGoal( + { + objective: "Ship the feature", + status: "paused", + tokensUsed: 999_500, + tokenBudget: 999_500, + }, + false, + ).usage, + ).toBe("1m / 1m tokens"); + }); +}); diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 52d07babfbb61..951e5d06404b3 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -428,7 +428,8 @@ const PROVIDER_GOAL_TITLES: Record Date: Fri, 30 Oct 2026 20:28:14 +0000 Subject: [PATCH 013/219] fix: Intel Mac installs and broken Codex versions fail with clear next steps (#27281) --- .../Adapters/CodexAdapterV2.test.ts | 53 +++++++++++++++++++ .../Adapters/CodexAdapterV2.ts | 29 ++++++++-- apps/server/src/provider/CodexInstallation.ts | 10 +++- .../src/provider/Drivers/CodexDriver.ts | 6 ++- .../provider-core/src/server/failure.test.ts | 14 ++++- packages/provider-core/src/server/failure.ts | 1 + scripts/install.sh | 7 ++- 7 files changed, 110 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 9dfe62bc4d36b..6413ed0bfe9ac 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -22,6 +22,7 @@ import { type ProviderApprovalDecision, ProviderInstanceId, ProviderSessionId, + ProviderSetupError, ProviderThreadId, ProviderTurnId, RunAttemptId, @@ -66,6 +67,8 @@ import * as Orchestrator from "../Orchestrator.ts"; import * as ProviderReplayHarness from "../testkit/ProviderReplayHarness.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; +import * as ModelManifest from "../../provider/ModelManifest.ts"; +import { resolveProviderCompatibility } from "../../provider/providerCompatibility.ts"; import * as CodexAdapterV2 from "./CodexAdapterV2.ts"; import { withCodexReplayChildMetadata } from "./CodexAdapterV2.testkit.ts"; import * as CodexAdapterV2Testkit from "./CodexAdapterV2.testkit.ts"; @@ -876,6 +879,7 @@ describe("CodexAdapterV2 session initialize", () => { const openReplaySession = ( transcript: CodexReplay.CodexAppServerReplayTranscript, beforeEmitInbound?: CodexReplay.CodexAppServerReplayDriver["beforeEmitInbound"], + compatibility?: CodexAdapterV2.CodexAdapterV2Options["compatibility"], ) => Effect.gen(function* () { const driver = yield* CodexReplay.makeReplayDriver( @@ -887,6 +891,7 @@ describe("CodexAdapterV2 session initialize", () => { instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, settings: DEFAULT_CODEX_SETTINGS, environment: {}, + ...(compatibility === undefined ? {} : { compatibility }), clientFactory: { open: (openInput) => Layer.build(CodexReplay.layerReplayWithDriver(driver)).pipe( @@ -1018,6 +1023,54 @@ describe("CodexAdapterV2 session initialize", () => { ), ); + it.effect("refuses to open a session on a Codex version marked broken", () => + Effect.gen(function* () { + const [initialize] = replayPreamble("broken-version"); + const failure = yield* openReplaySession( + makeCodexReplayTranscript({ + scenario: "broken-version", + entries: [ + initialize!, + { + type: "emit_inbound", + label: "initialize", + frame: { + id: 1, + result: { + userAgent: "T3 Code/0.144.3 (Mac OS 15.5.0; x86_64) unknown (T3 Code; 0.1.5)", + codexHome: "/tmp/codex-home", + platformFamily: "unix", + platformOs: "macos", + }, + }, + }, + ], + }), + undefined, + (version) => + Effect.succeed( + resolveProviderCompatibility( + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + CodexAdapterV2.CODEX_DRIVER_KIND, + version, + ), + ), + ).pipe(Effect.flip); + + assert.instanceOf(failure, ProviderAdapter.ProviderAdapterOpenSessionError); + assert.instanceOf(failure.cause, ProviderSetupError); + assert.equal( + failure.cause.message, + "Codex 0.144.3 does not work with this T3 Code release. Update Codex to >=0.159.0, then retry.", + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect("completes the handshake after a caller is interrupted mid-initialize", () => Effect.gen(function* () { const initializeAwaitingResponse = yield* Deferred.make(); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 4f960b6593c76..1ebb5b5fb12d4 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -36,7 +36,8 @@ import { OrchestrationV2ProviderGoal, parseGoalCommand, ProviderDriverKind, - type ProviderSetupError, + ProviderSetupError, + type ServerProviderCompatibilityAdvisory, } from "@t3tools/contracts"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { AgentScope } from "@t3tools/shared/AgentScope"; @@ -1549,7 +1550,7 @@ export type CodexAdapterV2DriverEnv = export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapterV2")(function* ( { instanceId, environment, enabled, config }: ProviderAdapterDriverCreateInput, - hooks: Pick = {}, + hooks: Pick = {}, ) { const clientFactory = yield* CodexAppServerClientFactory; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; @@ -1600,6 +1601,9 @@ export interface CodexAdapterV2Options { readonly clientFactory: CodexAppServerClientFactoryShape; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; readonly resolveRuntime?: Effect.Effect; + readonly compatibility?: ( + version: string, + ) => Effect.Effect; readonly crypto: Crypto.Crypto; readonly fileSystem: FileSystem.FileSystem; readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; @@ -1706,24 +1710,41 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( return; } - yield* client + const userAgent = yield* client .request("initialize", { clientInfo: buildCodexInitializeParams().clientInfo, capabilities: CODEX_CLIENT_CAPABILITIES, }) .pipe( + Effect.map((response) => response.userAgent), Effect.catchTags({ // Interruption can leave Codex initialized before the local flag is set. CodexAppServerRequestError: (error) => error.code === -32600 && error.errorMessage === "Already initialized" - ? Effect.void + ? Effect.succeed(null) : Effect.fail(error), }), ); + const version = userAgent && /\/(\S+)/u.exec(userAgent)?.[1]; + const advisory = + version && adapterOptions.compatibility + ? yield* adapterOptions.compatibility(version) + : undefined; + if (advisory?.status === "broken") { + const target = + advisory.recommendedVersion ?? advisory.recommendedRange ?? "a newer version"; + return yield* new ProviderSetupError({ + instanceId: adapterOptions.instanceId, + operation: "runtime", + detail: `Codex ${version} does not work with this T3 Code release. Update Codex to ${target}, then retry.`, + }); + } yield* client.notify("initialized", undefined); yield* Ref.set(initialized, true); }), ); + // A failed open is discarded, so the next turn spawns the updated binary. + if (adapterOptions.compatibility !== undefined) yield* ensureInitialized; const now = yield* DateTime.now; const session = providerSession({ providerSessionId: input.providerSessionId, diff --git a/apps/server/src/provider/CodexInstallation.ts b/apps/server/src/provider/CodexInstallation.ts index 758eb406f1b54..88ca2c272711d 100644 --- a/apps/server/src/provider/CodexInstallation.ts +++ b/apps/server/src/provider/CodexInstallation.ts @@ -1,5 +1,9 @@ // @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest. -import { ProviderDriverKind, type ProviderInstallState } from "@t3tools/contracts"; +import { + ProviderDriverKind, + type ProviderInstallState, + type ServerProviderCompatibilityAdvisory, +} from "@t3tools/contracts"; import { cliArchiveTarCommand } from "@t3tools/shared/cliRelease"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -125,6 +129,9 @@ interface CodexInstallationService { readonly managedDirectory: string; readonly resolve: () => Effect.Effect; readonly acquire: () => Effect.Effect; + readonly compatibility: ( + version: string, + ) => Effect.Effect; readonly start: Effect.Effect; readonly cancel: ( operationId: string, @@ -796,6 +803,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function managedDirectory, resolve, acquire, + compatibility, start, cancel, state: SubscriptionRef.get(state), diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index bbd910cf5d4a9..97ec4954ed840 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -147,6 +147,7 @@ export const CodexDriver: ProviderDriver< const pathService = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; + const installation = yield* CodexInstallation.CodexInstallation; const modelCatalog = yield* ModelCatalog.ModelCatalog; const currentCatalog = modelCatalog.current(DRIVER_KIND); const processEnv = yield* mergeProviderInstanceEnvironment(environment); @@ -199,7 +200,10 @@ export const CodexDriver: ProviderDriver< enabled, config, }, - { onUsageLimits: (update) => snapshot.applyUsageLimits(update) }, + { + onUsageLimits: (update) => snapshot.applyUsageLimits(update), + compatibility: installation.compatibility, + }, ).pipe( Effect.mapError( (cause) => diff --git a/packages/provider-core/src/server/failure.test.ts b/packages/provider-core/src/server/failure.test.ts index a50d3dac9599c..b24f5bad04084 100644 --- a/packages/provider-core/src/server/failure.test.ts +++ b/packages/provider-core/src/server/failure.test.ts @@ -2,6 +2,8 @@ import { assert, it } from "@effect/vitest"; import { NodeId, ProviderDriverKind, + ProviderInstanceId, + ProviderSetupError, ProviderThreadId, ProviderTurnId, RunId, @@ -83,8 +85,16 @@ it("does not expose arbitrary cause messages and preserves explicit messages", ( } }); -it("preserves actionable handoff errors wrapped by turn startup", () => { - for (const error of [new ContextHandoffBudgetError(), new ContextHandoffBackgroundWorkError()]) { +it("preserves actionable T3 errors wrapped by turn startup", () => { + for (const error of [ + new ContextHandoffBudgetError(), + new ContextHandoffBackgroundWorkError(), + new ProviderSetupError({ + instanceId: ProviderInstanceId.make("codex"), + operation: "runtime", + detail: "Codex 0.144.3 does not work with this T3 Code release.", + }), + ]) { const cause = new ProviderAdapter.ProviderAdapterTurnStartError({ driver: ProviderDriverKind.make("codex"), threadId: ThreadId.make("thread:handoff-error"), diff --git a/packages/provider-core/src/server/failure.ts b/packages/provider-core/src/server/failure.ts index 2dd418ea2dfac..30cf077931cb0 100644 --- a/packages/provider-core/src/server/failure.ts +++ b/packages/provider-core/src/server/failure.ts @@ -108,6 +108,7 @@ function curatedCauseMessages(cause: unknown): { case "ClaudeSettingsLaunchArgError": case "ClaudeMcpRegistrationTimeoutError": case "CursorModelUnavailableError": + case "ProviderSetupError": return { known: stringField(cause, "message"), category: categoryMessage() }; case "ContextHandoffDeliveryUncertainError": return { diff --git a/scripts/install.sh b/scripts/install.sh index 4c164c45040f8..8f61779352799 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -133,6 +133,9 @@ case "$(uname -m)" in x86_64 | amd64) arch="x64" ;; *) fail "unsupported architecture $(uname -m)" ;; esac +if [ "${platform}-${arch}" = darwin-x64 ]; then + fail "there is no t3 CLI for Intel Macs. Install the desktop app from https://t3.codes/download, or run a server from source: https://github.com/${repo}/blob/main/docs/user/install.md#intel-macs" +fi command -v tar >/dev/null 2>&1 || fail "tar is required" if command -v sha256sum >/dev/null 2>&1; then checksum() { sha256sum "$1" | cut -d' ' -f1; } @@ -206,11 +209,11 @@ else elif [ "$fetch_status" -ne 0 ]; then fail "could not download the release checksums" fi + expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)" + [ -n "$expected" ] || fail "t3 ${version} has no release archive for ${platform}-${arch}" download "${base_url}/v${version}/${archive}" "${staging}/${archive}" step "Verifying the download..." - expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)" - [ -n "$expected" ] || fail "${archive} is not listed in SHA256SUMS" actual="$(checksum "${staging}/${archive}")" [ "$actual" = "$expected" ] || fail "checksum mismatch for ${archive}" From 97c2cff89737b24e61742a7c1f13a1b62715c641 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:28:19 -0500 Subject: [PATCH 014/219] fix(server): retain relay unregister events at normal log level (#21650) Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../src/cloud/ManagedEndpointRuntime.test.ts | 101 ++++++++++++++++++ .../src/cloud/ManagedEndpointRuntime.ts | 11 +- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 3b9d55ce3f6cd..409ed3ce2fb61 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -5,9 +5,11 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; +import * as References from "effect/References"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -148,6 +150,28 @@ describe("CloudManagedEndpointRuntime", () => { ).toBe("warning"); }); + it("classifies connection unregistration apart from registration and routine output", () => { + expect( + ManagedEndpointRuntime.classifyRelayClientOutput( + "2026-10-06T00:00:00Z INF Unregistered tunnel connection connIndex=0 event=0 ip=198.41.200.23", + ), + ).toBe("unregistered"); + for (const level of ["ERR", "WRN", "FTL", "PNC"]) { + expect( + ManagedEndpointRuntime.classifyRelayClientOutput( + `2026-10-06T00:00:00Z ${level} Unregistered tunnel connection connIndex=0`, + ), + ).toBe("warning"); + } + for (const line of [ + "2026-10-06T00:00:00Z INF Initiating graceful shutdown due to signal terminated ...", + "2026-10-06T00:00:00Z INF Starting metrics server on 127.0.0.1:20241/metrics", + "2026-10-06T00:00:00Z DBG Unregistered tunnel connection connIndex=0", + ]) { + expect(ManagedEndpointRuntime.classifyRelayClientOutput(line)).toBe("debug"); + } + }); + it("recognizes tunnel authorization failures without matching ordinary transport errors", () => { expect( ManagedEndpointRuntime.isRejectedRelayClientTunnelOutput( @@ -203,6 +227,83 @@ describe("CloudManagedEndpointRuntime", () => { }), ); + it.effect("logs a connection unregistration at info with the connector token redacted", () => { + const logs: Array<{ readonly level: string; readonly message: unknown }> = []; + const unregistrationLogged = Deferred.makeUnsafe(); + const logger = Logger.make(({ logLevel, message }) => { + logs.push({ level: logLevel, message }); + if (Array.isArray(message) && message[0] === "Relay client tunnel connection unregistered") { + Deferred.doneUnsafe(unregistrationLogged, Effect.void); + } + }); + + return Effect.gen(function* () { + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "secret-connector-token", + tunnelId: "tunnel-1", + }; + const encoder = new TextEncoder(); + const output = yield* Queue.unbounded(); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const handle = makeHandle({ + pid: 701, + onKill: () => {}, + output: Stream.fromQueue(output), + }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); + yield* runtime.applyConfig(config); + yield* Queue.offer( + output, + encoder.encode( + "2026-10-06T00:00:00Z INF Unregistered tunnel connection connIndex=0 token=secret-connector-token\n", + ), + ); + yield* Deferred.await(unregistrationLogged); + + // An unregistration is not a registration, so the registration timeout still recovers. + yield* TestClock.adjust(Duration.minutes(3)); + expect(Option.getOrNull(yield* Stream.runHead(runtime.recoveryRequests))).toEqual(config); + + yield* Queue.offer( + output, + encoder.encode( + "2026-10-06T00:00:01Z INF Starting metrics server on 127.0.0.1:20241/metrics\n" + + "2026-10-06T00:00:01Z INF Registered tunnel connection connIndex=0\n", + ), + ); + yield* Stream.runHead(runtime.tunnelConnected); + + const relayLogs = logs.filter( + ({ message }) => + Array.isArray(message) && String(message[0]).startsWith("Relay client tunnel"), + ); + expect(relayLogs.map(({ level, message }) => [level, (message as unknown[])[0]])).toEqual([ + ["Info", "Relay client tunnel connection unregistered"], + ["Info", "Relay client tunnel connection registered"], + ]); + expect((relayLogs[0]!.message as unknown[])[1]).toMatchObject({ + output: + "2026-10-06T00:00:00Z INF Unregistered tunnel connection connIndex=0 token=", + }); + const logged = JSON.stringify(logs.map(({ message }) => message)); + expect(logged).not.toContain("secret-connector-token"); + expect(logged).not.toContain("Starting metrics server"); + }).pipe( + Effect.provide( + Layer.merge( + Logger.layer([logger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Info"), + ), + ), + ); + }); + const tunnelConfig = { providerKind: "cloudflare_tunnel" as const, connectorToken: "token", diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 5ffd31de9acab..2528a08630a1a 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -124,12 +124,17 @@ function needsPinnedRelayClient(executable: RelayClient.RelayClientStatus): bool ); } -export function classifyRelayClientOutput(line: string): "connected" | "warning" | "debug" { +export function classifyRelayClientOutput( + line: string, +): "connected" | "unregistered" | "warning" | "debug" { if (/\bRegistered tunnel connection\b/iu.test(line)) { return "connected"; } // cloudflared uses zerolog FTL/PNC for fatal/panic; classify them above debug too. - return /\b(?:ERR|WRN|FTL|PNC)\b/u.test(line) ? "warning" : "debug"; + if (/\b(?:ERR|WRN|FTL|PNC)\b/u.test(line)) { + return "warning"; + } + return /\bINF\b.*\bUnregistered tunnel connection\b/u.test(line) ? "unregistered" : "debug"; } /** Edge rejection wording varies; any Unauthorized registration error counts. */ @@ -513,6 +518,8 @@ export const make = Effect.gen(function* () { ), Effect.asVoid, ); + case "unregistered": + return Effect.logInfo("Relay client tunnel connection unregistered", attributes); case "warning": if (/\btimeout: no recent network activity\b/u.test(line)) { return noteQuicStall(connector).pipe( From 32683c96f6911cb3219a14b31a19350dccff3075 Mon Sep 17 00:00:00 2001 From: JesstLe <106968649+JesstLe@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:28:34 +0800 Subject: [PATCH 015/219] fix(server): bound terminal history names for encrypted filesystems (#21654) Co-authored-by: Le Jsessy <13083864+le-jsessy@user.noreply.gitee.com> Co-authored-by: maria-rcks --- apps/server/src/terminal/Manager.test.ts | 176 +++++++++++++++++++- apps/server/src/terminal/Manager.ts | 195 +++++++++++------------ 2 files changed, 272 insertions(+), 99 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 7e92567772950..61fe149cf8335 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1195,7 +1195,7 @@ it.layer( it.effect("keeps reading and deleting history at an encoded path that already fit", () => Effect.gen(function* () { - const threadId = "x".repeat(120); + const threadId = "x".repeat(48); const { manager, logsDir } = yield* createManager(); const fs = yield* FileSystem.FileSystem; const logPath = yield* historyLogPath(logsDir, threadId); @@ -1213,6 +1213,180 @@ it.layer( }), ); + it.effect.each([ + { + label: "delegated setup", + threadId: + "thread:delegated-task:command%3Amcp%3A00000000-0000-4000-8000-000000000000%3Adelegate-task%3Aexample-r4-workspace-prepare-engine3", + terminalId: "setup-setup-worktree-2", + }, + { label: "long thread", threadId: "t".repeat(400), terminalId: DEFAULT_TERMINAL_ID }, + { label: "Unicode thread", threadId: "项目😀".repeat(60), terminalId: "setup" }, + { label: "long terminal", threadId: "short", terminalId: "t".repeat(128) }, + { label: "142-byte boundary", threadId: "t".repeat(48), terminalId: "s".repeat(48) }, + { label: "144-byte boundary", threadId: "t".repeat(49), terminalId: "s".repeat(48) }, + ])("persists $label history with a 143-byte filename limit", ({ threadId, terminalId }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const check = (filePath: string) => + Buffer.byteLength(path.basename(filePath)) <= 143 + ? Effect.void + : Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "access", + pathOrDescriptor: filePath, + cause: { code: "ENAMETOOLONG" }, + }), + ); + const limited = FileSystem.FileSystem.of({ + ...fs, + exists: (filePath) => Effect.andThen(check(filePath), fs.exists(filePath)), + open: (filePath, options) => Effect.andThen(check(filePath), fs.open(filePath, options)), + writeFileString: (filePath, data, options) => + Effect.andThen(check(filePath), fs.writeFileString(filePath, data, options)), + remove: (filePath, options) => + Effect.andThen(check(filePath), fs.remove(filePath, options)), + }); + const { manager, ptyAdapter, logsDir } = yield* createManager().pipe( + Effect.provideService(FileSystem.FileSystem, limited), + ); + yield* manager.open(openInput({ threadId, terminalId })); + ptyAdapter.processes[0]!.emitData("setup output\n"); + yield* manager.close({ threadId, terminalId }); + const names = yield* fs.readDirectory(logsDir); + expect(names).toHaveLength(1); + expect(Buffer.byteLength(names[0]!)).toBeLessThanOrEqual(143); + expect((yield* manager.open(openInput({ threadId, terminalId }))).history).toBe( + "setup output\n", + ); + const otherThread = `${threadId}-other`; + expect((yield* manager.open(openInput({ threadId: otherThread, terminalId }))).history).toBe( + "", + ); + ptyAdapter.processes[2]!.emitData("other output\n"); + yield* manager.close({ threadId: otherThread, terminalId }); + expect(yield* fs.readDirectory(logsDir)).toHaveLength(2); + yield* manager.close({ threadId, terminalId, deleteHistory: true }); + expect(yield* fs.readDirectory(logsDir)).toHaveLength(1); + yield* manager.close({ threadId: otherThread, deleteHistory: true }); + expect(yield* fs.readDirectory(logsDir)).toEqual([]); + }), + ); + + it.effect.each([ + { source: "encoded", terminalId: DEFAULT_TERMINAL_ID }, + { source: "encoded", terminalId: "setup-setup-worktree-2" }, + { source: "sanitized", terminalId: DEFAULT_TERMINAL_ID }, + ])("migrates oversized $source history for $terminalId", ({ source, terminalId }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { manager, logsDir } = yield* createManager(); + const threadId = "m".repeat(100); + const oldPath = + source === "encoded" + ? path.join( + logsDir, + `terminal_${Base64Url.encode(threadId)}${terminalId === DEFAULT_TERMINAL_ID ? "" : `_${Base64Url.encode(terminalId)}`}.log`, + ) + : path.join(logsDir, `${threadId}.log`); + yield* fs.writeFileString(oldPath, "old output\n"); + expect((yield* manager.open(openInput({ threadId, terminalId }))).history).toBe( + "old output\n", + ); + const names = yield* fs.readDirectory(logsDir); + expect(names).toHaveLength(1); + expect(Buffer.byteLength(names[0]!)).toBeLessThanOrEqual(143); + expect(yield* fs.exists(oldPath)).toBe(false); + expect(yield* fs.readFileString(path.join(logsDir, names[0]!))).toBe("old output\n"); + yield* manager.close({ threadId, terminalId, deleteHistory: true }); + expect(yield* fs.readDirectory(logsDir)).toEqual([]); + }), + ); + + it.effect.each(["terminal", "thread"] as const)( + "deletes compact and older encoded history when closing a %s", + (scope) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { manager, logsDir } = yield* createManager(); + const threadId = "d".repeat(100); + const terminalId = "setup-setup-worktree-2"; + yield* manager.open(openInput({ threadId, terminalId })); + yield* manager.close({ threadId, terminalId }); + const oldPath = path.join( + logsDir, + `terminal_${Base64Url.encode(threadId)}_${Base64Url.encode(terminalId)}.log`, + ); + yield* fs.writeFileString(oldPath, "stale copy\n"); + yield* fs.writeFileString(path.join(logsDir, "unrelated.log"), "keep\n"); + if (scope === "terminal") { + yield* manager.close({ threadId, terminalId, deleteHistory: true }); + } else { + yield* manager.close({ threadId, deleteHistory: true }); + } + expect(yield* fs.readDirectory(logsDir)).toEqual(["unrelated.log"]); + }), + ); + + it.effect("prefers bounded history over a stale encoded copy", () => + Effect.gen(function* () { + const { manager, ptyAdapter, logsDir } = yield* createManager(); + const fs = yield* FileSystem.FileSystem; + const threadId = "p".repeat(100); + yield* manager.open(openInput({ threadId })); + ptyAdapter.processes[0]!.emitData("current\n"); + yield* manager.close({ threadId }); + const oldPath = yield* historyLogPath(logsDir, threadId); + yield* fs.writeFileString(oldPath, "stale\n"); + + expect((yield* manager.open(openInput({ threadId }))).history).toBe("current\n"); + yield* manager.close({ threadId, terminalId: DEFAULT_TERMINAL_ID, deleteHistory: true }); + expect(yield* fs.readDirectory(logsDir)).toEqual([]); + }), + ); + + it.effect("retains encoded history if migration cannot write the bounded file", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + let failNextWrite = true; + const limited = FileSystem.FileSystem.of({ + ...fs, + writeFileString: (filePath, data, options) => { + if (!failNextWrite) return fs.writeFileString(filePath, data, options); + failNextWrite = false; + return fs.writeFileString(filePath, "partial").pipe( + Effect.andThen( + Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "writeFileString", + cause: { code: "ENOSPC" }, + }), + ), + ), + ); + }, + }); + const { manager, logsDir } = yield* createManager().pipe( + Effect.provideService(FileSystem.FileSystem, limited), + ); + const threadId = "f".repeat(100); + const oldPath = yield* historyLogPath(logsDir, threadId); + yield* fs.writeFileString(oldPath, "preserve me\n"); + + expect(Exit.isFailure(yield* Effect.exit(manager.open(openInput({ threadId }))))).toBe(true); + expect(yield* fs.readFileString(oldPath)).toBe("preserve me\n"); + expect(yield* fs.readDirectory(logsDir)).toHaveLength(1); + expect((yield* manager.open(openInput({ threadId }))).history).toBe("preserve me\n"); + }), + ); + it.effect("migrates legacy transcript filenames to terminal-scoped history path on open", () => Effect.gen(function* () { const { manager, logsDir } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index bd7ca2c92e3c2..ca93558d623ec 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -93,6 +93,8 @@ export { const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_HISTORY_BYTE_LIMIT = 8 * 1024 * 1024; +// eCryptfs permits 143-byte names. +const MAX_HISTORY_FILENAME_BYTES = 143; const MAX_HISTORY_CHUNK_LENGTH = 16 * 1024; const DEFAULT_PERSIST_DEBOUNCE_MS = 250; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; @@ -1304,8 +1306,6 @@ function legacySafeThreadId(threadId: string): string { return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); } -const MAX_FILE_NAME_BYTES = 255; - function encodedHistoryThreadPart(threadId: string): string { return `terminal_${Base64Url.encode(threadId)}`; } @@ -1672,28 +1672,46 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } }); - // Nested subagent thread ids can encode past the filename limit. Only names that would not fit - // switch to hashed ids, so every history file that already exists keeps its path. - const historyPath = (threadId: string, terminalId: string) => { - const isDefault = terminalId === DEFAULT_TERMINAL_ID; - const encodedName = joinHistoryFileName( - encodedHistoryThreadPart(threadId), - isDefault ? null : Base64Url.encode(terminalId), + const encodedHistoryPath = (threadId: string, terminalId: string) => + path.join( + logsDir, + joinHistoryFileName( + encodedHistoryThreadPart(threadId), + terminalId === DEFAULT_TERMINAL_ID ? null : Base64Url.encode(terminalId), + ), ); - if (encodedName.length <= MAX_FILE_NAME_BYTES) { - return path.join(logsDir, encodedName); - } + + const historyPath = (threadId: string, terminalId: string) => { + const encoded = encodedHistoryPath(threadId, terminalId); + if (path.basename(encoded).length <= MAX_HISTORY_FILENAME_BYTES) return encoded; return path.join( logsDir, - joinHistoryFileName(hashedHistoryThreadPart(threadId), isDefault ? null : hashId(terminalId)), + joinHistoryFileName( + hashedHistoryThreadPart(threadId), + terminalId === DEFAULT_TERMINAL_ID ? null : hashId(terminalId), + ), ); }; - const legacyHistoryName = (threadId: string) => `${legacySafeThreadId(threadId)}.log`; - const legacyHistoryPath = (threadId: string) => path.join(logsDir, legacyHistoryName(threadId)); - // Ids this long could never have been written under the legacy name. - const hasLegacyHistoryPath = (threadId: string) => - Buffer.byteLength(legacyHistoryName(threadId)) <= MAX_FILE_NAME_BYTES; + const legacyHistoryPath = (threadId: string) => + path.join(logsDir, `${legacySafeThreadId(threadId)}.log`); + + const historyPaths = (threadId: string, terminalId: string) => [ + ...new Set([ + historyPath(threadId, terminalId), + encodedHistoryPath(threadId, terminalId), + ...(terminalId === DEFAULT_TERMINAL_ID ? [legacyHistoryPath(threadId)] : []), + ]), + ]; + + const historyExists = (filePath: string) => { + const name = path.basename(filePath); + // An oversized old path can itself fail on access. Directory entries also + // preserve migration of such histories on filesystems with a larger limit. + return name.length <= MAX_HISTORY_FILENAME_BYTES + ? fileSystem.exists(filePath) + : fileSystem.readDirectory(logsDir).pipe(Effect.map((entries) => entries.includes(name))); + }; const readManagerState = SynchronizedRef.get(managerStateRef); @@ -2022,105 +2040,86 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func terminalId: string, ) { const nextPath = historyPath(threadId, terminalId); - if ( - yield* fileSystem - .exists(nextPath) - .pipe( + for (const sourcePath of historyPaths(threadId, terminalId)) { + const migrating = sourcePath !== nextPath; + const operation = migrating ? "migrate" : "read"; + if ( + !(yield* historyExists(sourcePath).pipe( Effect.mapError( - (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), + (cause) => new TerminalHistoryError({ operation, threadId, terminalId, cause }), ), - ) - ) { - const { history: raw, truncated } = yield* readHistoryTail(nextPath).pipe( + )) + ) + continue; + const { history: raw, truncated } = yield* readHistoryTail(sourcePath).pipe( Effect.scoped, Effect.mapError( - (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), + (cause) => new TerminalHistoryError({ operation, threadId, terminalId, cause }), ), ); const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit); const capped = history.value(); - if (truncated || capped !== raw) { - yield* fileSystem - .writeFileString(nextPath, capped) - .pipe( - Effect.mapError( - (cause) => - new TerminalHistoryError({ operation: "truncate", threadId, terminalId, cause }), - ), - ); - } - return history; - } - - if (terminalId !== DEFAULT_TERMINAL_ID || !hasLegacyHistoryPath(threadId)) { - return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit); - } - - const legacyPath = legacyHistoryPath(threadId); - if ( - !(yield* fileSystem - .exists(legacyPath) - .pipe( + if (migrating || truncated || capped !== raw) { + const write = migrating + ? Effect.gen(function* () { + const temporaryPath = yield* fileSystem.makeTempFileScoped({ + directory: logsDir, + prefix: ".history-", + }); + yield* fileSystem.writeFileString(temporaryPath, capped); + yield* fileSystem.rename(temporaryPath, nextPath); + }).pipe(Effect.scoped) + : fileSystem.writeFileString(nextPath, capped); + yield* write.pipe( Effect.mapError( (cause) => - new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), + new TerminalHistoryError({ + operation: migrating ? "migrate" : "truncate", + threadId, + terminalId, + cause, + }), ), - )) - ) { - return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit); + ); + } + if (migrating) { + yield* fileSystem.remove(sourcePath, { force: true }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to remove legacy terminal history", { + threadId, + terminalId, + error, + }), + ), + ); + } + return history; } - - const { history: raw } = yield* readHistoryTail(legacyPath).pipe( - Effect.scoped, - Effect.mapError( - (cause) => new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), - ), - ); - const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit); - const capped = history.value(); - yield* fileSystem - .writeFileString(nextPath, capped) - .pipe( - Effect.mapError( - (cause) => - new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), - ), - ); - yield* fileSystem.remove(legacyPath, { force: true }).pipe( - Effect.catch((cleanupError) => - Effect.logWarning("failed to remove legacy terminal history", { - threadId, - error: cleanupError, - }), - ), - ); - return history; + return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit); }); const deleteHistory = Effect.fn("terminal.deleteHistory")(function* ( threadId: string, terminalId: string, ) { - yield* fileSystem.remove(historyPath(threadId, terminalId), { force: true }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to delete terminal history", { - threadId, - terminalId, - error, - }), - ), - ); - if (terminalId === DEFAULT_TERMINAL_ID && hasLegacyHistoryPath(threadId)) { - yield* fileSystem.remove(legacyHistoryPath(threadId), { force: true }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to delete terminal history", { - threadId, - terminalId, - error, - }), + yield* Effect.forEach( + historyPaths(threadId, terminalId), + (filePath) => + Effect.gen(function* () { + if (yield* historyExists(filePath)) { + yield* fileSystem.remove(filePath, { force: true }); + } + }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to delete terminal history", { + threadId, + terminalId, + error, + }), + ), ), - ); - } + { discard: true }, + ); }); const deleteAllHistoryForThread = Effect.fn("terminal.deleteAllHistoryForThread")(function* ( From ebd1265ccf9ef6d886b7c8e8f8b017b45bfe78eb Mon Sep 17 00:00:00 2001 From: Rodrigo Brechard Date: Fri, 30 Oct 2026 21:28:50 +0100 Subject: [PATCH 016/219] fix(connect): the remove dialog says whether a T3 Connect environment is still on the account (#21706) Co-authored-by: Rodrigo Brechard Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../features/cloud/T3ConnectProfilePage.tsx | 5 + .../state/use-remote-environment-registry.ts | 36 +++--- .../RemoveT3ConnectEnvironmentDialog.tsx | 57 +++++++--- .../clerk/T3ConnectUserProfilePage.tsx | 5 + .../settings/ConnectionsSettings.tsx | 1 + docs/user/remote-access.md | 3 +- .../src/relay/discovery.test.ts | 105 +++++++++++++++++- .../client-runtime/src/relay/discovery.ts | 15 +++ .../src/state/relayDiscovery.ts | 10 ++ 9 files changed, 204 insertions(+), 33 deletions(-) diff --git a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx index 842d58c9aa371..03b6daaa4e715 100644 --- a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx +++ b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx @@ -14,6 +14,7 @@ import { SymbolView } from "../../components/ui/AppSymbol"; import * as Dialog from "../../components/ui/Dialog"; import { Menu } from "../../components/ui/Menu"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { relayEnvironmentDiscovery } from "../../state/relay"; import { useAtomCommand } from "../../state/use-atom-command"; import { deregisterManagedRelayEnvironmentCommand, @@ -46,6 +47,9 @@ export function T3ConnectProfilePage() { const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, { reportFailure: false, }); + const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refreshAfterMutation, { + reportFailure: false, + }); const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] = useState(null); const mutationPendingRef = useRef(false); @@ -75,6 +79,7 @@ export function T3ConnectProfilePage() { return { accountId, linkedAtById }; }); environmentsState.refresh(); + void refreshRelayEnvironments(); return; } if (isAtomCommandInterrupted(result)) return; diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 8a8003a6e27d4..c75456461bf64 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -1,6 +1,7 @@ import * as Dialog from "../components/ui/Dialog"; import { useAtomValue } from "@effect/atom-react"; import { useNavigation } from "@react-navigation/native"; +import { Discovery } from "@t3tools/client-runtime/relay"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -17,8 +18,17 @@ import { environmentSession } from "./session"; import { environmentCatalog } from "../connection/catalog"; import { createRemoteEnvironmentProjectionAtoms } from "./remote-environment-projections"; import { serverEnvironment } from "./server"; +import { relayEnvironmentDiscovery } from "./relay"; import { connectPairingUrl as connectPairingUrlCommand } from "../connection/onboarding"; +const RELAY_REMOVAL_ACCOUNT_NOTE: Record = { + registered: + "It stays on your T3 Connect account and keeps its host space. Deregister it under T3 Account → T3 Connect to free it.", + unregistered: "It's no longer on your T3 Connect account.", + unknown: + "This doesn't change your T3 Connect account. To deregister it there, use T3 Account → T3 Connect.", +}; + const connectionPairingUrlAtom = Atom.make("").pipe( Atom.keepAlive, Atom.withLabel("mobile:connection-pairing-url"), @@ -180,22 +190,20 @@ export function useRemoteConnections() { }, } as const; if (environment.isRelayManaged) { + const registration = Discovery.relayEnvironmentRegistration( + appAtomRegistry.get(relayEnvironmentDiscovery.stateValueAtom), + environmentId, + ); + const openAccount = { + text: "Open T3 Account", + onPress: () => navigation.navigate("SettingsAuth"), + }; Dialog.alert( "Remove from this device?", - `Forget ${environment.environmentLabel} and its cached threads on this device.\n\nIt stays on your T3 Connect account and keeps its host space. Deregister it under T3 Account → T3 Connect to free it.`, - [ - { text: "Cancel", style: "cancel" }, - // SettingsAuth renders nothing in builds without cloud config. - ...(hasCloudPublicConfig() - ? [ - { - text: "Open T3 Account", - onPress: () => navigation.navigate("SettingsAuth"), - }, - ] - : []), - remove, - ], + `Forget ${environment.environmentLabel} and its cached threads on this device.\n\n${RELAY_REMOVAL_ACCOUNT_NOTE[registration]}`, + registration === "unregistered" || !hasCloudPublicConfig() + ? [{ text: "Cancel", style: "cancel" }, remove] + : [{ text: "Cancel", style: "cancel" }, openAccount, remove], ); return; } diff --git a/apps/web/src/components/clerk/RemoveT3ConnectEnvironmentDialog.tsx b/apps/web/src/components/clerk/RemoveT3ConnectEnvironmentDialog.tsx index efe6d5785d8b7..ff398e3a398c1 100644 --- a/apps/web/src/components/clerk/RemoveT3ConnectEnvironmentDialog.tsx +++ b/apps/web/src/components/clerk/RemoveT3ConnectEnvironmentDialog.tsx @@ -1,5 +1,8 @@ +import { Discovery } from "@t3tools/client-runtime/relay"; +import type { EnvironmentId } from "@t3tools/contracts"; import { useState } from "react"; +import { useRelayEnvironmentDiscovery } from "../../state/environments"; import { AlertDialog, AlertDialogClose, @@ -14,17 +17,40 @@ import { useT3ConnectAccountPage } from "./T3ConnectAccountPages"; export function RemoveT3ConnectEnvironmentDialog({ environmentLabel, + environmentId, onCancel, onConfirm, }: { readonly environmentLabel: string | null; + readonly environmentId: EnvironmentId | null; readonly onCancel: () => void; readonly onConfirm: () => void; }) { const accountPage = useT3ConnectAccountPage(); - const [shownLabel, setShownLabel] = useState(environmentLabel); - if (environmentLabel !== null && environmentLabel !== shownLabel) setShownLabel(environmentLabel); + const discovery = useRelayEnvironmentDiscovery(); + const registration = environmentId + ? Discovery.relayEnvironmentRegistration(discovery, environmentId) + : "unknown"; + const [shown, setShown] = useState({ label: environmentLabel, registration }); + if ( + environmentLabel !== null && + (environmentLabel !== shown.label || registration !== shown.registration) + ) { + setShown({ label: environmentLabel, registration }); + } const openAccountPage = accountPage.open; + const accountPageLink = openAccountPage ? ( + { + onCancel(); + openAccountPage(); + }} + > + T3 Connect settings + + ) : ( + "T3 Connect settings" + ); return ( <> @@ -36,25 +62,24 @@ export function RemoveT3ConnectEnvironmentDialog({ > - Remove {shownLabel} from this device? + Remove {shown.label} from this device? This forgets its pairing, credentials, and cached threads here. - It stays on your T3 Connect account and keeps its host space. Deregister it in{" "} - {openAccountPage ? ( - { - onCancel(); - openAccountPage(); - }} - > - T3 Connect settings - + {shown.registration === "registered" ? ( + <> + It stays on your T3 Connect account and keeps its host space. Deregister it in{" "} + {accountPageLink} to free it. + + ) : shown.registration === "unregistered" ? ( + "It's no longer on your T3 Connect account." ) : ( - "T3 Connect settings" - )}{" "} - to free it. + <> + This doesn't change your T3 Connect account. To deregister it there, use{" "} + {accountPageLink}. + + )} diff --git a/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx b/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx index be59127723c2d..e02186af9e6fd 100644 --- a/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx +++ b/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx @@ -13,6 +13,7 @@ import { useManagedRelayEnvironments, useManagedTunnelUsage, } from "../../cloud/managedRelayState"; +import { relayEnvironmentDiscovery } from "../../state/relay"; import { useAtomCommand } from "../../state/use-atom-command"; import { T3CHAT_SUBSCRIPTION_URL, TunnelUsageMeter, tunnelUpgradeCopy } from "../cloud/TunnelUsage"; import { Button, InlineButton } from "../ui/button"; @@ -165,6 +166,9 @@ export function T3ConnectUserProfilePage() { const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, { reportFailure: false, }); + const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refreshAfterMutation, { + reportFailure: false, + }); const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] = useState(null); const [confirmingEnvironmentId, setConfirmingEnvironmentId] = useState( @@ -197,6 +201,7 @@ export function T3ConnectUserProfilePage() { return { accountId, linkedAtById }; }); environmentsState.refresh(); + void refreshRelayEnvironments(); toastManager.add({ type: "success", title: "Server deregistered", diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 502dc8c6a6e1d..16bb4a4375d9f 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1518,6 +1518,7 @@ export function ConnectionsSettings({ view }: { readonly view: "environments" | {hasCloudPublicConfig() ? ( setPendingT3ConnectRemoval(null)} onConfirm={() => { if (!pendingT3ConnectRemoval) return; diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index eca8907e87e14..a01febe918821 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -374,7 +374,8 @@ shows that it was removed from T3 Connect. To link it again, turn on command-line host that released its tunnel when it last stopped may link itself again on its next start; run `t3 connect unlink` on that host to keep it off. Removing an environment from a device's connection settings only forgets it on -that device; it stays registered to your account. +that device and doesn't change your T3 Connect account. A device keeps a saved +environment after it's deregistered until you remove it from that device. When idle tunnel cleanup is enabled, T3 Connect removes a linked environment's tunnel after it stays offline for several minutes. The environment stays linked diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts index 2e15b5448c2ce..9332652df39b5 100644 --- a/packages/client-runtime/src/relay/discovery.test.ts +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -12,6 +12,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import { Atom, AtomRegistry } from "effect/reactivity"; import * as ManagedRelay from "./managedRelay.ts"; import * as ClientCapabilities from "../platform/capabilities.ts"; @@ -20,6 +21,7 @@ import { ConnectionBlockedError, type NetworkStatus } from "../connection/model. import * as ConnectionWakeups from "../connection/wakeups.ts"; import * as RelayEnvironmentDiscovery from "./discovery.ts"; import { NETWORK_BLOCKING_HINT } from "../errors/network.ts"; +import { createRelayEnvironmentDiscoveryAtoms } from "../state/relayDiscovery.ts"; const environments = [ { @@ -59,6 +61,8 @@ function status( const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { const networkStatus = yield* SubscriptionRef.make("online"); const listCalls = yield* Ref.make(0); + const listedEnvironments = + yield* Ref.make>(environments); const listFailure = yield* Ref.make(null); const secondListCall = yield* Deferred.make(); const clerkToken = yield* Ref.make("clerk-token"); @@ -100,7 +104,7 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { if (failure) { return yield* failure; } - return environments; + return yield* Ref.get(listedEnvironments); }), getEnvironmentStatus: ({ environmentId }) => Ref.get(statusRequests).pipe( @@ -165,6 +169,7 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { return { layer, listCalls, + listedEnvironments, listFailure, clerkToken, networkStatus, @@ -179,6 +184,51 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { }); describe("RelayEnvironmentDiscovery", () => { + it.effect("fetches a fresh list after removal while an older refresh is checking statuses", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; + const atoms = createRelayEnvironmentDiscoveryAtoms( + Atom.runtime( + Layer.succeed(RelayEnvironmentDiscovery.RelayEnvironmentDiscovery, discovery), + ), + ); + const registry = AtomRegistry.make(); + yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + + const refresh = atoms.refresh.run(registry, undefined); + yield* SubscriptionRef.changes(discovery.state).pipe( + Stream.filter((state) => state.environments.size === environments.length), + Stream.runHead, + ); + const coalescedRefresh = atoms.refresh.run(registry, undefined); + yield* Ref.set(harness.listedEnvironments, [environments[1]!]); + const mutationRefresh = atoms.refreshAfterMutation.run(registry, undefined); + + const requests = yield* Ref.get(harness.statusRequests); + for (const environment of environments) { + yield* Deferred.succeed( + requests.get(environment.environmentId)!, + status(environment, "online"), + ); + } + const results = yield* Effect.promise(() => + Promise.all([refresh, coalescedRefresh, mutationRefresh]), + ); + for (const result of results) { + expect(result._tag).toBe("Success"); + } + expect(yield* Ref.get(harness.listCalls)).toBe(2); + const complete = yield* SubscriptionRef.get(discovery.state); + expect([...complete.environments.keys()]).toEqual([environments[1]!.environmentId]); + expect(complete.refreshing).toBe(false); + }).pipe(Effect.provide(harness.layer)); + }), + ), + ); + it.effect("publishes each environment status as soon as that lookup completes", () => Effect.gen(function* () { const harness = yield* makeHarness(); @@ -228,6 +278,38 @@ describe("RelayEnvironmentDiscovery", () => { }), ); + it.effect("reports an environment as unregistered only once the relay list has loaded", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; + const listed = environments[0]!.environmentId; + const deregistered = "deregistered-environment"; + const before = yield* SubscriptionRef.get(discovery.state); + expect(RelayEnvironmentDiscovery.relayEnvironmentRegistration(before, deregistered)).toBe( + "unknown", + ); + + const requests = yield* Ref.get(harness.statusRequests); + for (const environment of environments) { + yield* Deferred.succeed( + requests.get(environment.environmentId)!, + status(environment, "online"), + ); + } + yield* discovery.refresh; + + const after = yield* SubscriptionRef.get(discovery.state); + expect(RelayEnvironmentDiscovery.relayEnvironmentRegistration(after, listed)).toBe( + "registered", + ); + expect(RelayEnvironmentDiscovery.relayEnvironmentRegistration(after, deregistered)).toBe( + "unregistered", + ); + }).pipe(Effect.provide(harness.layer)); + }), + ); + it.effect( "preserves discovered rows while offline and refreshes after connectivity returns", () => @@ -350,6 +432,12 @@ describe("RelayEnvironmentDiscovery", () => { const failed = yield* SubscriptionRef.get(discovery.state); expect(failed.environments.size).toBe(0); expect(Option.isSome(failed.error)).toBe(true); + expect( + RelayEnvironmentDiscovery.relayEnvironmentRegistration( + failed, + environments[0]!.environmentId, + ), + ).toBe("unknown"); }).pipe(Effect.provide(harness.layer)); }), ); @@ -397,6 +485,12 @@ describe("RelayEnvironmentDiscovery", () => { expect(state.environments.size).toBe(0); expect(state.refreshing).toBe(false); expect(Option.isNone(state.error)).toBe(true); + expect( + RelayEnvironmentDiscovery.relayEnvironmentRegistration( + state, + environments[0]!.environmentId, + ), + ).toBe("unknown"); }).pipe(Effect.provide(harness.layer)); }), ); @@ -429,7 +523,14 @@ describe("RelayEnvironmentDiscovery", () => { yield* Fiber.join(refreshFiber); yield* Effect.yieldNow; - expect((yield* SubscriptionRef.get(discovery.state)).environments.size).toBe(0); + const signedOut = yield* SubscriptionRef.get(discovery.state); + expect(signedOut.environments.size).toBe(0); + expect( + RelayEnvironmentDiscovery.relayEnvironmentRegistration( + signedOut, + environments[0]!.environmentId, + ), + ).toBe("unknown"); }).pipe(Effect.provide(harness.layer), Effect.scoped); }), ); diff --git a/packages/client-runtime/src/relay/discovery.ts b/packages/client-runtime/src/relay/discovery.ts index 25ac154c3d7f8..ce5e708829d63 100644 --- a/packages/client-runtime/src/relay/discovery.ts +++ b/packages/client-runtime/src/relay/discovery.ts @@ -34,6 +34,8 @@ export interface RelayDiscoveredEnvironment { export interface RelayEnvironmentDiscoveryState { readonly environments: ReadonlyMap; + // Only a loaded account list distinguishes a missing registration from an unknown one. + readonly listed: boolean; readonly refreshing: boolean; readonly offline: boolean; readonly error: Option.Option; @@ -49,11 +51,22 @@ export class RelayEnvironmentDiscovery extends Context.Service< export const EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE: RelayEnvironmentDiscoveryState = { environments: new Map(), + listed: false, refreshing: false, offline: false, error: Option.none(), }; +export type RelayEnvironmentRegistration = "registered" | "unregistered" | "unknown"; + +export function relayEnvironmentRegistration( + state: RelayEnvironmentDiscoveryState, + environmentId: string, +): RelayEnvironmentRegistration { + if (state.environments.has(environmentId)) return "registered"; + return state.listed ? "unregistered" : "unknown"; +} + function validateStatus( environment: RelayClientEnvironmentRecord, status: RelayEnvironmentStatusResponse, @@ -218,6 +231,7 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { yield* Ref.set(refreshGeneration, generation); yield* SubscriptionRef.set(state, { environments: new Map(), + listed: false, refreshing: true, offline: false, error: Option.none(), @@ -273,6 +287,7 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { yield* SubscriptionRef.update(state, (current) => ({ ...current, environments: next, + listed: true, })); yield* Effect.forEach( diff --git a/packages/client-runtime/src/state/relayDiscovery.ts b/packages/client-runtime/src/state/relayDiscovery.ts index b31dd67ca9dad..47d92621b4c20 100644 --- a/packages/client-runtime/src/state/relayDiscovery.ts +++ b/packages/client-runtime/src/state/relayDiscovery.ts @@ -32,9 +32,19 @@ export function createRelayEnvironmentDiscoveryAtoms( ), }); + const refreshAfterMutation = createRuntimeCommand(runtime, { + label: "relay-environment-discovery:refresh-after-mutation", + concurrency: { mode: "serial", key: () => "refresh-after-mutation" }, + execute: (_input: void) => + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.pipe( + Effect.flatMap((discovery) => discovery.refresh), + ), + }); + return { stateAtom, stateValueAtom, refresh, + refreshAfterMutation, }; } From 50cdc2440afaa9a1fb3c924d823f37444587ab71 Mon Sep 17 00:00:00 2001 From: Ibrahim Elkamali Date: Fri, 30 Oct 2026 23:29:08 +0300 Subject: [PATCH 017/219] fix(server): browser profiles reported by one client survive another client's report (#21745) Co-authored-by: maria-rcks --- apps/server/src/preview/ServerBrowser.test.ts | 51 ++++++++++++++++- apps/server/src/preview/ServerBrowser.ts | 57 +++++++++++++++---- .../src/preview/ServerBrowserStream.test.ts | 12 ++-- apps/server/src/ws.test.ts | 2 + apps/server/src/ws.ts | 6 +- 5 files changed, 106 insertions(+), 22 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 2f09e2b084613..3cf6d25383278 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -16,6 +16,8 @@ import { type PreviewAutomationStatus, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Scope from "effect/Scope"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -614,8 +616,9 @@ it.live.each([ Effect.scoped( Effect.gen(function* () { const { browser, broker, tabId } = yield* ready; + const reportProfiles = yield* browser.profileReporter; for (const recordingFrameRate of reports) { - yield* browser.reportProfiles({ + yield* reportProfiles({ profiles: [], defaultProfileId: "default", ...(recordingFrameRate === undefined ? {} : { recordingFrameRate }), @@ -1453,7 +1456,8 @@ it.live( const broker = yield* Broker.PreviewAutomationBroker; const manager = yield* Manager.PreviewManager; yield* Effect.yieldNow; - yield* browser.reportProfiles({ + const reportProfiles = yield* browser.profileReporter; + yield* reportProfiles({ profiles: [{ id: "profile-work", name: "Work", kind: "persistent" }], defaultProfileId: "profile-work", }); @@ -1498,3 +1502,46 @@ it.live( }), ).pipe(Effect.provide(layer)), ); + +it.live("profiles reported by one client survive another client's report", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker } = yield* ready; + contextRequests.length = 0; + const work = { id: "profile-work", name: "Work", kind: "persistent" } as const; + const shared = { id: "profile-shared", name: "Shared", kind: "persistent" } as const; + const openWork = () => + broker.invoke({ + scope, + operation: "open", + input: { reuseExistingTab: false, show: false, profileId: work.id }, + }); + const reportFromA = yield* browser.profileReporter; + const clientB = yield* Scope.make(); + const reportFromB = yield* browser.profileReporter.pipe( + Effect.provideService(Scope.Scope, clientB), + ); + yield* reportFromA({ profiles: [work, shared], defaultProfileId: work.id }); + yield* reportFromB({ profiles: [shared], defaultProfileId: shared.id }); + expect(yield* openWork()).toMatchObject({ + defaultProfileId: shared.id, + profiles: [ + { id: "default", name: "Default" }, + { id: "incognito", name: "Incognito", incognito: true }, + { id: work.id, name: work.name }, + { id: shared.id, name: shared.name }, + ], + }); + expect(contextRequests).toEqual([{ profileId: work.id, isolated: false }]); + yield* Scope.close(clientB, Exit.void); + expect(yield* openWork()).toMatchObject({ defaultProfileId: work.id }); + yield* reportFromA({ profiles: [shared], defaultProfileId: shared.id }); + const removed = yield* openWork().pipe(Effect.flip); + expect(removed).toMatchObject({ + _tag: "PreviewAutomationExecutionError", + reason: + 'No browser profile is named "profile-work". Use one of: Default (id default), Incognito (id incognito), Shared (id profile-shared).', + }); + }), + ).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 57e35be778634..0e52b5066c027 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -210,6 +210,8 @@ export interface ServerBrowserViewer { readonly input: (message: unknown) => Effect.Effect; } +export type ProfileReporter = (input: PreviewReportProfilesInput) => Effect.Effect; + export class ServerBrowser extends Context.Service< ServerBrowser, { @@ -244,7 +246,7 @@ export class ServerBrowser extends Context.Service< readonly clearProfile: (profileId: string) => Effect.Effect; - readonly reportProfiles: (input: PreviewReportProfilesInput) => Effect.Effect; + readonly profileReporter: Effect.Effect; } >()("t3/preview/ServerBrowser") {} @@ -1384,6 +1386,8 @@ const make = Effect.gen(function* () { ? ("agent" as const) : ("unclaimed" as const); + const reportsByClient = new Map(); + let nextClient = 0; let reportedProfiles: { readonly profiles: ReadonlyArray; readonly defaultProfileId: string; @@ -1392,16 +1396,45 @@ const make = Effect.gen(function* () { // last report outlives the client, which lets agents record while no client is connected. let recordingFrameRate = DEFAULT_BROWSER_RECORDING_FRAME_RATE; - const reportProfiles: ServerBrowser["Service"]["reportProfiles"] = (input) => - Effect.sync(() => { - if (input.recordingFrameRate !== undefined) recordingFrameRate = input.recordingFrameRate; - const profiles = resolveBrowserProfiles(input.profiles); - reportedProfiles = { - profiles, - defaultProfileId: - findBrowserProfile(profiles, input.defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID, - }; - }); + // Each client reports only the profiles in its own settings, so the catalog is + // their union while they stay connected. The last catalog stays usable after + // every client disconnects, as a single report did before. + const rebuildReportedProfiles = () => { + const byId = new Map(); + let latest: PreviewReportProfilesInput | undefined; + for (const report of reportsByClient.values()) { + for (const profile of report.profiles) byId.set(profile.id, profile); + latest = report; + } + if (latest === undefined) return; + const profiles = resolveBrowserProfiles([...byId.values()]); + reportedProfiles = { + profiles, + defaultProfileId: + findBrowserProfile(profiles, latest.defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID, + }; + }; + + const profileReporter: ServerBrowser["Service"]["profileReporter"] = Effect.acquireRelease( + Effect.sync(() => nextClient++), + (client) => + Effect.sync(() => { + reportsByClient.delete(client); + rebuildReportedProfiles(); + }), + ).pipe( + Effect.map( + (client): ProfileReporter => + (input) => + Effect.sync(() => { + if (input.recordingFrameRate !== undefined) + recordingFrameRate = input.recordingFrameRate; + reportsByClient.delete(client); + reportsByClient.set(client, input); + rebuildReportedProfiles(); + }), + ), + ); const profileStatus = () => { const profiles = reportedProfiles?.profiles ?? BUILT_IN_BROWSER_PROFILES; @@ -2618,7 +2651,7 @@ const make = Effect.gen(function* () { return ServerBrowser.of({ attachViewer, clearProfile, - reportProfiles, + profileReporter, openDownload, answerFileChooser, }); diff --git a/apps/server/src/preview/ServerBrowserStream.test.ts b/apps/server/src/preview/ServerBrowserStream.test.ts index d3f4781829a85..5f2c685fa685c 100644 --- a/apps/server/src/preview/ServerBrowserStream.test.ts +++ b/apps/server/src/preview/ServerBrowserStream.test.ts @@ -88,7 +88,7 @@ it.effect.each([ }); const browser = ServerBrowser.ServerBrowser.of({ clearProfile: () => Effect.void, - reportProfiles: () => Effect.void, + profileReporter: Effect.succeed(() => Effect.void), openDownload: () => Effect.succeedNone, answerFileChooser: () => Effect.succeed(false), attachViewer: (input) => @@ -163,7 +163,7 @@ it.effect("sends replies ahead of stale frames and only the newest frame after a }); const browser = ServerBrowser.ServerBrowser.of({ clearProfile: () => Effect.void, - reportProfiles: () => Effect.void, + profileReporter: Effect.succeed(() => Effect.void), openDownload: () => Effect.succeedNone, answerFileChooser: () => Effect.succeed(false), attachViewer: () => Effect.succeed({ output, input: () => Effect.void }), @@ -234,7 +234,7 @@ it.effect.each([ let attachments = 0; const browser = ServerBrowser.ServerBrowser.of({ clearProfile: () => Effect.void, - reportProfiles: () => Effect.void, + profileReporter: Effect.succeed(() => Effect.void), openDownload: () => Effect.succeedNone, answerFileChooser: () => Effect.succeed(false), attachViewer: () => { @@ -275,7 +275,7 @@ it.effect("serves a tab's download only to an authorized session", () => const requests: Array = []; const browser = ServerBrowser.ServerBrowser.of({ clearProfile: () => Effect.void, - reportProfiles: () => Effect.void, + profileReporter: Effect.succeed(() => Effect.void), openDownload: (input) => Effect.sync(() => { requests.push(input); @@ -323,7 +323,7 @@ it.effect("passes uploaded files to the page's open picker and needs operate sco const answers: Array<{ chooserId: string; files: Array<{ name: string; text: string }> }> = []; const browser = ServerBrowser.ServerBrowser.of({ clearProfile: () => Effect.void, - reportProfiles: () => Effect.void, + profileReporter: Effect.succeed(() => Effect.void), openDownload: () => Effect.succeedNone, answerFileChooser: (input) => Effect.sync(() => { @@ -392,7 +392,7 @@ it.effect.each([ Effect.gen(function* () { const browser = ServerBrowser.ServerBrowser.of({ clearProfile: () => Effect.void, - reportProfiles: () => Effect.void, + profileReporter: Effect.succeed(() => Effect.void), openDownload: () => Effect.succeedNone, answerFileChooser: () => Effect.succeed(false), attachViewer: () => Effect.fail(new ServerBrowser.ServerBrowserLaunchError({ cause: error })), diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 2c6adce82f390..d9dac8a0d9f29 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -659,6 +659,7 @@ it.effect( }, {}, yield* ServerBrowser.ServerBrowser, + () => Effect.void, ).pipe(Layer.provide(configRpcServices)), ), ); @@ -826,6 +827,7 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( }, {}, yield* ServerBrowser.ServerBrowser, + () => Effect.void, ).pipe(Layer.provide(configRpcServices)), ), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index befad768da3e5..cfe49ad39d52a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1222,6 +1222,7 @@ export const layerWsRpc = ( currentSession: EnvironmentAuth.AuthenticatedSession, clientAnalyticsProps: Readonly>, serverBrowser: ServerBrowser.ServerBrowser["Service"], + reportProfiles: ServerBrowser.ProfileReporter, ) => Layer.effectContext( Effect.gen(function* () { @@ -3032,7 +3033,7 @@ export const layerWsRpc = ( [WS_METHODS.previewClose]: (input) => previewManager.close(input), [WS_METHODS.previewList]: (input) => previewManager.list(input), [WS_METHODS.previewClearProfile]: (input) => serverBrowser.clearProfile(input.profileId), - [WS_METHODS.previewReportProfiles]: (input) => serverBrowser.reportProfiles(input), + [WS_METHODS.previewReportProfiles]: (input) => reportProfiles(input), [WS_METHODS.previewReportStatus]: (input) => previewManager.reportStatus(input), [WS_METHODS.subscribePreviewEvents]: (_input) => previewManager.events, [WS_METHODS.deviceConfigure]: (input) => deviceService.configure(input), @@ -3305,6 +3306,7 @@ export const layer = Layer.unwrap( ); const clientAnalyticsProps = readClientAnalyticsProps(request); yield* analytics.recordOwners(readClientOwners(request), "client"); + const reportProfiles = yield* serverBrowser.profileReporter; const rpcWebSocketHttpEffect = yield* Effect.gen(function* () { const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket; yield* RpcServer.make(ServerWsRpcGroup, WS_RPC_SERVER_OPTIONS).pipe( @@ -3334,7 +3336,7 @@ export const layer = Layer.unwrap( return httpEffect; }).pipe( Effect.provide( - layerWsRpc(session, clientAnalyticsProps, serverBrowser).pipe( + layerWsRpc(session, clientAnalyticsProps, serverBrowser, reportProfiles).pipe( Layer.provideMerge(RpcSerialization.layerJson), // Handler-context reporters see only request defects, not server-wide defects. Layer.provide(DefectReporter.layer), From bf4659a2d65b862bc3c94a055d850c23426d3fbd Mon Sep 17 00:00:00 2001 From: Nikita Maximilian Grimm Date: Fri, 30 Oct 2026 21:29:25 +0100 Subject: [PATCH 018/219] fix(mobile): file preview videos keep their own aspect ratio (#21894) Co-authored-by: maria-rcks --- .../src/components/MediaVideoPlayer.tsx | 48 +++++++++++++++++-- .../files/WorkspaceFileVideoPreview.tsx | 1 + .../src/lib/useMediaVideoPlayback.test.tsx | 43 ++++++++++++----- apps/mobile/src/lib/videoFrameFit.test.ts | 33 +++++++++++++ apps/mobile/src/lib/videoFrameFit.ts | 14 ++++++ 5 files changed, 124 insertions(+), 15 deletions(-) create mode 100644 apps/mobile/src/lib/videoFrameFit.test.ts create mode 100644 apps/mobile/src/lib/videoFrameFit.ts diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index 37a7d0e236f19..ea901f2441331 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -1,6 +1,7 @@ import { useEvent } from "expo"; import { useVideoPlayer, VideoView } from "expo-video"; -import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { useIsFocused } from "@react-navigation/native"; +import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react"; import { Pressable, View } from "react-native"; import { AppActivityIndicator } from "./ui/AppActivityIndicator"; @@ -8,6 +9,8 @@ import { AppText } from "./ui/AppText"; import { SymbolView } from "./ui/AppSymbol"; import { VideoThumbnailImage } from "./VideoThumbnailImage"; import { useMediaActions } from "../state/mediaActions"; +import { fitVideoFrame } from "../lib/videoFrameFit"; +import { loadVideoThumbnail } from "../lib/videoThumbnails"; import { type MediaActionsSource } from "../lib/mediaActionsSource"; import { useMediaVideoPlayback } from "../lib/useMediaVideoPlayback"; import { MediaActionsMenu } from "./MediaActionsMenu"; @@ -136,6 +139,8 @@ interface MediaVideoPlayerProps { readonly visible?: boolean; readonly unavailable?: boolean; readonly expanded?: boolean; + /** Fill the parent and size the frame to the video's shape instead of a fixed 16:9 slot. */ + readonly fitToVideo?: boolean; readonly autoPlay?: boolean; readonly paused?: boolean; readonly onPlaybackStart?: () => void; @@ -145,16 +150,41 @@ interface MediaVideoPlayerProps { function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { const mediaActions = useMediaActions(props.actionsSource); + const isFocused = useIsFocused(); const [playbackUri, setPlaybackUri] = useState(props.expanded ? props.uri : null); // Keep an opened player mounted while signing or reconnecting temporarily has no usable URL. if (playbackUri === null && props.expanded && props.uri !== null) setPlaybackUri(props.uri); + const [videoSize, setVideoSize] = useState<{ width: number; height: number } | null>(null); + const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null); + const sizeSource = props.fitToVideo && isFocused && videoSize === null ? props.uri : null; + // Queue before the poster so early Play cannot cancel the extraction that supplies frame dimensions. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- loads native thumbnail dimensions independently of the poster's lifetime + useLayoutEffect(() => { + if (sizeSource === null) return; + // Thumbnails are oriented like the displayed frame, unlike VideoTrack.size. + const controller = new AbortController(); + void loadVideoThumbnail( + props.thumbnailKey, + async () => ({ uri: sizeSource, dispose: () => undefined }), + controller.signal, + ).then((thumbnail) => { + if (thumbnail && !controller.signal.aborted) { + setVideoSize({ width: thumbnail.width, height: thumbnail.height }); + } + }); + return () => controller.abort(); + }, [props.thumbnailKey, sizeSource]); + const fittedFrame = + props.fitToVideo && videoSize && bounds ? fitVideoFrame(videoSize, bounds, 480) : null; - return ( + const frame = ( @@ -214,6 +244,18 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { )} ); + if (!props.fitToVideo) return frame; + return ( + { + const { width, height } = event.nativeEvent.layout; + setBounds({ width, height }); + }} + > + {frame} + + ); } export function MediaVideoPlayer(props: MediaVideoPlayerProps) { diff --git a/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx index 72b31f061e964..7ee4a818954d5 100644 --- a/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx @@ -19,6 +19,7 @@ export function WorkspaceFileVideoPreview(props: { resolvePlaybackUri={props.resolvePlaybackUri} name={props.name} thumbnailKey={props.thumbnailKey} + fitToVideo actionsSource={props.source?.actionsSource} /> diff --git a/apps/mobile/src/lib/useMediaVideoPlayback.test.tsx b/apps/mobile/src/lib/useMediaVideoPlayback.test.tsx index c52563b4b4e23..261017c49b06e 100644 --- a/apps/mobile/src/lib/useMediaVideoPlayback.test.tsx +++ b/apps/mobile/src/lib/useMediaVideoPlayback.test.tsx @@ -119,6 +119,7 @@ import { PullRequestMediaContext, PullRequestMediaVisibleContext, } from "../features/pull-requests/PullRequestMedia"; +import { MediaVideoPlayer } from "../components/MediaVideoPlayer"; function createPlayer() { let playing = false; @@ -149,7 +150,7 @@ function createPlayer() { }; } -function setup(kind: "thread" | "PR", compact = false) { +function setup(kind: "thread" | "PR" | "files", compact = false) { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; mocks.focused = true; mocks.appState = "active"; @@ -166,10 +167,20 @@ function setup(kind: "thread" | "PR", compact = false) { const environmentId = EnvironmentId.make("test"); return { render(visible = true) { + if (kind === "files") mocks.focused = visible; act(() => root.render( - {kind === "thread" ? ( + {kind === "files" ? ( + + ) : kind === "thread" ? ( { - using screen = setup("PR", compact); +it.each([ + ["PR", false], + ["PR", true], + ["files", false], +] as const)( + "stops inactive %s thumbnail requests (compact: %s) and restarts on return", + async (kind, compact) => { + using screen = setup(kind, compact); mocks.thumbnail.mockReturnValue(new Promise(() => {})); screen.render(false); expect(mocks.thumbnail).not.toHaveBeenCalled(); await act(async () => screen.render(true)); - expect(mocks.thumbnail).toHaveBeenCalledTimes(1); - const signal = mocks.thumbnail.mock.calls[0]![2]; - expect(signal.aborted).toBe(false); - const aborted = new Promise((resolve) => signal.addEventListener("abort", resolve)); + const requests = kind === "files" ? 2 : 1; + expect(mocks.thumbnail).toHaveBeenCalledTimes(requests); + const aborted = Promise.all( + mocks.thumbnail.mock.calls.map(([, , signal]) => { + expect(signal.aborted).toBe(false); + return new Promise((resolve) => signal.addEventListener("abort", resolve)); + }), + ); screen.render(false); await act(() => aborted); - expect(mocks.thumbnail).toHaveBeenCalledTimes(1); + expect(mocks.thumbnail).toHaveBeenCalledTimes(requests); await act(async () => screen.render(true)); - expect(mocks.thumbnail).toHaveBeenCalledTimes(2); + expect(mocks.thumbnail).toHaveBeenCalledTimes(requests * 2); }, ); diff --git a/apps/mobile/src/lib/videoFrameFit.test.ts b/apps/mobile/src/lib/videoFrameFit.test.ts new file mode 100644 index 0000000000000..065434a8c2624 --- /dev/null +++ b/apps/mobile/src/lib/videoFrameFit.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { fitVideoFrame } from "./videoFrameFit"; + +describe("fitVideoFrame", () => { + const bounds = { width: 360, height: 600 }; + + it("keeps landscape videos at the full capped width", () => { + expect(fitVideoFrame({ width: 1920, height: 1080 }, bounds, 480)).toEqual({ + width: 360, + height: 202.5, + }); + }); + + it("limits portrait videos by the available height", () => { + expect(fitVideoFrame({ width: 1080, height: 1920 }, bounds, 480)).toEqual({ + width: 337.5, + height: 600, + }); + }); + + it("caps wide bounds at the inline width", () => { + expect(fitVideoFrame({ width: 1080, height: 1080 }, { width: 800, height: 900 }, 480)).toEqual({ + width: 480, + height: 480, + }); + }); + + it("returns null before the video or bounds have a size", () => { + expect(fitVideoFrame({ width: 0, height: 0 }, bounds, 480)).toBeNull(); + expect(fitVideoFrame({ width: 1080, height: 1920 }, { width: 0, height: 0 }, 480)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/videoFrameFit.ts b/apps/mobile/src/lib/videoFrameFit.ts new file mode 100644 index 0000000000000..667132df98955 --- /dev/null +++ b/apps/mobile/src/lib/videoFrameFit.ts @@ -0,0 +1,14 @@ +interface Size { + readonly width: number; + readonly height: number; +} + +/** Largest frame with the video's aspect ratio that fits the bounds and the inline width cap. */ +export function fitVideoFrame(video: Size, bounds: Size, maxWidth: number): Size | null { + if (video.width <= 0 || video.height <= 0 || bounds.width <= 0 || bounds.height <= 0) { + return null; + } + const ratio = video.width / video.height; + const width = Math.min(bounds.width, maxWidth, bounds.height * ratio); + return { width, height: width / ratio }; +} From ff55c19baa7d9e3746cd6f2ea123233b5f04a11e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christopher=20G=C3=B3mez?= <60492166+chrischriscris@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:29:40 -0400 Subject: [PATCH 019/219] fix(server): show Claude MCP elicitations on the approval card (#21903) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../Adapters/ClaudeAdapterV2.test.ts | 267 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 197 ++++++++++++- .../src/provider/CodexMcpElicitation.ts | 16 +- 3 files changed, 475 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 40f33b3a1a309..f6c5926bf8107 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -8,6 +8,7 @@ import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; import { beforeEach, vi } from "vite-plus/test"; import type { + ElicitationRequest, Query as ClaudeQuery, SDKMessage, SDKResultMessage, @@ -1187,6 +1188,82 @@ describe("ClaudeAdapterV2 approval cancellation", () => { ); }); +describe("ClaudeAdapterV2 MCP elicitation acceptance", () => { + const form = ( + properties: Record, + required?: ReadonlyArray, + ): ElicitationRequest => ({ + serverName: "deploys", + message: "Deploy web?", + mode: "form", + requestedSchema: { type: "object", properties, ...(required ? { required } : {}) }, + }); + + it("answers an empty-properties form with empty content", () => { + assert.deepEqual(ClaudeAdapterV2.claudeMcpElicitationAcceptance(form({})), { + action: "accept", + content: {}, + }); + }); + + it("picks the one-time choice however the server spells it", () => { + for (const choice of [ + "allow_once", + "Allow", + "accept_once", + "Approve once", + "allow_this_time", + "allow_once_only", + ]) { + assert.deepEqual( + ClaudeAdapterV2.claudeMcpElicitationAcceptance( + form({ decision: { type: "string", enum: ["disallow", choice, "always"] } }, [ + "decision", + ]), + ), + { action: "accept", content: { decision: choice } }, + ); + } + }); + + it("answers a persistence toggle with false", () => { + assert.deepEqual( + ClaudeAdapterV2.claudeMcpElicitationAcceptance( + form({ remember: { type: "boolean", title: "Always allow" } }, ["remember"]), + ), + { action: "accept", content: { remember: false } }, + ); + }); + + it("leaves out optional fields the card does not show", () => { + assert.deepEqual( + ClaudeAdapterV2.claudeMcpElicitationAcceptance( + form({ scope: { type: "string", enum: ["once", "always"], default: "always" } }), + ), + { action: "accept", content: { scope: "once" } }, + ); + assert.deepEqual( + ClaudeAdapterV2.claudeMcpElicitationAcceptance( + form({ limit: { type: "number", default: 500 } }), + ), + { action: "accept", content: {} }, + ); + }); + + it("has no answer for URL mode or a form asking for a value", () => { + for (const request of [ + { serverName: "deploys", message: "Sign in", mode: "url", url: "https://example.com" }, + form({ reason: { type: "string" } }, ["reason"]), + form({ grant: { type: "string", enum: ["always", "session"] } }, ["grant"]), + form({ grant: { type: "string", enum: ["deny", "always"], default: "always" } }, ["grant"]), + form({ limit: { type: "number", default: 500 } }, ["limit"]), + { serverName: "deploys", message: "Deploy web?", mode: "form" }, + ] satisfies ReadonlyArray) { + assert.isUndefined(ClaudeAdapterV2.claudeMcpElicitationAcceptance(request)); + } + }); +}); + const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(function* ( binaryPath: string, environment: ResolvedProviderInstanceEnvironment = [], @@ -4403,6 +4480,196 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + describe("MCP elicitations", () => { + const testLayer = Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + ); + const approvalOnly: ElicitationRequest = { + serverName: "deploys", + message: "Deploy web to production?", + mode: "form", + requestedSchema: { type: "object", properties: {} }, + title: "Deploy", + }; + + const startElicitationTurn = Effect.fnUntraced(function* ( + options?: Parameters[0], + ) { + const harness = yield* makeWakeHarnessWithOptions(options); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-elicitation"), + text: "Deploy web.", + attachments: [], + }), + ); + const onElicitation = harness.getOpenedOptions()?.onElicitation; + if (onElicitation === undefined) throw new Error("The Claude query has no onElicitation."); + const runtimeRequests = () => + harness.events.flatMap((event) => + event.type === "runtime_request.updated" ? [event.runtimeRequest] : [], + ); + let requestNumber = 0; + const elicit = (request: ElicitationRequest, signal = new AbortController().signal) => + Effect.promise(() => + onElicitation(request, { signal, requestId: `sdk-${requestNumber++}` }), + ).pipe(Effect.forkScoped); + const awaitCard = Effect.fnUntraced(function* (count: number) { + const event = yield* Queue.take(harness.runtimeRequestReceipts); + assert.equal(runtimeRequests().length, count); + return event.runtimeRequest; + }); + return { harness, elicit, awaitCard, runtimeRequests }; + }); + + it.effect("shows an approval-only form and accepts only after Approve", () => + Effect.gen(function* () { + const { harness, elicit, awaitCard } = yield* startElicitationTurn(); + const answer = yield* elicit(approvalOnly); + const request = yield* awaitCard(1); + assert.equal(request.kind, "mcp-elicitation"); + assert.isTrue(request.responseCapability.type === "live"); + if (request.responseCapability.type === "live") { + assert.isUndefined(request.responseCapability.supportsFeedback); + } + const card = harness.events.findLast( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "approval_request", + ); + assert.deepInclude(card?.type === "turn_item.updated" ? card.turnItem : undefined, { + prompt: "Deploy web to production?", + appName: "Deploy", + options: [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "accept", label: "Approve" }, + ], + }); + assert.isUndefined(answer.pollUnsafe()); + + yield* harness.runtime.respondToRuntimeRequest({ + requestId: request.id, + decision: "accept", + }); + assert.deepEqual(yield* Fiber.join(answer), { action: "accept", content: {} }); + + // The card is gone, so a second click is an unknown request. + const late = yield* Effect.flip( + harness.runtime.respondToRuntimeRequest({ requestId: request.id, decision: "decline" }), + ); + assert.include(String(late.cause), "No pending Claude runtime request"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect.each(["decline", "cancel"] as const)("answers %s with that action", (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: decision }); + }).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 URL and value forms without a card", () => + Effect.gen(function* () { + const { elicit, runtimeRequests } = yield* startElicitationTurn(); + for (const request of [ + { ...approvalOnly, mode: "url", url: "https://example.com/auth" }, + { + ...approvalOnly, + requestedSchema: { + type: "object", + properties: { reason: { type: "string" } }, + required: ["reason"], + }, + }, + ] satisfies ReadonlyArray) { + assert.deepEqual(yield* Fiber.join(yield* elicit(request)), { action: "decline" }); + } + assert.isEmpty(runtimeRequests()); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("cancels an already-aborted request without a card", () => + Effect.gen(function* () { + const { harness, elicit, awaitCard, runtimeRequests } = yield* startElicitationTurn(); + const aborted = yield* Effect.sync(() => { + const controller = new AbortController(); + controller.abort(); + return controller.signal; + }); + const answer = yield* elicit(approvalOnly, aborted); + assert.deepEqual(yield* Fiber.join(answer), { action: "cancel" }); + assert.isEmpty(runtimeRequests()); + const next = yield* elicit(approvalOnly); + const card = yield* awaitCard(1); + assert.equal(card.nativeRequestRef?.nativeId, "mcp-elicitation:sdk-1"); + yield* harness.runtime.respondToRuntimeRequest({ requestId: card.id, decision: "accept" }); + assert.deepEqual(yield* Fiber.join(next), { action: "accept", content: {} }); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("withdraws an open card when the SDK cancels while the session continues", () => + Effect.gen(function* () { + const { harness, elicit, awaitCard } = yield* startElicitationTurn(); + const controller = yield* Effect.sync(() => new AbortController()); + const answer = yield* elicit(approvalOnly, controller.signal); + const request = yield* awaitCard(1); + yield* Effect.sync(() => controller.abort()); + assert.deepEqual(yield* Fiber.join(answer), { action: "cancel" }); + const cancelled = yield* Queue.take(harness.runtimeRequestReceipts); + assert.equal(cancelled.runtimeRequest.id, request.id); + assert.equal(cancelled.runtimeRequest.status, "cancelled"); + assert.equal( + harness.events.findLast((e) => e.type === "node.updated")?.node.status, + "cancelled", + ); + assert.equal( + harness.events.findLast((e) => e.type === "turn_item.updated")?.turnItem.status, + "cancelled", + ); + yield* Effect.flip( + harness.runtime.respondToRuntimeRequest({ requestId: request.id, decision: "accept" }), + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("cancels an open card when the session stops", () => + Effect.gen(function* () { + // Like the SDK, the fake query aborts in-flight callbacks when closed. + const inFlight = yield* Effect.sync(() => new AbortController()); + const session = yield* Scope.make(); + const { elicit, awaitCard } = yield* startElicitationTurn({ + close: () => Effect.sync(() => inFlight.abort()), + }).pipe(Scope.provide(session)); + const answer = yield* elicit(approvalOnly, inFlight.signal); + yield* awaitCard(1); + assert.isUndefined(answer.pollUnsafe()); + + yield* Scope.close(session, Exit.void); + assert.deepEqual(yield* Fiber.join(answer), { action: "cancel" }); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + }); + it.effect( "applies model, effort and Fast changes to a process running a background shell, but not thinking or a cleared effort", () => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index f42f37d1ab1b1..65d48b01dde21 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -22,8 +22,11 @@ import { import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts"; +import { toMcpElicitationResponse } from "../../provider/CodexMcpElicitation.ts"; import { type CanUseTool, + type ElicitationRequest, + type ElicitationResult, forkSession as forkClaudeSession, type EffortLevel, type ForkSessionOptions, @@ -1470,6 +1473,7 @@ export function makeClaudeQueryOptions(input: { readonly permissionMode?: PermissionMode; readonly canUseTool?: CanUseTool; readonly onUserDialog?: ClaudeQueryOptions["onUserDialog"]; + readonly onElicitation?: ClaudeQueryOptions["onElicitation"]; readonly supportedDialogKinds?: ClaudeQueryOptions["supportedDialogKinds"]; readonly allowDangerouslySkipPermissions?: boolean; readonly appendSystemPromptFileText?: string; @@ -1568,6 +1572,7 @@ export function makeClaudeQueryOptions(input: { ? {} : { settings: effectiveQuerySettings }), ...(input.onUserDialog === undefined ? {} : { onUserDialog: input.onUserDialog }), + ...(input.onElicitation === undefined ? {} : { onElicitation: input.onElicitation }), ...(input.supportedDialogKinds === undefined ? {} : { supportedDialogKinds: input.supportedDialogKinds }), @@ -3443,6 +3448,69 @@ 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. +const CLAUDE_MCP_ELICITATION_OPTIONS: ReadonlyArray = [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "accept", label: "Approve" }, +]; + +type ClaudeMcpElicitationContent = NonNullable; + +const isClaudeMcpElicitationValue = ( + value: unknown, +): value is ClaudeMcpElicitationContent[string] => + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + (Array.isArray(value) && value.every((item) => typeof item === "string")); + +const withoutSchemaDefaults = (schema: Record): Record => { + const properties = schema.properties; + if (typeof properties !== "object" || properties === null || Array.isArray(properties)) { + return schema; + } + return { + ...schema, + properties: Object.fromEntries( + Object.entries(properties).map(([key, field]) => [ + key, + typeof field === "object" && field !== null + ? Object.fromEntries(Object.entries(field).filter(([name]) => name !== "default")) + : field, + ]), + ), + }; +}; + +// The card collects consent only; schema defaults must never grant unseen permissions. +export function claudeMcpElicitationAcceptance( + request: ElicitationRequest, +): ElicitationResult | undefined { + if (request.mode === "url" || request.requestedSchema === undefined) return undefined; + const response = toMcpElicitationResponse( + { mode: "form", requestedSchema: withoutSchemaDefaults(request.requestedSchema) }, + "accept", + ); + // The helper leaves content out when the schema is not a form it can read. + const filled = response.content; + if ( + response.action !== "accept" || + typeof filled !== "object" || + filled === null || + Array.isArray(filled) + ) { + return undefined; + } + const content: ClaudeMcpElicitationContent = {}; + for (const [key, value] of Object.entries(filled)) { + if (!isClaudeMcpElicitationValue(value)) return undefined; + content[key] = value; + } + return { action: "accept", content }; +} + const awaitClaudeUserInputAnswers = Effect.fn("awaitClaudeUserInputAnswers")(function* ( answers: Deferred.Deferred, signal: AbortSignal, @@ -7015,7 +7083,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly nativeRequestId: string; readonly requestKind: OrchestrationV2RuntimeRequest["kind"]; readonly prompt?: string; + readonly appName?: string; + readonly options?: ReadonlyArray; readonly presentation?: ReturnType; + readonly questions?: ReadonlyArray; }) { const createdAt = yield* DateTime.now; @@ -7075,7 +7146,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( responseCapability: { type: "live", providerSessionId, - ...(input.questions === undefined ? { supportsFeedback: true } : {}), + ...(input.questions === undefined && input.requestKind !== "mcp-elicitation" + ? { supportsFeedback: true } + : {}), }, createdAt, resolvedAt: null, @@ -7101,6 +7174,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( requestId, requestKind: input.requestKind as ProviderRequestKind, ...(input.prompt === undefined ? {} : { prompt: input.prompt }), + ...(input.appName === undefined ? {} : { appName: input.appName }), + ...(input.options === undefined ? {} : { options: input.options }), ...input.presentation, } : { @@ -9715,6 +9790,125 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); + const onElicitationEffect = Effect.fn("ClaudeAdapterV2.onElicitation")(function* ( + request: ElicitationRequest, + callbackOptions: Parameters>[1], + ) { + if (callbackOptions.signal.aborted) + return { action: "cancel" } satisfies ElicitationResult; + const acceptance = claudeMcpElicitationAcceptance(request); + const context = yield* Ref.get(activeTurn); + if (acceptance === undefined || context === null) { + yield* Effect.logWarning( + "Declined an MCP elicitation the approval card cannot answer.", + { + serverName: request.serverName, + mode: request.mode, + activeTurn: context !== null, + }, + ); + return { action: "decline" } satisfies ElicitationResult; + } + // 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) { + yield* releaseHeldRootFrames(context); + } + + // The SDK's requestId names this control request; T3 allocates its own + // runtime request id from it. + const nativeRequestId = `mcp-elicitation:${callbackOptions.requestId}`; + const artifacts = yield* buildApprovalRequestArtifacts({ + context, + nativeItemId: nativeRequestId, + nativeRequestId, + requestKind: "mcp-elicitation", + prompt: request.message, + appName: request.displayName ?? request.title ?? request.serverName, + options: CLAUDE_MCP_ELICITATION_OPTIONS, + }); + const decision = yield* Deferred.make(); + yield* Ref.update(pendingRuntimeRequests, (current) => { + const updated = new Map(current); + updated.set(String(artifacts.request.id), { + type: "approval", + requestKind: "mcp-elicitation", + decision, + }); + return updated; + }); + yield* Effect.all( + [ + emitProviderEvent({ + type: "node.updated", + driver: CLAUDE_PROVIDER, + node: artifacts.node, + }), + emitProviderEvent({ + type: "runtime_request.updated", + driver: CLAUDE_PROVIDER, + runtimeRequest: artifacts.request, + }), + emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: artifacts.turnItem, + }), + ], + { concurrency: 1 }, + ); + + // Closing the query aborts the signal, so a stopped session settles + // this as cancel and a late response finds no pending request. + const { decision: resolved } = yield* awaitClaudeApprovalDecision( + decision, + callbackOptions.signal, + ).pipe( + Effect.ensuring( + Ref.update(pendingRuntimeRequests, (current) => { + const updated = new Map(current); + updated.delete(String(artifacts.request.id)); + return updated; + }), + ), + ); + if (resolved === "cancel" && callbackOptions.signal.aborted) { + const now = yield* DateTime.now; + yield* emitProviderEvent({ + type: "node.updated", + driver: CLAUDE_PROVIDER, + node: { ...artifacts.node, status: "cancelled", completedAt: now }, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: { + ...artifacts.turnItem, + status: "cancelled", + completedAt: now, + updatedAt: now, + }, + }); + yield* emitProviderEvent({ + type: "runtime_request.updated", + driver: CLAUDE_PROVIDER, + runtimeRequest: { ...artifacts.request, status: "cancelled", resolvedAt: now }, + }); + } + // 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. + return resolved === "accept" + ? acceptance + : ({ + action: resolved === "cancel" ? "cancel" : "decline", + } satisfies ElicitationResult); + }); + + const onElicitation: NonNullable = ( + request, + callbackOptions, + ) => runPromise(onElicitationEffect(request, callbackOptions)); + const onUserDialog: NonNullable = ( request, callbackOptions, @@ -9988,6 +10182,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), canUseTool, onUserDialog, + onElicitation, supportedDialogKinds: ["resume_return"], ...(appendSystemPromptFileText === undefined ? {} : { appendSystemPromptFileText }), ...(toolSearchOverride === undefined ? {} : { toolSearchOverride }), diff --git a/apps/server/src/provider/CodexMcpElicitation.ts b/apps/server/src/provider/CodexMcpElicitation.ts index 7703b02f03b94..eafe09e07e706 100644 --- a/apps/server/src/provider/CodexMcpElicitation.ts +++ b/apps/server/src/provider/CodexMcpElicitation.ts @@ -76,7 +76,14 @@ function mcpElicitationPersistenceDecision( return null; } -function mcpElicitationFormFields(payload: EffectCodexSchema.McpServerElicitationRequestParams) { +// Claude hands over the same MCP request with its own envelope; these are the +// only fields the response is built from. +type McpElicitationResponseInput = { + readonly mode: string; + readonly requestedSchema?: unknown; +}; + +function mcpElicitationFormFields(payload: McpElicitationResponseInput) { if (payload.mode === "url" || !isMcpElicitationForm(payload.requestedSchema)) { return undefined; } @@ -171,7 +178,7 @@ export function describeMcpElicitation( } export function toMcpElicitationResponse( - payload: EffectCodexSchema.McpServerElicitationRequestParams, + payload: McpElicitationResponseInput, decision: ProviderApprovalDecision, ): EffectCodexSchema.McpServerElicitationRequestResponse { if (decision === "decline" || decision === "cancel") { @@ -196,8 +203,9 @@ export function toMcpElicitationResponse( const chosenOption = options.find((option) => persist ? mcpElicitationPersistenceDecision(option.value) === decision - : /once|accept|approve|allow/i.test(option.value) && - mcpElicitationPersistenceDecision(option.value) === null, + : /^(?:once|accept|approve|allow)(?:[_\s-]*(?:once(?:[_\s-]*only)?|this[_\s-]*time))?$/i.test( + option.value.trim(), + ) && mcpElicitationPersistenceDecision(option.value) === null, ); if (chosenOption) { content[key] = chosenOption.value; From a3cf3f0fe7bf1598c6be9dea302e3867713ee2d9 Mon Sep 17 00:00:00 2001 From: KishanRPatel Date: Fri, 30 Oct 2026 13:29:56 -0700 Subject: [PATCH 020/219] fix(server): the Files tree lists symlinked files and folders (#21920) Co-authored-by: maria-rcks --- .../src/workspace/WorkspaceEntries.test.ts | 108 ++++++++++++++++++ apps/server/src/workspace/WorkspaceEntries.ts | 67 ++++++++++- 2 files changed, 172 insertions(+), 3 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 730d02c577f43..6bef5f04263c6 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, afterEach, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -132,6 +133,113 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); + it.effect("lists symlinks as what they point to, hiding ones that cannot be opened", () => + Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + if (platform === "win32") return; + const cwd = yield* makeTempDir({ git: true }); + const outside = yield* makeTempDir(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, "real.txt"); + yield* writeTextFile(cwd, "realdir/nested.txt"); + yield* writeTextFile(outside, "outside.txt"); + yield* fileSystem.symlink("real.txt", path.join(cwd, "link.txt")); + yield* fileSystem.symlink("realdir", path.join(cwd, "linkdir")); + yield* fileSystem.symlink(outside, path.join(cwd, "outside-link")); + yield* fileSystem.symlink(".git", path.join(cwd, "git-link")); + yield* fileSystem.symlink("missing", path.join(cwd, "dangling")); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const root = yield* workspaceEntries.list({ cwd, directoryPath: "" }); + expect(root.entries.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([ + { path: "link.txt", kind: "file" }, + { path: "linkdir", kind: "directory" }, + { path: "real.txt", kind: "file" }, + { path: "realdir", kind: "directory" }, + ]); + expect(yield* workspaceEntries.list({ cwd, directoryPath: "linkdir" })).toEqual({ + entries: [{ path: "linkdir/nested.txt", kind: "file" }], + truncated: false, + }); + }), + ); + + it.effect("hides folder links that lead back to a folder on the listed path", () => + // "Expand all folders" loads every folder it expands, so a link that loops would + // keep nesting the same entries without end. + Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + if (platform === "win32") return; + const cwd = yield* makeTempDir(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, "a/file.txt"); + yield* writeTextFile(cwd, "b/file.txt"); + yield* fileSystem.symlink(".", path.join(cwd, "self")); + yield* fileSystem.symlink("..", path.join(cwd, "a/up")); + yield* fileSystem.symlink("../b", path.join(cwd, "a/to-b")); + yield* fileSystem.symlink("../a", path.join(cwd, "b/to-a")); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const paths = (directoryPath: string) => + workspaceEntries + .list({ cwd, directoryPath }) + .pipe(Effect.map(({ entries }) => entries.map((entry) => entry.path).toSorted())); + expect(yield* paths("")).toEqual(["a", "b"]); + // A link into a sibling folder is fine on its own... + expect(yield* paths("a")).toEqual(["a/file.txt", "a/to-b"]); + // ...but once followed, the sibling's link back is hidden. + expect(yield* paths("a/to-b")).toEqual(["a/to-b/file.txt"]); + }), + ); + + it.effect("resolves a folder of many symlinks in bounded batches", () => + Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + if (platform === "win32") return; + const cwd = yield* makeTempDir(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Shaped like a pnpm `node_modules`: one link per package into `.pnpm`. + const directoryPath = [...Array.from({ length: 80 }, () => "nested"), "node_modules"].join( + "/", + ); + const packages = Array.from({ length: 200 }, (_, index) => `pkg-${index}`); + yield* Effect.forEach( + packages, + (name) => + Effect.gen(function* () { + yield* writeTextFile(cwd, `${directoryPath}/.pnpm/${name}/index.js`); + yield* fileSystem.symlink(`.pnpm/${name}`, path.join(cwd, directoryPath, name)); + }), + { concurrency: 32, discard: true }, + ); + const realpath = NodeFSP.realpath; + let inFlight = 0; + let maxInFlight = 0; + vi.spyOn(NodeFSP, "realpath").mockImplementation((async (target: string) => { + maxInFlight = Math.max(maxInFlight, ++inFlight); + try { + return await realpath(target); + } finally { + inFlight--; + } + }) as typeof NodeFSP.realpath); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const { entries } = yield* workspaceEntries.list({ cwd, directoryPath }); + expect(entries.map((entry) => entry.path).toSorted()).toEqual( + [ + `${directoryPath}/.pnpm`, + ...packages.map((name) => `${directoryPath}/${name}`), + ].toSorted(), + ); + expect(entries.every((entry) => entry.kind === "directory")).toBe(true); + expect(maxInFlight).toBeLessThanOrEqual(64); + }), + ); + it.effect("labels the children of wide folders exactly as git does for each child", () => Effect.gen(function* () { // Above 1,000 children, listing first asks whether the whole folder is ignored. diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 5f1c5f2660a68..0b853ccdaa437 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -117,6 +117,9 @@ const IGNORED_FILES_LIMIT = 200; /** Paths sent to one `git check-ignore` call when labelling a folder's children. */ const IGNORE_CHECK_CHUNK_SIZE = 1000; +/** Children classified at once when listing a folder; each symlink costs a realpath and a stat. */ +const SYMLINK_RESOLVE_BATCH_SIZE = 64; + const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* ( input: FilesystemBrowseInput, path: Path.Path, @@ -318,13 +321,71 @@ export const make = Effect.gen(function* () { throw new Error("Directory must be inside the workspace and outside .git."); } const children = await NodeFSP.readdir(directory, { withFileTypes: true }); + const isSameOrInside = (parent: string, child: string) => { + const relativeChild = path.relative(parent, child); + return ( + relativeChild !== ".." && + !relativeChild.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativeChild) + ); + }; + // Reject canonical ancestors so "Expand all folders" cannot follow a cycle. + let visitedFolders: Promise> | undefined; + const loadVisitedFolders = () => + (visitedFolders ??= (async () => { + const folders = [root]; + let current = normalizedCwd; + for (const segment of target.relativePath.split("/").filter(Boolean)) { + current = path.join(current, segment); + folders.push(await NodeFSP.realpath(current)); + } + return folders; + })()); + const resolveSymlinkKind = async ( + name: string, + ): Promise => { + const resolved = await NodeFSP.realpath(path.join(directory, name)).catch( + () => undefined, + ); + if ( + resolved === undefined || + !isSameOrInside(root, resolved) || + path.relative(root, resolved).split(path.sep).includes(".git") + ) { + return undefined; + } + const stat = await NodeFSP.stat(resolved).catch(() => undefined); + if (stat?.isFile()) return "file"; + if (!stat?.isDirectory()) return undefined; + const folders = await loadVisitedFolders(); + return folders.some((folder) => isSameOrInside(resolved, folder)) + ? undefined + : "directory"; + }; + // Bound filesystem work for directories such as pnpm node_modules. + const kinds: Array = []; + for (let offset = 0; offset < children.length; offset += SYMLINK_RESOLVE_BATCH_SIZE) { + kinds.push( + ...(await Promise.all( + children + .slice(offset, offset + SYMLINK_RESOLVE_BATCH_SIZE) + .map(async (child): Promise => { + if (child.name === ".git") return undefined; + if (child.isDirectory()) return "directory"; + if (child.isFile()) return "file"; + return child.isSymbolicLink() ? resolveSymlinkKind(child.name) : undefined; + }), + )), + ); + } return { - entries: children.flatMap((child): ProjectEntry[] => { - if (child.name === ".git" || (!child.isDirectory() && !child.isFile())) return []; + entries: children.flatMap((child, index): ProjectEntry[] => { + const kind = kinds[index]; + if (kind === undefined) return []; return [ { path: target.relativePath ? `${target.relativePath}/${child.name}` : child.name, - kind: child.isDirectory() ? "directory" : "file", + kind, }, ]; }), From 915bb6dd161c6bceec82fe7e9da705e06309ca60 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:30:11 -0500 Subject: [PATCH 021/219] fix(server): disable text conversion when measuring review-guide hunks (#21988) Co-authored-by: Claude Opus 5.5 --- .../PullRequestReviewGuideService.test.ts | 45 +++++++++++++++++++ .../PullRequestReviewGuideService.ts | 5 ++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts index b4dd965570a9f..a4d944a457286 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts @@ -466,6 +466,51 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic ), ); + it.effect("keeps excerpts of source lines a configured diff driver would convert away", () => + Effect.scoped( + Effect.gen(function* () { + const repositories = yield* makeRepositories; + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + `${repositories.workspace}/.git/info/attributes`, + "feature.txt diff=blank\n", + ); + yield* git(repositories.workspace, ["config", "diff.blank.textconv", "true"]); + const stored = yield* Deferred.make(); + const context = yield* Layer.build( + makeLayer({ + ...repositories, + stored, + generate: () => + Effect.succeed({ + stops: [ + { + title: "The feature", + summary: "The change.", + attention: null, + excerpts: [{ path: "feature.txt", side: "new", start: 1, end: 3, caption: "" }], + files: [], + notes: [], + questions: [], + }, + ], + }), + }), + ); + const service = Context.get( + context, + PullRequestReviewGuideService.PullRequestReviewGuideService, + ); + yield* service.generate(reference); + yield* Deferred.await(stored); + const result = yield* service.get(reference); + assert.deepStrictEqual(result.guide?.stops[0]?.excerpts, [ + { path: "feature.txt", side: "new", start: 1, end: 3, caption: "" }, + ]); + }), + ), + ); + it.effect("refuses generation when the verified remote disappeared despite a usable origin", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts index e0dc7309289b6..a7618a23d27ae 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts @@ -344,7 +344,10 @@ const make = Effect.gen(function* () { }) .pipe(Effect.map((result) => result.stdout)); const [nameOnly, patch] = yield* Effect.all( - [diff(["--name-only", "-z"]), diff(["--unified=0", "--no-color", "--no-ext-diff"])], + [ + diff(["--name-only", "-z"]), + diff(["--unified=0", "--no-color", "--no-ext-diff", "--no-textconv"]), + ], { concurrency: "unbounded" }, ); return parseReviewGuideChange(nameOnly, patch); From 3c3a8f5917ed9fd8e3d10208ea66ca23c32147df Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:30:27 -0500 Subject: [PATCH 022/219] fix(bitbucket): skip Cloud API reads for unsupported Server remotes (#22029) Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../pullRequest/PullRequestService.test.ts | 46 +++++++++++ .../src/pullRequest/PullRequestService.ts | 4 +- .../src/server/BitbucketApi.test.ts | 76 ++++++++++++++++++- .../src/server/BitbucketApi.ts | 21 ++++- .../BitbucketPullRequestProvider.test.ts | 25 ++++++ .../server/BitbucketPullRequestProvider.ts | 3 + .../src/server/PullRequestProvider.ts | 5 ++ 7 files changed, 177 insertions(+), 3 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 49060afb27586..004e883784499 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -482,6 +482,52 @@ it.effect("reads nothing from a host with no implementation, but reports it", () }), ); +it.effect("reads nothing from a host its provider does not serve, but reports it", () => + Effect.gen(function* () { + const reached = new Set(); + const bitbucket = (id: string, host: string) => + project({ + id, + title: host, + workspaceRoot: `/${id}`, + repository: "proj/repo", + provider: SourceControlProviderKind.make("bitbucket"), + host, + }); + const service = yield* makeService({ + projects: [bitbucket("p1", "bitbucket.org"), bitbucket("p2", "bitbucket.example.com")], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("bitbucket"), { + servesHost: (host) => host === "bitbucket.org", + getViewer: ({ host }) => { + reached.add(host ?? ""); + return Effect.succeed("bilal"); + }, + listChangeRequests: ({ host }) => { + reached.add(host); + return Effect.succeed({ items: [], truncated: false, continues: false }); + }, + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual([...reached], ["bitbucket.org"]); + assert.deepStrictEqual( + result.providers.map(({ host, configured, detail }) => ({ host, configured, detail })), + [ + { host: "bitbucket.org", configured: true, detail: null }, + { + host: "bitbucket.example.com", + configured: false, + detail: "This host cannot be browsed here yet.", + }, + ], + ); + }), +); + it.effect("says where each repository carries on, and from nothing it has run out of", () => Effect.gen(function* () { const service = yield* makeService({ diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index a3ce2e75ddacd..e77434c1697b3 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -515,6 +515,7 @@ function withRateLimitBackoff( ? {} : { unavailableRequirement: api.unavailableRequirement }), ...(api.repositoryKey === undefined ? {} : { repositoryKey: api.repositoryKey }), + ...(api.servesHost === undefined ? {} : { servesHost: api.servesHost }), ...(api.readsPublicHostWithoutCheckout === undefined ? {} : { readsPublicHostWithoutCheckout: api.readsPublicHostWithoutCheckout }), @@ -925,7 +926,8 @@ export const make = Effect.gen(function* () { if (filter.host !== undefined && host !== filter.host.toLowerCase()) { continue; } - const api = registry.get(kind); + const registered = registry.get(kind); + const api = registered?.servesHost?.(host) === false ? null : registered; if (api !== null) { const roots = viewerRoots.get(host); if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]); diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts index c0464276abf24..944397e51e715 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts @@ -10,6 +10,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; @@ -72,6 +73,7 @@ function makeLayer(input: { readonly stall?: boolean; readonly git?: Partial; readonly env?: Record; + readonly remoteUrl?: string; }) { const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => input.stall @@ -108,7 +110,7 @@ function makeLayer(input: { remotes: [ { name: "origin", - url: "git@bitbucket.org:pingdotgg/t3code.git", + url: input.remoteUrl ?? "git@bitbucket.org:pingdotgg/t3code.git", pushUrl: Option.none(), isPrimary: true, }, @@ -337,6 +339,78 @@ it.effect("prefers an explicit repository and uses context when the repository i }).pipe(Effect.provide(layer)); }); +it.effect("sends a self-hosted Bitbucket remote nowhere unless an API base url is configured", () => + Effect.gen(function* () { + const remoteUrl = "https://bitbucket.example.com/scm/proj/repo.git"; + const context = { + provider: { + kind: SourceControlProviderKind.make("bitbucket"), + name: "Bitbucket Self-Hosted", + baseUrl: "https://bitbucket.example.com", + }, + remoteName: "origin", + remoteUrl, + }; + const cloudEnv = { + T3CODE_BITBUCKET_EMAIL: "user@example.com", + T3CODE_BITBUCKET_API_TOKEN: "t", + }; + const response = (request: HttpClientRequest.HttpClientRequest) => + Response.json(request.url.endsWith("/branching-model") ? {} : repositoryJson); + const defaultBranch = (setup: ReturnType, withContext: boolean) => + BitbucketApi.BitbucketApi.pipe( + Effect.flatMap((bitbucket) => + bitbucket.getDefaultBranch({ cwd: "/repo", ...(withContext ? { context } : {}) }), + ), + Effect.provide(setup.layer), + ); + const requestedUrls = (setup: ReturnType) => + setup.execute.mock.calls.map(([request]) => request.url).toSorted(); + + // A self-hosted remote in the checkout, then a self-hosted context beside a Cloud remote. + for (const [setup, withContext] of [ + [makeLayer({ response, env: cloudEnv, remoteUrl }), false], + [makeLayer({ response, env: cloudEnv }), true], + ] as const) { + const result = yield* defaultBranch(setup, withContext).pipe(Effect.result); + assert.deepStrictEqual(requestedUrls(setup), []); + assert.strictEqual( + Result.isFailure(result) && result.failure._tag, + "BitbucketRepositoryRemoteNotFoundError", + ); + } + + // An explicit repository does not let a self-hosted context reach the Cloud API. + const explicit = makeLayer({ response, env: cloudEnv }); + const cloneUrls = yield* BitbucketApi.BitbucketApi.pipe( + Effect.flatMap((bitbucket) => + bitbucket.getRepositoryCloneUrls({ cwd: "/repo", context, repository: "proj/repo" }), + ), + Effect.provide(explicit.layer), + Effect.result, + ); + assert.deepStrictEqual(requestedUrls(explicit), []); + assert.strictEqual( + Result.isFailure(cloneUrls) && cloneUrls.failure._tag, + "BitbucketRepositoryRemoteNotFoundError", + ); + + const cloud = makeLayer({ response, env: cloudEnv }); + yield* defaultBranch(cloud, false); + assert.deepStrictEqual(requestedUrls(cloud), [ + "https://api.bitbucket.org/2.0/repositories/pingdotgg/t3code", + "https://api.bitbucket.org/2.0/repositories/pingdotgg/t3code/branching-model", + ]); + + const configured = makeLayer({ response, remoteUrl }); + yield* defaultBranch(configured, true); + assert.deepStrictEqual(requestedUrls(configured), [ + "https://api.test.local/2.0/repositories/proj/repo", + "https://api.test.local/2.0/repositories/proj/repo/branching-model", + ]); + }), +); + it.effect( "prefers the Bitbucket branching model development branch as the default PR target", () => { diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index 863b206d95578..511b6f2a07a79 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -12,6 +12,7 @@ import { SourceControlProviderKind, TrimmedNonEmptyString, type SourceControlProviderAuth, + type SourceControlProviderInfo, type SourceControlRepositoryCloneUrls, type SourceControlRepositoryVisibility, } from "@t3tools/contracts"; @@ -51,6 +52,15 @@ const BitbucketApiEnvConfig = Config.all({ apiToken: Config.String("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), }); +// Server and Data Center use a different API; a custom Cloud endpoint must be explicit. +export const BitbucketServesHost = Config.String("T3CODE_BITBUCKET_API_BASE_URL").pipe( + Config.option, + Config.map( + (baseUrl) => (host: string) => + Option.isSome(baseUrl) || host === "bitbucket.org" || host.endsWith(".bitbucket.org"), + ), +); + const BitbucketApiOperation = Schema.Literals([ "resolveRepository", "getRepository", @@ -646,6 +656,11 @@ export const make = Effect.gen(function* () { host: "bitbucket.org", }; + const servesHost = yield* BitbucketServesHost; + + const servesProvider = (provider: SourceControlProviderInfo | null) => + provider?.kind === "bitbucket" && servesHost(URL.parse(provider.baseUrl)?.hostname ?? ""); + const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; const currentCredential = host.settings.get.pipe( @@ -757,6 +772,10 @@ export const make = Effect.gen(function* () { readonly context?: SourceControlProvider.SourceControlProviderContext; readonly repository?: string; }) { + if (input.context?.provider.kind === "bitbucket" && !servesProvider(input.context.provider)) { + // Falling back to another remote could act on a different repository. + return yield* new BitbucketRepositoryRemoteNotFoundError({ cwd: input.cwd }); + } if (input.repository !== undefined) { return yield* requireRepositoryLocator(input.repository); } @@ -787,7 +806,7 @@ export const make = Effect.gen(function* () { ); for (const remote of remotes.remotes) { - if (recognizeRemoteWith(definition, remote.url) === null) continue; + if (!servesProvider(recognizeRemoteWith(definition, remote.url))) continue; const parsed = parseBitbucketRemoteUrl(remote.url); if (parsed) return parsed; } diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts index 9bdf13598d9e2..df1e9740cabe5 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts @@ -1,5 +1,6 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; @@ -178,6 +179,30 @@ it.effect("reads checks and PR state without diff, mergeability, or permission r }), ); +it.effect("serves only Bitbucket Cloud hosts unless an API base url is configured", () => + Effect.gen(function* () { + const servedHosts = (env: Record) => + make.pipe( + Effect.map(({ servesHost }) => + ["bitbucket.org", "altssh.bitbucket.org", "bitbucket.example.com"].filter( + (host) => servesHost?.(host) === true, + ), + ), + Effect.provide( + Layer.merge( + Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), + ConfigProvider.layer(ConfigProvider.fromEnv({ env })), + ), + ), + ); + + expect(yield* servedHosts({})).toEqual(["bitbucket.org", "altssh.bitbucket.org"]); + expect( + yield* servedHosts({ T3CODE_BITBUCKET_API_BASE_URL: "https://bitbucket.example.com/api" }), + ).toEqual(["bitbucket.org", "altssh.bitbucket.org", "bitbucket.example.com"]); + }), +); + describe("bitbucketProviderFailure", () => { it("treats only an HTTP 401 as unusable credentials", () => { const responseError = (status: number) => diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts index 0e134af8572df..a2bd46a285b79 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts @@ -2,6 +2,7 @@ import { resolvePullRequestAuthorFilter, SourceControlProviderKind } from "@t3to import * as Effect from "effect/Effect"; import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; +import * as BitbucketApi from "./BitbucketApi.ts"; import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; import { PullRequestProviderError, @@ -109,6 +110,7 @@ function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeReque export const make = Effect.gen(function* () { const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + const servesHost = yield* BitbucketApi.BitbucketServesHost.pipe(Effect.orDie); const fail = (operation: string) => (error: BitbucketPullRequestApi.BitbucketPullRequestApiError) => @@ -148,6 +150,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("bitbucket"), capabilities: CAPABILITIES, + servesHost, unavailableRequirement: UNAVAILABLE_REQUIREMENT, readsPublicHostWithoutCheckout: true, diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 4aa0c06cb8e8e..a175c365d080e 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -308,6 +308,11 @@ export interface PullRequestProviderApi { * definition's `publicHost` can be read from a project with no checkout of its repository. */ readonly readsPublicHostWithoutCheckout?: boolean; + /** + * Absent means every host detected as this kind is read. A host it refuses is listed as one that + * cannot be browsed here, and nothing is sent for it. + */ + readonly servesHost?: (host: string) => boolean; readonly getViewer: (input: { readonly cwd: string; From 79d9820ed9e2e19518fa6c916ff8eb8c95c373ce Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:30:45 -0500 Subject: [PATCH 023/219] fix(providers): apply the registry deadline while reading version metadata (#22044) Co-authored-by: Claude Opus 5.5 --- .../src/server/maintenanceResolver.test.ts | 35 +++++++++++++++++++ .../src/server/maintenanceResolver.ts | 19 ++++------ 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index 60522e0c29f48..ef2035089ff04 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -13,7 +13,9 @@ import * as Fiber from "effect/Fiber"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as TestClock from "effect/testing/TestClock"; import * as ProviderLatestVersions from "./ProviderLatestVersions.ts"; import { createProviderVersionAdvisory, @@ -141,6 +143,39 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ), ); + it.effect("reads the latest version from the npm registry", () => + resolveLatestProviderVersion(manualPackageTool).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "1.2.3" }))), + ), + ), + Effect.map((version) => { + expect(version).toBe("1.2.3"); + }), + ), + ); + + it.effect("gives up on a registry body that stalls after the headers", () => + Effect.gen(function* () { + const lookup = yield* resolveLatestProviderVersion(manualPackageTool).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(new ReadableStream()))), + ), + ), + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("4 seconds"); + expect(lookup.pollUnsafe()).toBeDefined(); + expect(yield* Fiber.join(lookup)).toBeNull(); + }), + ); + it.effect("does not fetch latest provider versions when update checks are disabled", () => enrichProviderSnapshotWithVersionAdvisory( { ...installedPackageToolProvider, installedVersion: "1.1.0" }, diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 8a8e11e2ec0d7..c97a246ae83e3 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -27,6 +27,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; @@ -827,22 +828,14 @@ const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (pack const request = HttpClientRequest.get( `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, ).pipe(HttpClientRequest.setHeader("accept", "application/json")); - const response = yield* client.execute(request).pipe( + // The deadline covers the body too: headers can arrive before the JSON stalls. + const payload = yield* client.execute(request).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(NpmLatestVersionResponse)), Effect.timeoutOption(LATEST_VERSION_TIMEOUT_MS), Effect.orElseSucceed(() => Option.none()), ); - if (Option.isNone(response)) { - return null; - } - const httpResponse = response.value; - if (httpResponse.status < 200 || httpResponse.status >= 300) { - return null; - } - const payload = yield* httpResponse.json.pipe( - Effect.flatMap(Schema.decodeUnknownEffect(NpmLatestVersionResponse)), - Effect.orElseSucceed(() => null), - ); - return payload ? nonEmptyString(payload.version) : null; + return Option.isSome(payload) ? nonEmptyString(payload.value.version) : null; }); /** The installer's own answer is cached apart from the npm registry entry. */ From 33c6401bf4ae1d0d3d7402cbb80ffd8ad318ddae Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:31:00 -0500 Subject: [PATCH 024/219] fix(server): review guides keep excerpts in files with spaces or escaped names (#22053) Co-authored-by: Claude Opus 5.5 --- .../pullRequestReviewGuideNormalize.test.ts | 31 +++++++++++++++++++ .../pullRequestReviewGuideNormalize.ts | 17 ++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts index 69479b1ccfeed..a47743b34263a 100644 --- a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts +++ b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts @@ -91,6 +91,37 @@ describe("parseReviewGuideChange", () => { expect(change.hunks.get("src/gone.ts")).toEqual({ new: [], old: [{ start: 1, end: 20 }] }); expect(change.hunks.get("docs/readme.md")).toEqual({ new: [{ start: 3, end: 3 }], old: [] }); }); + + it("reads file names git quotes or ends with a tab", () => { + const unusual = [ + "diff --git a/with space.txt b/with space.txt", + "--- a/with space.txt\t", + "+++ b/with space.txt\t", + "@@ -1,0 +2 @@", + 'diff --git "a/tab\\tname.txt" "b/tab\\tname.txt"', + '--- "a/tab\\tname.txt"', + '+++ "b/tab\\tname.txt"', + "@@ -1,0 +2 @@", + 'diff --git "a/back\\\\slash.txt" "b/back\\\\slash.txt"', + '--- "a/back\\\\slash.txt"', + '+++ "b/back\\\\slash.txt"', + "@@ -1,0 +2 @@", + 'diff --git "a/gone\\tfile.txt" "b/gone\\tfile.txt"', + '--- "a/gone\\tfile.txt"', + "+++ /dev/null", + "@@ -1 +0,0 @@", + "", + ].join("\n"); + const names = ["with space.txt", "tab\tname.txt", "back\\slash.txt", "gone\tfile.txt"]; + const parsed = parseReviewGuideChange(names.join("\0"), unusual); + expect([...parsed.hunks.keys()]).toEqual(names); + expect(parsed.hunks.get("gone\tfile.txt")).toEqual({ new: [], old: [{ start: 1, end: 1 }] }); + const result = normalizeReviewGuide( + guide({ stops: [stop({ excerpts: names.slice(0, 3).map((path) => excerpt(path, 1, 5)) })] }), + parsed, + ); + expect(result.stops[0]?.excerpts).toEqual(names.slice(0, 3).map((path) => excerpt(path, 2, 2))); + }); }); describe("normalizeReviewGuide", () => { diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts index 4075f19ba9ba9..184908e4bd7f2 100644 --- a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts +++ b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts @@ -1,5 +1,6 @@ import type { PullRequestReviewGuideExcerpt, PullRequestReviewGuideStop } from "@t3tools/contracts"; import type { ReviewGuideGenerationResult } from "@t3tools/provider-core/server/textGeneration"; +import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; const MAX_EXCERPT_LINES = 25; @@ -25,6 +26,18 @@ const hunkRange = (start: string, count: string | undefined): LineRange | null = return lines === 0 ? null : { start: Number(start), end: Number(start) + lines - 1 }; }; +/** + * Git quotes a name holding a tab, quote or backslash, and ends an unquoted name holding a space + * with a tab, so the first literal tab is never part of what the file is called. + */ +function headerPath(line: string, prefix: string): string | null { + const rest = line.slice(4); + const tab = rest.indexOf("\t"); + const token = tab === -1 ? rest : rest.slice(0, tab); + const path = token.startsWith('"') ? unquoteGitPatchPath(token) : token; + return path.startsWith(prefix) ? path.slice(prefix.length) : null; +} + export function parseReviewGuideChange(nameOnly: string, patch: string): ReviewGuideChange { const hunks = new Map(); let oldPath: string | null = null; @@ -37,9 +50,9 @@ export function parseReviewGuideChange(nameOnly: string, patch: string): ReviewG current = null; inHunks = false; } else if (!inHunks && line.startsWith("--- ")) { - oldPath = line.startsWith("--- a/") ? line.slice(6) : null; + oldPath = headerPath(line, "a/"); } else if (!inHunks && line.startsWith("+++ ")) { - const path = line.startsWith("+++ b/") ? line.slice(6) : oldPath; + const path = headerPath(line, "b/") ?? oldPath; if (path === null) continue; current = hunks.get(path) ?? { new: [], old: [] }; hunks.set(path, current); From f2417cde4d6c4ce9490aefafac35b0a11c88a278 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:36:37 -0500 Subject: [PATCH 025/219] fix(mobile): await branch refs before choosing an automatic feature branch (#22098) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../features/threads/git/GitConfirmSheet.tsx | 1 + .../use-selected-thread-git-actions.test.ts | 75 ++++++++++++++++++- .../state/use-selected-thread-git-actions.ts | 19 ++++- 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx index 4f26496160e34..ad76eb41540e0 100644 --- a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx @@ -94,6 +94,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { gitState.selectedThreadBranches.length > 0 ? gitState.selectedThreadBranches : await gitActions.refreshSelectedThreadBranches(); + if (branches === null) return; const newBranchName = resolveAutoFeatureBranchName( Arr.filterMap(branches, (branch) => branch.isRemote ? Result.failVoid : Result.succeed(branch.name), diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.test.ts b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts index ce3f8a83f2c13..548ae2d3ce6b0 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.test.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts @@ -1,4 +1,6 @@ import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; +import type { VcsListRefsResult } from "@t3tools/contracts"; +import { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -20,6 +22,8 @@ const state = vi.hoisted(() => ({ results: [] as { type: string; description?: string }[], afterGitAction: undefined as (() => void) | undefined, statusRefreshes: 0, + readRefs: vi.fn(), + cachedRefs: [] as VcsListRefsResult["refs"], })); vi.mock("react", () => ({ @@ -46,7 +50,7 @@ vi.mock("./use-selected-thread-worktree", () => ({ }), })); vi.mock("./queries", () => ({ - useBranches: () => ({ data: { refs: [] }, refresh: () => {} }), + useBranches: () => ({ data: { refs: state.cachedRefs }, refresh: () => {} }), })); vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); @@ -79,6 +83,7 @@ vi.mock("./threads", () => ({ })); vi.mock("./vcs", () => ({ vcsEnvironment: { + readRefs: state.readRefs, refreshStatus: async () => { state.statusRefreshes += 1; return AsyncResult.success({ refName: state.branch }); @@ -231,3 +236,71 @@ describe("thread Git mutation permissions", () => { }, ); }); + +describe("automatic feature branch refs", () => { + const empty: VcsListRefsResult = { + refs: [], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 0, + }; + + it.each([false, true])("awaits fresh refs before naming, cached: %s", async (cached) => { + const request = Promise.withResolvers(); + state.cachedRefs = cached + ? [ + { + name: "old-branch", + isRemote: false, + current: false, + isDefault: false, + worktreePath: null, + }, + ] + : []; + state.readRefs.mockReturnValue(request.promise.then(AsyncResult.success)); + let settled = false; + const refresh = useSelectedThreadGitActions() + .refreshSelectedThreadBranches() + .then((refs) => { + settled = true; + return refs; + }); + await Promise.resolve(); + expect(settled).toBe(false); + const local = { + name: "feature/update", + isRemote: false, + current: false, + isDefault: false, + worktreePath: null, + }; + request.resolve({ + ...empty, + refs: [ + local, + { ...local, name: "origin/feature/update", isRemote: true, remoteName: "origin" }, + ], + totalCount: 2, + }); + const refs = await refresh; + expect(refs).toEqual([local]); + expect(resolveAutoFeatureBranchName(refs!.map((ref) => ref.name))).toBe("feature/update-2"); + expect(state.readRefs).toHaveBeenCalledWith({ + environmentId: "environment", + input: { cwd: "/repo", limit: 100, refresh: true }, + }); + }); + + it("reports lookup failure instead of returning empty refs", async () => { + state.results = []; + state.readRefs.mockResolvedValue( + AsyncResult.failure(Cause.fail(new Error("Cannot list refs"))), + ); + expect(await useSelectedThreadGitActions().refreshSelectedThreadBranches()).toBeNull(); + expect(state.results).toEqual([ + { type: "error", title: "Git action failed", description: "Cannot list refs" }, + ]); + }); +}); diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index 0d1e5b9388e24..a5351fb3141d1 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -56,6 +56,7 @@ export function useSelectedThreadGitActions() { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const readRefs = useAtomCommand(vcsEnvironment.readRefs, { reportFailure: false }); const refreshStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const createRef = useAtomCommand(vcsEnvironment.createRef, { reportFailure: false }); @@ -206,9 +207,21 @@ export function useSelectedThreadGitActions() { return result.value; }; - const refreshSelectedThreadBranches = async (): Promise> => { - branchState.refresh(); - return dedupeRemoteBranchesWithLocalMatches(branchState.data?.refs ?? []).filter( + const refreshSelectedThreadBranches = async (): Promise | null> => { + if (branchTarget.environmentId === null || branchTarget.cwd === null) return null; + const result = await readRefs({ + environmentId: branchTarget.environmentId, + input: { cwd: branchTarget.cwd, limit: 100, refresh: true }, + }); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + const message = error instanceof Error ? error.message : "Failed to refresh branches."; + setPendingConnectionError(message); + showGitActionResult({ type: "error", title: "Git action failed", description: message }); + return null; + } + setPendingConnectionError(null); + return dedupeRemoteBranchesWithLocalMatches(result.value.refs).filter( (branch) => !branch.isRemote, ); }; From 120dbb062ded0a262e04c4dfe6b77a1c68db609c Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:36:54 -0500 Subject: [PATCH 026/219] fix(mobile): recover incoming shares after interrupted writes (#22115) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../sharing/incoming-share-storage.test.ts | 212 +++++++++++++++++- .../sharing/incoming-share-storage.ts | 51 ++++- 2 files changed, 243 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/sharing/incoming-share-storage.test.ts b/apps/mobile/src/features/sharing/incoming-share-storage.test.ts index 82913d48fd2e9..ceaaff3216a26 100644 --- a/apps/mobile/src/features/sharing/incoming-share-storage.test.ts +++ b/apps/mobile/src/features/sharing/incoming-share-storage.test.ts @@ -2,18 +2,84 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; const fileSystemMocks = vi.hoisted(() => { - let entries: File[] = []; + const files = new Map(); + let failNextWrite = false; + let failRelocationTo = ""; + let snapshots: Map[] | undefined; + const record = () => snapshots?.push(new Map(files)); class File { - readonly exists = true; + name: string; + readonly parentDirectory: Directory; + readonly contents: string; - constructor( - readonly name: string, - private readonly contents: string, - ) {} + constructor(parent: string | Directory, nameOrContents: string) { + this.name = typeof parent === "string" ? parent : nameOrContents; + this.parentDirectory = typeof parent === "string" ? new Directory() : parent; + this.contents = typeof parent === "string" ? nameOrContents : ""; + } + + get exists(): boolean { + return files.has(this.name); + } + + create(): void { + files.set(this.name, ""); + record(); + } + + async write(contents: string): Promise { + files.set(this.name, contents.slice(0, 1)); + record(); + if (failNextWrite) { + failNextWrite = false; + throw new Error("interrupted write"); + } + files.set(this.name, contents); + record(); + } + + copySync(destination: File): void { + files.delete(destination.name); + record(); + files.set(destination.name, files.get(this.name)!.slice(0, 1)); + record(); + if (destination.name.endsWith(failRelocationTo) && failRelocationTo) { + failRelocationTo = ""; + throw new Error("copy failed after partial destination write"); + } + files.set(destination.name, files.get(this.name)!); + record(); + } + + moveSync(destination: File): void { + // Expo removes the destination before attempting the native move. + files.delete(destination.name); + record(); + if (destination.name.endsWith(failRelocationTo) && failRelocationTo) { + failRelocationTo = ""; + throw new Error("move failed after destination removal"); + } + // Android can fall back to copy-and-delete after native moves fail. + files.set(destination.name, files.get(this.name)!.slice(0, 1)); + record(); + if (failRelocationTo === "partial") { + failRelocationTo = ""; + throw new Error("move failed during fallback copy"); + } + files.set(destination.name, files.get(this.name)!); + files.delete(this.name); + this.name = destination.name; + record(); + } + + delete(): void { + files.delete(this.name); + record(); + } async text(): Promise { - return this.contents; + return files.get(this.name)!; } } @@ -21,15 +87,31 @@ const fileSystemMocks = vi.hoisted(() => { create(): void {} list(): ReadonlyArray { - return entries; + return [...files].map(([name, contents]) => new File(name, contents)); } } return { Directory, File, + failNextWrite() { + failNextWrite = true; + }, + failRelocationTo(suffix: string) { + failRelocationTo = suffix; + }, + trackOperations() { + snapshots = []; + return snapshots; + }, setEntries(next: File[]) { - entries = next; + files.clear(); + for (const file of next) { + files.set(file.name, file.contents); + } + failNextWrite = false; + failRelocationTo = ""; + snapshots = undefined; }, }; }); @@ -40,7 +122,12 @@ vi.mock("expo-file-system", () => ({ Paths: { document: "/documents" }, })); -import { loadIncomingShareDrafts } from "./incoming-share-storage"; +import { + IncomingShareStorageError, + loadIncomingShareDrafts, + removeIncomingShareDraft, + writeIncomingShareDraft, +} from "./incoming-share-storage"; const VALID_DRAFT = { schemaVersion: 1, @@ -67,4 +154,109 @@ describe("incoming share storage", () => { await expect(loadIncomingShareDrafts()).resolves.toEqual([VALID_DRAFT]); expect(warning).toHaveBeenCalledOnce(); }); + + it.each(["write", "partial"])("retries an interrupted initial %s", async (failure) => { + if (failure === "write") fileSystemMocks.failNextWrite(); + else fileSystemMocks.failRelocationTo(failure); + + await expect(writeIncomingShareDraft(VALID_DRAFT)).rejects.toMatchObject({ + operation: "write", + shareId: VALID_DRAFT.id, + }); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + await expect(loadIncomingShareDrafts()).resolves.toEqual([]); + + await writeIncomingShareDraft(VALID_DRAFT); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]); + }); + + it("preserves a saved share when its reservation write fails and allows retrying", async () => { + await writeIncomingShareDraft(VALID_DRAFT); + const reserved = { + ...VALID_DRAFT, + destination: { environmentId: "env-1", projectId: "project-1" }, + }; + fileSystemMocks.failNextWrite(); + + await expect(writeIncomingShareDraft(reserved)).rejects.toBeInstanceOf( + IncomingShareStorageError, + ); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]); + + await writeIncomingShareDraft(reserved); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([reserved]); + }); + + it("removes staging and backup files after a successful replacement", async () => { + await writeIncomingShareDraft(VALID_DRAFT); + const updated = { ...VALID_DRAFT, text: "Updated" }; + + await writeIncomingShareDraft(updated); + + expect(new fileSystemMocks.Directory().list().map((file) => file.name)).toEqual([ + `${VALID_DRAFT.id}.json`, + ]); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([updated]); + }); + + it("keeps a successful replacement when backup cleanup fails", async () => { + await writeIncomingShareDraft(VALID_DRAFT); + const updated = { ...VALID_DRAFT, text: "Updated" }; + vi.spyOn(fileSystemMocks.File.prototype, "delete").mockImplementationOnce(() => { + throw new Error("backup cleanup failed"); + }); + + await expect(writeIncomingShareDraft(updated)).resolves.toBeUndefined(); + + expect(new fileSystemMocks.File(`${VALID_DRAFT.id}.json.bak`, "").exists).toBe(true); + expect(new fileSystemMocks.File(`${VALID_DRAFT.id}.json.tmp`, "").exists).toBe(false); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([updated]); + }); + + it.each([".bak", ".json", "partial"])( + "recovers after a failed replacement of %s", + async (suffix) => { + await writeIncomingShareDraft(VALID_DRAFT); + fileSystemMocks.failRelocationTo(suffix); + + await expect( + writeIncomingShareDraft({ ...VALID_DRAFT, text: "Updated" }), + ).rejects.toBeInstanceOf(IncomingShareStorageError); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]); + + // A second failed write must not damage a backup left by the failed move. + fileSystemMocks.failNextWrite(); + await expect(writeIncomingShareDraft(VALID_DRAFT)).rejects.toBeInstanceOf( + IncomingShareStorageError, + ); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]); + fileSystemMocks.failRelocationTo(".json"); + await expect(writeIncomingShareDraft(VALID_DRAFT)).rejects.toBeInstanceOf( + IncomingShareStorageError, + ); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]); + await writeIncomingShareDraft(VALID_DRAFT); + await removeIncomingShareDraft(VALID_DRAFT.id); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([]); + }, + ); + + it("loads one complete share after interruption at each replacement operation", async () => { + await writeIncomingShareDraft(VALID_DRAFT); + const updated = { ...VALID_DRAFT, text: "Updated" }; + const snapshots = fileSystemMocks.trackOperations(); + await writeIncomingShareDraft(updated); + + for (const snapshot of snapshots) { + fileSystemMocks.setEntries( + [...snapshot].map(([name, contents]) => new fileSystemMocks.File(name, contents)), + ); + const loaded = await loadIncomingShareDrafts({ strict: true }); + expect(loaded).toHaveLength(1); + expect([VALID_DRAFT, updated]).toContainEqual(loaded[0]); + await removeIncomingShareDraft(VALID_DRAFT.id); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([]); + } + expect(snapshots.length).toBeGreaterThan(0); + }); }); diff --git a/apps/mobile/src/features/sharing/incoming-share-storage.ts b/apps/mobile/src/features/sharing/incoming-share-storage.ts index 763da41cba8ca..06d64e4bc349b 100644 --- a/apps/mobile/src/features/sharing/incoming-share-storage.ts +++ b/apps/mobile/src/features/sharing/incoming-share-storage.ts @@ -1,4 +1,5 @@ import * as Schema from "effect/Schema"; +import type { File } from "expo-file-system"; import { decodeIncomingShareDraft, type IncomingShareDraft } from "./incoming-share-model"; @@ -28,9 +29,23 @@ async function getDirectory() { return directory; } -async function getFile(shareId: string) { +async function getFile(shareId: string, suffix = "") { const { File } = await import("expo-file-system"); - return new File(await getDirectory(), fileName(shareId)); + return new File(await getDirectory(), `${fileName(shareId)}${suffix}`); +} + +async function readSavedDraft(file: File) { + try { + return { file, draft: decodeIncomingShareDraft(JSON.parse(await file.text()) as unknown) }; + } catch (cause) { + const { File } = await import("expo-file-system"); + const backup = new File(file.parentDirectory, `${file.name}.bak`); + if (!backup.exists) throw cause; + return { + file: backup, + draft: decodeIncomingShareDraft(JSON.parse(await backup.text()) as unknown), + }; + } } export async function loadIncomingShareDrafts(options?: { @@ -40,11 +55,14 @@ export async function loadIncomingShareDrafts(options?: { const { File } = await import("expo-file-system"); const drafts: IncomingShareDraft[] = []; for (const entry of (await getDirectory()).list()) { - if (!(entry instanceof File) || !entry.name.endsWith(".json")) { + if (!(entry instanceof File)) continue; + if (entry.name.endsWith(".json.bak")) { + if (new File(entry.parentDirectory, entry.name.slice(0, -4)).exists) continue; + } else if (!entry.name.endsWith(".json")) { continue; } try { - drafts.push(decodeIncomingShareDraft(JSON.parse(await entry.text()) as unknown)); + drafts.push((await readSavedDraft(entry)).draft); } catch (cause) { const error = new IncomingShareStorageError({ operation: "load", shareId: null, cause }); if (options?.strict) { @@ -65,10 +83,22 @@ export async function loadIncomingShareDrafts(options?: { export async function writeIncomingShareDraft(draft: IncomingShareDraft): Promise { try { const file = await getFile(draft.id); - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); + const backup = await getFile(draft.id, ".bak"); + const temp = await getFile(draft.id, ".tmp"); + // Inbox mutations are serialized, so each share can reuse its staging path. + temp.create({ intermediates: true, overwrite: true }); + await temp.write(JSON.stringify(draft)); + // Expo can remove or partially copy the destination before a move fails. + if (file.exists) { + const saved = await readSavedDraft(file).catch(() => undefined); + if (!saved || saved.file === file) file.copySync(backup, { overwrite: true }); + } + temp.moveSync(file, { overwrite: true }); + try { + if (backup.exists) backup.delete(); + } catch { + // The installed JSON is authoritative even if backup cleanup fails. } - await file.write(JSON.stringify(draft)); } catch (cause) { throw new IncomingShareStorageError({ operation: "write", shareId: draft.id, cause }); } @@ -76,9 +106,10 @@ export async function writeIncomingShareDraft(draft: IncomingShareDraft): Promis export async function removeIncomingShareDraft(shareId: string): Promise { try { - const file = await getFile(shareId); - if (file.exists) { - file.delete(); + // Delete the backup first so a failed removal cannot resurrect a consumed share. + for (const suffix of [".tmp", ".bak", ""]) { + const file = await getFile(shareId, suffix); + if (file.exists) file.delete(); } } catch (cause) { throw new IncomingShareStorageError({ operation: "remove", shareId, cause }); From 1b78b65337af72a7516a3c8981534be812e76233 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:37:11 -0500 Subject: [PATCH 027/219] fix(ssh): reuse one askpass helper directory instead of a new temp dir per command (#22123) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- packages/ssh/src/auth.test.ts | 169 ++++++++++++++++++++++++++++++++++ packages/ssh/src/auth.ts | 53 ++++++++--- 2 files changed, 211 insertions(+), 11 deletions(-) diff --git a/packages/ssh/src/auth.test.ts b/packages/ssh/src/auth.test.ts index ebebe71e856be..439350d6010a1 100644 --- a/packages/ssh/src/auth.test.ts +++ b/packages/ssh/src/auth.test.ts @@ -1,11 +1,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { buildSshChildEnvironment, isSshAuthFailure } from "./auth.ts"; @@ -50,4 +54,169 @@ describe("ssh auth", () => { Effect.scoped, ), ); + + it.effect("reuses the default askpass directory across interactive commands", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const askpassPath = () => + buildSshChildEnvironment({ interactiveAuth: true, authSecret: "pw" }).pipe( + Effect.map((env) => env.SSH_ASKPASS ?? ""), + ); + const runtimeDirectoryOf = (askpass: string) => path.dirname(path.dirname(askpass)); + + const first = yield* askpassPath(); + assert.equal(yield* askpassPath(), first); + + yield* fs.remove(runtimeDirectoryOf(first), { recursive: true }); + const recreated = yield* askpassPath(); + assert.notEqual(recreated, first); + assert.equal(yield* fs.exists(recreated), true); + + const cachedOwner = yield* fs.stat(runtimeDirectoryOf(recreated)); + const otherUser = yield* askpassPath().pipe( + Effect.provideService( + HostProcess.UserId, + (Option.getOrUndefined(cachedOwner.uid) ?? 0) + 1, + ), + ); + assert.notEqual(otherUser, recreated); + + yield* fs.remove(runtimeDirectoryOf(recreated), { recursive: true }); + yield* fs.remove(runtimeDirectoryOf(otherUser), { recursive: true }); + }).pipe( + Effect.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux"))), + ), + ); + + it.effect.skipIf(!symlinksSupported)( + "does not reuse a symlink left at the default askpass directory path", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const askpassPath = () => + buildSshChildEnvironment({ interactiveAuth: true, authSecret: "pw" }).pipe( + Effect.map((env) => env.SSH_ASKPASS ?? ""), + ); + const runtimeDirectoryOf = (askpass: string) => path.dirname(path.dirname(askpass)); + + const first = yield* askpassPath(); + const ownedTarget = yield* fs.makeTempDirectory({ prefix: "t3code-ssh-symlink-target-" }); + yield* fs.remove(runtimeDirectoryOf(first), { recursive: true }); + yield* fs.symlink(ownedTarget, runtimeDirectoryOf(first)); + + const next = yield* askpassPath(); + assert.notEqual(next, first); + assert.equal(yield* fs.exists(path.join(ownedTarget, "t3code-ssh-askpass")), false); + + yield* fs.remove(runtimeDirectoryOf(first)); + yield* fs.remove(ownedTarget, { recursive: true }); + yield* fs.remove(runtimeDirectoryOf(next), { recursive: true }); + }).pipe( + Effect.provide( + Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux")), + ), + ), + ); + + it.effect("writes the default askpass helper once when interactive commands overlap", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const runtimeDirectories: Array = []; + yield* Effect.addFinalizer(() => + Effect.forEach(runtimeDirectories, (directory) => + fs.remove(directory, { recursive: true }).pipe(Effect.ignore), + ), + ); + + // The first helper write of each round waits for the test, so the second command starts + // while the first is still setting up and must not touch the shared files until it is done. + let gate: { arrived: Deferred.Deferred; release: Deferred.Deferred } | undefined; + let fileCalls = 0; + let helperWrites = 0; + const counted = (effect: Effect.Effect) => + Effect.suspend(() => { + fileCalls += 1; + return effect; + }); + const trackedFs: FileSystem.FileSystem = { + ...fs, + makeTempDirectory: (options) => + counted(fs.makeTempDirectory(options)).pipe( + Effect.tap((directory) => Effect.sync(() => runtimeDirectories.push(directory))), + ), + stat: (path) => counted(fs.stat(path)), + exists: (path) => counted(fs.exists(path)), + writeFileString: (path, data, options) => + counted( + Effect.gen(function* () { + helperWrites += 1; + const held = gate; + gate = undefined; + if (held) { + yield* Deferred.succeed(held.arrived, undefined); + yield* Deferred.await(held.release); + } + yield* fs.writeFileString(path, data, options); + }), + ), + }; + const askpassPath = buildSshChildEnvironment({ + interactiveAuth: true, + authSecret: "pw", + }).pipe( + Effect.map((env) => env.SSH_ASKPASS ?? ""), + Effect.provideService(FileSystem.FileSystem, trackedFs), + ); + const twoOverlappingCommands = Effect.gen(function* () { + const held = { + arrived: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + gate = held; + const first = yield* Effect.forkChild(askpassPath); + yield* Deferred.await(held.arrived); + const callsBefore = fileCalls; + const second = yield* askpassPath.pipe(Effect.forkChild({ startImmediately: true })); + assert.equal(fileCalls, callsBefore, "second command touched the files mid-setup"); + yield* Deferred.succeed(held.release, undefined); + return [yield* Fiber.join(first), yield* Fiber.join(second)] as const; + }); + + const [first, second] = yield* twoOverlappingCommands; + assert.equal(second, first); + assert.equal(runtimeDirectories.length, 1); + assert.equal(helperWrites, 1); + + yield* fs.remove(first); + assert.deepStrictEqual(yield* twoOverlappingCommands, [first, first]); + assert.equal(runtimeDirectories.length, 1); + assert.equal(helperWrites, 2); + + yield* fs.remove(first); + const held = { + arrived: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + gate = held; + const cancelled = yield* Effect.forkChild(askpassPath); + yield* Deferred.await(held.arrived); + const callsBeforeCancellation = fileCalls; + const interruption = yield* Fiber.interrupt(cancelled).pipe( + Effect.forkChild({ startImmediately: true }), + ); + const next = yield* askpassPath.pipe(Effect.forkChild({ startImmediately: true })); + const callsDuringCancellation = fileCalls; + yield* Deferred.succeed(held.release, undefined); + yield* Fiber.join(interruption); + assert.equal(yield* Fiber.join(next), first); + assert.equal(callsDuringCancellation, callsBeforeCancellation); + assert.equal(helperWrites, 3); + assert.include(yield* fs.readFileString(first), 'printf "%s\\n" "$T3_SSH_AUTH_SECRET"'); + }).pipe( + Effect.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux"))), + Effect.scoped, + ), + ); }); diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index 1ea49363b60b2..a759063e1df8d 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -7,6 +7,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Semaphore from "effect/Semaphore"; import { SshPasswordPromptError } from "./errors.ts"; @@ -101,15 +102,6 @@ if ($null -ne $env:T3_SSH_AUTH_SECRET) {\r exit 1\r `; -const getDefaultSshAskpassDirectory = Effect.fn("ssh/auth.getDefaultSshAskpassDirectory")( - function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const parentDirectory = yield* fs.makeTempDirectory({ prefix: "t3code-ssh-runtime-" }); - return path.join(parentDirectory, SSH_ASKPASS_DIR_NAME); - }, -); - const buildSshAskpassHelperDescriptor = Effect.fn("ssh/auth.buildSshAskpassHelperDescriptor")( function* (input: { readonly directory: string; @@ -172,6 +164,43 @@ const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")(fu return descriptor.launcherPath; }); +// Keep setup serialized through cancellation so writes finish before another command uses the helper. +let defaultSshAskpassRuntimeDirectory: string | undefined; +const defaultSshAskpassLock = Semaphore.makeUnsafe(1); + +const ensureDefaultSshAskpassHelpers = Effect.fn("ssh/auth.ensureDefaultSshAskpassHelpers")( + function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userId = yield* HostProcess.UserId; + let runtimeDirectory = defaultSshAskpassRuntimeDirectory; + if (runtimeDirectory !== undefined) { + const cached = runtimeDirectory; + const reusable = yield* Effect.all([fs.realPath(cached), fs.stat(cached)]).pipe( + Effect.map( + ([resolved, info]) => + resolved === cached && + info.type === "Directory" && + (userId === undefined || Option.getOrUndefined(info.uid) === userId), + ), + Effect.orElseSucceed(() => false), + ); + if (!reusable) { + runtimeDirectory = undefined; + } + } + runtimeDirectory ??= yield* fs.realPath( + yield* fs.makeTempDirectory({ prefix: "t3code-ssh-runtime-" }), + ); + const launcherPath = yield* ensureSshAskpassHelpers({ + directory: path.join(runtimeDirectory, SSH_ASKPASS_DIR_NAME), + }); + defaultSshAskpassRuntimeDirectory = runtimeDirectory; + return launcherPath; + }, + (effect) => defaultSshAskpassLock.withPermit(Effect.uninterruptible(effect)), +); + export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnvironment")(function* ( input: SshChildEnvironmentOptions = {}, ): Effect.fn.Return< @@ -192,8 +221,10 @@ export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnviron Effect.orElseSucceed(() => Option.none()), Effect.map(Option.getOrUndefined), ); - const directory = input.askpassDirectory ?? (yield* getDefaultSshAskpassDirectory()); - const sshAskpass = yield* ensureSshAskpassHelpers({ directory }); + const sshAskpass = + input.askpassDirectory === undefined + ? yield* ensureDefaultSshAskpassHelpers() + : yield* ensureSshAskpassHelpers({ directory: input.askpassDirectory }); return { ...baseEnv, From 3a9640c3eb839fadd22f43dd3fc7e5dc433f6725 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:37:27 -0500 Subject: [PATCH 028/219] fix(claude): name the notebook a NotebookEdit changed (#22130) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../Adapters/ClaudeAdapterV2.test.ts | 84 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 2 +- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index f6c5926bf8107..e1ee3bbe36f0a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6865,6 +6865,90 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("names the notebook a Claude NotebookEdit changed", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-notebook-edit"), + text: "Fix the notebook", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + uuid: "00000000-0000-4000-8000-000000000604", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + message: { + id: "msg_notebook_edit", + model: "claude-sonnet-4-6", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "notebook", + name: "NotebookEdit", + input: { + notebook_path: "/workspace/analysis.ipynb", + cell_id: "c1", + new_source: "print(1)", + }, + }, + ], + stop_reason: "tool_use", + stop_sequence: null, + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + uuid: "00000000-0000-4000-8000-000000000605", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "notebook", content: "Updated cell c1" }], + }, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000606", result: "Done" }), + ); + yield* Queue.take(harness.terminalReceipts); + const item = harness.events + .flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === "notebook" + ? [event.turnItem] + : [], + ) + .at(-1); + assert.equal(item?.type, "file_change"); + if (item?.type === "file_change") assert.equal(item.fileName, "/workspace/analysis.ipynb"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + ), + ), + ); + it.effect.each(["claude-haiku-4-5-20251001", "claude-sonnet-4-6"])( "renegotiates auto only when switching API models after fallback to default: %s", (nextModel) => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 65d48b01dde21..1fb3b9ff120e1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2923,7 +2923,7 @@ function parseClaudeBackgroundTaskEntry( function fileNameFromClaudeTool(toolName: string, input: ClaudeNativeToolInput): string { return ( - firstStringInputField(input, ["file_path", "path", "filename", "fileName"]) ?? + firstStringInputField(input, ["file_path", "notebook_path", "path", "filename", "fileName"]) ?? `${toolName} result` ); } From 2b77a05cdb7a8fc11767219638f60c65bcc707f5 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:37:43 -0700 Subject: [PATCH 029/219] fix(mobile): new-task context no longer changes every render (#22134) Co-authored-by: maria-rcks --- .../threads/new-task-flow-provider.tsx | 126 +++++++++--------- apps/mobile/src/reactCompilerCoverage.test.ts | 8 ++ .../src/state/queries.debounce.test.tsx | 105 ++++++++++++++- apps/mobile/src/state/queries.ts | 41 +++--- 4 files changed, 196 insertions(+), 84 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 14a9720cb4e96..4aa44e73c5586 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -327,72 +327,13 @@ export function NewTaskFlowProvider( null); const linkedEnvironments = useLinkedEnvironments(selectedProject?.environmentId ?? null); + const storedStickyModelSelection = useStickyComposerModelSelection(); + const modelSelectionNow = useNowMinuteMs(); const projectDefaults = useNewTaskProjectDefaults(selectedProject); const selectedEnvironmentServerConfig = projectDefaults.serverConfig; const isScratchDraft = projectDefaults.isScratch; const serverConfigs = useServerConfigs(); const { connectedEnvironments } = useRemoteConnectionStatus(); - const scratchEnvironments = connectedEnvironments.filter( - (environment) => - availableScratchWorkspaceRoot( - environment.connectionState, - serverConfigs.get(environment.environmentId), - ) !== null, - ); - - const selectedRepositoryKey = selectedProject?.repositoryIdentity - ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity) - : null; - // Use || null: placeholder workspaceRoot can be empty, and an empty basename rejects real hosts. - const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; - const selectedProjectTitle = selectedProject?.title ?? null; - const environments = (() => { - if (isScratchDraft) { - return scratchEnvironments.map((environment) => ({ - environmentId: environment.environmentId, - environmentLabel: environment.environmentLabel, - })); - } - const seen = new Set(); - const result: Array<{ - readonly environmentId: EnvironmentId; - readonly environmentLabel: string; - }> = []; - const hostsSelectedRepository = (project: EnvironmentProject) => { - if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) { - return true; - } - const projectKey = project.repositoryIdentity - ? repositoryGroupingKeyOf(project.repositoryIdentity) - : null; - if (selectedRepositoryKey !== null && projectKey !== null) { - return projectKey === selectedRepositoryKey; - } - return ( - project.workspaceRoot.split("/").at(-1) === selectedWorkspaceBasename || - (selectedProjectTitle !== null && project.title === selectedProjectTitle) - ); - }; - for (const project of projects) { - if (!hostsSelectedRepository(project)) { - continue; - } - if (seen.has(project.environmentId)) { - continue; - } - const environment = savedConnectionsById[project.environmentId]; - if (!environment) { - continue; - } - seen.add(project.environmentId); - result.push({ - environmentId: project.environmentId, - environmentLabel: environment.environmentLabel, - }); - } - return result; - })(); - const selectedProjectDraftKey = selectedProject ? activeDraftKey : null; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- creates the persistent draft in the new-task flow store once a project is selected, before the composer mounts useEffect(() => { @@ -485,13 +426,11 @@ export function NewTaskFlowProvider( selectedProjectDraft.modelSelection ?? null, selectedProject?.id ?? null, ); - const modelSelectionNow = useNowMinuteMs(); const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, projectSettings.settings.defaultModelSelection, selectedProject?.id ?? null, ); - const storedStickyModelSelection = useStickyComposerModelSelection(); const stickyModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, storedStickyModelSelection, @@ -842,6 +781,67 @@ export function NewTaskFlowProvider( }; }; + const scratchEnvironments = connectedEnvironments.filter( + (environment) => + availableScratchWorkspaceRoot( + environment.connectionState, + serverConfigs.get(environment.environmentId), + ) !== null, + ); + + const selectedRepositoryKey = selectedProject?.repositoryIdentity + ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity) + : null; + // Use || null: placeholder workspaceRoot can be empty, and an empty basename rejects real hosts. + const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; + const selectedProjectTitle = selectedProject?.title ?? null; + const environments = (() => { + if (isScratchDraft) { + return scratchEnvironments.map((environment) => ({ + environmentId: environment.environmentId, + environmentLabel: environment.environmentLabel, + })); + } + const seen = new Set(); + const result: Array<{ + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + }> = []; + const hostsSelectedRepository = (project: EnvironmentProject) => { + if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) { + return true; + } + const projectKey = project.repositoryIdentity + ? repositoryGroupingKeyOf(project.repositoryIdentity) + : null; + if (selectedRepositoryKey !== null && projectKey !== null) { + return projectKey === selectedRepositoryKey; + } + return ( + project.workspaceRoot.split("/").at(-1) === selectedWorkspaceBasename || + (selectedProjectTitle !== null && project.title === selectedProjectTitle) + ); + }; + for (const project of projects) { + if (!hostsSelectedRepository(project)) { + continue; + } + if (seen.has(project.environmentId)) { + continue; + } + const environment = savedConnectionsById[project.environmentId]; + if (!environment) { + continue; + } + seen.add(project.environmentId); + result.push({ + environmentId: project.environmentId, + environmentLabel: environment.environmentLabel, + }); + } + return result; + })(); + const value = { activeNewTask, projectScopes, diff --git a/apps/mobile/src/reactCompilerCoverage.test.ts b/apps/mobile/src/reactCompilerCoverage.test.ts index 654ab883d5550..192b70edba47e 100644 --- a/apps/mobile/src/reactCompilerCoverage.test.ts +++ b/apps/mobile/src/reactCompilerCoverage.test.ts @@ -182,6 +182,14 @@ describe("React Compiler coverage", () => { ).toEqual([true]); }); + // The compiler once silently dropped these caches, so the new-task context + // value changed on every provider render and re-rendered every consumer. + it("caches the new-task model options and host environments", () => { + const file = "src/features/threads/new-task-flow-provider.tsx"; + expect(compilerCachedCalls(file, "buildModelOptions")).toEqual([true]); + expect(compilerCachedCalls(file, "repositoryGroupingKeyOf")).toEqual([true, true]); + }); + // An uncached registration would re-register the workspace inspector on every render. it("registers the workspace inspector only when its renderer changes", () => { expect( diff --git a/apps/mobile/src/state/queries.debounce.test.tsx b/apps/mobile/src/state/queries.debounce.test.tsx index 0a4c8ff828f3a..dcef7887012df 100644 --- a/apps/mobile/src/state/queries.debounce.test.tsx +++ b/apps/mobile/src/state/queries.debounce.test.tsx @@ -1,11 +1,16 @@ import { AuthFilesystemReadScope, EnvironmentId } from "@t3tools/contracts"; -import { act } from "react"; +import { RegistryContext } from "@effect/atom-react"; +import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { act, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ sessionAtom: {}, searchEntries: vi.fn(() => null), + listRefs: vi.fn(), + refresh: vi.fn(), })); vi.mock("./session", () => ({ @@ -21,9 +26,9 @@ vi.mock("./pull-requests", () => ({ composerPullRequests: {} })); vi.mock("./projects", () => ({ projectEnvironment: { searchEntries: mocks.searchEntries }, })); -vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); +vi.mock("./atom-registry", () => ({ appAtomRegistry: { refresh: mocks.refresh } })); vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} })); -vi.mock("./vcs", () => ({ vcsEnvironment: {} })); +vi.mock("./vcs", () => ({ vcsEnvironment: { listRefs: mocks.listRefs } })); vi.mock("./threads", () => ({ olderSettledThreads: {}, threadFamilies: {} })); vi.mock("./query", () => ({ useEnvironmentQuery: (atom: unknown) => ({ @@ -37,7 +42,11 @@ vi.mock("./query", () => ({ }), })); -import { useComposerPathSearch, type ComposerPathSearchTarget } from "./queries"; +import { + useComposerPathSearch, + usePaginatedBranches, + type ComposerPathSearchTarget, +} from "./queries"; function setup() { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -104,3 +113,91 @@ it.each([ input: { cwd: changed.cwd, query: changed.query, limit: 20 }, }); }); + +it("keeps compiled branch results stable and resets pagination for a new target or refresh", () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const registry = AtomRegistry.make(); + const pages = Atom.family((key: string) => { + const [, cwd, cursor] = JSON.parse(key) as [string, string, number | null]; + return Atom.make( + AsyncResult.success({ + refs: [ + { name: `${cwd}-${cursor ?? 0}`, current: false, isDefault: false, worktreePath: null }, + ], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: cursor === null ? 100 : null, + totalCount: 2, + }), + ); + }); + mocks.listRefs.mockImplementation( + ({ + environmentId, + input, + }: { + environmentId: EnvironmentId; + input: { cwd: string; cursor?: number }; + }) => pages(JSON.stringify([environmentId, input.cwd, input.cursor ?? null])), + ); + mocks.refresh.mockClear(); + const root = createRoot(document.createElement("div")); + using _cleanup = { + [Symbol.dispose]() { + act(() => root.unmount()); + registry.dispose(); + }, + }; + let current: ReturnType; + function Probe({ target }: { target: VcsRefTarget }) { + const result = usePaginatedBranches(target); + useLayoutEffect(() => { + current = result; + }); + return null; + } + const render = (cwd: string | null, options: Partial = {}) => + act(() => + root.render( + + + , + ), + ); + render("/repo"); + const first = current!; + render("/repo"); + expect(current!.data).toBe(first.data); + expect(current!.refs).toBe(first.refs); + expect(current!.refresh).toBe(first.refresh); + expect(current!.loadNext).toBe(first.loadNext); + act(() => + registry.set( + pages(JSON.stringify([target.environmentId, "/repo", null])), + AsyncResult.success({ + ...first.data!, + refs: [{ name: "/repo-0", current: true, isDefault: false, worktreePath: null }], + }), + ), + ); + expect(current!.refs[0]?.current).toBe(true); + act(() => current!.loadNext()); + expect(current!.refs.map((ref) => ref.name)).toEqual(["/repo-0", "/repo-100"]); + act(() => current!.refresh()); + expect(current!.refs.map((ref) => ref.name)).toEqual(["/repo-0"]); + expect(mocks.refresh).toHaveBeenCalledExactlyOnceWith( + pages(JSON.stringify([target.environmentId, "/repo", null])), + ); + act(() => current!.loadNext()); + render("/other"); + expect(current!.refs.map((ref) => ref.name)).toEqual(["/other-0"]); + act(() => current!.loadNext()); + render("/other", { query: "feature" }); + expect(current!.refs).toHaveLength(1); + act(() => current!.loadNext()); + render("/other", { environmentId: EnvironmentId.make("other-environment"), query: "feature" }); + expect(current!.refs).toHaveLength(1); + render(null); + expect(current!.data).toBeNull(); + expect(current!.refs).toEqual([]); +}); diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 75e60887546d3..8721b9ecfda88 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -31,7 +31,7 @@ import * as Cause from "effect/Cause"; import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "./atom-registry"; import { orchestrationEnvironment } from "./orchestration"; @@ -299,22 +299,29 @@ export function usePaginatedBranches(target: VcsRefTarget) { cursors: INITIAL_BRANCH_CURSORS, }); const cursors = pagination.targetKey === targetKey ? pagination.cursors : INITIAL_BRANCH_CURSORS; - const pageAtoms = - target.environmentId !== null && target.cwd !== null - ? cursors.map((cursor) => - vcsEnvironment.listRefs({ - environmentId: target.environmentId!, - input: { - cwd: target.cwd!, - ...(query.length > 0 ? { query } : {}), - ...(cursor === undefined ? {} : { cursor }), - limit: VCS_REF_LIST_LIMIT, - }, - }), - ) - : []; - const pagesAtom = Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( - Atom.withLabel(`mobile:vcs-ref-pages:${targetKey ?? "empty"}`), + const pageAtoms = useMemo( + () => + target.environmentId !== null && target.cwd !== null + ? cursors.map((cursor) => + vcsEnvironment.listRefs({ + environmentId: target.environmentId!, + input: { + cwd: target.cwd!, + ...(query.length > 0 ? { query } : {}), + ...(cursor === undefined ? {} : { cursor }), + limit: VCS_REF_LIST_LIMIT, + }, + }), + ) + : [], + [target.environmentId, target.cwd, query, cursors], + ); + const pagesAtom = useMemo( + () => + Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( + Atom.withLabel(`mobile:vcs-ref-pages:${targetKey ?? "empty"}`), + ), + [pageAtoms, targetKey], ); const results = useAtomValue(pagesAtom); const values = results.flatMap((result) => { From f47700849e6f8c146cd022d3bdc6f3a045c9e98e Mon Sep 17 00:00:00 2001 From: Alb11747 Date: Fri, 30 Oct 2026 16:37:58 -0400 Subject: [PATCH 030/219] fix(web): show the result of the notified delegated task (#22141) Co-authored-by: GPT-6.1 Sol at high effort Co-authored-by: maria-rcks --- .../chat/MessagesTimeline.browser.test.tsx | 128 +++++++++++++++++- .../components/chat/TimelineWorkEntryRow.tsx | 1 + .../src/components/chat/V2LifecycleRow.tsx | 9 +- 3 files changed, 132 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 24e15957192b1..212f3e253ce01 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -1,25 +1,30 @@ import { AudioPreview } from "../files/AudioPreview"; -import { onTestFinished } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; -import { stubGlobal } from "../../../test/render"; import { ComposerContextId, EnvironmentId, MessageId, + NodeId, + ProviderDriverKind, + ProviderInstanceId, RunAttemptId, RunId, RuntimeRequestId, TurnItemId, ScheduledTaskId, ThreadId, + type OrchestrationV2Subagent, } from "@t3tools/contracts"; import type { LegendListRef } from "@legendapp/list/react"; import { createRef, Profiler, StrictMode, type ComponentProps, type ReactElement } from "react"; -import { describe, expect, it, vi } from "vite-plus/test"; +import * as DateTime from "effect/DateTime"; +import { Atom } from "effect/reactivity"; +import { describe, expect, it, onTestFinished, vi } from "vite-plus/test"; import "~/index.css"; -import { act, fakeTimers, render } from "../../../test/render"; +import { environmentThreadDetails } from "../../state/threads"; +import { act, fakeTimers, render, stubGlobal } from "../../../test/render"; import { MessagesTimeline } from "./MessagesTimeline"; import { makeStreamingTimelineFixture } from "../../test-fixtures"; import { @@ -128,6 +133,43 @@ function message( }; } +function work(id: string, entry: Record): Entry { + return { + id, + kind: "work", + createdAt: CREATED_AT, + entry: { id, createdAt: CREATED_AT, ...entry }, + } as Entry; +} + +function v2Item(type: string, fields: Record) { + const id = (fields.id as string | undefined) ?? type; + return { + position: 0, + visibility: "local", + sourceThreadId: "thread-1", + sourceItemId: id, + item: { + id, + threadId: "thread-1", + runId: "run-1", + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "completed", + title: null, + startedAt: null, + completedAt: null, + updatedAt: {}, + type, + ...fields, + }, + } as never; +} + async function renderElement(element: ReactElement, options: { router?: false } = {}) { const container = document.createElement("div"); container.style.cssText = "height: 1200px; width: 900px; display: flex; flex-direction: column;"; @@ -165,6 +207,84 @@ function setup({ scheduledTasks = [] as ReadonlyArray<{ id: string; title: strin const TASK_ID = ScheduledTaskId.make("task-1"); const AUTOMATION_PROMPT = "Triage the inbox\n\nLabel every new issue and close duplicates."; +describe("MessagesTimeline work rows", () => { + it.each(["delegated", "legacy", "missing"] as const)( + "uses the reported task for %s completion notifications", + async (source) => { + setup(); + const now = DateTime.makeUnsafe(CREATED_AT); + const childThreadId = ThreadId.make("shared-child"); + const task = ( + id: string, + status: OrchestrationV2Subagent["status"], + result: string, + ): OrchestrationV2Subagent => ({ + id: NodeId.make(id), + threadId: ThreadId.make("thread-1"), + runId: null, + parentNodeId: NodeId.make("parent-root"), + origin: "app_owned", + createdBy: "agent", + driver: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: "Simplify delivery", + title: "Simplify delivery", + model: null, + status, + result, + startedAt: now, + completedAt: now, + updatedAt: now, + }); + const tasks = [ + task("original", "interrupted", "Old interrupted progress"), + task("continued", "completed", "Final implementation report"), + task("follow-up", "completed", "Follow-up explanation"), + ]; + using _threadAtom = vi.spyOn(environmentThreadDetails, "threadAtom").mockReturnValue( + Atom.make({ + projection: { subagents: tasks, turnItems: [], visibleTurnItems: [] }, + }) as never, + ); + const onOpenThread = vi.fn(); + await using screen = await renderTimeline( + (source === "delegated" ? ["continued", "follow-up"] : [source]).map((id) => + work(`notification-${id}`, { + label: "Notification fallback", + tone: "info", + itemType: "notification", + projectedItem: v2Item("notification", { + id: `notification-${id}`, + source: + source === "legacy" + ? { kind: "subagent", childThreadId } + : { kind: "delegated_task", childThreadId, taskIds: [NodeId.make(id)] }, + outcome: "completed", + summary: "Notification fallback", + }), + }), + ), + { onOpenThread }, + ); + if (source === "delegated") { + expect(screen.text()).toContain("Final implementation report"); + expect(screen.text()).toContain("Follow-up explanation"); + expect(screen.text()).not.toContain("Old interrupted progress"); + await click(screen.buttons("Open Simplify delivery")[0]!); + expect(onOpenThread).toHaveBeenCalledWith(childThreadId); + } else if (source === "legacy") { + expect(screen.text()).toContain("Old interrupted progress"); + } else { + expect(screen.text()).toContain("Notification fallback"); + expect(screen.text()).not.toContain("Old interrupted progress"); + } + }, + ); +}); + describe("MessagesTimeline messages", () => { it("settles when equally long messages have very different rendered heights", async () => { const props = baseProps(); diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index a8d5e3e68de77..f485f62c9c398 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -160,6 +160,7 @@ export const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: WorkEn | undefined; readonly outcome: OrchestrationV2Notification["outcome"]; readonly createdAt: string; readonly timestampFormat: TimestampFormat; readonly onOpenThread: (threadId: ThreadId) => void; readonly fallback: ReactNode; }) { + // Follow-up tasks share a child conversation; this notice reports a particular task result. const agent = useAtomValue( environmentThreadDetails.threadAtom(props.parentRef), (thread) => - thread?.projection.subagents.find((agent) => agent.childThreadId === props.childThreadId) ?? - null, + thread?.projection.subagents.find((agent) => + props.taskIds === undefined + ? agent.childThreadId === props.childThreadId + : props.taskIds.includes(agent.id), + ) ?? null, ); if (agent === null) return props.fallback; const resumed = From 5fd36391db1b4951855505cedf0e9bb3e619e845 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:38:14 -0500 Subject: [PATCH 031/219] fix(mobile): accept comma decimals in scheduled task intervals (#22158) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../settings/SettingsScheduledTasksRouteScreen.tsx | 3 ++- .../src/features/settings/scheduledTaskDraft.test.ts | 11 +++++++++++ .../src/features/settings/scheduledTaskDraft.ts | 6 +++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 583ab61cf7d2f..bb1faf28f0d5c 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -72,6 +72,7 @@ import { SettingsSection } from "./components/SettingsSection"; import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter"; import { editDraft, + parseIntervalMinutes, scheduledTaskDefaultModel, scheduleFromDraft, type ScheduledTaskDraft as Draft, @@ -1127,7 +1128,7 @@ function TaskForm({ setDraft({ ...draft, schedule: { ...draft.schedule, intervalMinutes } }) } /> - {Number(draft.schedule.intervalMinutes) < 1 ? ( + {!(parseIntervalMinutes(draft.schedule.intervalMinutes) >= 1) ? ( Intervals must be at least 1 minute. Update this interval before saving. diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts index 8cf8bb5b2b72d..7359403a67dce 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts @@ -89,6 +89,8 @@ describe("scheduleFromDraft", () => { it.each([ ["1.5", 90_000], + ["2,5", 150_000], + [" 2,5 ", 150_000], [String(60_001 / 60_000), 60_001], ])("preserves a valid %s minute interval when saving", (intervalMinutes, everyMs) => { expect(scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "interval", intervalMinutes })).toEqual({ @@ -96,6 +98,15 @@ describe("scheduleFromDraft", () => { everyMs, }); }); + + it.each(["0,5", "2,5,0", "Infinity", "150119987580", ""])( + "rejects invalid interval minutes: %s", + (intervalMinutes) => { + expect( + scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "interval", intervalMinutes }), + ).toBeNull(); + }, + ); }); describe("scheduled task model defaults", () => { diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index 3ef1a2f3faf6e..4ff15c4d9e45b 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -116,6 +116,10 @@ export function scheduleDraftForTask(task: Pick): Sch } } +export function parseIntervalMinutes(value: string): number { + return Number(value.trim().replace(",", ".")); +} + export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSchedule | null { if (draft.mode === "webhook") { const maxDeliveryAgeMinutes = parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes); @@ -163,7 +167,7 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche }; } if (draft.mode === "interval") { - const minutes = Number(draft.intervalMinutes); + const minutes = parseIntervalMinutes(draft.intervalMinutes); const everyMs = Math.round(minutes * 60_000); return minutes >= 1 && Number.isSafeInteger(everyMs) ? { type: "interval", everyMs } : null; } From bd9cc0a901cd28738854344277bb9ba40ffe0877 Mon Sep 17 00:00:00 2001 From: maco Date: Fri, 30 Oct 2026 21:38:29 +0100 Subject: [PATCH 032/219] fix(desktop): store Clerk tokens with owner-only permissions (#22190) Co-authored-by: macodev00 Co-authored-by: maria-rcks --- apps/desktop/src/app/ClerkTokenStore.test.ts | 189 +++++++++++++++++++ apps/desktop/src/app/ClerkTokenStore.ts | 117 ++++++++++++ apps/desktop/src/app/DesktopClerk.test.ts | 20 +- apps/desktop/src/app/DesktopClerk.ts | 12 +- 4 files changed, 331 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/app/ClerkTokenStore.test.ts create mode 100644 apps/desktop/src/app/ClerkTokenStore.ts diff --git a/apps/desktop/src/app/ClerkTokenStore.test.ts b/apps/desktop/src/app/ClerkTokenStore.test.ts new file mode 100644 index 0000000000000..b7ac0e38d5142 --- /dev/null +++ b/apps/desktop/src/app/ClerkTokenStore.test.ts @@ -0,0 +1,189 @@ +// @effect-diagnostics nodeBuiltinImport:off -- These tests assert real POSIX modes on a temporary directory. +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 { vi } from "vite-plus/test"; + +vi.mock("node:fs", { spy: true }); + +import { openClerkTokenStorage, type ClerkTokenStorage } from "./ClerkTokenStore.ts"; + +// oxlint-disable-next-line t3code/no-global-process-runtime -- Unix mode bits are the assertion; Windows reports them through ACLs. +const describePosix = describe.skipIf(process.platform === "win32"); + +const modeOf = (path: string): number => NodeFS.statSync(path).mode & 0o777; + +function writeLoose(path: string, contents: string): void { + NodeFS.writeFileSync(path, contents, { mode: 0o666 }); + NodeFS.chmodSync(path, 0o666); +} + +async function withStateDir(run: (stateDir: string) => Promise | void): Promise { + const stateDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-clerk-tokens-")); + try { + await run(stateDir); + } finally { + NodeFS.rmSync(stateDir, { recursive: true, force: true }); + } +} + +const idleStorage = (): ClerkTokenStorage => ({ + getItem: async () => null, + setItem: async () => undefined, + removeItem: () => undefined, +}); + +describePosix("ClerkTokenStore", () => { + it("keeps clerk tokens at 0600 inside a 0700 directory", async () => { + await withStateDir(async (stateDir) => { + NodeFS.chmodSync(stateDir, 0o755); + const directory = NodePath.join(stateDir, "clerk"); + const legacyPath = NodePath.join(stateDir, "clerk-tokens.json"); + const tokenPath = NodePath.join(directory, "clerk-tokens.json"); + NodeFS.mkdirSync(directory, { mode: 0o755 }); + NodeFS.chmodSync(directory, 0o755); + writeLoose(legacyPath, '{"__clerk_client_jwt":"enc:existing"}\n'); + + const opened: string[] = []; + const storage = openClerkTokenStorage(stateDir, (cwd) => { + opened.push(cwd); + return { + getItem: async () => { + writeLoose(tokenPath, "from-get"); + return "from-get"; + }, + setItem: (_key, value) => { + writeLoose(tokenPath, value); + }, + removeItem: () => { + NodeFS.rmSync(tokenPath, { force: true }); + }, + }; + }); + + assert.deepEqual(opened, [directory]); + assert.equal(modeOf(stateDir), 0o755); + assert.equal(modeOf(directory), 0o700); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.existsSync(legacyPath), false); + assert.equal( + NodeFS.readFileSync(tokenPath, "utf8"), + '{"__clerk_client_jwt":"enc:existing"}\n', + ); + + assert.equal(await storage.getItem("client"), "from-get"); + assert.equal(modeOf(tokenPath), 0o600); + await storage.setItem("client", "from-set"); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "from-set"); + storage.removeItem("client"); + assert.equal(NodeFS.existsSync(tokenPath), false); + + writeLoose(tokenPath, "kept"); + writeLoose(legacyPath, "stale"); + NodeFS.chmodSync(directory, 0o755); + openClerkTokenStorage(stateDir, () => idleStorage()); + assert.equal(modeOf(directory), 0o700); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.existsSync(legacyPath), false); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "kept"); + }); + }); + + it("removes the legacy token file when the private copy already exists", async () => { + await withStateDir((stateDir) => { + const directory = NodePath.join(stateDir, "clerk"); + const legacyPath = NodePath.join(stateDir, "clerk-tokens.json"); + const tokenPath = NodePath.join(directory, "clerk-tokens.json"); + NodeFS.mkdirSync(directory, { mode: 0o755 }); + writeLoose(legacyPath, "stale-credential"); + writeLoose(tokenPath, "kept-credential"); + + openClerkTokenStorage(stateDir, () => idleStorage()); + + assert.equal(NodeFS.existsSync(legacyPath), false); + assert.equal(modeOf(directory), 0o700); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "kept-credential"); + }); + }); + + it("resolves a relative state directory before opening storage", async () => { + await withStateDir(async (stateDir) => { + const relativeStateDir = NodePath.relative(process.cwd(), stateDir); + const opened: string[] = []; + openClerkTokenStorage(relativeStateDir, (directory) => { + opened.push(directory); + return idleStorage(); + }); + + const directory = NodePath.join(stateDir, "clerk"); + assert.equal(NodePath.isAbsolute(relativeStateDir), false); + assert.deepEqual(opened, [directory]); + assert.equal(modeOf(directory), 0o700); + }); + }); + + it("migrates relative state paths from Electron userData, not the working directory", async () => { + await withStateDir((stateDir) => { + const relativeStateDir = NodePath.relative(process.cwd(), stateDir); + const userData = NodePath.join( + stateDir, + "electron", + ...relativeStateDir.split(NodePath.sep).map(() => "profile"), + ); + const legacyStateDir = NodePath.join(userData, relativeStateDir); + const legacyPath = NodePath.join(legacyStateDir, "clerk-tokens.json"); + NodeFS.mkdirSync(legacyStateDir, { recursive: true }); + writeLoose(legacyPath, "existing-session"); + const unrelatedPath = NodePath.join(stateDir, "clerk-tokens.json"); + writeLoose(unrelatedPath, "not-the-electron-session"); + + openClerkTokenStorage(relativeStateDir, idleStorage, userData); + + const tokenPath = NodePath.join(stateDir, "clerk", "clerk-tokens.json"); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "existing-session"); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.existsSync(legacyPath), false); + assert.equal(NodeFS.readFileSync(unrelatedPath, "utf8"), "not-the-electron-session"); + }); + }); + + it("migrates tokens when the legacy directory is on another filesystem", async () => { + await withStateDir((stateDir) => { + const legacyPath = NodePath.join(stateDir, "clerk-tokens.json"); + writeLoose(legacyPath, "existing-session"); + using _rename = vi.spyOn(NodeFS, "renameSync").mockImplementation(() => { + throw Object.assign(new Error("cross-device link"), { code: "EXDEV" }); + }); + + openClerkTokenStorage(stateDir, idleStorage); + + const tokenPath = NodePath.join(stateDir, "clerk", "clerk-tokens.json"); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "existing-session"); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.existsSync(legacyPath), false); + }); + }); + + it("does not follow a symlinked clerk directory or token file", async () => { + await withStateDir(async (stateDir) => { + const outsideDirectory = NodePath.join(stateDir, "outside-dir"); + NodeFS.mkdirSync(outsideDirectory, { mode: 0o755 }); + NodeFS.chmodSync(outsideDirectory, 0o755); + NodeFS.symlinkSync(outsideDirectory, NodePath.join(stateDir, "clerk")); + + assert.throws(() => openClerkTokenStorage(stateDir, idleStorage), /must be a real directory/); + assert.equal(modeOf(outsideDirectory), 0o755); + NodeFS.unlinkSync(NodePath.join(stateDir, "clerk")); + + const outsideFile = NodePath.join(stateDir, "outside.json"); + NodeFS.writeFileSync(outsideFile, "secret", { mode: 0o644 }); + NodeFS.chmodSync(outsideFile, 0o644); + NodeFS.symlinkSync(outsideFile, NodePath.join(stateDir, "clerk-tokens.json")); + assert.throws(() => openClerkTokenStorage(stateDir, idleStorage), /must be a regular file/); + assert.equal(modeOf(outsideFile), 0o644); + }); + }); +}); diff --git a/apps/desktop/src/app/ClerkTokenStore.ts b/apps/desktop/src/app/ClerkTokenStore.ts new file mode 100644 index 0000000000000..005736c97cb38 --- /dev/null +++ b/apps/desktop/src/app/ClerkTokenStore.ts @@ -0,0 +1,117 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The Clerk bridge must lock the token file down before startup can yield to Electron's ready event. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +// electron-store chmods every write to 0666; the private directory protects tokens during writes. +const CLERK_TOKEN_DIRECTORY_MODE = 0o700; +const CLERK_TOKEN_FILE_MODE = 0o600; +const CLERK_TOKEN_DIRECTORY_NAME = "clerk"; +const CLERK_TOKEN_FILE_NAME = "clerk-tokens.json"; + +type Awaitable = T | Promise; + +export interface ClerkTokenStorage { + readonly getItem: (key: string) => Awaitable; + readonly setItem: (key: string, value: string) => Awaitable; + readonly removeItem: (key: string) => Awaitable; +} + +function isErrno(error: unknown, code: string): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === code; +} + +function lstatIfExists(path: string): NodeFS.Stats | undefined { + try { + return NodeFS.lstatSync(path); + } catch (error) { + if (isErrno(error, "ENOENT")) return undefined; + throw error; + } +} + +function assertRegularFile(path: string, stat: NodeFS.Stats): void { + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error(`Clerk token file "${path}" must be a regular file.`); + } +} + +function restrictClerkTokenFile(path: string): void { + const stat = lstatIfExists(path); + if (stat === undefined) return; + assertRegularFile(path, stat); + if ((stat.mode & 0o777) === CLERK_TOKEN_FILE_MODE) return; + NodeFS.chmodSync(path, CLERK_TOKEN_FILE_MODE); +} + +function migrateLegacyClerkTokenFile(stateDir: string, directory: string): void { + const legacyPath = NodePath.join(stateDir, CLERK_TOKEN_FILE_NAME); + const tokenPath = NodePath.join(directory, CLERK_TOKEN_FILE_NAME); + const legacy = lstatIfExists(legacyPath); + if (legacy !== undefined) { + assertRegularFile(legacyPath, legacy); + NodeFS.chmodSync(legacyPath, CLERK_TOKEN_FILE_MODE); + } + + const destination = lstatIfExists(tokenPath); + if (destination === undefined) { + if (legacy === undefined) return; + try { + NodeFS.copyFileSync(legacyPath, tokenPath, NodeFS.constants.COPYFILE_EXCL); + NodeFS.chmodSync(tokenPath, CLERK_TOKEN_FILE_MODE); + NodeFS.unlinkSync(legacyPath); + } catch (error) { + if (!isErrno(error, "ENOENT")) throw error; + } + return; + } + + assertRegularFile(tokenPath, destination); + NodeFS.chmodSync(tokenPath, CLERK_TOKEN_FILE_MODE); + if (legacy === undefined) return; + try { + NodeFS.unlinkSync(legacyPath); + } catch (error) { + if (!isErrno(error, "ENOENT")) throw error; + } +} + +function restrictAfter(run: () => Awaitable, tighten: () => void): Awaitable { + let result: Awaitable; + try { + result = run(); + } catch (error) { + tighten(); + throw error; + } + if (result instanceof Promise) return result.finally(tighten); + tighten(); + return result; +} + +/** Opens Clerk token storage in a private directory and keeps the file at `0600`. */ +export function openClerkTokenStorage( + stateDir: string, + createStorage: (directory: string) => ClerkTokenStorage, + userDataPath = ".", +): ClerkTokenStorage { + const absoluteStateDir = NodePath.resolve(stateDir); + const directory = NodePath.join(absoluteStateDir, CLERK_TOKEN_DIRECTORY_NAME); + NodeFS.mkdirSync(directory, { recursive: true, mode: CLERK_TOKEN_DIRECTORY_MODE }); + const stat = NodeFS.lstatSync(directory); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new Error(`Clerk token directory "${directory}" must be a real directory.`); + } + NodeFS.chmodSync(directory, CLERK_TOKEN_DIRECTORY_MODE); + // electron-store rebases relative paths under Electron userData, not the working directory. + migrateLegacyClerkTokenFile(NodePath.resolve(userDataPath, stateDir), directory); + const tokenPath = NodePath.join(directory, CLERK_TOKEN_FILE_NAME); + restrictClerkTokenFile(tokenPath); + + const storage = createStorage(directory); + const tighten = () => restrictClerkTokenFile(tokenPath); + return { + getItem: (key) => restrictAfter(() => storage.getItem(key), tighten), + setItem: (key, value) => restrictAfter(() => storage.setItem(key, value), tighten), + removeItem: (key) => restrictAfter(() => storage.removeItem(key), tighten), + }; +} diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 34b7286c335ed..8a823cde8db79 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -1,13 +1,21 @@ // @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Hosted handoff test uses a real localhost listener without an OpenAI account. +import * as NodeFS from "node:fs"; import * as NodeHttp from "node:http"; -import * as NodePath from "@effect/platform-node/NodePath"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodePathLayer from "@effect/platform-node/NodePath"; import { codexAuthHandoffUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff"; import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { beforeEach, vi } from "vite-plus/test"; +import { afterAll, beforeEach, vi } from "vite-plus/test"; + +const clerkStateDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-desktop-clerk-")); +afterAll(() => { + NodeFS.rmSync(clerkStateDir, { recursive: true, force: true }); +}); const { createClerkBridgeMock, requestSingleInstanceLockMock, storageAdapter, storageMock } = vi.hoisted(() => ({ @@ -58,7 +66,7 @@ const layerDesktopClerk = ( listeners = new Map void>(), ) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ - stateDir: "/tmp/t3-state", + stateDir: clerkStateDir, isDevelopment, appDataDirectory: "/tmp/app-data", platform, @@ -92,7 +100,7 @@ const layerDesktopClerk = ( return DesktopClerk.layer.pipe( Layer.provide( Layer.mergeAll( - NodePath.layerPosix, + NodePathLayer.layerPosix, Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), Layer.succeed(ElectronApp.ElectronApp, electronApp), Layer.succeed(ElectronShell.ElectronShell, shell), @@ -159,6 +167,10 @@ describe("DesktopClerk", () => { "on:open-url", "createClerkBridge", ]); + assert.deepEqual(storageMock.mock.calls, [ + [{ path: NodePath.join(NodePath.resolve(clerkStateDir), "clerk") }], + ]); + assert.notStrictEqual(createClerkBridgeMock.mock.calls[0]?.[0]?.storage, storageAdapter); }, ); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 992e1fe056937..ef9d2aad56795 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -21,6 +21,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopUserData from "./DesktopUserData.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { openClerkTokenStorage } from "./ClerkTokenStore.ts"; import * as DesktopWebLinks from "./DesktopWebLinks.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; @@ -84,9 +85,13 @@ export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHos : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, ); -function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { +function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean, userDataPath: string) { return createClerkBridge({ - storage: storage({ path: stateDir }), + storage: openClerkTokenStorage( + stateDir, + (directory) => storage({ path: directory }), + userDataPath, + ), manageSingleInstanceLock: false, passkeys: true, renderer: { @@ -131,7 +136,8 @@ export const make = Effect.gen(function* () { yield* Effect.acquireRelease( Effect.try({ - try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), + try: () => + createDesktopClerkBridge(environment.stateDir, environment.isDevelopment, userDataPath), catch: (cause) => new DesktopClerkBridgeInitializationError({ stateDir: environment.stateDir, From f5372d27da5100dabde38e95992f647813d5f9bd Mon Sep 17 00:00:00 2001 From: Arham Mahajan <89351952+arhammahajan@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:08:45 +0530 Subject: [PATCH 033/219] fix(antigravity): reject unsupported manual compaction (#22217) Co-authored-by: maria-rcks --- .../Adapters/AntigravityAdapterV2.test.ts | 43 +++++++++++++++++++ .../Adapters/AntigravityAdapterV2.ts | 3 +- docs/user/providers-antigravity.md | 3 ++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 0f5670f1d2de0..75d6c78bba0f0 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -128,6 +128,49 @@ const layerSession = Layer.mergeAll( TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), ); +describe("AntigravityAdapterV2 compaction", () => { + it.effect("does not expose unsupported manual compaction on the provider session", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const instanceId = ProviderInstanceId.make("antigravity-compaction-test"); + const adapter = yield* makeAntigravityAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + makeRuntime: (input) => + makeAntigravityAcpRuntime({ + ...input, + childProcessSpawner, + spawn: { + command: process.execPath, + args: [mockAgentPath], + cwd: input.cwd, + env: { T3_ACP_ANTIGRAVITY: "1" }, + }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)), + withProcess: (_stop, task) => task, + // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. + defaultModel: Effect.succeed(undefined), + }); + const session = yield* adapter.openSession({ + threadId: ThreadId.make("thread-antigravity-compaction"), + providerSessionId: ProviderSessionId.make("provider-session-antigravity-compaction"), + modelSelection: { instanceId, model: "gemini-test-low" }, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }), + }); + assert.isUndefined(session.compactThread); + }).pipe(Effect.provide(layerSession), Effect.scoped), + ); +}); + describe("AntigravityAdapterV2 session open", () => { it.effect.each([ { diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index 9ccaca83b542f..bc842d2ec9724 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -238,7 +238,8 @@ export function makeAntigravityAcpAdapterFlavor( normalizeToolCall: normalizeAntigravityToolCall, extractSubagentUpdate: extractAntigravitySubagentUpdate, supportsImagePrompts: true, - supportsCompaction: true, + // ACP 1.3.0 treats /compact as ordinary model input. + supportsCompaction: false, requiresSignIn: isAntigravitySignInRequiredError, }; } diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md index 76bbc8266a941..268a32ec8dbe8 100644 --- a/docs/user/providers-antigravity.md +++ b/docs/user/providers-antigravity.md @@ -87,6 +87,9 @@ T3 Code keeps conversation history and file diffs, but Antigravity cannot rewind its conversation. Reverting a thread or editing and resubmitting an earlier turn is unavailable. Continue with a follow-up message or start a new thread. +Manual context compaction is unavailable through Antigravity's ACP runtime, even +though its CLI has `/compact`. Start a new thread when you need a fresh context. + ### Skills and attachments Put project skills in `.agents/skills`. T3 Code also reads `.gemini/skills` and the From b5a5ab4ea5cab1d27cfa3d7351ae56e4734b235c Mon Sep 17 00:00:00 2001 From: matthias-scale Date: Fri, 30 Oct 2026 21:39:01 +0100 Subject: [PATCH 034/219] fix(github): read github.com- SSH aliases through github.com (#22246) Co-authored-by: matthias schedel Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: maria-rcks --- .../GitHubSourceControlProvider.test.ts | 96 +++++++++ .../src/server/GitHubSourceControlProvider.ts | 3 + .../server/gitHubRepositoryResolution.test.ts | 191 +++++++++++++++++- .../src/server/gitHubRepositoryResolution.ts | 42 +++- 4 files changed, 322 insertions(+), 10 deletions(-) diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index 0fb270156ceaa..e14b495a4ef3e 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -288,6 +288,102 @@ describe("GitHubSourceControlProvider repository resolution", () => { assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); }).pipe(Effect.provide(layer)); }); + + it.effect.each([ + ["github", "git@github:acme/web.git"], + ["github.com-scale", "git@github.com-scale:acme/web.git"], + ["github.com-scale", "ssh://git@github.com-scale:22/acme/web.git"], + ] as const)("reads an SSH alias on %s through github.com (%s)", ([host, remoteUrl]) => { + const hosts: string[] = []; + const { layer } = harness({ + remotes: remotesOutput(["origin", remoteUrl]), + api: { + rest: (input) => + Effect.sync(() => { + hosts.push(`${input.host} ${input.path}`); + return restResponse({ + full_name: "acme/web", + html_url: "https://github.com/acme/web", + ssh_url: "git@github.com:acme/web.git", + default_branch: "main", + }); + }), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const resolveWebHost = gh.resolveRemoteWebHost; + assert.ok(resolveWebHost); + assert.strictEqual( + yield* resolveWebHost({ + cwd: "/repo", + context: { ...githubContext(host), remoteUrl }, + resolveContext: () => Effect.succeed(null), + }), + "github.com", + ); + yield* gh.getDefaultBranch({ cwd: "/repo" }); + yield* gh.getDefaultBranch({ + cwd: "/repo", + context: { ...githubContext(host), remoteUrl }, + }); + assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect.each(["sole", "upstream", "missing"])( + "preserves an HTTPS context host with %s remotes", + (mode) => { + const paths: string[] = []; + const remoteUrl = "https://github.com-scale/me/web.git"; + const { layer } = harness({ + remotes: + mode === "missing" + ? "" + : remotesOutput( + ["origin", remoteUrl], + ...(mode === "upstream" + ? ([["upstream", "https://github.com/acme/web.git"]] as const) + : []), + ), + api: { + rest: (input) => + Effect.sync(() => { + paths.push(`${input.host} ${input.path}`); + return restResponse({ + full_name: "me/web", + html_url: "https://github.com-scale/me/web", + ssh_url: "git@github.com-scale:me/web.git", + default_branch: "trunk", + }); + }), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const resolveWebHost = gh.resolveRemoteWebHost; + assert.ok(resolveWebHost); + for (const url of [remoteUrl, "git@github.com-evil.example:me/web.git"]) { + assert.strictEqual( + yield* resolveWebHost({ + cwd: "/repo", + context: { ...githubContext("github.com-scale"), remoteUrl: url }, + resolveContext: () => Effect.succeed(null), + }), + url === remoteUrl ? "github.com-scale" : "github.com-evil.example", + ); + } + assert.strictEqual( + yield* gh.getDefaultBranch({ + cwd: "/repo", + context: { ...githubContext("github.com-scale"), remoteUrl }, + }), + "trunk", + ); + assert.deepStrictEqual(paths, ["github.com-scale repos/me/web"]); + }).pipe(Effect.provide(layer)); + }, + ); }); describe("GitHubSourceControlProvider change request lookups by head", () => { diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index bfc5fc29041ea..4a42e9422a5de 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -859,6 +859,9 @@ export const make = Effect.gen(function* () { return SourceControlProvider.SourceControlProvider.of({ kind: SourceControlProviderKind.make("github"), + resolveRemoteWebHost: Effect.fn("GitHubSourceControlProvider.resolveRemoteWebHost")( + ({ context }) => Effect.succeed(gitHubApiHostForRemote(context.remoteUrl)), + ), // GitHub matches bare heads; read a full page to disambiguate forks by owner. headBranchProbe: ({ headSelectors }) => ({ headSelectors: headSelectors.filter((selector) => !selector.includes(":")), diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts index 9269116ea3c72..3447c3a1be2ab 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import { gitHubApiHostForRemote, + resolveGitHubRepository, parseGitHubRepositorySelector, selectGitHubBaseRepository, } from "./gitHubRepositoryResolution.ts"; @@ -48,6 +49,64 @@ describe("selectGitHubBaseRepository", () => { ); }); + it("honors a marked SSH alias origin over upstream", () => { + for (const url of [ + "git@github.com-scale:me/web.git", + "ssh://git@github.com-scale:22/me/web.git", + "git@github-work:me/web.git", + ]) { + const input = { + remotes: remotes(["origin", url], ["upstream", "https://github.com/acme/web.git"]), + resolved: "remote.origin.gh-resolved base", + }; + assert.deepStrictEqual(select(input), { owner: "me", name: "web" }, url); + assert.deepStrictEqual( + resolveGitHubRepository({ ...input, hostHint: undefined, defaultHost: "github.com" }), + { host: "github.com", locator: { host: "github.com", owner: "me", name: "web" } }, + url, + ); + } + }); + + it("does not honor a github.com mark on a different or unrecognized host", () => { + for (const url of [ + "git@github.com-evil.example:me/web.git", + "git@github.com.evil.example:me/web.git", + "git@evil-github.com:me/web.git", + "https://github.com-scale/me/web.git", + "git@work:me/web.git", + ]) { + const input = { + remotes: remotes(["origin", url], ["upstream", "https://github.com/acme/web.git"]), + resolved: "remote.origin.gh-resolved base", + }; + assert.strictEqual(select(input), null, url); + assert.deepStrictEqual( + resolveGitHubRepository({ ...input, hostHint: "github.com", defaultHost: "github.com" }), + { host: "github.com", locator: { host: "github.com", owner: "acme", name: "web" } }, + url, + ); + } + }); + + it("preserves selection behavior for real hosts with explicit ports", () => { + const input = { + remotes: remotes(["origin", "https://github.example.com:8443/me/web.git"]), + resolved: "remote.origin.gh-resolved acme/other", + }; + assert.strictEqual( + selectGitHubBaseRepository({ ...input, host: "github.example.com:8443" }), + null, + ); + assert.deepStrictEqual( + resolveGitHubRepository({ ...input, hostHint: undefined, defaultHost: "github.com" }), + { + host: "github.example.com:8443", + locator: { host: "github.example.com:8443", owner: "me", name: "web" }, + }, + ); + }); + it("leaves gh to choose when it might weigh the remotes differently", () => { for (const input of [ { remotes: "" }, @@ -60,7 +119,7 @@ describe("selectGitHubBaseRepository", () => { ["mirror", "git@gitlab.com:me/web.git"], ), }, - { remotes: remotes(["origin", "git@github-work:me/web.git"]) }, + { remotes: remotes(["origin", "git@work:me/web.git"]) }, { remotes: remotes( ["origin", "git@github.com:me/web.git"], @@ -111,6 +170,136 @@ describe("gitHubApiHostForRemote", () => { }); }); +describe("github.com- SSH aliases", () => { + it("reads a per-account alias through github.com", () => { + for (const url of [ + "git@github.com-scale:scalable-so/scalablev2.git", + "git@GitHub.com-Work:acme/web.git", + "ssh://git@github.com-personal/acme/web.git", + "ssh://git@github.com-scale:22/acme/web.git", + ]) { + assert.strictEqual(gitHubApiHostForRemote(url), "github.com", url); + } + }); + + it("never maps a hostile or real DNS host onto github.com", () => { + for (const [url, host] of [ + ["git@github.com-evil.example:acme/web.git", "github.com-evil.example"], + ["ssh://git@github.com-evil.example/acme/web.git", "github.com-evil.example"], + ["git@github.com.evil.example:acme/web.git", "github.com.evil.example"], + ["git@evil-github.com:acme/web.git", null], + ["https://github.com-scale/acme/web.git", "github.com-scale"], + ] as const) { + assert.strictEqual(gitHubApiHostForRemote(url), host, url); + } + }); + + it("resolves the API host and repository from an alias remote and its host hint", () => { + const remotes = [ + "origin\tgit@github.com-scale:scalable-so/scalablev2.git (fetch)", + "origin\tgit@github.com-scale:scalable-so/scalablev2.git (push)", + ].join("\n"); + const expected = { + host: "github.com", + locator: { host: "github.com", owner: "scalable-so", name: "scalablev2" }, + }; + for (const hostHint of ["github.com-scale", undefined]) { + assert.deepStrictEqual( + resolveGitHubRepository({ remotes, resolved: "", hostHint, defaultHost: "github.com" }), + expected, + ); + } + }); + + it.each([false, true])("preserves an HTTPS alias-shaped host hint (upstream: %s)", (upstream) => { + const remotes = [ + "origin\thttps://github.com-scale/me/web.git (fetch)", + ...(upstream ? ["upstream\thttps://github.com/acme/web.git (fetch)"] : []), + ].join("\n"); + assert.deepStrictEqual( + resolveGitHubRepository({ + remotes, + resolved: "", + hostHint: "github.com-scale", + defaultHost: "github.com", + }), + { + host: "github.com-scale", + locator: { host: "github.com-scale", owner: "me", name: "web" }, + }, + ); + }); + + it("requires matching SSH evidence to normalize a dotted hint", () => { + for (const remotes of [ + "", + "origin\tgit@github.com-work:me/web.git (fetch)", + "origin\tgit@github.com-scale:me/web.git (push)", + ]) { + assert.deepStrictEqual( + resolveGitHubRepository({ + remotes, + resolved: "", + hostHint: "GitHub.com-Scale", + defaultHost: "github.com", + }), + { host: "github.com-scale", locator: null }, + ); + } + }); + + it("preserves a dotted hint when matching remotes use both transports", () => { + const entries = [ + "origin\thttps://github.com-scale/me/web.git (fetch)", + "upstream\tgit@github.com-scale:acme/web.git (fetch)", + ]; + for (const [remotes, hostHint, resolved] of [ + [entries.join("\n"), "github.com-scale", "remote.origin.gh-resolved base"], + [entries.join("\n"), "github.com-scale", ""], + [entries.toReversed().join("\n"), undefined, "remote.origin.gh-resolved base"], + [entries.toReversed().join("\n"), undefined, ""], + ] as const) { + assert.deepStrictEqual( + resolveGitHubRepository({ + remotes, + resolved, + hostHint, + defaultHost: "github.com", + }), + { + host: "github.com-scale", + locator: { host: "github.com-scale", owner: "me", name: "web" }, + }, + ); + } + }); + + it("retains dotless hint compatibility without remote evidence", () => { + for (const hostHint of ["github", "github-work", "workgithub"]) { + assert.deepStrictEqual( + resolveGitHubRepository({ remotes: "", resolved: "", hostHint, defaultHost: hostHint }), + { host: "github.com", locator: null }, + ); + } + }); + + it("keeps a hostile host hint and its remote off github.com", () => { + const remotes = "origin\tgit@github.com-evil.example:acme/web.git (fetch)"; + assert.deepStrictEqual( + resolveGitHubRepository({ + remotes, + resolved: "", + hostHint: "github.com-evil.example", + defaultHost: "github.com-evil.example", + }), + { + host: "github.com-evil.example", + locator: { host: "github.com-evil.example", owner: "acme", name: "web" }, + }, + ); + }); +}); + describe("parseGitHubRepositorySelector", () => { it("reads host/owner/name only when the host is a plain hostname", () => { assert.deepStrictEqual(parseGitHubRepositorySelector("GHE.corp:8443/acme/web", "github.com"), { diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts index 3376412f0c3b1..e42e428d9d997 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts @@ -21,7 +21,7 @@ export function parseFetchRemotes( .map((match) => ({ name: match[1]!, url: match[2]! })); } -/** gh priority: marked default, upstream/github/origin, sole remote. Null if ambiguous or SSH-aliased. */ +/** gh priority: marked default, upstream/github/origin, sole remote. Null if ambiguous. */ export function selectGitHubBaseRepository(input: { readonly remotes: string; readonly resolved: string; @@ -30,7 +30,11 @@ export function selectGitHubBaseRepository(input: { const host = input.host.toLowerCase(); const repositories = new Map(); for (const remote of parseFetchRemotes(input.remotes)) { - const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/"); + const [normalizedHost, owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/"); + const remoteHost = + normalizedHost && isSshRemoteUrl(remote.url) + ? (apiHostForSshAlias(normalizedHost) ?? normalizedHost) + : normalizedHost; if (remoteHost !== host || !owner || !name || rest.length > 0) return null; repositories.set(remote.name, { owner, name }); } @@ -69,7 +73,7 @@ export function resolveGitHubRepository(input: { host: gitHubApiHostForRemote(remote.url), })); const host = - (input.hostHint === undefined ? undefined : apiHostForHint(input.hostHint)) ?? + (input.hostHint === undefined ? undefined : apiHostForHint(input.hostHint, fetchRemotes)) ?? fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? fetchRemotes.find((remote) => remote.host !== null)?.host ?? input.defaultHost; @@ -83,7 +87,9 @@ export function resolveGitHubRepository(input: { const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); const candidates = fetchRemotes .filter( - (remote) => remote.host === host || normalizeGitRemoteUrl(remote.url).split("/")[0] === host, + (remote) => + remote.host === host || + (remote.host === null && normalizeGitRemoteUrl(remote.url).split("/")[0] === host), ) .toSorted((left, right) => { const l = rank(left.name); @@ -148,18 +154,36 @@ export function parsePullRequestReference( return { kind: "branch", headSelector: trimmed }; } -/** SSH aliases without a domain cannot identify an API host; default to github.com. */ +// An additional dot is a real DNS host, never a github.com SSH alias. +const GITHUB_DOT_COM_SSH_ALIAS = /^github\.com-[a-z0-9_-]+$/u; + +function apiHostForSshAlias(host: string): string | null | undefined { + if (!host.includes(".")) return host.includes("github") ? "github.com" : null; + return GITHUB_DOT_COM_SSH_ALIAS.test(host) ? "github.com" : undefined; +} + +/** Recognized GitHub SSH aliases use github.com for API requests. */ export function gitHubApiHostForRemote(remoteUrl: string): string | null { const remote = parseGitRemoteHost(remoteUrl); if (remote === null) return null; const host = new URL(`https://${remote.host}`).host.toLowerCase(); - if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { - return host.includes("github") ? "github.com" : null; + if (isSshRemoteUrl(remoteUrl)) { + const aliased = apiHostForSshAlias(host); + if (aliased !== undefined) return aliased; } return definition.recognizeRemote(remote) === null ? null : host; } -function apiHostForHint(host: string): string { +function apiHostForHint(host: string, remotes: ReadonlyArray<{ readonly url: string }>): string { const normalized = host.toLowerCase(); - return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; + if (GITHUB_DOT_COM_SSH_ALIAS.test(normalized)) { + const matching = remotes.filter( + (remote) => normalizeGitRemoteUrl(remote.url).split("/")[0] === normalized, + ); + // A hint carries no transport; an HTTPS remote or missing evidence must not select + // the github.com endpoint and credentials for a dotted host. + if (matching.length === 0 || matching.some((remote) => !isSshRemoteUrl(remote.url))) + return normalized; + } + return apiHostForSshAlias(normalized) ?? normalized; } From 5e222be59ad0b657650c7286c189a9624bdda392 Mon Sep 17 00:00:00 2001 From: Patrick Schaub Date: Fri, 30 Oct 2026 21:39:17 +0100 Subject: [PATCH 035/219] fix(server): stop reporting Codex HTTP failures as disk errors (#22308) Co-authored-by: maria-rcks --- .../src/provider/CodexInstallation.test.ts | 86 ++++++++++++++++++- apps/server/src/provider/CodexInstallation.ts | 22 +++++ 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/CodexInstallation.test.ts b/apps/server/src/provider/CodexInstallation.test.ts index 849b3c928af8b..e0683199ffd5f 100644 --- a/apps/server/src/provider/CodexInstallation.test.ts +++ b/apps/server/src/provider/CodexInstallation.test.ts @@ -13,7 +13,8 @@ import * as Exit from "effect/Exit"; import * as Queue from "effect/Queue"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import { HttpClient, HttpClientResponse } from "effect/http"; +import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as CodexInstallation from "./CodexInstallation.ts"; @@ -31,7 +32,8 @@ const makeHarness = Effect.fn("test.makeCodexInstallation")(function* ( input: { options?: Partial; manifestCurrent?: Effect.Effect; - body?: Stream.Stream; + body?: Stream.Stream; + downloadStatus?: number; baseDir?: string; local?: { version: string; appServerFails?: boolean; versionFails?: boolean }; platform?: NodeJS.Platform; @@ -106,7 +108,10 @@ const makeHarness = Effect.fn("test.makeCodexInstallation")(function* ( Effect.sync(() => { downloads++; return Object.defineProperty( - HttpClientResponse.fromWeb(request, new Response(null)), + HttpClientResponse.fromWeb( + request, + new Response(null, { status: input.downloadStatus ?? 200 }), + ), "stream", { value: input.body ?? Stream.succeed(archive), @@ -366,6 +371,81 @@ it.effect("rejects corrupted downloads without publishing a runtime", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect.each(["interrupted body", "HTTP 404"] as const)( + "reports a download failure for %s without blaming disk access", + (failure) => + Effect.gen(function* () { + const request = HttpClientRequest.get(asset.url); + const response = HttpClientResponse.fromWeb(request, new Response(null)); + const h = yield* makeHarness( + failure === "HTTP 404" + ? { downloadStatus: 404 } + : { + body: Stream.concat( + Stream.succeed(archive.subarray(0, 10)), + Stream.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.DecodeError({ + request, + response, + cause: new TypeError("terminated"), + }), + }), + ), + ), + }, + ); + yield* h.installation.start; + expect(yield* terminalState(h.installation)).toMatchObject({ + phase: "failed", + message: + failure === "HTTP 404" + ? "The Codex download server returned HTTP 404. Try again later." + : "The Codex download was interrupted. Check your internet connection, then retry installation.", + }); + expect(h.downloads()).toBe(1); + expect(yield* h.fs.readDirectory(h.installation.managedDirectory)).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect( + "preserves the filesystem failure message when installation cannot create its directory", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-install-test-" }); + const baseDir = `${directory}/not-a-directory`; + yield* fs.writeFileString(baseDir, "existing file"); + const h = yield* makeHarness({ baseDir }); + yield* h.installation.start; + expect(yield* terminalState(h.installation)).toMatchObject({ + phase: "failed", + message: "Could not install Codex. Check disk space and directory access, then try again.", + }); + expect(h.downloads()).toBe(0); + expect(yield* fs.readFileString(baseDir)).toBe("existing file"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reports a download timeout and cleans the staging directory", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const stall = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)); + const h = yield* makeHarness({ + body: Stream.concat(Stream.succeed(archive.subarray(0, 10)), Stream.fromEffect(stall)), + }); + yield* h.installation.start; + yield* Deferred.await(started); + yield* TestClock.adjust("45 minutes"); + expect(yield* terminalState(h.installation)).toMatchObject({ + phase: "failed", + message: "The Codex download timed out. Retry installation.", + }); + expect(h.downloads()).toBe(1); + expect(yield* h.fs.readDirectory(h.installation.managedDirectory)).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("cancels an in-flight download and cleans the staging directory", () => Effect.gen(function* () { const downloading = yield* Deferred.make(); diff --git a/apps/server/src/provider/CodexInstallation.ts b/apps/server/src/provider/CodexInstallation.ts index 88ca2c272711d..7cd973b73fa76 100644 --- a/apps/server/src/provider/CodexInstallation.ts +++ b/apps/server/src/provider/CodexInstallation.ts @@ -546,6 +546,14 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function ), Stream.run(fs.sink(archivePath, { flag: "wx", mode: 0o600 })), Effect.timeout("45 minutes"), + Effect.catchTags({ + TimeoutError: (cause) => + installationError( + "download", + "The Codex download timed out. Retry installation.", + cause, + ), + }), ); if (downloadedBytes !== release.archiveBytes || hash.digest("hex") !== release.sha256) return yield* installationError( @@ -612,6 +620,20 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function yield* activate(); }, Effect.scoped, + Effect.catchTags({ + HttpClientError: (cause) => { + const httpStatus = + cause.reason._tag === "StatusCodeError" ? cause.reason.response.status : undefined; + return new CodexInstallationError({ + operation: "download", + detail: + httpStatus === undefined + ? "The Codex download was interrupted. Check your internet connection, then retry installation." + : `The Codex download server returned HTTP ${httpStatus}. Try again later.`, + cause, + }); + }, + }), Effect.mapError( wrapFailure( "install", From 5033239dec868def062f4ea3c513f964087d36b3 Mon Sep 17 00:00:00 2001 From: Nishchay Chandra <130458023+GLDRoger@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:09:33 +0530 Subject: [PATCH 036/219] fix(provider-muse): Muse turns no longer fail when the project path's case differs from the disk (#22346) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../provider-muse/src/server/adapter.test.ts | 35 ++++++++++++++ packages/provider-muse/src/server/adapter.ts | 7 ++- packages/provider-muse/src/server/sdk.test.ts | 47 ++++++++++++++++++- packages/provider-muse/src/server/sdk.ts | 13 +++-- 4 files changed, 94 insertions(+), 8 deletions(-) diff --git a/packages/provider-muse/src/server/adapter.test.ts b/packages/provider-muse/src/server/adapter.test.ts index 7950ef28cb93a..3baeabbbe3c12 100644 --- a/packages/provider-muse/src/server/adapter.test.ts +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Mock native realpath to exercise macOS case correction on every platform. +import * as NodeFSP from "node:fs/promises"; +import { vi } from "vite-plus/test"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { MspError } from "@muse-code/sdk"; @@ -379,6 +382,8 @@ const approval = (turnId: string) => ({ subject: { kind: "shell", command: "touch result.txt" }, }); +vi.mock("node:fs/promises", { spy: true }); + describe("MuseAdapterV2", () => { it.effect("connects the thread's MCP credential on native start and resume", () => Effect.gen(function* () { @@ -569,6 +574,36 @@ describe("MuseAdapterV2", () => { ), ); + it.effect("pins sessions and turns to native canonical case", () => + Effect.gen(function* () { + using _realpath = vi.spyOn(NodeFSP, "realpath").mockResolvedValue("/workspace/eevee"); + const fake = yield* makeFakeMuse(); + const policy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + ...runtimePolicy, + cwd: "/workspace/EEVEE", + }); + const harness = yield* makeHarness( + fake, + INSTANCE_ID, + undefined, + undefined, + undefined, + policy, + ); + yield* startConversation(harness, fake); + const sessionStart = fake.calls.find((call) => call.method === "session/start"); + const turnStart = fake.calls.find((call) => call.method === "turn/start"); + assert.strictEqual(sessionStart?.params.workspaceRoot, "/workspace/eevee"); + assert.deepStrictEqual(turnStart?.params.workspaceRoots, [ + sessionStart?.params.workspaceRoot, + ]); + }).pipe( + Effect.scoped, + Effect.provide(testLayer), + Effect.provideService(HostProcess.Platform, "darwin"), + ), + ); + it.effect("stops at a Meta usage limit until every exhausted window resets", () => Effect.gen(function* () { const fake = yield* makeFakeMuse(); diff --git a/packages/provider-muse/src/server/adapter.ts b/packages/provider-muse/src/server/adapter.ts index 40785df6bada1..f022bf407fb10 100644 --- a/packages/provider-muse/src/server/adapter.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -65,6 +65,7 @@ import { isMuseUsageLimitFailure, museUsageLimitResetAt } from "./usageLimits.ts import { createMuseSdkHostEffect, museApprovalMode, + museCanonicalPath, musePermissionProfile, museWorkspaceRoot, type createMuseSdkHost, @@ -289,11 +290,9 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( if (input.runtimePolicy.interactionMode === "plan") return yield* protocolError("Muse Code does not support dedicated Plan mode"); const scope = yield* Effect.scope; - // Muse requires canonical workspace roots and approval paths; resolve symlinks once. + // Native realpath also corrects filesystem case, which Muse validates. const requestedCwd = input.runtimePolicy.cwd ?? providerHost.paths.cwd; - const cwd = yield* fileSystem - .realPath(requestedCwd) - .pipe(Effect.orElseSucceed(() => requestedCwd)); + const cwd = yield* museCanonicalPath(requestedCwd); const workspaceRoot = yield* museWorkspaceRoot(cwd); const now = yield* DateTime.now; let session: OrchestrationV2ProviderSession = { diff --git a/packages/provider-muse/src/server/sdk.test.ts b/packages/provider-muse/src/server/sdk.test.ts index c1a11270e35de..739b8c04ff441 100644 --- a/packages/provider-muse/src/server/sdk.test.ts +++ b/packages/provider-muse/src/server/sdk.test.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Mock native realpath to exercise macOS case correction on every platform. +import * as NodeFSP from "node:fs/promises"; // @effect-diagnostics-next-line nodeBuiltinImport:off -- The Muse SDK spawns with Node's child_process, so the launch test quotes arguments the same way. import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -21,6 +23,8 @@ import { type MuseSdkHost, } from "./sdk.ts"; +vi.mock("node:fs/promises", { spy: true }); + vi.mock("@muse-code/sdk", () => ({ spawnMspConnection: vi.fn() })); function pending() { @@ -311,13 +315,54 @@ describe("Muse launch", () => { }); describe("Muse workspace root", () => { + it.effect("uses native canonical case off Windows", () => + Effect.gen(function* () { + using _realpath = vi.spyOn(NodeFSP, "realpath").mockResolvedValue("/workspace/eevee"); + expect(yield* museWorkspaceRoot("/workspace/EEVEE")).toBe("/workspace/eevee"); + }).pipe(Effect.provideService(HostProcess.Platform, "darwin")), + ); + it("writes Windows paths in their verbatim form", () => { expect(museVerbatimPath("C:\\Users\\Person\\repo")).toBe("\\\\?\\C:\\Users\\Person\\repo"); expect(museVerbatimPath("\\\\server\\share\\repo")).toBe("\\\\?\\UNC\\server\\share\\repo"); expect(museVerbatimPath("\\\\?\\C:\\repo")).toBe("\\\\?\\C:\\repo"); }); - // FileSystem.realPath keeps the case it is given; Muse rejects a root whose case differs from the disk's. + it.effect("keeps a root it cannot resolve off Windows", () => + Effect.gen(function* () { + expect(yield* museWorkspaceRoot("/workspace/repo")).toBe("/workspace/repo"); + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), + ); + + it.effect("keeps a Windows root it cannot resolve, in verbatim form", () => + Effect.gen(function* () { + expect(yield* museWorkspaceRoot("C:\\definitely-missing\\repo")).toBe( + "\\\\?\\C:\\definitely-missing\\repo", + ); + expect(yield* museWorkspaceRoot("C:/definitely-missing/repo")).toBe( + "\\\\?\\C:\\definitely-missing\\repo", + ); + expect(yield* museWorkspaceRoot("//server/share/missing")).toBe( + "\\\\?\\UNC\\server\\share\\missing", + ); + }).pipe(Effect.provideService(HostProcess.Platform, "win32")), + ); + + // FileSystem.realPath keeps the case it is given; Muse rejects a root whose case + // differs from the disk's, and APFS is case-insensitive just like NTFS. + it.effect("fixes the case of a macOS root natively", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) !== "darwin") return; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const created = yield* fs.makeTempDirectoryScoped({ prefix: "MuseRoot-" }); + // Only a case-insensitive volume resolves the miscased path; skip on a case-sensitive one. + if (!(yield* fs.exists(created.toUpperCase()))) return; + const root = yield* museWorkspaceRoot(created.toUpperCase()); + expect(path.basename(root)).toBe(path.basename(created)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("fixes the case of a Windows root natively", () => Effect.gen(function* () { if ((yield* HostProcess.Platform) !== "win32") return; diff --git a/packages/provider-muse/src/server/sdk.ts b/packages/provider-muse/src/server/sdk.ts index fdd8da3b87eb0..17e9a017d81e2 100644 --- a/packages/provider-muse/src/server/sdk.ts +++ b/packages/provider-muse/src/server/sdk.ts @@ -1,6 +1,6 @@ // @effect-diagnostics globalTimers:off // The SDK owns process shutdown; native deadlines cover initialize before an Effect resource exists. -// @effect-diagnostics-next-line nodeBuiltinImport:off -- FileSystem.realPath is Node's JS realpath; museWorkspaceRoot needs the native one. +// @effect-diagnostics-next-line nodeBuiltinImport:off -- FileSystem.realPath is Node's JS realpath; museCanonicalPath needs the native one. import * as NodeFSP from "node:fs/promises"; import { @@ -70,9 +70,16 @@ export function museVerbatimPath(path: string): string { return path.startsWith("\\\\") ? `\\\\?\\UNC\\${path.slice(2)}` : `\\\\?\\${path}`; } -/** Windows roots need native realpath for true case/long names, then verbatim form; launch cmd.exe in plain form. */ +// Native realpath corrects filesystem case as well as resolving symlinks. +export const museCanonicalPath = Effect.fn("museCanonicalPath")(function* (path: string) { + return yield* Effect.tryPromise(() => NodeFSP.realpath(path)).pipe( + Effect.orElseSucceed(() => path), + ); +}); + +// Windows turn roots need verbatim form; launch cmd.exe with the plain canonical path. export const museWorkspaceRoot = Effect.fn("museWorkspaceRoot")(function* (path: string) { - if ((yield* HostProcess.Platform) !== "win32") return path; + if ((yield* HostProcess.Platform) !== "win32") return yield* museCanonicalPath(path); const canonical = yield* Effect.tryPromise(() => NodeFSP.realpath(path)).pipe( // A verbatim path takes `/` literally, so an unresolved root needs Windows separators. Effect.orElseSucceed(() => path.replaceAll("/", "\\")), From 5ad7da060d058a1b07c18fb5f47b8230823c5c4d Mon Sep 17 00:00:00 2001 From: Zarrar Date: Fri, 30 Oct 2026 16:39:50 -0400 Subject: [PATCH 037/219] fix(web): keep markdown separators visible in dark mode (#22367) --- apps/web/src/index.css | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 8acc312321b8f..6b5f274b849b1 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2065,8 +2065,13 @@ code { margin: 0.65rem 0; } -/* Preflight already draws the 1px rule, as it does in chat; only the block - spacing needs to match. */ +/* Surface borders can blend into dark message bubbles; rules need a readable color. */ +:is(.chat-markdown, .composer-tiptap) hr { + @variant dark { + border-color: var(--contrast-muted-foreground); + } +} + .composer-tiptap hr { margin: 0.65rem 0; } From 444df6185081c577266e66a01e9b5762d15e8453 Mon Sep 17 00:00:00 2001 From: Antony <97451137+TonybynMp4@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:40:05 +0100 Subject: [PATCH 038/219] fix(gitlab): merge requests show their line counts (#22374) Co-authored-by: Claude Opus 5.5 Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: maria-rcks --- .../src/server/GitLabPullRequestCli.test.ts | 88 ++++++++++++ .../src/server/GitLabPullRequestCli.ts | 135 +++++++++++++++++- .../server/GitLabPullRequestProvider.test.ts | 70 ++++++++- .../src/server/GitLabPullRequestProvider.ts | 31 +++- .../src/server/gitLabMergeRequestJson.test.ts | 58 ++++++++ .../src/server/gitLabMergeRequestJson.ts | 115 +++++++++++++++ 6 files changed, 493 insertions(+), 4 deletions(-) diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts index 16326f57ffecd..d4d8369f97fb6 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts @@ -1184,4 +1184,92 @@ layer("GitLabPullRequestCli.layer", (it) => { assert.strictEqual(mockedExecute.mock.calls.length, 3); }), ); + + it.effect( + "reads line counts a hundred at a time per project, keeping the reads that answered", + () => + Effect.gen(function* () { + const variablesOf = (stdin: string | undefined) => + ( + JSON.parse(stdin ?? "{}") as { + readonly variables: { + readonly fullPath: string; + readonly iids: ReadonlyArray; + }; + } + ).variables; + mockedExecute.mockImplementation((request) => { + const { fullPath, iids } = variablesOf(request.stdin); + // GitLab answers only what it has (iid 3 is missing), and one project's read fails. + const nodes = iids + .filter((iid) => iid !== "3") + .map((iid) => ({ iid, diffStatsSummary: { additions: Number(iid), deletions: 1 } })); + return Effect.succeed( + output( + JSON.stringify( + fullPath === "acme/api" + ? { errors: [{ message: "Query timed out" }] } + : { data: { project: { mergeRequests: { nodes } } } }, + ), + ), + ); + }); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const stats = yield* cli.listMergeRequestStats({ + cwd: "/w", + changeRequests: [ + ...Array.from({ length: 150 }, (_, index) => ({ + repository: "acme/web", + number: index + 1, + })), + { repository: "acme/api", number: 9 }, + ], + }); + + expect(stats).toHaveLength(149); + expect(stats).toContainEqual({ + repository: "acme/web", + number: 150, + additions: 150, + deletions: 1, + }); + expect(stats.some((stat) => stat.number === 3 || stat.repository === "acme/api")).toBe( + false, + ); + expect( + mockedExecute.mock.calls.map(([request]) => { + const { fullPath, iids } = variablesOf(request.stdin); + return [fullPath, iids.length]; + }), + ).toEqual([ + ["acme/web", 100], + ["acme/web", 50], + ["acme/api", 1], + ]); + }), + ); + it.effect.each([ + GitLabCli.GitLabCliUnavailableError, + GitLabCli.GitLabCliAuthenticationError, + GitLabCli.GitLabCliRateLimitError, + ])("propagates unusable CLI and account failures from stats (%s)", (ErrorType) => + Effect.gen(function* () { + const error = new ErrorType({ + operation: "execute", + command: "glab", + cwd: "/w", + cause: null, + }); + mockedExecute.mockReturnValueOnce(Effect.fail(error)); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + const result = yield* Effect.result( + cli.listMergeRequestStats({ + cwd: "/w", + changeRequests: [{ repository: "acme/web", number: 7 }], + }), + ); + expect(result).toMatchObject({ _tag: "Failure", failure: error }); + }), + ); }); diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts index 0c1a74827bfa6..43f77e56c5598 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts @@ -37,6 +37,8 @@ import { decodeMergeRequestDiffsJson, decodeMergeRequestChangesJson, decodeMergeRequestListJson, + decodeMergeRequestFileStatsJson, + decodeMergeRequestStatsJson, decodeNotesJson, decodeOwnAwardIdJson, decodeProjectMergeCapabilitiesJson, @@ -45,13 +47,18 @@ import { decodeRepositoryBlobsJson, decodeViewerJson, gitLabAwardName, + MERGE_REQUEST_FILE_STATS_GRAPHQL_QUERY, + MERGE_REQUEST_STATS_GRAPHQL_QUERY, REPOSITORY_BLOBS_GRAPHQL_QUERY, type GitLabDiffRefs, type GitLabMergeRequestDetail, type GitLabMergeRequestListItem, type GitLabProjectUsers, } from "./gitLabMergeRequestJson.ts"; -import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; +import type { + ProviderFileStats, + ProviderListCursor, +} from "@t3tools/source-control-core/server/PullRequestProvider"; export class GitLabMergeRequestReadError extends Schema.TaggedError()( "GitLabMergeRequestReadError", @@ -237,6 +244,29 @@ export class GitLabPullRequestCli extends Context.Service< readonly number: number; }) => Effect.Effect; + /** Line counts for the given merge requests; one with no answer is left out. */ + readonly listMergeRequestStats: (input: { + readonly cwd: string; + readonly changeRequests: ReadonlyArray<{ + readonly repository: string; + readonly number: number; + }>; + }) => Effect.Effect< + ReadonlyArray<{ + readonly repository: string; + readonly number: number; + readonly additions: number; + readonly deletions: number; + }>, + GitLabPullRequestCliError + >; + + readonly getMergeRequestFileStats: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + readonly listNotes: (input: { readonly cwd: string; readonly repository: string; @@ -1021,6 +1051,97 @@ export const make = Effect.gen(function* () { }), ); + /** The stats connection hands back one page, so a repository's rows are asked for 100 at a time. */ + const STAT_IIDS_PER_REQUEST = 100; + + const graphqlRead = (input: { + readonly cwd: string; + readonly operation: string; + readonly query: string; + readonly variables: Record; + readonly decode: (raw: string) => Result.Result; + }) => + api({ + cwd: input.cwd, + path: "graphql", + method: "POST", + stdin: JSON.stringify({ query: input.query, variables: input.variables }), + }).pipe( + Effect.flatMap((result) => { + const decoded = input.decode(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: input.operation, + cause: decoded.failure, + }), + ); + }), + ); + + const mergeRequestStats = (input: { + readonly cwd: string; + readonly repository: string; + readonly numbers: ReadonlyArray; + }) => + graphqlRead({ + cwd: input.cwd, + operation: "listMergeRequestStats", + query: MERGE_REQUEST_STATS_GRAPHQL_QUERY, + variables: { fullPath: input.repository, iids: input.numbers.map(String) }, + decode: decodeMergeRequestStatsJson, + }); + + const STAT_REQUEST_CONCURRENCY = 4; + + const listMergeRequestStats: GitLabPullRequestCli["Service"]["listMergeRequestStats"] = ( + input, + ) => { + const byRepository = new Map>(); + for (const { repository, number } of input.changeRequests) { + const numbers = byRepository.get(repository) ?? new Set(); + numbers.add(number); + byRepository.set(repository, numbers); + } + const reads: Array<{ readonly repository: string; readonly numbers: ReadonlyArray }> = + []; + for (const [repository, numberSet] of byRepository) { + const numbers = [...numberSet]; + for (let start = 0; start < numbers.length; start += STAT_IIDS_PER_REQUEST) { + reads.push({ repository, numbers: numbers.slice(start, start + STAT_IIDS_PER_REQUEST) }); + } + } + return Effect.forEach( + reads, + (read) => + mergeRequestStats({ cwd: input.cwd, ...read }).pipe( + Effect.map((stats) => + read.numbers.flatMap((number) => { + const stat = stats.get(number); + return stat === undefined ? [] : [{ repository: read.repository, number, ...stat }]; + }), + ), + // One read GitLab could not answer costs its own rows their counts, not every other + // read's on the same page. An unusable CLI or account still reaches the caller. + Effect.catchIf( + (cause) => + cause._tag !== "GitLabCliUnavailableError" && + cause._tag !== "GitLabCliAuthenticationError" && + cause._tag !== "GitLabCliRateLimitError", + (cause) => + Effect.logWarning("GitLab merge request line count read failed", { + repository: read.repository, + reason: cause._tag, + }).pipe(Effect.as([])), + ), + ), + { concurrency: STAT_REQUEST_CONCURRENCY }, + ).pipe(Effect.map((results) => results.flat())); + }; + // GitLab charges by path count and returns one connection page; respect both limits. const BLOB_PATHS_PER_REQUEST = 100; @@ -1125,6 +1246,18 @@ export const make = Effect.gen(function* () { getMergeRequestDetail: mergeRequestDetail, + listMergeRequestStats, + + getMergeRequestFileStats: Effect.fn("GitLabPullRequestCli.getMergeRequestFileStats")((input) => + graphqlRead({ + cwd: input.cwd, + operation: "getMergeRequestFileStats", + query: MERGE_REQUEST_FILE_STATS_GRAPHQL_QUERY, + variables: { fullPath: input.repository, iid: String(input.number) }, + decode: decodeMergeRequestFileStatsJson, + }), + ), + listNotes: Effect.fn("GitLabPullRequestCli.listNotes")(function (input) { return notesPage({ ...input, page: 1, collected: [] }); }), diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts index cc6bab6c53f42..a00569a1b6b20 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts @@ -74,6 +74,7 @@ describe("getChangeRequest base freshness", () => { Effect.provide( Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ getMergeRequestDetail: () => Effect.succeed(detail), + listMergeRequestStats: () => Effect.succeed([]), }), ), ); @@ -85,7 +86,11 @@ describe("getChangeRequest base freshness", () => { }), ); - const readWith = (divergence: { readonly divergedCommits?: number }) => + const readWith = ( + divergence: { readonly divergedCommits?: number }, + stats: GitLabPullRequestCli.GitLabPullRequestCli["Service"]["listMergeRequestStats"] = () => + Effect.succeed([]), + ) => Effect.gen(function* () { const provider = yield* make; return yield* provider.getChangeRequest({ @@ -100,6 +105,7 @@ describe("getChangeRequest base freshness", () => { getMergeRequestDetail: () => Effect.succeed({ ...detail, ...divergence }), getProjectMergeCapabilities: () => Effect.succeed({ merge: true, squash: true, rebase: true }), + listMergeRequestStats: stats, }), ), ); @@ -121,6 +127,62 @@ describe("getChangeRequest base freshness", () => { expect(changeRequest.behindBy).toBeUndefined(); }), ); + + it.effect("fills in the line counts the REST detail leaves out", () => + Effect.gen(function* () { + const changeRequest = yield* readWith({}, () => + Effect.succeed([{ repository: "acme/web", number: 7, additions: 40, deletions: 2 }]), + ); + + expect(changeRequest.additions).toBe(40); + expect(changeRequest.deletions).toBe(2); + }), + ); + + it.effect("reports an unauthenticated line count read instead of a detail without counts", () => + Effect.gen(function* () { + const result = yield* Effect.result( + readWith({}, () => + Effect.fail( + new GitLabCli.GitLabCliAuthenticationError({ + operation: "execute", + command: "glab", + cwd: "/w", + cause: null, + }), + ), + ), + ); + + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "PullRequestProviderError", reason: "unauthenticated" }, + }); + }), + ); + + it.effect("gives linked threads line counts without reading the merge settings", () => + Effect.gen(function* () { + const summary = yield* Effect.gen(function* () { + const provider = yield* make; + const read = provider.getChangeRequestSummary; + if (read === undefined) return yield* Effect.die("summary read missing"); + return yield* read({ cwd: "/w", repository: "acme/web", host: "gitlab.com", number: 7 }); + }).pipe( + Effect.provide( + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ + getMergeRequestDetail: () => Effect.succeed(detail), + getProjectMergeCapabilities: () => Effect.die("linked threads skip merge settings"), + listMergeRequestStats: () => + Effect.succeed([{ repository: "acme/web", number: 7, additions: 40, deletions: 2 }]), + }), + ), + ); + + expect(summary.additions).toBe(40); + expect(summary.deletions).toBe(2); + }), + ); }); describe("merge request reads", () => { @@ -157,6 +219,7 @@ describe("merge request reads", () => { const provider = yield* make.pipe( Effect.provide( Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ + listMergeRequestStats: () => Effect.succeed([]), getMergeRequestDetail: (input) => Effect.sync(() => { calls.detail += 1; @@ -199,6 +262,11 @@ describe("merge request reads", () => { yield* checks(reference); expect(calls.detail).toBe(1); + + const summary = provider.getChangeRequestSummary; + assert.isDefined(summary); + yield* summary(reference); + expect(calls.detail).toBe(2); }), ); diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts index 79c3d43c01966..7184ebaa3ca05 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts @@ -173,6 +173,25 @@ export const make = Effect.gen(function* () { }), ); + // REST omits line counts; the CLI recovers optional stats failures per batch. + const mergeRequestWithStats = ( + input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }, + detail = cli.getMergeRequestDetail(input), + ) => + Effect.all([detail, cli.listMergeRequestStats({ cwd: input.cwd, changeRequests: [input] })], { + concurrency: 2, + }).pipe( + Effect.map(([mergeRequest, [stat]]) => + stat === undefined + ? mergeRequest + : { ...mergeRequest, additions: stat.additions, deletions: stat.deletions }, + ), + ); + const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("gitlab"), capabilities: CAPABILITIES, @@ -199,6 +218,14 @@ export const make = Effect.gen(function* () { Effect.map((batch) => ({ ...batch, continues: true })), ), + getFileStats: (input) => + cli.getMergeRequestFileStats(input).pipe(Effect.mapError(fail("getFileStats"))), + + listChangeRequestStats: (input) => + cli + .listMergeRequestStats({ cwd: input.cwd, changeRequests: input.changeRequests }) + .pipe(Effect.mapError(fail("listChangeRequestStats"))), + getChangeRequestChecks: (input) => mergeRequestDetail(input).pipe( Effect.map(({ state, checks }) => ({ state, checks })), @@ -210,12 +237,12 @@ export const make = Effect.gen(function* () { cli.getMergeRequestDetail(input).pipe(Effect.mapError(fail("getChangeRequestPreview"))), getChangeRequestSummary: (input) => - cli.getMergeRequestDetail(input).pipe(Effect.mapError(fail("getChangeRequestSummary"))), + mergeRequestWithStats(input).pipe(Effect.mapError(fail("getChangeRequestSummary"))), getChangeRequest: (input) => Effect.all( [ - mergeRequestDetail(input), + mergeRequestWithStats(input, mergeRequestDetail(input)), Cache.get(projectMergeCapabilities, JSON.stringify([input.cwd, input.repository])), ], { concurrency: 2 }, diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts index 92f9ed19fe4dc..890aa4b8dbee3 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts @@ -8,6 +8,8 @@ import { decodeMergeRequestDetailJson, decodeMergeRequestDiffsJson, decodeMergeRequestListJson, + decodeMergeRequestFileStatsJson, + decodeMergeRequestStatsJson, decodeNotesJson, decodeOwnAwardIdJson, } from "./gitLabMergeRequestJson.ts"; @@ -620,3 +622,59 @@ describe("decodeRepositoryBlobsJson", () => { ).toEqual(new Map()); }); }); + +describe("decodeMergeRequestStatsJson", () => { + it("keys the counts by merge request number", () => { + const stats = expectSuccess( + decodeMergeRequestStatsJson( + JSON.stringify({ + data: { + project: { + mergeRequests: { + nodes: [ + { iid: "7", diffStatsSummary: { additions: 12, deletions: 3 } }, + { iid: "8", diffStatsSummary: null }, + ], + }, + }, + }, + }), + ), + ); + + // A merge request without a summary is left out rather than shown as zero lines changed. + expect([...stats]).toEqual([[7, { additions: 12, deletions: 3 }]]); + }); + + it("answers nothing for a project the token cannot see", () => { + const stats = expectSuccess( + decodeMergeRequestStatsJson(JSON.stringify({ data: { project: null } })), + ); + + expect(stats.size).toBe(0); + }); + + it("reports a file list shorter than the summary as truncated", () => { + const read = (fileCount: number) => + expectSuccess( + decodeMergeRequestFileStatsJson( + JSON.stringify({ + data: { + project: { + mergeRequest: { + diffStatsSummary: { fileCount }, + diffStats: [{ path: "src/a.ts", additions: 2, deletions: 1 }], + }, + }, + }, + }), + ), + ); + + expect(read(1)).toEqual({ + files: [{ path: "src/a.ts", additions: 2, deletions: 1 }], + truncated: false, + }); + expect(read(2).truncated).toBe(true); + }); +}); diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts index 9c315669d0262..af21a5ed9f77e 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts @@ -946,3 +946,118 @@ export function decodeRepositoryBlobsJson( } return Result.succeed(blobs); } + +/** + * Line counts for merge requests of one project. The REST listing carries no line counts, and + * reading each diff to count them would cost a request per row, so GraphQL's per-merge-request + * summary is asked for in one read instead. + */ +export const MERGE_REQUEST_STATS_GRAPHQL_QUERY = `query($fullPath: ID!, $iids: [String!]) { + project(fullPath: $fullPath) { + mergeRequests(iids: $iids, first: 100) { + nodes { iid diffStatsSummary { additions deletions } } + } + } +}`; + +const LineCountsSchema = Schema.Struct({ additions: Schema.Int, deletions: Schema.Int }); + +const RawMergeRequestStatsSchema = Schema.Struct({ + data: Schema.Struct({ + project: Schema.NullOr( + Schema.Struct({ + mergeRequests: Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.NullOr( + Schema.Struct({ + iid: Schema.String, + diffStatsSummary: Schema.NullOr(LineCountsSchema), + }), + ), + ), + }), + ), + }), + ), + }), +}); + +const decodeMergeRequestStats = decodeJsonResult(RawMergeRequestStatsSchema); + +/** + * The counts by merge request number. A merge request GitLab returned no summary for is left + * out rather than counted as zero, so its row keeps whatever it already showed. + */ +export function decodeMergeRequestStatsJson( + raw: string, +): Result.Result< + ReadonlyMap, + DecodeFailure +> { + const decoded = decodeMergeRequestStats(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const stats = new Map(); + for (const node of decoded.success.data.project?.mergeRequests?.nodes ?? []) { + const iid = Number(node?.iid); + if (!Number.isSafeInteger(iid) || iid <= 0 || node?.diffStatsSummary == null) continue; + stats.set(iid, node.diffStatsSummary); + } + return Result.succeed(stats); +} + +/** Per-file line counts for one merge request, which the code/tests/generated breakdown sorts. */ +export const MERGE_REQUEST_FILE_STATS_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!) { + project(fullPath: $fullPath) { + mergeRequest(iid: $iid) { + diffStatsSummary { fileCount } + diffStats { path additions deletions } + } + } +}`; + +const decodeMergeRequestFileStats = decodeJsonResult( + Schema.Struct({ + data: Schema.Struct({ + project: Schema.NullOr( + Schema.Struct({ + mergeRequest: Schema.NullOr( + Schema.Struct({ + diffStatsSummary: Schema.NullOr(Schema.Struct({ fileCount: Schema.Int })), + diffStats: Schema.NullOr( + Schema.Array(Schema.Struct({ path: Schema.String, ...LineCountsSchema.fields })), + ), + }), + ), + }), + ), + }), + }), +); + +/** + * GitLab stops listing files past its diff limits while the summary still counts them all, so a + * shorter list is reported as truncated rather than as a breakdown that misses lines. + */ +export function decodeMergeRequestFileStatsJson(raw: string): Result.Result< + { + readonly files: ReadonlyArray<{ + readonly path: string; + readonly additions: number; + readonly deletions: number; + }>; + readonly truncated: boolean; + }, + DecodeFailure +> { + const decoded = decodeMergeRequestFileStats(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const mergeRequest = decoded.success.data.project?.mergeRequest; + const files = mergeRequest?.diffStats ?? []; + const fileCount = mergeRequest?.diffStatsSummary?.fileCount; + return Result.succeed({ files, truncated: fileCount === undefined || files.length < fileCount }); +} From b8851fbc825d11855a29233e84833e471f6c69a5 Mon Sep 17 00:00:00 2001 From: fixfon <12512757+fixfon@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:40:22 +0300 Subject: [PATCH 039/219] fix(server): don't bill a replayed Codex first request twice (#22392) Co-authored-by: maria-rcks --- .../src/provider/Drivers/codexUsage.test.ts | 129 ++++++++++++++++++ .../server/src/provider/Drivers/codexUsage.ts | 19 ++- 2 files changed, 146 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Drivers/codexUsage.test.ts b/apps/server/src/provider/Drivers/codexUsage.test.ts index d9ee4f4a04ff5..3820ce0cf5234 100644 --- a/apps/server/src/provider/Drivers/codexUsage.test.ts +++ b/apps/server/src/provider/Drivers/codexUsage.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { totalTokens } from "@t3tools/provider-core/server/usage"; import { codexUsageFormat, initialCodexScanState, parseCodexLine } from "./codexUsage.ts"; @@ -373,6 +374,106 @@ describe("parseCodexLine", () => { expect(codexUsageFormat.readMalformedRecords?.(state)).toBe(0); }); + it("ignores a replay of the current sequence's first request without rewinding the baseline", () => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const request = counters(100, 20); + const processed = [ + counted(request, request), + counted(request, counters(200, 40)), + counted(request, request), + counted(request, counters(300, 60)), + ].map((line) => { + const record = parseCodexLine(line, state); + return record === null ? 0 : totalTokens(record.totals); + }); + + expect({ + processed, + skipped: codexUsageFormat.readMalformedRecords?.(state), + }).toEqual({ processed: [120, 120, 0, 120], skipped: 0 }); + }); + + it("bills a genuine restart with the same counters at a new timestamp", () => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const request = counters(100, 20); + const lines = [ + counted(request, request), + counted(request, counters(200, 40)), + counted(request, request).replace("05:17:49.919Z", "05:18:49.919Z"), + counted(counters(30, 5), counters(130, 25)), + ]; + const processed = lines.map((line) => { + const record = parseCodexLine(line, state); + return record === null ? 0 : totalTokens(record.totals); + }); + expect({ processed, skipped: codexUsageFormat.readMalformedRecords?.(state) }).toEqual({ + processed: [120, 120, 120, 35], + skipped: 0, + }); + }); + + it.each([ + { + reset: true, + markerTotal: counters(0, 0), + nextTotal: counters(130, 25), + processed: [120, 120, 0, 120, 35], + }, + { + reset: false, + markerTotal: counters(200, 40), + nextTotal: counters(230, 45), + processed: [120, 120, 0, 0, 35], + }, + ])( + "bills a request equal to the old sequence start only after a context marker reset the counters (reset: $reset)", + ({ markerTotal, nextTotal, processed }) => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const request = counters(100, 20); + const marker = counted({ ...counters(0, 0), total_tokens: 26387 }, markerTotal, { + model_context_window: 475000, + }); + const billed = [ + counted(request, request), + counted(request, counters(200, 40)), + marker, + counted(request, request), + counted(counters(30, 5), nextTotal), + ].map((line) => { + const record = parseCodexLine(line, state); + return record === null ? 0 : totalTokens(record.totals); + }); + + expect({ + processed: billed, + skipped: codexUsageFormat.readMalformedRecords?.(state), + }).toEqual({ processed, skipped: 0 }); + }, + ); + + it("forgets the old sequence start when a malformed pair zeroes the cumulative baseline", () => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const request = counters(100, 20); + const processed = [ + counted(request, request), + counted(counters(30, 5), counters(0, 0)), + counted(request, request), + counted(counters(30, 5), counters(130, 25)), + ].map((line) => { + const record = parseCodexLine(line, state); + return record === null ? 0 : totalTokens(record.totals); + }); + + expect({ + processed, + skipped: codexUsageFormat.readMalformedRecords?.(state), + }).toEqual({ processed: [120, 0, 120, 35], skipped: 1 }); + }); + it("retains initial tier evidence only for the own OpenAI session and exact context until native settings arrive", () => { const meta = (id: string, model_provider?: string) => JSON.stringify({ type: "session_meta", payload: { id, model_provider } }); @@ -535,5 +636,33 @@ describe("parseCodexLine", () => { ); expect(record).not.toBeNull(); }); + + it("forgets the old sequence start when a copied zero baseline rebases the counters", () => { + const state = initialCodexScanState(); + const forkInstant = "2026-08-01T05:00:00.000Z"; + const request = counters(100, 20); + const billed = (line: string) => { + const record = parseCodexLine(line, state); + return record === null ? 0 : totalTokens(record.totals); + }; + parseCodexLine(stamped("2026-08-01T04:59:00.000Z", turnContext), state); + const first = billed(stamped("2026-08-01T04:59:01.000Z", counted(request, request))); + parseCodexLine(meta({ id: "child", timestamp: forkInstant, forkedFromId: "parent" }), state); + const copied = billed( + stamped( + "2026-08-01T05:00:00.001Z", + counted({ ...counters(0, 0), total_tokens: 26387 }, counters(0, 0)), + ), + ); + const afterBurst = billed(stamped("2026-08-01T05:00:06.000Z", counted(request, request))); + const next = billed( + stamped("2026-08-01T05:00:06.100Z", counted(counters(30, 5), counters(130, 25))), + ); + + expect({ + processed: [first, copied, afterBurst, next], + skipped: codexUsageFormat.readMalformedRecords?.(state), + }).toEqual({ processed: [120, 0, 120, 35], skipped: 0 }); + }); }); }); diff --git a/apps/server/src/provider/Drivers/codexUsage.ts b/apps/server/src/provider/Drivers/codexUsage.ts index 38615eb2cb0cb..20939e54db799 100644 --- a/apps/server/src/provider/Drivers/codexUsage.ts +++ b/apps/server/src/provider/Drivers/codexUsage.ts @@ -55,6 +55,12 @@ function readCodexTokenUsage(value: unknown): CodexTokenUsage | null { return isCodexTokenUsage(usage) ? usage : null; } +// A zeroed baseline rebases the counters, so the sequence start measured against it is forgotten. +function rebaseCodexUsage(state: CodexScanState, cumulative: CodexTokenUsage) { + state.lastCumulativeUsage = cumulative; + if (cumulative.input_tokens + cumulative.output_tokens === 0) delete state.sequenceStartSignature; +} + function advanceCodexUsage(previous: CodexTokenUsage | null, last: CodexTokenUsage) { return readCodexTokenUsage( Object.fromEntries(CODEX_USAGE_FIELDS.map((key) => [key, (previous?.[key] ?? 0) + last[key]])), @@ -71,6 +77,8 @@ export const CodexScanState = Schema.Struct({ sessionId: Schema.mutableKey(Schema.String), lastUsageSignature: Schema.mutableKey(Schema.NullOr(Schema.String)), lastCumulativeUsage: Schema.mutableKey(Schema.NullOr(CodexTokenUsage)), + // Optional so scan states cached before this field existed stay valid. + sequenceStartSignature: Schema.mutableKey(Schema.optionalKey(Schema.String)), malformedRecords: Schema.mutableKey(TokenCount), sawSessionMeta: Schema.mutableKey(Schema.Boolean), modelProvider: Schema.mutableKey(Schema.String), @@ -193,7 +201,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | state.forkCopyAnchorMs = timestampMs; // Copied history is already billed in the parent, but establishes the child's baseline. const signature = last === null ? null : JSON.stringify(last); - if (cumulative !== null) state.lastCumulativeUsage = cumulative; + if (cumulative !== null) rebaseCodexUsage(state, cumulative); else if (!hasCumulative && last !== null && signature !== state.lastUsageSignature) { state.lastCumulativeUsage = advanceCodexUsage(state.lastCumulativeUsage, last); } @@ -206,7 +214,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | const previous = state.lastCumulativeUsage; // A valid snapshot remains the next baseline even when its paired request is malformed. - if (cumulative !== null) state.lastCumulativeUsage = cumulative; + if (cumulative !== null) rebaseCodexUsage(state, cumulative); if (last === null || (hasCumulative && cumulative === null)) { state.malformedRecords += 1; return null; @@ -236,6 +244,12 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | if (cumulative !== null) { // A session resumed in a new Codex process restarts its cumulative counters at this request. const restarted = CODEX_USAGE_FIELDS.every((key) => cumulative[key] === last[key]); + const sequenceStart = JSON.stringify([timestampMs, last]); + // Only the same event is a replay; a later request can restart with identical counters. + if (restarted && sequenceStart === state.sequenceStartSignature) { + state.lastCumulativeUsage = previous; + return null; + } // Forks and paginated continuations can inherit totals without copying a counter snapshot. // Only bill the latest request, and require the unseen prefix to be valid usage too. const reconciled = @@ -248,6 +262,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | state.malformedRecords += 1; return null; } + if (restarted) state.sequenceStartSignature = sequenceStart; } else { if (signature === state.lastUsageSignature) return null; // Advance expected totals for billed legacy requests so a cumulative re-emission is stale. From 94b80eaf4f2c07d0cde2e35cda7852509f62ebae Mon Sep 17 00:00:00 2001 From: Yuruzuu <108398860+Yuruzuu@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:40:37 +0800 Subject: [PATCH 040/219] fix(web): language dialects use their base language's icon (#22455) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/web/src/pierre-icons.test.ts | 19 +++++++++++++++++++ apps/web/src/pierre-icons.ts | 4 ++++ 2 files changed, 23 insertions(+) diff --git a/apps/web/src/pierre-icons.test.ts b/apps/web/src/pierre-icons.test.ts index 0ffceec70136c..734cac8655379 100644 --- a/apps/web/src/pierre-icons.test.ts +++ b/apps/web/src/pierre-icons.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "vite-plus/test"; import { hasSpecificPierreIconForFileName, resolvePierreIconForEntry, + syntheticFileNameForLanguageId, T3_PIERRE_ICONS, } from "./pierre-icons"; @@ -64,4 +65,22 @@ describe("Pierre file icons", () => { assert.include(T3_PIERRE_ICONS.spriteSheet, `id="${iconName}"`); } }); + + it.each([ + ["luau", "lua"], + ["xsd", "xml"], + ["xsl", "xml"], + ["xslt", "xml"], + ])("gives %s files and fences the %s icon", (dialect, base) => { + const baseIcon = resolvePierreIconForEntry(`file.${base}`, "file")?.name; + assert.equal(baseIcon, `t3-file-icon-${base}`); + for (const path of [`src/init.${dialect}`, `src/INIT.${dialect.toUpperCase()}`]) { + assert.equal(resolvePierreIconForEntry(path, "file")?.name, baseIcon); + assert.isTrue(hasSpecificPierreIconForFileName(path)); + } + assert.equal( + resolvePierreIconForEntry(syntheticFileNameForLanguageId(dialect), "file")?.name, + baseIcon, + ); + }); }); diff --git a/apps/web/src/pierre-icons.ts b/apps/web/src/pierre-icons.ts index 852ccc26e127e..aa45b3025c103 100644 --- a/apps/web/src/pierre-icons.ts +++ b/apps/web/src/pierre-icons.ts @@ -176,6 +176,7 @@ export const T3_PIERRE_ICONS = { kt: "t3-file-icon-kotlin", kts: "t3-file-icon-kotlin", lua: "t3-file-icon-lua", + luau: "t3-file-icon-lua", php: "t3-file-icon-php", ps1: "t3-file-icon-powershell", psm1: "t3-file-icon-powershell", @@ -184,6 +185,9 @@ export const T3_PIERRE_ICONS = { scala: "t3-file-icon-scala", toml: "t3-file-icon-toml", xml: "t3-file-icon-xml", + xsd: "t3-file-icon-xml", + xsl: "t3-file-icon-xml", + xslt: "t3-file-icon-xml", }, } satisfies FileTreeIcons; From 65fc784150d283e99377628d8177222294a904dc Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Fri, 30 Oct 2026 21:40:55 +0100 Subject: [PATCH 041/219] fix(desktop): timestamps follow the macOS region and Linux LC_TIME settings (#22459) Co-authored-by: maria-rcks Co-authored-by: Illia Panasenko <576152+ipanasenko@users.noreply.github.com> --- apps/desktop/src/electron/ElectronApp.test.ts | 89 +++++++++++++++++++ apps/desktop/src/electron/ElectronApp.ts | 35 +++++++- 2 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/electron/ElectronApp.test.ts diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts new file mode 100644 index 0000000000000..30b7f2d7387e3 --- /dev/null +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -0,0 +1,89 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import { beforeEach, vi } from "vite-plus/test"; + +const { getSystemLocaleMock, getUserDefaultMock } = vi.hoisted(() => ({ + getSystemLocaleMock: vi.fn(() => "en-GB"), + getUserDefaultMock: vi.fn((_key: string) => false), +})); + +vi.mock("electron", () => ({ + app: { + getSystemLocale: getSystemLocaleMock, + }, + systemPreferences: { + getUserDefault: getUserDefaultMock, + }, +})); + +import * as ElectronApp from "./ElectronApp.ts"; + +describe("ElectronApp", () => { + beforeEach(() => { + getSystemLocaleMock.mockReset(); + getUserDefaultMock.mockReset(); + }); + + it("keeps the macOS region override and hour cycle in the locale tag", () => { + assert.strictEqual(ElectronApp.toSystemLocaleTag("en_GB"), "en-GB"); + assert.strictEqual(ElectronApp.toSystemLocaleTag("en-US@rg=nlzzzz", "h12"), "en-NL-u-hc-h12"); + assert.strictEqual(ElectronApp.toSystemLocaleTag("en_US@rg=nlzzzz"), "en-NL"); + assert.strictEqual(ElectronApp.toSystemLocaleTag("en-GB", "h23"), "en-GB-u-hc-h23"); + }); + + it("drops ICU keywords Intl cannot read instead of losing the whole locale", () => { + assert.strictEqual(ElectronApp.toSystemLocaleTag("de_DE@calendar=buddhist"), "de-DE"); + }); + + it.effect("follows LC_TIME on Linux when it differs from the language", () => + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const systemLocale = yield* electronApp.systemLocale.pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, { + LANG: "en_US.UTF-8", + LC_TIME: "de_DE.UTF-8", + }), + ); + + assert.strictEqual(systemLocale, "de-DE"); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + + it.effect("falls back to Chromium's locale on Linux when the time locale is C", () => + Effect.gen(function* () { + getSystemLocaleMock.mockImplementationOnce(() => "fr-FR"); + const electronApp = yield* ElectronApp.ElectronApp; + const systemLocale = yield* electronApp.systemLocale.pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, { LANG: "C.UTF-8" }), + ); + + assert.strictEqual(systemLocale, "fr-FR"); + }).pipe(Effect.provide(ElectronApp.layer)), + ); +}); + +it.effect("reads both macOS hour preferences without overriding explicit formats", () => + Effect.gen(function* () { + for (const [preference, expected] of [ + ["AppleICUForce12HourTime", "en-NL-u-hc-h12"], + ["AppleICUForce24HourTime", "en-NL-u-hc-h23"], + ["", "en-NL"], + ]) { + getSystemLocaleMock.mockReturnValue("en_US@rg=nlzzzz"); + getUserDefaultMock.mockImplementation((key) => key === preference); + const locale = yield* ElectronApp.make.systemLocale.pipe( + Effect.provideService(HostProcess.Platform, "darwin"), + ); + assert.strictEqual(locale, expected); + for (const hour12 of [true, false]) { + assert.strictEqual( + new Intl.DateTimeFormat(locale, { hour: "numeric", hour12 }).resolvedOptions().hour12, + hour12, + ); + } + } + }), +); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 04f26b1bced84..d677baa56dce3 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -1,3 +1,4 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -93,6 +94,21 @@ const addScopedAppListener = >( }), ).pipe(Effect.asVoid); +export function toSystemLocaleTag(identifier: string, hourCycle?: "h12" | "h23"): string { + const [base = "", keywords = ""] = identifier.split("@"); + const languageTag = base.replace(/_/g, "-"); + // Intl ignores rg extensions, so the macOS region override must replace the region subtag. + const region = /(?:^|;)rg=([a-z]{2}|\d{3})zzzz(?:;|$)/i.exec(keywords)?.[1]?.toUpperCase(); + try { + return new Intl.Locale(languageTag, { + ...(region ? { region } : {}), + ...(hourCycle ? { hourCycle } : {}), + }).toString(); + } catch { + return languageTag; + } +} + /** @public */ export const make = ElectronApp.of({ metadata: Effect.gen(function* () { @@ -122,8 +138,23 @@ export const make = ElectronApp.of({ }; }), name: Effect.sync(() => Electron.app.name), - // NSLocale returns en_GB-style tags; Intl requires hyphens. - systemLocale: Effect.sync(() => Electron.app.getSystemLocale().replace(/_/g, "-")), + systemLocale: Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + let identifier = Electron.app.getSystemLocale(); + let hourCycle: "h12" | "h23" | undefined; + if (platform === "linux") { + const env = yield* HostProcess.Environment; + const timeLocale = (env.LC_ALL || env.LC_TIME || env.LANG)?.split(".")[0]; + if (timeLocale && timeLocale !== "C" && timeLocale !== "POSIX") identifier = timeLocale; + } else if (platform === "darwin") { + if (Electron.systemPreferences.getUserDefault("AppleICUForce24HourTime", "boolean")) { + hourCycle = "h23"; + } else if (Electron.systemPreferences.getUserDefault("AppleICUForce12HourTime", "boolean")) { + hourCycle = "h12"; + } + } + return toSystemLocaleTag(identifier, hourCycle); + }), whenReady: Effect.gen(function* () { const isPackaged = Electron.app.isPackaged; yield* Effect.tryPromise({ From 40c0b0efe41ad3b3f6828e2b40e36a66191f86a4 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:41:11 -0500 Subject: [PATCH 042/219] fix(web): warn when ctrl shortcuts collide with mod on Windows and Linux (#22496) Co-authored-by: Claude Opus 5.5 --- .../KeybindingsSettings.logic.test.ts | 34 +++++++++++++++++++ .../settings/KeybindingsSettings.logic.ts | 25 ++++++++++---- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 7db732781773c..7f57caaf04f68 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -266,4 +266,38 @@ describe("KeybindingsSettings.logic", () => { }), ).toEqual(["Chat: New Local"]); }); + + it.each([ + { platform: "Linux x86_64", ctrl: ["Chat: New"], meta: [] }, + { platform: "Win32", ctrl: ["Chat: New"], meta: [] }, + { platform: "MacIntel", ctrl: [], meta: ["Chat: New"] }, + ])("resolves mod to the $platform modifier when reporting conflicts", (expected) => { + const rows = buildKeybindingRows( + compileResolvedKeybindingsConfig([ + { command: "chat.new", key: "mod+k" }, + { command: "chat.newLocal", key: "ctrl+k" }, + { command: "thread.copyReference", key: "meta+k" }, + ]), + expected.platform, + ); + const conflictsFor = (key: string) => rows.find((row) => row.key === key)?.conflicts; + + expect(conflictsFor("ctrl+k")).toEqual(expected.ctrl); + expect(conflictsFor("meta+k")).toEqual(expected.meta); + expect( + keybindingConflictLabels(rows, { rowId: "new", key: "ctrl+k", when: "" }, expected.platform), + ).toEqual(["Chat: New Local", ...expected.ctrl].toSorted()); + }); + + it("keeps mod and ctrl shortcuts apart when their when contexts differ", () => { + const rows = buildKeybindingRows( + compileResolvedKeybindingsConfig([ + { command: "chat.new", key: "mod+k", when: "!terminalFocus" }, + { command: "chat.newLocal", key: "ctrl+k", when: "terminalFocus" }, + ]), + "Linux x86_64", + ); + + expect(rows.map((row) => row.conflicts)).toEqual([[], []]); + }); }); diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 1e75a02343829..fe9b47b4dcadc 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -8,11 +8,12 @@ import { } from "@t3tools/contracts"; import { DEFAULT_RESOLVED_KEYBINDINGS, + parseKeybindingShortcut, parseKeybindingWhenExpression, US_SHIFTED_TO_UNSHIFTED_KEYS, } from "@t3tools/shared/keybindings"; -import { shortcutKeyFromEvent } from "../../keybindings"; +import { shortcutConflictKey, shortcutKeyFromEvent } from "../../keybindings"; import { isMacPlatform } from "../../lib/utils"; import { METRIC_OPTIONS, WINDOW_OPTIONS } from "../usage/usageShortcuts"; @@ -180,13 +181,18 @@ function conflictsWithWhen(leftWhen: string, rightWhen: string): boolean { export function keybindingConflictLabels( rows: ReadonlyArray, input: { readonly rowId: string; readonly key: string; readonly when: string }, + platform = navigator.platform, ): ReadonlyArray { if (input.key.trim().length === 0) return []; + const shortcut = parseKeybindingShortcut(input.key); + if (!shortcut) return []; + // Compare resolved chords so mod+k and ctrl+k collide where mod is Ctrl. + const conflictKey = shortcutConflictKey(shortcut, platform); const conflicts: Array = []; for (const candidate of rows) { if ( candidate.id !== input.rowId && - candidate.key === input.key && + shortcutConflictKey(candidate.binding.shortcut, platform) === conflictKey && conflictsWithWhen(candidate.when, input.when) ) { conflicts.push(commandLabel(candidate.command)); @@ -242,6 +248,7 @@ export function groupKeybindingRows( export function buildKeybindingRows( keybindings: ResolvedKeybindingsConfig, + platform = navigator.platform, ): ReadonlyArray { const rowsById = new Map(); for (const binding of keybindings) { @@ -264,11 +271,15 @@ export function buildKeybindingRows( const rows = [...rowsById.values()]; const rowsWithConflicts = rows.map((row) => { - const conflicts = keybindingConflictLabels(rows, { - rowId: row.id, - key: row.key, - when: row.when, - }); + const conflicts = keybindingConflictLabels( + rows, + { + rowId: row.id, + key: row.key, + when: row.when, + }, + platform, + ); return conflicts.length > 0 ? Object.assign({}, row, { conflicts: [...new Set(conflicts)].toSorted() }) : row; From a8d6b0e66bba41ae447ea9a1f06079e6a823f2a6 Mon Sep 17 00:00:00 2001 From: Nikita Maximilian Grimm Date: Fri, 30 Oct 2026 21:41:28 +0100 Subject: [PATCH 043/219] fix(web): mouse Back/Forward over a streamed preview navigate the preview (#22522) --- .../ServerBrowserSurface.browser.test.tsx | 123 ++++++++++++++++++ apps/web/src/browser/ServerBrowserSurface.tsx | 18 +++ 2 files changed, 141 insertions(+) create mode 100644 apps/web/src/browser/ServerBrowserSurface.browser.test.tsx diff --git a/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx b/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx new file mode 100644 index 0000000000000..39f151674b648 --- /dev/null +++ b/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx @@ -0,0 +1,123 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { + PreviewStreamEvents, + PreviewStreamInput, +} from "@t3tools/client-runtime/preview/server-browser-stream"; + +import "~/index.css"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; + +const mocks = vi.hoisted(() => ({ + send: vi.fn<(input: PreviewStreamInput) => void>(), + connect: vi.fn(), + events: null as PreviewStreamEvents | null, + painted: null as (() => void) | null, +})); + +vi.mock(import("@t3tools/client-runtime/preview/server-browser-stream"), async (original) => ({ + ...(await original()), + createPreviewFramePainter: (_canvas, onPainted) => { + mocks.painted = onPainted ?? null; + return { paint: () => undefined, stop: () => undefined }; + }, + createPreviewStreamClient: (_options, events) => { + mocks.events = events; + mocks.connect(); + return { send: mocks.send, stop: () => undefined } as never; + }, +})); + +vi.mock(import("~/state/previewStream"), async (original) => ({ + ...(await original()), + usePreviewStreamAccess: () => ({}) as never, +})); + +import { ServerBrowserSurface } from "./ServerBrowserSurface"; + +async function renderSurface() { + mocks.send.mockReset(); + mocks.connect.mockReset(); + const container = document.createElement("div"); + container.style.cssText = "width: 400px; height: 300px;"; + await using stack = new AsyncDisposableStack(); + stack.use( + await render( + , + { container }, + ), + ); + const canvas = container.querySelector("canvas"); + if (!canvas) throw new Error("The surface did not render its canvas."); + // The stream connects once the surface has measured its size. + await vi.waitFor(() => expect(mocks.connect).toHaveBeenCalled()); + return Object.assign(stack.move(), { canvas }); +} + +const mouseUp = (canvas: HTMLCanvasElement, button: number) => { + const event = new MouseEvent("mouseup", { button, bubbles: true, cancelable: true }); + canvas.dispatchEvent(event); + return event; +}; + +describe("ServerBrowserSurface", () => { + it("routes mouse thumb buttons to the preview's history instead of the app", async () => { + await using surface = await renderSurface(); + const { canvas } = surface; + + expect(mouseUp(canvas, 3).defaultPrevented).toBe(true); + expect(mouseUp(canvas, 4).defaultPrevented).toBe(true); + expect(mocks.send.mock.calls).toEqual([ + [{ type: "history", delta: -1 }], + [{ type: "history", delta: 1 }], + ]); + }); + + it("leaves other mouse buttons alone", async () => { + await using surface = await renderSurface(); + const { canvas } = surface; + + expect(mouseUp(canvas, 0).defaultPrevented).toBe(false); + expect(mocks.send).not.toHaveBeenCalled(); + }); + + it("does not forward thumb-button presses to the page", async () => { + await using surface = await renderSurface(); + const { canvas } = surface; + canvas.width = 400; + canvas.height = 300; + // Synthetic pointers have no active pointer id to capture. + canvas.setPointerCapture = () => undefined; + mocks.painted?.(); + mocks.events?.onViewport({ width: 400, height: 300 }); + mocks.events?.onControl?.({ canOperate: true, controller: "you", generation: 1, dialog: null }); + const { left, top } = canvas.getBoundingClientRect(); + const press = (type: "pointerdown" | "pointerup", button: number) => + canvas.dispatchEvent( + new PointerEvent(type, { + button, + buttons: type === "pointerdown" ? 1 << button : 0, + clientX: left + 10, + clientY: top + 10, + pointerType: "mouse", + bubbles: true, + }), + ); + const mouseInputs = () => mocks.send.mock.calls.filter(([input]) => input.type === "mouse"); + + for (const button of [3, 4]) { + press("pointerdown", button); + press("pointerup", button); + } + expect(mouseInputs()).toEqual([]); + + press("pointerdown", 0); + expect(mouseInputs()).not.toEqual([]); + }); +}); diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index c6ad2f51c3f31..3b14dcb5f3c78 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -22,6 +22,7 @@ import type { import { type FormEvent, type KeyboardEvent, + type MouseEvent, type PointerEvent, type Ref, useEffect, @@ -94,6 +95,10 @@ const buttonOf = (button: number): PreviewStreamMouseButton => const pressedButtonOf = (buttons: number): PreviewStreamMouseButton => buttons & 1 ? "left" : buttons & 2 ? "right" : buttons & 4 ? "middle" : "none"; +// Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward. +const historyDeltaOf = (button: number): -1 | 1 | null => + button === 3 ? -1 : button === 4 ? 1 : null; + /** Browsers may refuse a write that arrives after the key press; a toast button is a fresh gesture. */ async function copyPageText(text: string) { try { @@ -490,6 +495,8 @@ export function ServerBrowserSurface(props: { const handlePointer = (event: PointerEvent) => { if (controlRef.current?.controller !== "you") return; + // Thumb buttons drive this tab's history in handleMouseUp, never the page. + if (historyDeltaOf(event.button) !== null) return; const { type, pointerId, clientX, clientY } = event; const cancelled = type === "pointercancel"; const touch = touchRef.current; @@ -626,6 +633,16 @@ export function ServerBrowserSurface(props: { } }; + // Chromium runs a thumb button's back/forward on mouseup in this document, which + // would move the app's router. Cancel it and send the press to the preview, as + // the toolbar does. + const handleMouseUp = (event: MouseEvent) => { + const delta = historyDeltaOf(event.button); + if (delta === null) return; + event.preventDefault(); + send({ type: "history", delta }); + }; + const resetInput = (textarea: HTMLTextAreaElement) => { keySentRef.current = false; textarea.value = INPUT_SENTINEL; @@ -703,6 +720,7 @@ export function ServerBrowserSurface(props: {
Date: Sat, 31 Oct 2026 02:11:44 +0530 Subject: [PATCH 044/219] fix(server): the suggested t3 command names this install, not another t3 on PATH (#22639) Co-authored-by: Claude Sonnet 5.5 Co-authored-by: maria-rcks --- apps/server/src/cli/invocation.test.ts | 8 ++++++++ apps/server/src/cli/invocation.ts | 15 ++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index f23b96063a576..cd00f6ba1e408 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -71,7 +71,15 @@ it.layer(NodeServices.layer)("root CLI commands", (it) => { expect(yield* rootCommand(desktop)).toBe(`sudo '${shim}' browser setup`); + // An unrelated `t3` on PATH, such as an older npm global, is another install's CLI. expect(yield* rootCommand({ ...desktop, path: yield* pathWithT3 })).toBe( + `sudo '${shim}' browser setup`, + ); + + // The link the app installs onto PATH leads to its own launcher, so plain `t3` is right. + const linked = yield* fs.makeTempDirectoryScoped(); + yield* fs.symlink(shim, path.join(linked, "t3")); + expect(yield* rootCommand({ ...desktop, path: linked })).toBe( 'sudo env "PATH=$PATH" t3 browser setup', ); diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index 5ee3c58a68688..dbff3add054f7 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -6,7 +6,7 @@ import * as Schema from "effect/Schema"; import type { ServerInstallation } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { isCommandAvailable } from "@t3tools/shared/shell"; +import { resolveCommandPath } from "@t3tools/shared/shell"; import packageJson from "../../package.json" with { type: "json" }; @@ -143,7 +143,7 @@ const resolveInstallLauncher = Effect.gen(function* () { }); /** - * Uses `t3` on PATH, the originating package runner, or this install's absolute launcher path. + * Uses this install's `t3` on PATH, the originating runner, or its absolute launcher path. * Arguments are quoted for POSIX shells or PowerShell on Windows. */ export const resolveHostCliCommand = Effect.fn("invocation.resolveHostCliCommand")(function* ( @@ -155,7 +155,16 @@ export const resolveHostCliCommand = Effect.fn("invocation.resolveHostCliCommand [prefix, ...args.map((arg) => hostShellWord(platform, arg))].join(" "); const command = yield* resolveCliCommand(subcommand); if (command !== `t3 ${subcommand}`) return { command: withArgs(command), launcher: false }; - if (yield* isCommandAvailable("t3")) return { command: withArgs(command), launcher: false }; + const onPath = yield* resolveCommandPath("t3"); + if (onPath !== null) { + const shim = (yield* HostProcess.Environment).T3CODE_CLI_PATH?.trim(); + const fs = yield* FileSystem.FileSystem; + const real = (file: string) => fs.realPath(file).pipe(Effect.orElseSucceed(() => null)); + const shimReal = shim ? yield* real(shim) : null; + if (shimReal === null || shimReal === (yield* real(onPath))) { + return { command: withArgs(command), launcher: false }; + } + } const launcher = yield* resolveInstallLauncher; if (Option.isNone(launcher)) return { command: withArgs(command), launcher: false }; // PowerShell runs a quoted path only through the call operator. From e94c3a72ca25bf5f442d5c896752b6685ed10ebf Mon Sep 17 00:00:00 2001 From: Rookmate <14072042+rookmate@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:42:01 +0400 Subject: [PATCH 045/219] fix(server): streamed clicks no longer wait behind stale hovers (#22739) --- apps/server/src/preview/ServerBrowser.test.ts | 127 ++++++++++++ apps/server/src/preview/ServerBrowser.ts | 14 +- .../server/src/preview/SessionControl.test.ts | 187 ++++++++++++++++++ apps/server/src/preview/SessionControl.ts | 23 +++ 4 files changed, 348 insertions(+), 3 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 3cf6d25383278..1051514a053b4 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -977,6 +977,133 @@ it.live( ).pipe(Effect.provide(layer)), ); +it.effect.each([ + { + name: "hover burst before a click", + inputs: [ + ...Array.from({ length: 120 }, (_, x) => ({ + type: "mouse", + action: "move", + x, + buttons: 0, + button: "none", + })), + { type: "mouse", action: "down", x: 119, buttons: 1, button: "left" }, + { type: "mouse", action: "up", x: 119, buttons: 0, button: "left" }, + ], + expected: [ + { type: "mouseMoved", x: -1 }, + { type: "mouseMoved", x: 119 }, + { type: "mousePressed", x: 119 }, + { type: "mouseReleased", x: 119 }, + ], + }, + { + name: "modifier changes and drag points", + inputs: [ + { type: "mouse", action: "move", x: 1, buttons: 0, button: "none" }, + { type: "mouse", action: "move", x: 2, buttons: 0, button: "none", modifiers: 2 }, + { type: "mouse", action: "move", x: 3, buttons: 0, button: "none", modifiers: 2 }, + { type: "mouse", action: "down", x: 3, buttons: 1, button: "left" }, + { type: "mouse", action: "move", x: 4, buttons: 1, button: "left" }, + { type: "mouse", action: "move", x: 5, buttons: 1, button: "left" }, + { type: "mouse", action: "up", x: 5, buttons: 0, button: "left" }, + ], + expected: [ + { type: "mouseMoved", x: -1 }, + { type: "mouseMoved", x: 1, modifiers: 0 }, + { type: "mouseMoved", x: 3, modifiers: 2 }, + { type: "mousePressed", x: 3 }, + { type: "mouseMoved", x: 4 }, + { type: "mouseMoved", x: 5 }, + { type: "mouseReleased", x: 5 }, + ], + }, + { + name: "wheel and keyboard boundaries", + inputs: [ + { type: "mouse", action: "move", x: 1, buttons: 0, button: "none" }, + { type: "wheel", x: 1, deltaY: 20 }, + { type: "wheel", x: 1, deltaY: 30 }, + { type: "mouse", action: "move", x: 2, buttons: 0, button: "none" }, + { type: "key", action: "down", key: "a", code: "KeyA", text: "a" }, + { type: "mouse", action: "move", x: 3, buttons: 0, button: "none" }, + ], + expected: [ + { type: "mouseMoved", x: -1 }, + { type: "mouseMoved", x: 1 }, + { type: "mouseWheel", deltaY: 20 }, + { type: "mouseWheel", deltaY: 30 }, + { type: "mouseMoved", x: 2 }, + { type: "keyDown", key: "a" }, + { type: "mouseMoved", x: 3 }, + ], + }, + { + name: "text, navigation and resize boundaries", + inputs: [ + { type: "mouse", action: "move", x: 1, buttons: 0, button: "none" }, + { type: "text", text: "typed" }, + { type: "mouse", action: "move", x: 2, buttons: 0, button: "none" }, + { type: "navigate", url: "http://localhost:5173/next" }, + { type: "mouse", action: "move", x: 3, buttons: 0, button: "none" }, + { type: "resize", width: 640, height: 480 }, + { type: "mouse", action: "move", x: 4, buttons: 0, button: "none" }, + ], + expected: [ + { type: "mouseMoved", x: -1 }, + { type: "mouseMoved", x: 1 }, + { text: "typed" }, + { type: "mouseMoved", x: 2 }, + { type: "mouseMoved", x: 3 }, + { type: "mouseMoved", x: 4 }, + ], + }, + { + name: "ambiguous button fields", + inputs: [ + { type: "mouse", action: "move", x: 1, button: "none" }, + { type: "mouse", action: "move", x: 2, button: "none" }, + { type: "mouse", action: "move", x: 3, buttons: 0 }, + { type: "mouse", action: "move", x: 4, buttons: 0 }, + ], + expected: [-1, 1, 2, 3, 4].map((x) => ({ type: "mouseMoved", x })), + }, +])("orders streamed input correctly for $name", (testCase) => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + const session = contexts[0]!.sessions.at(-1)!; + const send = session.send.getMockImplementation()!; + const started = Promise.withResolvers(); + const finish = Promise.withResolvers(); + yield* Effect.addFinalizer(() => Effect.sync(() => finish.resolve())); + session.send.mockClear(); + session.send.mockImplementationOnce(async (method, input) => { + started.resolve(); + await finish.promise; + return send(method, input); + }); + const running = yield* viewer + .input({ type: "mouse", action: "move", x: -1, button: "none", buttons: 0 }) + .pipe(Effect.forkScoped({ startImmediately: true })); + yield* Effect.promise(() => started.promise); + const waiting = yield* Effect.forEach(testCase.inputs, (input) => + viewer.input(input).pipe(Effect.forkScoped({ startImmediately: true })), + ); + finish.resolve(); + yield* Effect.forEach([running, ...waiting], Fiber.join); + const dispatched = session.send.mock.calls.flatMap(([method, input]) => + method.startsWith("Input.") ? [input] : [], + ); + expect(dispatched).toHaveLength(testCase.expected.length); + expect(dispatched).toMatchObject(testCase.expected); + }), + ).pipe(Effect.provide(layer)), +); + it.live("only the controlling viewer's copy and cut keys reach its clipboard", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 0e52b5066c027..61caa1069bb7a 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -2550,9 +2550,17 @@ const make = Effect.gen(function* () { : {}), }); } else { - await tab.control.human(viewer.id, () => - dispatchViewerInput(tab, session, viewer, message), - ); + const dispatch = () => dispatchViewerInput(tab, session, viewer, message); + if ( + message.type === "mouse" && + message.action === "move" && + message.button === "none" && + message.buttons === 0 + ) { + await tab.control.humanLatest(viewer.id, `hover:${modifiersOf(message)}`, dispatch); + } else { + await tab.control.human(viewer.id, dispatch); + } } } catch { broadcastControl(tab); diff --git a/apps/server/src/preview/SessionControl.test.ts b/apps/server/src/preview/SessionControl.test.ts index a5971f010a57e..e086ad854b3fb 100644 --- a/apps/server/src/preview/SessionControl.test.ts +++ b/apps/server/src/preview/SessionControl.test.ts @@ -3,6 +3,193 @@ import { describe, expect, it } from "vite-plus/test"; import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts"; describe("SessionControl", () => { + it("replaces waiting hover work without changing the running action or delaying the following click", async () => { + const control = new SessionControl("agent"); + await control.take("viewer"); + const started = Promise.withResolvers(); + const finish = Promise.withResolvers(); + const events: Array = []; + const running = control.humanLatest("viewer", "hover:0", async () => { + events.push("running"); + started.resolve(); + await finish.promise; + }); + await started.promise; + const moves = Array.from({ length: 120 }, (_, position) => + control.humanLatest("viewer", "hover:0", async () => { + events.push(position); + }), + ); + const click = control.human("viewer", async () => { + events.push("click"); + }); + finish.resolve(); + await Promise.all([running, ...moves, click]); + expect(events).toEqual(["running", 119, "click"]); + }); + + it.each(["human", "system", "modifiers"] as const)( + "does not merge waiting hover work across a %s action", + async (barrier) => { + const control = new SessionControl("agent"); + await control.take("viewer"); + const finish = Promise.withResolvers(); + const events: string[] = []; + const running = control.human("viewer", () => finish.promise); + const before = control.humanLatest("viewer", "hover:0", async () => { + events.push("before"); + }); + const recordBarrier = async () => { + events.push("barrier"); + }; + const between = + barrier === "modifiers" + ? control.humanLatest("viewer", "hover:2", recordBarrier) + : barrier === "system" + ? control.system(recordBarrier) + : control.human("viewer", recordBarrier); + const after = control.humanLatest("viewer", "hover:0", async () => { + events.push("after"); + }); + finish.resolve(); + await Promise.all([running, before, between, after]); + expect(events).toEqual(["before", "barrier", "after"]); + }, + ); + + it("does not let another viewer replace or split the owner's waiting hover", async () => { + const control = new SessionControl("agent"); + await control.take("viewer"); + const finish = Promise.withResolvers(); + const events: string[] = []; + const running = control.human("viewer", () => finish.promise); + const stale = control.humanLatest("viewer", "hover", async () => { + events.push("stale"); + }); + await expect( + control.humanLatest("other", "hover", async () => { + events.push("other"); + }), + ).rejects.toThrow(BrowserControlInterrupted); + const latest = control.humanLatest("viewer", "hover", async () => { + events.push("latest"); + }); + finish.resolve(); + await Promise.all([running, stale, latest]); + expect(events).toEqual(["latest"]); + }); + + it.each(["release", "disconnect"] as const)( + "does not revive waiting hover work after %s and retaking control", + async (handoff) => { + const control = new SessionControl("agent"); + await control.take("viewer"); + const started = Promise.withResolvers(); + const finish = Promise.withResolvers(); + const events: string[] = []; + const running = control.human("viewer", () => { + started.resolve(); + return finish.promise; + }); + await started.promise; + const stale = expect( + control.humanLatest("viewer", "hover", async () => { + events.push("stale"); + }), + ).rejects.toThrow(BrowserControlInterrupted); + const released = control[handoff]("viewer"); + const retaken = control.take("viewer"); + const fresh = control.humanLatest("viewer", "hover", async () => { + events.push("fresh"); + }); + const latest = control.humanLatest("viewer", "hover", async () => { + events.push("latest"); + }); + finish.resolve(); + await Promise.all([running, stale, released, retaken, fresh, latest]); + expect(events).toEqual(["latest"]); + }, + ); + + it("rejects every caller of a merged hover when the tab closes", async () => { + const control = new SessionControl("agent"); + await control.take("viewer"); + const started = Promise.withResolvers(); + const finish = Promise.withResolvers(); + const events: string[] = []; + const running = control.human("viewer", () => { + started.resolve(); + return finish.promise; + }); + await started.promise; + const waiting = [1, 2].map((position) => + expect( + control.humanLatest("viewer", "hover", async () => { + events.push(`hover ${position}`); + }), + ).rejects.toThrow("closed"), + ); + const closed = control.close(); + await expect( + control.humanLatest("viewer", "hover", async () => { + events.push("after close"); + }), + ).rejects.toThrow("closed"); + finish.resolve(); + await Promise.all([running, ...waiting, closed]); + expect(events).toEqual([]); + }); + + it("shares a merged dispatch failure and accepts fresh hover work afterwards", async () => { + const control = new SessionControl("agent"); + await control.take("viewer"); + const finish = Promise.withResolvers(); + const events: string[] = []; + const running = control.human("viewer", () => finish.promise); + const first = expect( + control.humanLatest("viewer", "hover", async () => { + events.push("superseded"); + }), + ).rejects.toThrow("dispatch failed"); + const last = expect( + control.humanLatest("viewer", "hover", async () => { + throw new Error("dispatch failed"); + }), + ).rejects.toThrow("dispatch failed"); + finish.resolve(); + await Promise.all([running, first, last]); + await control.humanLatest("viewer", "hover", async () => { + events.push("recovered"); + }); + expect(events).toEqual(["recovered"]); + }); + + it("waits for tracked navigation before dispatching the latest human hover", async () => { + const control = new SessionControl("agent"); + await control.take("viewer"); + const committed = Promise.withResolvers(); + const events: string[] = []; + const navigation = control.human("viewer", async () => { + control.track( + committed.promise.then(() => { + events.push("navigation committed"); + }), + ); + return "started"; + }); + const first = control.humanLatest("viewer", "hover", async () => { + events.push("superseded"); + }); + const latest = control.humanLatest("viewer", "hover", async () => { + events.push("latest"); + }); + await expect(navigation).resolves.toBe("started"); + expect(events).toEqual([]); + committed.resolve(); + await Promise.all([first, latest]); + expect(events).toEqual(["navigation committed", "latest"]); + }); + it("returns an action result before tracked navigation but drains it before already queued actions", async () => { const control = new SessionControl("agent"); const committed = Promise.withResolvers(); diff --git a/apps/server/src/preview/SessionControl.ts b/apps/server/src/preview/SessionControl.ts index de02f32a1f774..cff65d3379a02 100644 --- a/apps/server/src/preview/SessionControl.ts +++ b/apps/server/src/preview/SessionControl.ts @@ -22,6 +22,10 @@ export class SessionControl { readonly agentId: string | null; private readonly onGenerationChange: () => void; private tail: Promise = Promise.resolve(); + private latestHuman: { + entry: { viewerId: string; key: string; run: () => Promise }; + result: Promise; + } | null = null; private pending: Promise = Promise.resolve(); private readonly handoff = new Set>(); private owner: string | null = null; @@ -42,6 +46,7 @@ export class SessionControl { } private enqueue(run: () => Promise): Promise { + this.latestHuman = null; const result = this.tail.then(run); this.tail = result.then( () => this.pending, @@ -68,6 +73,7 @@ export class SessionControl { } private changeOwner(owner: string | null) { + this.latestHuman = null; this.owner = owner; this.epoch++; this.onGenerationChange(); @@ -128,6 +134,23 @@ export class SessionControl { return this.action(() => this.owner === viewerId, run); } + /** Replace only adjacent waiting work; the running action and intervening actions keep their order. */ + humanLatest(viewerId: string, key: string, run: () => Promise): Promise { + if (this.closed || this.owner !== viewerId) return this.human(viewerId, run); + const latest = this.latestHuman; + if (latest?.entry.viewerId === viewerId && latest.entry.key === key) { + latest.entry.run = run; + return latest.result; + } + const entry = { viewerId, key, run }; + const result = this.human(viewerId, async () => { + if (this.latestHuman?.entry === entry) this.latestHuman = null; + await entry.run(); + }); + this.latestHuman = { entry, result }; + return result; + } + async take(viewerId: string) { this.assertOpen(); if (this.owner !== null && this.owner !== viewerId) { From 3776dfb4ea6a037b2941acb0f906a3db5fa5bd40 Mon Sep 17 00:00:00 2001 From: "jooyyy.eth" Date: Sat, 31 Oct 2026 04:42:20 +0800 Subject: [PATCH 046/219] fix(connect): a quit mid-download no longer wedges the relay client install (#22747) Co-authored-by: Cursor Co-authored-by: maria-rcks --- packages/shared/src/relayClient.test.ts | 144 +++++++++++++++++++++++- packages/shared/src/relayClient.ts | 89 +++++++++++++-- 2 files changed, 215 insertions(+), 18 deletions(-) diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 0a987832ff749..d2719c9ac33ff 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -1,6 +1,7 @@ import { sha256 } from "@noble/hashes/sha2"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -8,9 +9,11 @@ import * as Fiber from "effect/Fiber"; import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as HostProcess from "./HostProcess.ts"; @@ -156,12 +159,12 @@ describe("RelayClient", () => { const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }).pipe( Effect.provideService(FileSystem.FileSystem, { ...fileSystem, - writeFileString: (path, contents, options) => + rename: (from, to) => fileSystem - .writeFileString(path, contents, options) + .rename(from, to) .pipe( Effect.tapError(() => - path === lockPath ? Deferred.succeed(contended, undefined) : Effect.void, + to === lockPath ? Deferred.succeed(contended, undefined) : Effect.void, ), ), }), @@ -183,6 +186,135 @@ describe("RelayClient", () => { ), ); + it.effect.each(["live owner", "recent legacy"] as const)("keeps a lock with a %s", (kind) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cloudflared-test-" }); + const lockPath = `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/linux-x64/cloudflared.lock`; + const owner = `${process.pid}-still-running`; + yield* fileSystem.makeDirectory(lockPath.slice(0, lockPath.lastIndexOf("/")), { + recursive: true, + }); + if (kind === "live owner") { + yield* fileSystem.makeDirectory(lockPath); + yield* fileSystem.writeFileString(`${lockPath}/${owner}`, ""); + } else { + yield* fileSystem.writeFileString(lockPath, ""); + } + const written = yield* fileSystem.stat(lockPath); + yield* TestClock.setTime( + Option.getOrThrow(written.mtime).getTime() + (kind === "live owner" ? 3_600_000 : 6_000), + ); + const waiting = yield* Deferred.make(); + const clock = yield* Clock.Clock; + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: testReleaseAsset, + }); + const installing = yield* manager.install.pipe( + Effect.provideService(Clock.Clock, { + ...clock, + sleep: () => Deferred.succeed(waiting, undefined).pipe(Effect.andThen(Effect.never)), + }), + Effect.forkChild, + ); + yield* Deferred.await(waiting); + yield* Fiber.interrupt(installing); + if (kind === "live owner") { + expect(yield* fileSystem.readDirectory(lockPath)).toEqual([owner]); + } else { + expect(yield* fileSystem.readFileString(lockPath)).toBe(""); + } + }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("linux"))), + ); + + it.effect.skipIf(windowsHost)("replaces an install lock left by an exited installer", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const lockPath = `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/linux-x64/cloudflared.lock`; + yield* fileSystem.makeDirectory(lockPath.slice(0, lockPath.lastIndexOf("/")), { + recursive: true, + }); + yield* fileSystem.makeDirectory(lockPath); + yield* fileSystem.writeFileString(`${lockPath}/2147483647-exited-installer`, ""); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: testReleaseAsset, + }); + + expect(yield* manager.install).toMatchObject({ status: "available" }); + expect(yield* fileSystem.exists(lockPath)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("linux"))), + ); + + it.effect.skipIf(windowsHost)("replaces an empty install lock left by an exited installer", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const lockPath = `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/linux-x64/cloudflared.lock`; + yield* fileSystem.makeDirectory(lockPath.slice(0, lockPath.lastIndexOf("/")), { + recursive: true, + }); + yield* fileSystem.writeFileString(lockPath, ""); + const written = yield* fileSystem.stat(lockPath); + const mtime = Option.getOrThrow(written.mtime).getTime(); + // File mtimes use wall time; advance the test clock past legacy expiry. + yield* TestClock.setTime(mtime + 60 * 60 * 1_000); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: testReleaseAsset, + }); + + expect(yield* manager.install).toMatchObject({ status: "available" }); + expect(yield* fileSystem.exists(lockPath)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("linux"))), + ); + + it.effect("keeps a replacement lock when a stale contender resumes cleanup", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cloudflared-test-" }); + const lockPath = `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/linux-x64/cloudflared.lock`; + const abandoned = "2147483647-exited-installer"; + const replacement = `${process.pid}-replacement`; + yield* fileSystem.makeDirectory(lockPath, { recursive: true }); + yield* fileSystem.writeFileString(`${lockPath}/${abandoned}`, ""); + const checked = yield* Deferred.make(); + let replaced = false; + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + readDirectory: (path) => + fileSystem.readDirectory(path).pipe( + Effect.tap((entries) => + Effect.gen(function* () { + if (path !== lockPath) return; + if (replaced) { + expect(entries).toEqual([replacement]); + yield* Deferred.succeed(checked, undefined); + return; + } + replaced = true; + yield* fileSystem.rename(lockPath, `${lockPath}.old`); + yield* fileSystem.makeDirectory(lockPath); + yield* fileSystem.writeFileString(`${lockPath}/${replacement}`, ""); + }), + ), + ), + }), + ); + const installing = yield* manager.install.pipe(Effect.forkChild); + yield* Deferred.await(checked); + yield* Fiber.interrupt(installing); + expect(yield* fileSystem.readDirectory(lockPath)).toEqual([replacement]); + }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("linux"))), + ); + it.effect("releases the install lock when a download is cancelled", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -232,12 +364,12 @@ describe("RelayClient", () => { }).pipe( Effect.provideService(FileSystem.FileSystem, { ...fileSystem, - writeFileString: (path, contents, options) => + rename: (from, to) => fileSystem - .writeFileString(path, contents, options) + .rename(from, to) .pipe( Effect.tap(() => - path === lockPath && pauseWrite + to === lockPath && pauseWrite ? Deferred.succeed(acquired, undefined).pipe( Effect.andThen(Deferred.await(completeWrite)), ) diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 5933ea7e6a09c..e56ef42ee1a4d 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- FileSystem.remove cannot remove an empty directory nonrecursively. +import * as NodeFSP from "node:fs/promises"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import type { @@ -16,7 +18,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; +import type * as PlatformError from "effect/PlatformError"; import * as Schedule from "effect/Schedule"; import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; @@ -115,7 +117,8 @@ const CLOUDFLARED_RELEASE_ASSETS: Readonly< const INSTALL_LOCK_RETRY_COUNT = 100; const INSTALL_LOCK_RETRY_DELAY = "100 millis"; -const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; +// Legacy locks have no owner; allow both ten-minute download stages and the install tail. +const INSTALL_LOCK_LEGACY_STALE_MS = 30 * 60 * 1_000; const VERSION_PROBE_TIMEOUT = "10 seconds"; const ACTIVATE_RETRY_COUNT = 40; @@ -194,8 +197,30 @@ class VersionProbeKey extends Data.Class<{ readonly mtimeMillis: number; }> {} -function isAlreadyExists(error: PlatformError.PlatformError): boolean { - return error.reason._tag === "AlreadyExists"; +function isLockContention(error: PlatformError.PlatformError): boolean { + const code = (error.reason.cause as NodeJS.ErrnoException | undefined)?.code; + return ( + error.reason._tag === "AlreadyExists" || + error.reason._tag === "BadResource" || + code === "ENOTEMPTY" || + isWindowsRenameLock(error) + ); +} + +function parseLockOwnerPid(owner: string): number | null { + const separator = owner.indexOf("-"); + if (separator <= 0) return null; + const pid = Number(owner.slice(0, separator)); + return Number.isSafeInteger(pid) && pid > 0 ? pid : null; +} + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as { readonly code?: unknown }).code !== "ESRCH"; + } } const wrapInstallFailure = @@ -456,25 +481,65 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function return bytes; }); + const removeEmptyLock = (lockPath: string) => + Effect.tryPromise(() => NodeFSP.rmdir(lockPath)).pipe(Effect.ignore); + + const releaseOwnedLock = Effect.fn("cloudflared.releaseOwnedLock")(function* ( + lockPath: string, + owner: string, + ) { + yield* fileSystem.remove(path.join(lockPath, owner), { force: true }).pipe(Effect.ignore); + // A replacement contains a different owner entry, so rmdir cannot remove it. + yield* removeEmptyLock(lockPath); + }); + const acquireInstallLock = Effect.fn("cloudflared.acquireInstallLock")(function* ( lockPath: string, ) { + const owner = `${process.pid}-${yield* crypto.randomUUIDv4()}`; + const candidate = yield* Effect.acquireRelease( + fileSystem.makeTempDirectory({ directory: path.dirname(lockPath), prefix: ".lock-" }), + (directory) => + fileSystem.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore), + ); + yield* fileSystem.writeFileString(path.join(candidate, owner), ""); for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) { const acquired = yield* Effect.acquireRelease( - fileSystem.writeFileString(lockPath, "", { flag: "wx" }), - () => fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore), + // Publish a populated directory atomically, with no ownerless acquisition window. + fileSystem.rename(candidate, lockPath), + () => releaseOwnedLock(lockPath, owner), ).pipe( Effect.as(true), - Effect.catchIf(isAlreadyExists, () => Effect.succeed(false)), + Effect.catchIf(isLockContention, () => Effect.succeed(false)), ); if (acquired) return; - const now = yield* Clock.currentTimeMillis; const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option); - const mtime = Option.flatMap(lockInfo, (info) => info.mtime); - if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_STALE_MS) { - yield* fileSystem.remove(lockPath, { force: true }); - continue; + if (Option.isNone(lockInfo)) continue; + if (lockInfo.value.type === "Directory") { + const owners = yield* fileSystem + .readDirectory(lockPath) + .pipe(Effect.orElseSucceed(() => [])); + let recovered = owners.length === 0; + for (const entry of owners) { + const pid = parseLockOwnerPid(entry); + if (pid !== null && !processIsAlive(pid)) { + yield* releaseOwnedLock(lockPath, entry); + recovered = true; + } + } + if (recovered) { + yield* removeEmptyLock(lockPath); + continue; + } + } else { + const now = yield* Clock.currentTimeMillis; + const mtime = lockInfo.value.mtime; + if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_LEGACY_STALE_MS) { + // Nonrecursive removal also refuses a populated directory that replaced this file. + yield* fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore); + continue; + } } yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY); } From 6b276f5c40867c331002416bcd3f1669f3ecfdef Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Fri, 30 Oct 2026 13:42:37 -0700 Subject: [PATCH 047/219] fix(web): project removal says provider sessions are kept (#22867) Co-authored-by: maria-rcks --- .../components/settings/ProjectSettingsPanel.logic.test.ts | 4 ++-- packages/client-runtime/src/state/projectRemoval.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts index 52d4fe6c2f393..6f8ef830daf5c 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts @@ -29,7 +29,7 @@ describe("project removal confirmation", () => { }); expect(plan.projectThreads).toHaveLength(1); expect(plan.confirmationMessage).toBe( - 'Remove checkout "Repo" and delete its 1 thread?\nPath: /work/repo\nEnvironment: Local\nThis permanently clears conversation history for those threads and any archived threads.\nOther entries in this grouped project are unaffected.\nThis action cannot be undone.', + 'Remove checkout "Repo" and delete its 1 thread?\nPath: /work/repo\nEnvironment: Local\nThose threads and any archived threads are deleted from T3 Code. Provider sessions are kept.\nOther entries in this grouped project are unaffected.\nThis action cannot be undone.', ); }); @@ -47,7 +47,7 @@ describe("project removal confirmation", () => { "Other entries in this grouped project are unaffected.", ); expect(plan.confirmationMessage).toContain( - "This permanently clears any archived conversation history.", + "Any archived threads are deleted from T3 Code. Provider sessions are kept.", ); }); diff --git a/packages/client-runtime/src/state/projectRemoval.ts b/packages/client-runtime/src/state/projectRemoval.ts index 157c0901b1240..09c65a772e689 100644 --- a/packages/client-runtime/src/state/projectRemoval.ts +++ b/packages/client-runtime/src/state/projectRemoval.ts @@ -38,8 +38,10 @@ export function planProjectRemoval< ] : [`This removes ${members.length} grouped project entries.`]), ...(threadCount > 0 - ? ["This permanently clears conversation history for those threads and any archived threads."] - : ["This permanently clears any archived conversation history."]), + ? [ + "Those threads and any archived threads are deleted from T3 Code. Provider sessions are kept.", + ] + : ["Any archived threads are deleted from T3 Code. Provider sessions are kept."]), isWholeGroup && !hasOtherMembers ? "This removes only the project entries, not the files on disk." : "Other entries in this grouped project are unaffected.", From 9ceb949a018fe4540b5fb67eea3d49f71ed47811 Mon Sep 17 00:00:00 2001 From: Nikita Maximilian Grimm Date: Fri, 30 Oct 2026 21:43:04 +0100 Subject: [PATCH 048/219] fix(web): copied Markdown tables keep formula pipes (#22875) --- apps/web/src/markdown-clipboard.test.ts | 98 ++++++++++++++++++++++++- apps/web/src/markdown-clipboard.ts | 83 ++++++++++++--------- 2 files changed, 147 insertions(+), 34 deletions(-) diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index f40f176d37d48..b75291d47f769 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -1,11 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { serializeRenderedMarkdownFragment } from "./markdown-clipboard"; +import { + serializeRenderedMarkdownFragment, + serializeTableElementToMarkdown, +} from "./markdown-clipboard"; import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { collectAssistantCitations, serializeAssistantCitation, } from "@t3tools/shared/assistantCitations"; +import { markdownMathRanges, parseMarkdownWithMath } from "@t3tools/shared/markdownMath"; const TEXT_NODE = 3; const ELEMENT_NODE = 1; @@ -95,6 +99,22 @@ class FakeElement { }; return search(this); } + + /** Supports the `:scope > a > b` child paths the table serializer queries. */ + querySelectorAll(selector: string): FakeElement[] { + return selector.split(", ").flatMap((part) => + part + .replace(/^:scope > /, "") + .split(" > ") + .reduce( + (level, tag) => + level.flatMap((element) => + element.children.filter((child) => child.tagName === tag.toUpperCase()), + ), + [this], + ), + ); + } } function asNode(element: FakeElement): Node { @@ -198,3 +218,79 @@ describe("serializeRenderedMarkdownFragment", () => { ); }); }); + +function renderedFormula(tex: string): FakeElement { + // MarkdownMath's wrapper; its children stand in for KaTeX's glyphs. + return new FakeElement("SPAN", [], { + "data-markdown-math": "inline", + "data-markdown-copy": `\\(${tex}\\)`, + }).append(new FakeText(tex)); +} + +function renderedTable(...rows: Array>): FakeElement { + const row = (tag: string, cells: Array) => + new FakeElement("TR").append(...cells.map((cell) => new FakeElement(tag).append(cell))); + const [head = [], ...body] = rows; + return new FakeElement("TABLE").append( + new FakeElement("THEAD").append(row("TH", head)), + new FakeElement("TBODY").append(...body.map((cells) => row("TD", cells))), + ); +} + +describe("copying rendered Markdown tables", () => { + beforeEach(() => { + vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps formula pipes as authored while escaping prose pipes", () => { + const table = renderedTable( + [new FakeText("Formula"), new FakeText("Note")], + [renderedFormula("|x|"), new FakeText("a | b")], + [renderedFormula("\\|x\\|"), new FakeElement("CODE").append(new FakeText("\\(c|d\\)"))], + [ + new FakeElement("A", [], { href: "https://example.com/?q=a|b" }).append( + new FakeText("norm "), + renderedFormula("|y|"), + ), + new FakeText("e"), + ], + [ + new FakeElement("UL").append( + new FakeElement("LI").append(renderedFormula("|z|"), new FakeText(" a | b")), + ), + new FakeElement("BLOCKQUOTE").append(new FakeElement("P").append(new FakeText("c | d"))), + ], + ); + const markdown = serializeTableElementToMarkdown(table as unknown as Element); + + const tree = parseMarkdownWithMath(markdown); + const parsed = tree.children[0]; + if (parsed?.type !== "table") throw new Error("Expected a table"); + expect(parsed.children.map((row) => row.children.length)).toEqual([2, 2, 2, 2, 2]); + expect(markdownMathRanges(markdown, tree).map((formula) => formula.tex)).toEqual([ + "|x|", + "\\|x\\|", + "|y|", + "|z|", + ]); + expect(parsed.children[1]?.children[1]?.children).toMatchObject([ + { type: "text", value: "a | b" }, + ]); + expect(parsed.children[4]?.children[1]?.children).toMatchObject([ + { type: "text", value: "> c | d" }, + ]); + const [, , codeRow, linkRow] = parsed.children; + expect(codeRow?.children[1]?.children[0]).toMatchObject({ + type: "inlineCode", + value: "\\(c|d\\)", + }); + expect(linkRow?.children[0]?.children[0]).toMatchObject({ + type: "link", + url: "https://example.com/?q=a|b", + }); + }); +}); diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index a251b1f330809..62bbde6d71667 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -76,7 +76,7 @@ function serializeCodeBlock(pre: Element): string { } function serializeTableCell(cell: Element): string { - return serializeChildren(cell).replace(/\n+/g, " ").trim().replaceAll("|", "\\|"); + return serializeChildren(cell, true).replace(/\n+/g, " ").trim(); } function tableSeparatorFor(headerCells: Element[]): string { @@ -108,14 +108,19 @@ function serializeTable(table: Element): string { return `${lines.join("\n")}\n\n`; } -function serializeListItem(item: Element, ordered: boolean, index: number): string { +function serializeListItem( + item: Element, + ordered: boolean, + index: number, + inTableCell: boolean, +): string { const checkbox = item.querySelector( ':scope > input[type="checkbox"], :scope > p > input[type="checkbox"]', ); const task = checkbox ? `[${(checkbox as HTMLInputElement).checked ? "x" : " "}] ` : ""; const bullet = ordered ? `${index}. ` : "- "; const marker = `${bullet}${task}`; - let content = serializeChildren(item) + let content = serializeChildren(item, inTableCell) .replace(/\n{3,}/g, "\n\n") .trim(); if (!item.querySelector(":scope > p")) { @@ -130,15 +135,15 @@ function serializeListItem(item: Element, ordered: boolean, index: number): stri ].join("\n"); } -function serializeList(list: Element, ordered: boolean): string { +function serializeList(list: Element, ordered: boolean, inTableCell: boolean): string { const start = Number.parseInt(list.getAttribute("start") ?? "1", 10) || 1; const items = [...list.children].filter((child) => child.tagName === "LI"); if (items.length === 0) return ""; - return `${items.map((item, index) => serializeListItem(item, ordered, start + index)).join("\n")}\n\n`; + return `${items.map((item, index) => serializeListItem(item, ordered, start + index, inTableCell)).join("\n")}\n\n`; } -function serializeBlockquote(quote: Element): string { - const content = serializeChildren(quote) +function serializeBlockquote(quote: Element, inTableCell: boolean): string { + const content = serializeChildren(quote, inTableCell) .replace(/\n{3,}/g, "\n\n") .trim(); if (!content) return ""; @@ -159,11 +164,11 @@ function serializeDetails(details: Element): string { return `\n${summary}${content ? `\n\n${content}` : ""}\n\n\n`; } -function serializeAnchor(anchor: Element): string { +function serializeAnchor(anchor: Element, inTableCell: boolean): string { const markdownCopy = anchor.getAttribute("data-markdown-copy"); - if (markdownCopy !== null) return markdownCopy; - const content = serializeChildren(anchor); - const href = anchor.getAttribute("href") ?? ""; + if (markdownCopy !== null) return escapeTablePipes(markdownCopy, inTableCell); + const content = serializeChildren(anchor, inTableCell); + const href = escapeTablePipes(anchor.getAttribute("href") ?? "", inTableCell); if (!/^https?:\/\//i.test(href)) return content; const label = content.trim(); if (!label) return ""; @@ -171,32 +176,44 @@ function serializeAnchor(anchor: Element): string { return `[${label}](${href})`; } -function serializeChildren(node: Node): string { +/** + * GFM splits a table row at every unescaped pipe, but its math-aware parsing + * keeps a formula's pipes in the cell, so formula source is copied unescaped. + */ +function escapeTablePipes(text: string, inTableCell: boolean): string { + return inTableCell ? text.replaceAll("|", "\\|") : text; +} + +function serializeChildren(node: Node, inTableCell = false): string { let out = ""; for (const child of node.childNodes) { - out += serializeNode(child); + out += serializeNode(child, inTableCell); } return out; } -function serializeNode(node: Node): string { +function serializeNode(node: Node, inTableCell = false): string { if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent ?? ""; if (text.includes("\n") && text.trim().length === 0) return "\n"; - return text; + return escapeTablePipes(text, inTableCell); } if (node.nodeType !== Node.ELEMENT_NODE) return ""; const element = node as Element; if (element.hasAttribute("data-markdown-details")) { - return serializeDetails(element); + return escapeTablePipes(serializeDetails(element), inTableCell); } const markdownCopy = element.getAttribute("data-markdown-copy"); - if (markdownCopy !== null) return markdownCopy; + if (markdownCopy !== null) { + return element.hasAttribute("data-markdown-math") + ? markdownCopy + : escapeTablePipes(markdownCopy, inTableCell); + } if (isSkippedElement(element)) return ""; const headingLevel = /^H([1-6])$/.exec(element.tagName)?.[1]; if (headingLevel) { - return `${"#".repeat(Number(headingLevel))} ${serializeChildren(element).trim()}\n\n`; + return `${"#".repeat(Number(headingLevel))} ${serializeChildren(element, inTableCell).trim()}\n\n`; } switch (element.tagName) { @@ -205,45 +222,45 @@ function serializeNode(node: Node): string { case "HR": return "---\n\n"; case "P": - return `${serializeChildren(element).trim()}\n\n`; + return `${serializeChildren(element, inTableCell).trim()}\n\n`; case "PRE": - return serializeCodeBlock(element); + return escapeTablePipes(serializeCodeBlock(element), inTableCell); case "CODE": { - const content = element.textContent ?? ""; + const content = escapeTablePipes(element.textContent ?? "", inTableCell); return isBlockCodeElement(element, content) ? content : wrapInlineCode(content); } case "STRONG": case "B": - return wrapInlineMarker(serializeChildren(element), "**"); + return wrapInlineMarker(serializeChildren(element, inTableCell), "**"); case "EM": case "I": - return wrapInlineMarker(serializeChildren(element), "*"); + return wrapInlineMarker(serializeChildren(element, inTableCell), "*"); case "DEL": case "S": - return wrapInlineMarker(serializeChildren(element), "~~"); + return wrapInlineMarker(serializeChildren(element, inTableCell), "~~"); case "A": - return serializeAnchor(element); + return serializeAnchor(element, inTableCell); case "IMG": { - const alt = element.getAttribute("alt") ?? ""; - const src = element.getAttribute("src") ?? ""; + const alt = escapeTablePipes(element.getAttribute("alt") ?? "", inTableCell); + const src = escapeTablePipes(element.getAttribute("src") ?? "", inTableCell); return alt && src ? `![${alt}](${src})` : ""; } case "UL": - return serializeList(element, false); + return serializeList(element, false, inTableCell); case "OL": - return serializeList(element, true); + return serializeList(element, true, inTableCell); case "BLOCKQUOTE": - return serializeBlockquote(element); + return serializeBlockquote(element, inTableCell); case "TABLE": - return serializeTable(element); + return escapeTablePipes(serializeTable(element), inTableCell); case "DIV": case "SECTION": case "ARTICLE": { - const content = serializeChildren(element); + const content = serializeChildren(element, inTableCell); return content && !content.endsWith("\n") ? `${content}\n` : content; } default: - return serializeChildren(element); + return serializeChildren(element, inTableCell); } } From 503bcaaf99f1c24292fa972033143a6faaa9c5aa Mon Sep 17 00:00:00 2001 From: Devesh Sangwan Date: Sat, 31 Oct 2026 02:13:21 +0530 Subject: [PATCH 049/219] fix: keep offline environments out of thread reorder writes (#22887) Co-authored-by: maria-rcks --- apps/mobile/src/features/home/HomeScreen.tsx | 2 +- .../home/useThreadListActions.test.ts | 118 +++++++-- .../src/features/home/useThreadListActions.ts | 9 +- .../threads/ThreadArrangementSheet.tsx | 20 +- .../threads/ThreadNavigationSidebar.tsx | 2 +- .../threads/thread-provider-instance.test.ts | 134 +++++++++- apps/mobile/src/state/presentation.ts | 18 +- apps/mobile/src/state/server.ts | 5 - .../src/state/thread-list-environments.ts | 19 +- apps/web/src/components/Sidebar.logic.test.ts | 240 ++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 51 +++- apps/web/src/components/Sidebar.tsx | 27 +- 12 files changed, 592 insertions(+), 53 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 1edb92f0d556b..192755ec435df 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -53,7 +53,7 @@ import { useThreadListV2 } from "../threads/useThreadListV2"; import { useThreadListNow } from "../threads/use-thread-list-now"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; import { usePendingThreadOrder } from "../../state/thread-order"; -import { threadListEnvironmentsAtom } from "../../state/server"; +import { threadListEnvironmentsAtom } from "../../state/presentation"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index ec0f6a14558dd..9313aef299881 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -13,6 +13,7 @@ import { AsyncResult } from "effect/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ + connectedEnvironmentIds: [] as EnvironmentId[], pendingOrder: null as object | null, dropBusy: false, failedAction: null as string | null, @@ -60,6 +61,9 @@ vi.mock("../../state/session", () => ({ readEnvironmentScope: (environmentId: string, scope: string) => state.scopes.get(environmentId)?.has(scope) === true, })); +vi.mock("../../state/presentation", () => ({ + connectedEnvironmentIdsAtom: "connected-environments", +})); vi.mock("../../state/server", () => ({ environmentServerConfigsAtom: "server-configs", })); @@ -69,28 +73,30 @@ vi.mock("../../state/atom-registry", () => ({ state.dropBusy = value; }, get: (atom: string) => - atom === "thread-drop-busy" - ? state.dropBusy - : atom === "thread-shells" - ? state.shells - : atom === "queued-thread-keys" - ? new Set() - : new Map( - [...state.scopes.keys()].map((environmentId) => [ - environmentId, - { - environment: { - capabilities: { - threadSettlement: true, - threadSnooze: true, - threadPinning: true, - threadPinReorder: true, - threadTitleRegeneration: true, + atom === "connected-environments" + ? state.connectedEnvironmentIds + : atom === "thread-drop-busy" + ? state.dropBusy + : atom === "thread-shells" + ? state.shells + : atom === "queued-thread-keys" + ? new Set() + : new Map( + [...state.scopes.keys()].map((environmentId) => [ + environmentId, + { + environment: { + capabilities: { + threadSettlement: true, + threadSnooze: true, + threadPinning: true, + threadPinReorder: true, + threadTitleRegeneration: true, + }, }, }, - }, - ]), - ), + ]), + ), }, })); vi.mock("../../state/use-atom-command", () => ({ @@ -130,6 +136,7 @@ vi.mock("../../state/threads", () => ({ "pin", "unpin", "reorderPin", + "reorderActive", "updateMetadata", ].map((action) => [ action, @@ -179,6 +186,7 @@ const mutationCases = [ ] as const; beforeEach(() => { + state.connectedEnvironmentIds = [primaryEnvironmentId, otherEnvironmentId]; state.failedAction = null; state.archivedRefreshes = []; state.pendingOrder = null; @@ -286,6 +294,76 @@ describe("pinned thread operation permissions", () => { }); }); +describe("thread reorder connection availability", () => { + it.each(["active", "pinned"] as const)( + "rejects %s materialization involving a cached capable offline neighbor", + async (section) => { + const moved = makeThread({ + pinnedAt: section === "pinned" ? "2026-09-02T00:00:00.000Z" : null, + }); + state.shells = [ + moved, + makeThread({ + id: ThreadId.make("offline-neighbor"), + environmentId: otherEnvironmentId, + createdAt: "2026-09-02T00:00:00.000Z", + pinnedAt: moved.pinnedAt, + }), + ]; + state.scopes.get(otherEnvironmentId)?.add(AuthOrchestrationOperateScope); + state.connectedEnvironmentIds = [primaryEnvironmentId]; + + const { moveThread } = useThreadListActions(); + expect(await moveThread(moved, "up")).toBe(false); + expect(state.requests).toEqual([]); + + state.connectedEnvironmentIds = [primaryEnvironmentId, otherEnvironmentId]; + + expect(await moveThread(moved, "up")).toBe(true); + expect(state.requests).toHaveLength(2); + expect(new Set(state.requests.map(({ environmentId }) => environmentId))).toEqual( + new Set([primaryEnvironmentId, otherEnvironmentId]), + ); + }, + ); + + it.each(["active", "pinned"] as const)( + "uses the latest connection state in a retained %s move callback", + async (section) => { + const moved = makeThread({ + pinnedAt: section === "pinned" ? "2026-09-02T00:00:00.000Z" : null, + pinOrderKey: section === "pinned" ? "m" : null, + activeOrderKey: section === "active" ? "m" : null, + }); + state.shells = [ + moved, + makeThread({ + id: ThreadId.make("offline-neighbor"), + environmentId: otherEnvironmentId, + pinnedAt: moved.pinnedAt, + pinOrderKey: section === "pinned" ? "g" : null, + activeOrderKey: section === "active" ? "g" : null, + }), + ]; + const { moveThread } = useThreadListActions(); + state.connectedEnvironmentIds = []; + + expect(await moveThread(moved, "up")).toBe(false); + expect(state.requests).toEqual([]); + + state.connectedEnvironmentIds = [primaryEnvironmentId]; + + expect(await moveThread(moved, "up")).toBe(true); + expect(state.requests).toEqual([ + expect.objectContaining({ + action: section === "pinned" ? "reorderPin" : "reorderActive", + environmentId: primaryEnvironmentId, + }), + ]); + }, + ); +}); + describe("thread action undo", () => { it.each([ ["settleThread", "settle", "Settled", "unsettle"], diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index cac37f0552802..248c436905311 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -15,6 +15,7 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; +import { connectedEnvironmentIdsAtom } from "../../state/presentation"; import { readEnvironmentScope } from "../../state/session"; import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; @@ -591,6 +592,10 @@ export function useThreadListActions(): { return settled; } const configs = appAtomRegistry.get(environmentServerConfigsAtom); + const connectedEnvironmentIds = new Set(appAtomRegistry.get(connectedEnvironmentIdsAtom)); + + if (!connectedEnvironmentIds.has(thread.environmentId)) return false; + if (!configs.has(thread.environmentId)) { Dialog.alert( "Could not move thread", @@ -610,7 +615,9 @@ export function useThreadListActions(): { allThreads: shells, ordered, section, - reorderableEnvironmentIds: new Set(configs.keys()), + reorderableEnvironmentIds: new Set( + [...configs.keys()].filter((environmentId) => connectedEnvironmentIds.has(environmentId)), + ), })(scopedThreadKey(thread.environmentId, thread.id), direction); if (assignments === null) return false; const lifecycle = threadDropLifecycle(thread, section, new Date().toISOString()); diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx index 5b9afb49341a5..603fac3d00f5c 100644 --- a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx @@ -15,6 +15,7 @@ import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { environmentServerConfigsAtom } from "../../state/server"; +import { threadListEnvironmentsAtom } from "../../state/presentation"; import { environmentThreadShells } from "../../state/threads"; import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort"; import { useOlderSettledShelf, useOlderSettledShelfGrowth } from "../../state/olderSettledShelf"; @@ -164,6 +165,7 @@ function DragHandle(props: { function ThreadArrangementSheet(props: { onClose: () => void }) { const threads = useAtomValue(environmentThreadShells.navigationThreadShellsAtom); const configs = useAtomValue(environmentServerConfigsAtom); + const listEnvironments = useAtomValue(threadListEnvironmentsAtom); const queuedThreadKeys = useAtomValue(queuedThreadKeysAtom); const pendingOrder = useAtomValue(pendingThreadOrderAtom); const dropBusy = useAtomValue(threadDropBusyAtom); @@ -214,12 +216,15 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { ordered: sections[section], allThreads: threads, section, - reorderableEnvironmentIds: new Set( - section === "pinned" || !workingShelfEnabled ? configs.keys() : [], - ), + reorderableEnvironmentIds: + section === "pinned" + ? listEnvironments.pinReorderEnvironmentIds + : workingShelfEnabled + ? new Set() + : listEnvironments.activeReorderEnvironmentIds, }); return { pinned: planner("pinned"), active: planner("active") }; - }, [sections, threads, configs, workingShelfEnabled]); + }, [sections, threads, listEnvironments, workingShelfEnabled]); const rows = useMemo(() => { const result: Row[] = []; let offset = 0; @@ -468,7 +473,12 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { disabled={ dropBusy || pendingOrder !== null || - !configs.has(thread.environmentId) + (!listEnvironments.pinReorderEnvironmentIds.has( + thread.environmentId, + ) && + !listEnvironments.activeReorderEnvironmentIds.has( + thread.environmentId, + )) } sectionActions={sectionActions} onSectionMove={(section) => { diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 6ba0583a7a541..7975097f6a931 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -37,7 +37,7 @@ import { useThreadSearch } from "../../state/queries"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; import { useThreadListNow } from "./use-thread-list-now"; import { usePendingThreadOrder } from "../../state/thread-order"; -import { threadListEnvironmentsAtom } from "../../state/server"; +import { threadListEnvironmentsAtom } from "../../state/presentation"; import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { useWorkspaceState } from "../../state/workspace"; diff --git a/apps/mobile/src/features/threads/thread-provider-instance.test.ts b/apps/mobile/src/features/threads/thread-provider-instance.test.ts index be63be589b975..56ddec3ab1542 100644 --- a/apps/mobile/src/features/threads/thread-provider-instance.test.ts +++ b/apps/mobile/src/features/threads/thread-provider-instance.test.ts @@ -9,6 +9,8 @@ import { import { describe, expect, it } from "vite-plus/test"; import { Atom, AtomRegistry } from "effect/reactivity"; +import { computeThreadMoveAvailability, createThreadMovePlanner } from "./threadOrder"; +import { makeThreadShellFixture } from "../../test-fixtures"; import { createThreadListEnvironmentsAtom } from "../../state/thread-list-environments"; import { @@ -127,7 +129,7 @@ describe("createThreadRowProviderInstanceResolver", () => { const configs = Atom.make>( new Map([[environmentId, config]]), ); - const environments = createThreadListEnvironmentsAtom(configs); + const environments = createThreadListEnvironmentsAtom(configs, Atom.make([environmentId])); const registry = AtomRegistry.make(); registry.mount(environments); try { @@ -168,3 +170,133 @@ describe("createThreadRowProviderInstanceResolver", () => { } }); }); + +describe("thread list reorder availability", () => { + it.each(["active", "pinned"] as const)( + "updates %s moves on disconnect and reconnect without changing cached config", + (section) => { + const onlineId = EnvironmentId.make("online"); + const offlineId = EnvironmentId.make("offline"); + const config = makeConfig([{ instanceId: "codex", driver: "codex" }]); + const configs = Atom.make>( + new Map([ + [onlineId, config], + [offlineId, config], + ]), + ); + const connected = Atom.make>([onlineId, offlineId]); + const environments = createThreadListEnvironmentsAtom(configs, connected); + const registry = AtomRegistry.make(); + registry.mount(environments); + try { + const ordered = [onlineId, offlineId].map((environmentId, index) => + makeThreadShellFixture({ + environmentId, + id: ThreadId.make(`thread-${index}`), + pinnedAt: section === "pinned" ? "2026-09-02T00:00:00.000Z" : null, + }), + ); + const movedId = `${onlineId}:thread-0`; + const plannerInput = () => { + const current = registry.get(environments); + return { + ordered, + section, + reorderableEnvironmentIds: + section === "pinned" + ? current.pinReorderEnvironmentIds + : current.activeReorderEnvironmentIds, + }; + }; + expect(createThreadMovePlanner(plannerInput())(movedId, "down")).toHaveLength(2); + const before = registry.get(environments); + + registry.set(connected, [onlineId]); + + expect(createThreadMovePlanner(plannerInput())(movedId, "down")).toBeNull(); + expect(computeThreadMoveAvailability(plannerInput()).get(movedId)?.canMoveDown).toBe(false); + expect(registry.get(environments).pinningEnvironmentIds.has(offlineId)).toBe(true); + expect(registry.get(environments).settlementEnvironmentIds.has(offlineId)).toBe(true); + expect(registry.get(environments).providersByEnvironmentId.get(offlineId)).toBe( + before.providersByEnvironmentId.get(offlineId), + ); + + const keyedInput = { + ...plannerInput(), + ordered: ordered.map((row, index) => ({ + ...row, + pinOrderKey: section === "pinned" ? (index === 0 ? "bb" : "dd") : null, + activeOrderKey: section === "active" ? (index === 0 ? "bb" : "dd") : null, + })), + }; + expect(createThreadMovePlanner(keyedInput)(movedId, "down")).toEqual([ + expect.objectContaining({ id: movedId }), + ]); + expect(createThreadMovePlanner(keyedInput)(`${offlineId}:thread-1`, "up")).toBeNull(); + expect(computeThreadMoveAvailability(keyedInput).get(movedId)?.canMoveDown).toBe(true); + expect(computeThreadMoveAvailability(keyedInput).get(`${offlineId}:thread-1`)).toEqual({ + canMoveUp: false, + canMoveDown: false, + }); + + const updatedConfig = { + ...config, + settings: { + ...config.settings, + snoozePresets: [{ kind: "after" as const, minutes: 90 }], + }, + }; + registry.set( + configs, + new Map([ + [onlineId, config], + [offlineId, updatedConfig], + ]), + ); + + const updated = registry.get(environments); + expect(updated.snoozePresetsByEnvironmentId.get(offlineId)).toEqual( + updatedConfig.settings.snoozePresets, + ); + expect(updated.pinReorderEnvironmentIds.has(offlineId)).toBe(false); + expect(updated.activeReorderEnvironmentIds.has(offlineId)).toBe(false); + expect(createThreadMovePlanner(plannerInput())(movedId, "down")).toBeNull(); + + registry.set( + configs, + new Map([ + [onlineId, config], + [ + offlineId, + { + ...updatedConfig, + settings: { + ...updatedConfig.settings, + snoozePresets: updatedConfig.settings.snoozePresets.map((preset) => ({ + ...preset, + })), + }, + }, + ], + ]), + ); + expect(registry.get(environments)).toBe(updated); + + registry.set(connected, [onlineId, offlineId]); + + expect(createThreadMovePlanner(plannerInput())(movedId, "down")).toHaveLength(2); + expect(computeThreadMoveAvailability(plannerInput()).get(movedId)?.canMoveDown).toBe(true); + expect(registry.get(environments).snoozePresetsByEnvironmentId.get(offlineId)).toBe( + updated.snoozePresetsByEnvironmentId.get(offlineId), + ); + + registry.set(configs, new Map([[onlineId, config]])); + + expect(createThreadMovePlanner(plannerInput())(movedId, "down")).toBeNull(); + expect(computeThreadMoveAvailability(plannerInput()).get(movedId)?.canMoveDown).toBe(false); + } finally { + registry.dispose(); + } + }, + ); +}); diff --git a/apps/mobile/src/state/presentation.ts b/apps/mobile/src/state/presentation.ts index 437a4b7ccb6f4..68b2d278cd95b 100644 --- a/apps/mobile/src/state/presentation.ts +++ b/apps/mobile/src/state/presentation.ts @@ -1,11 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentPresentation } from "@t3tools/client-runtime/connection"; -import { createEnvironmentPresentationAtoms } from "@t3tools/client-runtime/state/presentation"; +import { + createEnvironmentPresentationAtoms, + createEnvironmentSummaryAtoms, +} from "@t3tools/client-runtime/state/presentation"; import type { EnvironmentId } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; -import { serverEnvironment } from "./server"; +import { environmentServerConfigsAtom, serverEnvironment } from "./server"; +import { createThreadListEnvironmentsAtom } from "./thread-list-environments"; export const environmentPresentations = createEnvironmentPresentationAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, @@ -13,6 +17,16 @@ export const environmentPresentations = createEnvironmentPresentationAtoms({ serverConfigValueAtom: serverEnvironment.configValueAtom, }); +export const { connectedEnvironmentIdsAtom } = createEnvironmentSummaryAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + presentationAtom: environmentPresentations.presentationAtom, +}); + +export const threadListEnvironmentsAtom = createThreadListEnvironmentsAtom( + environmentServerConfigsAtom, + connectedEnvironmentIdsAtom, +); + const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-environment-presentation:empty"), ); diff --git a/apps/mobile/src/state/server.ts b/apps/mobile/src/state/server.ts index 991fd72fdbad3..8de9354d38542 100644 --- a/apps/mobile/src/state/server.ts +++ b/apps/mobile/src/state/server.ts @@ -8,7 +8,6 @@ import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/stat import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSession } from "./session"; -import { createThreadListEnvironmentsAtom } from "./thread-list-environments"; export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { initialConfigValueAtom: environmentSession.initialConfigValueAtom, @@ -21,7 +20,3 @@ export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, serverConfigValueAtom: serverEnvironment.configValueAtom, }); - -export const threadListEnvironmentsAtom = createThreadListEnvironmentsAtom( - environmentServerConfigsAtom, -); diff --git a/apps/mobile/src/state/thread-list-environments.ts b/apps/mobile/src/state/thread-list-environments.ts index 0bf3fff4e3507..9a2a6f42990ff 100644 --- a/apps/mobile/src/state/thread-list-environments.ts +++ b/apps/mobile/src/state/thread-list-environments.ts @@ -14,8 +14,9 @@ export type ThreadListProvider = Pick< const capabilityKeys = ["threadAutoSettleOptOut"] as const; -function selectEnvironment(config: ServerConfig) { +function selectEnvironment(config: ServerConfig, isConnected: boolean) { return { + isConnected, providers: config.providers.map( ({ instanceId, driver, enabled, displayName, accentColor, iconUrl }) => ({ instanceId, @@ -60,13 +61,18 @@ function collectEnvironments(environments: ReadonlyMap(); const autoSettleOptOutEnvironmentIds = new Set(); + const reorderableEnvironmentIds = new Set(); const snoozePresetsByEnvironmentId = new Map(); - for (const [id, { providers, machineKind, capabilities, snoozePresets }] of environments) { + for (const [ + id, + { providers, machineKind, capabilities, isConnected, snoozePresets }, + ] of environments) { providersByEnvironmentId.set(id, providers); snoozePresetsByEnvironmentId.set(id, snoozePresets); machineByEnvironmentId.set(id, machineKind); loadedEnvironmentIds.add(id); if (capabilities.threadAutoSettleOptOut === true) autoSettleOptOutEnvironmentIds.add(id); + if (isConnected) reorderableEnvironmentIds.add(id); } return { providersByEnvironmentId, @@ -75,8 +81,8 @@ function collectEnvironments(environments: ReadonlyMap>, + connectedEnvironmentIdsAtom: Atom.Atom>, ) { let previous = new Map(); let result = collectEnvironments(previous); return Atom.make((get) => { const configs = get(configsAtom); + const connectedEnvironmentIds = new Set(get(connectedEnvironmentIdsAtom)); const next = new Map(); let changed = configs.size !== previous.size; for (const [id, config] of configs) { - const selected = selectEnvironment(config); + const selected = selectEnvironment(config, connectedEnvironmentIds.has(id)); const prior = previous.get(id); if (prior && sameProviders(prior.providers, selected.providers)) { selected.providers = prior.providers; @@ -104,6 +112,7 @@ export function createThreadListEnvironmentsAtom( prior && prior.providers === selected.providers && prior.machineKind === selected.machineKind && + prior.isConnected === selected.isConnected && prior.snoozePresets === selected.snoozePresets && capabilityKeys.every( (key) => (prior.capabilities[key] === true) === (selected.capabilities[key] === true), diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index e4fa68a65201b..68d8577690643 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -23,6 +23,7 @@ import { sortInboxThreadsByReturn, resolveSidebarDropTarget, planSidebarThreadDrop, + resolveSidebarReorderableThreadKeys, sortPinnedThreadsForSidebar, sortProjectsForSidebar, sortScopedProjectsForSidebar, @@ -36,6 +37,11 @@ import { resolveSidebarFolderDot, sidebarFolderExpansionKeys, } from "./Sidebar.logic"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + sortActiveThreadsByOrderKey, + sortPinnedThreadsByOrderKey, +} from "@t3tools/client-runtime/state/thread-sort"; import { resolveProjectExpanded } from "../uiStateStore"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; @@ -1230,6 +1236,240 @@ describe("Working shelf (beta)", () => { expect(above.kind === "move-active" && above.assignments[0]!.orderKey < "m").toBe(true); }); + describe("reordering with cached offline configuration", () => { + const onlineEnvironmentId = EnvironmentId.make("online"); + const offlineEnvironmentId = EnvironmentId.make("offline"); + const capabilities = { + repositoryIdentity: true, + threadPinning: true, + threadPinReorder: true, + threadActiveReorder: true, + }; + const config = (environmentId: EnvironmentId) => ({ + environment: { + environmentId, + label: environmentId, + platform: { os: "linux" as const, arch: "x64" as const }, + serverVersion: "0.1.1", + capabilities, + }, + }); + const serverConfigs = new Map([ + [onlineEnvironmentId, config(onlineEnvironmentId)], + [offlineEnvironmentId, config(offlineEnvironmentId)], + ]); + const thread = ( + id: string, + environmentId = onlineEnvironmentId, + key: string | null = null, + ) => ({ + id: ThreadId.make(id), + environmentId, + createdAt: `2026-03-09T${id === "offline" ? "12" : id === "online" ? "11" : "10"}:00:00.000Z`, + activeOrderKey: key, + pinOrderKey: key, + }); + const keyOf = (entry: ReturnType) => + scopedThreadKey(scopeThreadRef(entry.environmentId, entry.id)); + const online = thread("online"); + const offline = thread("offline", offlineEnvironmentId); + const membership = (connectedEnvironmentIds = [onlineEnvironmentId]) => + resolveSidebarReorderableThreadKeys({ + threads: [online, offline], + serverConfigs, + connectedEnvironmentIds, + }); + const planDrop = (input: { + section: "active" | "pinned"; + rows: ReturnType[]; + moved: ReturnType; + order: ReturnType[]; + from?: "active" | "pinned"; + connectedEnvironmentIds?: EnvironmentId[]; + }) => { + const writable = resolveSidebarReorderableThreadKeys({ + threads: input.rows, + serverConfigs, + connectedEnvironmentIds: input.connectedEnvironmentIds ?? [onlineEnvironmentId], + }); + const sort = (rows: typeof input.rows) => + input.section === "active" + ? sortActiveThreadsByOrderKey(rows) + : sortPinnedThreadsByOrderKey(rows); + const originalOrder = sort(input.rows).map(keyOf); + const targetOrder = input.order.map(keyOf); + const keysById = new Map(input.rows.map((entry) => [keyOf(entry), entry.activeOrderKey])); + const plan = planSidebarThreadDrop({ + activeKey: keyOf(input.moved), + activeSection: input.from ?? input.section, + target: { section: input.section, pinnedOrder: targetOrder, activeOrder: targetOrder }, + pinnedOrder: originalOrder, + activeOrder: originalOrder, + pinnedKeysById: keysById, + activeKeysById: keysById, + reorderableKeys: writable.draggableThreadKeys, + activeReorderableKeys: writable.activeReorderableThreadKeys, + }); + const assignments = + plan.kind === "move-active" || plan.kind === "reorder-pinned" + ? plan.assignments + : plan.kind === "pin" + ? [ + ...plan.extraAssignments, + ...(plan.orderKey === undefined + ? [] + : [{ id: keyOf(input.moved), orderKey: plan.orderKey }]), + ] + : []; + const assignedKeys = new Map(assignments.map(({ id, orderKey }) => [id, orderKey])); + const actualOrder = sort( + input.rows.map((entry) => ({ + ...entry, + activeOrderKey: assignedKeys.get(keyOf(entry)) ?? entry.activeOrderKey, + pinOrderKey: assignedKeys.get(keyOf(entry)) ?? entry.pinOrderKey, + })), + ).map(keyOf); + + return { plan, assignments, actualOrder, targetOrder }; + }; + + it("excludes cached capable offline rows from both writable sets", () => { + const writable = membership(); + + expect([...writable.draggableThreadKeys]).toEqual([keyOf(online)]); + expect([...writable.activeReorderableThreadKeys]).toEqual([keyOf(online)]); + expect(serverConfigs.has(offlineEnvironmentId)).toBe(true); + }); + + it("updates membership through disconnect, reconnect, and missing configuration", () => { + expect(membership([]).draggableThreadKeys.size).toBe(0); + const connected = membership([onlineEnvironmentId, offlineEnvironmentId]); + expect(connected.draggableThreadKeys.size).toBe(2); + expect(connected.activeReorderableThreadKeys.size).toBe(2); + + const changed = resolveSidebarReorderableThreadKeys({ + threads: [online, offline], + connectedEnvironmentIds: [onlineEnvironmentId, offlineEnvironmentId], + serverConfigs: new Map([[onlineEnvironmentId, config(onlineEnvironmentId)]]), + }); + expect([...changed.draggableThreadKeys]).toEqual([keyOf(online)]); + expect([...changed.activeReorderableThreadKeys]).toEqual([keyOf(online)]); + }); + + it.each(["active", "pinned"] as const)( + "replans a disconnected drag source in %s", + (section) => { + const bound = thread("offline", offlineEnvironmentId, "m"); + const moved = thread("online", onlineEnvironmentId, "t"); + const input = { section, rows: [bound, moved], moved, order: [moved, bound] }; + expect(planDrop(input).plan.kind).toBe( + section === "active" ? "move-active" : "reorder-pinned", + ); + + const disconnected = planDrop({ ...input, connectedEnvironmentIds: [] }); + + expect(disconnected.plan).toEqual({ kind: "none" }); + expect(disconnected.assignments).toEqual([]); + }, + ); + + it.each(["active", "pinned"] as const)("preserves a keyed offline bound in %s", (section) => { + const bound = thread("offline", offlineEnvironmentId, "m"); + const moved = thread("online", onlineEnvironmentId, "t"); + const result = planDrop({ section, rows: [bound, moved], moved, order: [moved, bound] }); + + expect(result.plan.kind).toBe(section === "active" ? "move-active" : "reorder-pinned"); + expect(result.assignments.map(({ id }) => id)).toEqual([keyOf(moved)]); + expect(result.actualOrder).toEqual(result.targetOrder); + }); + + it.each(["active", "pinned"] as const)( + "rejects rebalancing a keyed offline bound in %s", + (section) => { + const bound = thread("offline", offlineEnvironmentId, "m"); + const neighbor = thread("neighbor"); + const result = planDrop({ + section, + rows: [bound, online, neighbor], + moved: online, + order: [bound, neighbor, online], + }); + + expect(result.plan).toEqual({ kind: "none" }); + expect(result.assignments).toEqual([]); + }, + ); + + it.each(["active", "pinned"] as const)( + "rejects crossing the frozen keyless boundary in %s", + (section) => { + const bound = { + ...offline, + createdAt: + section === "active" ? "2026-03-09T12:00:00.000Z" : "2026-03-09T10:00:00.000Z", + }; + const order = section === "active" ? [online, bound] : [bound, online]; + const result = planDrop({ section, rows: [bound, online], moved: online, order }); + + expect(result.plan).toEqual({ kind: "none" }); + expect(result.assignments).toEqual([]); + }, + ); + + it.each(["active", "pinned"] as const)( + "allows realizable moves beside a frozen keyless row in %s", + (section) => { + const neighbor = thread("neighbor"); + const order = + section === "active" ? [offline, neighbor, online] : [online, neighbor, offline]; + const result = planDrop({ + section, + rows: [offline, online, neighbor], + moved: online, + order, + }); + + expect(result.plan.kind).toBe(section === "active" ? "move-active" : "reorder-pinned"); + expect(result.assignments.length).toBeGreaterThan(0); + expect(result.assignments.every(({ id }) => id !== keyOf(offline))).toBe(true); + expect(result.actualOrder).toEqual(result.targetOrder); + }, + ); + + it.each(["active", "pinned"] as const)( + "checks fallback assignments to other rows at the %s boundary", + (section) => { + const neighbor = thread("neighbor"); + const order = + section === "active" ? [neighbor, offline, online] : [online, offline, neighbor]; + const result = planDrop({ + section, + rows: [offline, online, neighbor], + moved: online, + order, + }); + + expect(result.plan).toEqual({ kind: "none" }); + }, + ); + + it.each(["active", "pinned"] as const)( + "rejects impossible pin/unpin placement across the %s boundary", + (section) => { + const order = section === "active" ? [online, offline] : [offline, online]; + const result = planDrop({ + section, + rows: [offline, online], + moved: online, + order, + from: section === "active" ? "pinned" : "active", + }); + + expect(result.plan).toEqual({ kind: "none" }); + }, + ); + }); + it("only changes lifecycle when the inbox is time-ordered", () => { const base = { pinnedOrder: ["p1"], diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index efd8244f8c2b2..43c87f530e454 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,3 +1,5 @@ +import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models"; import { isShownUnderParent } from "@t3tools/client-runtime/state/threads"; import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; @@ -309,6 +311,32 @@ export function sidebarThreadKeyAtY(list: HTMLElement, y: number): string | null return key; } +export function resolveSidebarReorderableThreadKeys(input: { + readonly threads: ReadonlyArray>; + readonly serverConfigs: ReadonlyMap>; + readonly connectedEnvironmentIds: readonly EnvironmentId[]; +}) { + const connectedEnvironmentIds = new Set(input.connectedEnvironmentIds); + const draggableThreadKeys = new Set(); + const activeReorderableThreadKeys = new Set(); + + for (const thread of input.threads) { + // Config is cached for offline rendering, so its presence does not make a row writable. + if ( + !connectedEnvironmentIds.has(thread.environmentId) || + !input.serverConfigs.has(thread.environmentId) + ) { + continue; + } + + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + activeReorderableThreadKeys.add(threadKey); + draggableThreadKeys.add(threadKey); + } + + return { draggableThreadKeys, activeReorderableThreadKeys }; +} + export function planSidebarThreadDrop(input: { readonly activeKey: string; readonly activeSection: SidebarSection; @@ -353,7 +381,28 @@ export function planSidebarThreadDrop(input: { keysById, movedId: activeKey, }); - return assignments.every(({ id }) => writable.has(id)) ? assignments : null; + if (assignments.some(({ id }) => !writable.has(id))) return null; + + // Keyless Active rows sort before keyed rows; keyless Pinned rows sort after them. + // Materializing any row across a frozen keyless boundary cannot persist that position. + const assignedIds = new Set(assignments.map(({ id }) => id)); + let hasFrozenKeylessBefore = false; + let hasAssignedBefore = false; + for (const id of order) { + const isFrozenKeyless = !writable.has(id) && keysById.get(id) == null; + const isAssigned = assignedIds.has(id); + if ( + (target.section === "active" && isFrozenKeyless && hasAssignedBefore) || + (target.section === "pinned" && isAssigned && hasFrozenKeylessBefore) + ) { + return null; + } + + hasFrozenKeylessBefore ||= isFrozenKeyless; + hasAssignedBefore ||= isAssigned; + } + + return assignments; }; switch (target.section) { case "active": { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index de16be6d2dcfb..3c09b32bd2db3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -189,6 +189,7 @@ import { resolveSidebarDropTarget, resolveSidebarDropVerb, resolveSidebarThreadSection, + resolveSidebarReorderableThreadKeys, searchSidebarThreads, shouldCreateNewThreadInCurrentProject, shouldNavigateAfterThreadPark, @@ -339,6 +340,7 @@ interface SidebarThreadPartitionInput { /** Tasks linked environments run for a listed thread; they show under it. */ readonly delegatedUnderParent: ReadonlySet; readonly serverConfigs: ReadonlyMap; + readonly connectedEnvironmentIds: readonly EnvironmentId[]; readonly optimisticDrop: SidebarOptimisticDrop | null; readonly workingShelfEnabled: boolean; /** Boring mode: folders list active threads by the user's latest message. */ @@ -399,6 +401,7 @@ function useSidebarThreads(input: { readonly projectScopeKey: string | null; readonly scopedProjectKeys: ReadonlySet | null; readonly serverConfigs: ReadonlyMap; + readonly connectedEnvironmentIds: readonly EnvironmentId[]; readonly optimisticDrop: SidebarOptimisticDrop | null; readonly threads: readonly EnvironmentThreadShell[]; readonly delegatedUnderParent: ReadonlySet; @@ -412,6 +415,7 @@ function useSidebarThreads(input: { projectScopeKey, scopedProjectKeys, serverConfigs, + connectedEnvironmentIds, optimisticDrop, threads, delegatedUnderParent, @@ -462,6 +466,7 @@ function useSidebarThreads(input: { scopedProjectKeys, delegatedUnderParent, serverConfigs, + connectedEnvironmentIds, optimisticDrop, workingShelfEnabled, sortActiveBySend: folderGroups !== null, @@ -614,15 +619,14 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { : active; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; - const draggable = new Set(); - const activeReorderable = new Set(); + const { draggableThreadKeys, activeReorderableThreadKeys } = resolveSidebarReorderableThreadKeys({ + threads: visible, + serverConfigs, + connectedEnvironmentIds: input.connectedEnvironmentIds, + }); for (const thread of visible) { const configLoaded = serverConfigs.has(thread.environmentId); const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - if (configLoaded) { - activeReorderable.add(threadKey); - draggable.add(threadKey); - } if (optimisticDrop?.key === threadKey) { const projected = applySidebarThreadDrop( thread, @@ -656,7 +660,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { ).push(thread); } } - // A loaded config gates dragging only; the sort must not depend on it, so web and mobile agree. + // Availability gates dragging only; sorting must stay consistent across clients. const sortedPinned = sortPinnedThreadsForSidebar(pinned); const sortedActive = sortActiveBySend ? sortWorkingThreadsBySend(active) @@ -672,8 +676,8 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { preferredIds: optimisticDrop.order, getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), }), - draggableThreadKeys: draggable, - activeReorderableThreadKeys: activeReorderable, + draggableThreadKeys, + activeReorderableThreadKeys, activeThreads: optimisticDrop?.section !== "active" || optimisticDrop.order === null ? sortedActive @@ -1001,7 +1005,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); const [searchHighlightKey, setSearchHighlightKey] = useState(null); - const searchEnvironmentIds = useConnectedEnvironmentIds(); + const connectedEnvironmentIds = useConnectedEnvironmentIds(); const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop); const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); const [renamingThreadRef, setRenamingThreadRef] = useState(null); @@ -1145,7 +1149,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const scopedProjectRefs = scopedProjectGroups.flatMap((project) => project.memberProjectRefs); const scopedProjectKeys = revealWidensScope ? null : chosenScopeProjectKeys; const threadSearch = useThreadSearch( - searchEnvironmentIds, + connectedEnvironmentIds, threadSearchQuery, scopedProjectGroups.length === 0 ? {} @@ -1277,6 +1281,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve projectScopeKey, scopedProjectKeys, serverConfigs, + connectedEnvironmentIds, optimisticDrop, threads, delegatedUnderParent, From c78712e84bcda486b3518b995235004bf9654335 Mon Sep 17 00:00:00 2001 From: Jaron Kramer <121427510+jkramer5103@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:43:37 +0200 Subject: [PATCH 050/219] fix(server): clarify browser setup when running as root (#22892) Co-authored-by: maria-rcks --- .../src/mcp/PreviewAutomationBroker.test.ts | 24 +++++++++++---- .../src/preview/PreviewBrowserHost.test.ts | 30 +++++++++++++++++++ apps/server/src/preview/PreviewBrowserHost.ts | 2 +- docs/user/remote-access.md | 18 +++++++++-- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index f9d618f0520e3..ae54eca6f983e 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -19,6 +19,7 @@ import * as Fiber from "effect/Fiber"; import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; +import { PreviewBrowserRootError } from "../preview/PreviewBrowserHost.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer)); @@ -288,9 +289,22 @@ it.effect("announces a live replacement stream before delivering requests", () = ); it.effect.each([ - { clientId: SERVER_BROWSER_AUTOMATION_CLIENT_ID, shown: true }, - { clientId: "client-1", shown: false }, -])("tells the agent why its own server browser failed ($clientId)", ({ clientId, shown }) => + { + clientId: SERVER_BROWSER_AUTOMATION_CLIENT_ID, + shown: true, + message: "page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:4719/", + }, + { + clientId: "client-1", + shown: false, + message: "page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:4719/", + }, + { + clientId: SERVER_BROWSER_AUTOMATION_CLIENT_ID, + shown: true, + message: new PreviewBrowserRootError().message, + }, +])("preserves server browser failure guidance ($clientId)", ({ clientId, shown, message }) => Effect.scoped( Effect.gen(function* () { const broker = yield* makeBroker; @@ -303,7 +317,7 @@ it.effect.each([ ok: false, error: { _tag: "PreviewAutomationExecutionError", - message: "page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:4719/", + message, }, }), ).pipe(Effect.forkScoped); @@ -312,7 +326,7 @@ it.effect.each([ .invoke({ scope, operation: "open", input: {} }) .pipe(Effect.flip); expect(error._tag).toBe("PreviewAutomationExecutionError"); - expect(error.message.includes("ERR_CONNECTION_REFUSED")).toBe(shown); + expect(error.message.includes(message)).toBe(shown); }), ), ); diff --git a/apps/server/src/preview/PreviewBrowserHost.test.ts b/apps/server/src/preview/PreviewBrowserHost.test.ts index 2a90d000b262b..02ea6bedc4281 100644 --- a/apps/server/src/preview/PreviewBrowserHost.test.ts +++ b/apps/server/src/preview/PreviewBrowserHost.test.ts @@ -72,6 +72,36 @@ const diagnose = (input: { }; describe("diagnoseLaunchFailure", () => { + it.effect("explains how to restart a root server without Chrome's sandbox", () => + Effect.gen(function* () { + const { error, commands } = yield* diagnose({ + platform: "linux", + uid: 0, + output: PreviewBrowserHost.NO_SANDBOX_SIGNATURE, + ldd: "", + }); + expect(error).toBeInstanceOf(PreviewBrowserHost.PreviewBrowserRootError); + expect(error?.message).toContain("T3 server is running as root"); + expect(error?.message).toContain("T3 server's environment and restart the server"); + expect(error?.message).toContain("T3CODE_SERVER_BROWSER_SANDBOX=0 t3 serve"); + expect(error?.message).toContain("Environment=T3CODE_SERVER_BROWSER_SANDBOX=0"); + expect(error?.message).toContain("this disables Chrome's sandbox"); + expect(commands).toEqual([]); + }), + ); + + it.effect("keeps host setup guidance for a nonroot sandbox failure", () => + Effect.gen(function* () { + const { error } = yield* diagnose({ + platform: "linux", + output: PreviewBrowserHost.NO_SANDBOX_SIGNATURE, + ldd: "", + }); + expect(error).toBeInstanceOf(PreviewBrowserHost.PreviewBrowserSandboxError); + expect(error?.message).toContain("Run `sudo t3 browser setup` on the host"); + }), + ); + it.effect("names every library the loader cannot find", () => Effect.gen(function* () { const { error } = yield* diagnose({ diff --git a/apps/server/src/preview/PreviewBrowserHost.ts b/apps/server/src/preview/PreviewBrowserHost.ts index 896f51b308e9b..1847e5f232072 100644 --- a/apps/server/src/preview/PreviewBrowserHost.ts +++ b/apps/server/src/preview/PreviewBrowserHost.ts @@ -73,7 +73,7 @@ export class PreviewBrowserRootError extends Schema.TaggedError Date: Fri, 30 Oct 2026 21:43:54 +0100 Subject: [PATCH 051/219] fix(markdown): an escaped backslash before \( no longer crashes dev builds (#22893) Co-authored-by: maria-rcks --- packages/shared/src/markdownMath.test.ts | 9 +++++++++ patches/micromark-extension-llm-math@3.1.0.patch | 10 ++++++++++ pnpm-lock.yaml | 6 +++--- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/markdownMath.test.ts b/packages/shared/src/markdownMath.test.ts index 0bc6941e494c0..e34dde0e9e212 100644 --- a/packages/shared/src/markdownMath.test.ts +++ b/packages/shared/src/markdownMath.test.ts @@ -193,6 +193,15 @@ describe("chat math parsing", () => { expect(formulas("Pay \\$$x$ now.")).toEqual([["inline", "x"]]); }); + it("starts a formula right after an escaped backslash", () => { + const markdown = "Path \\\\\\(x^2\\) and \\\\\\[y\\]."; + expect(formulas(markdown)).toEqual([ + ["inline", "x^2"], + ["display", "y"], + ]); + expect(text(markdown)).toBe("Path \\ and \\."); + }); + it.each([ ["$a`$HOME`", "$HOME"], ["\\(a`\\)`", "\\)"], diff --git a/patches/micromark-extension-llm-math@3.1.0.patch b/patches/micromark-extension-llm-math@3.1.0.patch index 25da4f5f47755..e09a8d4d889eb 100644 --- a/patches/micromark-extension-llm-math@3.1.0.patch +++ b/patches/micromark-extension-llm-math@3.1.0.patch @@ -232,6 +232,16 @@ diff --git a/dev/lib/math-tex-text.js b/dev/lib/math-tex-text.js effects.consume(code) effects.exit('mathTextSequence') effects.exit('mathText') +@@ -180,5 +184,8 @@ + */ + function previous(code) { + // If there is a previous code, there will always be a tail. +- return code !== codes.backslash ++ return ( ++ code !== codes.backslash || ++ this.events[this.events.length - 1][1].type === types.characterEscape ++ ) + } diff --git a/dev/lib/math-text.js b/dev/lib/math-text.js --- a/dev/lib/math-text.js +++ b/dev/lib/math-text.js diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39c062c66dbdf..3dce27954d239 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -383,7 +383,7 @@ patchedDependencies: mdast-util-math@3.0.0: afe6b2c89e1eaddfd3c90cf2723f12c9a17d513e65beff51653781486013251c mdast-util-to-hast@13.2.1: 57ea46c73aea3c876ab8d351fe0482ddd081dcb4a0610576e399dbe5eb59d2b1 micromark-extension-gfm-table@2.1.1: 9ad23a530e45d9f83cba1da8654fe16d5af8b59d5c73d5c7a7c91fd25b9be1d8 - micromark-extension-llm-math@3.1.0: 7beb6ac4ba52166286593a494531aace81547e55fdddecf892824c25bd84d15a + micromark-extension-llm-math@3.1.0: 4eeb9d9e27ebc75e98795e21c0719005483dad7d233055698257f7d10e91603b micromark@4.0.2: cb79edde7eca83bbcaaafb6ceb7b044b81b987314cde4910823b15257ecada67 node-pty@1.2.0-beta.15: f1e680afec85f650699ea756f91f469844ec4c976f4f168492a45c6b3f67d651 react-native-gesture-handler@3.3.0: abe20e1d1c23549297cbbc818e14fe479b41455930d9663135d6b40fe359b626 @@ -20161,7 +20161,7 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 - micromark-extension-llm-math@3.1.0(patch_hash=7beb6ac4ba52166286593a494531aace81547e55fdddecf892824c25bd84d15a): + micromark-extension-llm-math@3.1.0(patch_hash=4eeb9d9e27ebc75e98795e21c0719005483dad7d233055698257f7d10e91603b): dependencies: '@types/katex': 0.16.8 devlop: 1.1.0 @@ -21453,7 +21453,7 @@ snapshots: dependencies: '@types/mdast': 4.0.4 mdast-util-math: 3.0.0(patch_hash=afe6b2c89e1eaddfd3c90cf2723f12c9a17d513e65beff51653781486013251c)(supports-color@8.1.1) - micromark-extension-math: micromark-extension-llm-math@3.1.0(patch_hash=7beb6ac4ba52166286593a494531aace81547e55fdddecf892824c25bd84d15a) + micromark-extension-math: micromark-extension-llm-math@3.1.0(patch_hash=4eeb9d9e27ebc75e98795e21c0719005483dad7d233055698257f7d10e91603b) unified: 11.0.5 transitivePeerDependencies: - supports-color From e4926868daafd066692b6aed102c9485d9723929 Mon Sep 17 00:00:00 2001 From: Saikrishna Ambeti Date: Sat, 31 Oct 2026 02:14:10 +0530 Subject: [PATCH 052/219] fix(web): diagnostics cards use one border radius (#22906) --- .../settings/ResourceTelemetryDiagnostics.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx index 74df6a4d6858b..b79b8057e3e2b 100644 --- a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx +++ b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx @@ -992,7 +992,7 @@ function ResourceTelemetryDiagnosticsBody({
} > -
+
@@ -1100,7 +1100,7 @@ function ResourceTelemetryDiagnosticsBody({ ) : null } > -
+
@@ -1237,7 +1237,7 @@ function ResourceTelemetryDiagnosticsBody({
} > -
+
{history.error ? (
@@ -1260,7 +1260,7 @@ function ResourceTelemetryDiagnosticsBody({ ) : null } > -
+
Logical bytes by operation } > -
+
Native counters identify which process is reading or writing. These application-level counters identify known T3 operations so process spikes can be correlated with specific From ae1e89e6ba7eb9371b16bced010b92ef94dd8108 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Fri, 30 Oct 2026 16:44:28 -0400 Subject: [PATCH 053/219] fix(desktop): SnapShot shortcuts with arrow keys register on macOS and Windows (#22934) Co-authored-by: Claude Opus 5.5 --- apps/desktop/src/snapShot/snapShot.test.ts | 17 +++++++++++++++++ apps/desktop/src/snapShot/snapShot.ts | 11 ++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/snapShot/snapShot.test.ts b/apps/desktop/src/snapShot/snapShot.test.ts index 7c63a8b9aadef..fe166f5d099a0 100644 --- a/apps/desktop/src/snapShot/snapShot.test.ts +++ b/apps/desktop/src/snapShot/snapShot.test.ts @@ -1,5 +1,6 @@ import { it as effectIt } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { parseKeybindingShortcut } from "@t3tools/shared/keybindings"; import * as Effect from "effect/Effect"; import { describe, expect, it } from "vite-plus/test"; @@ -9,6 +10,7 @@ import { compactAccessibilityTree, findAccessibleWindow, isWaylandSession, + toElectronAccelerator, } from "./snapShot.ts"; describe("accessibleWindowText", () => { @@ -448,6 +450,21 @@ describe("findAccessibleWindow", () => { }); }); +describe("toElectronAccelerator", () => { + // The settings recorder stores shortcuts through parseKeybindingShortcut, which lowercases keys. + it.each([ + ["mod+shift+arrowup", "CommandOrControl+Shift+Up"], + ["mod+shift+arrowdown", "CommandOrControl+Shift+Down"], + ["ctrl+alt+arrowleft", "Control+Alt+Left"], + ["ctrl+alt+arrowright", "Control+Alt+Right"], + ["mod+shift+esc", "CommandOrControl+Shift+Esc"], + ["mod+shift+space", "CommandOrControl+Shift+Space"], + ["mod+shift+2", "CommandOrControl+Shift+2"], + ])("maps a recorded %s to an Electron accelerator", (recorded, accelerator) => { + expect(toElectronAccelerator(parseKeybindingShortcut(recorded)!)).toBe(accelerator); + }); +}); + describe("isWaylandSession", () => { effectIt.effect( "falls back to a live runtime directory socket when session variables are stripped", diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 872f99e1d6a72..8be137cb32a46 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -539,14 +539,15 @@ function findBrowserTitleMatch( return browserMatches.length === 1 ? browserMatches[0] : undefined; } +// Keyed by the lowercase tokens parseKeybindingShortcut stores; Electron has no `ArrowUp` token. const ELECTRON_KEY_NAMES: Readonly> = { " ": "Space", "+": "Plus", - ArrowDown: "Down", - ArrowLeft: "Left", - ArrowRight: "Right", - ArrowUp: "Up", - Escape: "Esc", + arrowdown: "Down", + arrowleft: "Left", + arrowright: "Right", + arrowup: "Up", + escape: "Esc", }; export function sameSnapShotShortcut(left: SnapShotShortcut, right: SnapShotShortcut): boolean { From 0ce257b8804a7a6dffa2892a280a59dd20e5a159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Cmit=20Demir?= <108368506+yumitdemir@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:44:45 +0500 Subject: [PATCH 054/219] fix(ssh): detect lost connections during remote commands (#23025) Co-authored-by: maria-rcks --- packages/ssh/src/command.test.ts | 12 ++++++++++++ packages/ssh/src/command.ts | 5 +++++ 2 files changed, 17 insertions(+) diff --git a/packages/ssh/src/command.test.ts b/packages/ssh/src/command.test.ts index 0ba12303985b5..9503e4d0606d5 100644 --- a/packages/ssh/src/command.test.ts +++ b/packages/ssh/src/command.test.ts @@ -143,6 +143,18 @@ describe("ssh command", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.live("enables server-alive checks for long remote commands", () => + Effect.gen(function* () { + // Ask OpenSSH for its effective configuration without opening a connection. + const { stdout } = yield* runSshCommand( + { alias: "synthetic-fixture", hostname: "synthetic-fixture", username: null, port: null }, + { preHostArgs: ["-F", "none", "-G"], timeoutMs: 900_000 }, + ); + assert.match(stdout, /^serveraliveinterval 15\r?$/mu); + assert.match(stdout, /^serveralivecountmax 3\r?$/mu); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("redacts credentials from stdout in non-zero command failures", () => { const spawner = ChildProcessSpawner.make(() => Effect.succeed(makeFailedProcess({ stdout: '{"credential":"pairing-secret"}\n' })), diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index dbe1bf7e30560..5011eea7cd7d9 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -202,6 +202,11 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func ...baseSshArgs(target, { batchMode: input.batchMode ?? (input.interactiveAuth ? "no" : "yes"), }), + // Cold installs need a long command budget; a lost connection must fail sooner. + "-o", + "ServerAliveInterval=15", + "-o", + "ServerAliveCountMax=3", ...(input.preHostArgs ?? []), "--", hostSpec, From 13c7de296c3e761b824dda3a6819421bf77ca06b Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:45:01 -0500 Subject: [PATCH 055/219] fix(codex): approvals still appear after many stopped Codex turns (#23234) Co-authored-by: Claude Opus 5.5 --- .../src/protocol.test.ts | 69 +++++++++++++++++++ .../effect-codex-app-server/src/protocol.ts | 17 ++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/packages/effect-codex-app-server/src/protocol.test.ts b/packages/effect-codex-app-server/src/protocol.test.ts index f470d0b25bfae..965764f468b61 100644 --- a/packages/effect-codex-app-server/src/protocol.test.ts +++ b/packages/effect-codex-app-server/src/protocol.test.ts @@ -418,6 +418,75 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => { }), ); + it.effect("releases a waiting request handler when Codex resolves its request", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: (request) => + request.id === 33 ? Effect.succeed({ decision: "accept" }) : Effect.never, + }); + + const messages = Array.from({ length: 32 }, (_, index) => + encodeUnknownJsonString({ + id: index + 1, + method: "item/commandExecution/requestApproval", + params: {}, + }), + ); + for (let requestId = 1; requestId <= 32; requestId++) { + messages.push( + encodeUnknownJsonString({ + method: "serverRequest/resolved", + params: { threadId: "thread-1", requestId }, + }), + ); + } + messages.push( + encodeUnknownJsonString({ + id: 33, + method: "item/commandExecution/requestApproval", + params: {}, + }), + ); + yield* Queue.offer(input, encoder.encode(`${messages.join("\n")}\n`)); + + assert.deepEqual(yield* decodeJson(yield* Queue.take(output)), { + id: 33, + result: { decision: "accept" }, + }); + }), + ); + + it.effect("keeps numeric and string request ids apart when Codex resolves one", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const interrupted: Array = []; + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: (request) => + request.id === 2 + ? Effect.sync(() => ({ interrupted })) + : Effect.never.pipe( + Effect.onInterrupt(() => Effect.sync(() => interrupted.push(request.id))), + ), + }); + + const messages = [ + { id: 1, method: "item/commandExecution/requestApproval", params: {} }, + { id: "1", method: "item/commandExecution/requestApproval", params: {} }, + { method: "serverRequest/resolved", params: { threadId: "thread-1", requestId: "1" } }, + { id: 2, method: "item/commandExecution/requestApproval", params: {} }, + ].map((message) => encodeUnknownJsonString(message)); + yield* Queue.offer(input, encoder.encode(`${messages.join("\n")}\n`)); + + assert.deepEqual(yield* decodeJson(yield* Queue.take(output)), { + id: 2, + result: { interrupted: ["1"] }, + }); + }), + ); + it.effect("interrupts pending request handlers when the protocol terminates", () => Effect.gen(function* () { const { stdio, input } = yield* makeInMemoryStdio(); diff --git a/packages/effect-codex-app-server/src/protocol.ts b/packages/effect-codex-app-server/src/protocol.ts index b0ff5436e9e77..c6a28992cad16 100644 --- a/packages/effect-codex-app-server/src/protocol.ts +++ b/packages/effect-codex-app-server/src/protocol.ts @@ -2,6 +2,7 @@ import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FiberMap from "effect/FiberMap"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -176,6 +177,9 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const terminationFailure = yield* Ref.make(Option.none()); const terminationSignal = yield* Deferred.make(); const activeRequestHandlers = yield* Ref.make(0); + const requestHandlers = yield* FiberMap.make().pipe( + Scope.provide(requestHandlerScope), + ); const logProtocol = (event: CodexAppServerProtocolLogEvent) => { if (event.direction === "incoming" && !options.logIncoming) { @@ -343,7 +347,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa Effect.asVoid, ), ), - Effect.forkIn(requestHandlerScope, { startImmediately: true }), + FiberMap.run(requestHandlers, request.id), Effect.asVoid, ); }), @@ -352,8 +356,17 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa Effect.asVoid, ); + // Codex settles requests itself when their turn ends; a handler still waiting would hold its slot. + const releaseResolvedRequest = (notification: CodexAppServerIncomingNotification) => + notification.method === "serverRequest/resolved" && + isObject(notification.params) && + isJsonRpcId(notification.params.requestId) + ? FiberMap.remove(requestHandlers, notification.params.requestId) + : Effect.void; + const handleNotification = (notification: CodexAppServerIncomingNotification) => - Queue.offer(incomingNotifications, notification).pipe( + releaseResolvedRequest(notification).pipe( + Effect.andThen(Queue.offer(incomingNotifications, notification)), Effect.andThen(options.onNotification ? options.onNotification(notification) : Effect.void), Effect.asVoid, ); From ae1a3b10e67ed14cffff65be3e02036557f2097c Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:45:17 -0500 Subject: [PATCH 056/219] fix(claude): keep model upgrade notices when signed out (#23272) Co-authored-by: Claude Opus 5.5 --- apps/server/src/provider/ClaudeProvider.ts | 1 + .../src/provider/ProviderRegistry.test.ts | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 723101fd3447a..93baa797e7f8e 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -612,6 +612,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models, + updateRequiredModels, slashCommands: dedupedSlashCommands, skills, probe: { diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index f0560c8738d20..10771e6366529 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -2465,6 +2465,42 @@ it.layer( ), ); + it.effect("tells a logged-out CLI which models need a Claude Code update", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + tokenSource: "none", + apiKeySource: "none", + apiProvider: "firstParty", + }), + { ANTHROPIC_BASE_URL: "" }, + undefined, + { + models: [ + { + model: { slug: "claude-next", name: "Next", isCustom: false, capabilities: null }, + runtime: {}, + compatibility: { minVersion: "1.1.0" }, + }, + ], + }, + ); + assert.strictEqual(status.auth.status, "unauthenticated"); + assert.deepStrictEqual(status.updateRequiredModels, [ + { slug: "claude-next", name: "Next", minVersion: "1.1.0" }, + ]); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect.each([ { source: "inherited HTTPS endpoint", baseUrl: "https://proxy.example/api", overrides: [] }, { From 8f6c9b71444c56a3454e2eb40899672dc509e9da Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:45:33 -0500 Subject: [PATCH 057/219] fix(web): preserve typed dimensions with the aspect ratio locked (#23338) Co-authored-by: GPT-6.1 Sol --- .../BrowserDeviceToolbar.browser.test.tsx | 71 +++++++++++++++++++ apps/web/src/browser/BrowserDeviceToolbar.tsx | 34 ++++++--- 2 files changed, 96 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/browser/BrowserDeviceToolbar.browser.test.tsx diff --git a/apps/web/src/browser/BrowserDeviceToolbar.browser.test.tsx b/apps/web/src/browser/BrowserDeviceToolbar.browser.test.tsx new file mode 100644 index 0000000000000..65b58b6c4f683 --- /dev/null +++ b/apps/web/src/browser/BrowserDeviceToolbar.browser.test.tsx @@ -0,0 +1,71 @@ +import type { PreviewViewportSetting } from "@t3tools/contracts"; +import { useState } from "react"; +import { expect, it } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { render } from "../../test/render"; +import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; + +function Viewport({ width = 1920, height = 1080 }: { width?: number; height?: number }) { + const [setting, setSetting] = useState({ + _tag: "freeform", + width, + height, + }); + const [aspectRatio, setAspectRatio] = useState(width / height); + if (setting._tag === "fill") throw new Error("Expected fixed viewport"); + return ( + <> + setSetting(next)} + /> + + {setting.width} × {setting.height} + + + + ); +} + +it.each([ + { width: 1920, height: 1080, axis: "width", typed: "2560", expected: "2560 × 1440" }, + { width: 390, height: 844, axis: "height", typed: "2532", expected: "1170 × 2532" }, +])("keeps each typed $axis digit until the locked size is committed", async (testCase) => { + await using screen = await render(); + const input = screen.getByRole("spinbutton", { name: `Viewport ${testCase.axis}` }); + await userEvent.clear(input); + await userEvent.type(input, testCase.typed); + await expect.element(input).toHaveValue(Number(testCase.typed)); + await screen.getByRole("button", { name: "Leave toolbar" }).click(); + await expect + .element(screen.getByRole("status", { name: "Saved viewport" })) + .toHaveTextContent(testCase.expected); +}); + +it("clamps a completed width below the locked minimum when leaving the toolbar", async () => { + await using screen = await render(); + const input = screen.getByRole("spinbutton", { name: "Viewport width" }); + await userEvent.clear(input); + await userEvent.type(input, "256"); + await expect.element(input).toHaveValue(256); + await screen.getByRole("button", { name: "Leave toolbar" }).click(); + await expect + .element(screen.getByRole("status", { name: "Saved viewport" })) + .toHaveTextContent("427 × 240"); +}); + +it("still limits a completed locked size to the viewport area cap", async () => { + await using screen = await render(); + const input = screen.getByRole("spinbutton", { name: "Viewport width" }); + await userEvent.clear(input); + await userEvent.type(input, "3840"); + await expect.element(input).toHaveValue(3840); + await screen.getByRole("button", { name: "Leave toolbar" }).click(); + await expect + .element(screen.getByRole("status", { name: "Saved viewport" })) + .toHaveTextContent("2880 × 2880"); +}); diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx index dd5b707cfebdb..977431aa6ff4d 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.tsx +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -51,6 +51,7 @@ export function BrowserDeviceToolbar({ const [customSize, setCustomSize] = useState<{ readonly width: string; readonly height: string; + readonly axis?: "width" | "height"; } | null>(null); const presentedSize = customSize ?? { width: String(setting.width), @@ -61,15 +62,25 @@ export function BrowserDeviceToolbar({ PREVIEW_VIEWPORT_PRESETS.some((preset) => preset.id === setting.presetId) ? setting.presetId : RESPONSIVE_VALUE; - const customWidth = Number(presentedSize.width); - const customHeight = Number(presentedSize.height); + const typedWidth = Number(presentedSize.width); + const typedHeight = Number(presentedSize.height); + const { width: customWidth, height: customHeight } = + aspectRatio === null || customSize?.axis === undefined + ? { width: typedWidth, height: typedHeight } + : resizeFreeformViewport( + { width: typedWidth, height: typedHeight }, + { x: 0, y: 0 }, + 1, + customSize?.axis === "height" ? "south" : "east", + aspectRatio, + ); const customValid = - Number.isInteger(customWidth) && - Number.isInteger(customHeight) && - customWidth >= PREVIEW_VIEWPORT_MIN_DIMENSION && - customWidth <= PREVIEW_VIEWPORT_MAX_DIMENSION && - customHeight >= PREVIEW_VIEWPORT_MIN_DIMENSION && - customHeight <= PREVIEW_VIEWPORT_MAX_DIMENSION && + Number.isInteger(typedWidth) && + Number.isInteger(typedHeight) && + typedWidth >= PREVIEW_VIEWPORT_MIN_DIMENSION && + typedWidth <= PREVIEW_VIEWPORT_MAX_DIMENSION && + typedHeight >= PREVIEW_VIEWPORT_MIN_DIMENSION && + typedHeight <= PREVIEW_VIEWPORT_MAX_DIMENSION && customWidth * customHeight <= PREVIEW_VIEWPORT_MAX_AREA; const apply = (next: PreviewViewportSetting, nextAspectRatio = aspectRatio) => { @@ -96,6 +107,7 @@ export function BrowserDeviceToolbar({ const next = { width: axis === "width" ? value : (current?.width ?? String(setting.width)), height: axis === "height" ? value : (current?.height ?? String(setting.height)), + axis, }; const numeric = Number(value); if ( @@ -115,7 +127,11 @@ export function BrowserDeviceToolbar({ axis === "width" ? "east" : "south", aspectRatio, ); - return { width: String(resized.width), height: String(resized.height) }; + return { + ...next, + width: axis === "width" ? value : String(resized.width), + height: axis === "height" ? value : String(resized.height), + }; }); }; From 87b36c77fb5cdb26911d2419f62a23dbab6becc5 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Fri, 30 Oct 2026 20:45:50 +0000 Subject: [PATCH 058/219] fix(codex): preserve partial answer messages and thread histories (#23388) Co-authored-by: SAPHID Co-authored-by: maria-rcks --- .../Adapters/CodexAdapterV2.test.ts | 35 ++++- .../Adapters/CodexAdapterV2.ts | 4 +- .../scripts/generate.ts | 23 +++ .../src/_generated/schema.gen.ts | 135 +++++++++++++++--- .../src/schema.test.ts | 46 ++++++ 5 files changed, 219 insertions(+), 24 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 6413ed0bfe9ac..8efdc1b468b3a 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -3201,7 +3201,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { answers: ReadonlyArray<{ readonly id: string; readonly text: string; - readonly phase?: "commentary" | "final_answer" | null; + readonly phase?: "commentary" | "partial_answer" | "final_answer" | null; readonly omitPhase?: boolean; readonly streamed?: boolean; readonly completionDelayMs?: number; @@ -3296,6 +3296,39 @@ describe("CodexAdapterV2 post-settle continuation", () => { }); }; + it.effect.each([false, true])( + "preserves a final answer that repeats a preceding partial answer (streamed: %s)", + (streamed) => + Effect.scoped( + Effect.gen(function* () { + const transcript = finalAnswerTranscript("codex-partial-then-final", [ + { id: "partial", text: "CODEX_RECOVERY_OK", phase: "partial_answer", streamed }, + { id: "final", text: "CODEX_RECOVERY_OK", phase: "final_answer", streamed }, + ]); + const harness = yield* makeCodexReplayHarness(transcript); + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-partial-then-final"), + text: "Reply with the requested recovery marker.", + }), + ); + yield* harness.awaitTerminal(1); + assert.deepEqual( + assistantMessages(harness.events).map((event) => event.message.text), + ["CODEX_RECOVERY_OK", "CODEX_RECOVERY_OK"], + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect.each([ { name: "a text reply gains each saved image once", diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 1ebb5b5fb12d4..37426780466f8 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -4769,7 +4769,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } if (payload.item.type === "agentMessage") { - if (payload.item.phase !== "commentary") { + if (payload.item.phase == null || payload.item.phase === "final_answer") { yield* Ref.update(finalAnswerItemIdsByTurn, (current) => { const updated = new Map(current); const itemIds = new Set(updated.get(payload.turnId) ?? []); @@ -5132,7 +5132,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( return; } - const finalAnswer = payload.item.phase !== "commentary"; + const finalAnswer = payload.item.phase == null || payload.item.phase === "final_answer"; if (finalAnswer) { yield* Ref.update(finalAnswerItemIdsByTurn, (current) => { const updated = new Map(current); diff --git a/packages/effect-codex-app-server/scripts/generate.ts b/packages/effect-codex-app-server/scripts/generate.ts index 23ce2e1edd360..8f9a45bb9293c 100644 --- a/packages/effect-codex-app-server/scripts/generate.ts +++ b/packages/effect-codex-app-server/scripts/generate.ts @@ -145,6 +145,29 @@ const ManualSchemas: Record = { // `account/read` decode and take the provider down with it. const DefinitionOverrides: Record = { PlanType: { type: "string" }, + // Newer custom Codex binaries emit partial answers before additional tools or text. + MessagePhase: { + description: + 'Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as "phase unknown" and keep compatibility behavior for legacy models.', + oneOf: [ + { + type: "string", + enum: ["commentary"], + description: + "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + }, + { + type: "string", + enum: ["partial_answer"], + description: "Stable answer text that may be followed by more assistant output or tools.", + }, + { + type: "string", + enum: ["final_answer"], + description: "The assistant's terminal answer text for the current turn.", + }, + ], + }, }; const getGeneratedPaths = Effect.fn("getGeneratedPaths")(function* () { diff --git a/packages/effect-codex-app-server/src/_generated/schema.gen.ts b/packages/effect-codex-app-server/src/_generated/schema.gen.ts index 90006c016978b..90869b56aa89d 100644 --- a/packages/effect-codex-app-server/src/_generated/schema.gen.ts +++ b/packages/effect-codex-app-server/src/_generated/schema.gen.ts @@ -1532,13 +1532,16 @@ export const ClientRequest__InternalChatMessageMetadataPassthrough = Schema.Stru identifier: "ClientRequest__InternalChatMessageMetadataPassthrough", }); -export type ClientRequest__MessagePhase = "commentary" | "final_answer"; +export type ClientRequest__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const ClientRequest__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -2219,13 +2222,16 @@ export const ServerNotification__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "ServerNotification__MemoryCitationEntry" }); -export type ServerNotification__MessagePhase = "commentary" | "final_answer"; +export type ServerNotification__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const ServerNotification__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -5991,13 +5997,19 @@ export const V2ItemCompletedNotification__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ItemCompletedNotification__MemoryCitationEntry" }); -export type V2ItemCompletedNotification__MessagePhase = "commentary" | "final_answer"; +export type V2ItemCompletedNotification__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2ItemCompletedNotification__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -6538,13 +6550,19 @@ export const V2ItemStartedNotification__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ItemStartedNotification__MemoryCitationEntry" }); -export type V2ItemStartedNotification__MessagePhase = "commentary" | "final_answer"; +export type V2ItemStartedNotification__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2ItemStartedNotification__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -7915,13 +7933,19 @@ export const V2RawResponseItemCompletedNotification__InternalChatMessageMetadata identifier: "V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough", }); -export type V2RawResponseItemCompletedNotification__MessagePhase = "commentary" | "final_answer"; +export type V2RawResponseItemCompletedNotification__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2RawResponseItemCompletedNotification__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -8215,13 +8239,16 @@ export const V2ReviewStartResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ReviewStartResponse__MemoryCitationEntry" }); -export type V2ReviewStartResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ReviewStartResponse__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const V2ReviewStartResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -8927,13 +8954,16 @@ export const V2ThreadForkResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadForkResponse__MemoryCitationEntry" }); -export type V2ThreadForkResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadForkResponse__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const V2ThreadForkResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -9382,13 +9412,19 @@ export const V2ThreadItemsListResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadItemsListResponse__MemoryCitationEntry" }); -export type V2ThreadItemsListResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadItemsListResponse__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2ThreadItemsListResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -9872,13 +9908,16 @@ export const V2ThreadListResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadListResponse__MemoryCitationEntry" }); -export type V2ThreadListResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadListResponse__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const V2ThreadListResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -10364,13 +10403,19 @@ export const V2ThreadMetadataUpdateResponse__MemoryCitationEntry = Schema.Struct path: Schema.String, }).annotate({ identifier: "V2ThreadMetadataUpdateResponse__MemoryCitationEntry" }); -export type V2ThreadMetadataUpdateResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadMetadataUpdateResponse__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2ThreadMetadataUpdateResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -10822,13 +10867,16 @@ export const V2ThreadReadResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadReadResponse__MemoryCitationEntry" }); -export type V2ThreadReadResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadReadResponse__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const V2ThreadReadResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -11382,13 +11430,16 @@ export const V2ThreadResumeParams__InternalChatMessageMetadataPassthrough = Sche identifier: "V2ThreadResumeParams__InternalChatMessageMetadataPassthrough", }); -export type V2ThreadResumeParams__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadResumeParams__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const V2ThreadResumeParams__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -11755,13 +11806,16 @@ export const V2ThreadResumeResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadResumeResponse__MemoryCitationEntry" }); -export type V2ThreadResumeResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadResumeResponse__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const V2ThreadResumeResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -12202,13 +12256,16 @@ export const V2ThreadRevertResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadRevertResponse__MemoryCitationEntry" }); -export type V2ThreadRevertResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadRevertResponse__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const V2ThreadRevertResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -12841,13 +12898,19 @@ export const V2ThreadStartedNotification__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadStartedNotification__MemoryCitationEntry" }); -export type V2ThreadStartedNotification__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadStartedNotification__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2ThreadStartedNotification__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -13469,13 +13532,16 @@ export const V2ThreadStartResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadStartResponse__MemoryCitationEntry" }); -export type V2ThreadStartResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadStartResponse__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const V2ThreadStartResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -13910,13 +13976,19 @@ export const V2ThreadTurnsListResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadTurnsListResponse__MemoryCitationEntry" }); -export type V2ThreadTurnsListResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadTurnsListResponse__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2ThreadTurnsListResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -14384,13 +14456,19 @@ export const V2ThreadUnarchiveResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadUnarchiveResponse__MemoryCitationEntry" }); -export type V2ThreadUnarchiveResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadUnarchiveResponse__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2ThreadUnarchiveResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -14785,13 +14863,19 @@ export const V2TurnCompletedNotification__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2TurnCompletedNotification__MemoryCitationEntry" }); -export type V2TurnCompletedNotification__MessagePhase = "commentary" | "final_answer"; +export type V2TurnCompletedNotification__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2TurnCompletedNotification__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -15200,13 +15284,19 @@ export const V2TurnStartedNotification__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2TurnStartedNotification__MemoryCitationEntry" }); -export type V2TurnStartedNotification__MessagePhase = "commentary" | "final_answer"; +export type V2TurnStartedNotification__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2TurnStartedNotification__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -15729,13 +15819,16 @@ export const V2TurnStartResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2TurnStartResponse__MemoryCitationEntry" }); -export type V2TurnStartResponse__MessagePhase = "commentary" | "final_answer"; +export type V2TurnStartResponse__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const V2TurnStartResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), diff --git a/packages/effect-codex-app-server/src/schema.test.ts b/packages/effect-codex-app-server/src/schema.test.ts index 2f6fefde0d606..dfbedf86af978 100644 --- a/packages/effect-codex-app-server/src/schema.test.ts +++ b/packages/effect-codex-app-server/src/schema.test.ts @@ -33,6 +33,52 @@ it("keeps async questions in live notifications and thread history", () => { } }); +it("keeps partial answers in notifications and persisted thread histories", () => { + const item = { + type: "agentMessage", + id: "partial-1", + text: "A stable answer before more tool work.", + phase: "partial_answer", + } as const; + for (const schema of [ + CodexSchema.ServerNotification__ThreadItem, + CodexSchema.V2ItemStartedNotification__ThreadItem, + CodexSchema.V2ItemCompletedNotification__ThreadItem, + CodexSchema.V2ThreadReadResponse__ThreadItem, + CodexSchema.V2ThreadResumeResponse__ThreadItem, + CodexSchema.V2ThreadForkResponse__ThreadItem, + CodexSchema.V2TurnCompletedNotification__ThreadItem, + ]) { + assert.deepEqual(Schema.decodeSync(schema)(item), item); + } + const response = { + approvalPolicy: "never", + approvalsReviewer: "user", + cwd: "/tmp/project", + model: "gpt-5.6-sol", + modelProvider: "openai", + sandbox: { type: "dangerFullAccess" }, + thread: { + cliVersion: "0.162.0", + createdAt: 0, + updatedAt: 0, + cwd: "/tmp/project", + ephemeral: false, + id: "thread-1", + modelProvider: "openai", + preview: "", + projectId: null, + sessionId: "session-1", + source: "cli", + status: { type: "idle" }, + turns: [{ id: "turn-1", status: "completed", items: [item] }], + }, + }; + assert.equal(isThreadForkResponse(response), true); + assert.equal(isThreadResumeResponse(response), true); + assert.equal(isThreadReadResponse({ thread: response.thread }), true); +}); + it("accepts Codex 0.150 multi-agent values", () => { const schemas = [ CodexSchema.ServerNotification__SubAgentActivityKind, From e92981dcf3f79c13aff01f975d68ea80bf4b2770 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:46:05 -0500 Subject: [PATCH 059/219] fix(server): list folders named with a backslash on Linux and macOS (#23398) Co-authored-by: Claude Opus 5.5 --- .../src/workspace/WorkspacePaths.test.ts | 22 +++++++++++++++++++ apps/server/src/workspace/WorkspacePaths.ts | 9 ++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/server/src/workspace/WorkspacePaths.test.ts b/apps/server/src/workspace/WorkspacePaths.test.ts index 574904b343563..b178c621453b8 100644 --- a/apps/server/src/workspace/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/WorkspacePaths.test.ts @@ -5,6 +5,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + import * as WorkspacePaths from "./WorkspacePaths.ts"; const layerTest = Layer.empty.pipe( @@ -58,5 +60,25 @@ it.layer(layerTest)("WorkspacePathsLive", (it) => { ); }), ); + + // On Windows `\` is a separator, so these names cannot exist there. + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "keeps a backslash in a name as part of the name", + () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const path = yield* Path.Path; + const cwd = yield* makeTempDir(); + + for (const relativePath of ["weird\\name.txt", "..\\..\\etc"]) { + expect( + yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: cwd, + relativePath, + }), + ).toEqual({ absolutePath: path.join(cwd, relativePath), relativePath }); + } + }), + ); }); }); diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index 6b748e19396ec..ea183ed43e1ab 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -103,10 +103,6 @@ export class WorkspacePaths extends Context.Service< } >()("t3/workspace/WorkspacePaths") {} -function toPosixRelativePath(input: string): string { - return input.replaceAll("\\", "/"); -} - const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -188,7 +184,10 @@ const make = Effect.gen(function* () { } const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath); - const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath)); + // Only the host separator becomes `/`: on POSIX `\` is a file name character. + const relativeToRoot = path + .relative(input.workspaceRoot, absolutePath) + .replaceAll(path.sep, "/"); if ( relativeToRoot.length === 0 || relativeToRoot === "." || From 14d56e18f94b73fc5a1cecf2078d8530ab1304ad Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:16:16 +0530 Subject: [PATCH 060/219] fix(server): preserve byte-order marks in Git diff contents (#23421) --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 21 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 3566a48fc4caf..c0f0967725e58 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2536,6 +2536,27 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect.each([ + { scenario: "kept", before: "\uFEFFold\n", after: "\uFEFFnew\n" }, + { scenario: "added", before: "same\n", after: "\uFEFFsame\n" }, + { scenario: "removed", before: "\uFEFFsame\n", after: "same\n" }, + ])("preserves byte-order marks in diff expansion when $scenario", ({ before, after }) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "README.md", before); + yield* git(cwd, ["commit", "-am", "write original contents"]); + yield* writeTextFile(cwd, "README.md", after); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const contents = yield* driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd), + ); + + assert.deepStrictEqual(contents, { oldContents: before, newContents: after }); + }), + ); + it.effect("reads a base ref that looks like an option as a revision", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 79086ab9e9fa1..421c240514e42 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -844,7 +844,7 @@ const collectOutput = Effect.fnUntraced(function* ( onLine: ((line: string) => Effect.Effect) | undefined, keepLineCallbacksAfterTruncation = false, ): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> { - const decoder = new TextDecoder(); + const decoder = new TextDecoder("utf-8", { ignoreBOM: true }); const lineDecoder = keepLineCallbacksAfterTruncation && onLine ? new TextDecoder() : null; let bytes = 0; let text = ""; @@ -3562,7 +3562,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (bytes.includes(0)) { return yield* fileError("fs.readFile", `Cannot expand binary file '${input.newPath}'.`); } - return new TextDecoder("utf-8").decode(bytes); + return new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes); }); const getReviewDiffFileContents = Effect.fn("getReviewDiffFileContents")(function* ( From c65e4883596e6ca06c71176006b1b7fb61995a2e Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:46:32 -0500 Subject: [PATCH 061/219] fix(web): save settings fields only after an edit (#23427) Co-authored-by: GPT-6.1 Sol --- .../hooks/useCommitOnBlur.browser.test.tsx | 41 +++++++++++++++++++ apps/web/src/hooks/useCommitOnBlur.ts | 6 ++- 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/hooks/useCommitOnBlur.browser.test.tsx diff --git a/apps/web/src/hooks/useCommitOnBlur.browser.test.tsx b/apps/web/src/hooks/useCommitOnBlur.browser.test.tsx new file mode 100644 index 0000000000000..cec4f8cb8ea5b --- /dev/null +++ b/apps/web/src/hooks/useCommitOnBlur.browser.test.tsx @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { render } from "../../test/render"; +import { DraftInput } from "../components/ui/draft-input"; + +describe("settings draft commits", () => { + it("preserves an external update when an untouched field loses focus", async () => { + let saved = "~/"; + const commit = (next: string) => { + saved = next; + }; + await using screen = await render(); + const input = screen.getByRole("textbox"); + await input.click(); + + saved = "/projects"; + await screen.rerender(); + await userEvent.tab(); + + expect(saved).toBe("/projects"); + }); + + it("preserves a saved edit when refocused before its server snapshot arrives", async () => { + let saved = "~/"; + const commit = (next: string) => { + saved = next; + }; + await using screen = await render(); + const input = screen.getByRole("textbox"); + await input.fill("/projects"); + await userEvent.tab(); + expect(saved).toBe("/projects"); + + await input.click(); + await screen.rerender(); + await userEvent.tab(); + + expect(saved).toBe("/projects"); + }); +}); diff --git a/apps/web/src/hooks/useCommitOnBlur.ts b/apps/web/src/hooks/useCommitOnBlur.ts index 6906402d718ab..022f79a2a58bb 100644 --- a/apps/web/src/hooks/useCommitOnBlur.ts +++ b/apps/web/src/hooks/useCommitOnBlur.ts @@ -1,7 +1,8 @@ -import { type ChangeEvent, type KeyboardEvent, useState } from "react"; +import { type ChangeEvent, type KeyboardEvent, useRef, useState } from "react"; export function useCommitOnBlur(value: string, onCommit: (next: string) => void) { const [draft, setDraft] = useState(null); + const valueOnFocus = useRef(value); return { value: draft ?? value, @@ -9,12 +10,13 @@ export function useCommitOnBlur(value: string, onCommit: (next: string) => void) setDraft(event.target.value); }, onFocus: () => { + valueOnFocus.current = value; setDraft(value); }, onBlur: () => { const next = draft ?? value; setDraft(null); - if (next !== value) { + if (next !== valueOnFocus.current && next !== value) { onCommit(next); } }, From 2b1e15c999aed3b8a06238b467d74f9cd9a1aeae Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:18:09 +0530 Subject: [PATCH 062/219] fix(shared): preserve hyphens in prerelease versions (#23430) Co-authored-by: maria-rcks --- packages/shared/src/semver.test.ts | 18 ++++++++++++++++++ packages/shared/src/semver.ts | 7 +++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/semver.test.ts b/packages/shared/src/semver.test.ts index f77926313dd72..a66e5b07d8bcc 100644 --- a/packages/shared/src/semver.test.ts +++ b/packages/shared/src/semver.test.ts @@ -35,6 +35,24 @@ describe("semver helpers", () => { expect(compareSemverVersions("2.1.111-beta.1", "2.1.111")).toBeLessThan(0); }); + it("preserves hyphens within prerelease identifiers", () => { + expect(normalizeSemverVersion("20-alpha-1")).toBe("20.0.0-alpha-1"); + expect(parseSemver("1.2.3-alpha-1.2")).toEqual({ + major: 1, + minor: 2, + patch: 3, + prerelease: ["alpha-1", "2"], + }); + expect(parseSemver("1.2.3-x-y-z.--")?.prerelease).toEqual(["x-y-z", "--"]); + }); + + it("distinguishes releases whose identifiers differ after a hyphen", () => { + expect(compareSemverVersions("1.2.3-alpha-1", "1.2.3-alpha-2")).toBeLessThan(0); + expect(compareSemverVersions("1.2.3-alpha-2", "1.2.3-alpha-1")).toBeGreaterThan(0); + expect(compareSemverVersions("1.2.3-alpha-1.2", "1.2.3-alpha-1.10")).toBeLessThan(0); + expect(compareSemverVersions("1.2.3-alpha.1+build-2", "1.2.3-alpha.1+build-3")).toBe(0); + }); + it("supports comparison comparators", () => { expect(satisfiesSemverRange("24.9.0", ">=24.0 <24.10")).toBe(true); expect(satisfiesSemverRange("24.10.0", ">=24.0 <24.10")).toBe(false); diff --git a/packages/shared/src/semver.ts b/packages/shared/src/semver.ts index 88ae4754f1809..8d1a354f008bc 100644 --- a/packages/shared/src/semver.ts +++ b/packages/shared/src/semver.ts @@ -8,7 +8,9 @@ interface ParsedSemver { const SEMVER_NUMBER_SEGMENT = /^\d+$/; export function normalizeSemverVersion(version: string): string { - const [main, prerelease] = version.trim().split("-", 2); + const [versionWithoutBuildMetadata = ""] = version.trim().split("+", 1); + const [main, ...prereleaseParts] = versionWithoutBuildMetadata.split("-"); + const prerelease = prereleaseParts.join("-"); const segments: string[] = []; for (const segment of (main ?? "").split(".")) { const trimmed = segment.trim(); @@ -26,7 +28,8 @@ export function normalizeSemverVersion(version: string): string { export function parseSemver(value: string): ParsedSemver | null { const normalized = normalizeSemverVersion(value).replace(/^v/, ""); - const [main = "", prerelease] = normalized.split("-", 2); + const [main = "", ...prereleaseParts] = normalized.split("-"); + const prerelease = prereleaseParts.join("-"); const segments = main.split("."); if (segments.length !== 3) { return null; From b0410a445397a695782e5ca27da8515e5f845d70 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:18:25 +0530 Subject: [PATCH 063/219] fix(browser): serialize concurrent profile clears (#23434) --- .../src/preview/ServerBrowserContexts.test.ts | 23 +++++++++++++++++++ .../src/preview/ServerBrowserContexts.ts | 2 ++ 2 files changed, 25 insertions(+) diff --git a/apps/server/src/preview/ServerBrowserContexts.test.ts b/apps/server/src/preview/ServerBrowserContexts.test.ts index 3cab864de54f2..522df30fb66c8 100644 --- a/apps/server/src/preview/ServerBrowserContexts.test.ts +++ b/apps/server/src/preview/ServerBrowserContexts.test.ts @@ -183,6 +183,29 @@ describe("ServerBrowserContexts", () => { await pool.close(); }); + it("waits for the context to close when the same profile is cleared concurrently", async () => { + const human = makeContext(); + const closed = Promise.withResolvers(); + human.close.mockImplementation(async () => closed.promise); + launches.persistent.mockResolvedValue(human as unknown as BrowserContext); + const pool = new ServerBrowserContexts(options()); + await pool.contextFor("work/team"); + + const first = pool.clearProfile("work/team"); + const second = pool.clearProfile("work/team"); + const deletionsBeforeClose = launches.rm.mock.calls.length; + closed.resolve(); + await Promise.all([first, second]); + + expect(deletionsBeforeClose).toBe(0); + expect(human.close).toHaveBeenCalledTimes(1); + expect(launches.rm).toHaveBeenCalledExactlyOnceWith("/test/profiles/work%2Fteam", { + recursive: true, + force: true, + }); + await pool.close(); + }); + it("opens a profile requested during its clear only after the storage is deleted", async () => { launches.persistent.mockImplementation(async () => makeContext() as unknown as BrowserContext); const configuration = options(); diff --git a/apps/server/src/preview/ServerBrowserContexts.ts b/apps/server/src/preview/ServerBrowserContexts.ts index 9f3fd48430752..2a7c342d30d81 100644 --- a/apps/server/src/preview/ServerBrowserContexts.ts +++ b/apps/server/src/preview/ServerBrowserContexts.ts @@ -212,6 +212,8 @@ export class ServerBrowserContexts { async clearProfile(profileId: string) { if (profileId === INCOGNITO_BROWSER_PROFILE_ID) return; const key = JSON.stringify([profileId, null]); + const existing = this.clearing.get(key); + if (existing) return existing; const clearing = (async () => { const pending = this.contexts.get(key); if (pending) { From 3a537d2c916e61493e91fd104e3fc525116d980d Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:18:41 +0530 Subject: [PATCH 064/219] fix(diagnostics): include browser trace failures (#23438) --- .../src/diagnostics/TraceDiagnostics.test.ts | 29 +++++++++++++++++++ .../src/diagnostics/TraceDiagnostics.ts | 27 ++++++++--------- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/apps/server/src/diagnostics/TraceDiagnostics.test.ts b/apps/server/src/diagnostics/TraceDiagnostics.test.ts index 97156800e3db9..35909007c395c 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.test.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.test.ts @@ -61,6 +61,35 @@ function readTraces(fileSystem: Partial) { } describe("TraceDiagnostics", () => { + it("includes browser failures and interruptions in diagnostics", () => { + const diagnostics = aggregateLines( + [ + { status: { code: "2", message: "Browser request failed" }, attributes: {} }, + { status: { code: "0" }, attributes: { "effect.fiber.interrupted": true } }, + { status: { code: "1", message: "Interrupted" }, attributes: {} }, + { status: { code: "1" }, attributes: {} }, + ].map((outcome, index) => + JSON.stringify({ + type: "otlp-span", + name: "browser.request", + traceId: `trace-${index}`, + spanId: `span-${index}`, + startTimeUnixNano: ns(index * 100), + endTimeUnixNano: ns(index * 100 + 10), + durationMs: 10, + ...outcome, + }), + ), + ); + + assert.equal(diagnostics.recordCount, 4); + assert.equal(diagnostics.failureCount, 1); + assert.equal(diagnostics.interruptionCount, 2); + assert.equal(diagnostics.topSpansByCount[0]?.failureCount, 1); + assert.equal(diagnostics.commonFailures[0]?.cause, "Browser request failed"); + assert.equal(diagnostics.latestFailures[0]?.cause, "Browser request failed"); + }); + it.effect("aggregates failures, slow spans, log levels, and parse errors", () => Effect.sync(() => { const diagnostics = aggregateLines([ diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts index 0cd5ae9b3a750..6f25c424a7b83 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -18,17 +18,6 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -interface TraceRecordLike { - readonly name?: unknown; - readonly traceId?: unknown; - readonly spanId?: unknown; - readonly startTimeUnixNano?: unknown; - readonly endTimeUnixNano?: unknown; - readonly durationMs?: unknown; - readonly exit?: unknown; - readonly events?: unknown; -} - interface TraceEventLike { readonly name?: unknown; readonly timeUnixNano?: unknown; @@ -85,7 +74,7 @@ export function toRotatedTracePaths( return [...backups, traceFilePath]; } -function isRecordObject(value: unknown): value is TraceRecordLike { +function isRecordObject(value: unknown): value is Readonly> { return typeof value === "object" && value !== null; } @@ -253,8 +242,13 @@ export function makeTraceDiagnosticsAggregator( lastSpanAt === null || DateTime.isGreaterThan(endedAt, lastSpanAt) ? endedAt : lastSpanAt; const exitTag = readExitTag(parsed.exit); - const isFailure = exitTag === "Failure"; - const isInterrupted = exitTag === "Interrupted"; + const status = isRecordObject(parsed.status) ? parsed.status : {}; + const attributes = isRecordObject(parsed.attributes) ? parsed.attributes : {}; + const isFailure = exitTag === "Failure" || status.code === "2"; + const isInterrupted = + exitTag === "Interrupted" || + attributes["effect.fiber.interrupted"] === true || + status.message === "Interrupted"; if (isFailure) failureCount += 1; if (isInterrupted) interruptionCount += 1; @@ -277,7 +271,10 @@ export function makeTraceDiagnosticsAggregator( insertBounded(slowestSpans, spanItem, TOP_LIMIT, slowestFirst); if (isFailure) { - const cause = readExitCause(parsed.exit); + const cause = + exitTag === "Failure" + ? readExitCause(parsed.exit) + : (toStringValue(status.message)?.trim() ?? "Failure"); insertBounded(latestFailures, { ...spanItem, cause }, RECENT_LIMIT, latestEndedFirst); const failureKey = `${name}\0${cause}`; From 9522c7dd8f7050db198ee758e66bc62a146db1e5 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:18:57 +0530 Subject: [PATCH 065/219] fix(acp): forward empty plan replacements (#23440) --- .../src/server/runtimeModel.test.ts | 29 +++++++++++++++++++ .../provider-acp/src/server/runtimeModel.ts | 20 ++++++------- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/packages/provider-acp/src/server/runtimeModel.test.ts b/packages/provider-acp/src/server/runtimeModel.test.ts index e60671c775d51..45063ffc325dd 100644 --- a/packages/provider-acp/src/server/runtimeModel.test.ts +++ b/packages/provider-acp/src/server/runtimeModel.test.ts @@ -329,6 +329,35 @@ describe("AcpRuntimeModel", () => { ).toBe(true); }); + it("forwards an empty ACP v1 plan to clear previous entries", () => { + const entries = [ + [{ content: "Inspect state", priority: "high", status: "pending" }], + [], + ] as const; + const updates = entries.flatMap( + (plan) => + parseSessionUpdateEvent({ + sessionId: "session-1", + update: { sessionUpdate: "plan", entries: plan }, + }).events, + ); + + expect(updates).toMatchObject([ + { + _tag: "PlanUpdated", + payload: { + nativePlanId: "legacy", + kind: "items", + plan: [{ step: "Inspect state", status: "pending" }], + }, + }, + { + _tag: "PlanUpdated", + payload: { nativePlanId: "legacy", kind: "items", plan: [] }, + }, + ]); + }); + it("projects typed ACP plan and content updates", () => { const planResult = parseSessionUpdateEvent({ sessionId: "session-1", diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts index 43fa2bf758e2f..ca6ed15db80d1 100644 --- a/packages/provider-acp/src/server/runtimeModel.ts +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -1392,17 +1392,15 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat step: entry.content.trim().length > 0 ? entry.content.trim() : `Step ${index + 1}`, status: normalizePlanStepStatus(entry.status), })); - if (plan.length > 0) { - events.push({ - _tag: "PlanUpdated", - payload: { - nativePlanId: "legacy", - kind: "items", - plan, - }, - rawPayload: params, - }); - } + events.push({ + _tag: "PlanUpdated", + payload: { + nativePlanId: "legacy", + kind: "items", + plan, + }, + rawPayload: params, + }); break; } case "plan_update": { From b665f0b11c351935bea5aa633416f9a32c9c6b08 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:49:13 -0500 Subject: [PATCH 066/219] fix(web): recognize plain text aliases in code fences (#23446) Co-authored-by: GPT-6.1 Sol --- apps/web/src/composer-code-languages.test.ts | 11 +++++++++++ apps/web/src/composer-code-languages.ts | 5 ++++- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/composer-code-languages.test.ts diff --git a/apps/web/src/composer-code-languages.test.ts b/apps/web/src/composer-code-languages.test.ts new file mode 100644 index 0000000000000..0e8c3f0188f91 --- /dev/null +++ b/apps/web/src/composer-code-languages.test.ts @@ -0,0 +1,11 @@ +import { expect, it } from "vite-plus/test"; + +import { codeLanguageEntry, languageOfInfoString } from "./composer-code-languages"; + +it("recognizes plain-text fence info strings without treating metadata as the language", () => { + const entries = ["Text", "TEXT", "plaintext", "txt"].map((language) => + codeLanguageEntry(languageOfInfoString(` ${language} title=notes.txt`)), + ); + + expect(entries).toEqual(Array.from({ length: 4 }, () => ({ id: "", label: "Plain text" }))); +}); diff --git a/apps/web/src/composer-code-languages.ts b/apps/web/src/composer-code-languages.ts index 42558162d9fde..641b75b6926df 100644 --- a/apps/web/src/composer-code-languages.ts +++ b/apps/web/src/composer-code-languages.ts @@ -47,12 +47,15 @@ const LANGUAGE_ALIASES: Record = { "c++": "cpp", js: "javascript", md: "markdown", + plaintext: "", py: "python", rb: "ruby", rs: "rust", sh: "bash", shell: "bash", + text: "", ts: "typescript", + txt: "", yml: "yaml", }; @@ -69,7 +72,7 @@ export function withInfoStringLanguage(info: string, language: string): string { } export function codeLanguageEntry(language: string): CodeBlockLanguage | null { - const id = language === "text" ? "" : (LANGUAGE_ALIASES[language.toLowerCase()] ?? language); + const id = LANGUAGE_ALIASES[language.toLowerCase()] ?? language; return CODE_BLOCK_LANGUAGES.find((entry) => entry.id === id.toLowerCase()) ?? null; } From 42ca5d560d053831e5c26ea826291d56ab94052c Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:49:30 -0500 Subject: [PATCH 067/219] fix(web): keep deleted clipboard attachments out of the draft (#23447) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/chat/ChatComposer.tsx | 1 + .../useComposerContextImport.browser.test.tsx | 188 ++++++++++++++++++ .../chat/useComposerContextImport.ts | 17 ++ 3 files changed, 206 insertions(+) create mode 100644 apps/web/src/components/chat/useComposerContextImport.browser.test.tsx diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ae0bb19336e0e..e08e74a49063b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1356,6 +1356,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerContextRecords, composerImages, composerFilesRef, + promptRef, pendingThreadImportsRef, addComposerFilesToDraft, }); diff --git a/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx new file mode 100644 index 0000000000000..a050b0f51edf0 --- /dev/null +++ b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx @@ -0,0 +1,188 @@ +import { useEffect } from "react"; +import { type ComposerContextRecord, ComposerContextId, EnvironmentId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render, stubGlobal } from "../../../test/render"; +import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { formatInlineContextReference } from "../../lib/composerContextReferences"; +import { rememberCopiedComposerAttachments } from "../../lib/copiedComposerAttachments"; +import { pendingDraftWork } from "./pendingDraftWork"; +import { useComposerContextImport } from "./useComposerContextImport"; + +vi.mock("~/state/use-atom-query-runner", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomQueryRunner: () => async () => ({ _tag: "Success", value: { relativeUrl: "/file" } }), +})); +vi.mock("../../state/use-atom-command", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomCommand: () => () => {}, +})); +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), + readPreparedConnection: () => ({ httpBaseUrl: "http://localhost" }), +})); + +type ImportInput = Parameters[0]; +type Importer = ReturnType; + +function Harness({ input, ready }: { input: ImportInput; ready: (value: Importer) => void }) { + const importer = useComposerContextImport(input); + useEffect(() => ready(importer)); + return null; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((fulfill) => { + resolve = fulfill; + }); + return { promise, resolve }; +} + +it.each([ + { kind: "file", edit: "delete" }, + { kind: "image", edit: "delete" }, + { kind: "file", edit: "undo" }, + { kind: "file", edit: "retain" }, + { kind: "file", edit: "switch" }, + { kind: "file", edit: "cached" }, + { kind: "file", edit: "question" }, + { kind: "image", edit: "question" }, + { kind: "image", edit: "annotation" }, + { kind: "image", edit: "delete-annotation" }, +] as const)("settles a delayed $kind import after $edit", async ({ kind, edit }) => { + const target = DraftId.make(`clipboard-${kind}-${edit}`); + const store = useComposerDraftStore.getState(); + using _draft = { [Symbol.dispose]: () => store.clearComposerContent(target) }; + const sourceEnvironmentId = EnvironmentId.make("clipboard-source"); + rememberCopiedComposerAttachments( + sourceEnvironmentId, + edit === "cached" + ? new Map([["source-attachment", new File(["notes"], "notes.txt")]]) + : new Map(), + ); + using _copy = { + [Symbol.dispose]: () => rememberCopiedComposerAttachments(sourceEnvironmentId, new Map()), + }; + const promptRef = { current: "" }; + const downloaded = deferred(); + const requested = deferred(); + const settled = deferred(); + const input = { + environmentId: EnvironmentId.make("clipboard-target"), + linkedEnvironments: new Map(), + activeThread: undefined, + activeThreadId: null, + composerDraftTarget: target, + attachmentDraftTarget: target, + attachmentTargetKey: target, + attachmentTargetKeyRef: { current: target }, + questionAttachmentTarget: edit === "question" ? target : null, + pendingUserInputs: [], + composerContextRecords: new Map(), + composerImages: [], + composerFilesRef: { current: [] }, + pendingThreadImportsRef: { current: new Map() }, + promptRef, + addComposerFilesToDraft: (files: Parameters[1]) => + store.addFiles(target, files, { appendReference: edit !== "question" }), + } satisfies ImportInput & { promptRef: { current: string } }; + let importer!: Importer; + await using _screen = await render( + { + importer = value; + }} + />, + ); + const end = pendingDraftWork.end.bind(pendingDraftWork); + using _settled = vi.spyOn(pendingDraftWork, "end").mockImplementation((key) => { + end(key); + settled.resolve(); + }); + using _fetch = stubGlobal("fetch", () => { + requested.resolve(); + return downloaded.promise; + }); + const contextId = ComposerContextId.make(`${kind}_source`); + const attachmentFields = { + version: 1 as const, + contextId, + label: "notes.txt", + attachmentId: "source-attachment", + name: "notes.txt", + mimeType: kind === "file" ? "text/plain" : "image/png", + sizeBytes: 5, + }; + const attachment = ( + kind === "file" + ? { ...attachmentFields, kind: "file" as const } + : { ...attachmentFields, kind: "image" as const } + ) satisfies ComposerContextRecord; + const annotation = { + version: 1, + kind: "preview-annotation", + contextId: ComposerContextId.make("preview-annotation_source"), + label: "Page annotation", + annotationId: "source", + pageUrl: "http://localhost/page", + pageTitle: "Page", + comment: "Check this", + targetSummary: "Page", + styleChanges: [], + screenshotContextId: contextId, + } satisfies ComposerContextRecord; + const annotated = edit === "annotation" || edit === "delete-annotation"; + let pastedPrompt = ""; + await act(async () => { + const remapped = importer.importContextRecords( + annotated ? [attachment, annotation] : [attachment], + sourceEnvironmentId, + ); + pastedPrompt = formatInlineContextReference({ + kind: annotated ? "preview-annotation" : kind, + contextId: remapped.get(annotated ? annotation.contextId : contextId)!, + label: annotated ? "Page annotation" : "notes.txt", + }); + if (edit === "question") pastedPrompt = "Question answer"; + promptRef.current = pastedPrompt; + store.setPrompt(target, pastedPrompt); + if (edit !== "cached") await requested.promise; + }); + if (edit !== "cached") expect(pendingDraftWork.has(target)).toBe(true); + await act(() => { + if ( + edit !== "annotation" && + edit !== "retain" && + edit !== "cached" && + edit !== "question" && + edit !== "switch" + ) { + promptRef.current = "Keep this edit"; + store.setPrompt(target, promptRef.current); + } + if (edit === "undo") { + promptRef.current = pastedPrompt; + store.setPrompt(target, pastedPrompt); + } + if (edit === "switch") input.attachmentTargetKeyRef.current = DraftId.make("other-draft"); + }); + await act(async () => { + downloaded.resolve(new Response("notes", { headers: { "Content-Type": attachment.mimeType } })); + await settled.promise; + }); + expect(pendingDraftWork.has(target)).toBe(false); + const draft = store.getComposerDraft(target)!; + expect(draft.prompt).toBe(promptRef.current); + const attachments = kind === "file" ? draft.files : draft.images; + expect(attachments.map((file) => file.name)).toEqual( + edit === "undo" || + edit === "retain" || + edit === "annotation" || + edit === "cached" || + edit === "question" + ? ["notes.txt"] + : [], + ); +}); diff --git a/apps/web/src/components/chat/useComposerContextImport.ts b/apps/web/src/components/chat/useComposerContextImport.ts index e859e816074e6..74e97a5076d7d 100644 --- a/apps/web/src/components/chat/useComposerContextImport.ts +++ b/apps/web/src/components/chat/useComposerContextImport.ts @@ -39,6 +39,7 @@ import { import { assetEnvironment } from "~/state/assets"; import { copiedComposerAttachmentFile } from "~/lib/copiedComposerAttachments"; import { countQuestionAttachments, questionAttachmentDraftId } from "../../questionAttachments"; +import { collectInlineContextIds } from "../../lib/composerContextReferences"; import { elementContextToPreviewAnnotation } from "../../lib/elementContext"; import { orchestrationEnvironment } from "~/state/orchestration"; import { pendingDraftWork } from "./pendingDraftWork"; @@ -87,6 +88,7 @@ export function useComposerContextImport(input: { readonly composerContextRecords: ComposerDraftContextRecords; readonly composerImages: ReadonlyArray; readonly composerFilesRef: ChatComposerProps["composerFilesRef"]; + readonly promptRef: RefObject; readonly pendingThreadImportsRef: RefObject>; readonly addComposerFilesToDraft: (files: ComposerFileAttachment[]) => string[]; }) { @@ -104,6 +106,7 @@ export function useComposerContextImport(input: { composerContextRecords, composerImages, composerFilesRef, + promptRef, pendingThreadImportsRef, addComposerFilesToDraft, } = input; @@ -191,6 +194,20 @@ export function useComposerContextImport(input: { fail("Downloading it from the source failed."); return; } + // Question attachments have no inline reference; annotations can own their screenshots. + const references = new Set(collectInlineContextIds(promptRef.current)); + if ( + !questionAttachmentTarget && + !references.has(toKindScopedComposerContextId(record.kind, localId)) && + !( + record.kind === "image" && + references.has(previewAnnotationContextId(localId)) && + getComposerDraft(composerDraftTarget)?.previewAnnotations.some( + (annotation) => annotation.id === localId, + ) + ) + ) + return; file = new File([blob], record.name, { type: record.mimeType || blob.type }); } // The draft these bytes belong to may have been sent or switched away from while they From c40b32a729dcf21eb14aa9dcb140bf978303f9b1 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:49:45 -0500 Subject: [PATCH 068/219] fix(web): keep ACP agent logos in multi-model selections (#23448) Co-authored-by: GPT-6.1 Sol --- .../chat/ProviderModelPicker.browser.test.tsx | 90 +++++++++++++++++++ .../components/chat/ProviderModelPicker.tsx | 2 + 2 files changed, 92 insertions(+) create mode 100644 apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx new file mode 100644 index 0000000000000..72c9cdf39f23d --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx @@ -0,0 +1,90 @@ +import { ProviderDriverKind, ProviderInstanceId, type ModelSelection } from "@t3tools/contracts"; +import { useState } from "react"; +import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import "~/index.css"; + +import { render } from "../../../test/render"; +import { deriveProviderInstanceEntries } from "../../providerInstances"; +import { ProviderModelPicker } from "./ProviderModelPicker"; + +it("keeps each registry image when adding a model from another ACP agent", async () => { + const icons = [ + "https://cdn.agentclientprotocol.com/registry/icons/picker-explicit.svg", + "https://cdn.agentclientprotocol.com/registry/v1/latest/picker-by-id.svg", + ]; + const cache = await caches.open("t3-acp-registry-icons-v1"); + await using _cachedIcons = { + async [Symbol.asyncDispose]() { + await Promise.all(icons.map((url) => cache.delete(url))); + }, + }; + for (const [index, url] of icons.entries()) { + await cache.put( + url, + new Response( + ``, + { headers: { "content-type": "image/svg+xml" } }, + ), + ); + } + const entries = deriveProviderInstanceEntries( + ["First", "Second"].map((name) => ({ + instanceId: ProviderInstanceId.make(name.toLowerCase()), + driver: ProviderDriverKind.make("acpRegistry"), + displayName: `${name} agent`, + enabled: true, + installed: true, + version: null, + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [ + { slug: name.toLowerCase(), name: `${name} model`, isCustom: false, capabilities: {} }, + ], + slashCommands: [], + skills: [], + })), + ).map((entry, index) => ({ + ...entry, + ...(index === 0 ? { acpRegistryIconUrl: icons[0] } : { acpRegistryAgentId: "picker-by-id" }), + })); + const first = { instanceId: entries[0]!.instanceId, model: "first" }; + const options = new Map(entries.map((entry) => [entry.instanceId, entry.models])); + + function Picker() { + const [selection, setSelection] = useState>([first]); + return ( + 1 ? { selectedModels: selection } : {})} + lockedProvider={null} + instanceEntries={entries} + modelOptionsByInstance={options} + onToggleModel={(instanceId, model) => setSelection([...selection, { instanceId, model }])} + onInstanceModelChange={(instanceId, model) => setSelection([{ instanceId, model }])} + /> + ); + } + + await using screen = await render(); + const imageWidths = () => + Array.from( + screen.container.querySelectorAll("[data-chat-provider-model-picker] img"), + (image) => image.naturalWidth, + ); + await vi.waitFor(() => expect(imageWidths()).toEqual([11])); + await screen.getByRole("button", { name: "First model", exact: true }).click(); + await page.getByRole("button", { name: "Second agent", exact: true }).click(); + await userEvent.keyboard("{Shift>}"); + try { + await page.getByRole("option", { name: /Second model/ }).click(); + } finally { + await userEvent.keyboard("{/Shift}"); + } + await userEvent.keyboard("{Escape}"); + await vi.waitFor(() => expect(imageWidths()).toEqual([11, 17])); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 83e028a9bbcd2..1551ac881e73f 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -221,6 +221,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { driverKind={selection.entry.driverKind} displayName={selection.entry.displayName} accentColor={selection.entry.accentColor} + acpRegistryAgentId={selection.entry.acpRegistryAgentId} + acpRegistryIconUrl={selection.entry.acpRegistryIconUrl} className="size-4 rounded-full bg-(--chat-composer-glass-surface,var(--background)) ring-2 ring-(--chat-composer-glass-surface,var(--background))" iconClassName="size-4" /> From 6ad082745a5d19e8e5363e537b09d591e2cd0cbe Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:50:00 -0500 Subject: [PATCH 069/219] fix(web): name inherited background activity profiles (#23450) Co-authored-by: GPT-6.1 Sol --- .../settings/SettingInheritance.test.ts | 25 +++++++++++++++++++ .../settings/SettingInheritance.tsx | 10 ++++++++ 2 files changed, 35 insertions(+) diff --git a/apps/web/src/components/settings/SettingInheritance.test.ts b/apps/web/src/components/settings/SettingInheritance.test.ts index e02bb6b3d35ec..527a1cdbbf58f 100644 --- a/apps/web/src/components/settings/SettingInheritance.test.ts +++ b/apps/web/src/components/settings/SettingInheritance.test.ts @@ -8,6 +8,31 @@ const environmentId = EnvironmentId.make("laptop"); const projectId = ProjectId.make("project"); describe("settingInheritanceLayers", () => { + it("identifies the background activity profile supplied by each layer", () => { + const settings = { + ...DEFAULT_SERVER_SETTINGS, + backgroundActivity: { + schemaVersion: 1 as const, + profile: "performance" as const, + overrides: {}, + }, + }; + const layers = settingInheritanceLayers( + { + environmentId, + label: "Laptop", + projectId: null, + ...resolveProjectSettings(settings, projectId), + }, + settings, + "backgroundActivity", + ); + expect(layers.map((layer) => [layer.key, layer.value, layer.effective])).toEqual([ + ["environment", "Performance", true], + ["built-in", "Balanced", false], + ]); + }); + it("shows the checkout's t3.json as a layer for file-backed keys", () => { const file = { defaultThreadEnvMode: "worktree" as const }; const fromFile = settingInheritanceLayers( diff --git a/apps/web/src/components/settings/SettingInheritance.tsx b/apps/web/src/components/settings/SettingInheritance.tsx index 692f254df1fad..051017a95894e 100644 --- a/apps/web/src/components/settings/SettingInheritance.tsx +++ b/apps/web/src/components/settings/SettingInheritance.tsx @@ -37,6 +37,13 @@ const WRITING_STYLE_LABELS: Record = { custom: "Custom instructions", }; +const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record = { + balanced: "Balanced", + performance: "Performance", + "battery-saver": "Battery saver", + custom: "Advanced", +}; + function formatValue(key: keyof ServerSettings, value: unknown): string { if (value === null || value === undefined) { return key === "pullRequestMergeMethod" @@ -75,6 +82,9 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { } if (Array.isArray(value)) return `${value.length} ${value.length === 1 ? "item" : "items"}`; if (typeof value === "object") { + if (key === "backgroundActivity" && "profile" in value && typeof value.profile === "string") { + return BACKGROUND_ACTIVITY_PROFILE_LABELS[value.profile] ?? value.profile; + } if ("model" in value && typeof value.model === "string") return value.model; if ("mode" in value && typeof value.mode === "string") { return WRITING_STYLE_LABELS[value.mode] ?? value.mode; From f8768d68b6e27b05b9a8cf4582002db2e4d12a8f Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 30 Oct 2026 13:50:17 -0700 Subject: [PATCH 070/219] fix(mobile): use the Android sheet header for Add environment (#23452) Co-authored-by: maria-rcks --- apps/mobile/src/Stack.tsx | 1 + .../settings/components/SettingsScreen.tsx | 31 +++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7a0042903a3c3..872ca142c8aaa 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -1006,6 +1006,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...HORIZONTAL_FORM_SHEET_OPTIONS, sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, + sheetCornerRadius: Platform.OS === "android" ? 28 : undefined, }, }), NotFound: createNativeStackScreen({ diff --git a/apps/mobile/src/features/settings/components/SettingsScreen.tsx b/apps/mobile/src/features/settings/components/SettingsScreen.tsx index 05b267242d41c..b61a57c8b7536 100644 --- a/apps/mobile/src/features/settings/components/SettingsScreen.tsx +++ b/apps/mobile/src/features/settings/components/SettingsScreen.tsx @@ -2,6 +2,7 @@ import { useNavigation, useRoute } from "@react-navigation/native"; import type { ReactNode } from "react"; import { Platform, View } from "react-native"; +import { AndroidSheetHeader } from "../../../components/ui/AndroidScreenHeader"; import type { AppBarProps } from "../../../components/ui/AppBar.types"; import { AppBar } from "../../../components/ui/AppBar"; import { ScreenContent as SettingsScreenContent } from "../../../components/ui/ScreenContent"; @@ -43,18 +44,28 @@ export function SettingsScreen( return ( - navigation.goBack()} - hideBottomBorder={!props.formSheet} - /> + {Platform.OS === "android" && props.formSheet ? ( + navigation.goBack()} + hideBottomBorder + /> + ) : ( + navigation.goBack()} + hideBottomBorder={!props.formSheet} + /> + )} - {props.formSheet ? ( + {props.formSheet && Platform.OS !== "android" ? ( props.children ) : ( Date: Fri, 30 Oct 2026 15:50:33 -0500 Subject: [PATCH 071/219] fix(desktop): oversized preview events no longer take down the app (#23461) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/desktop/src/preview/CdpRelay.test.ts | 82 +++++++++++++++---- apps/desktop/src/preview/CdpRelay.ts | 48 ++++++++++- .../src/preview/DesktopBrowserHost.test.ts | 71 +++++++++++++++- .../desktop/src/preview/DesktopBrowserHost.ts | 20 ++++- 4 files changed, 201 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 1c1f9dc078ca0..a2589edf8f7b2 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -14,6 +14,9 @@ const makeTarget = ( setDownloadDirectory, }); +const makeRelay = (target: CdpRelayTarget, write: (raw: string) => void) => + createCdpRelayConnection(target, write, () => {}); + // Each relay reply resolves after a few microtasks; one macrotask lets them all land. // oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- a zero-delay macrotask boundary, not a wait for time const settle = () => new Promise((resolve) => setImmediate(resolve)); @@ -21,9 +24,7 @@ const settle = () => new Promise((resolve) => setImmediate(resolve)); describe("CDP relay", () => { it("announces only the tab's page, under its real target id", async () => { const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(vi.fn()), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(vi.fn()), (raw) => written.push(JSON.parse(raw))); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); relay.receive(JSON.stringify({ id: 2, method: "Target.createTarget", params: {} })); await settle(); @@ -51,9 +52,7 @@ describe("CDP relay", () => { new Promise((resolve) => pending.push(() => resolve({ method }))), ); const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(send), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw))); relay.receive( JSON.stringify({ id: 5, method: "Page.captureScreenshot", sessionId: "t3-preview-page" }), ); @@ -76,7 +75,7 @@ describe("CDP relay", () => { it("resumes paused child frames, so cross-site iframes run", async () => { const send = vi.fn(async () => ({})); - const relay = createCdpRelayConnection(makeTarget(send), () => {}); + const relay = makeRelay(makeTarget(send), () => {}); relay.receive( JSON.stringify({ id: 1, @@ -102,9 +101,7 @@ describe("CDP relay", () => { it("acknowledges Playwright's focus emulation without forcing it onto the guest", async () => { const send = vi.fn(async () => ({})); const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(send), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw))); relay.receive( JSON.stringify({ id: 1, @@ -120,9 +117,7 @@ describe("CDP relay", () => { it("routes tab events to the page session once the page is announced", async () => { const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(vi.fn()), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(vi.fn()), (raw) => written.push(JSON.parse(raw))); relay.event("Page.loadEventFired", {}, undefined); expect(written).toEqual([]); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); @@ -140,9 +135,7 @@ describe("CDP relay extra sessions", () => { it("gives a second page session the page's commands and events", async () => { const send = vi.fn(async () => ({})); const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(send), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw))); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); relay.receive(JSON.stringify({ id: 2, method: "Target.attachToBrowserTarget" })); await settle(); @@ -182,7 +175,7 @@ describe("CDP relay downloads", () => { const send = vi.fn(async () => ({})); const directories: Array = []; const written: Array> = []; - const relay = createCdpRelayConnection( + const relay = makeRelay( makeTarget(send, (directory) => directories.push(directory)), (raw) => written.push(JSON.parse(raw)), ); @@ -213,3 +206,58 @@ describe("CDP relay downloads", () => { ]); }); }); + +describe("CDP relay payload budget", () => { + // 2,048 references to one 40 KiB string: about 80 MiB once serialized, little memory here. + const oversized = Array(2048).fill("x".repeat(40 * 1024)); + + it("fails only an oversized reply and still returns a large screenshot", async () => { + const screenshot = "A".repeat(16 * 1024 * 1024); + const send = vi.fn(async (method: string) => + method === "DOM.getDocument" ? { nodes: oversized } : { data: screenshot }, + ); + const written: Array> = []; + const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw))); + relay.receive( + JSON.stringify({ id: 1, method: "DOM.getDocument", sessionId: "t3-preview-page" }), + ); + relay.receive( + JSON.stringify({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page" }), + ); + await settle(); + + const replies = written.map(({ id, error, result }) => ({ + id, + error, + size: (result as { data?: string } | undefined)?.data?.length, + })); + expect(replies).toEqual([ + { id: 1, error: { code: -32000, message: "CDP payload too large" }, size: undefined }, + { id: 2, error: undefined, size: screenshot.length }, + ]); + }); + + it.each([ + { args: oversized }, + { args: "\u0001".repeat(12 * 1024 * 1024) }, + { args: Array(2_700_000).fill(-0.0000010000000000000002) }, + ])("closes instead of dropping an oversized event (%#)", async ({ args }) => { + const written: Array> = []; + const close = vi.fn(); + const relay = createCdpRelayConnection( + makeTarget(vi.fn()), + (raw) => written.push(JSON.parse(raw)), + close, + ); + relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); + await settle(); + written.length = 0; + + relay.event("Runtime.consoleAPICalled", { args }, ""); + relay.event("Page.loadEventFired", { timestamp: 1 }, ""); + + relay.receive(JSON.stringify({ id: 2, method: "Target.setAutoAttach" })); + expect(close).toHaveBeenCalledOnce(); + expect(written.map((message) => message["method"])).toEqual([]); + }); +}); diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 948bc361f5d92..d52b0a3b2ab55 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -36,6 +36,29 @@ const isCommand = (value: unknown): value is CdpCommand => typeof (value as { id?: unknown }).id === "number" && typeof (value as { method?: unknown }).method === "string"; +// CDP values are plain JSON data, so their serialized size can be bounded before +// JSON.stringify allocates it. A string counts at its length unless it holds a +// character JSON may escape, which can take six code units. +const MAX_CDP_PAYLOAD_SIZE = 64 * 1024 * 1024; +const MAY_ESCAPE = /[^\x20\x21\x23-\x5b\x5d-\ud7ff\ue000-\uffff]/; +const serializedSize = (text: string) => + (MAY_ESCAPE.test(text) ? text.length * 6 : text.length) + 3; + +const fitsPayloadBudget = (message: unknown) => { + let remaining = MAX_CDP_PAYLOAD_SIZE; + const visit = (value: unknown): boolean => { + remaining -= typeof value === "string" ? serializedSize(value) : 26; + if (remaining < 0) return false; + if (typeof value !== "object" || value === null) return true; + if (Array.isArray(value)) return value.every(visit); + return Object.entries(value).every(([key, item]) => { + remaining -= serializedSize(key); + return visit(item); + }); + }; + return visit(message); +}; + const BROWSER_CONTEXT_ID = "t3-preview"; const ANNOUNCE = Symbol("announce"); const PAGE_SESSION_ID = "t3-preview-page"; @@ -50,8 +73,30 @@ const ACKNOWLEDGED = new Set([ export function createCdpRelayConnection( target: CdpRelayTarget, write: (message: string) => void, + close: () => void, ): CdpRelayConnection { - const send = (message: Record) => write(JSON.stringify(message)); + let closed = false; + const send = (message: Record) => { + if (closed) return; + if (fitsPayloadBudget(message)) { + write(JSON.stringify(message)); + return; + } + // An error reply fails only that command. A dropped event would leave + // Playwright out of step with the page, so the relay closes instead. + if (typeof message["id"] === "number") { + write( + JSON.stringify({ + id: message["id"], + error: { code: -32000, message: "CDP payload too large" }, + ...(message["sessionId"] === undefined ? {} : { sessionId: message["sessionId"] }), + }), + ); + return; + } + closed = true; + close(); + }; let attached = false; /** Relay-created page sessions share one real debugger; child frame sessions retain their own ids. */ const sessions = new Map([[PAGE_SESSION_ID, "page"]]); @@ -151,6 +196,7 @@ export function createCdpRelayConnection( return { receive: (raw) => { + if (closed) return; let command: unknown; try { command = JSON.parse(raw); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 08fefd8d733f5..3168788bebe9b 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -1,5 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - Stands in for an Electron debugger. -import { describe, expect, it } from "@effect/vitest"; +import { describe, expect, it, vi } from "@effect/vitest"; import { DesktopBrowserEvent } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -968,4 +968,73 @@ describe("DesktopBrowserHost", () => { expect(host.humanStartedDownload(debuggee.tab.webContents)).toBe(false); }), ); + + it.effect("detaches only the tab whose CDP event is too large to relay", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const tabs = [makeDebuggee({ deferFocus: true }), makeDebuggee()]; + const send = vi.spyOn(tabs[0]!.tab.debugger, "sendCommand"); + const keys = [key, { threadId: "thread-2", tabId: "tab-2" }]; + const events = yield* Queue.unbounded(); + yield* host.events.pipe( + Stream.runForEach((line) => + Queue.offer(events, decodeEvent(new TextDecoder().decode(line))), + ), + Effect.forkScoped, + ); + for (const [index, tabKey] of keys.entries()) { + host.attach(tabKey, tabs[index]!.tab); + expect(yield* Queue.take(events)).toEqual({ type: "attached", ...tabKey }); + yield* host.handleCommandLine( + encodeJson({ + type: "cdp", + ...tabKey, + message: encodeJson({ id: 1, method: "Target.setAutoAttach" }), + }), + ); + // The command's reply and the page announcement. + yield* Queue.takeN(events, 2); + } + + yield* host.handleCommandLine( + encodeJson({ + type: "cdp", + ...key, + message: encodeJson({ + id: 2, + method: "Target.setAutoAttach", + params: { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }, + sessionId: "t3-preview-page", + }), + }), + ); + yield* command(host, 3, "Page.startScreencast"); + tabs[0]!.emit("Runtime.consoleAPICalled", { + args: Array(2048).fill("x".repeat(40 * 1024)), + }); + tabs[1]!.emit("Page.loadEventFired", { timestamp: 1 }); + const [detached, next] = yield* Queue.takeN(events, 2); + + const { type, threadId, tabId } = detached!; + expect({ type, threadId, tabId }).toEqual({ type: "detached", ...key }); + expect(send).toHaveBeenCalledWith("Target.setAutoAttach", { + autoAttach: false, + waitForDebuggerOnStart: false, + flatten: true, + }); + tabs[0]!.settleFocus(); + yield* settle; + expect(send).not.toHaveBeenCalledWith("Target.setAutoAttach", { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: true, + }); + expect(send).toHaveBeenCalledWith("Page.stopScreencast"); + expect(tabs[0]!.focusEmulation).toEqual([true, false]); + expect(next).toMatchObject({ type: "cdp", ...keys[1] }); + expect(JSON.parse((next as { message: string }).message)).toMatchObject({ + method: "Page.loadEventFired", + }); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 30f252e3a677e..5a2b5463fc674 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -329,7 +329,7 @@ export const make = Effect.gen(function* () { const forward = (current?: () => boolean) => { const change = tab.focusChange; const run = () => - current === undefined || current() + tab.relay === relay && (current === undefined || current()) ? send(method, params, sessionId) : Promise.reject(new Error("The page this screenshot was for has moved on.")); return change === null ? run() : change.then(run); @@ -372,6 +372,24 @@ export const make = Effect.gen(function* () { emit({ type: "cdp", ...tab.key, message }); } }, + // Announcing the tab again would reconnect Playwright, and the page could + // replay the same oversized event, so the tab stays detached. + () => { + if (tab.relay !== relay || tabs.get(keyOf(tab.key)) !== tab) return; + // Disabling auto-attach detaches child sessions and releases their debugger pauses. + void debuggee + .sendCommand("Target.setAutoAttach", { + autoAttach: false, + waitForDebuggerOnStart: false, + flatten: true, + }) + .catch(() => {}); + if (tab.screencasts.size > 0) { + void debuggee.sendCommand("Page.stopScreencast").catch(() => {}); + } + setFocusEmulation(tab, false); + detach(tab.key); + }, ); tab.relay = relay; return relay; From 8bbe5aa384ed40f1582c06b010ff649e7bec2107 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:50:49 -0500 Subject: [PATCH 072/219] fix(mobile): share pull request refresh subscriptions (#23464) Co-authored-by: GPT-6.1 Sol --- .../src/state/pullRequests.test.ts | 53 +++++++++++++++++++ .../client-runtime/src/state/pullRequests.ts | 17 +++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index a8627e2d9977e..18e33cbbcf559 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -1177,6 +1177,59 @@ it.effect("keeps hover previews fresh after edits and turns", () => ), ); +it.effect("shares one refresh stream between independently created pull request readers", () => + Effect.scoped( + Effect.gen(function* () { + const revision = yield* SubscriptionRef.make(0); + let subscriptions = 0; + let title = "Original title"; + const client = { + [WS_METHODS.pullRequestsSubscribeRefreshes]: () => + Stream.unwrap( + Effect.sync(() => { + subscriptions++; + return SubscriptionRef.changes(revision); + }), + ), + [WS_METHODS.pullRequestsSummary]: () => Effect.sync(() => ({ title, state: "open" })), + [WS_METHODS.pullRequestsDetail]: () => Effect.sync(() => ({ title, state: "open" })), + } as unknown as WsRpcProtocolClient; + const { runtime, atoms, registry } = yield* makeTestRuntime(client); + const target = { + environmentId: TARGET.environmentId, + input: { projectId: ProjectId.make("project-1"), repository: "acme/web", number: 1 }, + }; + const summary = createLinkedPullRequestSummaryAtomFamily(runtime)(target); + const detail = atoms.detail(target); + for (const atom of [summary, detail]) { + const unmount = registry.mount(atom); + yield* Effect.addFinalizer(() => Effect.sync(unmount)); + expect((yield* AtomRegistry.getResult(registry, atom)).title).toBe(title); + } + yield* AtomRegistry.getResult( + registry, + atoms.refreshes({ environmentId: target.environmentId, input: {} }), + ); + expect(subscriptions).toBe(1); + + const changed = [summary, detail].map((atom) => { + const ready = Latch.makeUnsafe(); + const stop = registry.subscribe(atom, (result) => { + if (AsyncResult.isSuccess(result) && result.value.title === "Updated title") { + ready.openUnsafe(); + } + }); + return { ready, stop }; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => changed.forEach(({ stop }) => stop()))); + title = "Updated title"; + yield* SubscriptionRef.update(revision, (value) => value + 1); + for (const { ready } of changed) yield* ready.await; + expect(subscriptions).toBe(1); + }), + ), +); + it.effect("shares close, reopen, and merge with an untouched client's mounted PR readers", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index ca5fb6d7a77d5..1785af6fd384f 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -115,7 +115,7 @@ function updateCached( registry.update(atom, AsyncResult.map(update)); } -function createPullRequestRefreshAtomFamily( +function makePullRequestRefreshAtomFamily( runtime: Atom.AtomRuntime, ) { return createEnvironmentRpcSubscriptionAtomFamily(runtime, { @@ -124,6 +124,21 @@ function createPullRequestRefreshAtomFamily( }); } +const refreshFamiliesByRuntime = new WeakMap< + object, + ReturnType +>(); + +function createPullRequestRefreshAtomFamily( + runtime: Atom.AtomRuntime, +): ReturnType> { + const existing = refreshFamiliesByRuntime.get(runtime); + if (existing) return existing as ReturnType>; + const refreshes = makePullRequestRefreshAtomFamily(runtime); + refreshFamiliesByRuntime.set(runtime, refreshes); + return refreshes; +} + export function createLinkedPullRequestSummaryAtomFamily( runtime: Atom.AtomRuntime, refreshes = createPullRequestRefreshAtomFamily(runtime), From 4ce9205d1fe86c7b977a9e12960614bfe797777c Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:51:05 -0500 Subject: [PATCH 073/219] fix(web): support Home and End on hue sliders (#23465) Co-authored-by: GPT-6.1 Sol --- .../settings/colorPickers.browser.test.tsx | 24 ++++++++++++++++++- apps/web/src/components/ui/color-picker.tsx | 5 ++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/colorPickers.browser.test.tsx b/apps/web/src/components/settings/colorPickers.browser.test.tsx index e468830ff3e64..f93659f112f05 100644 --- a/apps/web/src/components/settings/colorPickers.browser.test.tsx +++ b/apps/web/src/components/settings/colorPickers.browser.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { act, render } from "../../../test/render"; import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; @@ -69,7 +69,29 @@ async function renderThemeField(value: string) { }); } +function ControlledThemeField() { + const [value, setValue] = useState("#408080"); + return setValue(next)} />; +} + describe("theme color field", () => { + it("jumps hue to both endpoints without changing saturation or brightness", async () => { + await using _screen = await render(); + await open("Choose Accent color color"); + + await press(HUE, "Home"); + expect(slider(HUE).getAttribute("aria-valuenow")).toBe("0"); + expect(byLabel("Accent color picker hex value").value).toBe("#804040"); + + await press(HUE, "End"); + expect(slider(HUE).getAttribute("aria-valuenow")).toBe("360"); + expect(byLabel("Accent color picker hex value").value).toBe("#804040"); + + await press(HUE, "ArrowRight", true); + expect(slider(HUE).getAttribute("aria-valuenow")).toBe("10"); + expect(byLabel("Accent color picker hex value").value).toBe("#804b40"); + }); + it("keeps the selected hue when a grey color is echoed back from theme settings", async () => { await using screen = await renderThemeField("#ff0000"); const { onChange, echo } = screen; diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx index 588428c62536c..b640942ef528c 100644 --- a/apps/web/src/components/ui/color-picker.tsx +++ b/apps/web/src/components/ui/color-picker.tsx @@ -182,6 +182,11 @@ export function ColorHueSlider({ }, onInteractionEnd); const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Home" || event.key === "End") { + event.preventDefault(); + onChange(event.key === "Home" ? 0 : 360); + return; + } if (!["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(event.key)) return; event.preventDefault(); const step = event.shiftKey ? 10 : 1; From 4524eeeaa3fbcfde64b207c553b42357e3542433 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:51:21 -0500 Subject: [PATCH 074/219] fix(claude): no failed AskUserQuestion row after the resume dialog (#23481) Co-authored-by: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 63 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 24 +++++-- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index e1ee3bbe36f0a..07124b0240648 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -4953,6 +4953,69 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("completes a turn after the resume dialog without a failed tool row", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-resume-dialog"), + text: "continue", + attachments: [], + }), + ); + const dialog = yield* Effect.promise(() => + harness.getOpenedOptions()!.onUserDialog!( + { + dialogKind: "resume_return", + payload: { sessionAgeMinutes: 90, estimatedTokens: 120000 }, + }, + { signal: new AbortController().signal, requestId: "dialog-resume" }, + ), + ).pipe(Effect.forkScoped); + const request = yield* Queue.take(harness.runtimeRequestReceipts); + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "node.updated" && event.node.id === request.runtimeRequest.nodeId + ? [event.node.parentNodeId] + : [], + ), + [NodeId.make("node-attempt-resume-dialog")], + ); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: request.runtimeRequest.id, + answers: { + [formatClaudeResumeCompactionQuestion({ ageMinutes: 90, estimatedTokens: 120000 })]: + "Keep full history", + }, + }); + assert.deepEqual(yield* Fiber.join(dialog), { behavior: "completed", result: "continue" }); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "result-resume-dialog", result: "Done" }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.nativeItemRef?.nativeId === "dialog-resume" + ? [event.turnItem.status] + : [], + ), + [], + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect.each( (["aborted_tools", "aborted_streaming"] as const).flatMap((terminalReason) => [true, false].map((steered) => ({ terminalReason, steered })), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 1fb3b9ff120e1..b1a94c2e1c0f5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -7081,6 +7081,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly context: ActiveClaudeTurnContext; readonly nativeItemId: string; readonly nativeRequestId: string; + readonly parentNodeId?: OrchestrationV2ExecutionNode["id"]; readonly requestKind: OrchestrationV2RuntimeRequest["kind"]; readonly prompt?: string; readonly appName?: string; @@ -7116,10 +7117,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( id: nodeId, threadId: input.context.input.threadId, runId: input.context.input.runId, - parentNodeId: idAllocator.derive.nodeFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: input.nativeItemId, - }), + parentNodeId: + input.parentNodeId ?? + idAllocator.derive.nodeFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: input.nativeItemId, + }), rootNodeId: input.context.input.rootNodeId, kind: input.questions === undefined ? "approval_request" : "user_input_request", status: "waiting", @@ -9556,6 +9559,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( toolName: Parameters[0], toolInput: Parameters[1], callbackOptions: Parameters[2], + options?: { readonly startToolCall?: boolean }, ) { const context = yield* Ref.get(activeTurn); if (context === null) { @@ -9569,6 +9573,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const nativeRequestId = callbackOptions.toolUseID; const nativeToolInput = claudeNativeToolInputFromRecord(toolInput); const heldForEcho = context.heldRootFrames.length > 0; + const startToolCall = toolName !== "Agent" && options?.startToolCall !== false; if (toolName === "Agent") { rememberClaudeSubagentLaunch( subagentModelCatalog, @@ -9578,7 +9583,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeToolInput, null, ); - } else if (!heldForEcho) { + } else if (startToolCall && !heldForEcho) { yield* ensureToolCallStarted({ context, nativeItemId: nativeRequestId, @@ -9595,7 +9600,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { // The SDK blocks prompt echoes on permission answers; release held output except for ExitPlanMode. yield* releaseHeldRootFrames(context); - if (toolName !== "Agent") { + if (startToolCall) { yield* ensureToolCallStarted({ context, nativeItemId: nativeRequestId, @@ -9612,6 +9617,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( context, nativeItemId: nativeRequestId, nativeRequestId, + // No tool node exists for a control request to hang under. + ...(options?.startToolCall === false + ? { parentNodeId: context.input.rootNodeId } + : {}), requestKind: "user_input", questions, }); @@ -9973,6 +9982,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( requestId: callbackOptions.requestId, toolUseID: request.toolUseID ?? callbackOptions.requestId, }, + // The dialog is a control request, not a tool call, so no + // tool_result would ever settle a tool row started for it. + { startToolCall: false }, ); if (result.behavior !== "allow") return { behavior: "cancelled" as const }; const answers = From 3fa79a5c83817c15b04b07f5db6c639fca33abda Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:51:37 -0500 Subject: [PATCH 075/219] fix(scripts): copy web brand icons to an absolute target directory (#23491) Co-authored-by: Claude Opus 5.5 --- scripts/apply-web-brand-assets.test.ts | 63 ++++++++++++++++++++++++++ scripts/apply-web-brand-assets.ts | 7 +-- 2 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 scripts/apply-web-brand-assets.test.ts diff --git a/scripts/apply-web-brand-assets.test.ts b/scripts/apply-web-brand-assets.test.ts new file mode 100644 index 0000000000000..c3073879b207c --- /dev/null +++ b/scripts/apply-web-brand-assets.test.ts @@ -0,0 +1,63 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { applyWebBrandAssets } from "./apply-web-brand-assets.ts"; + +const WEB_ICON_FILENAMES = [ + "apple-touch-icon.png", + "favicon-16x16.png", + "favicon-32x32.png", + "favicon.ico", +]; + +it.layer(NodeServices.layer)("apply-web-brand-assets", (it) => { + it.effect("copies the icons into an absolute target directory as given", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const targetDirectory = yield* fs.makeTempDirectoryScoped({ + prefix: "apply-web-brand-assets-", + }); + + yield* applyWebBrandAssets("production", targetDirectory); + + assert.deepStrictEqual( + (yield* fs.readDirectory(targetDirectory)).toSorted(), + WEB_ICON_FILENAMES, + ); + }), + ); + + it.effect("resolves an empty target directory to the repo root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repoRoot = yield* path.fromFileUrl(new URL("..", import.meta.url)); + const destinations = WEB_ICON_FILENAMES.map((filename) => path.join(repoRoot, filename)); + // readDirectory does not follow symlinks, so a dangling link counts as an existing entry. + // Names are lowercased so a case-insensitive filesystem cannot hide a differently cased file. + const existingEntries = new Set( + (yield* fs.readDirectory(repoRoot)).map((entry) => entry.toLowerCase()), + ); + for (const destination of destinations) { + assert.isFalse( + existingEntries.has(path.basename(destination)), + `${destination} already exists`, + ); + } + yield* Effect.addFinalizer(() => + Effect.forEach(destinations, (destination) => fs.remove(destination, { force: true })).pipe( + Effect.orDie, + ), + ); + + yield* applyWebBrandAssets("production", ""); + + for (const destination of destinations) { + assert.isTrue(yield* fs.exists(destination)); + } + }), + ); +}); diff --git a/scripts/apply-web-brand-assets.ts b/scripts/apply-web-brand-assets.ts index 90b4e39c104ac..a6941de8b4307 100644 --- a/scripts/apply-web-brand-assets.ts +++ b/scripts/apply-web-brand-assets.ts @@ -29,12 +29,9 @@ export const applyWebBrandAssets = Effect.fn("applyWebBrandAssets")(function* ( const repoRoot = yield* path.fromFileUrl(new URL("..", import.meta.url)); yield* Effect.forEach( - resolveWebIconOverrides(brand, targetDirectory), + resolveWebIconOverrides(brand, path.resolve(repoRoot, targetDirectory)), (override) => - fs.copyFile( - path.join(repoRoot, override.sourceRelativePath), - path.join(repoRoot, override.targetRelativePath), - ), + fs.copyFile(path.join(repoRoot, override.sourceRelativePath), override.targetRelativePath), { concurrency: "unbounded" }, ); }); From 54c1f1007cd59dbc899befc568ba643bf594cb62 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:51:54 -0500 Subject: [PATCH 076/219] fix(web): keep model picker open during IME composition (#23493) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 --- .../components/chat/ModelPickerContent.tsx | 13 +- ...erModelPicker.composition.browser.test.tsx | 120 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/chat/ProviderModelPicker.composition.browser.test.tsx diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index c5a8cdf64c18f..6e35ebb5f7e28 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -773,7 +773,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }; const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.defaultPrevented || event.repeat || isCommandPaletteOpen()) { + if ( + event.defaultPrevented || + event.repeat || + event.isComposing || + event.keyCode === 229 || + isCommandPaletteOpen() + ) { return; } @@ -912,6 +918,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { setSearchQuery(e.target.value); }} onKeyDown={(e) => { + if (e.nativeEvent.isComposing || e.keyCode === 229) { + (e as typeof e & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + e.stopPropagation(); + return; + } if ( showSidebar && !e.altKey && diff --git a/apps/web/src/components/chat/ProviderModelPicker.composition.browser.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.composition.browser.test.tsx new file mode 100644 index 0000000000000..a4dbe8bd31398 --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.composition.browser.test.tsx @@ -0,0 +1,120 @@ +import { + ProviderDriverKind, + ProviderInstanceId, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import { compileResolvedKeybindingsConfig } from "@t3tools/shared/keybindings"; +import { useState } from "react"; +import { expect, it } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { act, render } from "../../../test/render"; +import { deriveProviderInstanceEntries } from "../../providerInstances"; +import { ProviderModelPicker } from "./ProviderModelPicker"; + +const instanceId = ProviderInstanceId.make("codex"); + +function Picker({ keybindings = [] }: { keybindings?: ResolvedKeybindingsConfig }) { + const [model, setModel] = useState("original"); + return ( + <> + {model} + setModel(selected)} + /> + + ); +} + +it("keeps composition keys out of model selection and dismissal", async () => { + await using screen = await render(); + await page.getByRole("button", { name: "Choose model" }).click(); + const search = page.getByPlaceholder("Search models..."); + await search.fill("Target"); + await expect.element(page.getByRole("option", { name: /Target model/ })).toBeVisible(); + + for (const nativeEvent of [{ isComposing: true }, { keyCode: 229 }]) { + for (const key of ["Enter", "Escape"]) { + const event = new KeyboardEvent("keydown", { + key, + ...nativeEvent, + bubbles: true, + cancelable: true, + }); + await act(() => search.element().dispatchEvent(event)); + expect(event.defaultPrevented).toBe(false); + await expect.element(search).toBeVisible(); + await expect + .element(screen.getByRole("status", { name: "Selected model" })) + .toHaveTextContent("original"); + } + } + + await userEvent.keyboard("{Enter}"); + await expect.element(search).not.toBeInTheDocument(); + await expect + .element(screen.getByRole("status", { name: "Selected model" })) + .toHaveTextContent("target"); + await page.getByRole("button", { name: "Choose model" }).click(); + await expect.element(search).toBeVisible(); + await userEvent.keyboard("{Escape}"); + await expect.element(search).not.toBeInTheDocument(); +}); + +it("keeps composing keys from triggering model jump shortcuts", async () => { + const keybindings = compileResolvedKeybindingsConfig([ + { key: "enter", command: "modelPicker.jump.1", when: "modelPickerOpen" }, + ]); + await using screen = await render(); + await page.getByRole("button", { name: "Choose model" }).click(); + const search = page.getByPlaceholder("Search models..."); + await search.fill("Target"); + await expect.element(page.getByRole("option", { name: /Target model/ })).toBeVisible(); + + for (const nativeEvent of [{ isComposing: true }, { keyCode: 229 }]) { + const event = new KeyboardEvent("keydown", { + key: "Enter", + ...nativeEvent, + bubbles: true, + cancelable: true, + }); + await act(() => search.element().dispatchEvent(event)); + expect(event.defaultPrevented).toBe(false); + await expect.element(search).toBeVisible(); + await expect + .element(screen.getByRole("status", { name: "Selected model" })) + .toHaveTextContent("original"); + } +}); From 269357ccd9d3e38367248707e3931e0e7dfe76aa Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:52:09 -0500 Subject: [PATCH 077/219] fix(mobile): fit large thread titles in Arrange threads (#23499) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../threads/ThreadArrangementSheet.tsx | 36 +++++++++++-------- apps/mobile/src/lib/layout.test.ts | 13 +++++++ apps/mobile/src/lib/layout.ts | 15 ++++++++ 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx index 603fac3d00f5c..839506c84bc19 100644 --- a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx @@ -5,7 +5,7 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import { sortInboxThreadsByReturn } from "@t3tools/client-runtime/state/thread-inbox"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; -import { Animated, FlatList, Modal, Pressable, View } from "react-native"; +import { Animated, FlatList, Modal, Pressable, useWindowDimensions, View } from "react-native"; import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated"; import { threadDragGapOffset } from "./threadDragGap"; @@ -14,6 +14,7 @@ import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { deriveThreadArrangementSizing } from "../../lib/layout"; import { environmentServerConfigsAtom } from "../../state/server"; import { threadListEnvironmentsAtom } from "../../state/presentation"; import { environmentThreadShells } from "../../state/threads"; @@ -39,9 +40,8 @@ import { } from "./threadListV2"; import { useThreadListNow } from "./use-thread-list-now"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -const ROW_HEIGHT = 56; -const HEADER_HEIGHT = 48; const keyOf = (thread: EnvironmentThreadShell) => scopedThreadKey(thread.environmentId, thread.id); type Section = "pinned" | "active" | "snoozed" | "settled"; type Destination = Exclude; @@ -163,6 +163,12 @@ function DragHandle(props: { } function ThreadArrangementSheet(props: { onClose: () => void }) { + const { appearance } = useAppearancePreferences(); + const { fontScale } = useWindowDimensions(); + const { rowHeight, headerHeight } = deriveThreadArrangementSizing({ + baseFontSize: appearance.baseFontSize, + fontScale, + }); const threads = useAtomValue(environmentThreadShells.navigationThreadShellsAtom); const configs = useAtomValue(environmentServerConfigsAtom); const listEnvironments = useAtomValue(threadListEnvironmentsAtom); @@ -230,20 +236,20 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { let offset = 0; for (const section of ["pinned", "active", "snoozed", "settled"] as const) { if (section === "snoozed" && sections[section].length === 0) continue; - result.push({ key: section, section, offset, height: HEADER_HEIGHT }); - offset += HEADER_HEIGHT; + result.push({ key: section, section, offset, height: headerHeight }); + offset += headerHeight; if ((section === "snoozed" || section === "settled") && !expanded[section]) continue; for (const thread of section === "settled" ? visibleSettled : sections[section]) { - result.push({ key: keyOf(thread), section, thread, offset, height: ROW_HEIGHT }); - offset += ROW_HEIGHT; + result.push({ key: keyOf(thread), section, thread, offset, height: rowHeight }); + offset += rowHeight; } if (section === "settled" && settledCount > visibleSettled.length) { - result.push({ key: "settled-more", section, more: true, offset, height: ROW_HEIGHT }); - offset += ROW_HEIGHT; + result.push({ key: "settled-more", section, more: true, offset, height: rowHeight }); + offset += rowHeight; } } return result; - }, [sections, expanded, visibleSettled, settledCount]); + }, [sections, expanded, visibleSettled, settledCount, rowHeight, headerHeight]); const list = useRef>(null); const geometry = useRef({ height: 0, offset: 0 }); const drag = useRef(null); @@ -261,7 +267,9 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { setPreview(null); } const orderVersion = rows - .map((row) => `${row.key}:${row.thread?.pinOrderKey}:${row.thread?.activeOrderKey}`) + .map( + (row) => `${row.key}:${row.height}:${row.thread?.pinOrderKey}:${row.thread?.activeOrderKey}`, + ) .join("|"); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- cancels the in-progress drag gesture when the thread order changes underneath it useEffect(() => { @@ -282,7 +290,7 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { current.translation = translation; const { height, offset } = geometry.current; const y = current.startY + translation; - translateY.setValue(Math.max(0, Math.min(height - ROW_HEIGHT, y - ROW_HEIGHT / 2))); + translateY.setValue(Math.max(0, Math.min(height - rowHeight, y - rowHeight / 2))); const contentY = Math.max(0, y + offset); const target = latest.current.rows.find((row) => contentY < row.offset + row.height) ?? @@ -321,7 +329,7 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { orderVersion, sourceSection: row.section, thread: row.thread, - startY: row.offset + ROW_HEIGHT / 2 - geometry.current.offset, + startY: row.offset + row.height / 2 - geometry.current.offset, translation: 0, destination: null, }; @@ -554,7 +562,7 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { { + it("fits two title lines and section labels with combined app and system text scaling", () => { + const sizing = deriveThreadArrangementSizing({ baseFontSize: 22, fontScale: 1.3 }); + expect(sizing.rowHeight).toBeGreaterThanOrEqual(2 * 32 * 1.3 + 10); + expect(sizing.headerHeight).toBeGreaterThanOrEqual(26 * 1.3 + 24); + expect(deriveThreadArrangementSizing({ baseFontSize: 16, fontScale: 1 })).toEqual({ + rowHeight: 56, + headerHeight: 48, + }); + }); +}); + describe("thread work-log text sizing", () => { it.each([ { baseFontSize: 16, fontScale: 1.25, estimatedRowHeight: 28 }, diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index ccff7fdf6c890..4155d834ba24d 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -19,6 +19,21 @@ export const ANDROID_READING_MAX_WIDTH = 640; // min-h-8 uses the 14px rem configured in metro.config.js. export const THREAD_WORK_ROW_MIN_HEIGHT = 28; +export function deriveThreadArrangementSizing(input: { + readonly baseFontSize: number; + readonly fontScale: number; +}) { + const bodyLineHeight = scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.body, input.baseFontSize); + const headerLineHeight = scaledTypographyLineHeight( + MOBILE_TYPOGRAPHY.footnote, + input.baseFontSize, + ); + return { + rowHeight: Math.max(56, Math.ceil(2 * bodyLineHeight * input.fontScale) + 10), + headerHeight: Math.max(48, Math.ceil(headerLineHeight * input.fontScale) + 24), + }; +} + export function deriveThreadWorkLogSizing(input: { readonly baseFontSize: number; readonly fontScale: number; From b1b3c10e249c68d6be86708f453c28b5c226f9b9 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:52:27 -0500 Subject: [PATCH 078/219] fix(clients): keep accepted server updates through disconnects (#23532) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../src/connection/outdatedHostUpdate.test.ts | 125 +++++++++++++- .../src/connection/outdatedHostUpdate.ts | 13 +- .../client-runtime/src/state/server.test.ts | 163 ++++++++++++++++++ packages/client-runtime/src/state/server.ts | 16 +- 4 files changed, 313 insertions(+), 4 deletions(-) diff --git a/packages/client-runtime/src/connection/outdatedHostUpdate.test.ts b/packages/client-runtime/src/connection/outdatedHostUpdate.test.ts index f241f7b464cbd..33aa01ee71b06 100644 --- a/packages/client-runtime/src/connection/outdatedHostUpdate.test.ts +++ b/packages/client-runtime/src/connection/outdatedHostUpdate.test.ts @@ -124,6 +124,20 @@ class OutdatedHostSocket { const message = decodeJson(data); if (!isRpcRequest(message)) return; this.requests.push(message); + if (message.tag === WS_METHODS.serverUpdateServerWithProgress) { + this.onUpdate(); + queueMicrotask(() => + this.emit({ + type: "message", + data: encodeJson({ + _tag: "Chunk", + requestId: message.id, + values: [{ type: "progress", stage: "downloading" }], + }), + }), + ); + return; + } if (message.tag !== WS_METHODS.serverUpdateServer) return; this.onUpdate(); queueMicrotask(() => @@ -143,10 +157,10 @@ class OutdatedHostSocket { close() { this.readyState = 3; - this.emit({ type: "close" }); + this.emit({ type: "close", code: 1006 }); } - private emit(event: { readonly type: string; readonly data?: unknown }) { + private emit(event: { readonly type: string; readonly data?: unknown; readonly code?: number }) { for (const listener of this.listeners.get(event.type) ?? []) listener(event); } } @@ -314,4 +328,111 @@ describe("updateOutdatedHost", () => { expect(opened).toBe(false); }), ); + + it.effect("resumes an outdated host update after progress transport loss", () => + Effect.gen(function* () { + const blocked = orchestrationProtocolCompatibilityError(descriptor(undefined, "0.0.45")); + expect(blocked).toMatchObject({ serverUpdateRequired: true }); + + const sockets: Array = []; + let served: ExecutionEnvironmentDescriptor = { + ...descriptor(undefined, "0.0.45"), + capabilities: { + repositoryIdentity: true, + serverSelfUpdate: "boot-service", + serverSelfUpdateProgress: true, + }, + }; + const entries = yield* SubscriptionRef.make< + ReadonlyMap + >( + new Map([ + [ + TARGET.environmentId, + { + target: TARGET, + profile: Option.none(), + enabled: false, + unsupportedReason: blocked?.message ?? "", + serverUpdateRequired: true, + }, + ], + ]), + ); + const calls: Array = []; + const registry = EnvironmentRegistry.EnvironmentRegistry.of({ + entries, + setCompatibility: (_environmentId: EnvironmentId, error: unknown) => + Effect.sync(() => calls.push(`compatibility:${error === null ? "clear" : "block"}`)), + setEnabled: (_environmentId: EnvironmentId, enabled: boolean) => + Effect.sync(() => calls.push(`enabled:${enabled}`)), + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const resolver = ConnectionResolver.ConnectionResolver.of({ + prepare: () => Effect.die(new Error("The update must bypass the protocol gate.")), + prepareForUpdate: () => + Effect.sync(() => served).pipe( + Effect.map((current) => ({ + descriptor: current, + prepared: { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://build.example.test/ws?wsTicket=ticket", + httpAuthorization: null, + target: TARGET, + }, + })), + ), + }); + const httpClient = HttpClient.make((request) => + Effect.sync(() => + HttpClientResponse.fromWeb(request, new Response(encodeDescriptor(served))), + ), + ); + + const stages: Array = []; + const result = yield* updateOutdatedHost( + TARGET.environmentId, + { targetVersion: "0.0.46" }, + (stage) => + Effect.sync(() => { + stages.push(stage); + // Lose the transport only after the consumer has observed progress. + if (stage === "downloading") sockets[0]?.close(); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, registry), + Layer.succeed(ConnectionResolver.ConnectionResolver, resolver), + Layer.succeed( + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery, + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.of({ + state: yield* SubscriptionRef.make( + RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, + ), + refresh: Effect.void, + }), + ), + Layer.succeed(HttpClient.HttpClient, httpClient), + Layer.succeed(Socket.WebSocketConstructor, (url) => { + const socket = new OutdatedHostSocket(url, () => { + served = descriptor(ORCHESTRATION_PROTOCOL_VERSION, "0.0.46"); + }); + sockets.push(socket); + return socket as unknown as globalThis.WebSocket; + }), + ), + ), + ); + + expect(sockets[0]?.url).not.toContain("orchestrationProtocol"); + expect(sockets[0]?.requests.map((request) => request.tag)).toEqual([ + WS_METHODS.serverUpdateServerWithProgress, + ]); + expect(stages).toEqual(["downloading", "resuming"]); + expect(result.targetVersion).toBe("0.0.46"); + expect(calls).toEqual(["compatibility:clear", "enabled:true"]); + }), + ); }); diff --git a/packages/client-runtime/src/connection/outdatedHostUpdate.ts b/packages/client-runtime/src/connection/outdatedHostUpdate.ts index 444a33a1ac54e..d702e1f6b057f 100644 --- a/packages/client-runtime/src/connection/outdatedHostUpdate.ts +++ b/packages/client-runtime/src/connection/outdatedHostUpdate.ts @@ -141,20 +141,31 @@ export const updateOutdatedHost = Effect.fn("clientRuntime.connection.updateOutd capabilities.serverSelfUpdateProgress === true ? yield* Effect.gen(function* () { const terminal = yield* Ref.make(Option.none()); + let receivedProgress = false; const streamExit = yield* client[WS_METHODS.serverUpdateServerWithProgress]( input, ).pipe( Stream.runForEach((event) => event.type === "complete" ? Ref.set(terminal, Option.some(event.result)) - : onStage(event.stage), + : Effect.sync(() => { + receivedProgress = true; + }).pipe(Effect.andThen(onStage(event.stage))), ), Effect.exit, ); + const selfUpdateMethod = capabilities.serverSelfUpdate; + // Desktop preparation needs its terminal commit token. + const accepted = + receivedProgress && + (selfUpdateMethod === "boot-service" || selfUpdateMethod === "respawn") + ? { targetVersion: input.targetVersion, method: selfUpdateMethod } + : undefined; return yield* resolveServerUpdateProgressResult( input.targetVersion, yield* Ref.get(terminal), streamExit, + accepted, ); }) : yield* client[WS_METHODS.serverUpdateServer](input).pipe( diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index b07148a9c4bc5..c852afdcae32e 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -1,5 +1,6 @@ import { EnvironmentId, + ServerSelfUpdateError, ProviderInstanceId, ServerObservability, EnvironmentThemeId, @@ -16,12 +17,14 @@ 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 Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import * as TestClock from "effect/testing/TestClock"; import { RpcClientError } from "effect/rpc"; import * as Socket from "effect/socket/Socket"; @@ -32,11 +35,15 @@ import { type PreparedConnection, } from "../connection/model.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import { RpcPermissionGuard } from "../rpc/client.ts"; import * as Persistence from "../platform/persistence.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; import { applyServerWelcomeEvent, + createServerEnvironmentAtoms, + serverUpdateStateAtom, makeEnvironmentServerWelcomeState, makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, @@ -216,6 +223,162 @@ describe("update restart reconnect nudges", () => { ); }); +describe("accepted server update recovery", () => { + it.effect.each([ + { loss: "interrupt", resumes: true }, + { loss: "socket", resumes: true }, + { loss: "server_error", resumes: false }, + { loss: "no_progress", resumes: false }, + { loss: "cancel", resumes: false }, + { loss: "socket", method: "respawn", resumes: true }, + { loss: "socket", method: "desktop-managed", resumes: false }, + ] as const)("handles $loss with $method and recovery=$resumes", (scenario) => + Effect.gen(function* () { + const { loss, resumes } = scenario; + const method = "method" in scenario ? scenario.method : "boot-service"; + const observing = yield* Deferred.make(); + const cancelFiber = yield* Deferred.make>(); + const oldReadyObserved = yield* Deferred.make(); + const readyEvents = + yield* Queue.unbounded[1]>(); + const config = { + ...CONFIG, + environment: { + ...CONFIG.environment, + serverVersion: "0.0.30", + capabilities: { serverSelfUpdate: method, serverSelfUpdateProgress: true }, + }, + } as ServerConfig; + const client = { + [WS_METHODS.subscribeServerConfig]: () => Stream.never, + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(observing, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromQueue(readyEvents)), + Stream.tap((event) => + event.payload.environment.serverVersion === "0.0.30" + ? Deferred.succeed(oldReadyObserved, undefined) + : Effect.void, + ), + ), + [WS_METHODS.serverUpdateServerWithProgress]: () => + (loss === "no_progress" + ? Stream.empty + : Stream.make({ type: "progress" as const, stage: "downloading" as const }) + ).pipe( + Stream.concat( + Stream.fromEffect( + loss === "cancel" + ? Effect.fiber.pipe( + Effect.flatMap((fiber) => Deferred.succeed(cancelFiber, fiber)), + Effect.andThen(Effect.never), + ) + : loss === "interrupt" + ? Effect.interrupt + : Effect.fail( + loss === "server_error" + ? new ServerSelfUpdateError({ + reason: "Archive verification failed.", + }) + : new RpcClientError.RpcClientError({ + reason: new Socket.SocketCloseError({ code: 1006 }), + }), + ), + ), + ), + ), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + const run: EnvironmentRegistry.EnvironmentRegistry["Service"]["run"] = (_id, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const runStream: EnvironmentRegistry.EnvironmentRegistry["Service"]["runStream"] = ( + _id, + stream, + ) => Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({ + run, + runStream, + followStream: runStream, + stateChanges: () => Stream.never, + retryNow: () => Effect.void, + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const runtime = Atom.runtime( + Layer.mergeAll( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Layer.succeed(RpcPermissionGuard, { authorize: () => Effect.void }), + Layer.succeed(Persistence.EnvironmentCacheStore, { + loadServerConfig: () => Effect.succeedNone, + } as unknown as Persistence.EnvironmentCacheStore["Service"]), + ), + ); + const atoms = createServerEnvironmentAtoms(runtime, { + initialConfigValueAtom: () => Atom.make(config), + }); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (value) => + Effect.sync(() => value.dispose()), + ); + const pending = atoms.updateServer.run(registry, { + environmentId: TARGET.environmentId, + input: { targetVersion: "0.0.31" }, + }); + if (loss === "cancel") { + yield* Fiber.interrupt(yield* Deferred.await(cancelFiber)); + const cancelled = yield* Effect.promise(() => pending); + expect(cancelled._tag).toBe("Failure"); + if (cancelled._tag === "Failure") + expect(Cause.hasInterruptsOnly(cancelled.cause)).toBe(true); + expect(registry.get(serverUpdateStateAtom(TARGET.environmentId))).toEqual({ + status: "idle", + }); + expect(yield* Deferred.isDone(observing)).toBe(false); + return; + } + const first = yield* Effect.raceFirst( + Effect.promise(() => pending).pipe(Effect.as("settled")), + Deferred.await(observing).pipe(Effect.as("observing")), + ); + if (!resumes) { + expect(first).toBe("settled"); + expect(AsyncResult.isFailure(yield* Effect.promise(() => pending))).toBe(true); + expect(registry.get(serverUpdateStateAtom(TARGET.environmentId))).toMatchObject({ + status: "failed", + }); + return; + } + expect(first).toBe("observing"); + expect(registry.get(serverUpdateStateAtom(TARGET.environmentId))).toMatchObject({ + status: "running", + stage: "resuming", + }); + const ready = (serverVersion: string) => + ({ + version: 1, + sequence: 1, + type: "ready", + payload: { at: "2026-09-01T00:00:00.000Z", environment: { serverVersion } }, + }) as Parameters[1]; + yield* Queue.offer(readyEvents, ready("0.0.30")); + yield* Deferred.await(oldReadyObserved); + expect(registry.get(serverUpdateStateAtom(TARGET.environmentId))).toMatchObject({ + status: "running", + }); + yield* Queue.offer(readyEvents, ready("0.0.31")); + expect(AsyncResult.isSuccess(yield* Effect.promise(() => pending))).toBe(true); + expect(registry.get(serverUpdateStateAtom(TARGET.environmentId))).toEqual({ + status: "idle", + }); + }).pipe(Effect.scoped), + ); +}); + const makeConfigPersistenceHarness = Effect.fn("test.makeConfigPersistenceHarness")(function* ( saveServerConfig: Persistence.EnvironmentCacheStore["Service"]["saveServerConfig"], ) { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index dadaf2dafb7a5..4c44c7df9dec9 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -103,7 +103,7 @@ export class ServerUpdateResumeTimeoutError extends Schema.TaggedError( targetVersion: string, terminal: Option.Option, streamExit: Exit.Exit, + accepted?: ServerSelfUpdateResult, ): Effect.Effect { if ( Option.isSome(terminal) && @@ -346,6 +347,9 @@ export function resolveServerUpdateProgressResult( return Effect.succeed(terminal.value); } if (Exit.isFailure(streamExit)) { + if (accepted !== undefined && isLegacyUpdateHandoffLoss(streamExit.cause)) { + return Effect.succeed(accepted); + } return Effect.failCause(streamExit.cause); } return Effect.fail(new ServerUpdateProgressIncompleteError({ targetVersion })); @@ -882,6 +886,7 @@ export function createServerEnvironmentAtoms( currentConfig?.environment.capabilities.serverSelfUpdateProgress === true; const updateResult: ServerSelfUpdateResult = supportsProgress ? yield* Effect.gen(function* () { + let receivedProgress = false; const terminal = yield* Ref.make>( Option.none(), ); @@ -893,6 +898,7 @@ export function createServerEnvironmentAtoms( .pipe( Stream.runForEach((event) => Effect.sync(() => { + receivedProgress ||= event.type === "progress"; const state = serverUpdateStateForProgressEvent( fromVersion, targetVersion, @@ -910,10 +916,18 @@ export function createServerEnvironmentAtoms( ), Effect.exit, ); + const selfUpdateMethod = currentConfig?.environment.capabilities.serverSelfUpdate; + // Desktop preparation needs its terminal commit token. + const accepted = + receivedProgress && + (selfUpdateMethod === "boot-service" || selfUpdateMethod === "respawn") + ? { targetVersion, method: selfUpdateMethod } + : undefined; return yield* resolveServerUpdateProgressResult( targetVersion, yield* Ref.get(terminal), streamExit, + accepted, ); }) : yield* Effect.gen(function* () { From 0804c60a9cd0a120092a565b5c422a19fc6c5612 Mon Sep 17 00:00:00 2001 From: Afonso Ferreira <99990310+Aforno@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:52:44 +0000 Subject: [PATCH 079/219] fix(server): reject busy merge-back targets before blocking their queue (#23554) --- .../src/orchestration-v2/Orchestrator.ts | 13 ++- .../src/orchestration-v2/runtimeLayer.test.ts | 90 ++++++++++++++++++- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index ce43fef58a48a..ada7bba8234aa 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4340,7 +4340,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // The source is unlocked; validate the modes copied here, not an earlier read. yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread); const targetProjection = yield* projectionStore - .getThreadRecords(command.targetThreadId, ["contextTransfers"]) + .getThreadRecords(command.targetThreadId, ["runs", "contextTransfers"]) .pipe( Effect.mapError( (cause) => @@ -4392,6 +4392,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } + // Queued runs cannot consume merge-back context. Refuse under the target's + // command lock before a pending transfer can block its ordinary messages. + if (targetProjection.runs.some((run) => isBlockingRun(run) || run.status === "queued")) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: + "Finish or stop the target thread's active work and clear its queue before merging back.", + }); + } + const sourceProviderThread = providerThreadForRun(sourceProjection, sourceRun); const now = command.createdAt ?? (yield* DateTime.now); const emitEvent = emit(events, command); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 20158a80d3ff6..a04fe4c7c1374 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1726,7 +1726,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { }), ); - it.effect("merges an explicit provider-finished run while checkpoint capture is pending", () => + it.effect("refuses busy merge-back targets without blocking the queue", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const eventSink = yield* EventSink.EventSinkV2; @@ -1741,6 +1741,13 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { ); const forkTransferId = ContextTransferId.make("runtime-layer-waiting-merge-fork-transfer"); + yield* seedProject({ + projectId, + title: "Merge-back project", + workspaceRoot: process.cwd(), + defaultModelSelection: modelSelection, + createdAt: DateTime.formatIso(now), + }); yield* orchestrator.dispatch({ type: "thread.create", createdBy: "user", @@ -1868,6 +1875,87 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { ], }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-merge-target-start"), + threadId: targetThreadId, + messageId: MessageId.make("runtime-layer-merge-target-message"), + text: "Keep working in the parent.", + attachments: [], + dispatchMode: { type: "start_immediately" }, + }); + const activeTarget = yield* orchestrator.getThreadProjection(targetThreadId); + const targetRun = activeTarget.runs[0]!; + for (const status of ["preparing", "starting", "waiting", "queued", "running"] as const) { + yield* eventSink.write({ + commandId: CommandId.make(`runtime-layer-merge-target-${status}`), + events: [ + { + id: EventId.make(`runtime-layer-merge-target-${status}-event`), + type: "run.updated", + threadId: targetThreadId, + runId: targetRun.id, + occurredAt: now, + payload: { ...targetRun, status }, + }, + ], + }); + const error = yield* orchestrator + .dispatch({ + type: "thread.merge_back", + createdBy: "user", + creationSource: "mobile", + commandId: CommandId.make(`runtime-layer-merge-refused-${status}`), + sourceThreadId, + targetThreadId, + sourcePoint: { type: "run", runId: sourceRunId }, + }) + .pipe(Effect.flip); + assert.instanceOf(error, Orchestrator.OrchestratorDispatchError); + assert.equal( + error.cause, + "Finish or stop the target thread's active work and clear its queue before merging back.", + ); + const refusedTarget = yield* orchestrator.getThreadProjection(targetThreadId); + assert.deepEqual(refusedTarget.contextTransfers, activeTarget.contextTransfers); + assert.equal(refusedTarget.runs[0]?.status, status); + } + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-merge-target-queue"), + threadId: targetThreadId, + messageId: MessageId.make("runtime-layer-merge-target-queued-message"), + text: "Run this after the current turn.", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + }); + const queuedTarget = yield* orchestrator.getThreadProjection(targetThreadId); + const queuedRun = queuedTarget.runs.find((run) => run.status === "queued"); + assert.isDefined(queuedRun); + yield* orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make("runtime-layer-merge-target-cancel-queue"), + threadId: targetThreadId, + runId: queuedRun.id, + }); + yield* eventSink.write({ + commandId: CommandId.make("runtime-layer-merge-target-complete"), + events: [ + { + id: EventId.make("runtime-layer-merge-target-completed-event"), + type: "run.updated", + threadId: targetThreadId, + runId: targetRun.id, + occurredAt: now, + payload: { ...targetRun, status: "completed", completedAt: now }, + }, + ], + }); + yield* orchestrator.dispatch({ type: "thread.merge_back", createdBy: "user", From 37022730bacb03f26c1cba9b31ec1d8da3c6e730 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:53:05 -0500 Subject: [PATCH 080/219] perf(web): skip favicon loading for custom project icons (#23569) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 --- .../ProjectFavicon.browser.test.tsx | 57 +++++++++++++++++++ apps/web/src/components/ProjectFavicon.tsx | 19 ++++--- 2 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/ProjectFavicon.browser.test.tsx diff --git a/apps/web/src/components/ProjectFavicon.browser.test.tsx b/apps/web/src/components/ProjectFavicon.browser.test.tsx new file mode 100644 index 0000000000000..746302ef5b242 --- /dev/null +++ b/apps/web/src/components/ProjectFavicon.browser.test.tsx @@ -0,0 +1,57 @@ +import { RegistryContext } from "@effect/atom-react"; +import { EnvironmentId } from "@t3tools/contracts"; +import { Atom, AtomRegistry } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; +import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; + +const state = vi.hoisted(() => ({ favicon: undefined as Atom.Atom | undefined })); +vi.mock(import("../state/assets"), () => ({ + projectFaviconUrlAtom: () => { + if (!state.favicon) throw new Error("Favicon resource not configured"); + return state.favicon; + }, +})); + +function setup() { + const activeResources = new Set(); + state.favicon = Atom.make((get) => { + activeResources.add("favicon"); + get.addFinalizer(() => activeResources.delete("favicon")); + return null; + }); + return { activeResources }; +} + +it("only activates the favicon resource after removing a custom icon", async () => { + const { activeResources } = setup(); + const registry = AtomRegistry.make(); + const project: ProjectFaviconProject = { + environmentId: EnvironmentId.make("test"), + workspaceRoot: "/project", + title: "Project", + faviconPath: "icon.png", + projectIcon: { kind: "monogram", text: "PR", color: "blue" }, + }; + const view = (projectIcon: ProjectFaviconProject["projectIcon"]) => ( + + + + ); + try { + await using screen = await render(view(project.projectIcon)); + expect(activeResources.size).toBe(0); + await screen.rerender(view({ kind: "emoji", emoji: "🌱" })); + expect(activeResources.size).toBe(0); + await screen.rerender(view({ kind: "lucide", name: "leaf", color: "green" })); + expect(activeResources.size).toBe(0); + await screen.rerender(view(null)); + expect(activeResources.size).toBe(1); + await screen.rerender(view(project.projectIcon)); + await screen.rerender(view(null)); + expect(activeResources.size).toBe(1); + } finally { + registry.dispose(); + } +}); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 14a00ae86583b..3545a0aa5db87 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -38,13 +38,6 @@ export function ProjectFavicon(input: { fallbackIcon?: ComponentType<{ className?: string }>; }) { const { project } = input; - const src = useAtomValue( - projectFaviconUrlAtom({ - environmentId: project.environmentId, - cwd: project.workspaceRoot, - faviconPath: project.faviconPath, - }), - ); if (project.projectIcon?.kind === "monogram") { return ( ); } + return ; +} + +function AutomaticProjectFavicon(input: Parameters[0]) { + const { project } = input; + const src = useAtomValue( + projectFaviconUrlAtom({ + environmentId: project.environmentId, + cwd: project.workspaceRoot, + faviconPath: project.faviconPath, + }), + ); const FallbackIcon = input.fallbackIcon ?? FolderCodeIcon; if (!src || isProjectFaviconFallbackUrl(src)) { From f46f838bf22460be5061d378723ff1c15f6d9417 Mon Sep 17 00:00:00 2001 From: Afonso Ferreira <99990310+Aforno@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:53:26 +0000 Subject: [PATCH 081/219] fix(server): setup scripts follow terminal shell fallbacks (#23576) Co-authored-by: maria-rcks --- .../project/ProjectSetupScriptRunner.test.ts | 54 +++++-- .../src/project/ProjectSetupScriptRunner.ts | 138 ++++++++++-------- apps/server/src/terminal/Manager.test.ts | 115 +++++++++++++++ apps/server/src/terminal/Manager.ts | 18 ++- 4 files changed, 252 insertions(+), 73 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 29a15a6f0715b..f383436cdb439 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -1,7 +1,11 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { describe, expect, it } from "@effect/vitest"; -import { ProjectId, type TerminalEvent, type TerminalSessionSnapshot } from "@t3tools/contracts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; +import { + ProjectId, + TerminalNotRunningError, + type TerminalEvent, + type TerminalSessionSnapshot, +} from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -36,6 +40,7 @@ const openSnapshot = (history: string): TerminalSessionSnapshot => ({ const makeHarness = Effect.fn(function* (options: { readonly history?: string; readonly opening?: Deferred.Deferred; + readonly shell?: string | null; }) { const listeners = new Set<(event: TerminalEvent) => Effect.Effect>(); const writes = yield* Queue.unbounded(); @@ -46,6 +51,10 @@ const makeHarness = Effect.fn(function* (options: { ? Deferred.succeed(options.opening, undefined).pipe(Effect.andThen(Effect.never)) : Effect.void ).pipe(Effect.as(openSnapshot(options.history ?? ""))), + getShell: () => + options.shell === null + ? Effect.fail(new TerminalNotRunningError({ threadId, terminalId })) + : Effect.succeed(options.shell ?? "/bin/zsh"), subscribe: (listener) => Effect.sync(() => { listeners.add(listener); @@ -66,8 +75,6 @@ const makeHarness = Effect.fn(function* (options: { NodeCrypto.layer, ), ), - Effect.provideService(HostProcess.Platform, "darwin"), - Effect.provideService(HostProcess.Environment, { SHELL: "/bin/zsh" }), ); const start = runner.runForThread({ threadId, @@ -92,7 +99,12 @@ const makeHarness = Effect.fn(function* (options: { return { start, listeners, writes, outputLines, emit }; }); -const setup = Effect.fn(function* (options: { readonly history?: string } = {}) { +const setup = Effect.fn(function* ( + options: { + readonly history?: string; + readonly shell?: string | null; + } = {}, +) { const harness = yield* makeHarness(options); const result = yield* harness.start; const completion = result.status === "started" ? result.completion : undefined; @@ -101,6 +113,15 @@ const setup = Effect.fn(function* (options: { readonly history?: string } = {}) }); describe("ProjectSetupScriptRunner", () => { + it.effect("writes immediately when the spawned fallback is PowerShell", () => + Effect.gen(function* () { + const { writes } = yield* setup({ + shell: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + }); + expect(yield* Queue.take(writes)).toContain("$global:LASTEXITCODE"); + }), + ); + it.effect("shows a startup prompt and types only once the line editor starts", () => Effect.gen(function* () { const { result, writes, outputLines, emit } = yield* setup(); @@ -160,13 +181,20 @@ describe("ProjectSetupScriptRunner", () => { }), ); - it.effect("does not type into a shell that exits during startup", () => - Effect.gen(function* () { - const { completion, writes, emit } = yield* setup(); - yield* emit({ ...event, type: "exited", exitCode: 1, exitSignal: null }); - expect(yield* completion).toMatchObject({ exitCode: null }); - yield* TestClock.adjust("1 minute"); - expect(yield* Queue.size(writes)).toBe(0); - }), + it.effect.each([false, true])( + "does not type into a shell that exits during startup (before shell lookup: %s)", + (beforeShellLookup) => + Effect.gen(function* () { + const { completion, writes, emit, listeners } = yield* setup( + beforeShellLookup ? { shell: null } : {}, + ); + if (!beforeShellLookup) { + yield* emit({ ...event, type: "exited", exitCode: 1, exitSignal: null }); + } + expect(yield* completion).toMatchObject({ exitCode: null }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(writes)).toBe(0); + expect(listeners.size).toBe(0); + }), ); }); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 095f20938c8b0..ec11ce9840448 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,5 +1,4 @@ import { linkOriginIsFenced, ProjectId, type ProjectScript, ThreadId } from "@t3tools/contracts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import { projectScriptRuntimeEnv, projectScriptTerminalInput, @@ -107,9 +106,19 @@ export class ProjectSetupScriptProjectNotFoundError extends Schema.TaggedError

()( + "ProjectSetupScriptUnsupportedShellError", + { shell: Schema.String }, +) { + override get message(): string { + return "Project script completion is not supported in cmd.exe. Install PowerShell and retry."; + } +} + export const ProjectSetupScriptRunnerError = Schema.Union([ ProjectSetupScriptOperationError, ProjectSetupScriptProjectNotFoundError, + ProjectSetupScriptUnsupportedShellError, ]); export type ProjectSetupScriptRunnerError = typeof ProjectSetupScriptRunnerError.Type; @@ -159,13 +168,9 @@ function lineEditorReading(text: string, previous: boolean): boolean { type CompletionShell = "posix" | "fish" | "powershell"; -function resolveCompletionShell( - platform: NodeJS.Platform, - env: NodeJS.ProcessEnv, -): CompletionShell { - if (platform === "win32") return "powershell"; - const shell = env.SHELL ?? ""; - const name = shell.split("/").at(-1) ?? shell; +function resolveCompletionShell(shell: string): CompletionShell | null { + const name = (shell.split(/[/\\]/).at(-1) ?? shell).toLowerCase().replace(/\.exe$/, ""); + if (name === "cmd") return null; if (name === "fish") return "fish"; if (name === "pwsh" || name === "powershell") return "powershell"; return "posix"; @@ -196,10 +201,6 @@ export const make = Effect.gen(function* () { const serverSettings = yield* ServerSettings.ServerSettingsService; const crypto = yield* Crypto.Crypto; const serviceScope = yield* Effect.scope; - const completionShell = resolveCompletionShell( - yield* HostProcess.Platform, - yield* HostProcess.Environment, - ); const observeTerminalCompletion = Effect.fn("ProjectSetupScriptRunner.observeTerminalCompletion")( function* (input: { @@ -444,15 +445,7 @@ export const make = Effect.gen(function* () { const completionToken = observe ? (yield* crypto.randomUUIDv4().pipe(Effect.orDie)).replaceAll("-", "") : null; - const commandLine = - observe && completionToken - ? wrapCommandForCompletion( - script.command, - completionShell, - completionSentinel(completionToken), - ) - : script.command; - + const echoedWrapperLines: string[] = []; // Subscribe before opening so neither startup activity nor the sentinel can race the listeners. const observed = observe && completionToken @@ -461,49 +454,76 @@ export const make = Effect.gen(function* () { terminalId, sentinel: completionSentinel(completionToken), sentinelPattern: completionSentinelPattern(completionToken), - echoedWrapperLines: commandLine.split("\r").filter((line) => line.length > 0), + echoedWrapperLines, onOutputLine: observe.onOutputLine, }) : undefined; - // PowerShell has no line-editor signal; keep its immediate write. - const readiness = - completionShell === "powershell" - ? undefined - : yield* watchShellReadiness({ - threadId: input.threadId, - terminalId, - onStartupPrompt: observed?.flushPartialLine ?? Effect.void, - }); - - yield* terminalManager - .open({ - threadId: input.threadId, - terminalId, - cwd, - worktreePath: input.worktreePath, - env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" }, - }) - .pipe( - Effect.mapError( - (cause) => - new ProjectSetupScriptOperationError({ - ...errorContext, - operation: "openTerminal", - cause, - }), - ), - Effect.onError(() => - Effect.sync(() => { - observed?.unsubscribe(); - readiness?.unsubscribe(); - }), - ), - ); + const readiness = yield* watchShellReadiness({ + threadId: input.threadId, + terminalId, + onStartupPrompt: observed?.flushPartialLine ?? Effect.void, + }); + const { completionShell, commandLine } = yield* Effect.gen(function* () { + const shell = yield* terminalManager + .open({ + threadId: input.threadId, + terminalId, + cwd, + worktreePath: input.worktreePath, + env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" }, + }) + .pipe( + Effect.andThen(() => + terminalManager + .getShell({ threadId: input.threadId, terminalId }) + .pipe(Effect.catchTags({ TerminalNotRunningError: () => Effect.succeed(null) })), + ), + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "openTerminal", + cause, + }), + ), + ); + if (shell === null) { + yield* observed?.abandon ?? Effect.void; + return { completionShell: null, commandLine: null }; + } + const completionShell = resolveCompletionShell(shell); + let commandLine = script.command; + if (observe && completionToken) { + if (completionShell === null) { + yield* terminalManager.closeIdle({ threadId: input.threadId, terminalId }); + return yield* new ProjectSetupScriptUnsupportedShellError({ shell }); + } + commandLine = wrapCommandForCompletion( + script.command, + completionShell, + completionSentinel(completionToken), + ); + } + echoedWrapperLines.push(...commandLine.split("\r").filter((line) => line.length > 0)); + return { completionShell, commandLine }; + }).pipe( + Effect.onError(() => + Effect.sync(() => { + observed?.unsubscribe(); + readiness.unsubscribe(); + }), + ), + ); // Return once the terminal exists so callers can show it while a startup prompt waits. - yield* (readiness?.wait ?? Effect.succeed("ready" as const)).pipe( + // PowerShell and cmd have no line-editor signal. + yield* ( + completionShell === "posix" || completionShell === "fish" + ? readiness.wait + : Effect.succeed("ready" as const) + ).pipe( Effect.flatMap((shell) => - shell === "exited" + shell === "exited" || commandLine === null ? Effect.void : terminalManager.write({ threadId: input.threadId, @@ -518,7 +538,7 @@ export const make = Effect.gen(function* () { cause, }).pipe(Effect.andThen(observed?.abandon ?? Effect.void)), ), - Effect.ensuring(Effect.sync(() => readiness?.unsubscribe())), + Effect.ensuring(Effect.sync(() => readiness.unsubscribe())), Effect.forkIn(serviceScope), ); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 61fe149cf8335..4afae89eb5a50 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -7,6 +7,7 @@ import { type TerminalOpenInput, ProviderDriverKind, ProviderInstanceId, + ProjectId, TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -31,8 +32,11 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as TestClock from "effect/testing/TestClock"; import { expect, vi } from "vite-plus/test"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as TerminalManager from "./Manager.ts"; import { terminalOutputStream } from "./OutputProtocol.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; @@ -1441,6 +1445,61 @@ it.layer( }), ); + it.effect( + "writes completion syntax for the fallback shell instead of the configured fish shell", + () => + Effect.gen(function* () { + const env = { SHELL: "/missing/fish" }; + const { manager, ptyAdapter, baseDir } = yield* createManager(5, { env }).pipe( + Effect.provide(layerWithHostPlatform("linux")), + ); + ptyAdapter.spawnFailures.push(new Error("spawn fish ENOENT")); + const written = yield* Deferred.make(); + const terminals = TerminalManager.TerminalManager.of({ + ...manager, + write: (input) => + manager.write(input).pipe(Effect.tap(() => Deferred.succeed(written, undefined))), + }); + const runner = yield* ProjectSetupScriptRunner.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(TerminalManager.TerminalManager, terminals), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadShell: () => Effect.succeed(null), + }), + ServerSettings.layerTest(), + Layer.succeed(HostProcess.Platform, "linux"), + Layer.succeed(HostProcess.Environment, env), + ), + ), + ); + yield* runner.runForThread({ + threadId: "thread-setup-fallback", + worktreePath: baseDir, + project: { + id: ProjectId.make("project-setup-fallback"), + workspaceRoot: baseDir, + scripts: [ + { + id: "setup", + name: "Setup", + command: "echo setup-ready", + icon: "play", + runOnWorktreeCreate: true, + }, + ], + }, + observeCompletion: {}, + }); + const process = ptyAdapter.processes[0]!; + process.emitData("\x1b[?2004h"); + yield* Deferred.await(written); + const command = process.writes[0]!; + expect(command).toContain('"$?"'); + }), + ); + it.effect("preserves Windows Path casing when appending managed ACP binaries", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1534,6 +1593,62 @@ it.layer( }), ); + it.effect("closes an idle cmd fallback before rejecting its completion wrapper", () => + Effect.gen(function* () { + const { manager, ptyAdapter, baseDir } = yield* createManager(5, { + env: { SystemRoot: "C:\\Windows" }, + subprocessInspector: () => + Effect.succeed({ + hasRunningSubprocess: false, + childCommand: null, + processIds: [], + }), + }).pipe(Effect.provide(layerWithHostPlatform("win32"))); + ptyAdapter.spawnFailures.push( + new Error("spawn pwsh ENOENT"), + new Error("spawn WindowsPowerShell ENOENT"), + new Error("spawn powershell ENOENT"), + ); + const runner = yield* ProjectSetupScriptRunner.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(TerminalManager.TerminalManager, manager), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadShell: () => Effect.succeed(null), + }), + ServerSettings.layerTest(), + ), + ), + ); + const error = yield* runner + .runForThread({ + threadId: "thread-cmd-fallback", + worktreePath: baseDir, + project: { + id: ProjectId.make("project-cmd-fallback"), + workspaceRoot: baseDir, + scripts: [ + { + id: "setup", + name: "Setup", + command: "echo setup-ready", + icon: "play", + runOnWorktreeCreate: true, + }, + ], + }, + observeCompletion: {}, + }) + .pipe(Effect.flip); + + expect(error._tag).toBe("ProjectSetupScriptUnsupportedShellError"); + expect(ptyAdapter.spawnInputs.at(-1)?.shell).toBe("C:\\Windows\\System32\\cmd.exe"); + expect(ptyAdapter.processes[0]?.writes).toEqual([]); + expect(ptyAdapter.processes[0]?.killed).toBe(true); + }), + ); + it.effect("turns off fish terminal queries unless the user chose query-term", () => Effect.gen(function* () { for (const [platform, env, runtimeEnv, expected] of [ diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index ca93558d623ec..9e0eabc88da8a 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -172,6 +172,8 @@ export class TerminalManager extends Context.Service< input: TerminalOpenInput, ) => Effect.Effect; + readonly getShell: (input: TerminalObserveInput) => Effect.Effect; + /** * Attached viewers share one PTY sized to the smallest of them, so a narrow client * still sees the whole TUI. Streams and resizes with the same `viewerId` (one client @@ -296,6 +298,7 @@ interface TerminalSessionState { cols: number; rows: number; process: PtyAdapter.PtyProcess | null; + shell: string | null; unsubscribeData: (() => void) | null; unsubscribeExit: (() => void) | null; hasRunningSubprocess: boolean; @@ -2370,7 +2373,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func index = 0, lastError: PtyAdapter.PtySpawnError | null = null, ): Effect.fn.Return< - { process: PtyAdapter.PtyProcess; shellLabel: string }, + { process: PtyAdapter.PtyProcess; shell: string; shellLabel: string }, PtyAdapter.PtySpawnError > { if (index >= shellCandidates.length) { @@ -2413,6 +2416,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if (attempt._tag === "Success") { return { process: attempt.success, + shell: candidate.shell, shellLabel: formatShellCandidate(candidate), }; } @@ -2441,6 +2445,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const startingAt = yield* nowIso; yield* modifyManagerState((state) => { session.status = "starting"; + session.shell = null; session.cwd = input.cwd; session.worktreePath = input.worktreePath ?? null; session.cols = input.cols; @@ -2515,6 +2520,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }; yield* modifyManagerState((state) => { session.process = ptyProcess; + session.shell = spawnResult.shell; session.pid = processPid; session.status = "running"; // onExit may replay an exit immediately; accept it before subscribing. @@ -2891,6 +2897,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func cols, rows, process: null, + shell: null, unsubscribeData: null, unsubscribeExit: null, hasRunningSubprocess: false, @@ -2988,6 +2995,14 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), ); + const getShell = Effect.fn("terminal.getShell")(function* (input: TerminalObserveInput) { + const session = yield* requireSession(input.threadId, input.terminalId); + if (session.status !== "running" || session.shell === null) { + return yield* new TerminalNotRunningError(input); + } + return session.shell; + }); + const openOrAttachForStream = Effect.fnUntraced(function* (requestedInput: TerminalAttachInput) { const terminalId = requestedInput.terminalId; const input = withSharedGrid(toSessionKey(requestedInput.threadId, terminalId), requestedInput); @@ -3472,6 +3487,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return TerminalManager.of({ open, + getShell, attachStream, attachOutputStream, observeStream, From 1c99a55444b6a03ec6a056a99a44131f3e1d13fb Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:53:47 -0500 Subject: [PATCH 082/219] fix(web): report invalid preview addresses (#23577) Co-authored-by: GPT-6.1 Sol --- .../preview/PreviewView.browser.test.tsx | 23 +++++++++++++---- .../src/components/preview/PreviewView.tsx | 25 +++++++++++++++++-- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index c17655db964bc..43f7845d10dbb 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -28,7 +28,6 @@ const mocks = vi.hoisted(() => ({ pickElement: vi.fn(), addPreviewAnnotation: vi.fn(), addImage: vi.fn(), - toast: vi.fn(), pictureInPicture: false, showEmptyState: false, loading: false, @@ -136,10 +135,6 @@ vi.mock(import("~/browser/browserRecording"), async (original) => ({ findActiveBrowserRecordingRuntimeTabId: () => mocks.recordingRuntimeTabId, useActiveBrowserRecordingTabIds: () => mocks.recordingTabIds, })); -vi.mock(import("~/components/ui/toast"), async (original) => { - const actual = await original(); - return { ...actual, toastManager: { ...actual.toastManager, add: mocks.toast } }; -}); vi.mock(import("./useDiscoveredLocalServers"), async (original) => ({ ...(await original()), useDiscoveredLocalServers: () => mocks.servers, @@ -162,6 +157,7 @@ import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { dispatchPreviewAction } from "./previewActionBus"; import { PreviewView } from "./PreviewView"; +import { ToastProvider } from "~/components/ui/toast"; const TEST_THREAD_REF = { environmentId: EnvironmentId.make("environment-1"), @@ -219,6 +215,23 @@ function setup() { } describe("PreviewView", () => { + it.each(["mailto:alice@example.com", "http://"])( + "reports rejected address %s without navigating or recording it", + async (address) => { + setup(); + await using _screen = await render({view()}); + const input = page.getByPlaceholder("Search or enter URL"); + await input.fill(address); + await userEvent.keyboard("{Enter}"); + + await expect.element(page.getByText("Could not open this address")).toBeVisible(); + await expect.element(page.getByText("Enter an http or https URL.")).toBeVisible(); + expect(mocks.navigate).not.toHaveBeenCalled(); + expect(mocks.recordVisitForThread).not.toHaveBeenCalled(); + await userEvent.click(page.getByRole("button", { name: "Dismiss notification" })); + }, + ); + it("retains the annotation locally when its own environment loses access during capture", async () => { setup(); const picked = annotation("annotation-revoked"); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 4eed7d21bc04a..5126b18ec7a91 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -17,6 +17,7 @@ import { } from "@t3tools/contracts"; import { normalizePreviewUrl, + isPreviewUrlNormalizationError, resolveAddressBarInput, previewPdfAssetUrl, resolveSearchUrlTemplate, @@ -259,7 +260,17 @@ export function PreviewView({ if (await navigateToResolvedUrl(resolved)) { recordVisitForThread(threadRef, resolved); } - } catch {} + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not open this address", + description: isPreviewUrlNormalizationError(error) + ? "Enter an http or https URL." + : error instanceof Error + ? error.message + : "An error occurred.", + }); + } }; const handleOpenServerUrl = async (next: string) => { @@ -271,7 +282,17 @@ export function PreviewView({ if (await navigateToResolvedUrl(resolved)) { recordVisitForThread(threadRef, next); } - } catch {} + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not open this address", + description: isPreviewUrlNormalizationError(error) + ? "Enter an http or https URL." + : error instanceof Error + ? error.message + : "An error occurred.", + }); + } }; const handleRefresh = () => { From 200a373fbde4a41dd70c4d075a993fb079df14f4 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:54:03 -0500 Subject: [PATCH 083/219] fix(opencode): keep streamed reply text after an event stream reconnect (#23591) Co-authored-by: Claude Opus 5.5 --- .../Adapters/OpenCode2AdapterV2.test.ts | 43 +++++++++++++++++++ .../src/server/v2/adapter.ts | 15 ++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 37350b4925cf9..bb26a5dd53a30 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -2265,6 +2265,49 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + it.effect("continues a reply still streaming at reconnect from the text already shown", () => + Effect.gen(function* () { + const delta = (text: string) => + event("session.text.delta", { + sessionID: SESSION, + assistantMessageID: "msg_assistant_gap", + ordinal: 0, + delta: text, + }); + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "" }), + promptAccepted, + delta("Sent before the stream dropped"), + // OpenCode saves a block's text only when the block ends. + ...reconnected({ [SESSION]: { type: "running" } }, undefined, { + content: [{ type: "text", text: "" }], + finish: undefined, + }), + ...openRequests([]), + delta(" and after it came back."), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const collected = yield* Fiber.join(events); + const texts = collected.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" + ? [event.turnItem.text] + : [], + ); + assert.deepEqual(texts, [ + "Sent before the stream dropped", + "Sent before the stream dropped", + "Sent before the stream dropped and after it came back.", + "Sent before the stream dropped and after it came back.", + ]); + }).pipe(Effect.scoped), + ); + it.effect("shows a permission asked while the stream was down, and answers it", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed( diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 1f2dd94ca2112..91b3d62fffd37 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -2493,10 +2493,21 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr for (const message of recent.toReversed()) { if (message.type !== "assistant" || state.active !== turn) continue; const ordinals = { text: 0, reasoning: 0 }; - for (const part of message.content) { + for (const [index, part] of message.content.entries()) { if (part.type === "text" || part.type === "reasoning") { const block = { assistantMessageID: message.id, ordinal: ordinals[part.type]++ }; - yield* emitText(state, turn, block, part.type, () => part.text, true); + // OpenCode saves a block's text only when the block ends, so the last block of a + // response still streaming stays open and keeps the text that already arrived. + const open = + message.time.streamed === undefined && index === message.content.length - 1; + yield* emitText( + state, + turn, + block, + part.type, + (text) => (open && !part.text.startsWith(text) ? text : part.text), + !open, + ); continue; } if (part.type !== "tool") continue; From 9facfc59230a68be71c427d708d3ee6c2ccaa2d3 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:54:20 -0500 Subject: [PATCH 084/219] fix(opencode): apply the 3-minute limit to the whole v2 text generation (#23598) Co-authored-by: Claude Opus 5.5 --- .../OpenCode2TextGeneration.test.ts | 69 +++++++++++++++++++ .../src/server/v2/textGeneration.ts | 65 +++++++++-------- 2 files changed, 106 insertions(+), 28 deletions(-) diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts index 775d2542d67ec..2cfe556ebf58f 100644 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts @@ -1,8 +1,11 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderInstanceId } from "@t3tools/contracts"; +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 { TestClock } from "effect/testing"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import * as OpenCode2AdapterV2Testkit from "../orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts"; @@ -119,4 +122,70 @@ it.layer(layer)("OpenCode2TextGeneration", (it) => { assert.deepEqual(failure.cause, refusal); }).pipe(Effect.scoped), ); + + it.effect("times out when the prompt request and its interrupt never answer", () => + Effect.gen(function* () { + const [subscribe, connected, create, created] = OPENCODE2_TITLE_GENERATION; + const sessionId = "ses_f0ec85cf6ffebfalpvV2E9nocu"; + const prompted = yield* Deferred.make(); + const server = yield* OpenCode2AdapterV2Testkit.replayServer( + { + provider: "opencode", + protocol: OpenCode2AdapterV2Testkit.OPENCODE2_HTTP_PROTOCOL, + version: "2.0.18", + scenario: "opencode2_title_generation_stalled", + entries: [ + subscribe!, + connected!, + create!, + created!, + { + type: "expect_outbound", + frame: { type: "session.prompt", input: { sessionID: sessionId, text: "" } }, + }, + { + type: "emit_inbound", + frame: { type: "sdk.response", operation: "session.prompt", data: "" }, + }, + { + type: "expect_outbound", + frame: { type: "session.interrupt", input: { sessionID: sessionId } }, + }, + { + type: "emit_inbound", + frame: { type: "sdk.response", operation: "session.interrupt", data: "" }, + }, + { + type: "expect_outbound", + frame: { type: "session.remove", input: { sessionID: sessionId } }, + }, + { + type: "emit_inbound", + frame: { type: "sdk.response", operation: "session.remove", data: null }, + }, + { type: "runtime_exit", status: "success" }, + ], + }, + { onPrompt: () => Deferred.doneUnsafe(prompted, Effect.void) }, + ); + const textGeneration = yield* OpenCode2TextGeneration.make().pipe( + Effect.provideService(OpenCode2Server.OpenCode2Server, server), + ); + const generation = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "fix the login redirect loop after oauth", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "opencode/big-pickle", + }, + }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Deferred.await(prompted); + yield* TestClock.adjust("3 minutes"); + yield* TestClock.adjust("5 seconds"); + const failure = yield* Fiber.join(generation); + assert.equal(failure.detail, "OpenCode did not finish generating in time."); + }).pipe(Effect.scoped), + ); }); diff --git a/packages/provider-opencode/src/server/v2/textGeneration.ts b/packages/provider-opencode/src/server/v2/textGeneration.ts index ff47491e14fe1..4897b58afdc17 100644 --- a/packages/provider-opencode/src/server/v2/textGeneration.ts +++ b/packages/provider-opencode/src/server/v2/textGeneration.ts @@ -112,38 +112,47 @@ const runOnServer = Effect.fn("Opencode2TextGeneration.runOnServer")(function* ( ), Effect.forkScoped, ); - const session = yield* client.session.create({ - title: `T3 Code ${input.operation}`, - location: Location.PublicRef.make({ directory: AbsolutePath.make(input.cwd) }), - model: Model.Ref.make({ - providerID: Provider.ID.make(parsed.providerID), - id: Model.ID.make(parsed.modelID), - ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), - }), - permissions: GENERATION_PERMISSIONS, - }); - sessionId = session.id; - yield* Effect.addFinalizer(() => - client.session - .remove({ sessionID: session.id }) - .pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })), - ); - const images = (input.attachments ?? []).flatMap((attachment) => { - if (attachment.type !== "image") return []; - const path = resolveAttachmentPath(attachment); - return path === null ? [] : [{ uri: `file://${path}`, name: attachment.name }]; - }); - yield* client.session.prompt({ - sessionID: session.id, - text: input.prompt, - ...(images.length === 0 ? {} : { files: images }), + // The limit covers creating the session and sending the prompt as well, since + // either request can stall while the event stream stays connected. + const generate = Effect.gen(function* () { + const session = yield* client.session.create({ + title: `T3 Code ${input.operation}`, + location: Location.PublicRef.make({ directory: AbsolutePath.make(input.cwd) }), + model: Model.Ref.make({ + providerID: Provider.ID.make(parsed.providerID), + id: Model.ID.make(parsed.modelID), + ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), + }), + permissions: GENERATION_PERMISSIONS, + }); + sessionId = session.id; + yield* Effect.addFinalizer(() => + client.session + .remove({ sessionID: session.id }) + .pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })), + ); + const images = (input.attachments ?? []).flatMap((attachment) => { + if (attachment.type !== "image") return []; + const path = resolveAttachmentPath(attachment); + return path === null ? [] : [{ uri: `file://${path}`, name: attachment.name }]; + }); + yield* client.session.prompt({ + sessionID: session.id, + text: input.prompt, + ...(images.length === 0 ? {} : { files: images }), + }); + return yield* Deferred.await(outcome); }); - const result = yield* Deferred.await(outcome).pipe( + const result = yield* generate.pipe( Effect.timeoutOrElse({ duration: GENERATION_TIMEOUT, orElse: () => - client.session.interrupt({ sessionID: Session.ID.make(session.id) }).pipe( - Effect.ignore, + (sessionId === undefined + ? Effect.void + : client.session + .interrupt({ sessionID: Session.ID.make(sessionId) }) + .pipe(Effect.timeout("5 seconds"), Effect.ignore) + ).pipe( Effect.as({ _tag: "failed", detail: "OpenCode did not finish generating in time.", From 72986b483ad5913cdbc1147017cd5fb7d541a9b4 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:54:36 -0500 Subject: [PATCH 085/219] fix(mobile): show draft review comments inline and in the count (#23602) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../src/features/review/ReviewSheet.tsx | 3 +- .../review/reviewCommentSelection.test.ts | 72 +++++++++++++++++++ .../features/review/reviewCommentSelection.ts | 42 +++++++++-- .../src/features/review/useReviewDiffData.ts | 10 +-- .../src/state/use-thread-composer-state.ts | 1 + 5 files changed, 117 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 18908302d577e..359ea229cb8cc 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -435,7 +435,7 @@ export function ReviewSheet(props: ReviewSheetProps) { const environment = useEnvironmentPresentation(environmentId); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; - const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); + const { draftMessage, draftContext } = useThreadDraftForThread({ environmentId, threadId }); const reviewCache = useReviewCacheForThread({ environmentId, threadId }); const { selectedThreadCwd } = useSelectedThreadWorktree(); const columnMetrics = useNativeColumnLayoutMetrics(); @@ -475,6 +475,7 @@ export function ReviewSheet(props: ReviewSheetProps) { cwd: selectedThreadCwd, selectedSection, draftMessage, + draftContext, }); // Expo may return null before registration or in older binaries; use the fallback. const NativeReviewDiffView = resolveNativeReviewDiffView(); diff --git a/apps/mobile/src/features/review/reviewCommentSelection.test.ts b/apps/mobile/src/features/review/reviewCommentSelection.test.ts index 59a0bb6bcb683..81eb0bba4083a 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.test.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; +import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy"; +import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; + +import { pullRequestComposerContext } from "../../lib/composerContext"; import { clearReviewCommentTarget, @@ -50,6 +54,74 @@ function makeTarget(): ReviewCommentTarget { } describe("review comment serialization", () => { + it("excludes a linked pull request chip from draft review comments", () => { + const record = pullRequestComposerContext( + { + number: 42, + title: "Fix checkout", + url: "https://github.com/example/repo/pull/42", + headBranch: "fix-checkout", + baseBranch: "main", + state: "open", + isDraft: false, + }, + "pr-42", + ); + + expect( + parseReviewInlineComments(formatComposerContextReference(record), { + version: 1, + records: [record], + }), + ).toEqual([]); + }); + + it("reads an added review comment after the composer upgrades it to a context chip", () => { + const upgraded = upgradeLegacyContextMessage( + formatReviewCommentContext(makeTarget(), "Please keep this configurable."), + ); + const comments = parseReviewInlineComments(upgraded.text, { + version: 1, + records: upgraded.records, + }); + + expect(comments).toEqual([ + { + id: upgraded.records[0]!.contextId, + sectionId: "section-1", + sectionTitle: "Working tree", + filePath: "apps/demo/src/main.ts", + startIndex: 0, + endIndex: 1, + rangeLabel: "7", + text: "Please keep this configurable.", + diff: "@@ -7,1 +7,1 @@\n-const retryLimit = 2;\n+const retryLimit = 4;", + fenceLanguage: "diff", + }, + ]); + }); + + it("keeps legacy comments and counts a typed comment only while its chip is present", () => { + const upgraded = upgradeLegacyContextMessage( + formatReviewCommentContext(makeTarget(), "Typed comment"), + ); + const context = { version: 1, records: upgraded.records } as const; + const legacy = formatReviewCommentContext(makeTarget(), "Legacy comment"); + const comments = parseReviewInlineComments( + `${legacy}\n${upgraded.text}\n${upgraded.text}`, + context, + ); + + expect(comments.map((comment) => comment.text)).toEqual(["Legacy comment", "Typed comment"]); + expect(parseReviewInlineComments(legacy, context).map((comment) => comment.text)).toEqual([ + "Legacy comment", + ]); + expect(parseReviewInlineComments("", context)).toEqual([]); + expect( + parseReviewInlineComments(upgraded.text.replace("/review-comment/", "/mention/"), context), + ).toEqual([]); + }); + it("keeps closing-tag text inside a chip label within a real review body", () => { const body = "Before [](t3-context://v1/mention/context-1) after"; const serialized = `${body}`; diff --git a/apps/mobile/src/features/review/reviewCommentSelection.ts b/apps/mobile/src/features/review/reviewCommentSelection.ts index 4e3b22407f5a0..70f226d604e04 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.ts @@ -1,5 +1,9 @@ import { useSyncExternalStore } from "react"; -import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import type { OrchestrationMessageContext } from "@t3tools/contracts"; +import { + collectComposerContextReferences, + replaceComposerContextReferences, +} from "@t3tools/shared/composerContextReferences"; import type { ReviewRenderableLineRow } from "./reviewModel"; @@ -357,11 +361,10 @@ export function formatReviewCommentContext(target: ReviewCommentTarget, comment: ].join("\n"); } -export function countReviewCommentContexts(value: string): number { - return Array.from(value.matchAll(/ { +export function parseReviewInlineComments( + value: string, + context?: OrchestrationMessageContext, +): ReadonlyArray { const comments: ReviewInlineComment[] = []; // Mask chip delimiters: labels containing would otherwise truncate the block. const masked = replaceComposerContextReferences(value, (reference) => @@ -385,6 +388,33 @@ export function parseReviewInlineComments(value: string): ReadonlyArray reference.kind === "review-comment") + .map((reference) => reference.contextId), + ); + for (const record of context?.records ?? []) { + if ( + record.kind !== "review-comment" || + "payload" in record || + record.sectionId.startsWith("pull-request:") || + !referencedIds.has(record.contextId) + ) { + continue; + } + comments.push({ + id: record.contextId, + sectionId: record.sectionId, + sectionTitle: record.sectionTitle, + filePath: record.filePath, + startIndex: record.startIndex, + endIndex: record.endIndex, + rangeLabel: record.rangeLabel, + text: record.text, + diff: record.diff, + ...(record.fenceLanguage !== undefined ? { fenceLanguage: record.fenceLanguage } : {}), + }); + } return comments; } diff --git a/apps/mobile/src/features/review/useReviewDiffData.ts b/apps/mobile/src/features/review/useReviewDiffData.ts index 6ccc5ae3f495c..d9ec656cbc773 100644 --- a/apps/mobile/src/features/review/useReviewDiffData.ts +++ b/apps/mobile/src/features/review/useReviewDiffData.ts @@ -1,7 +1,7 @@ import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; -import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection"; +import { parseReviewInlineComments } from "./reviewCommentSelection"; import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter"; import { getCachedReviewParsedDiff } from "./reviewState"; import { @@ -14,6 +14,7 @@ import { import type { EnvironmentId, + OrchestrationMessageContext, ReviewDiffFileStat, ReviewDiffPreviewSource, } from "@t3tools/contracts"; @@ -126,8 +127,9 @@ export function useReviewDiffData(input: { readonly cwd: string | null; readonly selectedSection: ReviewSectionItem | null; readonly draftMessage: string; + readonly draftContext: OrchestrationMessageContext | undefined; }) { - const { draftMessage, selectedSection, threadKey } = input; + const { draftMessage, draftContext, selectedSection, threadKey } = input; const selectedSectionId = selectedSection?.id ?? null; const source = selectedSection?.source; const lazySource = source?.truncated && source.files ? source : null; @@ -230,7 +232,7 @@ export function useReviewDiffData(input: { () => formatHeaderDiffSummary(parsedDiff, selectedSection?.files), [parsedDiff, selectedSection?.files], ); - const inlineReviewComments = parseReviewInlineComments(draftMessage); + const inlineReviewComments = parseReviewInlineComments(draftMessage, draftContext); const selectedSectionInlineComments = useMemo(() => { if (!selectedSectionId || inlineReviewComments.length === 0) { return EMPTY_INLINE_REVIEW_COMMENTS; @@ -241,7 +243,7 @@ export function useReviewDiffData(input: { parsedDiff, comments: selectedSectionInlineComments, }); - const pendingReviewCommentCount = countReviewCommentContexts(draftMessage); + const pendingReviewCommentCount = inlineReviewComments.length; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- records review perf marks and diagnostic logs once a parsed diff has committed useEffect(() => { diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 9056bc20937fa..3926b2f781f5d 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -164,6 +164,7 @@ export function useThreadDraftForThread(input: { return { draftMessage: draft.text, + draftContext: draft.context, draftAttachments: draft.attachments, }; } From 2b98f54fc05256b7158bb23fd8f1ab4c4112059e Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:55:37 -0500 Subject: [PATCH 086/219] fix(github): expand pull request context from the merge base (#23638) Co-authored-by: Claude Opus 5.5 --- .../src/server/GitHubPullRequestApi.test.ts | 31 ++++++++++++++++++ .../src/server/GitHubPullRequestApi.ts | 32 ++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index 78deffdfaeb10..5a5c46f28d99a 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -574,6 +574,7 @@ const checkContextsPage = ( }); const pullRequestRefs = encodeJson({ base: { sha: "a1b2c3d" }, head: { sha: "b1c2d3e" } }); +const pullRequestMergeBase = encodeJson({ merge_base_commit: { sha: "c1d2e3f" } }); const crossRepositoryDetail = (headRefOid = "abc123") => coreResponse({ @@ -2856,6 +2857,34 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); + it.effect("expands a pull request file from its merge base, not the moved base tip", () => + Effect.gen(function* () { + const answers: Record = { + "repos/acme/web/pulls/7": pullRequestRefs, + "repos/acme/web/compare/a1b2c3d...b1c2d3e?per_page=1&page=2": pullRequestMergeBase, + "repos/acme/web/contents/src/a.ts?ref=a1b2c3d": "base tip\n", + "repos/acme/web/contents/src/a.ts?ref=c1d2e3f": "merge base\n", + "repos/acme/web/contents/src/a.ts?ref=b1c2d3e": "head\n", + }; + mockedExecute.mockImplementation((call) => + Effect.succeed(output(call.kind === "rest" ? (answers[call.path] ?? "") : "")), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const contents = yield* cli.getPullRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + changeType: "change", + oldPath: "src/a.ts", + newPath: "src/a.ts", + }); + + expect(contents).toEqual({ oldContents: "merge base\n", newContents: "head\n" }); + }), + ); + it.effect("reports unusable diff revisions as a structured error", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output("not-a-sha\tstill-not-a-sha\n"))); @@ -2885,6 +2914,7 @@ layer("GitHubPullRequestApi.layer", (it) => { it.effect("reports undecodable diff file contents as binary", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestMergeBase))); mockedExecute.mockReturnValueOnce(Effect.succeed(output("binary�contents", false, true))); const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; @@ -2911,6 +2941,7 @@ layer("GitHubPullRequestApi.layer", (it) => { it.effect("returns valid text containing a literal replacement character", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestMergeBase))); mockedExecute.mockReturnValueOnce(Effect.succeed(output("before�after"))); const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index b812fd7682eaf..2defbb6684919 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -863,6 +863,13 @@ const decodeRevisionRefs = Schema.decodeUnknownOption( ), ); +/** Where a pull request's head forked off its base, the commit its patch is computed from. */ +const decodeMergeBase = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ merge_base_commit: Schema.Struct({ sha: Schema.String }) }), + ), +); + const GRAPHQL_MERGE_METHODS = { merge: "MERGE", squash: "SQUASH", @@ -1299,6 +1306,29 @@ export const make = Effect.gen(function* () { ...(input.commit === undefined ? {} : { commit: input.commit }), }); } + // A pull request's patch runs from the merge base, and the base branch may have moved on + // since, so its old side is read there. Page 2 of a compare leaves out the file list; a + // failed read falls back to the base branch tip. + const oldRef = + input.commit === undefined && input.changeType !== "new" + ? yield* api + .rest({ + host: input.host, + operation: "getPullRequestDiffFileContents", + path: `repos/${owner}/${name}/compare/${baseRef}...${headRef}?per_page=1&page=2`, + timeout: DIFF_TIMEOUT, + }) + .pipe( + Effect.map((response) => + decodeMergeBase(response.body).pipe( + Option.map((compare) => compare.merge_base_commit.sha), + Option.filter(isCommitSha), + Option.getOrElse(() => baseRef ?? ""), + ), + ), + Effect.orElseSucceed(() => baseRef ?? ""), + ) + : (baseRef ?? ""); const readFile = (revision: string, filePath: string) => api @@ -1329,7 +1359,7 @@ export const make = Effect.gen(function* () { const [oldContents, newContents] = yield* Effect.all( [ - input.changeType === "new" ? Effect.succeed("") : readFile(baseRef ?? "", input.oldPath), + input.changeType === "new" ? Effect.succeed("") : readFile(oldRef, input.oldPath), input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath), ], { concurrency: 2 }, From 353cf7b519251bb073515e337d23be9eea93a05a Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:55:55 -0500 Subject: [PATCH 087/219] fix(web): show project action edit buttons on touch screens (#23640) Co-authored-by: GPT-6.1 Sol --- apps/web/src/components/settings/ProjectActionsList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ProjectActionsList.tsx b/apps/web/src/components/settings/ProjectActionsList.tsx index cc19de17746be..f30b90c184ba2 100644 --- a/apps/web/src/components/settings/ProjectActionsList.tsx +++ b/apps/web/src/components/settings/ProjectActionsList.tsx @@ -56,7 +56,7 @@ export function ProjectActionsList({ {shortcutLabel ? ( {shortcutLabel} ) : null} - +